@stigmer/react 3.0.9-dev.20260616060535 → 3.1.0

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 (426) hide show
  1. package/execution/ApprovalCard.d.ts +47 -3
  2. package/execution/ApprovalCard.d.ts.map +1 -1
  3. package/execution/ApprovalCard.js +138 -70
  4. package/execution/ApprovalCard.js.map +1 -1
  5. package/execution/ApprovalContext.d.ts +72 -0
  6. package/execution/ApprovalContext.d.ts.map +1 -0
  7. package/execution/ApprovalContext.js +41 -0
  8. package/execution/ApprovalContext.js.map +1 -0
  9. package/execution/ArtifactCard.d.ts.map +1 -1
  10. package/execution/ArtifactCard.js +3 -1
  11. package/execution/ArtifactCard.js.map +1 -1
  12. package/execution/ArtifactPreviewModal.d.ts.map +1 -1
  13. package/execution/ArtifactPreviewModal.js +5 -3
  14. package/execution/ArtifactPreviewModal.js.map +1 -1
  15. package/execution/EmptyChangeNotice.d.ts +38 -0
  16. package/execution/EmptyChangeNotice.d.ts.map +1 -0
  17. package/execution/EmptyChangeNotice.js +27 -0
  18. package/execution/EmptyChangeNotice.js.map +1 -0
  19. package/execution/FileChangesView.d.ts +81 -0
  20. package/execution/FileChangesView.d.ts.map +1 -0
  21. package/execution/FileChangesView.js +164 -0
  22. package/execution/FileChangesView.js.map +1 -0
  23. package/execution/FilePathLink.d.ts +24 -4
  24. package/execution/FilePathLink.d.ts.map +1 -1
  25. package/execution/FilePathLink.js +31 -9
  26. package/execution/FilePathLink.js.map +1 -1
  27. package/execution/FileReviewCard.d.ts +111 -0
  28. package/execution/FileReviewCard.d.ts.map +1 -0
  29. package/execution/FileReviewCard.js +558 -0
  30. package/execution/FileReviewCard.js.map +1 -0
  31. package/execution/FileReviewContext.d.ts +35 -0
  32. package/execution/FileReviewContext.d.ts.map +1 -0
  33. package/execution/FileReviewContext.js +23 -0
  34. package/execution/FileReviewContext.js.map +1 -0
  35. package/execution/FileReviewDock.d.ts +73 -0
  36. package/execution/FileReviewDock.d.ts.map +1 -0
  37. package/execution/FileReviewDock.js +65 -0
  38. package/execution/FileReviewDock.js.map +1 -0
  39. package/execution/McpToolDetail.d.ts.map +1 -1
  40. package/execution/McpToolDetail.js +38 -5
  41. package/execution/McpToolDetail.js.map +1 -1
  42. package/execution/MessageThread.d.ts +42 -3
  43. package/execution/MessageThread.d.ts.map +1 -1
  44. package/execution/MessageThread.js +263 -18
  45. package/execution/MessageThread.js.map +1 -1
  46. package/execution/PlanArtifactCard.d.ts.map +1 -1
  47. package/execution/PlanArtifactCard.js +3 -1
  48. package/execution/PlanArtifactCard.js.map +1 -1
  49. package/execution/PlanCompletionCard.d.ts +1 -1
  50. package/execution/PlanCompletionCard.js +1 -1
  51. package/execution/ResultView.d.ts +19 -1
  52. package/execution/ResultView.d.ts.map +1 -1
  53. package/execution/ResultView.js +141 -40
  54. package/execution/ResultView.js.map +1 -1
  55. package/execution/SubAgentSection.d.ts.map +1 -1
  56. package/execution/SubAgentSection.js +45 -6
  57. package/execution/SubAgentSection.js.map +1 -1
  58. package/execution/TerminalSession.d.ts +34 -0
  59. package/execution/TerminalSession.d.ts.map +1 -0
  60. package/execution/TerminalSession.js +34 -0
  61. package/execution/TerminalSession.js.map +1 -0
  62. package/execution/TodoCard.d.ts +53 -0
  63. package/execution/TodoCard.d.ts.map +1 -0
  64. package/execution/TodoCard.js +77 -0
  65. package/execution/TodoCard.js.map +1 -0
  66. package/execution/ToolArgsView.d.ts +7 -1
  67. package/execution/ToolArgsView.d.ts.map +1 -1
  68. package/execution/ToolArgsView.js +10 -9
  69. package/execution/ToolArgsView.js.map +1 -1
  70. package/execution/ToolCallDetail.d.ts +10 -1
  71. package/execution/ToolCallDetail.d.ts.map +1 -1
  72. package/execution/ToolCallDetail.js +66 -19
  73. package/execution/ToolCallDetail.js.map +1 -1
  74. package/execution/ToolCallGroup.d.ts +28 -18
  75. package/execution/ToolCallGroup.d.ts.map +1 -1
  76. package/execution/ToolCallGroup.js +39 -148
  77. package/execution/ToolCallGroup.js.map +1 -1
  78. package/execution/ToolCallItem.d.ts +36 -8
  79. package/execution/ToolCallItem.d.ts.map +1 -1
  80. package/execution/ToolCallItem.js +140 -21
  81. package/execution/ToolCallItem.js.map +1 -1
  82. package/execution/ToolRunGroup.d.ts +48 -0
  83. package/execution/ToolRunGroup.d.ts.map +1 -0
  84. package/execution/ToolRunGroup.js +168 -0
  85. package/execution/ToolRunGroup.js.map +1 -0
  86. package/execution/approval-provenance.d.ts +2 -0
  87. package/execution/approval-provenance.d.ts.map +1 -0
  88. package/execution/approval-provenance.js +6 -0
  89. package/execution/approval-provenance.js.map +1 -0
  90. package/execution/artifact-utils.d.ts +0 -10
  91. package/execution/artifact-utils.d.ts.map +1 -1
  92. package/execution/artifact-utils.js +0 -17
  93. package/execution/artifact-utils.js.map +1 -1
  94. package/execution/execution-phases.d.ts +1 -6
  95. package/execution/execution-phases.d.ts.map +1 -1
  96. package/execution/execution-phases.js +4 -14
  97. package/execution/execution-phases.js.map +1 -1
  98. package/execution/file-path-resolver.d.ts +28 -0
  99. package/execution/file-path-resolver.d.ts.map +1 -1
  100. package/execution/file-path-resolver.js +24 -0
  101. package/execution/file-path-resolver.js.map +1 -1
  102. package/execution/file-review-status.d.ts +127 -0
  103. package/execution/file-review-status.d.ts.map +1 -0
  104. package/execution/file-review-status.js +135 -0
  105. package/execution/file-review-status.js.map +1 -0
  106. package/execution/index.d.ts +39 -5
  107. package/execution/index.d.ts.map +1 -1
  108. package/execution/index.js +23 -3
  109. package/execution/index.js.map +1 -1
  110. package/execution/segment-tool-calls.d.ts +46 -0
  111. package/execution/segment-tool-calls.d.ts.map +1 -0
  112. package/execution/segment-tool-calls.js +71 -0
  113. package/execution/segment-tool-calls.js.map +1 -0
  114. package/execution/tool-categories.d.ts +62 -1
  115. package/execution/tool-categories.d.ts.map +1 -1
  116. package/execution/tool-categories.js +96 -2
  117. package/execution/tool-categories.js.map +1 -1
  118. package/execution/tool-presenter.d.ts +38 -2
  119. package/execution/tool-presenter.d.ts.map +1 -1
  120. package/execution/tool-presenter.js +23 -1
  121. package/execution/tool-presenter.js.map +1 -1
  122. package/execution/tool-rendering-primitives.d.ts.map +1 -1
  123. package/execution/tool-rendering-primitives.js +3 -2
  124. package/execution/tool-rendering-primitives.js.map +1 -1
  125. package/execution/useArtifactDownload.d.ts +44 -0
  126. package/execution/useArtifactDownload.d.ts.map +1 -0
  127. package/execution/useArtifactDownload.js +80 -0
  128. package/execution/useArtifactDownload.js.map +1 -0
  129. package/execution/useArtifactDownloadUrl.d.ts +67 -0
  130. package/execution/useArtifactDownloadUrl.d.ts.map +1 -0
  131. package/execution/useArtifactDownloadUrl.js +59 -0
  132. package/execution/useArtifactDownloadUrl.js.map +1 -0
  133. package/execution/useFileChangeContent.d.ts +61 -0
  134. package/execution/useFileChangeContent.d.ts.map +1 -0
  135. package/execution/useFileChangeContent.js +89 -0
  136. package/execution/useFileChangeContent.js.map +1 -0
  137. package/execution/useFileReview.d.ts +89 -0
  138. package/execution/useFileReview.d.ts.map +1 -0
  139. package/execution/useFileReview.js +106 -0
  140. package/execution/useFileReview.js.map +1 -0
  141. package/execution/useSubmitApproval.d.ts +17 -2
  142. package/execution/useSubmitApproval.d.ts.map +1 -1
  143. package/execution/useSubmitApproval.js +27 -23
  144. package/execution/useSubmitApproval.js.map +1 -1
  145. package/execution/useToolOutputContent.d.ts +44 -0
  146. package/execution/useToolOutputContent.d.ts.map +1 -0
  147. package/execution/useToolOutputContent.js +31 -0
  148. package/execution/useToolOutputContent.js.map +1 -0
  149. package/index.d.ts +6 -6
  150. package/index.d.ts.map +1 -1
  151. package/index.js +3 -3
  152. package/index.js.map +1 -1
  153. package/internal/ApprovalPeekBar.d.ts +23 -0
  154. package/internal/ApprovalPeekBar.d.ts.map +1 -0
  155. package/internal/ApprovalPeekBar.js +25 -0
  156. package/internal/ApprovalPeekBar.js.map +1 -0
  157. package/internal/BoundedContent.d.ts +49 -0
  158. package/internal/BoundedContent.d.ts.map +1 -0
  159. package/internal/BoundedContent.js +50 -0
  160. package/internal/BoundedContent.js.map +1 -0
  161. package/internal/DecisionButton.d.ts +47 -0
  162. package/internal/DecisionButton.d.ts.map +1 -0
  163. package/internal/DecisionButton.js +42 -0
  164. package/internal/DecisionButton.js.map +1 -0
  165. package/internal/InCardDecisionError.d.ts +31 -0
  166. package/internal/InCardDecisionError.d.ts.map +1 -0
  167. package/internal/InCardDecisionError.js +23 -0
  168. package/internal/InCardDecisionError.js.map +1 -0
  169. package/internal/RevealToggle.d.ts +36 -0
  170. package/internal/RevealToggle.d.ts.map +1 -0
  171. package/internal/RevealToggle.js +32 -0
  172. package/internal/RevealToggle.js.map +1 -0
  173. package/internal/VirtualizedThread.d.ts +7 -1
  174. package/internal/VirtualizedThread.d.ts.map +1 -1
  175. package/internal/VirtualizedThread.js +7 -3
  176. package/internal/VirtualizedThread.js.map +1 -1
  177. package/internal/useAutoDisclosure.d.ts +34 -0
  178. package/internal/useAutoDisclosure.d.ts.map +1 -0
  179. package/internal/useAutoDisclosure.js +53 -0
  180. package/internal/useAutoDisclosure.js.map +1 -0
  181. package/internal/useElapsedSince.d.ts +30 -0
  182. package/internal/useElapsedSince.d.ts.map +1 -0
  183. package/internal/useElapsedSince.js +70 -0
  184. package/internal/useElapsedSince.js.map +1 -0
  185. package/internal/useIsOverflowing.d.ts +30 -0
  186. package/internal/useIsOverflowing.d.ts.map +1 -0
  187. package/internal/useIsOverflowing.js +47 -0
  188. package/internal/useIsOverflowing.js.map +1 -0
  189. package/internal/useIsTextTruncated.d.ts +24 -0
  190. package/internal/useIsTextTruncated.d.ts.map +1 -0
  191. package/internal/useIsTextTruncated.js +41 -0
  192. package/internal/useIsTextTruncated.js.map +1 -0
  193. package/internal/useKeyedSubmission.d.ts +49 -0
  194. package/internal/useKeyedSubmission.d.ts.map +1 -0
  195. package/internal/useKeyedSubmission.js +67 -0
  196. package/internal/useKeyedSubmission.js.map +1 -0
  197. package/package.json +4 -4
  198. package/session/SessionViewer.d.ts +2 -2
  199. package/session/SessionViewer.d.ts.map +1 -1
  200. package/session/SessionViewer.js +10 -4
  201. package/session/SessionViewer.js.map +1 -1
  202. package/session/index.d.ts +2 -0
  203. package/session/index.d.ts.map +1 -1
  204. package/session/index.js +1 -0
  205. package/session/index.js.map +1 -1
  206. package/session/inspector/ChangesTab.d.ts +8 -5
  207. package/session/inspector/ChangesTab.d.ts.map +1 -1
  208. package/session/inspector/ChangesTab.js +11 -8
  209. package/session/inspector/ChangesTab.js.map +1 -1
  210. package/session/inspector/SessionInspector.d.ts +4 -4
  211. package/session/inspector/SessionInspector.d.ts.map +1 -1
  212. package/session/inspector/SessionInspector.js +4 -4
  213. package/session/inspector/SessionInspector.js.map +1 -1
  214. package/session/inspector/index.d.ts +0 -2
  215. package/session/inspector/index.d.ts.map +1 -1
  216. package/session/inspector/index.js +0 -1
  217. package/session/inspector/index.js.map +1 -1
  218. package/session/inspector/useSessionInspector.d.ts +8 -4
  219. package/session/inspector/useSessionInspector.d.ts.map +1 -1
  220. package/session/inspector/useSessionInspector.js +27 -20
  221. package/session/inspector/useSessionInspector.js.map +1 -1
  222. package/session/useSessionConversation.d.ts +37 -3
  223. package/session/useSessionConversation.d.ts.map +1 -1
  224. package/session/useSessionConversation.js +18 -2
  225. package/session/useSessionConversation.js.map +1 -1
  226. package/session/useSessionFileChanges.d.ts +74 -0
  227. package/session/useSessionFileChanges.d.ts.map +1 -0
  228. package/session/useSessionFileChanges.js +178 -0
  229. package/session/useSessionFileChanges.js.map +1 -0
  230. package/src/__tests__/styles-border-layer-invariant.test.ts +105 -0
  231. package/src/execution/ApprovalCard.tsx +368 -230
  232. package/src/execution/ApprovalContext.ts +109 -0
  233. package/src/execution/ArtifactCard.tsx +10 -6
  234. package/src/execution/ArtifactPreviewModal.tsx +12 -6
  235. package/src/execution/EmptyChangeNotice.tsx +62 -0
  236. package/src/execution/FileChangesView.tsx +383 -0
  237. package/src/execution/FilePathLink.tsx +65 -12
  238. package/src/execution/FileReviewCard.tsx +1118 -0
  239. package/src/execution/FileReviewContext.ts +53 -0
  240. package/src/execution/FileReviewDock.tsx +157 -0
  241. package/src/execution/McpToolDetail.tsx +98 -32
  242. package/src/execution/MessageThread.tsx +374 -9
  243. package/src/execution/PlanArtifactCard.tsx +9 -6
  244. package/src/execution/PlanCompletionCard.tsx +1 -1
  245. package/src/execution/ResultView.tsx +333 -162
  246. package/src/execution/SubAgentSection.tsx +58 -11
  247. package/src/execution/TerminalSession.tsx +91 -0
  248. package/src/execution/TodoCard.tsx +186 -0
  249. package/src/execution/ToolArgsView.tsx +28 -17
  250. package/src/execution/ToolCallDetail.tsx +137 -26
  251. package/src/execution/ToolCallGroup.tsx +68 -296
  252. package/src/execution/ToolCallItem.tsx +265 -67
  253. package/src/execution/ToolRunGroup.tsx +302 -0
  254. package/src/execution/__tests__/ApprovalCard.test.tsx +395 -5
  255. package/src/execution/__tests__/ArtifactPreviewModal.test.tsx +0 -1
  256. package/src/execution/__tests__/EmptyChangeNotice.test.tsx +24 -0
  257. package/src/execution/__tests__/FileChangesView.test.tsx +296 -0
  258. package/src/execution/__tests__/FilePathLink.test.tsx +92 -0
  259. package/src/execution/__tests__/FileReviewCard.test.tsx +1044 -0
  260. package/src/execution/__tests__/FileReviewDock.test.tsx +134 -0
  261. package/src/execution/__tests__/McpToolDetail.test.tsx +79 -0
  262. package/src/execution/__tests__/MessageThread.test.tsx +324 -0
  263. package/src/execution/__tests__/PlanArtifactCard.test.tsx +37 -16
  264. package/src/execution/__tests__/ResultView.test.tsx +280 -2
  265. package/src/execution/__tests__/SubAgentSection.test.tsx +180 -0
  266. package/src/execution/__tests__/TerminalSession.test.tsx +72 -0
  267. package/src/execution/__tests__/TodoCard.test.tsx +102 -0
  268. package/src/execution/__tests__/ToolArgsView.test.tsx +48 -0
  269. package/src/execution/__tests__/ToolCallDetail.test.tsx +195 -0
  270. package/src/execution/__tests__/ToolCallGroup.test.tsx +132 -0
  271. package/src/execution/__tests__/ToolCallItem.test.tsx +521 -0
  272. package/src/execution/__tests__/ToolRunGroup.test.tsx +137 -0
  273. package/src/execution/__tests__/blanked-narration.test.tsx +149 -0
  274. package/src/execution/__tests__/buildThreadItems-approvals.test.ts +304 -0
  275. package/src/execution/__tests__/buildThreadItems-file-review.test.ts +331 -0
  276. package/src/execution/__tests__/file-path-resolver.test.ts +44 -0
  277. package/src/execution/__tests__/file-review-status.test.ts +343 -0
  278. package/src/execution/__tests__/segment-tool-calls.test.ts +124 -0
  279. package/src/execution/__tests__/subagent-file-review-badge.test.tsx +273 -0
  280. package/src/execution/__tests__/thread-keys.test.ts +195 -0
  281. package/src/execution/__tests__/tool-presenter.test.tsx +95 -0
  282. package/src/execution/__tests__/useArtifactDownload.test.tsx +67 -0
  283. package/src/execution/__tests__/useArtifactDownloadUrl.test.tsx +51 -0
  284. package/src/execution/__tests__/useFileChangeContent.test.tsx +240 -0
  285. package/src/execution/__tests__/useFileReview.test.tsx +236 -0
  286. package/src/execution/__tests__/useSubmitApproval.test.tsx +165 -0
  287. package/src/execution/__tests__/useToolOutputContent.test.tsx +82 -0
  288. package/src/execution/approval-provenance.ts +8 -0
  289. package/src/execution/artifact-utils.ts +0 -18
  290. package/src/execution/execution-phases.ts +4 -16
  291. package/src/execution/file-path-resolver.ts +38 -0
  292. package/src/execution/file-review-status.ts +219 -0
  293. package/src/execution/index.ts +84 -4
  294. package/src/execution/segment-tool-calls.ts +101 -0
  295. package/src/execution/tool-categories.ts +128 -1
  296. package/src/execution/tool-presenter.ts +58 -3
  297. package/src/execution/tool-rendering-primitives.tsx +11 -14
  298. package/src/execution/useArtifactDownload.ts +109 -0
  299. package/src/execution/useArtifactDownloadUrl.ts +116 -0
  300. package/src/execution/useFileChangeContent.ts +149 -0
  301. package/src/execution/useFileReview.ts +197 -0
  302. package/src/execution/useSubmitApproval.ts +49 -27
  303. package/src/execution/useToolOutputContent.ts +70 -0
  304. package/src/index.ts +60 -1
  305. package/src/internal/ApprovalPeekBar.tsx +73 -0
  306. package/src/internal/BoundedContent.tsx +97 -0
  307. package/src/internal/DecisionButton.tsx +127 -0
  308. package/src/internal/InCardDecisionError.tsx +47 -0
  309. package/src/internal/RevealToggle.tsx +91 -0
  310. package/src/internal/VirtualizedThread.tsx +28 -2
  311. package/src/internal/__tests__/ApprovalPeekBar.test.tsx +34 -0
  312. package/src/internal/__tests__/BoundedContent.test.tsx +92 -0
  313. package/src/internal/__tests__/DecisionButton.test.tsx +107 -0
  314. package/src/internal/__tests__/RevealToggle.test.tsx +61 -0
  315. package/src/internal/__tests__/useAutoDisclosure.test.ts +74 -0
  316. package/src/internal/__tests__/useElapsedSince.test.tsx +68 -0
  317. package/src/internal/__tests__/useIsOverflowing.test.ts +36 -0
  318. package/src/internal/__tests__/useKeyedSubmission.test.tsx +126 -0
  319. package/src/internal/useAutoDisclosure.ts +58 -0
  320. package/src/internal/useElapsedSince.ts +72 -0
  321. package/src/internal/useIsOverflowing.ts +61 -0
  322. package/src/internal/useIsTextTruncated.ts +55 -0
  323. package/src/internal/useKeyedSubmission.ts +101 -0
  324. package/src/session/SessionViewer.tsx +21 -4
  325. package/src/session/__tests__/SessionViewer-fileReviewDock.test.tsx +198 -0
  326. package/src/session/__tests__/audienceWiring.test.tsx +4 -0
  327. package/src/session/__tests__/useSessionFileChanges.test.ts +303 -0
  328. package/src/session/index.ts +3 -0
  329. package/src/session/inspector/ChangesTab.tsx +20 -17
  330. package/src/session/inspector/SessionInspector.tsx +12 -10
  331. package/src/session/inspector/__tests__/ArtifactsTab.test.tsx +0 -1
  332. package/src/session/inspector/__tests__/ChangesTab.test.tsx +38 -0
  333. package/src/session/inspector/__tests__/useSessionInspector.test.ts +46 -61
  334. package/src/session/inspector/index.ts +0 -3
  335. package/src/session/inspector/useSessionInspector.ts +27 -20
  336. package/src/session/useSessionConversation.ts +80 -3
  337. package/src/session/useSessionFileChanges.ts +207 -0
  338. package/src/styles.css +35 -10
  339. package/src/version-history/DiffViewer.tsx +10 -7
  340. package/src/version-history/UnifiedDiffView.tsx +66 -0
  341. package/src/version-history/__tests__/DiffViewer.test.tsx +57 -0
  342. package/src/version-history/__tests__/UnifiedDiffView.test.tsx +50 -0
  343. package/src/version-history/__tests__/computeDiff.test.ts +35 -0
  344. package/src/version-history/__tests__/parseUnifiedDiff.test.ts +82 -0
  345. package/src/version-history/computeDiff.ts +6 -37
  346. package/src/version-history/diff-hunks.ts +63 -0
  347. package/src/version-history/index.ts +1 -0
  348. package/src/version-history/parseUnifiedDiff.ts +34 -0
  349. package/src/workflow/WorkflowExecutionApprovalCard.tsx +49 -24
  350. package/src/workflow/WorkflowExecutionTimeline.tsx +15 -4
  351. package/src/workflow/WorkflowExecutionTimelineEvent.tsx +8 -3
  352. package/src/workflow/WorkflowExecutionViewer.tsx +72 -28
  353. package/src/workflow/WorkflowFileReviewList.tsx +152 -0
  354. package/src/workflow/WorkflowTaskApprovalCard.tsx +37 -85
  355. package/src/workflow/__tests__/ExecutionInspector.test.tsx +30 -2
  356. package/src/workflow/__tests__/WorkflowExecutionApprovalCard.test.tsx +129 -0
  357. package/src/workflow/__tests__/WorkflowFileReviewList.test.tsx +129 -0
  358. package/src/workflow/__tests__/WorkflowTaskApprovalCard.test.tsx +43 -11
  359. package/src/workflow/__tests__/useWorkflowExecutionActions.test.tsx +276 -0
  360. package/src/workflow/execution-inspector/ExecutionInspector.tsx +42 -18
  361. package/src/workflow/index.ts +5 -0
  362. package/src/workflow/useWorkflowExecutionActions.ts +177 -29
  363. package/styles.css +1 -1
  364. package/version-history/DiffViewer.d.ts.map +1 -1
  365. package/version-history/DiffViewer.js +9 -3
  366. package/version-history/DiffViewer.js.map +1 -1
  367. package/version-history/UnifiedDiffView.d.ts +29 -0
  368. package/version-history/UnifiedDiffView.d.ts.map +1 -0
  369. package/version-history/UnifiedDiffView.js +36 -0
  370. package/version-history/UnifiedDiffView.js.map +1 -0
  371. package/version-history/computeDiff.d.ts +3 -1
  372. package/version-history/computeDiff.d.ts.map +1 -1
  373. package/version-history/computeDiff.js +5 -35
  374. package/version-history/computeDiff.js.map +1 -1
  375. package/version-history/diff-hunks.d.ts +21 -0
  376. package/version-history/diff-hunks.d.ts.map +1 -0
  377. package/version-history/diff-hunks.js +58 -0
  378. package/version-history/diff-hunks.js.map +1 -0
  379. package/version-history/index.d.ts +1 -0
  380. package/version-history/index.d.ts.map +1 -1
  381. package/version-history/index.js +1 -0
  382. package/version-history/index.js.map +1 -1
  383. package/version-history/parseUnifiedDiff.d.ts +22 -0
  384. package/version-history/parseUnifiedDiff.d.ts.map +1 -0
  385. package/version-history/parseUnifiedDiff.js +34 -0
  386. package/version-history/parseUnifiedDiff.js.map +1 -0
  387. package/workflow/WorkflowExecutionApprovalCard.d.ts +8 -1
  388. package/workflow/WorkflowExecutionApprovalCard.d.ts.map +1 -1
  389. package/workflow/WorkflowExecutionApprovalCard.js +17 -3
  390. package/workflow/WorkflowExecutionApprovalCard.js.map +1 -1
  391. package/workflow/WorkflowExecutionTimeline.d.ts +11 -2
  392. package/workflow/WorkflowExecutionTimeline.d.ts.map +1 -1
  393. package/workflow/WorkflowExecutionTimeline.js +2 -2
  394. package/workflow/WorkflowExecutionTimeline.js.map +1 -1
  395. package/workflow/WorkflowExecutionTimelineEvent.d.ts +4 -1
  396. package/workflow/WorkflowExecutionTimelineEvent.d.ts.map +1 -1
  397. package/workflow/WorkflowExecutionTimelineEvent.js +2 -2
  398. package/workflow/WorkflowExecutionTimelineEvent.js.map +1 -1
  399. package/workflow/WorkflowExecutionViewer.d.ts.map +1 -1
  400. package/workflow/WorkflowExecutionViewer.js +18 -10
  401. package/workflow/WorkflowExecutionViewer.js.map +1 -1
  402. package/workflow/WorkflowFileReviewList.d.ts +46 -0
  403. package/workflow/WorkflowFileReviewList.d.ts.map +1 -0
  404. package/workflow/WorkflowFileReviewList.js +48 -0
  405. package/workflow/WorkflowFileReviewList.js.map +1 -0
  406. package/workflow/WorkflowTaskApprovalCard.d.ts +8 -1
  407. package/workflow/WorkflowTaskApprovalCard.d.ts.map +1 -1
  408. package/workflow/WorkflowTaskApprovalCard.js +12 -19
  409. package/workflow/WorkflowTaskApprovalCard.js.map +1 -1
  410. package/workflow/execution-inspector/ExecutionInspector.d.ts +21 -4
  411. package/workflow/execution-inspector/ExecutionInspector.d.ts.map +1 -1
  412. package/workflow/execution-inspector/ExecutionInspector.js +6 -3
  413. package/workflow/execution-inspector/ExecutionInspector.js.map +1 -1
  414. package/workflow/index.d.ts +1 -0
  415. package/workflow/index.d.ts.map +1 -1
  416. package/workflow/index.js +1 -0
  417. package/workflow/index.js.map +1 -1
  418. package/workflow/useWorkflowExecutionActions.d.ts +62 -4
  419. package/workflow/useWorkflowExecutionActions.d.ts.map +1 -1
  420. package/workflow/useWorkflowExecutionActions.js +94 -18
  421. package/workflow/useWorkflowExecutionActions.js.map +1 -1
  422. package/session/inspector/PlanTab.d.ts +0 -15
  423. package/session/inspector/PlanTab.d.ts.map +0 -1
  424. package/session/inspector/PlanTab.js +0 -19
  425. package/session/inspector/PlanTab.js.map +0 -1
  426. package/src/session/inspector/PlanTab.tsx +0 -31
@@ -0,0 +1,1118 @@
1
+ "use client";
2
+
3
+ import { memo, useCallback, useEffect, useId, useMemo, useState } from "react";
4
+ import type {
5
+ CapturedFileChange,
6
+ FileChangeSet,
7
+ FileDecision,
8
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/filereview_pb";
9
+ import {
10
+ FileCaptureClass,
11
+ FileChangeKind,
12
+ FileDecisionAction,
13
+ FileDecisionOrigin,
14
+ FileDecisionScope,
15
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
16
+ import { toDisplayFileChange } from "@stigmer/sdk";
17
+ import { cn } from "@stigmer/theme";
18
+ import { FileChangeDiff } from "./FileChangesView";
19
+ import { DiffSummary } from "../version-history/DiffSummary";
20
+ import { DecisionButton } from "../internal/DecisionButton";
21
+ import { InCardDecisionError } from "../internal/InCardDecisionError";
22
+ import { splitDisplayPath } from "./file-path-resolver";
23
+ import { fileDecisionKey, type FileDecisionOptions } from "./useFileReview";
24
+ import {
25
+ changeSetReviewability,
26
+ deriveEffectiveVerdicts,
27
+ fileReviewability,
28
+ type FileReviewability,
29
+ } from "./file-review-status";
30
+
31
+ /** A stable empty set so the default `submittingDecisionKeys` keeps a constant ref. */
32
+ const NO_KEYS: ReadonlySet<string> = new Set();
33
+
34
+ /** A stable empty map so the default `decisionErrors` keeps a constant ref. */
35
+ const NO_ERRORS: ReadonlyMap<string, Error> = new Map();
36
+
37
+ /** Props for {@link FileReviewCard}. */
38
+ export interface FileReviewCardProps {
39
+ /** The captured change set awaiting review (status AWAITING_REVIEW). */
40
+ readonly fileChangeSet: FileChangeSet;
41
+ /**
42
+ * Called when the user makes a decision. The consumer (typically
43
+ * {@link MessageThread} or a platform builder's surface) handles the RPC via
44
+ * {@link useFileReview}. A whole-set Approve/Reject sends `CHANGE_SET` scope;
45
+ * a per-file Keep/Discard sends `FILE` scope with the file's id. Optional and
46
+ * ignored when {@link FileReviewCardProps.interactive} is `false`.
47
+ */
48
+ readonly onSubmit?: (action: FileDecisionAction, options?: FileDecisionOptions) => void;
49
+ /**
50
+ * Whether the card presents decision controls. Defaults to `true` (the live
51
+ * review surface). Set to `false` to render a **settled** change set read-only:
52
+ * the per-file diffs and each file's committed verdict are shown, but the
53
+ * decision controls, bulk footer, and error affordances are omitted. Used for a
54
+ * decided/reconciled set, or any set on a terminal execution — a historical
55
+ * record of what changed and how it was decided, not an actionable gate.
56
+ */
57
+ readonly interactive?: boolean;
58
+ /**
59
+ * Whether the expanded body renders each file's diff. Defaults to `true` —
60
+ * the right default wherever this card is the *only* review surface (the
61
+ * workflow file-review list, standalone platform-builder embeds).
62
+ *
63
+ * `MessageThread` passes `false`: the transcript's stamped edit rows already
64
+ * show every diff in place, so the expanded body collapses to a compact file
65
+ * list (kind letter + path + per-file controls) and the card stays purely
66
+ * the decision surface. Decision semantics — digests, scopes,
67
+ * acknowledgments — are identical in both modes; this is presentation only
68
+ * (opt-in with a backward-compatible default, DD-011).
69
+ */
70
+ readonly showDiffs?: boolean;
71
+ /**
72
+ * The decision keys currently being submitted — exactly the set
73
+ * {@link useFileReview} returns as `submittingDecisionKeys`. A card has more
74
+ * than one decision target (the whole set plus each file), so it takes the
75
+ * keyed set rather than a single boolean and derives each control's in-flight
76
+ * state via {@link fileDecisionKey}.
77
+ */
78
+ readonly submittingDecisionKeys?: ReadonlySet<string>;
79
+ /**
80
+ * Per-decision failures, keyed exactly like {@link submittingDecisionKeys}
81
+ * (via {@link fileDecisionKey}) — the set's id for a whole-set decision, the
82
+ * `setId:fileChangeId` for a per-file one. A failed decision is surfaced
83
+ * in-card, beside the control that failed (and the optimistic verdict reverts),
84
+ * so the reviewer sees *which* action did not take and why. Supply
85
+ * {@link useFileReview}'s `decisionErrors`.
86
+ */
87
+ readonly decisionErrors?: ReadonlyMap<string, Error>;
88
+ /** Additional CSS class names for the root container. */
89
+ readonly className?: string;
90
+ }
91
+
92
+ /**
93
+ * Renders a captured {@link FileChangeSet} as a **compact decision bar**: one
94
+ * line carrying the set summary and the whole-set decision controls, with the
95
+ * per-file detail behind an expander. What the expander holds depends on
96
+ * {@link FileReviewCardProps.showDiffs}: the diff-rich body (default — for
97
+ * surfaces where this card is the only review surface) or a compact file list
98
+ * (the session thread, whose stamped edit rows already show every diff in
99
+ * place). A set that needs per-file attention (binary acknowledgment, blocked
100
+ * files, mid-review) starts expanded.
101
+ *
102
+ * File edits are reviewed as a captured workspace delta, not tool calls, so this
103
+ * is a dedicated decision surface distinct from the message transcript and the
104
+ * tool-approval {@link ApprovalCard}. The reviewer can decide each file
105
+ * individually (Keep / Discard, a `FILE`-scoped decision) or act on the whole
106
+ * set at once (Approve / Reject, a `CHANGE_SET` decision). Either way the runner
107
+ * reconciles the exact approved bytes with hash verification.
108
+ *
109
+ * The unified HITL gate clears only when EVERY file has a verdict — one
110
+ * `CHANGE_SET` decision, or a `FILE` decision per file — so a partially-decided
111
+ * set stays actionable and deciding the last file is what resumes the turn. A
112
+ * per-file verdict is changeable while the set is still open: clicking the other
113
+ * option records a new decision (the backend reconcile is last-write-wins).
114
+ *
115
+ * A `binary-only` set (binary files are the only blocker) can be kept in one
116
+ * action: the bulk button reads "Keep all" and carries the acknowledgment
117
+ * (DD-17). A `blocked` set (something unavailable to review) cannot be approved
118
+ * as a whole — the Approve button is disabled with an explanation, and the
119
+ * per-file path is the escape: keep the reviewable files and discard the rest.
120
+ *
121
+ * Each non-reviewable file is labeled honestly by {@link fileReviewability}: a
122
+ * `binary` change has no text diff to review; an `unavailable` change is one
123
+ * whose diff isn't available at all, with the honest cause the runner recorded
124
+ * (a secret path whose bytes were never captured, or a diff dropped to bound the
125
+ * status — doc 15). Both are discard-only, and their Keep affordance is disabled
126
+ * with the reason associated via `aria-describedby`. Files captured outside
127
+ * normal git tracking (gitignored / non-git CAS) carry a small provenance badge
128
+ * so the reviewer knows the change is not part of the repo's tracked history.
129
+ *
130
+ * Wrapped in `React.memo` — the streamed `FileChangeSet` reference is preserved
131
+ * by structural sharing when unchanged, so review cards skip re-renders during
132
+ * unrelated stream updates.
133
+ *
134
+ * @example
135
+ * ```tsx
136
+ * const { submitFileDecision, submittingDecisionKeys } = useFileReview();
137
+ * <FileReviewCard
138
+ * fileChangeSet={changeSet}
139
+ * onSubmit={(action, opts) => submitFileDecision(executionId, changeSet.id, action, opts)}
140
+ * submittingDecisionKeys={submittingDecisionKeys}
141
+ * />
142
+ * ```
143
+ */
144
+ export const FileReviewCard = memo(function FileReviewCard({
145
+ fileChangeSet,
146
+ onSubmit,
147
+ submittingDecisionKeys = NO_KEYS,
148
+ decisionErrors = NO_ERRORS,
149
+ interactive = true,
150
+ showDiffs = true,
151
+ className,
152
+ }: FileReviewCardProps) {
153
+ const changes = fileChangeSet.changes;
154
+ const total = changes.length;
155
+ const setId = fileChangeSet.id;
156
+
157
+ // Each file's current verdict, a last-write-wins fold over the ledger-ordered
158
+ // FILE decisions (the same precedence the runner reconcile applies). Drives
159
+ // which per-file option reads as selected and the reviewed-count progress.
160
+ const verdictByFileId = useMemo(
161
+ () => deriveFileVerdicts(fileChangeSet.decisions),
162
+ [fileChangeSet.decisions],
163
+ );
164
+ const decidedCount = verdictByFileId.size;
165
+
166
+ // The EFFECTIVE verdicts for history (a CHANGE_SET decision covers every file
167
+ // a FILE decision does not override — the reconcile's own precedence), so a
168
+ // settled "Keep all" set reads every file as kept, never "Not reviewed".
169
+ // Interactive per-file selection deliberately keeps the FILE-only fold above.
170
+ const effectiveVerdicts = useMemo(
171
+ () => deriveEffectiveVerdicts(fileChangeSet),
172
+ [fileChangeSet],
173
+ );
174
+
175
+ // The set-level reviewability drives the bulk affordance. A "binary-only" set
176
+ // (binary files are the ONLY blocker) is keepable in one acknowledged action
177
+ // ("Keep all", DD-17); a "blocked" set (a secret/elided file with no keepable
178
+ // bytes) can never be approved at once and must be resolved per file.
179
+ const reviewability = changeSetReviewability(fileChangeSet);
180
+ const incomplete = reviewability !== "complete";
181
+ const binaryOnly = reviewability === "binary-only";
182
+
183
+ // Per-file controls earn their space when there is more than one file OR when a
184
+ // lone file is not reviewable — a single binary needs its per-file "Keep anyway"
185
+ // (FILE-scope, acknowledged), which the CHANGE_SET-scoped bulk controls can't
186
+ // offer. A single COMPLETE file stays decided once via the bar's bulk controls.
187
+ const showPerFile = total > 1 || incomplete;
188
+ // The bulk (CHANGE_SET) controls live on the bar for multi-file sets and for a
189
+ // single complete file; they are hidden for a single incomplete file, whose
190
+ // only honest decision is the per-file control in the expanded body (a single
191
+ // binary uses its per-file "Keep anyway" — DD-16 — and a blocked lone file can
192
+ // only be discarded).
193
+ const showBulkControls = total > 1 || !incomplete;
194
+
195
+ // The bar starts expanded exactly when the collapsed bar cannot honestly carry
196
+ // the decision: an incomplete set (binary acknowledgment / blocked files need
197
+ // their per-file context) or a review already in progress. A complete set and
198
+ // a settled record start collapsed — the transcript's stamped rows already
199
+ // show what changed, and the expander reveals the per-file detail on demand.
200
+ const [expanded, setExpanded] = useState(
201
+ () => interactive && (incomplete || decidedCount > 0),
202
+ );
203
+ const bodyId = useId();
204
+
205
+ const wholeSetSubmitting = submittingDecisionKeys.has(setId);
206
+ const wholeSetError = decisionErrors.get(setId) ?? null;
207
+
208
+ // Bulk (CHANGE_SET) decision state — mirrors ApprovalCardBody: one active
209
+ // action drives the clicked button's spinner, cleared when the RPC settles.
210
+ const [activeAction, setActiveAction] = useState<FileDecisionAction | null>(null);
211
+ useEffect(() => {
212
+ if (!wholeSetSubmitting) setActiveAction(null);
213
+ }, [wholeSetSubmitting]);
214
+
215
+ const handleBulk = useCallback(
216
+ (action: FileDecisionAction) => {
217
+ setActiveAction(action);
218
+ // Whole-set decision: CHANGE_SET scope, bound to the aggregate digest the
219
+ // reviewer saw (the enforcement gate the runner re-verifies at reconcile).
220
+ // A "Keep all" on a binary-only set carries the acknowledgment: the
221
+ // binaries have no text diff but reconcilable bytes, so the user
222
+ // consciously keeps the whole set (DD-17). The server honors it only when
223
+ // every incompleteness is binary and never relaxes the digest gate.
224
+ onSubmit?.(action, {
225
+ scope: FileDecisionScope.CHANGE_SET,
226
+ expectedDigest: fileChangeSet.aggregateDigest,
227
+ acknowledgeUnreviewable: action === FileDecisionAction.APPROVE && binaryOnly,
228
+ });
229
+ },
230
+ [onSubmit, fileChangeSet.aggregateDigest, binaryOnly],
231
+ );
232
+
233
+ const handleFileDecide = useCallback(
234
+ (change: CapturedFileChange, action: FileDecisionAction) => {
235
+ // Per-file decision: FILE scope correlated by the change id, bound to that
236
+ // file's digest (echoed verbatim — the server compares it against the same
237
+ // captured value, so it can never spuriously mismatch). A "Keep anyway" on a
238
+ // binary file carries the acknowledgment: it has no text diff, but its exact
239
+ // bytes are captured and reconcilable, so the user consciously keeps it
240
+ // (DD-16). The server honors this only for a binary file and never relaxes
241
+ // the digest gate.
242
+ onSubmit?.(action, {
243
+ scope: FileDecisionScope.FILE,
244
+ fileChangeId: change.id,
245
+ expectedDigest: change.fileDigest,
246
+ acknowledgeUnreviewable:
247
+ action === FileDecisionAction.APPROVE &&
248
+ fileReviewability(change).kind === "binary",
249
+ });
250
+ },
251
+ [onSubmit],
252
+ );
253
+
254
+ const totals = useMemo(() => {
255
+ let additions = 0;
256
+ let deletions = 0;
257
+ for (const c of changes) {
258
+ if (c.kind === FileChangeKind.ADD) additions += 1;
259
+ else if (c.kind === FileChangeKind.DELETE) deletions += 1;
260
+ }
261
+ return { additions, deletions };
262
+ }, [changes]);
263
+
264
+ // Which non-reviewable classes are present in the set — folded once (memoized
265
+ // like `totals`, DD-010) so the blocked-set copy is accurate to what's actually
266
+ // here (never the generic "binary or truncated").
267
+ const blockSummary = useMemo(() => {
268
+ let hasBinary = false;
269
+ let hasUnavailable = false;
270
+ for (const c of changes) {
271
+ const r = fileReviewability(c);
272
+ if (r.kind === "binary") hasBinary = true;
273
+ else if (r.kind === "unavailable") hasUnavailable = true;
274
+ }
275
+ return { hasBinary, hasUnavailable };
276
+ }, [changes]);
277
+
278
+ // Associates the disabled whole-set Approve with the notice explaining why it
279
+ // is unavailable (a11y: a disabled action is never an unexplained dead end).
280
+ const incompleteNoticeId = useId();
281
+
282
+ const labels = bulkLabels(total, decidedCount, binaryOnly);
283
+ // A set the DD-28 policy kept (every change produced by a command the user
284
+ // already approved) says so explicitly: consent was given at the command
285
+ // gate, and the record must never read as if a human reviewed it here.
286
+ const autoKept = !interactive && isPolicyAutoKept(fileChangeSet);
287
+ const summary = interactive
288
+ ? `${total} file${total === 1 ? "" : "s"} awaiting review`
289
+ : autoKept
290
+ ? "Kept automatically — produced by a command you approved"
291
+ : settledSummary(total, changes, effectiveVerdicts);
292
+
293
+ return (
294
+ <div
295
+ // Interactive: an actionable alert (a pending gate). Read-only: a passive
296
+ // historical record, so a neutral group without the warning accent.
297
+ role={interactive ? "alert" : "group"}
298
+ aria-label={
299
+ interactive
300
+ ? `Review ${total} file change${total === 1 ? "" : "s"}`
301
+ : `${total} file change${total === 1 ? "" : "s"}`
302
+ }
303
+ className={cn(
304
+ "rounded-lg border border-border-prominent",
305
+ interactive && "border-l-2 border-l-warning",
306
+ className,
307
+ )}
308
+ >
309
+ {/* The bar: summary + whole-set verdict + the detail expander, one line. */}
310
+ <div className="flex flex-wrap items-center gap-2 px-2.5 py-1.5 text-xs">
311
+ <span className="shrink-0 font-medium text-foreground">
312
+ {interactive ? "Review file changes" : "File changes"}
313
+ </span>
314
+ <span className="min-w-0 flex-1 truncate text-muted-foreground">
315
+ {summary}
316
+ </span>
317
+
318
+ {interactive && showBulkControls && (
319
+ <>
320
+ <DecisionButton
321
+ label={labels.approve}
322
+ variant="primary"
323
+ onClick={() => handleBulk(FileDecisionAction.APPROVE)}
324
+ isActive={activeAction === FileDecisionAction.APPROVE}
325
+ isSubmitting={wholeSetSubmitting}
326
+ disabled={incomplete && !binaryOnly}
327
+ ariaDescribedby={incomplete ? incompleteNoticeId : undefined}
328
+ cursorTarget="file-review-approve"
329
+ />
330
+ <DecisionButton
331
+ label={labels.reject}
332
+ variant="danger"
333
+ onClick={() => handleBulk(FileDecisionAction.REJECT)}
334
+ isActive={activeAction === FileDecisionAction.REJECT}
335
+ isSubmitting={wholeSetSubmitting}
336
+ cursorTarget="file-review-reject"
337
+ />
338
+ </>
339
+ )}
340
+
341
+ <button
342
+ type="button"
343
+ aria-expanded={expanded}
344
+ aria-controls={bodyId}
345
+ onClick={() => setExpanded((v) => !v)}
346
+ data-cursor-target="file-review-expander"
347
+ className={cn(
348
+ "inline-flex shrink-0 items-center gap-1 rounded-md border border-border px-2 py-1",
349
+ "text-xs font-medium text-muted-foreground transition-colors",
350
+ "hover:bg-accent-hover hover:text-foreground",
351
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
352
+ )}
353
+ >
354
+ {/* The closed label states what expanding reveals: the reviewable
355
+ diffs ("Review"), the file inventory ("Files"), or a settled
356
+ record ("Show"). */}
357
+ {expanded ? "Hide" : !interactive ? "Show" : showDiffs ? "Review" : "Files"}
358
+ <ExpanderChevron expanded={expanded} />
359
+ </button>
360
+ </div>
361
+
362
+ {/* The disabled whole-set Approve's explanation must be visible even when
363
+ the diffs are collapsed, so it lives on the bar, not in the body. */}
364
+ {interactive && incomplete && showBulkControls && (
365
+ <p
366
+ id={incompleteNoticeId}
367
+ className="px-2.5 pb-1.5 text-[11px] italic text-muted-foreground"
368
+ >
369
+ {incompleteNotice(showPerFile, blockSummary, binaryOnly)}
370
+ </p>
371
+ )}
372
+
373
+ {/* A failed whole-set decision is surfaced HERE, in-card, not via the
374
+ session's global error banner. A review surface has many decision
375
+ targets (the whole set plus each file), so a failure must name the
376
+ control it belongs to — something a single global banner cannot do.
377
+ Rendered on the bar so it is visible regardless of expansion. */}
378
+ {interactive && wholeSetError && (
379
+ <div className="px-2.5 pb-1.5">
380
+ <InCardDecisionError
381
+ error={wholeSetError}
382
+ leadIn="submit decision"
383
+ cursorTarget="file-review-error"
384
+ />
385
+ </div>
386
+ )}
387
+
388
+ {expanded && (
389
+ <div
390
+ id={bodyId}
391
+ className="space-y-2 border-t border-border-muted px-3 py-2.5"
392
+ >
393
+ {/* The list body carries no summary header: the collapsed bar's
394
+ file count is the summary, and per-file counts would require
395
+ fetching offloaded bodies just to decorate a list. */}
396
+ {showDiffs && (
397
+ <DiffSummary
398
+ fileCount={total}
399
+ additions={totals.additions}
400
+ deletions={totals.deletions}
401
+ />
402
+ )}
403
+
404
+ {!interactive
405
+ ? // Settled: every file paired with its committed EFFECTIVE
406
+ // verdict (a bulk decision covers files without their own), no
407
+ // controls — a record of what changed and how it was decided.
408
+ changes.map((change) => (
409
+ <SettledFileRow
410
+ key={change.id}
411
+ change={change}
412
+ verdict={effectiveVerdicts.get(change.id) ?? null}
413
+ showDiffs={showDiffs}
414
+ />
415
+ ))
416
+ : showPerFile
417
+ ? changes.map((change) => (
418
+ <FileChangeReviewRow
419
+ key={change.id}
420
+ change={change}
421
+ verdict={verdictByFileId.get(change.id) ?? null}
422
+ isSubmitting={
423
+ submittingDecisionKeys.has(fileDecisionKey(setId, change.id)) ||
424
+ wholeSetSubmitting
425
+ }
426
+ error={decisionErrors.get(fileDecisionKey(setId, change.id)) ?? null}
427
+ onDecide={handleFileDecide}
428
+ showDiffs={showDiffs}
429
+ />
430
+ ))
431
+ : // A single complete file decided via the bar's bulk controls:
432
+ // its detail is informational, with no per-file control.
433
+ changes.map((change) =>
434
+ showDiffs ? (
435
+ <div key={change.id} className="space-y-1.5">
436
+ <CaptureBadge change={change} />
437
+ <CapturedChangeDiff change={change} />
438
+ </div>
439
+ ) : (
440
+ <FileListRow key={change.id} change={change} />
441
+ ),
442
+ )}
443
+
444
+ {interactive && showPerFile && (
445
+ <ReviewProgress decided={decidedCount} total={total} />
446
+ )}
447
+ </div>
448
+ )}
449
+ </div>
450
+ );
451
+ });
452
+
453
+ /**
454
+ * Whether the set was kept by the approved-command auto-keep policy (DD-28):
455
+ * some decision carries origin POLICY_APPROVED_COMMAND. Origin is audit
456
+ * provenance — the record label derives from it so an automatic keep is never
457
+ * presented as a human review.
458
+ */
459
+ function isPolicyAutoKept(fileChangeSet: FileChangeSet): boolean {
460
+ return fileChangeSet.decisions.some(
461
+ (d) => d.origin === FileDecisionOrigin.POLICY_APPROVED_COMMAND,
462
+ );
463
+ }
464
+
465
+ /**
466
+ * The settled bar's one-line history: verdict counts folded from the effective
467
+ * decisions ("2 kept · 1 discarded"), with an honest "not reviewed" bucket for
468
+ * files a terminated set never decided. Falls back to a plain changed-count
469
+ * when nothing was decided at all.
470
+ */
471
+ function settledSummary(
472
+ total: number,
473
+ changes: readonly CapturedFileChange[],
474
+ effectiveVerdicts: ReadonlyMap<string, FileDecisionAction>,
475
+ ): string {
476
+ let kept = 0;
477
+ let discarded = 0;
478
+ for (const c of changes) {
479
+ const v = effectiveVerdicts.get(c.id);
480
+ if (v === FileDecisionAction.APPROVE) kept++;
481
+ else if (v === FileDecisionAction.REJECT) discarded++;
482
+ }
483
+ const undecided = total - kept - discarded;
484
+ if (kept === 0 && discarded === 0) {
485
+ return `${total} file${total === 1 ? "" : "s"} changed`;
486
+ }
487
+ const parts: string[] = [];
488
+ if (kept > 0) parts.push(`${kept} kept`);
489
+ if (discarded > 0) parts.push(`${discarded} discarded`);
490
+ if (undecided > 0) parts.push(`${undecided} not reviewed`);
491
+ return parts.join(" · ");
492
+ }
493
+
494
+ /** The bar expander's disclosure chevron (rotates when open). */
495
+ function ExpanderChevron({ expanded }: { expanded: boolean }) {
496
+ return (
497
+ <svg
498
+ width="10"
499
+ height="10"
500
+ viewBox="0 0 10 10"
501
+ fill="none"
502
+ stroke="currentColor"
503
+ strokeWidth="1.5"
504
+ strokeLinecap="round"
505
+ strokeLinejoin="round"
506
+ className={cn(
507
+ "shrink-0 transition-transform duration-150",
508
+ expanded && "rotate-90",
509
+ )}
510
+ aria-hidden="true"
511
+ >
512
+ <path d="M3.5 2L6.5 5L3.5 8" />
513
+ </svg>
514
+ );
515
+ }
516
+
517
+ // ---------------------------------------------------------------------------
518
+ // FileChangeReviewRow — one file paired with its Keep/Discard verdict
519
+ // ---------------------------------------------------------------------------
520
+
521
+ interface FileChangeReviewRowProps {
522
+ readonly change: CapturedFileChange;
523
+ /** The file's committed verdict (server truth), or null if undecided. */
524
+ readonly verdict: FileDecisionAction | null;
525
+ /** True while THIS file's decision (or a whole-set decision) is in flight. */
526
+ readonly isSubmitting: boolean;
527
+ /** This file's last failed decision, or null — surfaced beside its control. */
528
+ readonly error: Error | null;
529
+ readonly onDecide: (change: CapturedFileChange, action: FileDecisionAction) => void;
530
+ /** Diff-rich body vs compact list line — see {@link FileReviewCardProps.showDiffs}. */
531
+ readonly showDiffs: boolean;
532
+ }
533
+
534
+ /**
535
+ * One reviewable file with its Keep/Discard control. The decision machinery
536
+ * (verdict radiogroup, block-reason note, in-card error) is identical in both
537
+ * body modes; only the file presentation varies — the bounded diff, or the
538
+ * compact {@link FileListRow} line with the control trailing it.
539
+ */
540
+ const FileChangeReviewRow = memo(function FileChangeReviewRow({
541
+ change,
542
+ verdict,
543
+ isSubmitting,
544
+ error,
545
+ onDecide,
546
+ showDiffs,
547
+ }: FileChangeReviewRowProps) {
548
+ // One classification per change drives BOTH the disabled Keep and its reason,
549
+ // so the two can never disagree (single source of truth).
550
+ const reviewability = fileReviewability(change);
551
+ const blocked = reviewability.kind !== "reviewable";
552
+ const reasonId = useId();
553
+ const verdictControl = (
554
+ <FileVerdictControl
555
+ change={change}
556
+ reviewability={reviewability}
557
+ verdict={verdict}
558
+ isSubmitting={isSubmitting}
559
+ onDecide={onDecide}
560
+ describedById={blocked ? reasonId : undefined}
561
+ />
562
+ );
563
+ return (
564
+ <div className="space-y-1.5">
565
+ {showDiffs ? (
566
+ <>
567
+ <CaptureBadge change={change} />
568
+ <CapturedChangeDiff change={change} />
569
+ {verdictControl}
570
+ </>
571
+ ) : (
572
+ <FileListRow change={change} trailing={verdictControl} />
573
+ )}
574
+ {blocked && <BlockReasonNote reviewability={reviewability} id={reasonId} />}
575
+ {error && (
576
+ <InCardDecisionError
577
+ error={error}
578
+ leadIn="save"
579
+ cursorTarget="file-review-file-error"
580
+ />
581
+ )}
582
+ </div>
583
+ );
584
+ });
585
+
586
+ // ---------------------------------------------------------------------------
587
+ // SettledFileRow — one file paired with its committed verdict (read-only)
588
+ // ---------------------------------------------------------------------------
589
+
590
+ interface SettledFileRowProps {
591
+ readonly change: CapturedFileChange;
592
+ /** The file's committed verdict, or null if the set was never decided. */
593
+ readonly verdict: FileDecisionAction | null;
594
+ /** Diff-rich body vs compact list line — see {@link FileReviewCardProps.showDiffs}. */
595
+ readonly showDiffs: boolean;
596
+ }
597
+
598
+ /**
599
+ * A read-only file row for a settled change set: the capture provenance badge
600
+ * and a static verdict badge, with the before/after diff in diff mode or the
601
+ * compact list line otherwise. No decision controls — the verdict is history,
602
+ * not an action. Used by the read-only {@link FileReviewCard}
603
+ * (`interactive={false}`) for decided/reconciled sets and terminal executions.
604
+ */
605
+ const SettledFileRow = memo(function SettledFileRow({
606
+ change,
607
+ verdict,
608
+ showDiffs,
609
+ }: SettledFileRowProps) {
610
+ if (!showDiffs) {
611
+ return <FileListRow change={change} trailing={<VerdictBadge verdict={verdict} />} />;
612
+ }
613
+ return (
614
+ <div className="space-y-1.5">
615
+ <div className="flex items-center gap-2">
616
+ <CaptureBadge change={change} />
617
+ <VerdictBadge verdict={verdict} />
618
+ </div>
619
+ <CapturedChangeDiff change={change} />
620
+ </div>
621
+ );
622
+ });
623
+
624
+ // ---------------------------------------------------------------------------
625
+ // CapturedChangeDiff — a captured change's bounded diff (showDiffs mode)
626
+ // ---------------------------------------------------------------------------
627
+
628
+ /**
629
+ * Projects a {@link CapturedFileChange} onto the display `FileChange` and
630
+ * renders its bounded diff — the one place the diff body renders, shared by the
631
+ * interactive, settled, and single-file branches of the diff-mode body.
632
+ */
633
+ function CapturedChangeDiff({ change }: { change: CapturedFileChange }) {
634
+ const adapted = useMemo(() => toDisplayFileChange(change), [change]);
635
+ return <FileChangeDiff change={adapted} bounded />;
636
+ }
637
+
638
+ // ---------------------------------------------------------------------------
639
+ // FileListRow — one file as a compact list line (list mode)
640
+ // ---------------------------------------------------------------------------
641
+
642
+ /**
643
+ * One file of the change set as a compact, deliberately inert list line: kind
644
+ * letter, path, provenance badge, and an optional trailing slot (the verdict
645
+ * control while reviewing; the committed verdict once settled).
646
+ *
647
+ * The path is plain text — not `FilePathLink` — because the list is an
648
+ * inventory of what the decision covers, not a navigation surface: the diffs
649
+ * live on the transcript's stamped edit rows. The full path stays available on
650
+ * hover via `title`. A rename shows its source path so the move reads in one
651
+ * line.
652
+ */
653
+ function FileListRow({
654
+ change,
655
+ trailing,
656
+ }: {
657
+ readonly change: CapturedFileChange;
658
+ readonly trailing?: React.ReactNode;
659
+ }) {
660
+ const path = change.pathAfter || change.pathBefore;
661
+ const { dir, base } = splitDisplayPath(path);
662
+ const renamedFrom =
663
+ change.kind === FileChangeKind.RENAME && change.pathBefore !== change.pathAfter
664
+ ? change.pathBefore
665
+ : "";
666
+ return (
667
+ <div
668
+ className="flex flex-wrap items-center gap-x-2 gap-y-1"
669
+ data-cursor-target="file-review-list-row"
670
+ >
671
+ <FileKindBadge kind={change.kind} />
672
+ {/* The directory truncates (context); the base never shrinks (identity) —
673
+ the same filename-first rule FilePathLink established. */}
674
+ <span
675
+ className="flex min-w-0 flex-1 items-center font-mono text-xs text-foreground"
676
+ title={path}
677
+ >
678
+ {renamedFrom && (
679
+ <span className="min-w-0 truncate text-muted-foreground-faint">
680
+ {renamedFrom}&nbsp;→&nbsp;
681
+ </span>
682
+ )}
683
+ {dir && (
684
+ <span className="min-w-0 truncate text-muted-foreground-faint">{dir}</span>
685
+ )}
686
+ <span className="shrink-0">{base}</span>
687
+ </span>
688
+ <CaptureBadge change={change} />
689
+ {trailing}
690
+ </div>
691
+ );
692
+ }
693
+
694
+ /** The kind letter, tone, and a11y name for each wire {@link FileChangeKind}. */
695
+ const KIND_BADGE: Partial<
696
+ Record<FileChangeKind, { letter: string; colorClass: string; label: string }>
697
+ > = {
698
+ [FileChangeKind.ADD]: {
699
+ letter: "A",
700
+ colorClass: "text-diff-added-fg",
701
+ label: "added",
702
+ },
703
+ [FileChangeKind.MODIFY]: {
704
+ letter: "M",
705
+ colorClass: "text-diff-hunk-header-fg",
706
+ label: "modified",
707
+ },
708
+ [FileChangeKind.DELETE]: {
709
+ letter: "D",
710
+ colorClass: "text-diff-removed-fg",
711
+ label: "deleted",
712
+ },
713
+ [FileChangeKind.RENAME]: {
714
+ letter: "R",
715
+ colorClass: "text-diff-hunk-header-fg",
716
+ label: "renamed",
717
+ },
718
+ // A binary change is a modification whose diff is not text; the letter stays
719
+ // M and the binary-ness is told by the row's "Keep anyway" + reason note.
720
+ [FileChangeKind.BINARY_CHANGE]: {
721
+ letter: "M",
722
+ colorClass: "text-diff-hunk-header-fg",
723
+ label: "modified (binary)",
724
+ },
725
+ };
726
+
727
+ /**
728
+ * The single-letter change-kind marker — the same M/A/D visual vocabulary as
729
+ * the version-history `DiffFileList`, extended with R for captured renames.
730
+ * The letter itself is the non-color channel; the full word is exposed to
731
+ * assistive tech. An UNSPECIFIED kind renders nothing rather than guessing.
732
+ */
733
+ function FileKindBadge({ kind }: { kind: FileChangeKind }) {
734
+ const badge = KIND_BADGE[kind];
735
+ if (!badge) return null;
736
+ return (
737
+ <span
738
+ className={cn("shrink-0 font-mono text-[10px] font-bold", badge.colorClass)}
739
+ aria-label={badge.label}
740
+ >
741
+ {badge.letter}
742
+ </span>
743
+ );
744
+ }
745
+
746
+ /**
747
+ * A change's committed verdict as a static badge: APPROVE reads "Kept" (the
748
+ * bytes were reconciled into the workspace), REJECT reads "Discarded" (reverted
749
+ * to baseline), and an undecided change (a set terminated mid-review) reads "Not
750
+ * reviewed". Uses the same diff add/remove tokens the card uses elsewhere, so it
751
+ * introduces no new token.
752
+ */
753
+ function VerdictBadge({ verdict }: { verdict: FileDecisionAction | null }) {
754
+ if (verdict === FileDecisionAction.APPROVE) {
755
+ return (
756
+ <span className="shrink-0 text-[11px] font-medium text-diff-added-fg">Kept</span>
757
+ );
758
+ }
759
+ if (verdict === FileDecisionAction.REJECT) {
760
+ return (
761
+ <span className="shrink-0 text-[11px] font-medium text-diff-removed-fg">
762
+ Discarded
763
+ </span>
764
+ );
765
+ }
766
+ return (
767
+ <span className="shrink-0 text-[11px] italic text-muted-foreground">
768
+ Not reviewed
769
+ </span>
770
+ );
771
+ }
772
+
773
+ // ---------------------------------------------------------------------------
774
+ // FileVerdictControl — a Keep|Discard segmented control for one file
775
+ // ---------------------------------------------------------------------------
776
+
777
+ interface FileVerdictControlProps {
778
+ readonly change: CapturedFileChange;
779
+ /** The file's reviewability — `Keep` is available only when `reviewable`. */
780
+ readonly reviewability: FileReviewability;
781
+ readonly verdict: FileDecisionAction | null;
782
+ readonly isSubmitting: boolean;
783
+ readonly onDecide: (change: CapturedFileChange, action: FileDecisionAction) => void;
784
+ /** Id of the reason note describing why `Keep` is unavailable (a11y). */
785
+ readonly describedById?: string;
786
+ }
787
+
788
+ /**
789
+ * The per-file decision affordance: a two-option segmented control modeled as a
790
+ * `radiogroup` (the established idiom across the SDK — Keep XOR Discard is a
791
+ * persistent, mutually-exclusive choice because the card stays visible after
792
+ * each file decision). The committed verdict reads as `aria-checked`; clicking
793
+ * the other option flips it (a new last-write-wins decision).
794
+ *
795
+ * `Keep` behavior by reviewability: a `reviewable` file keeps normally; a
796
+ * `binary` file has no text diff but reconcilable bytes, so `Keep` is enabled as
797
+ * an explicit "Keep anyway" that carries the acknowledgment (DD-16); an
798
+ * `unavailable` file (secret-withheld / size-elided) has no keepable bytes, so
799
+ * `Keep` stays disabled — it can only be discarded. `describedById` associates
800
+ * the group with the note explaining the binary/unavailable case.
801
+ */
802
+ function FileVerdictControl({
803
+ change,
804
+ reviewability,
805
+ verdict,
806
+ isSubmitting,
807
+ onDecide,
808
+ describedById,
809
+ }: FileVerdictControlProps) {
810
+ // Optimistic: show the clicked option immediately; the server's decisions
811
+ // projection then confirms it (or, on failure, it reverts to `verdict`).
812
+ const [pending, setPending] = useState<FileDecisionAction | null>(null);
813
+ useEffect(() => {
814
+ if (!isSubmitting) setPending(null);
815
+ }, [isSubmitting]);
816
+
817
+ const effective = pending ?? verdict;
818
+ const path = change.pathAfter || change.pathBefore;
819
+ // A binary file is keepable via an explicit acknowledgment; only a truly
820
+ // unavailable diff (no captured bytes) leaves Keep disabled.
821
+ const isBinary = reviewability.kind === "binary";
822
+ const keepDisabled = reviewability.kind === "unavailable";
823
+ const keepLabel = isBinary ? "Keep anyway" : "Keep";
824
+ const keepAccessibleLabel = isBinary
825
+ ? `Keep ${path} anyway (binary — no text diff)`
826
+ : `Keep ${path}`;
827
+
828
+ const decide = useCallback(
829
+ (action: FileDecisionAction) => {
830
+ if (isSubmitting) return;
831
+ setPending(action);
832
+ onDecide(change, action);
833
+ },
834
+ [isSubmitting, onDecide, change],
835
+ );
836
+
837
+ return (
838
+ <div
839
+ role="radiogroup"
840
+ aria-label={`Decision for ${path}`}
841
+ aria-describedby={describedById}
842
+ className="flex items-center gap-1.5"
843
+ >
844
+ <VerdictOption
845
+ label={keepLabel}
846
+ accessibleLabel={keepAccessibleLabel}
847
+ tone="keep"
848
+ selected={effective === FileDecisionAction.APPROVE}
849
+ busy={isSubmitting && pending === FileDecisionAction.APPROVE}
850
+ disabled={isSubmitting || keepDisabled}
851
+ onClick={() => decide(FileDecisionAction.APPROVE)}
852
+ />
853
+ <VerdictOption
854
+ label="Discard"
855
+ accessibleLabel={`Discard ${path}`}
856
+ tone="discard"
857
+ selected={effective === FileDecisionAction.REJECT}
858
+ busy={isSubmitting && pending === FileDecisionAction.REJECT}
859
+ disabled={isSubmitting}
860
+ onClick={() => decide(FileDecisionAction.REJECT)}
861
+ />
862
+ </div>
863
+ );
864
+ }
865
+
866
+ interface VerdictOptionProps {
867
+ readonly label: string;
868
+ readonly accessibleLabel: string;
869
+ readonly tone: "keep" | "discard";
870
+ readonly selected: boolean;
871
+ readonly busy: boolean;
872
+ readonly disabled: boolean;
873
+ readonly onClick: () => void;
874
+ }
875
+
876
+ // Quiet at rest (ghost), tinted when selected — the verdict is signalled by both
877
+ // fill AND tone (not color alone), and every property flows through `--stgm-*`
878
+ // tokens with no opacity modifiers.
879
+ const VERDICT_BASE = cn(
880
+ "inline-flex items-center justify-center gap-1.5 rounded-md border px-2.5 py-1",
881
+ "text-xs font-medium transition-colors",
882
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
883
+ "disabled:cursor-not-allowed disabled:opacity-50",
884
+ );
885
+
886
+ const VERDICT_REST = "border-border text-muted-foreground hover:bg-accent-hover hover:text-foreground";
887
+
888
+ const VERDICT_SELECTED: Record<VerdictOptionProps["tone"], string> = {
889
+ keep: "border-success bg-success-subtle text-success",
890
+ discard: "border-destructive bg-destructive-subtle text-destructive",
891
+ };
892
+
893
+ function VerdictOption({
894
+ label,
895
+ accessibleLabel,
896
+ tone,
897
+ selected,
898
+ busy,
899
+ disabled,
900
+ onClick,
901
+ }: VerdictOptionProps) {
902
+ return (
903
+ <button
904
+ type="button"
905
+ role="radio"
906
+ aria-checked={selected}
907
+ aria-label={accessibleLabel}
908
+ disabled={disabled}
909
+ onClick={onClick}
910
+ data-cursor-target={`file-review-${tone}`}
911
+ className={cn(VERDICT_BASE, selected ? VERDICT_SELECTED[tone] : VERDICT_REST)}
912
+ >
913
+ {busy ? <SpinnerIcon /> : null}
914
+ {label}
915
+ </button>
916
+ );
917
+ }
918
+
919
+ // ---------------------------------------------------------------------------
920
+ // CaptureBadge — provenance for a file captured outside normal git tracking
921
+ // ---------------------------------------------------------------------------
922
+
923
+ /** Non-default provenance the reviewer should be told about, with its a11y text. */
924
+ const PROVENANCE: Partial<Record<FileCaptureClass, { label: string; aria: string }>> = {
925
+ [FileCaptureClass.GIT_IGNORED_CAPTURED]: {
926
+ label: "gitignored",
927
+ aria: "This file is ignored by git; the change was captured for review",
928
+ },
929
+ [FileCaptureClass.NON_GIT_CAS]: {
930
+ label: "outside git",
931
+ aria: "This file is outside the git repository; the change was captured for review",
932
+ },
933
+ };
934
+
935
+ /**
936
+ * A small provenance pill for a file captured outside normal git tracking —
937
+ * gitignored or non-git (CAS). Renders nothing for ordinary git-tracked (or
938
+ * untracked-but-new) files, so the badge appears only when it carries real
939
+ * signal: this change is not part of the repo's tracked history.
940
+ */
941
+ function CaptureBadge({ change }: { change: CapturedFileChange }) {
942
+ const provenance = PROVENANCE[change.captureClass];
943
+ if (!provenance) return null;
944
+ return (
945
+ <span
946
+ className="inline-flex w-fit items-center rounded border border-border bg-muted-subtle px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground"
947
+ aria-label={provenance.aria}
948
+ data-cursor-target="file-review-capture"
949
+ >
950
+ {provenance.label}
951
+ </span>
952
+ );
953
+ }
954
+
955
+ // ---------------------------------------------------------------------------
956
+ // BlockReasonNote — why a file's Keep is unavailable (associated via aria)
957
+ // ---------------------------------------------------------------------------
958
+
959
+ /**
960
+ * The in-place explanation for a non-reviewable file, associated with the verdict
961
+ * control via `aria-describedby`. Honest per {@link fileReviewability}: a `binary`
962
+ * change has no text diff but is keepable-as-bytes (an explicit "Keep anyway"); an
963
+ * `unavailable` change's diff cannot be shown at all and is discard-only, with the
964
+ * specific cause the runner recorded (secret-withheld vs size-dropped, doc 15). A
965
+ * reviewable file renders no note.
966
+ */
967
+ function BlockReasonNote({
968
+ reviewability,
969
+ id,
970
+ }: {
971
+ reviewability: FileReviewability;
972
+ id: string;
973
+ }) {
974
+ const text = blockReasonText(reviewability);
975
+ if (!text) return null;
976
+ return (
977
+ <p
978
+ id={id}
979
+ className="text-[11px] italic text-muted-foreground"
980
+ data-cursor-target="file-review-block-reason"
981
+ >
982
+ {text}
983
+ </p>
984
+ );
985
+ }
986
+
987
+ /** The per-file reason copy for a non-reviewable change (null when reviewable). */
988
+ function blockReasonText(reviewability: FileReviewability): string | null {
989
+ switch (reviewability.kind) {
990
+ case "binary":
991
+ return "Binary file — no text diff to review. Keep it as-is (its exact bytes are applied) or discard it.";
992
+ case "unavailable":
993
+ switch (reviewability.reason) {
994
+ case "secret":
995
+ return "Contents withheld — this path looks like a secret, so it was never captured for review and can only be discarded.";
996
+ case "size":
997
+ return "Diff too large to display — the full contents were elided to stay within limits, so this change can only be discarded.";
998
+ default:
999
+ return "The full diff isn't available to review, so this change can only be discarded.";
1000
+ }
1001
+ default:
1002
+ return null;
1003
+ }
1004
+ }
1005
+
1006
+ /**
1007
+ * The whole-set incompleteness copy, accurate to which non-reviewable classes are
1008
+ * present (never the generic "binary or truncated"). Unavailable is reported
1009
+ * ahead of binary because it is the stronger "nothing to see" signal.
1010
+ *
1011
+ * A `binaryOnly` set is the keepable case (DD-17): binary files are the only
1012
+ * blocker, so the copy explains that "Keep all" is available rather than saying
1013
+ * the set can't be approved. A `blocked` set (something unavailable) keeps the
1014
+ * discard-oriented copy.
1015
+ */
1016
+ function incompleteNotice(
1017
+ showPerFile: boolean,
1018
+ summary: { hasBinary: boolean; hasUnavailable: boolean },
1019
+ binaryOnly: boolean,
1020
+ ): string {
1021
+ if (binaryOnly) {
1022
+ // Every non-binary file is reviewable; only binaries have no text diff.
1023
+ return showPerFile
1024
+ ? "This set includes binary files with no text diff. Keep all keeps every file as-is (their exact bytes are applied), or decide each file below."
1025
+ : "This is a binary change with no text diff. Keep it as-is (its exact bytes are applied) or discard it.";
1026
+ }
1027
+ if (showPerFile) {
1028
+ if (summary.hasUnavailable) {
1029
+ return "Part of this change isn't available to review, so the whole set can't be approved — keep the reviewable files and discard the rest.";
1030
+ }
1031
+ if (summary.hasBinary) {
1032
+ return "This set includes a binary change with no text diff, so it can't be approved at once — decide each file below (keep the reviewable ones; keep or discard the binary).";
1033
+ }
1034
+ return "Some files can't be reviewed completely, so the whole set can't be approved at once — keep the reviewable files and discard the rest.";
1035
+ }
1036
+ if (summary.hasUnavailable) {
1037
+ return "This change isn't available to review, so it can't be approved — it can only be discarded.";
1038
+ }
1039
+ if (summary.hasBinary) {
1040
+ return "This is a binary change with no text diff, so it can't be approved as a complete change — it can only be discarded.";
1041
+ }
1042
+ return "This change can't be reviewed completely, so it can't be approved as a complete change.";
1043
+ }
1044
+
1045
+ // ---------------------------------------------------------------------------
1046
+ // ReviewProgress — how many files still need a verdict before the turn resumes
1047
+ // ---------------------------------------------------------------------------
1048
+
1049
+ function ReviewProgress({ decided, total }: { decided: number; total: number }) {
1050
+ return (
1051
+ <p role="status" className="text-[11px] text-muted-foreground">
1052
+ {decided} of {total} files reviewed
1053
+ {decided > 0 && decided < total && " — decide the rest to continue"}
1054
+ </p>
1055
+ );
1056
+ }
1057
+
1058
+ // ---------------------------------------------------------------------------
1059
+ // Helpers
1060
+ // ---------------------------------------------------------------------------
1061
+
1062
+ /**
1063
+ * Fold the ledger-ordered decisions into each file's current verdict, last
1064
+ * write wins (the precedence the runner reconcile applies). Only FILE-scoped
1065
+ * decisions are per-file; a CHANGE_SET decision cannot coexist with an
1066
+ * AWAITING_REVIEW set, so it is ignored defensively.
1067
+ */
1068
+ function deriveFileVerdicts(
1069
+ decisions: readonly FileDecision[],
1070
+ ): Map<string, FileDecisionAction> {
1071
+ const byFileId = new Map<string, FileDecisionAction>();
1072
+ for (const d of decisions) {
1073
+ if (d.scope === FileDecisionScope.FILE && d.fileChangeId) {
1074
+ byFileId.set(d.fileChangeId, d.action);
1075
+ }
1076
+ }
1077
+ return byFileId;
1078
+ }
1079
+
1080
+ /**
1081
+ * The whole-set action labels, sharpened by file count and review progress. A
1082
+ * `binaryOnly` set (DD-17) reads as "Keep all" rather than "Approve all": the
1083
+ * bulk keep carries the binary acknowledgment, and "Keep" is the same verb the
1084
+ * per-file binary control uses ("Keep anyway").
1085
+ */
1086
+ function bulkLabels(
1087
+ total: number,
1088
+ decided: number,
1089
+ binaryOnly: boolean,
1090
+ ): { approve: string; reject: string } {
1091
+ const approveVerb = binaryOnly ? "Keep" : "Approve";
1092
+ if (total <= 1) return { approve: approveVerb, reject: "Reject" };
1093
+ if (decided > 0 && decided < total) {
1094
+ return { approve: `${approveVerb} remaining`, reject: "Reject remaining" };
1095
+ }
1096
+ return { approve: `${approveVerb} all`, reject: "Reject all" };
1097
+ }
1098
+
1099
+ // ---------------------------------------------------------------------------
1100
+ // Inline SVG icon
1101
+ // ---------------------------------------------------------------------------
1102
+
1103
+ function SpinnerIcon() {
1104
+ return (
1105
+ <svg
1106
+ width="10"
1107
+ height="10"
1108
+ viewBox="0 0 12 12"
1109
+ fill="none"
1110
+ stroke="currentColor"
1111
+ strokeWidth="1.5"
1112
+ className="animate-spin"
1113
+ aria-hidden="true"
1114
+ >
1115
+ <path d="M6 1.5A4.5 4.5 0 1 1 1.5 6" strokeLinecap="round" />
1116
+ </svg>
1117
+ );
1118
+ }