diffity 0.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 (174) hide show
  1. package/.claude/settings.local.json +11 -0
  2. package/LICENSE +21 -0
  3. package/README.md +71 -0
  4. package/development.md +156 -0
  5. package/package.json +32 -0
  6. package/packages/cli/build.js +38 -0
  7. package/packages/cli/package.json +51 -0
  8. package/packages/cli/src/agent.ts +187 -0
  9. package/packages/cli/src/db.ts +58 -0
  10. package/packages/cli/src/index.ts +196 -0
  11. package/packages/cli/src/review-routes.ts +150 -0
  12. package/packages/cli/src/server.ts +370 -0
  13. package/packages/cli/src/session.ts +48 -0
  14. package/packages/cli/src/threads.ts +238 -0
  15. package/packages/cli/tsconfig.json +13 -0
  16. package/packages/git/package.json +24 -0
  17. package/packages/git/src/commits.ts +28 -0
  18. package/packages/git/src/diff.ts +97 -0
  19. package/packages/git/src/exec.ts +35 -0
  20. package/packages/git/src/index.ts +5 -0
  21. package/packages/git/src/repo.ts +63 -0
  22. package/packages/git/src/status.ts +9 -0
  23. package/packages/git/src/types.ts +12 -0
  24. package/packages/git/tsconfig.json +9 -0
  25. package/packages/parser/package.json +26 -0
  26. package/packages/parser/src/index.ts +12 -0
  27. package/packages/parser/src/parse.ts +299 -0
  28. package/packages/parser/src/types.ts +52 -0
  29. package/packages/parser/src/word-diff.ts +155 -0
  30. package/packages/parser/tests/fixtures/binary-deleted.diff +4 -0
  31. package/packages/parser/tests/fixtures/binary-file.diff +4 -0
  32. package/packages/parser/tests/fixtures/binary-modified.diff +3 -0
  33. package/packages/parser/tests/fixtures/copied-file.diff +12 -0
  34. package/packages/parser/tests/fixtures/deleted-file.diff +9 -0
  35. package/packages/parser/tests/fixtures/empty.diff +0 -0
  36. package/packages/parser/tests/fixtures/hunk-with-context.diff +12 -0
  37. package/packages/parser/tests/fixtures/mode-change-with-content.diff +10 -0
  38. package/packages/parser/tests/fixtures/mode-change.diff +3 -0
  39. package/packages/parser/tests/fixtures/multi-file.diff +22 -0
  40. package/packages/parser/tests/fixtures/new-file.diff +9 -0
  41. package/packages/parser/tests/fixtures/no-newline.diff +10 -0
  42. package/packages/parser/tests/fixtures/renamed-file.diff +12 -0
  43. package/packages/parser/tests/fixtures/single-file-additions.diff +11 -0
  44. package/packages/parser/tests/fixtures/single-file-deletions.diff +11 -0
  45. package/packages/parser/tests/fixtures/single-file-mixed.diff +15 -0
  46. package/packages/parser/tests/fixtures/single-file-multi-hunk.diff +22 -0
  47. package/packages/parser/tests/fixtures/spaces-in-path.diff +9 -0
  48. package/packages/parser/tests/fixtures/submodule.diff +7 -0
  49. package/packages/parser/tests/fixtures/unicode-content.diff +11 -0
  50. package/packages/parser/tests/parse.test.ts +312 -0
  51. package/packages/parser/tests/word-diff-integration.test.ts +52 -0
  52. package/packages/parser/tests/word-diff.test.ts +121 -0
  53. package/packages/parser/tsconfig.json +10 -0
  54. package/packages/skills/diffity-resolve/SKILL.md +55 -0
  55. package/packages/skills/diffity-review/SKILL.md +74 -0
  56. package/packages/skills/diffity-start/SKILL.md +25 -0
  57. package/packages/ui/index.html +13 -0
  58. package/packages/ui/package.json +35 -0
  59. package/packages/ui/public/brand.svg +12 -0
  60. package/packages/ui/public/favicon.svg +15 -0
  61. package/packages/ui/src/app.tsx +14 -0
  62. package/packages/ui/src/components/comment-bubble.tsx +78 -0
  63. package/packages/ui/src/components/comment-form-row.tsx +58 -0
  64. package/packages/ui/src/components/comment-form.tsx +78 -0
  65. package/packages/ui/src/components/comment-line-number.tsx +60 -0
  66. package/packages/ui/src/components/comment-thread.tsx +209 -0
  67. package/packages/ui/src/components/commit-list.tsx +100 -0
  68. package/packages/ui/src/components/dashboard.tsx +84 -0
  69. package/packages/ui/src/components/diff-line.tsx +90 -0
  70. package/packages/ui/src/components/diff-page.tsx +332 -0
  71. package/packages/ui/src/components/diff-stats.tsx +20 -0
  72. package/packages/ui/src/components/diff-view.tsx +278 -0
  73. package/packages/ui/src/components/expand-row.tsx +45 -0
  74. package/packages/ui/src/components/file-block.tsx +536 -0
  75. package/packages/ui/src/components/file-tree-item.tsx +84 -0
  76. package/packages/ui/src/components/file-tree.tsx +72 -0
  77. package/packages/ui/src/components/general-comments.tsx +174 -0
  78. package/packages/ui/src/components/hunk-block-split.tsx +357 -0
  79. package/packages/ui/src/components/hunk-block.tsx +161 -0
  80. package/packages/ui/src/components/hunk-header.tsx +144 -0
  81. package/packages/ui/src/components/hunk-with-gap.tsx +113 -0
  82. package/packages/ui/src/components/icons/arrow-down-icon.tsx +7 -0
  83. package/packages/ui/src/components/icons/arrow-up-icon.tsx +7 -0
  84. package/packages/ui/src/components/icons/check-circle-icon.tsx +8 -0
  85. package/packages/ui/src/components/icons/check-icon.tsx +9 -0
  86. package/packages/ui/src/components/icons/chevron-down-icon.tsx +11 -0
  87. package/packages/ui/src/components/icons/chevron-icon.tsx +20 -0
  88. package/packages/ui/src/components/icons/chevron-up-down-icon.tsx +7 -0
  89. package/packages/ui/src/components/icons/chevron-up-icon.tsx +11 -0
  90. package/packages/ui/src/components/icons/comment-icon.tsx +9 -0
  91. package/packages/ui/src/components/icons/copy-icon.tsx +10 -0
  92. package/packages/ui/src/components/icons/eye-icon.tsx +10 -0
  93. package/packages/ui/src/components/icons/eye-off-icon.tsx +12 -0
  94. package/packages/ui/src/components/icons/file-icon.tsx +7 -0
  95. package/packages/ui/src/components/icons/folder-icon.tsx +19 -0
  96. package/packages/ui/src/components/icons/git-branch-icon.tsx +13 -0
  97. package/packages/ui/src/components/icons/keyboard-icon.tsx +13 -0
  98. package/packages/ui/src/components/icons/moon-icon.tsx +9 -0
  99. package/packages/ui/src/components/icons/plus-icon.tsx +9 -0
  100. package/packages/ui/src/components/icons/search-icon.tsx +10 -0
  101. package/packages/ui/src/components/icons/sidebar-icon.tsx +10 -0
  102. package/packages/ui/src/components/icons/spinner.tsx +7 -0
  103. package/packages/ui/src/components/icons/split-view-icon.tsx +10 -0
  104. package/packages/ui/src/components/icons/sun-icon.tsx +17 -0
  105. package/packages/ui/src/components/icons/trash-icon.tsx +11 -0
  106. package/packages/ui/src/components/icons/undo-icon.tsx +9 -0
  107. package/packages/ui/src/components/icons/unified-view-icon.tsx +12 -0
  108. package/packages/ui/src/components/icons/x-icon.tsx +10 -0
  109. package/packages/ui/src/components/line-number-cell.tsx +18 -0
  110. package/packages/ui/src/components/markdown-content.tsx +139 -0
  111. package/packages/ui/src/components/orphaned-threads.tsx +80 -0
  112. package/packages/ui/src/components/overview-file-list.tsx +57 -0
  113. package/packages/ui/src/components/render-expansion-rows.tsx +47 -0
  114. package/packages/ui/src/components/shortcut-modal.tsx +93 -0
  115. package/packages/ui/src/components/sidebar.tsx +80 -0
  116. package/packages/ui/src/components/skeleton.tsx +9 -0
  117. package/packages/ui/src/components/stale-diff-banner.tsx +21 -0
  118. package/packages/ui/src/components/summary-bar.tsx +39 -0
  119. package/packages/ui/src/components/toolbar.tsx +246 -0
  120. package/packages/ui/src/components/ui/badge.tsx +17 -0
  121. package/packages/ui/src/components/ui/confirm-dialog.tsx +52 -0
  122. package/packages/ui/src/components/ui/icon-button.tsx +23 -0
  123. package/packages/ui/src/components/ui/status-badge.tsx +57 -0
  124. package/packages/ui/src/components/ui/thread-badge.tsx +35 -0
  125. package/packages/ui/src/components/word-diff.tsx +126 -0
  126. package/packages/ui/src/hooks/use-comment-actions.ts +97 -0
  127. package/packages/ui/src/hooks/use-commits.ts +12 -0
  128. package/packages/ui/src/hooks/use-copy.ts +18 -0
  129. package/packages/ui/src/hooks/use-diff-staleness.ts +58 -0
  130. package/packages/ui/src/hooks/use-diff.ts +12 -0
  131. package/packages/ui/src/hooks/use-highlighter.ts +190 -0
  132. package/packages/ui/src/hooks/use-info.ts +12 -0
  133. package/packages/ui/src/hooks/use-keyboard.ts +55 -0
  134. package/packages/ui/src/hooks/use-line-selection.ts +157 -0
  135. package/packages/ui/src/hooks/use-overview.ts +12 -0
  136. package/packages/ui/src/hooks/use-review-threads.ts +12 -0
  137. package/packages/ui/src/hooks/use-search-params.ts +26 -0
  138. package/packages/ui/src/hooks/use-theme.ts +34 -0
  139. package/packages/ui/src/hooks/use-thread-navigation.ts +43 -0
  140. package/packages/ui/src/lib/api.ts +232 -0
  141. package/packages/ui/src/lib/cn.ts +6 -0
  142. package/packages/ui/src/lib/context-expansion.ts +122 -0
  143. package/packages/ui/src/lib/diff-utils.ts +268 -0
  144. package/packages/ui/src/lib/dom-utils.ts +13 -0
  145. package/packages/ui/src/lib/file-tree.ts +122 -0
  146. package/packages/ui/src/lib/query-client.ts +10 -0
  147. package/packages/ui/src/lib/render-content.tsx +23 -0
  148. package/packages/ui/src/lib/syntax-token.ts +4 -0
  149. package/packages/ui/src/main.tsx +14 -0
  150. package/packages/ui/src/queries/commits.ts +9 -0
  151. package/packages/ui/src/queries/diff.ts +9 -0
  152. package/packages/ui/src/queries/file.ts +10 -0
  153. package/packages/ui/src/queries/info.ts +9 -0
  154. package/packages/ui/src/queries/overview.ts +9 -0
  155. package/packages/ui/src/styles/app.css +178 -0
  156. package/packages/ui/src/types/comment.ts +61 -0
  157. package/packages/ui/src/vite-env.d.ts +1 -0
  158. package/packages/ui/tests/context-expansion.test.ts +279 -0
  159. package/packages/ui/tests/diff-utils.test.ts +409 -0
  160. package/packages/ui/tsconfig.json +14 -0
  161. package/packages/ui/vite.config.ts +23 -0
  162. package/scripts/build-skills.ts +26 -0
  163. package/scripts/build.ts +15 -0
  164. package/scripts/dev.ts +32 -0
  165. package/scripts/lib/transformers/claude-code.ts +11 -0
  166. package/scripts/lib/transformers/codex.ts +17 -0
  167. package/scripts/lib/transformers/cursor.ts +17 -0
  168. package/scripts/lib/transformers/index.ts +3 -0
  169. package/scripts/lib/utils.ts +70 -0
  170. package/scripts/link-dev.ts +54 -0
  171. package/skills/diffity-resolve/SKILL.md +55 -0
  172. package/skills/diffity-review/SKILL.md +74 -0
  173. package/skills/diffity-start/SKILL.md +27 -0
  174. package/tsconfig.json +22 -0
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@diffity/ui",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "private": true,
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
+ "preview": "vite preview",
10
+ "test": "vitest run",
11
+ "test:watch": "vitest"
12
+ },
13
+ "dependencies": {
14
+ "@tailwindcss/vite": "^4.2.1",
15
+ "@tanstack/react-query": "^5.90.21",
16
+ "@tanstack/react-virtual": "^3.13.22",
17
+ "clsx": "^2.1.1",
18
+ "react": "latest",
19
+ "react-dom": "latest",
20
+ "react-hotkeys-hook": "^5.2.4",
21
+ "react-markdown": "^10.1.0",
22
+ "remark-gfm": "^4.0.1",
23
+ "shiki": "^4.0.2",
24
+ "tailwind-merge": "^3.5.0",
25
+ "tailwindcss": "^4.2.1"
26
+ },
27
+ "devDependencies": {
28
+ "@types/react": "latest",
29
+ "@types/react-dom": "latest",
30
+ "@vitejs/plugin-react": "latest",
31
+ "typescript": "latest",
32
+ "vite": "latest",
33
+ "vitest": "^4.1.0"
34
+ }
35
+ }
@@ -0,0 +1,12 @@
1
+ <svg width="411" height="395" viewBox="0 0 411 395" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <style>
3
+ .bubble { fill: white; }
4
+ .text { fill: black; }
5
+ @media (prefers-color-scheme: light) {
6
+ .bubble { fill: black; }
7
+ .text { fill: white; }
8
+ }
9
+ </style>
10
+ <path class="bubble" d="M0.0505394 178.855C6.76429 71.9657 97.6316 0 205.839 0C315.66 0 410.839 71.6797 410.839 166.889C410.839 253.623 331.853 320.83 234.765 332.109L262.001 369.673C265.392 374.351 265.873 380.536 263.247 385.684C260.621 390.831 255.332 394.072 249.553 394.072C185.518 394.072 123.058 374.827 76.4811 338.337C29.5845 301.595 -0.929973 247.475 0.0216367 179.605C0.0253267 179.355 0.0347544 179.105 0.0505394 178.855Z"/>
11
+ <path class="text" d="M122.036 232.224C110.816 232.224 101.942 228.654 95.414 221.514C88.988 214.374 85.775 203.919 85.775 190.149C85.775 180.969 87.101 173.676 89.753 168.27C92.405 162.864 96.077 159.039 100.769 156.795C105.563 154.449 110.969 153.276 116.987 153.276C122.393 153.276 126.779 153.939 130.145 155.265C133.511 156.591 136.163 158.835 138.101 161.997C137.387 159.855 136.826 157.866 136.418 156.03C136.112 154.092 135.857 151.848 135.653 149.298C135.551 146.748 135.5 143.484 135.5 139.506V126.348C135.5 118.392 138.917 114.414 145.751 114.414C153.197 114.414 156.92 118.392 156.92 126.348V191.067C156.92 204.837 153.86 215.139 147.74 221.973C141.62 228.807 133.052 232.224 122.036 232.224ZM120.965 213.864C125.249 213.864 128.564 212.334 130.91 209.274C133.358 206.214 134.582 200.502 134.582 192.138C134.582 186.528 134.021 182.295 132.899 179.439C131.879 176.481 130.349 174.441 128.309 173.319C126.269 172.197 123.821 171.636 120.965 171.636C118.109 171.636 115.661 172.197 113.621 173.319C111.581 174.441 110 176.481 108.878 179.439C107.858 182.295 107.348 186.528 107.348 192.138C107.348 200.502 108.521 206.214 110.867 209.274C113.315 212.334 116.681 213.864 120.965 213.864ZM184.448 140.118C180.674 140.118 177.512 138.894 174.962 136.446C172.412 133.998 171.137 130.989 171.137 127.419C171.137 123.645 172.412 120.534 174.962 118.086C177.512 115.638 180.674 114.414 184.448 114.414C188.324 114.414 191.486 115.638 193.934 118.086C196.484 120.534 197.759 123.645 197.759 127.419C197.759 130.989 196.484 133.998 193.934 136.446C191.486 138.894 188.324 140.118 184.448 140.118ZM195.464 221.973C195.464 228.807 191.894 232.224 184.754 232.224C181.286 232.224 178.532 231.408 176.492 229.776C174.452 228.042 173.432 225.441 173.432 221.973V163.68C173.432 160.416 174.248 157.866 175.88 156.03C177.614 154.194 180.47 153.276 184.448 153.276C188.426 153.276 191.231 154.194 192.863 156.03C194.597 157.866 195.464 160.416 195.464 163.68V221.973ZM228.501 232.224C224.523 232.224 221.667 231.102 219.933 228.858C218.301 226.512 217.485 223.605 217.485 220.137V173.013H215.343C207.795 173.013 204.021 169.953 204.021 163.833C204.021 157.611 207.795 154.5 215.343 154.5H217.485V153.888C217.485 142.056 220.035 132.519 225.135 125.277C230.235 118.035 238.038 114.414 248.544 114.414C248.646 114.414 248.748 114.414 248.85 114.414H249.003C249.309 114.414 249.615 114.414 249.921 114.414C258.285 114.516 264.354 115.689 268.128 117.933C272.004 120.177 273.942 123.594 273.942 128.184C273.942 131.55 273.024 133.794 271.188 134.916C269.454 135.936 267.006 136.446 263.844 136.446C261.09 136.446 258.744 136.242 256.806 135.834C254.97 135.324 252.573 134.967 249.615 134.763H248.697C244.617 134.763 241.965 136.344 240.741 139.506C239.517 142.566 238.905 146.34 238.905 150.828V154.5H247.626C254.562 154.5 258.03 157.611 258.03 163.833C258.03 169.953 254.562 173.013 247.626 173.013H238.905V220.137C238.905 224.319 237.987 227.379 236.151 229.317C234.417 231.255 231.867 232.224 228.501 232.224ZM288.715 232.224C284.737 232.224 281.881 231.102 280.147 228.858C278.515 226.512 277.699 223.605 277.699 220.137V173.013H275.557C268.009 173.013 264.235 169.953 264.235 163.833C264.235 157.611 268.009 154.5 275.557 154.5H277.699V153.888C277.699 142.056 280.249 132.519 285.349 125.277C290.449 118.035 298.252 114.414 308.758 114.414C308.86 114.414 308.962 114.414 309.064 114.414H309.217C309.523 114.414 309.829 114.414 310.135 114.414C318.499 114.516 324.568 115.689 328.342 117.933C332.218 120.177 334.156 123.594 334.156 128.184C334.156 131.55 333.238 133.794 331.402 134.916C329.668 135.936 327.22 136.446 324.058 136.446C321.304 136.446 318.958 136.242 317.02 135.834C315.184 135.324 312.787 134.967 309.829 134.763H308.911C304.831 134.763 302.179 136.344 300.955 139.506C299.731 142.566 299.119 146.34 299.119 150.828V154.5H307.84C314.776 154.5 318.244 157.611 318.244 163.833C318.244 169.953 314.776 173.013 307.84 173.013H299.119V220.137C299.119 224.319 298.201 227.379 296.365 229.317C294.631 231.255 292.081 232.224 288.715 232.224Z"/>
12
+ </svg>
@@ -0,0 +1,15 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 411 395">
2
+ <style>
3
+ .bubble { fill: black }
4
+ .symbol { fill: white }
5
+ @media (prefers-color-scheme: dark) {
6
+ .bubble { fill: white }
7
+ .symbol { fill: black }
8
+ }
9
+ </style>
10
+ <path class="bubble" d="M0.0505394 178.855C6.76429 71.9657 97.6316 0 205.839 0C315.66 0 410.839 71.6797 410.839 166.889C410.839 253.623 331.853 320.83 234.765 332.109L262.001 369.673C265.392 374.351 265.873 380.536 263.247 385.684C260.621 390.831 255.332 394.072 249.553 394.072C185.518 394.072 123.058 374.827 76.4811 338.337C29.5845 301.595 -0.929973 247.475 0.0216367 179.605C0.0253267 179.355 0.0347544 179.105 0.0505394 178.855Z"/>
11
+ <g transform="translate(-12, 0)">
12
+ <path class="symbol" d="M166.35 159.819C160.895 159.819 156.496 158.51 153.152 155.893C149.984 153.101 148.401 149.001 148.401 143.592C148.401 138.008 149.984 133.908 153.152 131.291C156.496 128.673 160.895 127.365 166.35 127.365H200.666V109.829C200.666 104.245 201.986 99.8831 204.626 96.7424C207.442 93.6016 211.577 92.0312 217.032 92.0312C222.664 92.0312 226.799 93.6016 229.439 96.7424C232.079 99.8831 233.399 104.245 233.399 109.829V127.365H267.451C273.082 127.365 277.481 128.673 280.649 131.291C283.817 133.908 285.401 138.008 285.401 143.592C285.401 149.001 283.817 153.101 280.649 155.893C277.481 158.51 273.082 159.819 267.451 159.819H233.399V180.234C233.399 185.643 232.079 190.005 229.439 193.32C226.799 196.461 222.664 198.031 217.032 198.031C211.577 198.031 207.442 196.461 204.626 193.32C201.986 190.005 200.666 185.643 200.666 180.234V159.819H166.35Z"/>
13
+ <path class="symbol" d="M154.152 243.16C157.496 245.741 161.895 247.031 167.35 247.031H268.451C274.082 247.031 278.481 245.741 281.649 243.16C284.817 240.408 286.401 236.365 286.401 231.031C286.401 225.526 284.817 221.483 281.649 218.902C278.481 216.322 274.082 215.031 268.451 215.031H167.35C161.895 215.031 157.496 216.322 154.152 218.902C150.984 221.483 149.401 225.526 149.401 231.031C149.401 236.365 150.984 240.408 154.152 243.16Z"/>
14
+ </g>
15
+ </svg>
@@ -0,0 +1,14 @@
1
+ import { useSearchParams } from './hooks/use-search-params';
2
+ import { DiffPage } from './components/diff-page';
3
+
4
+ export function App() {
5
+ const { ref, theme, view } = useSearchParams();
6
+
7
+ return (
8
+ <DiffPage
9
+ refParam={ref ?? 'work'}
10
+ initialTheme={theme}
11
+ initialViewMode={view}
12
+ />
13
+ );
14
+ }
@@ -0,0 +1,78 @@
1
+ import type { Comment } from '../types/comment';
2
+ import { TrashIcon } from './icons/trash-icon';
3
+ import { MarkdownContent } from './markdown-content';
4
+
5
+ interface CommentBubbleProps {
6
+ comment: Comment;
7
+ onDelete: () => void;
8
+ }
9
+
10
+ function formatRelativeTime(dateStr: string): string {
11
+ const date = new Date(dateStr);
12
+ const now = new Date();
13
+ const diffMs = now.getTime() - date.getTime();
14
+ const diffSec = Math.floor(diffMs / 1000);
15
+ const diffMin = Math.floor(diffSec / 60);
16
+ const diffHr = Math.floor(diffMin / 60);
17
+ const diffDays = Math.floor(diffHr / 24);
18
+
19
+ if (diffSec < 60) {
20
+ return 'just now';
21
+ }
22
+ if (diffMin < 60) {
23
+ return `${diffMin}m ago`;
24
+ }
25
+ if (diffHr < 24) {
26
+ return `${diffHr}h ago`;
27
+ }
28
+ if (diffDays < 30) {
29
+ return `${diffDays}d ago`;
30
+ }
31
+ return date.toLocaleDateString();
32
+ }
33
+
34
+ function AuthorAvatar(props: { name: string; avatarUrl?: string; type: 'user' | 'agent' }) {
35
+ const { name, avatarUrl, type } = props;
36
+
37
+ if (avatarUrl) {
38
+ return (
39
+ <img src={avatarUrl} alt={name} className="w-5 h-5 rounded-full" />
40
+ );
41
+ }
42
+
43
+ const bgColor = type === 'agent' ? 'bg-accent' : 'bg-text-muted';
44
+ const initial = name.charAt(0).toUpperCase();
45
+
46
+ return (
47
+ <div className={`w-5 h-5 rounded-full ${bgColor} flex items-center justify-center text-white text-[10px] font-medium`}>
48
+ {initial}
49
+ </div>
50
+ );
51
+ }
52
+
53
+ export function CommentBubble(props: CommentBubbleProps) {
54
+ const { comment, onDelete } = props;
55
+
56
+ return (
57
+ <div className="px-3 py-2 border-b border-border last:border-b-0 group">
58
+ <div className="flex items-center gap-2 mb-1">
59
+ <AuthorAvatar name={comment.author.name} avatarUrl={comment.author.avatarUrl} type={comment.author.type} />
60
+ <span className="text-xs font-semibold text-text">{comment.author.name}</span>
61
+ {comment.author.type === 'agent' && (
62
+ <span className="text-[10px] px-1.5 py-0.5 rounded-full bg-accent/15 text-accent font-medium">bot</span>
63
+ )}
64
+ <span className="text-[11px] text-text-muted">{formatRelativeTime(comment.createdAt)}</span>
65
+ <button
66
+ onClick={onDelete}
67
+ className="ml-auto text-text-muted hover:text-deleted opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
68
+ title="Delete comment"
69
+ >
70
+ <TrashIcon className="w-3.5 h-3.5" />
71
+ </button>
72
+ </div>
73
+ <div className="text-sm text-text pl-7">
74
+ <MarkdownContent content={comment.body} />
75
+ </div>
76
+ </div>
77
+ );
78
+ }
@@ -0,0 +1,58 @@
1
+ import type { CommentAuthor, CommentSide } from '../types/comment';
2
+ import { CommentForm } from './comment-form';
3
+
4
+ interface CommentFormRowProps {
5
+ colSpan: number;
6
+ filePath: string;
7
+ side: CommentSide;
8
+ startLine: number;
9
+ endLine: number;
10
+ currentAuthor: CommentAuthor;
11
+ onSubmit: (filePath: string, side: CommentSide, startLine: number, endLine: number, body: string, author: CommentAuthor) => void;
12
+ onCancel: () => void;
13
+ viewMode?: 'unified' | 'split';
14
+ }
15
+
16
+ export function CommentFormRow(props: CommentFormRowProps) {
17
+ const { colSpan, filePath, side, startLine, endLine, currentAuthor, onSubmit, onCancel, viewMode } = props;
18
+
19
+ const lineLabel = startLine === endLine
20
+ ? `${startLine}`
21
+ : `${startLine} to ${endLine}`;
22
+
23
+ const formContent = (
24
+ <div className="max-w-[700px]">
25
+ <CommentForm
26
+ onSubmit={(body) => onSubmit(filePath, side, startLine, endLine, body, currentAuthor)}
27
+ onCancel={onCancel}
28
+ lineLabel={`Add a comment on line${startLine !== endLine ? 's' : ''} ${lineLabel}`}
29
+ />
30
+ </div>
31
+ );
32
+
33
+ if (viewMode === 'split') {
34
+ return (
35
+ <tr>
36
+ {side === 'old' ? (
37
+ <>
38
+ <td colSpan={colSpan} className="px-4 py-3 bg-bg-secondary">{formContent}</td>
39
+ <td colSpan={colSpan} className="bg-bg-secondary"></td>
40
+ </>
41
+ ) : (
42
+ <>
43
+ <td colSpan={colSpan} className="bg-bg-secondary"></td>
44
+ <td colSpan={colSpan} className="px-4 py-3 bg-bg-secondary border-l border-border">{formContent}</td>
45
+ </>
46
+ )}
47
+ </tr>
48
+ );
49
+ }
50
+
51
+ return (
52
+ <tr>
53
+ <td colSpan={colSpan} className="px-4 py-3 bg-bg-secondary">
54
+ {formContent}
55
+ </td>
56
+ </tr>
57
+ );
58
+ }
@@ -0,0 +1,78 @@
1
+ import { useState, useRef, useEffect } from 'react';
2
+
3
+ interface CommentFormProps {
4
+ onSubmit: (body: string) => void;
5
+ onCancel: () => void;
6
+ placeholder?: string;
7
+ submitLabel?: string;
8
+ autoFocus?: boolean;
9
+ lineLabel?: string;
10
+ }
11
+
12
+ export function CommentForm(props: CommentFormProps) {
13
+ const { onSubmit, onCancel, placeholder = 'Leave a comment', submitLabel = 'Comment', autoFocus = true, lineLabel } = props;
14
+ const [body, setBody] = useState('');
15
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
16
+
17
+ useEffect(() => {
18
+ if (autoFocus && textareaRef.current) {
19
+ textareaRef.current.focus();
20
+ }
21
+ }, [autoFocus]);
22
+
23
+ const handleSubmit = () => {
24
+ const trimmed = body.trim();
25
+ if (!trimmed) {
26
+ return;
27
+ }
28
+ onSubmit(trimmed);
29
+ setBody('');
30
+ };
31
+
32
+ const handleKeyDown = (e: React.KeyboardEvent) => {
33
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
34
+ e.preventDefault();
35
+ handleSubmit();
36
+ return;
37
+ }
38
+ if (e.key === 'Escape') {
39
+ e.preventDefault();
40
+ onCancel();
41
+ }
42
+ };
43
+
44
+ return (
45
+ <div className="border border-border rounded-lg overflow-hidden bg-bg">
46
+ {lineLabel && (
47
+ <div className="px-3 py-1.5 bg-bg-secondary border-b border-border">
48
+ <span className="text-xs text-text-secondary font-medium">{lineLabel}</span>
49
+ </div>
50
+ )}
51
+ <textarea
52
+ ref={textareaRef}
53
+ value={body}
54
+ onChange={(e) => setBody(e.target.value)}
55
+ onKeyDown={handleKeyDown}
56
+ placeholder={placeholder}
57
+ rows={3}
58
+ className="w-full px-3 py-2 text-sm bg-bg text-text resize-y outline-none placeholder:text-text-muted min-h-[80px] font-mono"
59
+ />
60
+ <div className="flex items-center gap-2 px-3 py-2 bg-bg-secondary border-t border-border">
61
+ <div className="flex-1" />
62
+ <button
63
+ onClick={onCancel}
64
+ className="px-3 py-1.5 text-xs font-medium rounded-md border border-border text-text-secondary hover:bg-hover transition-colors cursor-pointer"
65
+ >
66
+ Cancel
67
+ </button>
68
+ <button
69
+ onClick={handleSubmit}
70
+ disabled={!body.trim()}
71
+ className="px-3 py-1.5 text-xs font-medium rounded-md bg-accent text-white hover:bg-accent-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
72
+ >
73
+ {submitLabel}
74
+ </button>
75
+ </div>
76
+ </div>
77
+ );
78
+ }
@@ -0,0 +1,60 @@
1
+ import { cn } from '../lib/cn';
2
+ import { PlusIcon } from './icons/plus-icon';
3
+
4
+ interface CommentLineNumberProps {
5
+ lineNumber: number | null;
6
+ className?: string;
7
+ isSelected?: boolean;
8
+ onMouseDown?: () => void;
9
+ onMouseEnter?: () => void;
10
+ onCommentClick?: () => void;
11
+ showCommentButton?: boolean;
12
+ forceShowButton?: boolean;
13
+ }
14
+
15
+ const baseClass = 'w-12.5 min-w-12.5 px-2 text-right text-text-muted select-none cursor-pointer align-top text-xs leading-6 relative group/line';
16
+
17
+ export function CommentLineNumber(props: CommentLineNumberProps) {
18
+ const { lineNumber, className, isSelected, onMouseDown, onMouseEnter, onCommentClick, showCommentButton, forceShowButton } = props;
19
+
20
+ return (
21
+ <td
22
+ className={cn(
23
+ baseClass,
24
+ className,
25
+ isSelected && 'bg-diff-comment-gutter',
26
+ )}
27
+ onMouseDown={(e) => {
28
+ if (onMouseDown && lineNumber !== null) {
29
+ e.preventDefault();
30
+ onMouseDown();
31
+ }
32
+ }}
33
+ onMouseEnter={() => {
34
+ if (onMouseEnter && lineNumber !== null) {
35
+ onMouseEnter();
36
+ }
37
+ }}
38
+ >
39
+ {showCommentButton && lineNumber !== null && (
40
+ <button
41
+ className={cn(
42
+ 'absolute right-[-2px] top-0.5 w-5 h-5 flex items-center justify-center rounded bg-accent text-white cursor-pointer z-10 hover:bg-accent-hover',
43
+ forceShowButton ? 'opacity-100' : 'opacity-0 group-hover/row:opacity-100 group-hover/line:opacity-100',
44
+ )}
45
+ onClick={(e) => {
46
+ e.stopPropagation();
47
+ e.preventDefault();
48
+ if (onCommentClick) {
49
+ onCommentClick();
50
+ }
51
+ }}
52
+ title="Add comment"
53
+ >
54
+ <PlusIcon className="w-2.5 h-2.5" />
55
+ </button>
56
+ )}
57
+ {lineNumber ?? ''}
58
+ </td>
59
+ );
60
+ }
@@ -0,0 +1,209 @@
1
+ import { useState } from 'react';
2
+ import type { CommentThread as CommentThreadType } from '../types/comment';
3
+ import type { CommentAuthor, CommentSide } from '../types/comment';
4
+ import { isThreadResolved } from '../types/comment';
5
+ import { CommentForm } from './comment-form';
6
+ import { CommentBubble } from './comment-bubble';
7
+ import { TrashIcon } from './icons/trash-icon';
8
+ import { CommentIcon } from './icons/comment-icon';
9
+ import { ThreadBadge } from './ui/thread-badge';
10
+ import { cn } from '../lib/cn';
11
+
12
+ interface CommentThreadProps {
13
+ thread: CommentThreadType;
14
+ onReply: (threadId: string, body: string, author: CommentAuthor) => void;
15
+ onResolve: (threadId: string) => void;
16
+ onUnresolve: (threadId: string) => void;
17
+ onDeleteComment: (threadId: string, commentId: string) => void;
18
+ onDeleteThread: (threadId: string) => void;
19
+ currentAuthor: CommentAuthor;
20
+ colSpan: number;
21
+ viewMode?: 'unified' | 'split';
22
+ side?: CommentSide;
23
+ currentCode?: string;
24
+ }
25
+
26
+ function StatusBadge(props: { status: string }) {
27
+ const { status } = props;
28
+
29
+ switch (status) {
30
+ case 'resolved':
31
+ return <ThreadBadge variant='resolved' />;
32
+ case 'dismissed':
33
+ return <ThreadBadge variant='dismissed' />;
34
+ default:
35
+ return null;
36
+ }
37
+ }
38
+
39
+ export function CommentThread(props: CommentThreadProps) {
40
+ const {
41
+ thread,
42
+ onReply,
43
+ onResolve,
44
+ onUnresolve,
45
+ onDeleteComment,
46
+ onDeleteThread,
47
+ currentAuthor,
48
+ colSpan,
49
+ viewMode,
50
+ side,
51
+ currentCode,
52
+ } = props;
53
+ const [showReply, setShowReply] = useState(false);
54
+ const [isCollapsed, setIsCollapsed] = useState(isThreadResolved(thread));
55
+
56
+ const isOutdated =
57
+ thread.anchorContent && currentCode && thread.anchorContent !== currentCode;
58
+
59
+ if (isCollapsed) {
60
+ const collapsedContent = (
61
+ <td colSpan={colSpan} className='px-4 py-2 border-l border-border'>
62
+ <button
63
+ onClick={() => setIsCollapsed(false)}
64
+ className='thread-card flex items-center gap-2 text-xs text-text-muted hover:text-text-secondary transition-colors cursor-pointer rounded'
65
+ >
66
+ <CommentIcon className='w-4 h-4' />
67
+ <span>
68
+ {thread.comments.length} comment
69
+ {thread.comments.length !== 1 ? 's' : ''}
70
+ </span>
71
+ <StatusBadge status={thread.status} />
72
+ {isOutdated && <ThreadBadge variant='outdated' />}
73
+ </button>
74
+ </td>
75
+ );
76
+
77
+ if (viewMode === 'split') {
78
+ return (
79
+ <tr data-thread-id={thread.id}>
80
+ {side === 'old' ? (
81
+ <>
82
+ {collapsedContent}
83
+ <td colSpan={colSpan}></td>
84
+ </>
85
+ ) : (
86
+ <>
87
+ <td colSpan={colSpan}></td>
88
+ {collapsedContent}
89
+ </>
90
+ )}
91
+ </tr>
92
+ );
93
+ }
94
+
95
+ return <tr data-thread-id={thread.id}>{collapsedContent}</tr>;
96
+ }
97
+
98
+ const lineLabel =
99
+ thread.startLine === thread.endLine
100
+ ? `Line ${thread.startLine}`
101
+ : `Lines ${thread.startLine}–${thread.endLine}`;
102
+
103
+ const resolved = isThreadResolved(thread);
104
+
105
+ const threadContent = (
106
+ <td
107
+ colSpan={colSpan}
108
+ className={cn('px-4 py-3', {
109
+ 'border-l border-border': side === 'new' && viewMode === 'split',
110
+ })}
111
+ >
112
+ <div className='thread-card border border-border rounded-lg overflow-hidden max-w-[700px]'>
113
+ <div className='flex items-center justify-between px-3 py-1.5 bg-bg-secondary border-b border-border'>
114
+ <div className='flex items-center gap-2'>
115
+ <span className='text-[11px] text-text-muted font-mono'>
116
+ {lineLabel}
117
+ </span>
118
+ <StatusBadge status={thread.status} />
119
+ {isOutdated && <ThreadBadge variant='outdated' />}
120
+ </div>
121
+ <div className='flex items-center gap-1'>
122
+ {resolved ? (
123
+ <button
124
+ onClick={() => onUnresolve(thread.id)}
125
+ className='text-[11px] text-text-muted hover:text-text-secondary transition-colors cursor-pointer'
126
+ >
127
+ Reopen
128
+ </button>
129
+ ) : (
130
+ <button
131
+ onClick={() => {
132
+ onResolve(thread.id);
133
+ setIsCollapsed(true);
134
+ }}
135
+ className='text-[11px] text-text-muted hover:text-text-secondary transition-colors cursor-pointer'
136
+ >
137
+ Resolve
138
+ </button>
139
+ )}
140
+ <button
141
+ onClick={() => setIsCollapsed(true)}
142
+ className='text-[11px] text-text-muted hover:text-text-secondary transition-colors cursor-pointer ml-2'
143
+ >
144
+ Collapse
145
+ </button>
146
+ <button
147
+ onClick={() => onDeleteThread(thread.id)}
148
+ className='text-text-muted hover:text-deleted transition-colors cursor-pointer ml-1'
149
+ title='Delete thread'
150
+ >
151
+ <TrashIcon className='w-3.5 h-3.5' />
152
+ </button>
153
+ </div>
154
+ </div>
155
+ <div>
156
+ {thread.comments.map((comment) => (
157
+ <CommentBubble
158
+ key={comment.id}
159
+ comment={comment}
160
+ onDelete={() => onDeleteComment(thread.id, comment.id)}
161
+ />
162
+ ))}
163
+ </div>
164
+ {showReply ? (
165
+ <div className='px-3 py-2 border-t border-border'>
166
+ <CommentForm
167
+ onSubmit={(body) => {
168
+ onReply(thread.id, body, currentAuthor);
169
+ setShowReply(false);
170
+ }}
171
+ onCancel={() => setShowReply(false)}
172
+ placeholder='Reply...'
173
+ submitLabel='Reply'
174
+ />
175
+ </div>
176
+ ) : (
177
+ <div className='px-3 py-2 border-t border-border'>
178
+ <button
179
+ onClick={() => setShowReply(true)}
180
+ className='text-xs text-accent hover:text-accent-hover transition-colors cursor-pointer'
181
+ >
182
+ Reply
183
+ </button>
184
+ </div>
185
+ )}
186
+ </div>
187
+ </td>
188
+ );
189
+
190
+ if (viewMode === 'split') {
191
+ return (
192
+ <tr data-thread-id={thread.id}>
193
+ {side === 'old' ? (
194
+ <>
195
+ {threadContent}
196
+ <td colSpan={colSpan}></td>
197
+ </>
198
+ ) : (
199
+ <>
200
+ <td colSpan={colSpan}></td>
201
+ {threadContent}
202
+ </>
203
+ )}
204
+ </tr>
205
+ );
206
+ }
207
+
208
+ return <tr data-thread-id={thread.id}>{threadContent}</tr>;
209
+ }
@@ -0,0 +1,100 @@
1
+ import { useState, useCallback, useRef } from 'react';
2
+ import { type Commit, fetchCommits } from '../lib/api';
3
+
4
+ interface CommitListProps {
5
+ initialCommits: Commit[];
6
+ initialHasMore: boolean;
7
+ onCommitClick: (hash: string) => void;
8
+ }
9
+
10
+ export function CommitList(props: CommitListProps) {
11
+ const { initialCommits, initialHasMore, onCommitClick } = props;
12
+ const [commits, setCommits] = useState(initialCommits);
13
+ const [hasMore, setHasMore] = useState(initialHasMore);
14
+ const [loading, setLoading] = useState(false);
15
+ const [search, setSearch] = useState('');
16
+ const searchTimeout = useRef<ReturnType<typeof setTimeout>>(null);
17
+
18
+ const handleSearch = useCallback((value: string) => {
19
+ setSearch(value);
20
+
21
+ if (searchTimeout.current) {
22
+ clearTimeout(searchTimeout.current);
23
+ }
24
+
25
+ searchTimeout.current = setTimeout(async () => {
26
+ setLoading(true);
27
+ try {
28
+ const trimmed = value.trim();
29
+ const page = await fetchCommits(0, 10, trimmed || undefined);
30
+ setCommits(page.commits);
31
+ setHasMore(page.hasMore);
32
+ } finally {
33
+ setLoading(false);
34
+ }
35
+ }, 300);
36
+ }, []);
37
+
38
+ const loadMore = useCallback(async () => {
39
+ setLoading(true);
40
+ try {
41
+ const trimmed = search.trim();
42
+ const page = await fetchCommits(commits.length, 10, trimmed || undefined);
43
+ setCommits((prev) => [...prev, ...page.commits]);
44
+ setHasMore(page.hasMore);
45
+ } finally {
46
+ setLoading(false);
47
+ }
48
+ }, [commits.length, search]);
49
+
50
+ return (
51
+ <div>
52
+ <div className="px-4 py-2 border-b border-border">
53
+ <input
54
+ type="text"
55
+ value={search}
56
+ onChange={(e) => handleSearch(e.target.value)}
57
+ placeholder="Search commits..."
58
+ className="w-full text-sm bg-bg border border-border rounded-md px-3 py-1.5 text-text placeholder:text-text-muted focus:outline-none focus:border-accent"
59
+ />
60
+ </div>
61
+ {commits.length === 0 ? (
62
+ <p className="text-sm text-text-muted px-4 py-3">
63
+ {search ? 'No matching commits' : 'No recent commits'}
64
+ </p>
65
+ ) : (
66
+ <ul className="divide-y divide-border">
67
+ {commits.map((commit) => (
68
+ <li key={commit.hash}>
69
+ <button
70
+ onClick={() => onCommitClick(commit.hash)}
71
+ className="w-full text-left px-4 py-3 hover:bg-bg-tertiary transition-colors flex items-center gap-3"
72
+ >
73
+ <code className="text-xs font-mono text-accent shrink-0">
74
+ {commit.shortHash}
75
+ </code>
76
+ <span className="text-sm text-text truncate flex-1">
77
+ {commit.message}
78
+ </span>
79
+ <span className="text-xs text-text-muted shrink-0">
80
+ {commit.relativeDate}
81
+ </span>
82
+ </button>
83
+ </li>
84
+ ))}
85
+ </ul>
86
+ )}
87
+ {hasMore && (
88
+ <div className="px-4 py-3 border-t border-border">
89
+ <button
90
+ onClick={loadMore}
91
+ disabled={loading}
92
+ className="text-sm text-accent hover:text-accent/80 transition-colors disabled:opacity-50"
93
+ >
94
+ {loading ? 'Loading...' : 'Load more'}
95
+ </button>
96
+ </div>
97
+ )}
98
+ </div>
99
+ );
100
+ }