shelley/ui: switch patch tool diffs to @pierre/diffs library

Philip Zeyliger and Shelley created

Prompt: Can you use the @pierre/diffs library to render the diffs displayed in the patch tool? [...] Can you squash the "use diffs library" thing into one commit rebased on origin/main for me?

Replace the custom diff rendering in PatchTool with the @pierre/diffs
library which provides syntax-highlighted, split/unified diff views.

Changes:
- Add @pierre/diffs dependency
- Update PatchTool to use MultiFileDiff component
- Add language detection from file extension for syntax highlighting
- Support both split (side-by-side) and unified diff views with toggle
- Persist user's diff view preference in localStorage
- Auto-switch to unified view on mobile
- Add CSS containment properties for Safari rendering performance
  (content-visibility, contain) to optimize rendering of many diffs

Co-authored-by: Shelley <shelley@exe.dev>

Change summary

ui/package.json                 |   1 
ui/pnpm-lock.yaml               | 356 +++++++++++++++++++++
ui/src/components/PatchTool.tsx | 593 ++++++----------------------------
ui/src/styles.css               | 210 -----------
4 files changed, 471 insertions(+), 689 deletions(-)

Detailed changes

ui/package.json 🔗

@@ -20,6 +20,7 @@
     "test:e2e:debug": "pnpm run build && playwright test --debug"
   },
   "dependencies": {
+    "@pierre/diffs": "^1.0.9",
     "@xterm/addon-fit": "^0.11.0",
     "@xterm/addon-web-links": "^0.12.0",
     "@xterm/xterm": "^6.0.0",

ui/pnpm-lock.yaml 🔗

@@ -8,6 +8,9 @@ importers:
 
   .:
     dependencies:
+      '@pierre/diffs':
+        specifier: ^1.0.9
+        version: 1.0.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
       '@xterm/addon-fit':
         specifier: ^0.11.0
         version: 0.11.0
@@ -426,17 +429,53 @@ packages:
     resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
     engines: {node: '>=18.18'}
 
+  '@pierre/diffs@1.0.9':
+    resolution: {integrity: sha512-PiRhcAzz0yuifRTe2DmTsKOHQgGf1qgh5W9OVO3/c+DONrjo1PM5tquOxndCAZDTdPxwzGuWc6jPUaDrfu6nDg==}
+    peerDependencies:
+      react: ^18.3.1 || ^19.0.0
+      react-dom: ^18.3.1 || ^19.0.0
+
   '@playwright/test@1.57.0':
     resolution: {integrity: sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==}
     engines: {node: '>=18'}
     hasBin: true
 
+  '@shikijs/core@3.21.0':
+    resolution: {integrity: sha512-AXSQu/2n1UIQekY8euBJlvFYZIw0PHY63jUzGbrOma4wPxzznJXTXkri+QcHeBNaFxiiOljKxxJkVSoB3PjbyA==}
+
+  '@shikijs/engine-javascript@3.21.0':
+    resolution: {integrity: sha512-ATwv86xlbmfD9n9gKRiwuPpWgPENAWCLwYCGz9ugTJlsO2kOzhOkvoyV/UD+tJ0uT7YRyD530x6ugNSffmvIiQ==}
+
+  '@shikijs/engine-oniguruma@3.21.0':
+    resolution: {integrity: sha512-OYknTCct6qiwpQDqDdf3iedRdzj6hFlOPv5hMvI+hkWfCKs5mlJ4TXziBG9nyabLwGulrUjHiCq3xCspSzErYQ==}
+
+  '@shikijs/langs@3.21.0':
+    resolution: {integrity: sha512-g6mn5m+Y6GBJ4wxmBYqalK9Sp0CFkUqfNzUy2pJglUginz6ZpWbaWjDB4fbQ/8SHzFjYbtU6Ddlp1pc+PPNDVA==}
+
+  '@shikijs/themes@3.21.0':
+    resolution: {integrity: sha512-BAE4cr9EDiZyYzwIHEk7JTBJ9CzlPuM4PchfcA5ao1dWXb25nv6hYsoDiBq2aZK9E3dlt3WB78uI96UESD+8Mw==}
+
+  '@shikijs/transformers@3.21.0':
+    resolution: {integrity: sha512-CZwvCWWIiRRiFk9/JKzdEooakAP8mQDtBOQ1TKiCaS2E1bYtyBCOkUzS8akO34/7ufICQ29oeSfkb3tT5KtrhA==}
+
+  '@shikijs/types@3.21.0':
+    resolution: {integrity: sha512-zGrWOxZ0/+0ovPY7PvBU2gIS9tmhSUUt30jAcNV0Bq0gb2S98gwfjIs1vxlmH5zM7/4YxLamT6ChlqqAJmPPjA==}
+
+  '@shikijs/vscode-textmate@10.0.2':
+    resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
+
   '@types/estree@1.0.8':
     resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
 
+  '@types/hast@3.0.4':
+    resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
+
   '@types/json-schema@7.0.15':
     resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
 
+  '@types/mdast@4.0.4':
+    resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
+
   '@types/node@22.19.5':
     resolution: {integrity: sha512-HfF8+mYcHPcPypui3w3mvzuIErlNOh2OAG+BCeBZCEwyiD5ls2SiCwEyT47OELtf7M3nHxBdu0FsmzdKxkN52Q==}
 
@@ -451,6 +490,9 @@ packages:
   '@types/react@18.3.27':
     resolution: {integrity: sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==}
 
+  '@types/unist@3.0.3':
+    resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
+
   '@typescript-eslint/eslint-plugin@8.52.0':
     resolution: {integrity: sha512-okqtOgqu2qmZJ5iN4TWlgfF171dZmx2FzdOv2K/ixL2LZWDStL8+JgQerI2sa8eAEfoydG9+0V96m7V+P8yE1Q==}
     engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -510,6 +552,9 @@ packages:
     resolution: {integrity: sha512-ink3/Zofus34nmBsPjow63FP5M7IGff0RKAgqR6+CFpdk22M7aLwC9gOcLGYqr7MczLPzZVERW9hRog3O4n1sQ==}
     engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
 
+  '@ungap/structured-clone@1.3.0':
+    resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
+
   '@xterm/addon-fit@0.11.0':
     resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
 
@@ -600,10 +645,19 @@ packages:
     resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
     engines: {node: '>=6'}
 
+  ccount@2.0.1:
+    resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
+
   chalk@4.1.2:
     resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
     engines: {node: '>=10'}
 
+  character-entities-html4@2.1.0:
+    resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
+
+  character-entities-legacy@3.0.0:
+    resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==}
+
   color-convert@2.0.1:
     resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
     engines: {node: '>=7.0.0'}
@@ -611,6 +665,9 @@ packages:
   color-name@1.1.4:
     resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
 
+  comma-separated-tokens@2.0.3:
+    resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
+
   concat-map@0.0.1:
     resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
 
@@ -656,6 +713,17 @@ packages:
     resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
     engines: {node: '>= 0.4'}
 
+  dequal@2.0.3:
+    resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
+    engines: {node: '>=6'}
+
+  devlop@1.1.0:
+    resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
+
+  diff@8.0.2:
+    resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==}
+    engines: {node: '>=0.3.1'}
+
   doctrine@2.1.0:
     resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
     engines: {node: '>=0.10.0'}
@@ -883,6 +951,15 @@ packages:
     resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
     engines: {node: '>= 0.4'}
 
+  hast-util-to-html@9.0.5:
+    resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
+
+  hast-util-whitespace@3.0.0:
+    resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
+
+  html-void-elements@3.0.0:
+    resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
+
   ignore@5.3.2:
     resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
     engines: {node: '>= 4'}
@@ -1062,10 +1139,31 @@ packages:
     resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
     hasBin: true
 
+  lru_map@0.4.1:
+    resolution: {integrity: sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==}
+
   math-intrinsics@1.1.0:
     resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
     engines: {node: '>= 0.4'}
 
+  mdast-util-to-hast@13.2.1:
+    resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==}
+
+  micromark-util-character@2.1.1:
+    resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==}
+
+  micromark-util-encode@2.0.1:
+    resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==}
+
+  micromark-util-sanitize-uri@2.0.1:
+    resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==}
+
+  micromark-util-symbol@2.0.1:
+    resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==}
+
+  micromark-util-types@2.0.2:
+    resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==}
+
   minimatch@3.1.2:
     resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
 
@@ -1110,6 +1208,12 @@ packages:
     resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
     engines: {node: '>= 0.4'}
 
+  oniguruma-parser@0.12.1:
+    resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==}
+
+  oniguruma-to-es@4.3.4:
+    resolution: {integrity: sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==}
+
   optionator@0.9.4:
     resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
     engines: {node: '>= 0.8.0'}
@@ -1177,6 +1281,9 @@ packages:
   prop-types@15.8.1:
     resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
 
+  property-information@7.1.0:
+    resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
+
   punycode@2.3.1:
     resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
     engines: {node: '>=6'}
@@ -1200,6 +1307,15 @@ packages:
     resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
     engines: {node: '>= 0.4'}
 
+  regex-recursion@6.0.2:
+    resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
+
+  regex-utilities@2.3.0:
+    resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==}
+
+  regex@6.1.0:
+    resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
+
   regexp.prototype.flags@1.5.4:
     resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
     engines: {node: '>= 0.4'}
@@ -1265,6 +1381,9 @@ packages:
     resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
     engines: {node: '>=8'}
 
+  shiki@3.21.0:
+    resolution: {integrity: sha512-N65B/3bqL/TI2crrXr+4UivctrAGEjmsib5rPMMPpFp1xAx/w03v8WZ9RDDFYteXoEgY7qZ4HGgl5KBIu1153w==}
+
   side-channel-list@1.0.0:
     resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
     engines: {node: '>= 0.4'}
@@ -1281,6 +1400,9 @@ packages:
     resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
     engines: {node: '>= 0.4'}
 
+  space-separated-tokens@2.0.2:
+    resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
+
   stop-iteration-iterator@1.1.0:
     resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
     engines: {node: '>= 0.4'}
@@ -1307,6 +1429,9 @@ packages:
   string_decoder@1.1.1:
     resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
 
+  stringify-entities@4.0.4:
+    resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
+
   strip-json-comments@3.1.1:
     resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
     engines: {node: '>=8'}
@@ -1323,6 +1448,9 @@ packages:
     resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
     engines: {node: '>=12.0.0'}
 
+  trim-lines@3.0.1:
+    resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
+
   ts-api-utils@2.4.0:
     resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==}
     engines: {node: '>=18.12'}
@@ -1373,12 +1501,33 @@ packages:
   undici-types@6.21.0:
     resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
 
+  unist-util-is@6.0.1:
+    resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
+
+  unist-util-position@5.0.0:
+    resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==}
+
+  unist-util-stringify-position@4.0.0:
+    resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==}
+
+  unist-util-visit-parents@6.0.2:
+    resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==}
+
+  unist-util-visit@5.1.0:
+    resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==}
+
   uri-js@4.4.1:
     resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
 
   util-deprecate@1.0.2:
     resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
 
+  vfile-message@4.0.3:
+    resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
+
+  vfile@6.0.3:
+    resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
+
   which-boxed-primitive@1.1.1:
     resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
     engines: {node: '>= 0.4'}
@@ -1408,6 +1557,9 @@ packages:
     resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
     engines: {node: '>=10'}
 
+  zwitch@2.0.4:
+    resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
+
 snapshots:
 
   '@esbuild/aix-ppc64@0.19.12':
@@ -1614,14 +1766,72 @@ snapshots:
 
   '@humanwhocodes/retry@0.4.3': {}
 
+  '@pierre/diffs@1.0.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+    dependencies:
+      '@shikijs/core': 3.21.0
+      '@shikijs/engine-javascript': 3.21.0
+      '@shikijs/transformers': 3.21.0
+      diff: 8.0.2
+      hast-util-to-html: 9.0.5
+      lru_map: 0.4.1
+      react: 18.3.1
+      react-dom: 18.3.1(react@18.3.1)
+      shiki: 3.21.0
+
   '@playwright/test@1.57.0':
     dependencies:
       playwright: 1.57.0
 
+  '@shikijs/core@3.21.0':
+    dependencies:
+      '@shikijs/types': 3.21.0
+      '@shikijs/vscode-textmate': 10.0.2
+      '@types/hast': 3.0.4
+      hast-util-to-html: 9.0.5
+
+  '@shikijs/engine-javascript@3.21.0':
+    dependencies:
+      '@shikijs/types': 3.21.0
+      '@shikijs/vscode-textmate': 10.0.2
+      oniguruma-to-es: 4.3.4
+
+  '@shikijs/engine-oniguruma@3.21.0':
+    dependencies:
+      '@shikijs/types': 3.21.0
+      '@shikijs/vscode-textmate': 10.0.2
+
+  '@shikijs/langs@3.21.0':
+    dependencies:
+      '@shikijs/types': 3.21.0
+
+  '@shikijs/themes@3.21.0':
+    dependencies:
+      '@shikijs/types': 3.21.0
+
+  '@shikijs/transformers@3.21.0':
+    dependencies:
+      '@shikijs/core': 3.21.0
+      '@shikijs/types': 3.21.0
+
+  '@shikijs/types@3.21.0':
+    dependencies:
+      '@shikijs/vscode-textmate': 10.0.2
+      '@types/hast': 3.0.4
+
+  '@shikijs/vscode-textmate@10.0.2': {}
+
   '@types/estree@1.0.8': {}
 
+  '@types/hast@3.0.4':
+    dependencies:
+      '@types/unist': 3.0.3
+
   '@types/json-schema@7.0.15': {}
 
+  '@types/mdast@4.0.4':
+    dependencies:
+      '@types/unist': 3.0.3
+
   '@types/node@22.19.5':
     dependencies:
       undici-types: 6.21.0
@@ -1637,6 +1847,8 @@ snapshots:
       '@types/prop-types': 15.7.15
       csstype: 3.2.3
 
+  '@types/unist@3.0.3': {}
+
   '@typescript-eslint/eslint-plugin@8.52.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3)':
     dependencies:
       '@eslint-community/regexpp': 4.12.2
@@ -1728,6 +1940,8 @@ snapshots:
       '@typescript-eslint/types': 8.52.0
       eslint-visitor-keys: 4.2.1
 
+  '@ungap/structured-clone@1.3.0': {}
+
   '@xterm/addon-fit@0.11.0': {}
 
   '@xterm/addon-web-links@0.12.0': {}
@@ -1846,17 +2060,25 @@ snapshots:
 
   callsites@3.1.0: {}
 
+  ccount@2.0.1: {}
+
   chalk@4.1.2:
     dependencies:
       ansi-styles: 4.3.0
       supports-color: 7.2.0
 
+  character-entities-html4@2.1.0: {}
+
+  character-entities-legacy@3.0.0: {}
+
   color-convert@2.0.1:
     dependencies:
       color-name: 1.1.4
 
   color-name@1.1.4: {}
 
+  comma-separated-tokens@2.0.3: {}
+
   concat-map@0.0.1: {}
 
   core-util-is@1.0.3: {}
@@ -1905,6 +2127,14 @@ snapshots:
       has-property-descriptors: 1.0.2
       object-keys: 1.1.1
 
+  dequal@2.0.3: {}
+
+  devlop@1.1.0:
+    dependencies:
+      dequal: 2.0.3
+
+  diff@8.0.2: {}
+
   doctrine@2.1.0:
     dependencies:
       esutils: 2.0.3
@@ -2279,6 +2509,26 @@ snapshots:
     dependencies:
       function-bind: 1.1.2
 
+  hast-util-to-html@9.0.5:
+    dependencies:
+      '@types/hast': 3.0.4
+      '@types/unist': 3.0.3
+      ccount: 2.0.1
+      comma-separated-tokens: 2.0.3
+      hast-util-whitespace: 3.0.0
+      html-void-elements: 3.0.0
+      mdast-util-to-hast: 13.2.1
+      property-information: 7.1.0
+      space-separated-tokens: 2.0.2
+      stringify-entities: 4.0.4
+      zwitch: 2.0.4
+
+  hast-util-whitespace@3.0.0:
+    dependencies:
+      '@types/hast': 3.0.4
+
+  html-void-elements@3.0.0: {}
+
   ignore@5.3.2: {}
 
   ignore@7.0.5: {}
@@ -2470,8 +2720,39 @@ snapshots:
     dependencies:
       js-tokens: 4.0.0
 
+  lru_map@0.4.1: {}
+
   math-intrinsics@1.1.0: {}
 
+  mdast-util-to-hast@13.2.1:
+    dependencies:
+      '@types/hast': 3.0.4
+      '@types/mdast': 4.0.4
+      '@ungap/structured-clone': 1.3.0
+      devlop: 1.1.0
+      micromark-util-sanitize-uri: 2.0.1
+      trim-lines: 3.0.1
+      unist-util-position: 5.0.0
+      unist-util-visit: 5.1.0
+      vfile: 6.0.3
+
+  micromark-util-character@2.1.1:
+    dependencies:
+      micromark-util-symbol: 2.0.1
+      micromark-util-types: 2.0.2
+
+  micromark-util-encode@2.0.1: {}
+
+  micromark-util-sanitize-uri@2.0.1:
+    dependencies:
+      micromark-util-character: 2.1.1
+      micromark-util-encode: 2.0.1
+      micromark-util-symbol: 2.0.1
+
+  micromark-util-symbol@2.0.1: {}
+
+  micromark-util-types@2.0.2: {}
+
   minimatch@3.1.2:
     dependencies:
       brace-expansion: 1.1.12
@@ -2522,6 +2803,14 @@ snapshots:
       define-properties: 1.2.1
       es-object-atoms: 1.1.1
 
+  oniguruma-parser@0.12.1: {}
+
+  oniguruma-to-es@4.3.4:
+    dependencies:
+      oniguruma-parser: 0.12.1
+      regex: 6.1.0
+      regex-recursion: 6.0.2
+
   optionator@0.9.4:
     dependencies:
       deep-is: 0.1.4
@@ -2581,6 +2870,8 @@ snapshots:
       object-assign: 4.1.1
       react-is: 16.13.1
 
+  property-information@7.1.0: {}
+
   punycode@2.3.1: {}
 
   react-dom@18.3.1(react@18.3.1):
@@ -2616,6 +2907,16 @@ snapshots:
       get-proto: 1.0.1
       which-builtin-type: 1.2.1
 
+  regex-recursion@6.0.2:
+    dependencies:
+      regex-utilities: 2.3.0
+
+  regex-utilities@2.3.0: {}
+
+  regex@6.1.0:
+    dependencies:
+      regex-utilities: 2.3.0
+
   regexp.prototype.flags@1.5.4:
     dependencies:
       call-bind: 1.0.8
@@ -2694,6 +2995,17 @@ snapshots:
 
   shebang-regex@3.0.0: {}
 
+  shiki@3.21.0:
+    dependencies:
+      '@shikijs/core': 3.21.0
+      '@shikijs/engine-javascript': 3.21.0
+      '@shikijs/engine-oniguruma': 3.21.0
+      '@shikijs/langs': 3.21.0
+      '@shikijs/themes': 3.21.0
+      '@shikijs/types': 3.21.0
+      '@shikijs/vscode-textmate': 10.0.2
+      '@types/hast': 3.0.4
+
   side-channel-list@1.0.0:
     dependencies:
       es-errors: 1.3.0
@@ -2722,6 +3034,8 @@ snapshots:
       side-channel-map: 1.0.1
       side-channel-weakmap: 1.0.2
 
+  space-separated-tokens@2.0.2: {}
+
   stop-iteration-iterator@1.1.0:
     dependencies:
       es-errors: 1.3.0
@@ -2775,6 +3089,11 @@ snapshots:
     dependencies:
       safe-buffer: 5.1.2
 
+  stringify-entities@4.0.4:
+    dependencies:
+      character-entities-html4: 2.1.0
+      character-entities-legacy: 3.0.0
+
   strip-json-comments@3.1.1: {}
 
   supports-color@7.2.0:
@@ -2788,6 +3107,8 @@ snapshots:
       fdir: 6.5.0(picomatch@4.0.3)
       picomatch: 4.0.3
 
+  trim-lines@3.0.1: {}
+
   ts-api-utils@2.4.0(typescript@5.9.3):
     dependencies:
       typescript: 5.9.3
@@ -2858,12 +3179,45 @@ snapshots:
 
   undici-types@6.21.0: {}
 
+  unist-util-is@6.0.1:
+    dependencies:
+      '@types/unist': 3.0.3
+
+  unist-util-position@5.0.0:
+    dependencies:
+      '@types/unist': 3.0.3
+
+  unist-util-stringify-position@4.0.0:
+    dependencies:
+      '@types/unist': 3.0.3
+
+  unist-util-visit-parents@6.0.2:
+    dependencies:
+      '@types/unist': 3.0.3
+      unist-util-is: 6.0.1
+
+  unist-util-visit@5.1.0:
+    dependencies:
+      '@types/unist': 3.0.3
+      unist-util-is: 6.0.1
+      unist-util-visit-parents: 6.0.2
+
   uri-js@4.4.1:
     dependencies:
       punycode: 2.3.1
 
   util-deprecate@1.0.2: {}
 
+  vfile-message@4.0.3:
+    dependencies:
+      '@types/unist': 3.0.3
+      unist-util-stringify-position: 4.0.0
+
+  vfile@6.0.3:
+    dependencies:
+      '@types/unist': 3.0.3
+      vfile-message: 4.0.3
+
   which-boxed-primitive@1.1.1:
     dependencies:
       is-bigint: 1.1.0
@@ -2912,3 +3266,5 @@ snapshots:
   word-wrap@1.2.5: {}
 
   yocto-queue@0.1.0: {}
+
+  zwitch@2.0.4: {}

ui/src/components/PatchTool.tsx 🔗

@@ -1,21 +1,12 @@
-import React, { useState, useEffect, useRef, useCallback } from "react";
-import type * as Monaco from "monaco-editor";
+import React, { useState, useEffect, useCallback } from "react";
+import { MultiFileDiff } from "@pierre/diffs/react";
+import type { FileContents, SupportedLanguages, ThemeTypes, ThemesType } from "@pierre/diffs";
 import { LLMContent } from "../types";
 import { isDarkModeActive } from "../services/theme";
 
-// LocalStorage keys for preferences
-const STORAGE_KEY_MONACO_ENABLED = "shelley-use-monaco-diff";
+// LocalStorage key for side-by-side preference
 const STORAGE_KEY_SIDE_BY_SIDE = "shelley-diff-side-by-side";
 
-// Feature flag for Monaco diff view
-function useMonacoDiff(): boolean {
-  try {
-    return localStorage.getItem(STORAGE_KEY_MONACO_ENABLED) === "true";
-  } catch {
-    return false;
-  }
-}
-
 // Get saved side-by-side preference (default: true for desktop)
 function getSideBySidePreference(): boolean {
   try {
@@ -59,485 +50,121 @@ interface PatchToolProps {
   onCommentTextChange?: (text: string) => void;
 }
 
-// Global Monaco instance - loaded lazily
-let monacoInstance: typeof Monaco | null = null;
-let monacoLoadPromise: Promise<typeof Monaco> | null = null;
-
-function loadMonaco(): Promise<typeof Monaco> {
-  if (monacoInstance) {
-    return Promise.resolve(monacoInstance);
-  }
-  if (monacoLoadPromise) {
-    return monacoLoadPromise;
-  }
-
-  monacoLoadPromise = (async () => {
-    // Configure Monaco environment for web workers before importing
-    const monacoEnv: Monaco.Environment = {
-      getWorkerUrl: () => "/editor.worker.js",
-    };
-    (self as Window).MonacoEnvironment = monacoEnv;
-
-    // Load Monaco CSS if not already loaded
-    if (!document.querySelector('link[href="/monaco-editor.css"]')) {
-      const link = document.createElement("link");
-      link.rel = "stylesheet";
-      link.href = "/monaco-editor.css";
-      document.head.appendChild(link);
-    }
-
-    // Load Monaco from our local bundle (runtime URL, cast to proper types)
-    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
-    // @ts-ignore - dynamic runtime URL import
-    const monaco = (await import("/monaco-editor.js")) as typeof Monaco;
-    monacoInstance = monaco;
-    return monacoInstance;
-  })();
-
-  return monacoLoadPromise;
-}
-
-// Simple diff view component (default)
-function SimpleDiffView({ displayData }: { displayData: PatchDisplayData | null }) {
-  // Get diff text from displayData or fall back to empty
-  const diff = displayData?.diff || "";
-
-  // Parse unified diff to extract lines
-  const lines = diff ? diff.split("\n") : [];
-
-  return (
-    <pre className="patch-tool-diff">
-      {lines.map((line, idx) => {
-        // Determine line type for styling
-        let className = "patch-diff-line";
-        if (line.startsWith("+") && !line.startsWith("+++")) {
-          className += " patch-diff-addition";
-        } else if (line.startsWith("-") && !line.startsWith("---")) {
-          className += " patch-diff-deletion";
-        } else if (line.startsWith("@@")) {
-          className += " patch-diff-hunk";
-        } else if (line.startsWith("---") || line.startsWith("+++")) {
-          className += " patch-diff-header";
-        }
-
-        return (
-          <div key={idx} className={className}>
-            {line || " "}
-          </div>
-        );
-      })}
-    </pre>
-  );
+// Map file extension to language for syntax highlighting
+function getLanguageFromPath(path: string): SupportedLanguages {
+  const ext = path.split(".").pop()?.toLowerCase() || "";
+  const langMap: Record<string, SupportedLanguages> = {
+    ts: "typescript",
+    tsx: "tsx",
+    js: "javascript",
+    jsx: "jsx",
+    py: "python",
+    rb: "ruby",
+    go: "go",
+    rs: "rust",
+    java: "java",
+    c: "c",
+    cpp: "cpp",
+    h: "c",
+    hpp: "cpp",
+    cs: "csharp",
+    php: "php",
+    swift: "swift",
+    kt: "kotlin",
+    scala: "scala",
+    sh: "bash",
+    bash: "bash",
+    zsh: "bash",
+    fish: "fish",
+    ps1: "powershell",
+    sql: "sql",
+    html: "html",
+    htm: "html",
+    css: "css",
+    scss: "scss",
+    sass: "sass",
+    less: "less",
+    json: "json",
+    xml: "xml",
+    yaml: "yaml",
+    yml: "yaml",
+    toml: "toml",
+    ini: "ini",
+    md: "markdown",
+    markdown: "markdown",
+    txt: "text",
+    dockerfile: "dockerfile",
+    makefile: "makefile",
+    cmake: "cmake",
+    lua: "lua",
+    perl: "perl",
+    r: "r",
+    vue: "vue",
+    svelte: "svelte",
+    astro: "astro",
+  };
+  return langMap[ext] || "text";
 }
 
-// Monaco diff view component (feature-flagged)
-function MonacoDiffView({
+// Diff view component using @pierre/diffs
+function DiffView({
   displayData,
-  isMobile,
   sideBySide,
-  onCommentTextChange,
-  filename,
 }: {
   displayData: PatchDisplayData;
-  isMobile: boolean;
   sideBySide: boolean;
-  onCommentTextChange?: (text: string) => void;
-  filename: string;
 }) {
-  const [monacoLoaded, setMonacoLoaded] = useState(false);
-  const [isVisible, setIsVisible] = useState(false);
-  const [editorHeight, setEditorHeight] = useState<number>(100);
-  const [showCommentDialog, setShowCommentDialog] = useState<{
-    line: number;
-    selectedText?: string;
-  } | null>(null);
-  const [commentText, setCommentText] = useState("");
-
-  const containerRef = useRef<HTMLDivElement>(null);
-  const editorContainerRef = useRef<HTMLDivElement>(null);
-  const editorRef = useRef<Monaco.editor.IStandaloneDiffEditor | null>(null);
-  const monacoRef = useRef<typeof Monaco | null>(null);
-  const commentInputRef = useRef<HTMLTextAreaElement>(null);
-  const hoverDecorationsRef = useRef<string[]>([]);
-  const heightSetRef = useRef(false);
-  const modelsRef = useRef<{
-    original: Monaco.editor.ITextModel | null;
-    modified: Monaco.editor.ITextModel | null;
-  }>({
-    original: null,
-    modified: null,
-  });
-
-  // Intersection observer for lazy loading
-  useEffect(() => {
-    const container = containerRef.current;
-    if (!container) return;
-
-    const observer = new IntersectionObserver(
-      (entries) => {
-        for (const entry of entries) {
-          if (entry.isIntersecting) {
-            setIsVisible(true);
-            // Once visible, we don't need to observe anymore
-            observer.disconnect();
-          }
-        }
-      },
-      {
-        rootMargin: "100px", // Start loading a bit before it's visible
-        threshold: 0,
-      },
-    );
-
-    observer.observe(container);
-
-    return () => observer.disconnect();
-  }, []);
-
-  // Load Monaco only when visible
-  useEffect(() => {
-    if (!isVisible || monacoLoaded) return;
-
-    loadMonaco()
-      .then((monaco) => {
-        monacoRef.current = monaco;
-        setMonacoLoaded(true);
-      })
-      .catch((err) => {
-        console.error("Failed to load Monaco:", err);
-      });
-  }, [isVisible, monacoLoaded]);
-
-  // Update side-by-side mode when prop changes
-  useEffect(() => {
-    if (editorRef.current) {
-      editorRef.current.updateOptions({ renderSideBySide: sideBySide });
-      // Reset height flag to allow recalculation after mode change
-      heightSetRef.current = false;
-    }
-  }, [sideBySide]);
-
-  // Create Monaco editor when data is ready and visible
-  useEffect(() => {
-    if (!monacoLoaded || !isVisible || !editorContainerRef.current || !monacoRef.current) {
-      return;
-    }
-
-    const monaco = monacoRef.current;
-
-    // Dispose previous editor and models
-    if (editorRef.current) {
-      editorRef.current.dispose();
-      editorRef.current = null;
-    }
-    if (modelsRef.current.original) {
-      modelsRef.current.original.dispose();
-      modelsRef.current.original = null;
-    }
-    if (modelsRef.current.modified) {
-      modelsRef.current.modified.dispose();
-      modelsRef.current.modified = null;
-    }
-
-    // Reset height tracking for new editor
-    heightSetRef.current = false;
-
-    // Get language from file extension
-    const ext = "." + (displayData.path.split(".").pop()?.toLowerCase() || "");
-    const languages = monaco.languages.getLanguages();
-    let language = "plaintext";
-    for (const lang of languages) {
-      if (lang.extensions?.includes(ext)) {
-        language = lang.id;
-        break;
-      }
-    }
+  const [themeType, setThemeType] = useState<ThemeTypes>(isDarkModeActive() ? "dark" : "light");
 
-    // Create models with unique URIs (include timestamp to avoid conflicts)
-    const timestamp = Date.now();
-    const originalUri = monaco.Uri.file(`patch-original-${timestamp}-${displayData.path}`);
-    const modifiedUri = monaco.Uri.file(`patch-modified-${timestamp}-${displayData.path}`);
-
-    // Check for and dispose any existing models with these URIs (defensive, shouldn't happen)
-    const existingOriginal = monaco.editor.getModel(originalUri);
-    if (existingOriginal) existingOriginal.dispose();
-    const existingModified = monaco.editor.getModel(modifiedUri);
-    if (existingModified) existingModified.dispose();
-
-    const originalModel = monaco.editor.createModel(displayData.oldContent, language, originalUri);
-    const modifiedModel = monaco.editor.createModel(displayData.newContent, language, modifiedUri);
-    modelsRef.current = { original: originalModel, modified: modifiedModel };
-
-    // Create diff editor with collapsed unchanged regions
-    const diffEditor = monaco.editor.createDiffEditor(editorContainerRef.current, {
-      theme: isDarkModeActive() ? "vs-dark" : "vs",
-      readOnly: true,
-      originalEditable: false,
-      automaticLayout: true,
-      renderSideBySide: sideBySide,
-      enableSplitViewResizing: true,
-      renderIndicators: true,
-      renderMarginRevertIcon: false,
-      lineNumbers: isMobile ? "off" : "on",
-      minimap: { enabled: false },
-      scrollBeyondLastLine: false,
-      wordWrap: "on",
-      glyphMargin: !isMobile, // Enable glyph margin for comment indicator
-      lineDecorationsWidth: isMobile ? 0 : 10,
-      lineNumbersMinChars: isMobile ? 0 : 3,
-      quickSuggestions: false,
-      suggestOnTriggerCharacters: false,
-      lightbulb: { enabled: false },
-      codeLens: false,
-      contextmenu: false,
-      links: false,
-      folding: !isMobile,
-      // Hide unchanged regions to show only edited sections
-      hideUnchangedRegions: {
-        enabled: true,
-        revealLineCount: 2, // Show 2 lines of context around changes
-        minimumLineCount: 3, // Hide regions with 3+ unchanged lines
-        contextLineCount: 2, // Context lines to show when expanding
-      },
-      // Disable scrollbar when content fits
-      scrollbar: {
-        vertical: "auto",
-        horizontal: "auto",
-        alwaysConsumeMouseWheel: false,
-      },
-    });
-
-    diffEditor.setModel({
-      original: originalModel,
-      modified: modifiedModel,
-    });
-
-    editorRef.current = diffEditor;
-
-    // Function to update height - only do this once to avoid scroll disruption
-    const updateHeight = () => {
-      if (heightSetRef.current) return;
-
-      const modifiedEditor = diffEditor.getModifiedEditor();
-      const contentHeight = modifiedEditor.getContentHeight();
-
-      if (contentHeight > 0) {
-        // Add small buffer, no max height - let it expand fully
-        const newHeight = Math.max(60, contentHeight + 4);
-        heightSetRef.current = true;
-        setEditorHeight(newHeight);
-      }
-    };
-
-    // Update height after diff is computed
-    // Monaco needs time to compute the diff and layout
-    const heightUpdateTimer = setTimeout(updateHeight, 200);
-
-    // Also listen for content size change (fires when diff is computed)
-    const modifiedEditor = diffEditor.getModifiedEditor();
-    const contentSizeDisposable = modifiedEditor.onDidContentSizeChange(() => {
-      updateHeight();
-    });
-
-    // Add click handler for commenting if callback is provided
-    if (onCommentTextChange) {
-      const openCommentDialog = (lineNumber: number) => {
-        const model = modifiedEditor.getModel();
-        const selection = modifiedEditor.getSelection();
-        let selectedText = "";
-
-        if (selection && !selection.isEmpty() && model) {
-          selectedText = model.getValueInRange(selection);
-        } else if (model) {
-          selectedText = model.getLineContent(lineNumber) || "";
-        }
-
-        setShowCommentDialog({
-          line: lineNumber,
-          selectedText,
-        });
-      };
-
-      modifiedEditor.onMouseDown((e: Monaco.editor.IEditorMouseEvent) => {
-        const isLineClick =
-          e.target.type === monaco.editor.MouseTargetType.CONTENT_TEXT ||
-          e.target.type === monaco.editor.MouseTargetType.CONTENT_EMPTY;
-
-        if (isLineClick) {
-          const position = e.target.position;
-          if (position) {
-            openCommentDialog(position.lineNumber);
-          }
-        }
-      });
-
-      // Add hover highlighting with comment indicator
-      let lastHoveredLine = -1;
-      modifiedEditor.onMouseMove((e: Monaco.editor.IEditorMouseEvent) => {
-        const position = e.target.position;
-        const lineNumber = position?.lineNumber ?? -1;
-
-        if (lineNumber === lastHoveredLine) return;
-        lastHoveredLine = lineNumber;
-
-        if (lineNumber > 0) {
-          hoverDecorationsRef.current = modifiedEditor.deltaDecorations(
-            hoverDecorationsRef.current,
-            [
-              {
-                range: new monaco.Range(lineNumber, 1, lineNumber, 1),
-                options: {
-                  isWholeLine: true,
-                  className: "patch-line-hover",
-                  glyphMarginClassName: "patch-comment-glyph",
-                },
-              },
-            ],
-          );
-        } else {
-          // Clear decorations when not hovering a line
-          hoverDecorationsRef.current = modifiedEditor.deltaDecorations(
-            hoverDecorationsRef.current,
-            [],
-          );
-        }
-      });
-
-      // Clear decorations when mouse leaves editor
-      modifiedEditor.onMouseLeave(() => {
-        lastHoveredLine = -1;
-        hoverDecorationsRef.current = modifiedEditor.deltaDecorations(
-          hoverDecorationsRef.current,
-          [],
-        );
-      });
-    }
-
-    // Cleanup function
-    return () => {
-      clearTimeout(heightUpdateTimer);
-      contentSizeDisposable.dispose();
-      if (editorRef.current) {
-        editorRef.current.dispose();
-        editorRef.current = null;
-      }
-      if (modelsRef.current.original) {
-        modelsRef.current.original.dispose();
-        modelsRef.current.original = null;
-      }
-      if (modelsRef.current.modified) {
-        modelsRef.current.modified.dispose();
-        modelsRef.current.modified = null;
-      }
-    };
-  }, [monacoLoaded, isVisible, displayData, isMobile, onCommentTextChange, sideBySide]);
-
-  // Update Monaco theme when dark mode changes
+  // Listen for theme changes
   useEffect(() => {
-    if (!monacoRef.current) return;
-
-    const updateMonacoTheme = () => {
-      const theme = isDarkModeActive() ? "vs-dark" : "vs";
-      monacoRef.current?.editor.setTheme(theme);
+    const updateTheme = () => {
+      setThemeType(isDarkModeActive() ? "dark" : "light");
     };
 
     const observer = new MutationObserver((mutations) => {
       for (const mutation of mutations) {
         if (mutation.attributeName === "class") {
-          updateMonacoTheme();
+          updateTheme();
         }
       }
     });
 
     observer.observe(document.documentElement, { attributes: true });
-
     return () => observer.disconnect();
-  }, [monacoLoaded]);
-
-  // Focus comment input when dialog opens
-  useEffect(() => {
-    if (showCommentDialog && commentInputRef.current) {
-      setTimeout(() => {
-        commentInputRef.current?.focus();
-      }, 50);
-    }
-  }, [showCommentDialog]);
+  }, []);
 
-  // Handle adding a comment
-  const handleAddComment = useCallback(() => {
-    if (!showCommentDialog || !commentText.trim() || !onCommentTextChange) return;
+  const lang = getLanguageFromPath(displayData.path);
 
-    const line = showCommentDialog.line;
-    const codeSnippet = showCommentDialog.selectedText?.split("\n")[0]?.trim() || "";
-    const truncatedCode =
-      codeSnippet.length > 60 ? codeSnippet.substring(0, 57) + "..." : codeSnippet;
+  const oldFile: FileContents = {
+    name: displayData.path,
+    contents: displayData.oldContent,
+    lang,
+  };
 
-    const commentBlock = `> ${filename}:${line}: ${truncatedCode}\n${commentText}\n\n`;
+  const newFile: FileContents = {
+    name: displayData.path,
+    contents: displayData.newContent,
+    lang,
+  };
 
-    onCommentTextChange(commentBlock);
-    setShowCommentDialog(null);
-    setCommentText("");
-  }, [showCommentDialog, commentText, onCommentTextChange, filename]);
+  const theme: ThemesType = {
+    dark: "github-dark",
+    light: "github-light",
+  };
 
   return (
-    <div ref={containerRef} className="patch-tool-monaco-container">
-      {/* Monaco editor container */}
-      {!isVisible ? (
-        <div className="patch-tool-monaco-placeholder" style={{ height: "100px" }}>
-          <span>Scroll to load diff...</span>
-        </div>
-      ) : !monacoLoaded ? (
-        <div className="patch-tool-monaco-placeholder" style={{ height: "100px" }}>
-          <div className="spinner-small" />
-          <span>Loading editor...</span>
-        </div>
-      ) : (
-        <div
-          ref={editorContainerRef}
-          className="patch-tool-monaco-editor"
-          style={{ height: `${editorHeight}px`, width: "100%" }}
-        />
-      )}
-
-      {/* Comment dialog */}
-      {showCommentDialog && onCommentTextChange && (
-        <div className="patch-tool-comment-dialog">
-          <h4>Add Comment (Line {showCommentDialog.line})</h4>
-          {showCommentDialog.selectedText && (
-            <pre className="patch-tool-selected-text">{showCommentDialog.selectedText}</pre>
-          )}
-          <textarea
-            ref={commentInputRef}
-            value={commentText}
-            onChange={(e) => setCommentText(e.target.value)}
-            placeholder="Enter your comment..."
-            className="patch-tool-comment-input"
-            autoFocus
-            onKeyDown={(e) => {
-              if (e.key === "Escape") {
-                setShowCommentDialog(null);
-              } else if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
-                handleAddComment();
-              }
-            }}
-          />
-          <div className="patch-tool-comment-actions">
-            <button
-              onClick={() => setShowCommentDialog(null)}
-              className="patch-tool-btn patch-tool-btn-secondary"
-            >
-              Cancel
-            </button>
-            <button
-              onClick={handleAddComment}
-              className="patch-tool-btn patch-tool-btn-primary"
-              disabled={!commentText.trim()}
-            >
-              Add Comment
-            </button>
-          </div>
-        </div>
-      )}
+    <div className="patch-tool-diffs-container">
+      <MultiFileDiff
+        oldFile={oldFile}
+        newFile={newFile}
+        options={{
+          diffStyle: sideBySide ? "split" : "unified",
+          theme,
+          themeType,
+          disableFileHeader: true,
+        }}
+      />
     </div>
   );
 }
@@ -606,26 +233,21 @@ function DiffModeToggle({ sideBySide, onToggle }: { sideBySide: boolean; onToggl
   );
 }
 
-function PatchTool({
-  toolInput,
-  isRunning,
-  toolResult,
-  hasError,
-  display,
-  onCommentTextChange,
-}: PatchToolProps) {
+function PatchTool({ toolInput, isRunning, toolResult, hasError, display }: PatchToolProps) {
   // Default to collapsed for errors (since agents typically recover), expanded otherwise
   const [isExpanded, setIsExpanded] = useState(!hasError);
   const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
   const [sideBySide, setSideBySide] = useState(() => !isMobile && getSideBySidePreference());
 
-  // Check feature flag for Monaco diff view
-  const useMonaco = useMonacoDiff();
-
   // Track viewport size
   useEffect(() => {
     const handleResize = () => {
-      setIsMobile(window.innerWidth < 768);
+      const mobile = window.innerWidth < 768;
+      setIsMobile(mobile);
+      // Force unified view on mobile
+      if (mobile) {
+        setSideBySide(false);
+      }
     };
     window.addEventListener("resize", handleResize);
     return () => window.removeEventListener("resize", handleResize);
@@ -668,9 +290,8 @@ function PatchTool({
   // Extract filename from path or diff headers
   const filename = displayData?.path || path || "patch";
 
-  // Show toggle only for Monaco view on desktop when expanded and complete
-  const showDiffToggle =
-    useMonaco && !isMobile && isExpanded && isComplete && !hasError && displayData;
+  // Show toggle only on desktop when expanded and complete with diff data
+  const showDiffToggle = !isMobile && isExpanded && isComplete && !hasError && displayData;
 
   return (
     <div
@@ -718,17 +339,7 @@ function PatchTool({
         <div className="patch-tool-details">
           {isComplete && !hasError && displayData && (
             <div className="patch-tool-section">
-              {useMonaco ? (
-                <MonacoDiffView
-                  displayData={displayData}
-                  isMobile={isMobile}
-                  sideBySide={sideBySide}
-                  onCommentTextChange={onCommentTextChange}
-                  filename={filename}
-                />
-              ) : (
-                <SimpleDiffView displayData={displayData} />
-              )}
+              <DiffView displayData={displayData} sideBySide={sideBySide} />
             </div>
           )}
 

ui/src/styles.css 🔗

@@ -1122,6 +1122,8 @@ button {
   border-radius: 0.5rem;
   margin: 0.5rem 0;
   width: 100%;
+  /* Performance: isolate layout/paint for each patch tool */
+  contain: layout style;
 }
 
 .dark .patch-tool {
@@ -1326,179 +1328,20 @@ button {
   font-style: italic;
 }
 
-/* Patch Tool Monaco Container */
-.patch-tool-monaco-container {
-  display: flex;
-  flex-direction: column;
-  gap: 0.25rem;
-}
-
-/* Patch Tool Monaco Placeholder (for lazy loading) */
-.patch-tool-monaco-placeholder {
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  gap: 0.5rem;
-  background: var(--bg-tertiary);
-  border: 1px solid var(--border);
-  border-radius: 0.25rem;
-  color: var(--text-secondary);
-  font-size: 0.75rem;
-}
-
-.spinner-small {
-  width: 14px;
-  height: 14px;
-  border: 2px solid var(--border);
-  border-top-color: var(--primary);
-  border-radius: 50%;
-  animation: spin 0.8s linear infinite;
-}
-
-@keyframes spin {
-  to {
-    transform: rotate(360deg);
-  }
-}
-
-/* Patch Tool Monaco Editor */
-.patch-tool-monaco-editor {
-  border: 1px solid var(--border);
+/* Patch Tool Diffs Container */
+.patch-tool-diffs-container {
   border-radius: 0.25rem;
   overflow: hidden;
+  font-size: 0.8125rem;
+  /* Performance: allow browser to skip rendering off-screen diffs */
+  content-visibility: auto;
+  contain-intrinsic-size: auto 200px;
 }
 
-/* Monaco line hover highlighting (via decoration API) */
-.patch-tool-monaco-editor .monaco-editor .patch-line-hover {
-  background-color: rgba(37, 99, 235, 0.08) !important;
-}
-
-.dark .patch-tool-monaco-editor .monaco-editor .patch-line-hover {
-  background-color: rgba(96, 165, 250, 0.12) !important;
-}
-
-/* Comment indicator glyph in margin */
-.patch-tool-monaco-editor .monaco-editor .patch-comment-glyph {
-  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z'%3E%3C/path%3E%3Cline x1='9' y1='10' x2='15' y2='10'%3E%3C/line%3E%3C/svg%3E");
-  background-size: 14px 14px;
-  background-repeat: no-repeat;
-  background-position: center;
-  opacity: 0.6;
-  cursor: pointer;
-}
-
-.patch-tool-monaco-editor .monaco-editor .patch-comment-glyph:hover {
-  opacity: 1;
-}
-
-.dark .patch-tool-monaco-editor .monaco-editor .patch-comment-glyph {
-  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z'%3E%3C/path%3E%3Cline x1='9' y1='10' x2='15' y2='10'%3E%3C/line%3E%3C/svg%3E");
-}
-
-/* Make Monaco lines clickable with pointer cursor */
-.patch-tool-monaco-editor .monaco-editor .view-lines {
-  cursor: pointer;
-}
-
-/* Patch Tool Comment Dialog */
-.patch-tool-comment-dialog {
-  position: fixed;
-  top: 50%;
-  left: 50%;
-  transform: translate(-50%, -50%);
-  background: var(--bg-base);
-  border: 1px solid var(--border);
-  border-radius: 0.5rem;
-  padding: 1rem;
-  z-index: 1000;
-  max-width: 500px;
-  width: 90%;
-  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
-}
-
-.dark .patch-tool-comment-dialog {
-  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
-}
-
-.patch-tool-comment-dialog h4 {
-  margin: 0 0 0.75rem 0;
-  font-size: 0.875rem;
-  font-weight: 600;
-  color: var(--text-primary);
-}
-
-.patch-tool-selected-text {
-  font-family: var(--font-mono);
-  font-size: 0.75rem;
-  background: var(--bg-tertiary);
-  border: 1px solid var(--border);
-  border-radius: 0.25rem;
-  padding: 0.5rem;
-  margin: 0 0 0.75rem 0;
-  overflow-x: auto;
-  max-height: 100px;
-  white-space: pre;
-  color: var(--text-secondary);
-}
-
-.patch-tool-comment-input {
-  width: 100%;
-  min-height: 80px;
-  padding: 0.5rem;
-  border: 1px solid var(--border);
-  border-radius: 0.25rem;
-  background: var(--bg-base);
-  color: var(--text-primary);
-  font-family: inherit;
-  font-size: 0.875rem;
-  resize: vertical;
-  box-sizing: border-box;
-}
-
-.patch-tool-comment-input:focus {
-  outline: none;
-  border-color: var(--primary);
-  box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
-}
-
-.patch-tool-comment-actions {
-  display: flex;
-  gap: 0.5rem;
-  justify-content: flex-end;
-  margin-top: 0.75rem;
-}
-
-.patch-tool-btn {
-  padding: 0.5rem 1rem;
-  border-radius: 0.25rem;
-  font-size: 0.875rem;
-  font-weight: 500;
-  cursor: pointer;
-  border: none;
-  transition: background-color 0.15s;
-}
-
-.patch-tool-btn-primary {
-  background: var(--primary);
-  color: white;
-}
-
-.patch-tool-btn-primary:hover:not(:disabled) {
-  background: var(--primary-dark);
-}
-
-.patch-tool-btn-primary:disabled {
-  opacity: 0.5;
-  cursor: not-allowed;
-}
-
-.patch-tool-btn-secondary {
-  background: var(--bg-tertiary);
-  color: var(--text-primary);
-}
-
-.patch-tool-btn-secondary:hover {
-  background: var(--bg-secondary);
+/* Target the diffs-container custom element from @pierre/diffs */
+.patch-tool-diffs-container diffs-container {
+  display: block;
+  contain: content;
 }
 
 /* Screenshot Tool */
@@ -3942,35 +3785,6 @@ svg {
   .diff-viewer-comment-input {
     min-height: 60px;
   }
-
-  /* Patch Tool Monaco editor mobile styles - hide gutters completely */
-  .patch-tool-monaco-editor .monaco-editor .margin {
-    display: none !important;
-    width: 0 !important;
-  }
-
-  .patch-tool-monaco-editor .monaco-editor .margin-view-overlays {
-    display: none !important;
-    width: 0 !important;
-  }
-
-  .patch-tool-monaco-editor .monaco-editor .editor-scrollable {
-    left: 0 !important;
-  }
-
-  .patch-tool-monaco-editor .monaco-editor .lines-content {
-    margin-left: 0 !important;
-  }
-
-  /* Ensure patch tool diff editor uses full width */
-  .patch-tool-monaco-editor .monaco-diff-editor {
-    width: 100% !important;
-  }
-
-  .patch-tool-monaco-editor .monaco-diff-editor .editor.modified {
-    width: 100% !important;
-    left: 0 !important;
-  }
 }
 
 /* Injected text indicator (for diff comments) */