From ef018784c47dbe07a4a453d61fbe73aae2ca8b00 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Mon, 23 Mar 2026 22:22:59 +1000
Subject: [PATCH 01/25] Update markdown streaming

---
 ui/package.json                               |   4 +-
 ui/pnpm-lock.yaml                             | 220 +++++++++---------
 ui/src/components/ChatMessage/ChatMessage.tsx |  16 +-
 .../StreamingMarkdown/StreamingMarkdown.tsx   |   5 +-
 ui/src/index.css                              |  16 --
 5 files changed, 118 insertions(+), 143 deletions(-)

diff --git a/ui/package.json b/ui/package.json
index 1853c15c..1b68c78d 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -29,7 +29,7 @@
     "@jitl/quickjs-singlefile-browser-release-sync": "^0.31.0",
     "@mcp-ui/client": "^6.1.0",
     "@radix-ui/react-popover": "^1.1.15",
-    "@streamdown/code": "^1.0.3",
+    "@streamdown/code": "^1.1.1",
     "@streamdown/math": "^1.0.2",
     "@streamdown/mermaid": "^1.0.2",
     "@tanstack/react-query": "^5.90.21",
@@ -50,7 +50,7 @@
     "remark-gfm": "^4.0.1",
     "shiki": "^3.22.0",
     "sql.js": "^1.12.0",
-    "streamdown": "^2.3.0",
+    "streamdown": "^2.5.0",
     "tailwind-merge": "^3.5.0",
     "use-debounce": "^10.1.0",
     "vega": "^6.2.0",
diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml
index 89ef60de..bfa2a459 100644
--- a/ui/pnpm-lock.yaml
+++ b/ui/pnpm-lock.yaml
@@ -50,8 +50,8 @@ importers:
         specifier: ^1.1.15
         version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
       '@streamdown/code':
-        specifier: ^1.0.3
-        version: 1.0.3(react@19.2.4)
+        specifier: ^1.1.1
+        version: 1.1.1(react@19.2.4)
       '@streamdown/math':
         specifier: ^1.0.2
         version: 1.0.2(react@19.2.4)
@@ -113,8 +113,8 @@ importers:
         specifier: ^1.12.0
         version: 1.14.1
       streamdown:
-        specifier: ^2.3.0
-        version: 2.3.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+        specifier: ^2.5.0
+        version: 2.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
       tailwind-merge:
         specifier: ^3.5.0
         version: 3.5.0
@@ -148,19 +148,19 @@ importers:
         version: 10.2.11(storybook@10.2.11(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
       '@storybook/addon-docs':
         specifier: ^10.2.11
-        version: 10.2.11(@types/react@19.2.14)(esbuild@0.27.3)(rollup@2.80.0)(storybook@10.2.11(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))
+        version: 10.2.11(@types/react@19.2.14)(esbuild@0.27.3)(rollup@2.80.0)(storybook@10.2.11(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))
       '@storybook/addon-vitest':
         specifier: ^10.2.11
-        version: 10.2.11(@vitest/browser-playwright@4.0.18)(@vitest/browser@4.0.18(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))(vitest@4.0.18))(@vitest/runner@4.0.18)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.11(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vitest@4.0.18)
+        version: 10.2.11(@vitest/browser-playwright@4.0.18)(@vitest/browser@4.0.18(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))(vitest@4.0.18))(@vitest/runner@4.0.18)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.11(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vitest@4.0.18)
       '@storybook/react':
         specifier: ^10.2.11
         version: 10.2.11(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.11(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
       '@storybook/react-vite':
         specifier: ^10.2.11
-        version: 10.2.11(esbuild@0.27.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@2.80.0)(storybook@10.2.11(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))
+        version: 10.2.11(esbuild@0.27.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@2.80.0)(storybook@10.2.11(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))
       '@tailwindcss/vite':
         specifier: ^4.2.1
-        version: 4.2.1(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))
+        version: 4.2.1(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))
       '@types/node':
         specifier: ^24.10.13
         version: 24.10.13
@@ -172,13 +172,13 @@ importers:
         version: 19.2.3(@types/react@19.2.14)
       '@vitejs/plugin-react':
         specifier: ^5.1.4
-        version: 5.1.4(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))
+        version: 5.1.4(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))
       '@vitest/browser-playwright':
         specifier: ^4.0.18
-        version: 4.0.18(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(playwright@1.58.2)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))(vitest@4.0.18)
+        version: 4.0.18(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(playwright@1.58.2)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))(vitest@4.0.18)
       '@vitest/coverage-v8':
         specifier: ^4.0.18
-        version: 4.0.18(@vitest/browser@4.0.18(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))(vitest@4.0.18))(vitest@4.0.18)
+        version: 4.0.18(@vitest/browser@4.0.18(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))(vitest@4.0.18))(vitest@4.0.18)
       autoprefixer:
         specifier: ^10.4.24
         version: 10.4.24(postcss@8.5.6)
@@ -232,13 +232,13 @@ importers:
         version: 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
       vite:
         specifier: ^7.3.1
-        version: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)
+        version: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1)
       vite-plugin-pwa:
         specifier: ^1.2.0
-        version: 1.2.0(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0)
+        version: 1.2.0(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0)
       vitest:
         specifier: ^4.0.18
-        version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.31.1)(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(terser@5.46.0)
+        version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.31.1)(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(terser@5.46.1)
 
 packages:
 
@@ -290,8 +290,8 @@ packages:
     peerDependencies:
       '@babel/core': ^7.0.0
 
-  '@babel/helper-define-polyfill-provider@0.6.6':
-    resolution: {integrity: sha512-mOAsxeeKkUKayvZR3HeTYD/fICpCPLJrU5ZjelT/PA6WHtNDBOE436YiaEUvHN454bRM3CebhDsIpieCc4texA==}
+  '@babel/helper-define-polyfill-provider@0.6.8':
+    resolution: {integrity: sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==}
     peerDependencies:
       '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0
 
@@ -734,8 +734,8 @@ packages:
     peerDependencies:
       '@babel/core': ^7.0.0
 
-  '@babel/preset-env@7.29.0':
-    resolution: {integrity: sha512-fNEdfc0yi16lt6IZo2Qxk3knHVdfMYX33czNb4v8yWhemoBhibCpQK/uYHtSKIiO+p/zd3+8fYVXhQdOVV608w==}
+  '@babel/preset-env@7.29.2':
+    resolution: {integrity: sha512-DYD23veRYGvBFhcTY1iUvJnDNpuqNd/BzBwCvzOTKUnJjKg5kpUBh3/u9585Agdkgj+QuygG7jLfOPWMa2KVNw==}
     engines: {node: '>=6.9.0'}
     peerDependencies:
       '@babel/core': ^7.0.0-0
@@ -745,8 +745,8 @@ packages:
     peerDependencies:
       '@babel/core': ^7.0.0-0 || ^8.0.0-0 <8.0.0
 
-  '@babel/runtime@7.28.6':
-    resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
+  '@babel/runtime@7.29.2':
+    resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
     engines: {node: '>=6.9.0'}
 
   '@babel/template@7.28.6':
@@ -1839,8 +1839,8 @@ packages:
       typescript:
         optional: true
 
-  '@streamdown/code@1.0.3':
-    resolution: {integrity: sha512-3Ym5TCLcGhrHY2qBaUVWpqNRtxnZvqh4Y5Qm/pTIKA4AmEWwAAoYjZnxG7mOsvOpWVWiDwETjUtchNL1XzQEAw==}
+  '@streamdown/code@1.1.1':
+    resolution: {integrity: sha512-i7HTNuDgZWb+VdrNVOam9gQhIc5MSSDXKWXgbUrn/4vSRaSMM+Rtl10MQj4wLWPNpF7p80waJsAqFP8HZfb0Jg==}
     peerDependencies:
       react: ^18.0.0 || ^19.0.0
 
@@ -2470,18 +2470,18 @@ packages:
     resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
     engines: {node: '>= 0.4'}
 
-  babel-plugin-polyfill-corejs2@0.4.15:
-    resolution: {integrity: sha512-hR3GwrRwHUfYwGfrisXPIDP3JcYfBrW7wKE7+Au6wDYl7fm/ka1NEII6kORzxNU556JjfidZeBsO10kYvtV1aw==}
+  babel-plugin-polyfill-corejs2@0.4.17:
+    resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==}
     peerDependencies:
       '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0
 
-  babel-plugin-polyfill-corejs3@0.14.0:
-    resolution: {integrity: sha512-AvDcMxJ34W4Wgy4KBIIePQTAOP1Ie2WFwkQp3dB7FQ/f0lI5+nM96zUnYEOE1P9sEg0es5VCP0HxiWu5fUHZAQ==}
+  babel-plugin-polyfill-corejs3@0.14.2:
+    resolution: {integrity: sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==}
     peerDependencies:
       '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0
 
-  babel-plugin-polyfill-regenerator@0.6.6:
-    resolution: {integrity: sha512-hYm+XLYRMvupxiQzrvXUj7YyvFFVfv5gI0R71AJzudg1g2AI2vyCPPIFEBjk162/wFzti3inBHo7isWFuEVS/A==}
+  babel-plugin-polyfill-regenerator@0.6.8:
+    resolution: {integrity: sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==}
     peerDependencies:
       '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0
 
@@ -2700,8 +2700,8 @@ packages:
     resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
     engines: {node: '>=18'}
 
-  core-js-compat@3.48.0:
-    resolution: {integrity: sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==}
+  core-js-compat@3.49.0:
+    resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==}
 
   cors@2.8.6:
     resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
@@ -3260,9 +3260,8 @@ packages:
     resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
     engines: {node: '>=16.0.0'}
 
-  filelist@1.0.5:
-    resolution: {integrity: sha512-ct/ckWBV/9Dg3MlvCXsLcSUyoWwv9mCKqlhLNB2DAuXR/NZolSXlQqP5dyy6guWlPXBhodZyZ5lGPQcbQDxrEQ==}
-    engines: {node: 20 || >=22}
+  filelist@1.0.6:
+    resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==}
 
   finalhandler@2.1.1:
     resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
@@ -4651,8 +4650,8 @@ packages:
   remark-stringify@11.0.0:
     resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
 
-  remend@1.2.1:
-    resolution: {integrity: sha512-4wC12bgXsfKAjF1ewwkNIQz5sqewz/z1xgIgjEMb3r1pEytQ37F0Cm6i+OhbTWEvguJD7lhOUJhK5fSasw9f0w==}
+  remend@1.3.0:
+    resolution: {integrity: sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw==}
 
   require-directory@2.1.1:
     resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
@@ -4740,8 +4739,8 @@ packages:
     resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
     engines: {node: '>= 18'}
 
-  serialize-javascript@7.0.3:
-    resolution: {integrity: sha512-h+cZ/XXarqDgCjo+YSyQU/ulDEESGGf8AMK9pPNmhNSl/FzPl6L8pMp1leca5z6NuG6tvV/auC8/43tmovowww==}
+  serialize-javascript@7.0.4:
+    resolution: {integrity: sha512-DuGdB+Po43Q5Jxwpzt1lhyFSYKryqoNjQSA9M92tyw0lyHIOur+XCalOUe0KTJpyqzT8+fQ5A0Jf7vCx/NKmIg==}
     engines: {node: '>=20.0.0'}
 
   serve-static@2.2.1:
@@ -4857,8 +4856,8 @@ packages:
       prettier:
         optional: true
 
-  streamdown@2.3.0:
-    resolution: {integrity: sha512-OqS3by/lt91lSicE8RQP2nTsYI6Q/dQgGP2vcyn9YesCmRHhNjswAuBAZA1z0F4+oBU3II/eV51LqjCqwTb1lw==}
+  streamdown@2.5.0:
+    resolution: {integrity: sha512-/tTnURfIOxZK/pqJAxsfCvETG/XCJHoWnk3jq9xLcuz6CSpnjjuxSRBTTL4PKGhxiZQf0lqPxGhImdpwcZ2XwA==}
     peerDependencies:
       react: ^18.0.0 || ^19.0.0
       react-dom: ^18.0.0 || ^19.0.0
@@ -4976,8 +4975,8 @@ packages:
     resolution: {integrity: sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==}
     engines: {node: '>=10'}
 
-  terser@5.46.0:
-    resolution: {integrity: sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==}
+  terser@5.46.1:
+    resolution: {integrity: sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==}
     engines: {node: '>=10'}
     hasBin: true
 
@@ -5751,7 +5750,7 @@ snapshots:
       regexpu-core: 6.4.0
       semver: 6.3.1
 
-  '@babel/helper-define-polyfill-provider@0.6.6(@babel/core@7.29.0)':
+  '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.29.0)':
     dependencies:
       '@babel/core': 7.29.0
       '@babel/helper-compilation-targets': 7.28.6
@@ -6239,7 +6238,7 @@ snapshots:
       '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0)
       '@babel/helper-plugin-utils': 7.28.6
 
-  '@babel/preset-env@7.29.0(@babel/core@7.29.0)':
+  '@babel/preset-env@7.29.2(@babel/core@7.29.0)':
     dependencies:
       '@babel/compat-data': 7.29.0
       '@babel/core': 7.29.0
@@ -6307,10 +6306,10 @@ snapshots:
       '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.0)
       '@babel/plugin-transform-unicode-sets-regex': 7.28.6(@babel/core@7.29.0)
       '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.0)
-      babel-plugin-polyfill-corejs2: 0.4.15(@babel/core@7.29.0)
-      babel-plugin-polyfill-corejs3: 0.14.0(@babel/core@7.29.0)
-      babel-plugin-polyfill-regenerator: 0.6.6(@babel/core@7.29.0)
-      core-js-compat: 3.48.0
+      babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.0)
+      babel-plugin-polyfill-corejs3: 0.14.2(@babel/core@7.29.0)
+      babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.0)
+      core-js-compat: 3.49.0
       semver: 6.3.1
     transitivePeerDependencies:
       - supports-color
@@ -6322,7 +6321,7 @@ snapshots:
       '@babel/types': 7.29.0
       esutils: 2.0.3
 
-  '@babel/runtime@7.28.6': {}
+  '@babel/runtime@7.29.2': {}
 
   '@babel/template@7.28.6':
     dependencies:
@@ -6627,11 +6626,11 @@ snapshots:
     dependencies:
       '@jitl/quickjs-ffi-types': 0.31.0
 
-  '@joshwooding/vite-plugin-react-docgen-typescript@0.6.4(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))':
+  '@joshwooding/vite-plugin-react-docgen-typescript@0.6.4(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))':
     dependencies:
       glob: 13.0.6
       react-docgen-typescript: 2.4.0(typescript@5.9.3)
-      vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)
+      vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1)
     optionalDependencies:
       typescript: 5.9.3
 
@@ -7060,9 +7059,9 @@ snapshots:
 
   '@rollup/plugin-terser@0.4.4(rollup@2.80.0)':
     dependencies:
-      serialize-javascript: 7.0.3
+      serialize-javascript: 7.0.4
       smob: 1.6.1
-      terser: 5.46.0
+      terser: 5.46.1
     optionalDependencies:
       rollup: 2.80.0
 
@@ -7199,10 +7198,10 @@ snapshots:
       axe-core: 4.11.1
       storybook: 10.2.11(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
 
-  '@storybook/addon-docs@10.2.11(@types/react@19.2.14)(esbuild@0.27.3)(rollup@2.80.0)(storybook@10.2.11(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))':
+  '@storybook/addon-docs@10.2.11(@types/react@19.2.14)(esbuild@0.27.3)(rollup@2.80.0)(storybook@10.2.11(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))':
     dependencies:
       '@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@19.2.4)
-      '@storybook/csf-plugin': 10.2.11(esbuild@0.27.3)(rollup@2.80.0)(storybook@10.2.11(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))
+      '@storybook/csf-plugin': 10.2.11(esbuild@0.27.3)(rollup@2.80.0)(storybook@10.2.11(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))
       '@storybook/icons': 2.0.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
       '@storybook/react-dom-shim': 10.2.11(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.11(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
       react: 19.2.4
@@ -7216,39 +7215,39 @@ snapshots:
       - vite
       - webpack
 
-  '@storybook/addon-vitest@10.2.11(@vitest/browser-playwright@4.0.18)(@vitest/browser@4.0.18(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))(vitest@4.0.18))(@vitest/runner@4.0.18)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.11(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vitest@4.0.18)':
+  '@storybook/addon-vitest@10.2.11(@vitest/browser-playwright@4.0.18)(@vitest/browser@4.0.18(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))(vitest@4.0.18))(@vitest/runner@4.0.18)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.11(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vitest@4.0.18)':
     dependencies:
       '@storybook/global': 5.0.0
       '@storybook/icons': 2.0.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
       storybook: 10.2.11(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
     optionalDependencies:
-      '@vitest/browser': 4.0.18(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))(vitest@4.0.18)
-      '@vitest/browser-playwright': 4.0.18(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(playwright@1.58.2)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))(vitest@4.0.18)
+      '@vitest/browser': 4.0.18(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))(vitest@4.0.18)
+      '@vitest/browser-playwright': 4.0.18(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(playwright@1.58.2)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))(vitest@4.0.18)
       '@vitest/runner': 4.0.18
-      vitest: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.31.1)(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(terser@5.46.0)
+      vitest: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.31.1)(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(terser@5.46.1)
     transitivePeerDependencies:
       - react
       - react-dom
 
-  '@storybook/builder-vite@10.2.11(esbuild@0.27.3)(rollup@2.80.0)(storybook@10.2.11(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))':
+  '@storybook/builder-vite@10.2.11(esbuild@0.27.3)(rollup@2.80.0)(storybook@10.2.11(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))':
     dependencies:
-      '@storybook/csf-plugin': 10.2.11(esbuild@0.27.3)(rollup@2.80.0)(storybook@10.2.11(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))
+      '@storybook/csf-plugin': 10.2.11(esbuild@0.27.3)(rollup@2.80.0)(storybook@10.2.11(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))
       storybook: 10.2.11(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
       ts-dedent: 2.2.0
-      vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)
+      vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1)
     transitivePeerDependencies:
       - esbuild
       - rollup
       - webpack
 
-  '@storybook/csf-plugin@10.2.11(esbuild@0.27.3)(rollup@2.80.0)(storybook@10.2.11(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))':
+  '@storybook/csf-plugin@10.2.11(esbuild@0.27.3)(rollup@2.80.0)(storybook@10.2.11(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))':
     dependencies:
       storybook: 10.2.11(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
       unplugin: 2.3.11
     optionalDependencies:
       esbuild: 0.27.3
       rollup: 2.80.0
-      vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)
+      vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1)
 
   '@storybook/global@5.0.0': {}
 
@@ -7263,11 +7262,11 @@ snapshots:
       react-dom: 19.2.4(react@19.2.4)
       storybook: 10.2.11(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
 
-  '@storybook/react-vite@10.2.11(esbuild@0.27.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@2.80.0)(storybook@10.2.11(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))':
+  '@storybook/react-vite@10.2.11(esbuild@0.27.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@2.80.0)(storybook@10.2.11(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))':
     dependencies:
-      '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.4(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))
+      '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.4(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))
       '@rollup/pluginutils': 5.3.0(rollup@2.80.0)
-      '@storybook/builder-vite': 10.2.11(esbuild@0.27.3)(rollup@2.80.0)(storybook@10.2.11(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))
+      '@storybook/builder-vite': 10.2.11(esbuild@0.27.3)(rollup@2.80.0)(storybook@10.2.11(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))
       '@storybook/react': 10.2.11(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.11(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
       empathic: 2.0.0
       magic-string: 0.30.21
@@ -7277,7 +7276,7 @@ snapshots:
       resolve: 1.22.11
       storybook: 10.2.11(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
       tsconfig-paths: 4.2.0
-      vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)
+      vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1)
     transitivePeerDependencies:
       - esbuild
       - rollup
@@ -7298,7 +7297,7 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
-  '@streamdown/code@1.0.3(react@19.2.4)':
+  '@streamdown/code@1.1.1(react@19.2.4)':
     dependencies:
       react: 19.2.4
       shiki: 3.22.0
@@ -7389,12 +7388,12 @@ snapshots:
       '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1
       '@tailwindcss/oxide-win32-x64-msvc': 4.2.1
 
-  '@tailwindcss/vite@4.2.1(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))':
+  '@tailwindcss/vite@4.2.1(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))':
     dependencies:
       '@tailwindcss/node': 4.2.1
       '@tailwindcss/oxide': 4.2.1
       tailwindcss: 4.2.1
-      vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)
+      vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1)
 
   '@tanstack/query-core@5.90.20': {}
 
@@ -7422,7 +7421,7 @@ snapshots:
   '@testing-library/dom@10.4.1':
     dependencies:
       '@babel/code-frame': 7.29.0
-      '@babel/runtime': 7.28.6
+      '@babel/runtime': 7.29.2
       '@types/aria-query': 5.0.4
       aria-query: 5.3.0
       dom-accessibility-api: 0.5.16
@@ -7763,7 +7762,7 @@ snapshots:
       d3-selection: 3.0.0
       d3-transition: 3.0.1(d3-selection@3.0.0)
 
-  '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))':
+  '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))':
     dependencies:
       '@babel/core': 7.29.0
       '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
@@ -7771,33 +7770,33 @@ snapshots:
       '@rolldown/pluginutils': 1.0.0-rc.3
       '@types/babel__core': 7.20.5
       react-refresh: 0.18.0
-      vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)
+      vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1)
     transitivePeerDependencies:
       - supports-color
 
-  '@vitest/browser-playwright@4.0.18(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(playwright@1.58.2)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))(vitest@4.0.18)':
+  '@vitest/browser-playwright@4.0.18(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(playwright@1.58.2)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))(vitest@4.0.18)':
     dependencies:
-      '@vitest/browser': 4.0.18(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))(vitest@4.0.18)
-      '@vitest/mocker': 4.0.18(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))
+      '@vitest/browser': 4.0.18(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))(vitest@4.0.18)
+      '@vitest/mocker': 4.0.18(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))
       playwright: 1.58.2
       tinyrainbow: 3.0.3
-      vitest: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.31.1)(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(terser@5.46.0)
+      vitest: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.31.1)(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(terser@5.46.1)
     transitivePeerDependencies:
       - bufferutil
       - msw
       - utf-8-validate
       - vite
 
-  '@vitest/browser@4.0.18(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))(vitest@4.0.18)':
+  '@vitest/browser@4.0.18(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))(vitest@4.0.18)':
     dependencies:
-      '@vitest/mocker': 4.0.18(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))
+      '@vitest/mocker': 4.0.18(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))
       '@vitest/utils': 4.0.18
       magic-string: 0.30.21
       pixelmatch: 7.1.0
       pngjs: 7.0.0
       sirv: 3.0.2
       tinyrainbow: 3.0.3
-      vitest: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.31.1)(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(terser@5.46.0)
+      vitest: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.31.1)(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(terser@5.46.1)
       ws: 8.19.0
     transitivePeerDependencies:
       - bufferutil
@@ -7805,7 +7804,7 @@ snapshots:
       - utf-8-validate
       - vite
 
-  '@vitest/coverage-v8@4.0.18(@vitest/browser@4.0.18(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))(vitest@4.0.18))(vitest@4.0.18)':
+  '@vitest/coverage-v8@4.0.18(@vitest/browser@4.0.18(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))(vitest@4.0.18))(vitest@4.0.18)':
     dependencies:
       '@bcoe/v8-coverage': 1.0.2
       '@vitest/utils': 4.0.18
@@ -7817,9 +7816,9 @@ snapshots:
       obug: 2.1.1
       std-env: 3.10.0
       tinyrainbow: 3.0.3
-      vitest: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.31.1)(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(terser@5.46.0)
+      vitest: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.31.1)(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(terser@5.46.1)
     optionalDependencies:
-      '@vitest/browser': 4.0.18(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))(vitest@4.0.18)
+      '@vitest/browser': 4.0.18(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))(vitest@4.0.18)
 
   '@vitest/expect@3.2.4':
     dependencies:
@@ -7838,14 +7837,14 @@ snapshots:
       chai: 6.2.2
       tinyrainbow: 3.0.3
 
-  '@vitest/mocker@4.0.18(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))':
+  '@vitest/mocker@4.0.18(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))':
     dependencies:
       '@vitest/spy': 4.0.18
       estree-walker: 3.0.3
       magic-string: 0.30.21
     optionalDependencies:
       msw: 2.12.10(@types/node@24.10.13)(typescript@5.9.3)
-      vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)
+      vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1)
 
   '@vitest/pretty-format@3.2.4':
     dependencies:
@@ -8031,27 +8030,27 @@ snapshots:
 
   axobject-query@4.1.0: {}
 
-  babel-plugin-polyfill-corejs2@0.4.15(@babel/core@7.29.0):
+  babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.0):
     dependencies:
       '@babel/compat-data': 7.29.0
       '@babel/core': 7.29.0
-      '@babel/helper-define-polyfill-provider': 0.6.6(@babel/core@7.29.0)
+      '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0)
       semver: 6.3.1
     transitivePeerDependencies:
       - supports-color
 
-  babel-plugin-polyfill-corejs3@0.14.0(@babel/core@7.29.0):
+  babel-plugin-polyfill-corejs3@0.14.2(@babel/core@7.29.0):
     dependencies:
       '@babel/core': 7.29.0
-      '@babel/helper-define-polyfill-provider': 0.6.6(@babel/core@7.29.0)
-      core-js-compat: 3.48.0
+      '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0)
+      core-js-compat: 3.49.0
     transitivePeerDependencies:
       - supports-color
 
-  babel-plugin-polyfill-regenerator@0.6.6(@babel/core@7.29.0):
+  babel-plugin-polyfill-regenerator@0.6.8(@babel/core@7.29.0):
     dependencies:
       '@babel/core': 7.29.0
-      '@babel/helper-define-polyfill-provider': 0.6.6(@babel/core@7.29.0)
+      '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0)
     transitivePeerDependencies:
       - supports-color
 
@@ -8265,7 +8264,7 @@ snapshots:
 
   cookie@1.1.1: {}
 
-  core-js-compat@3.48.0:
+  core-js-compat@3.49.0:
     dependencies:
       browserslist: 4.28.1
 
@@ -8936,7 +8935,7 @@ snapshots:
     dependencies:
       flat-cache: 4.0.1
 
-  filelist@1.0.5:
+  filelist@1.0.6:
     dependencies:
       minimatch: 10.2.4
 
@@ -9479,7 +9478,7 @@ snapshots:
   jake@10.9.4:
     dependencies:
       async: 3.2.6
-      filelist: 1.0.5
+      filelist: 1.0.6
       picocolors: 1.1.1
 
   jiti@2.6.1: {}
@@ -10655,7 +10654,7 @@ snapshots:
       mdast-util-to-markdown: 2.1.2
       unified: 11.0.5
 
-  remend@1.2.1: {}
+  remend@1.3.0: {}
 
   require-directory@2.1.1: {}
 
@@ -10776,7 +10775,7 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
-  serialize-javascript@7.0.3: {}
+  serialize-javascript@7.0.4: {}
 
   serve-static@2.2.1:
     dependencies:
@@ -10923,12 +10922,13 @@ snapshots:
       - react-dom
       - utf-8-validate
 
-  streamdown@2.3.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
+  streamdown@2.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
     dependencies:
       clsx: 2.1.1
       hast-util-to-jsx-runtime: 2.3.6
       html-url-attributes: 3.0.1
       marked: 17.0.3
+      mermaid: 11.13.0
       react: 19.2.4
       react-dom: 19.2.4(react@19.2.4)
       rehype-harden: 1.1.8
@@ -10937,7 +10937,7 @@ snapshots:
       remark-gfm: 4.0.1
       remark-parse: 11.0.0
       remark-rehype: 11.1.2
-      remend: 1.2.1
+      remend: 1.3.0
       tailwind-merge: 3.5.0
       unified: 11.0.5
       unist-util-visit: 5.1.0
@@ -11077,7 +11077,7 @@ snapshots:
       type-fest: 0.16.0
       unique-string: 2.0.0
 
-  terser@5.46.0:
+  terser@5.46.1:
     dependencies:
       '@jridgewell/source-map': 0.3.11
       acorn: 8.16.0
@@ -11626,18 +11626,18 @@ snapshots:
       d3-time: 3.1.0
       d3-timer: 3.0.1
 
-  vite-plugin-pwa@1.2.0(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0):
+  vite-plugin-pwa@1.2.0(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0):
     dependencies:
       debug: 4.4.3
       pretty-bytes: 6.1.1
       tinyglobby: 0.2.15
-      vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)
+      vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1)
       workbox-build: 7.4.0(@types/babel__core@7.20.5)
       workbox-window: 7.4.0
     transitivePeerDependencies:
       - supports-color
 
-  vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0):
+  vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1):
     dependencies:
       esbuild: 0.27.3
       fdir: 6.5.0(picomatch@4.0.3)
@@ -11650,12 +11650,12 @@ snapshots:
       fsevents: 2.3.3
       jiti: 2.6.1
       lightningcss: 1.31.1
-      terser: 5.46.0
+      terser: 5.46.1
 
-  vitest@4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.31.1)(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(terser@5.46.0):
+  vitest@4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.31.1)(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(terser@5.46.1):
     dependencies:
       '@vitest/expect': 4.0.18
-      '@vitest/mocker': 4.0.18(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))
+      '@vitest/mocker': 4.0.18(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))
       '@vitest/pretty-format': 4.0.18
       '@vitest/runner': 4.0.18
       '@vitest/snapshot': 4.0.18
@@ -11672,11 +11672,11 @@ snapshots:
       tinyexec: 1.0.2
       tinyglobby: 0.2.15
       tinyrainbow: 3.0.3
-      vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)
+      vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1)
       why-is-node-running: 2.3.0
     optionalDependencies:
       '@types/node': 24.10.13
-      '@vitest/browser-playwright': 4.0.18(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(playwright@1.58.2)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))(vitest@4.0.18)
+      '@vitest/browser-playwright': 4.0.18(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(playwright@1.58.2)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))(vitest@4.0.18)
     transitivePeerDependencies:
       - jiti
       - less
@@ -11786,8 +11786,8 @@ snapshots:
     dependencies:
       '@apideck/better-ajv-errors': 0.3.6(ajv@8.18.0)
       '@babel/core': 7.29.0
-      '@babel/preset-env': 7.29.0(@babel/core@7.29.0)
-      '@babel/runtime': 7.28.6
+      '@babel/preset-env': 7.29.2(@babel/core@7.29.0)
+      '@babel/runtime': 7.29.2
       '@rollup/plugin-babel': 5.3.1(@babel/core@7.29.0)(@types/babel__core@7.20.5)(rollup@2.80.0)
       '@rollup/plugin-node-resolve': 15.3.1(rollup@2.80.0)
       '@rollup/plugin-replace': 2.4.2(rollup@2.80.0)
diff --git a/ui/src/components/ChatMessage/ChatMessage.tsx b/ui/src/components/ChatMessage/ChatMessage.tsx
index 8f2cea91..25cdd92a 100644
--- a/ui/src/components/ChatMessage/ChatMessage.tsx
+++ b/ui/src/components/ChatMessage/ChatMessage.tsx
@@ -23,6 +23,7 @@ import {
 import { Avatar, AvatarFallback } from "@/components/Avatar/Avatar";
 import { Button } from "@/components/Button/Button";
 import { Markdown } from "@/components/Markdown/Markdown";
+import { StreamingMarkdown } from "@/components/StreamingMarkdown/StreamingMarkdown";
 import { QuoteSelectionPopover } from "@/components/QuoteSelectionPopover";
 import { Textarea } from "@/components/Textarea/Textarea";
 import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/Tooltip/Tooltip";
@@ -324,19 +325,10 @@ function ChatMessageComponent({
               ) : (
                 <Markdown content={message.content} />
               )
+            ) : isStreaming ? (
+              <StreamingMarkdown content={message.content} isStreaming />
             ) : (
-              <>
-                <Markdown content={message.content} />
-                {isStreaming && (
-                  <>
-                    <span
-                      className="inline-block h-4 w-0.5 animate-blink rounded-full bg-primary"
-                      aria-hidden="true"
-                    />
-                    <span className="sr-only">Generating response...</span>
-                  </>
-                )}
-              </>
+              <Markdown content={message.content} />
             )}
           </div>
         </div>
diff --git a/ui/src/components/StreamingMarkdown/StreamingMarkdown.tsx b/ui/src/components/StreamingMarkdown/StreamingMarkdown.tsx
index a7a8555e..473f3d74 100644
--- a/ui/src/components/StreamingMarkdown/StreamingMarkdown.tsx
+++ b/ui/src/components/StreamingMarkdown/StreamingMarkdown.tsx
@@ -3,6 +3,7 @@ import { createCodePlugin } from "@streamdown/code";
 import { math } from "@streamdown/math";
 import { mermaid } from "@streamdown/mermaid";
 import "katex/dist/katex.min.css";
+import "streamdown/styles.css";
 import { memo } from "react";
 
 import { cn } from "@/utils/cn";
@@ -89,12 +90,10 @@ function StreamingMarkdownComponent({ content, isStreaming, className }: Streami
         mode={isStreaming ? "streaming" : "static"}
         isAnimating={isStreaming}
         parseIncompleteMarkdown={isStreaming}
+        animated
       >
         {content}
       </Streamdown>
-      {isStreaming && (
-        <span className="inline-block h-4 w-0.5 animate-blink rounded-full bg-primary" />
-      )}
     </div>
   );
 }
diff --git a/ui/src/index.css b/ui/src/index.css
index cb98b47b..497b1b13 100644
--- a/ui/src/index.css
+++ b/ui/src/index.css
@@ -300,22 +300,6 @@
     animation: bounce 1s infinite;
   }
 
-  /* Blink animation for streaming cursor */
-  @keyframes blink {
-    0%,
-    50% {
-      opacity: 1;
-    }
-    51%,
-    100% {
-      opacity: 0;
-    }
-  }
-
-  .animate-blink {
-    animation: blink 1s step-end infinite;
-  }
-
   /* Bouncy scale entrance */
   @keyframes bounce-in {
     0% {

From cc400fe367068dd1a22a6e81c8d53847e1d0ca64 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Mon, 23 Mar 2026 23:09:26 +1000
Subject: [PATCH 02/25] Remove unused system prompt

---
 ui/src/components/ChatView/ChatView.stories.tsx | 8 +-------
 1 file changed, 1 insertion(+), 7 deletions(-)

diff --git a/ui/src/components/ChatView/ChatView.stories.tsx b/ui/src/components/ChatView/ChatView.stories.tsx
index a033a387..b1df3365 100644
--- a/ui/src/components/ChatView/ChatView.stories.tsx
+++ b/ui/src/components/ChatView/ChatView.stories.tsx
@@ -449,7 +449,7 @@ export const NoModelsSelected: Story = {
 };
 
 /**
- * Test: Multi-model conversation with system prompt indicator
+ * Test: Multi-model conversation
  */
 function MultiModelConversationStory({
   onSendMessage,
@@ -497,7 +497,6 @@ function MultiModelConversationStory({
   useStoreSetup({
     messages: multiModelMessages,
     selectedModels: ["anthropic/claude-4.6-opus", "openai/gpt-5.3", "google/gemini-3.1-pro"],
-    systemPrompt: "You are a helpful coding assistant.",
   });
 
   return (
@@ -532,11 +531,6 @@ export const MultiModelConversation: Story = {
     await expect(canvas.getByText(/error boundaries/)).toBeInTheDocument();
     await expect(canvas.getByText(/neverthrow/)).toBeInTheDocument();
 
-    // Verify system prompt is active - settings button has text-primary class
-    // (The button turns primary color when system prompt is set)
-    const settingsButton = canvasElement.querySelector('button[class*="text-primary"]');
-    await expect(settingsButton).toBeInTheDocument();
-
     // Verify total usage is displayed (80 + 65 + 75 = 220 tokens)
     await expect(canvas.getByText(/220/)).toBeInTheDocument();
   },

From 845425764d9e4bc026b94ac924d1d009f9e81535 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Mon, 23 Mar 2026 23:13:21 +1000
Subject: [PATCH 03/25] Add openrouter category

---
 docs/content/docs/configuration/providers.mdx | 2 ++
 src/bin/record_fixtures.rs                    | 3 ++-
 src/config/providers.rs                       | 5 +++--
 src/providers/open_ai/mod.rs                  | 3 +++
 4 files changed, 10 insertions(+), 3 deletions(-)

diff --git a/docs/content/docs/configuration/providers.mdx b/docs/content/docs/configuration/providers.mdx
index c5eb27e0..561a435d 100644
--- a/docs/content/docs/configuration/providers.mdx
+++ b/docs/content/docs/configuration/providers.mdx
@@ -48,10 +48,12 @@ base_url = "https://openrouter.ai/api/v1"
 # App attribution headers are sent automatically:
 #   HTTP-Referer: https://hadriangateway.com
 #   X-OpenRouter-Title: Hadrian Gateway
+#   X-OpenRouter-Categories: general-chat
 # Override to customize, or set to "" to opt out:
 # [providers.openrouter.headers]
 # HTTP-Referer = "https://myapp.example.com"
 # X-OpenRouter-Title = "My Application"
+# X-OpenRouter-Categories = "coding-app"
 ```
 
 **Ollama** (local, no API key needed):
diff --git a/src/bin/record_fixtures.rs b/src/bin/record_fixtures.rs
index bf2c130d..d3741075 100644
--- a/src/bin/record_fixtures.rs
+++ b/src/bin/record_fixtures.rs
@@ -1920,7 +1920,8 @@ async fn record_fixture(
             if def.provider == "openrouter" {
                 request = request
                     .header("HTTP-Referer", "https://hadriangateway.com")
-                    .header("X-OpenRouter-Title", "Hadrian Gateway");
+                    .header("X-OpenRouter-Title", "Hadrian Gateway")
+                    .header("X-OpenRouter-Categories", "general-chat");
             }
         }
 
diff --git a/src/config/providers.rs b/src/config/providers.rs
index 9c11b275..136a39d1 100644
--- a/src/config/providers.rs
+++ b/src/config/providers.rs
@@ -606,8 +606,9 @@ pub struct OpenAiProviderConfig {
     pub model_aliases: HashMap<String, String>,
 
     /// Custom headers to include in requests.
-    /// For OpenRouter providers, `HTTP-Referer` and `X-OpenRouter-Title` are set
-    /// automatically for app attribution. Override here to customize or opt out.
+    /// For OpenRouter providers, `HTTP-Referer`, `X-OpenRouter-Title`, and
+    /// `X-OpenRouter-Categories` are set automatically for app attribution.
+    /// Override here to customize or opt out.
     #[serde(default)]
     pub headers: HashMap<String, String>,
 
diff --git a/src/providers/open_ai/mod.rs b/src/providers/open_ai/mod.rs
index 0697c96a..85e57019 100644
--- a/src/providers/open_ai/mod.rs
+++ b/src/providers/open_ai/mod.rs
@@ -79,6 +79,9 @@ impl OpenAICompatibleProvider {
             headers
                 .entry("X-OpenRouter-Title".to_string())
                 .or_insert_with(|| "Hadrian Gateway".to_string());
+            headers
+                .entry("X-OpenRouter-Categories".to_string())
+                .or_insert_with(|| "general-chat".to_string());
         }
 
         Self {

From 886ae3cc66e29e251310054d56561e1f65de37c4 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Mon, 23 Mar 2026 23:18:15 +1000
Subject: [PATCH 04/25] Unregister service workers when not enabled

---
 ui/src/main.tsx | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/ui/src/main.tsx b/ui/src/main.tsx
index 322c50d5..a4647825 100644
--- a/ui/src/main.tsx
+++ b/ui/src/main.tsx
@@ -10,6 +10,11 @@ async function bootstrap() {
   if (import.meta.env.VITE_WASM_MODE === "true") {
     const { registerWasmServiceWorker } = await import("./service-worker/register");
     await registerWasmServiceWorker();
+  } else if ("serviceWorker" in navigator) {
+    // Unregister any lingering WASM service workers so they don't intercept
+    // requests when running the normal dev server.
+    const registrations = await navigator.serviceWorker.getRegistrations();
+    await Promise.all(registrations.map((r) => r.unregister()));
   }
 
   createRoot(document.getElementById("root")!).render(

From 383fe345c77a189e31aa5c0413f2c9c0b534f33d Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Mon, 23 Mar 2026 23:44:00 +1000
Subject: [PATCH 05/25] Status indicator fixes

---
 .../MultiModelResponse/MultiModelResponse.tsx | 62 ++++++++++++-------
 ui/src/stores/streamingStore.ts               | 13 ++++
 2 files changed, 52 insertions(+), 23 deletions(-)

diff --git a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
index 1a391c24..9497f98d 100644
--- a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
+++ b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
@@ -85,6 +85,8 @@ import {
   useToolExecutionRounds,
   useIsStreaming,
   useHasActiveToolCalls,
+  useHasRunningExecution,
+  useRunningExecutionStatusMessage,
 } from "@/stores/streamingStore";
 import { cn } from "@/utils/cn";
 import { getModelDisplayName } from "@/utils/modelNames";
@@ -260,9 +262,9 @@ function useStreamingPhase(
   const rounds = response.completedRounds;
   const hasRounds = !!rounds?.length;
 
-  // No output at all yet — waiting for first token
+  // No output at all yet — waiting for first token (unless tools are executing)
   if (!hasContent && !hasReasoning && !hasRounds) {
-    return "thinking";
+    return hasActiveTools ? "processing" : "thinking";
   }
 
   // Multi-round: check whether the current (in-flight) round has content yet
@@ -312,11 +314,20 @@ const PHASE_LABEL: Record<StreamingPhase, string> = {
 };
 
 /** Animated dots + label shown when the model is thinking or processing. */
-function StreamingStatusIndicator({ phase }: { phase: StreamingPhase }) {
+function StreamingStatusIndicator({
+  phase,
+  toolStatusMessage,
+}: {
+  phase: StreamingPhase;
+  toolStatusMessage?: string;
+}) {
   if (phase === "idle") return null;
+  // Strip trailing dots/ellipsis from tool status messages since we show animated dots
+  const label =
+    (phase === "processing" && toolStatusMessage?.replace(/\.+$/, "")) || PHASE_LABEL[phase];
   return (
     <div className="flex items-baseline gap-1.5 pl-3 border-l-2 border-transparent text-muted-foreground">
-      <span className="text-sm">{PHASE_LABEL[phase]}</span>
+      <span className="text-sm">{label}</span>
       <div className="flex items-baseline gap-1 py-1">
         <span className="h-1 w-1 rounded-full bg-muted-foreground/60 animate-typing-dot" />
         <span className="h-1 w-1 rounded-full bg-muted-foreground/60 animate-typing-dot-delay-1" />
@@ -547,7 +558,14 @@ const ModelResponseCard = memo(function ModelResponseCard({
   const isComplete = !response.isStreaming && response.content && !response.error;
   const isAnyStreaming = useIsStreaming();
   const compactMode = useCompactMode();
-  const hasActiveTools = useHasActiveToolCalls(model);
+  // Only query streaming store for actively-streaming responses — committed
+  // messages must not read the current stream's state (same model key would
+  // leak status from a newer message into older ones).
+  const streamingModelKey = response.isStreaming ? model : "";
+  const hasActiveToolCalls = useHasActiveToolCalls(streamingModelKey);
+  const hasRunningExecution = useHasRunningExecution(streamingModelKey);
+  const hasActiveTools = hasActiveToolCalls || hasRunningExecution;
+  const toolStatusMessage = useRunningExecutionStatusMessage(streamingModelKey);
   const streamingPhase = useStreamingPhase(response, hasActiveTools, compactMode);
 
   // State for artifact modal
@@ -665,35 +683,27 @@ const ModelResponseCard = memo(function ModelResponseCard({
     stop: handleStopSpeaking,
   } = useTTSForResponse(response.content, groupId, instanceId);
 
-  // Get citations from streaming store (for active/recent streams) or from response props
-  const streamingCitations = useCitations(model);
+  // Get citations from streaming store (for active/recent streams) or from response props.
+  // Use streamingModelKey ("" for committed) to avoid leaking state across messages.
+  const streamingCitations = useCitations(streamingModelKey);
   const citations = useMemo(() => {
-    // Use streaming citations if available, otherwise use response citations
-    if (streamingCitations.length > 0) {
-      return streamingCitations;
-    }
+    if (streamingCitations.length > 0) return streamingCitations;
     return response.citations ?? [];
   }, [streamingCitations, response.citations]);
   const hasCitations = citations.length > 0;
 
   // Get artifacts from streaming store (for active/recent streams) or from response props
-  const streamingArtifacts = useArtifacts(model);
+  const streamingArtifacts = useArtifacts(streamingModelKey);
   const artifacts = useMemo(() => {
-    // Use streaming artifacts if available, otherwise use response artifacts
-    if (streamingArtifacts.length > 0) {
-      return streamingArtifacts;
-    }
+    if (streamingArtifacts.length > 0) return streamingArtifacts;
     return response.artifacts ?? [];
   }, [streamingArtifacts, response.artifacts]);
   const hasArtifacts = artifacts.length > 0;
 
   // Get tool execution rounds from streaming store (for active/recent streams) or from response props
-  const streamingToolExecutionRounds = useToolExecutionRounds(model);
+  const streamingToolExecutionRounds = useToolExecutionRounds(streamingModelKey);
   const toolExecutionRounds = useMemo(() => {
-    // Use streaming rounds if available, otherwise use response rounds
-    if (streamingToolExecutionRounds.length > 0) {
-      return streamingToolExecutionRounds;
-    }
+    if (streamingToolExecutionRounds.length > 0) return streamingToolExecutionRounds;
     return response.toolExecutionRounds ?? [];
   }, [streamingToolExecutionRounds, response.toolExecutionRounds]);
   const hasToolExecutionRounds = toolExecutionRounds.length > 0;
@@ -975,7 +985,10 @@ const ModelResponseCard = memo(function ModelResponseCard({
                         isReasoningStreaming={response.isStreaming && !currentContent}
                       />
                     )}
-                    <StreamingStatusIndicator phase={streamingPhase} />
+                    <StreamingStatusIndicator
+                      phase={streamingPhase}
+                      toolStatusMessage={toolStatusMessage}
+                    />
                   </div>
                 );
               }
@@ -1013,7 +1026,10 @@ const ModelResponseCard = memo(function ModelResponseCard({
             })()}
             {/* Streaming status indicator (outside multi-round div for single-round paths) */}
             {!response.completedRounds?.length && (
-              <StreamingStatusIndicator phase={streamingPhase} />
+              <StreamingStatusIndicator
+                phase={streamingPhase}
+                toolStatusMessage={toolStatusMessage}
+              />
             )}
             {/* Citations from file_search/web_search */}
             {hasCitations && (
diff --git a/ui/src/stores/streamingStore.ts b/ui/src/stores/streamingStore.ts
index 56ab9338..99ffab76 100644
--- a/ui/src/stores/streamingStore.ts
+++ b/ui/src/stores/streamingStore.ts
@@ -1620,3 +1620,16 @@ export const useHasRunningExecution = (model: string) =>
     if (!stream?.toolExecutionRounds) return false;
     return stream.toolExecutionRounds.some((r) => r.executions.some((e) => e.status === "running"));
   });
+
+/** Get the status message of the currently running execution (if any) */
+export const useRunningExecutionStatusMessage = (model: string) =>
+  useStreamingStore((state) => {
+    const stream = state.streams.get(model);
+    if (!stream?.toolExecutionRounds) return undefined;
+    for (const round of stream.toolExecutionRounds) {
+      for (const exec of round.executions) {
+        if (exec.status === "running" && exec.statusMessage) return exec.statusMessage;
+      }
+    }
+    return undefined;
+  });

From 6b8b7845fbd83cfc1d9db528b3ec2283e21c99c6 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Tue, 24 Mar 2026 20:33:38 +1000
Subject: [PATCH 06/25] Remove 2MB json limit

---
 src/app.rs | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/src/app.rs b/src/app.rs
index ba8ebeb9..950d3a9a 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -2087,7 +2087,8 @@ pub fn build_app(config: &config::GatewayConfig, state: AppState) -> Router {
         app = app.layer(cors_layer);
     }
 
-    app.layer(TraceLayer::new_for_http())
+    app.layer(axum::extract::DefaultBodyLimit::disable())
+        .layer(TraceLayer::new_for_http())
         .layer(RequestBodyLimitLayer::new(config.server.body_limit_bytes))
         .with_state(state)
 }

From fd2fe2b48735e85f7acb46bb5c55d36c74bd3e8e Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Tue, 24 Mar 2026 20:34:14 +1000
Subject: [PATCH 07/25] Incude assistant responses in subsequent rounds

---
 ui/src/pages/chat/useChat.ts | 11 +++++++++++
 1 file changed, 11 insertions(+)

diff --git a/ui/src/pages/chat/useChat.ts b/ui/src/pages/chat/useChat.ts
index d6bfba2d..949286ac 100644
--- a/ui/src/pages/chat/useChat.ts
+++ b/ui/src/pages/chat/useChat.ts
@@ -1533,6 +1533,14 @@ export function useChat({
           arguments: JSON.stringify(tc.arguments),
         }));
 
+        // Extract assistant output message items from response output.
+        // When the model emits text AND tool calls in the same turn, the text
+        // must be included in the continuation so the next round has full context.
+        const outputMessageItems = (result.responseOutput ?? []).filter((item) => {
+          const obj = item as Record<string, unknown>;
+          return obj.type === "message" && obj.role === "assistant";
+        }) as Array<Record<string, unknown>>;
+
         // Capture debug data: tool results and continuation items
         if (messageId) {
           // Convert toolResults Map to array for debug store
@@ -1557,6 +1565,7 @@ export function useChat({
 
           // Capture continuation items (what gets sent to the next round)
           debugStore.setRoundContinuationItems(messageId, model, iterations, [
+            ...outputMessageItems,
             ...functionCallItems,
             ...toolResultItems,
           ]);
@@ -1568,6 +1577,8 @@ export function useChat({
         // Continue conversation with function calls and their results
         currentInputItems = [
           ...currentInputItems,
+          // The assistant's output message (text emitted before/alongside tool calls)
+          ...outputMessageItems,
           // The assistant's function call(s)
           ...functionCallItems,
           // The tool results

From 8733dd0d3d7b19429956e8c915494fb606828c74 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Tue, 24 Mar 2026 20:42:50 +1000
Subject: [PATCH 08/25] Limit exa response sizes

---
 src/config/features.rs  | 12 ++++++++++++
 src/routes/api/tools.rs | 27 +++++++++++++++++++++++++--
 2 files changed, 37 insertions(+), 2 deletions(-)

diff --git a/src/config/features.rs b/src/config/features.rs
index 1f4f10e3..965dda5f 100644
--- a/src/config/features.rs
+++ b/src/config/features.rs
@@ -2194,6 +2194,13 @@ pub struct WebSearchConfig {
     /// Lower than file_search since web search rarely needs multiple rounds.
     #[serde(default = "default_web_search_max_iterations")]
     pub max_iterations: usize,
+
+    /// Maximum characters of content text per search result.
+    /// Applies to Exa's `text.maxCharacters` parameter. Tavily returns concise
+    /// summaries by default so this is not needed there.
+    /// Set to 0 to disable (return full text). Default: 2000.
+    #[serde(default = "default_web_search_max_content_chars")]
+    pub max_content_chars: usize,
 }
 
 impl std::fmt::Debug for WebSearchConfig {
@@ -2208,6 +2215,7 @@ impl std::fmt::Debug for WebSearchConfig {
                 &self.cost_microcents_per_request,
             )
             .field("max_iterations", &self.max_iterations)
+            .field("max_content_chars", &self.max_content_chars)
             .finish()
     }
 }
@@ -2237,6 +2245,10 @@ fn default_web_search_max_iterations() -> usize {
     3
 }
 
+fn default_web_search_max_content_chars() -> usize {
+    2000
+}
+
 // ─────────────────────────────────────────────────────────────────────────────
 // Web Fetch
 // ─────────────────────────────────────────────────────────────────────────────
diff --git a/src/routes/api/tools.rs b/src/routes/api/tools.rs
index 8df37bc7..85c201cd 100644
--- a/src/routes/api/tools.rs
+++ b/src/routes/api/tools.rs
@@ -76,7 +76,16 @@ struct ExaSearchRequest {
 
 #[derive(Debug, Serialize)]
 struct ExaContents {
-    text: bool,
+    text: ExaTextOptions,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct ExaTextOptions {
+    /// Maximum characters of text to return per result.
+    /// Omit to return full text (Exa default).
+    #[serde(skip_serializing_if = "Option::is_none")]
+    max_characters: Option<usize>,
 }
 
 #[derive(Debug, Deserialize)]
@@ -103,6 +112,15 @@ pub async fn execute_web_search(
     max_results: usize,
 ) -> Result<Vec<WebSearchResult>, WebSearchError> {
     let timeout = std::time::Duration::from_secs(config.timeout_secs);
+    let max_chars = config.max_content_chars;
+
+    let truncate = |s: String| -> String {
+        if max_chars == 0 || s.len() <= max_chars {
+            return s;
+        }
+        let end = s.floor_char_boundary(max_chars);
+        format!("{}…[truncated]", &s[..end])
+    };
 
     match config.provider {
         WebSearchProvider::Tavily => {
@@ -146,10 +164,15 @@ pub async fn execute_web_search(
                 .collect())
         }
         WebSearchProvider::Exa => {
+            let max_chars = config.max_content_chars;
             let req = ExaSearchRequest {
                 query: query.to_string(),
                 num_results: max_results,
-                contents: ExaContents { text: true },
+                contents: ExaContents {
+                    text: ExaTextOptions {
+                        max_characters: if max_chars > 0 { Some(max_chars) } else { None },
+                    },
+                },
             };
             let resp = client
                 .post("https://api.exa.ai/search")

From 46794bdbb024380b23a11f1eff2216536142b927 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Tue, 24 Mar 2026 22:32:59 +1000
Subject: [PATCH 09/25] Improve wizard

---
 ui/src/components/Header/Header.tsx       |  11 +-
 ui/src/components/WasmSetup/WasmSetup.tsx | 149 +++++++++++++++-------
 2 files changed, 110 insertions(+), 50 deletions(-)

diff --git a/ui/src/components/Header/Header.tsx b/ui/src/components/Header/Header.tsx
index 395e7cc3..b88298e5 100644
--- a/ui/src/components/Header/Header.tsx
+++ b/ui/src/components/Header/Header.tsx
@@ -148,8 +148,15 @@ export function Header({ onMenuClick, showMenuButton = false, className }: Heade
       <div className="flex items-center gap-2">
         <ThemeToggle />
         {isWasm && (
-          <Button variant="ghost" size="icon" onClick={openSetupWizard} aria-label="Setup Wizard">
-            <WandSparkles className="h-4 w-4" />
+          <Button
+            variant="outline"
+            size="sm"
+            className="gap-1.5 border-dashed text-muted-foreground hover:text-foreground"
+            onClick={openSetupWizard}
+            aria-label="Setup Wizard"
+          >
+            <WandSparkles className="h-3.5 w-3.5" />
+            Setup
           </Button>
         )}
         <UserMenu />
diff --git a/ui/src/components/WasmSetup/WasmSetup.tsx b/ui/src/components/WasmSetup/WasmSetup.tsx
index 99b28277..ecfa400c 100644
--- a/ui/src/components/WasmSetup/WasmSetup.tsx
+++ b/ui/src/components/WasmSetup/WasmSetup.tsx
@@ -14,6 +14,7 @@ import {
 import { useMutation, useQueryClient } from "@tanstack/react-query";
 import {
   meProvidersCreateMutation,
+  meProvidersDeleteMutation,
   meProvidersListQueryKey,
   apiV1ModelsQueryKey,
 } from "@/api/generated/@tanstack/react-query.gen";
@@ -32,6 +33,7 @@ import { Input } from "@/components/Input/Input";
 import { FormField } from "@/components/FormField/FormField";
 import { HadrianIcon } from "@/components/HadrianIcon/HadrianIcon";
 import { startOpenRouterOAuth, isInIframe } from "./openrouter-oauth";
+import { cn } from "@/utils/cn";
 
 interface ProviderTemplate {
   id: string;
@@ -147,6 +149,23 @@ export function WasmSetup({
     ...meProvidersCreateMutation(),
   });
 
+  const deleteMutation = useMutation({
+    ...meProvidersDeleteMutation(),
+  });
+
+  const handleDeleteExisting = useCallback(
+    async (id: string) => {
+      try {
+        await deleteMutation.mutateAsync({ path: { id } });
+        queryClient.invalidateQueries({ queryKey: meProvidersListQueryKey() });
+        queryClient.invalidateQueries({ queryKey: apiV1ModelsQueryKey() });
+      } catch (err) {
+        console.error("Failed to delete provider:", err);
+      }
+    },
+    [deleteMutation, queryClient]
+  );
+
   const updateEntry = useCallback((key: string, update: Partial<ProviderEntry>) => {
     setEntries((prev) => prev.map((e) => (e.key === key ? { ...e, ...update } : e)));
   }, []);
@@ -244,6 +263,8 @@ export function WasmSetup({
           ollamaConnecting={ollamaConnecting}
           hasExistingOllama={hasExistingOllama}
           onOllamaConnect={onOllamaConnect}
+          existingProviders={existingProviders}
+          onDeleteExisting={handleDeleteExisting}
         />
       )}
       {step === "providers" && (
@@ -266,6 +287,8 @@ export function WasmSetup({
           ollamaConnecting={ollamaConnecting}
           hasExistingOllama={hasExistingOllama}
           onOllamaConnect={onOllamaConnect}
+          existingProviders={existingProviders}
+          onDeleteExisting={handleDeleteExisting}
         />
       )}
       {step === "done" && <DoneStep savedCount={savedCount} onComplete={onComplete} />}
@@ -284,6 +307,8 @@ function WelcomeStep({
   ollamaConnecting,
   hasExistingOllama,
   onOllamaConnect,
+  existingProviders,
+  onDeleteExisting,
 }: {
   onNext: () => void;
   onReady: () => void;
@@ -295,6 +320,8 @@ function WelcomeStep({
   ollamaConnecting?: boolean;
   hasExistingOllama: boolean;
   onOllamaConnect?: () => void;
+  existingProviders?: DynamicProviderResponse[];
+  onDeleteExisting: (id: string) => void;
 }) {
   const hasProvider = hasExistingOpenRouter || hasExistingOllama;
   return (
@@ -314,18 +341,13 @@ function WelcomeStep({
         <h3 className="text-base font-semibold">Connect your providers</h3>
 
         {hasExistingOpenRouter ? (
-          <div className="mt-4 rounded-lg border border-border bg-muted/30 p-4">
-            <div className="flex items-center justify-between">
-              <div>
-                <p className="text-sm font-medium">OpenRouter</p>
-                <p className="text-xs text-muted-foreground">https://openrouter.ai/api/v1</p>
-              </div>
-              <div className="flex items-center gap-1.5 text-sm text-green-700 dark:text-green-400">
-                <CheckCircle2 className="h-4 w-4" />
-                Connected
-              </div>
-            </div>
-          </div>
+          <ExistingProviderCard
+            name="OpenRouter"
+            baseUrl="https://openrouter.ai/api/v1"
+            className="mt-4"
+            existingProviders={existingProviders}
+            onDelete={onDeleteExisting}
+          />
         ) : (
           <div className="mt-4 rounded-lg border border-violet-200 bg-violet-50 p-4 dark:border-violet-500/20 dark:bg-violet-500/5">
             <p className="text-sm font-medium mb-1">OpenRouter</p>
@@ -348,18 +370,13 @@ function WelcomeStep({
         )}
 
         {hasExistingOllama ? (
-          <div className="mt-3 rounded-lg border border-border bg-muted/30 p-4">
-            <div className="flex items-center justify-between">
-              <div>
-                <p className="text-sm font-medium">Ollama</p>
-                <p className="text-xs text-muted-foreground">http://localhost:11434/v1</p>
-              </div>
-              <div className="flex items-center gap-1.5 text-sm text-green-700 dark:text-green-400">
-                <CheckCircle2 className="h-4 w-4" />
-                Connected
-              </div>
-            </div>
-          </div>
+          <ExistingProviderCard
+            name="Ollama"
+            baseUrl="http://localhost:11434/v1"
+            className="mt-3"
+            existingProviders={existingProviders}
+            onDelete={onDeleteExisting}
+          />
         ) : ollamaDetected ? (
           <div className="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 p-4 dark:border-emerald-500/20 dark:bg-emerald-500/5">
             <p className="text-sm font-medium mb-1">Ollama</p>
@@ -504,6 +521,8 @@ function ProvidersStep({
   ollamaConnecting,
   hasExistingOllama,
   onOllamaConnect,
+  existingProviders,
+  onDeleteExisting,
 }: {
   entries: ProviderEntry[];
   onUpdate: (key: string, update: Partial<ProviderEntry>) => void;
@@ -523,6 +542,8 @@ function ProvidersStep({
   ollamaConnecting?: boolean;
   hasExistingOllama: boolean;
   onOllamaConnect?: () => void;
+  existingProviders?: DynamicProviderResponse[];
+  onDeleteExisting: (id: string) => void;
 }) {
   return (
     <>
@@ -533,18 +554,13 @@ function ProvidersStep({
       <ModalContent>
         {/* OpenRouter OAuth section */}
         {hasExistingOpenRouter ? (
-          <div className="mb-4 rounded-lg border border-border bg-muted/30 p-4">
-            <div className="flex items-center justify-between">
-              <div>
-                <p className="text-sm font-medium">OpenRouter</p>
-                <p className="text-xs text-muted-foreground">https://openrouter.ai/api/v1</p>
-              </div>
-              <div className="flex items-center gap-1.5 text-sm text-green-700 dark:text-green-400">
-                <CheckCircle2 className="h-4 w-4" />
-                Connected
-              </div>
-            </div>
-          </div>
+          <ExistingProviderCard
+            name="OpenRouter"
+            baseUrl="https://openrouter.ai/api/v1"
+            className="mb-4"
+            existingProviders={existingProviders}
+            onDelete={onDeleteExisting}
+          />
         ) : (
           <div className="mb-4 rounded-lg border border-violet-200 bg-violet-50/50 p-3 dark:border-violet-500/20 dark:bg-violet-500/5">
             <div className="flex items-center justify-between gap-3">
@@ -578,18 +594,13 @@ function ProvidersStep({
 
         {/* Ollama section */}
         {hasExistingOllama ? (
-          <div className="mb-4 rounded-lg border border-border bg-muted/30 p-4">
-            <div className="flex items-center justify-between">
-              <div>
-                <p className="text-sm font-medium">Ollama</p>
-                <p className="text-xs text-muted-foreground">http://localhost:11434/v1</p>
-              </div>
-              <div className="flex items-center gap-1.5 text-sm text-green-700 dark:text-green-400">
-                <CheckCircle2 className="h-4 w-4" />
-                Connected
-              </div>
-            </div>
-          </div>
+          <ExistingProviderCard
+            name="Ollama"
+            baseUrl="http://localhost:11434/v1"
+            className="mb-4"
+            existingProviders={existingProviders}
+            onDelete={onDeleteExisting}
+          />
         ) : ollamaDetected ? (
           <div className="mb-4 rounded-lg border border-emerald-200 bg-emerald-50/50 p-3 dark:border-emerald-500/20 dark:bg-emerald-500/5">
             <div className="flex items-center justify-between gap-3">
@@ -669,6 +680,48 @@ function ProvidersStep({
   );
 }
 
+function ExistingProviderCard({
+  name,
+  baseUrl,
+  className,
+  existingProviders,
+  onDelete,
+}: {
+  name: string;
+  baseUrl: string;
+  className?: string;
+  existingProviders?: DynamicProviderResponse[];
+  onDelete: (id: string) => void;
+}) {
+  const provider = existingProviders?.find((p) => p.base_url.includes(new URL(baseUrl).host));
+  return (
+    <div className={cn("rounded-lg border border-border bg-muted/30 p-4", className)}>
+      <div className="flex items-center justify-between">
+        <div>
+          <p className="text-sm font-medium">{name}</p>
+          <p className="text-xs text-muted-foreground">{baseUrl}</p>
+        </div>
+        <div className="flex items-center gap-2">
+          <div className="flex items-center gap-1.5 text-sm text-green-700 dark:text-green-400">
+            <CheckCircle2 className="h-4 w-4" />
+            Connected
+          </div>
+          {provider && (
+            <button
+              type="button"
+              onClick={() => onDelete(provider.id)}
+              className="text-muted-foreground hover:text-destructive"
+              aria-label={`Remove ${name} provider`}
+            >
+              <Trash2 className="h-3.5 w-3.5" />
+            </button>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}
+
 function ProviderKeyEntry({
   entry,
   canRemove,

From 66fbf130d9a397741e08addc5968f6959a3a1b30 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Tue, 24 Mar 2026 22:45:37 +1000
Subject: [PATCH 10/25] Review changes

---
 .../MultiModelResponse/MultiModelResponse.tsx   |  2 +-
 ui/src/pages/chat/useChat.ts                    | 17 +++++++----------
 ui/src/stores/streamingStore.ts                 |  6 ++++--
 3 files changed, 12 insertions(+), 13 deletions(-)

diff --git a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
index 9497f98d..669b3d43 100644
--- a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
+++ b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
@@ -561,7 +561,7 @@ const ModelResponseCard = memo(function ModelResponseCard({
   // Only query streaming store for actively-streaming responses — committed
   // messages must not read the current stream's state (same model key would
   // leak status from a newer message into older ones).
-  const streamingModelKey = response.isStreaming ? model : "";
+  const streamingModelKey = response.isStreaming ? instanceId : "";
   const hasActiveToolCalls = useHasActiveToolCalls(streamingModelKey);
   const hasRunningExecution = useHasRunningExecution(streamingModelKey);
   const hasActiveTools = hasActiveToolCalls || hasRunningExecution;
diff --git a/ui/src/pages/chat/useChat.ts b/ui/src/pages/chat/useChat.ts
index 949286ac..44cf883f 100644
--- a/ui/src/pages/chat/useChat.ts
+++ b/ui/src/pages/chat/useChat.ts
@@ -199,7 +199,7 @@ interface UseChatReturn {
 }
 
 /** Maximum number of tool execution iterations to prevent infinite loops */
-const MAX_TOOL_ITERATIONS = 5;
+const MAX_TOOL_ITERATIONS = 25;
 
 /** Result from streaming a response, including any tool calls */
 interface StreamResponseResult {
@@ -883,12 +883,8 @@ export function useChat({
                   // Final reasoning text
                   reasoningContent = event.text;
                   streamingStore.setReasoningContent(storeKey, reasoningContent);
-                } else if (event.type === "response.output_text.done" && event.text) {
-                  // Use the final text from done event for the local content.
-                  // Don't call setContent on the streaming store — appendContent
-                  // already accumulated the right content, and setContent would
-                  // overwrite content from earlier rounds in multi-round tool use.
-                  content = event.text;
+                } else if (event.type === "response.output_text.done") {
+                  // Completion signal only — streamed deltas are authoritative.
                 } else if (event.type === "response.output_item.done" && event.item) {
                   // Handle file_search_call output items (server-side file search)
                   if (event.item.type === "file_search_call" && event.item.results) {
@@ -1024,9 +1020,10 @@ export function useChat({
                     streamingStore.setReasoningContent(storeKey, reasoningContent);
                   }
 
-                  // Prefer actual output over reasoning, but use reasoning as fallback
-                  const finalText = outputText || reasoningText || content;
-                  content = finalText;
+                  // Only use response object text as fallback when no streamed deltas were received
+                  if (!hasOutputText) {
+                    content = outputText || reasoningText || content;
+                  }
 
                   // Extract usage data if present
                   if (event.response.usage) {
diff --git a/ui/src/stores/streamingStore.ts b/ui/src/stores/streamingStore.ts
index 99ffab76..41c269e4 100644
--- a/ui/src/stores/streamingStore.ts
+++ b/ui/src/stores/streamingStore.ts
@@ -1148,10 +1148,12 @@ export const useStreamingStore = create<StreamingStore>((set) => ({
       if (!existing) return state;
 
       const existingArtifacts = existing.artifacts ?? [];
-      const newArtifacts = [...existingArtifacts, ...artifacts];
+      const existingIds = new Set(existingArtifacts.map((a) => a.id));
+      const deduped = artifacts.filter((a) => !existingIds.has(a.id));
+      if (deduped.length === 0) return state;
 
       const newStreams = new Map(state.streams);
-      newStreams.set(model, { ...existing, artifacts: newArtifacts });
+      newStreams.set(model, { ...existing, artifacts: [...existingArtifacts, ...deduped] });
       return { streams: newStreams };
     }),
 

From da7f6c6e2bfa32c1c175cb7e4397fcd2696911a8 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Wed, 25 Mar 2026 17:02:56 +1000
Subject: [PATCH 11/25] Handle single rounds and regeneration better

---
 .../ConversationsProvider.tsx                 |  19 +-
 .../MultiModelResponse/MultiModelResponse.tsx | 179 ++++++------------
 ui/src/pages/chat/useChat.ts                  |  43 ++++-
 ui/src/stores/conversationStore.ts            |  12 +-
 4 files changed, 102 insertions(+), 151 deletions(-)

diff --git a/ui/src/components/ConversationsProvider/ConversationsProvider.tsx b/ui/src/components/ConversationsProvider/ConversationsProvider.tsx
index 05967ccf..25a2bf29 100644
--- a/ui/src/components/ConversationsProvider/ConversationsProvider.tsx
+++ b/ui/src/components/ConversationsProvider/ConversationsProvider.tsx
@@ -31,24 +31,13 @@ const SYNC_DEBOUNCE_MS = 2000;
 const MAX_RETRY_ATTEMPTS = 3;
 const BASE_RETRY_DELAY_MS = 1000;
 
+/** Stored message: same as ChatMessage but with timestamp serialized to string. */
+type StoredMessage = Omit<ChatMessage, "timestamp"> & { timestamp: string };
+
 interface StoredConversation {
   id: string;
   title: string;
-  messages: Array<{
-    id: string;
-    role: "user" | "assistant" | "system";
-    content: string;
-    model?: string;
-    timestamp: string;
-    files?: Array<{
-      id: string;
-      name: string;
-      type: string;
-      size: number;
-      base64: string;
-      preview?: string;
-    }>;
-  }>;
+  messages: StoredMessage[];
   models: string[];
   createdAt: string;
   updatedAt: string;
diff --git a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
index 669b3d43..4011050f 100644
--- a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
+++ b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
@@ -35,7 +35,6 @@ import { ArtifactList, ArtifactModal } from "@/components/Artifact";
 import { CitationList } from "@/components/CitationList";
 import { DebugModal } from "@/components/DebugModal";
 import { QuoteSelectionPopover } from "@/components/QuoteSelectionPopover";
-import { ToolExecutionBlock } from "@/components/ToolExecution";
 import type { Artifact as ArtifactType, DisplaySelectionData } from "@/components/chat-types";
 import { useDebugInfo } from "@/stores/debugStore";
 import { ContentRound } from "./ContentRound";
@@ -54,8 +53,6 @@ import type {
   ToolExecutionRound,
 } from "@/components/chat-types";
 import { DEFAULT_ACTION_CONFIG } from "@/components/chat-types";
-import { ReasoningSection } from "@/components/ReasoningSection/ReasoningSection";
-import { StreamingMarkdown } from "@/components/StreamingMarkdown/StreamingMarkdown";
 import {
   ResponseActions,
   type ResponseActionConfig as ActionConfig,
@@ -708,12 +705,12 @@ const ModelResponseCard = memo(function ModelResponseCard({
   }, [streamingToolExecutionRounds, response.toolExecutionRounds]);
   const hasToolExecutionRounds = toolExecutionRounds.length > 0;
 
+  const completedRounds = response.completedRounds ?? [];
+
   // All output artifacts across all rounds (for resolving display_artifacts selections)
   const allOutputArtifacts = useMemo(() => {
     const result: ArtifactType[] = [];
-    // Check completedRounds first (unified source for multi-round)
-    const rounds = response.completedRounds ?? [];
-    for (const round of rounds) {
+    for (const round of completedRounds) {
       if (round.toolExecution) {
         for (const execution of round.toolExecution.executions) {
           for (const a of execution.outputArtifacts) {
@@ -722,16 +719,8 @@ const ModelResponseCard = memo(function ModelResponseCard({
         }
       }
     }
-    // Also check standalone toolExecutionRounds (single-round path)
-    for (const round of toolExecutionRounds) {
-      for (const execution of round.executions) {
-        for (const a of execution.outputArtifacts) {
-          if (a.type !== "display_selection") result.push(a);
-        }
-      }
-    }
     return result;
-  }, [response.completedRounds, toolExecutionRounds]);
+  }, [completedRounds]);
 
   // Extract display selection for a specific tool execution round
   const getDisplaySelectionForRound = useCallback(
@@ -747,13 +736,6 @@ const ModelResponseCard = memo(function ModelResponseCard({
     []
   );
 
-  // Global display selection (for single-round fallback)
-  const displaySelection = useMemo((): DisplaySelectionData | null => {
-    const selectionArtifact = artifacts.find((a) => a.type === "display_selection");
-    if (!selectionArtifact) return null;
-    return selectionArtifact.data as DisplaySelectionData;
-  }, [artifacts]);
-
   // Measure header width to determine if we should collapse controls
   const headerRef = useRef<HTMLDivElement>(null);
   const [isCollapsed, setIsCollapsed] = useState(false);
@@ -941,119 +923,70 @@ const ModelResponseCard = memo(function ModelResponseCard({
           </div>
         ) : (
           <>
-            {/* Content: rounds or single-round reasoning + content */}
+            {/* Content: unified rendering via ContentRound for all responses */}
             {(() => {
-              const rounds = response.completedRounds;
-              if (rounds && rounds.length > 0) {
-                // Multi-round: render each completed round + in-flight round
-                const currentReasoning =
-                  response.reasoningContent &&
-                  !rounds.some((r) => r.reasoning === response.reasoningContent)
-                    ? response.reasoningContent
-                    : null;
-                const currentContent =
-                  response.isStreaming && response.content?.trim() ? response.content : null;
-                return (
-                  <div className="space-y-3">
-                    {rounds.map((round, i) => (
-                      <ContentRound
-                        key={i}
-                        reasoning={round.reasoning}
-                        content={round.content}
-                        toolExecutionRound={round.toolExecution}
-                        isToolsStreaming={
-                          response.isStreaming &&
-                          i === rounds.length - 1 &&
-                          !!round.toolExecution?.executions.some(
-                            (e) => e.status === "pending" || e.status === "running"
-                          )
-                        }
-                        onArtifactClick={handleArtifactClick}
-                        displaySelection={
-                          round.toolExecution
-                            ? getDisplaySelectionForRound(round.toolExecution)
-                            : null
-                        }
-                        allOutputArtifacts={allOutputArtifacts}
-                      />
-                    ))}
-                    {(currentReasoning || currentContent) && (
-                      <ContentRound
-                        reasoning={currentReasoning}
-                        content={currentContent}
-                        isStreaming={response.isStreaming}
-                        isReasoningStreaming={response.isStreaming && !currentContent}
-                      />
-                    )}
-                    <StreamingStatusIndicator
-                      phase={streamingPhase}
-                      toolStatusMessage={toolStatusMessage}
-                    />
-                  </div>
-                );
-              }
-              // Single-round: reasoning then content
-              const singleReasoning = response.reasoningContent || response.usage?.reasoningContent;
-              if (compactMode) {
-                return (
-                  <>
-                    {response.content && (
-                      <StreamingMarkdown
-                        content={response.content}
-                        isStreaming={response.isStreaming}
-                      />
-                    )}
-                  </>
-                );
-              }
+              // Detect in-flight content that hasn't been captured in a completed round yet
+              const currentReasoning =
+                response.isStreaming &&
+                response.reasoningContent &&
+                !completedRounds.some((r) => r.reasoning === response.reasoningContent)
+                  ? response.reasoningContent
+                  : null;
+              const currentContent =
+                response.isStreaming && response.content?.trim() ? response.content : null;
+              // During first streaming frame (no rounds yet), show in-flight content directly
+              const showInFlight =
+                completedRounds.length === 0 || currentReasoning || currentContent;
               return (
-                <>
-                  {singleReasoning && (
-                    <ReasoningSection
-                      content={singleReasoning}
-                      isStreaming={response.isStreaming && !response.content}
-                      tokenCount={response.usage?.reasoningTokens}
+                <div className="space-y-3">
+                  {completedRounds.map((round, i) => (
+                    <ContentRound
+                      key={i}
+                      reasoning={round.reasoning}
+                      content={round.content}
+                      reasoningTokenCount={
+                        completedRounds.length === 1 ? response.usage?.reasoningTokens : undefined
+                      }
+                      toolExecutionRound={round.toolExecution}
+                      isToolsStreaming={
+                        response.isStreaming &&
+                        i === completedRounds.length - 1 &&
+                        !!round.toolExecution?.executions.some(
+                          (e) => e.status === "pending" || e.status === "running"
+                        )
+                      }
+                      onArtifactClick={handleArtifactClick}
+                      displaySelection={
+                        round.toolExecution
+                          ? getDisplaySelectionForRound(round.toolExecution)
+                          : null
+                      }
+                      allOutputArtifacts={allOutputArtifacts}
                     />
-                  )}
-                  {response.content && (
-                    <StreamingMarkdown
-                      content={response.content}
+                  ))}
+                  {showInFlight && completedRounds.length > 0 && (
+                    <ContentRound
+                      reasoning={currentReasoning}
+                      content={currentContent}
                       isStreaming={response.isStreaming}
+                      isReasoningStreaming={response.isStreaming && !currentContent}
                     />
                   )}
-                </>
+                  <StreamingStatusIndicator
+                    phase={streamingPhase}
+                    toolStatusMessage={toolStatusMessage}
+                  />
+                </div>
               );
             })()}
-            {/* Streaming status indicator (outside multi-round div for single-round paths) */}
-            {!response.completedRounds?.length && (
-              <StreamingStatusIndicator
-                phase={streamingPhase}
-                toolStatusMessage={toolStatusMessage}
-              />
-            )}
             {/* Citations from file_search/web_search */}
             {hasCitations && (
               <CitationList citations={citations} className="mt-4 pt-4 border-t" compact={false} />
             )}
-            {/* Tool execution / artifact display */}
-            {compactMode
-              ? null
-              : hasToolExecutionRounds
-                ? // Multi-round with completedRounds: per-round tool execution + artifacts rendered above.
-                  // Only show ToolExecutionBlock for single-round fallback (no completedRounds).
-                  !response.completedRounds && (
-                    <div className="mt-4 pt-4 border-t">
-                      <ToolExecutionBlock
-                        rounds={toolExecutionRounds}
-                        isStreaming={response.isStreaming}
-                        onArtifactClick={handleArtifactClick}
-                        displaySelection={displaySelection}
-                      />
-                    </div>
-                  )
-                : hasArtifacts && (
-                    <ArtifactList artifacts={artifacts} className="mt-4 pt-4 border-t" />
-                  )}
+            {/* Standalone artifacts (not from tool execution) */}
+            {!compactMode && hasArtifacts && !hasToolExecutionRounds && (
+              <ArtifactList artifacts={artifacts} className="mt-4 pt-4 border-t" />
+            )}
           </>
         )}
       </div>
diff --git a/ui/src/pages/chat/useChat.ts b/ui/src/pages/chat/useChat.ts
index 44cf883f..976f287a 100644
--- a/ui/src/pages/chat/useChat.ts
+++ b/ui/src/pages/chat/useChat.ts
@@ -1896,19 +1896,43 @@ export function useChat({
       const controller = new AbortController();
       abortControllersRef.current = [controller];
 
-      // Stream the response
-      const result = await streamResponse(model, inputItems, controller, settings);
+      const debugMessageId = `msg_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
+
+      // Stream the response (with tool execution support)
+      const result = await streamWithToolExecution(
+        model,
+        inputItems,
+        controller,
+        settings,
+        model,
+        debugMessageId
+      );
 
       if (result !== null) {
-        // Use the conversation store's replaceAssistantMessage action
-        replaceAssistantMessage(userMessageId, model, result.content, result.usage);
+        const stream = useStreamingStore.getState().streams.get(model);
+        replaceAssistantMessage(userMessageId, model, {
+          content: result.content,
+          usage: result.usage,
+          completedRounds: stream?.completedRounds.length ? stream.completedRounds : undefined,
+          toolExecutionRounds: stream?.toolExecutionRounds,
+          citations: stream?.citations,
+          artifacts: stream?.artifacts,
+          debugMessageId,
+        });
       }
 
       // Clear streaming state
       streamingStore.clearStreams();
       abortControllersRef.current = [];
     },
-    [messages, settings, historyMode, streamResponse, streamingStore, replaceAssistantMessage]
+    [
+      messages,
+      settings,
+      historyMode,
+      streamWithToolExecution,
+      streamingStore,
+      replaceAssistantMessage,
+    ]
   );
 
   /**
@@ -1954,6 +1978,7 @@ export function useChat({
         abortControllersRef.current = controllers;
 
         const filterFn = createFilterFn();
+        const debugMessageId = `msg_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
 
         // Get messages up to and including the edited message (use updated content)
         // We need to read the latest messages from the store after our updates
@@ -1969,7 +1994,7 @@ export function useChat({
             controllers[index],
             settings,
             instance.id,
-            undefined,
+            debugMessageId,
             instance.parameters,
             instance.label
           );
@@ -1990,6 +2015,8 @@ export function useChat({
           citations?: Citation[];
           artifacts?: Artifact[];
           toolExecutionRounds?: ToolExecutionRound[];
+          completedRounds?: CompletedRound[];
+          debugMessageId?: string;
         }> = [];
 
         for (let index = 0; index < instances.length; index++) {
@@ -2005,6 +2032,8 @@ export function useChat({
               citations: stream?.citations,
               artifacts: stream?.artifacts,
               toolExecutionRounds: stream?.toolExecutionRounds,
+              completedRounds: stream?.completedRounds.length ? stream.completedRounds : undefined,
+              debugMessageId,
             });
           } else if (stream?.error) {
             allResponses.push({
@@ -2015,6 +2044,8 @@ export function useChat({
               citations: stream?.citations,
               artifacts: stream?.artifacts,
               toolExecutionRounds: stream?.toolExecutionRounds,
+              completedRounds: stream?.completedRounds.length ? stream.completedRounds : undefined,
+              debugMessageId,
             });
           }
         }
diff --git a/ui/src/stores/conversationStore.ts b/ui/src/stores/conversationStore.ts
index c76cea83..019535f0 100644
--- a/ui/src/stores/conversationStore.ts
+++ b/ui/src/stores/conversationStore.ts
@@ -113,8 +113,7 @@ interface ConversationActions {
   replaceAssistantMessage: (
     userMessageId: string,
     model: string,
-    content: string,
-    usage?: MessageUsage
+    updates: Partial<ChatMessage>
   ) => void;
   /** Set feedback on a message */
   setMessageFeedback: (userMessageId: string, model: string, feedback: ResponseFeedback) => void;
@@ -223,7 +222,7 @@ export const useConversationStore = create<ConversationStore>((set) => ({
       return { messages: state.messages.slice(0, messageIndex + 1) };
     }),
 
-  replaceAssistantMessage: (userMessageId, model, content, usage) =>
+  replaceAssistantMessage: (userMessageId, model, updates) =>
     set((state) => {
       const messages = [...state.messages];
       const userIndex = messages.findIndex((m) => m.id === userMessageId);
@@ -235,8 +234,7 @@ export const useConversationStore = create<ConversationStore>((set) => ({
         if (messages[i].role === "assistant" && messages[i].model === model) {
           messages[i] = {
             ...messages[i],
-            content,
-            usage,
+            ...updates,
             timestamp: new Date(),
           };
           replaced = true;
@@ -253,10 +251,10 @@ export const useConversationStore = create<ConversationStore>((set) => ({
         messages.splice(insertIndex, 0, {
           id: crypto.randomUUID(),
           role: "assistant",
-          content,
+          content: updates.content ?? "",
           model,
           timestamp: new Date(),
-          usage,
+          ...updates,
         });
       }
 

From bfd8b24426fb0752238735c54156aced3b0ede3f Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Wed, 25 Mar 2026 17:09:34 +1000
Subject: [PATCH 12/25] Use stream field helper

---
 ui/src/pages/chat/useChat.ts | 78 ++++++++++++++++--------------------
 1 file changed, 34 insertions(+), 44 deletions(-)

diff --git a/ui/src/pages/chat/useChat.ts b/ui/src/pages/chat/useChat.ts
index 976f287a..4163896d 100644
--- a/ui/src/pages/chat/useChat.ts
+++ b/ui/src/pages/chat/useChat.ts
@@ -1,7 +1,12 @@
 import { useCallback, useRef } from "react";
 
 import { useAuth } from "@/auth";
-import { useStreamingStore, useAllStreams, useIsStreaming } from "@/stores/streamingStore";
+import {
+  useStreamingStore,
+  useAllStreams,
+  useIsStreaming,
+  type StreamingResponse,
+} from "@/stores/streamingStore";
 import {
   useConversationStore,
   useMessages,
@@ -220,6 +225,16 @@ interface StreamResponseResult {
   responseOutput?: unknown[];
 }
 
+/** Extract the metadata fields from a streaming response for committing to the conversation store. */
+function commitFieldsFromStream(stream: StreamingResponse | undefined) {
+  return {
+    citations: stream?.citations,
+    artifacts: stream?.artifacts,
+    toolExecutionRounds: stream?.toolExecutionRounds,
+    completedRounds: stream?.completedRounds.length ? stream.completedRounds : undefined,
+  };
+}
+
 export function useChat({
   models,
   settings,
@@ -1774,10 +1789,7 @@ export function useChat({
               content: result.content,
               usage: result.usage,
               modeMetadata: result.modeMetadata,
-              citations: stream?.citations,
-              artifacts: stream?.artifacts,
-              toolExecutionRounds: stream?.toolExecutionRounds,
-              completedRounds: stream?.completedRounds.length ? stream.completedRounds : undefined,
+              ...commitFieldsFromStream(stream),
             });
           }
         }
@@ -1793,10 +1805,7 @@ export function useChat({
               content: result.content,
               usage: result.usage,
               modeMetadata: result.modeMetadata,
-              citations: stream?.citations,
-              artifacts: stream?.artifacts,
-              toolExecutionRounds: stream?.toolExecutionRounds,
-              completedRounds: stream?.completedRounds.length ? stream.completedRounds : undefined,
+              ...commitFieldsFromStream(stream),
             });
           }
         }
@@ -1807,6 +1816,8 @@ export function useChat({
           const instance = instances[index];
           // Use instance ID for stream lookup (streams are keyed by instance ID)
           const stream = currentStreams.get(instance.id);
+          // Only include debugMessageId for multiple mode (default)
+          const msgDebugId = conversationMode === "multiple" ? debugMessageId : undefined;
           if (result !== null) {
             allResponses.push({
               model: instance.modelId,
@@ -1814,30 +1825,18 @@ export function useChat({
               content: result.content,
               usage: result.usage,
               modeMetadata: result.modeMetadata,
-              citations: stream?.citations,
-              artifacts: stream?.artifacts,
-              toolExecutionRounds: stream?.toolExecutionRounds,
-              completedRounds: stream?.completedRounds.length ? stream.completedRounds : undefined,
-              // Only include debugMessageId for multiple mode (default)
-              debugMessageId: conversationMode === "multiple" ? debugMessageId : undefined,
+              debugMessageId: msgDebugId,
+              ...commitFieldsFromStream(stream),
+            });
+          } else if (stream?.error) {
+            allResponses.push({
+              model: instance.modelId,
+              instanceId: instance.id,
+              content: "",
+              error: stream.error,
+              debugMessageId: msgDebugId,
+              ...commitFieldsFromStream(stream),
             });
-          } else {
-            // Check if this instance has an error in the streaming store
-            if (stream?.error) {
-              allResponses.push({
-                model: instance.modelId,
-                instanceId: instance.id,
-                content: "",
-                error: stream.error,
-                citations: stream?.citations,
-                artifacts: stream?.artifacts,
-                toolExecutionRounds: stream?.toolExecutionRounds,
-                completedRounds: stream?.completedRounds.length
-                  ? stream.completedRounds
-                  : undefined,
-                debugMessageId: conversationMode === "multiple" ? debugMessageId : undefined,
-              });
-            }
           }
         }
       }
@@ -1913,11 +1912,8 @@ export function useChat({
         replaceAssistantMessage(userMessageId, model, {
           content: result.content,
           usage: result.usage,
-          completedRounds: stream?.completedRounds.length ? stream.completedRounds : undefined,
-          toolExecutionRounds: stream?.toolExecutionRounds,
-          citations: stream?.citations,
-          artifacts: stream?.artifacts,
           debugMessageId,
+          ...commitFieldsFromStream(stream),
         });
       }
 
@@ -2029,11 +2025,8 @@ export function useChat({
               instanceId: instance.id,
               content: result.content,
               usage: result.usage,
-              citations: stream?.citations,
-              artifacts: stream?.artifacts,
-              toolExecutionRounds: stream?.toolExecutionRounds,
-              completedRounds: stream?.completedRounds.length ? stream.completedRounds : undefined,
               debugMessageId,
+              ...commitFieldsFromStream(stream),
             });
           } else if (stream?.error) {
             allResponses.push({
@@ -2041,11 +2034,8 @@ export function useChat({
               instanceId: instance.id,
               content: "",
               error: stream.error,
-              citations: stream?.citations,
-              artifacts: stream?.artifacts,
-              toolExecutionRounds: stream?.toolExecutionRounds,
-              completedRounds: stream?.completedRounds.length ? stream.completedRounds : undefined,
               debugMessageId,
+              ...commitFieldsFromStream(stream),
             });
           }
         }

From c601776c74625ff8a905e206b2f3d1746d6ea14d Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Wed, 25 Mar 2026 17:24:50 +1000
Subject: [PATCH 13/25] Customise the number of rounds

---
 ui/src/components/ChatView/ChatView.tsx       |  5 ++
 .../ConversationSettingsModal.stories.tsx     |  9 ++-
 .../ConversationSettingsModal.tsx             | 69 +++++++++++++------
 ui/src/pages/chat/ChatPage.tsx                |  3 +
 ui/src/pages/chat/useChat.ts                  | 15 ++--
 ui/src/stores/chatUIStore.ts                  | 14 ++++
 6 files changed, 89 insertions(+), 26 deletions(-)

diff --git a/ui/src/components/ChatView/ChatView.tsx b/ui/src/components/ChatView/ChatView.tsx
index ad6dcef9..e40a662e 100644
--- a/ui/src/components/ChatView/ChatView.tsx
+++ b/ui/src/components/ChatView/ChatView.tsx
@@ -15,6 +15,7 @@ import {
   useVectorStoreIds,
   useClientSideRAG,
   useEnabledTools,
+  useMaxToolIterations,
   useCaptureRawSSEEvents,
   useTTSVoice,
   useTTSSpeed,
@@ -107,6 +108,7 @@ export function ChatView({
   const vectorStoreIds = useVectorStoreIds();
   const clientSideRAG = useClientSideRAG();
   const enabledTools = useEnabledTools();
+  const maxToolIterations = useMaxToolIterations();
   const captureRawSSEEvents = useCaptureRawSSEEvents();
   const ttsVoice = useTTSVoice();
   const ttsSpeed = useTTSSpeed();
@@ -125,6 +127,7 @@ export function ChatView({
     setVectorStoreIds,
     setClientSideRAG,
     setEnabledTools,
+    setMaxToolIterations,
     setCaptureRawSSEEvents,
     setTTSVoice,
     setTTSSpeed,
@@ -254,6 +257,8 @@ export function ChatView({
         vectorStoreOwnerId={vectorStoreOwnerId}
         clientSideRAG={clientSideRAG}
         onClientSideRAGChange={setClientSideRAG}
+        maxToolIterations={maxToolIterations}
+        onMaxToolIterationsChange={setMaxToolIterations}
         captureRawSSEEvents={captureRawSSEEvents}
         onCaptureRawSSEEventsChange={setCaptureRawSSEEvents}
         ttsVoice={ttsVoice}
diff --git a/ui/src/components/ConversationSettingsModal/ConversationSettingsModal.stories.tsx b/ui/src/components/ConversationSettingsModal/ConversationSettingsModal.stories.tsx
index a27fc657..d2878196 100644
--- a/ui/src/components/ConversationSettingsModal/ConversationSettingsModal.stories.tsx
+++ b/ui/src/components/ConversationSettingsModal/ConversationSettingsModal.stories.tsx
@@ -383,12 +383,14 @@ export const WithPromptTemplates: Story = {
 function WithDebugOptionsStory() {
   const [open, setOpen] = useState(true);
   const [systemPrompt, setSystemPrompt] = useState("");
+  const [maxToolIterations, setMaxToolIterations] = useState(25);
   const [captureRawSSEEvents, setCaptureRawSSEEvents] = useState(false);
 
   return (
     <>
       <Button onClick={() => setOpen(true)}>Open Settings</Button>
       <div className="mt-4 text-sm text-muted-foreground">
+        <div>Max tool iterations: {maxToolIterations}</div>
         <div>Capture SSE events: {captureRawSSEEvents ? "enabled" : "disabled"}</div>
       </div>
       <ConversationSettingsModal
@@ -396,6 +398,8 @@ function WithDebugOptionsStory() {
         onClose={() => setOpen(false)}
         systemPrompt={systemPrompt}
         onSystemPromptChange={setSystemPrompt}
+        maxToolIterations={maxToolIterations}
+        onMaxToolIterationsChange={setMaxToolIterations}
         captureRawSSEEvents={captureRawSSEEvents}
         onCaptureRawSSEEventsChange={setCaptureRawSSEEvents}
       />
@@ -414,6 +418,7 @@ function AllFeaturesStory() {
   );
   const [vectorStoreIds, setVectorStoreIds] = useState<string[]>(["vs_001", "vs_002"]);
   const [clientSideRAG, setClientSideRAG] = useState(false);
+  const [maxToolIterations, setMaxToolIterations] = useState(25);
   const [captureRawSSEEvents, setCaptureRawSSEEvents] = useState(false);
 
   return (
@@ -425,7 +430,7 @@ function AllFeaturesStory() {
           <li>System Prompt with templates</li>
           <li>Knowledge Base (vector stores)</li>
           <li>Response Actions</li>
-          <li>Debug Options</li>
+          <li>Advanced options</li>
         </ul>
         <p className="mt-2 text-xs">
           Note: Per-model parameters are now configured via the settings icon on each model chip in
@@ -443,6 +448,8 @@ function AllFeaturesStory() {
         vectorStoreOwnerId="user_123"
         clientSideRAG={clientSideRAG}
         onClientSideRAGChange={setClientSideRAG}
+        maxToolIterations={maxToolIterations}
+        onMaxToolIterationsChange={setMaxToolIterations}
         captureRawSSEEvents={captureRawSSEEvents}
         onCaptureRawSSEEventsChange={setCaptureRawSSEEvents}
       />
diff --git a/ui/src/components/ConversationSettingsModal/ConversationSettingsModal.tsx b/ui/src/components/ConversationSettingsModal/ConversationSettingsModal.tsx
index 72dcf29a..356f411f 100644
--- a/ui/src/components/ConversationSettingsModal/ConversationSettingsModal.tsx
+++ b/ui/src/components/ConversationSettingsModal/ConversationSettingsModal.tsx
@@ -41,6 +41,10 @@ interface ConversationSettingsModalProps {
   clientSideRAG?: boolean;
   /** Callback when client-side RAG setting changes */
   onClientSideRAGChange?: (enabled: boolean) => void;
+  /** Maximum tool execution iterations */
+  maxToolIterations?: number;
+  /** Callback when max tool iterations changes */
+  onMaxToolIterationsChange?: (iterations: number) => void;
   /** Whether to capture raw SSE events for debugging */
   captureRawSSEEvents?: boolean;
   /** Callback when SSE capture setting changes */
@@ -74,6 +78,8 @@ export function ConversationSettingsModal({
   vectorStoreOwnerId,
   clientSideRAG,
   onClientSideRAGChange,
+  maxToolIterations = 25,
+  onMaxToolIterationsChange,
   captureRawSSEEvents,
   onCaptureRawSSEEventsChange,
   ttsVoice = DEFAULT_TTS_VOICE,
@@ -91,6 +97,15 @@ export function ConversationSettingsModal({
     el.style.height = `${el.scrollHeight}px`;
   }, []);
 
+  // Stable ref callback so auto-resize only fires on mount, not every render
+  // (an inline ref would re-fire and collapse the textarea, scrolling the modal to top)
+  const textareaRef = useCallback(
+    (el: HTMLTextAreaElement | null) => {
+      if (el) autoResize(el);
+    },
+    [autoResize]
+  );
+
   const handleSaveAsTemplate = () => {
     setPromptFormOpen(true);
   };
@@ -168,9 +183,7 @@ export function ConversationSettingsModal({
             </div>
           </div>
           <textarea
-            ref={(el) => {
-              if (el) autoResize(el);
-            }}
+            ref={textareaRef}
             value={displayedPrompt}
             onChange={(e) => {
               onSystemPromptChange(e.target.value);
@@ -333,30 +346,44 @@ export function ConversationSettingsModal({
           </div>
         )}
 
-        {/* Debug Section */}
-        {onCaptureRawSSEEventsChange && (
+        {/* Advanced Section */}
+        {(onMaxToolIterationsChange || onCaptureRawSSEEventsChange) && (
           <div className="space-y-3 mb-6">
             <div>
-              <h3 className="text-sm font-medium mb-1">Debug Options</h3>
+              <h3 className="text-sm font-medium mb-1">Advanced</h3>
               <p className="text-xs text-muted-foreground">
-                Advanced options for debugging and development.
+                Advanced options for tool execution and debugging.
               </p>
             </div>
-            <div className="rounded-lg border p-4 space-y-3">
-              <div className="flex items-center justify-between">
-                <div>
-                  <span className="text-sm">Capture raw SSE events</span>
-                  <p className="text-xs text-muted-foreground mt-0.5">
-                    Record streaming events for inspection in the debug view. Can generate
-                    significant data.
-                  </p>
-                </div>
-                <Switch
-                  checked={captureRawSSEEvents ?? false}
-                  onChange={(e) => onCaptureRawSSEEventsChange(e.target.checked)}
-                  aria-label="Capture raw SSE events"
+            <div className="rounded-lg border p-4 space-y-4">
+              {onMaxToolIterationsChange && (
+                <Slider
+                  label="Max tool iterations"
+                  value={maxToolIterations}
+                  onChange={onMaxToolIterationsChange}
+                  min={1}
+                  max={100}
+                  step={1}
+                  showValue
+                  aria-label="Maximum tool iterations"
                 />
-              </div>
+              )}
+              {onCaptureRawSSEEventsChange && (
+                <div className="flex items-center justify-between">
+                  <div>
+                    <span className="text-sm">Capture raw SSE events</span>
+                    <p className="text-xs text-muted-foreground mt-0.5">
+                      Record streaming events for inspection in the debug view. Can generate
+                      significant data.
+                    </p>
+                  </div>
+                  <Switch
+                    checked={captureRawSSEEvents ?? false}
+                    onChange={(e) => onCaptureRawSSEEventsChange(e.target.checked)}
+                    aria-label="Capture raw SSE events"
+                  />
+                </div>
+              )}
             </div>
           </div>
         )}
diff --git a/ui/src/pages/chat/ChatPage.tsx b/ui/src/pages/chat/ChatPage.tsx
index 54009d0c..78d110b2 100644
--- a/ui/src/pages/chat/ChatPage.tsx
+++ b/ui/src/pages/chat/ChatPage.tsx
@@ -24,6 +24,7 @@ import {
   useClientSideRAG,
   useEnabledTools,
   useDataFiles,
+  useMaxToolIterations,
   useCaptureRawSSEEvents,
   useSubAgentModel,
 } from "@/stores/chatUIStore";
@@ -56,6 +57,7 @@ export default function ChatPage() {
   const clientSideRAG = useClientSideRAG();
   const enabledTools = useEnabledTools();
   const dataFiles = useDataFiles();
+  const maxToolIterations = useMaxToolIterations();
   const captureRawSSEEvents = useCaptureRawSSEEvents();
   const subAgentModel = useSubAgentModel();
 
@@ -164,6 +166,7 @@ export default function ChatPage() {
     clientSideToolExecution,
     enabledTools,
     dataFiles: registeredDataFiles.length > 0 ? registeredDataFiles : undefined,
+    maxToolIterations,
     captureRawSSEEvents,
     subAgentModel,
     projectId: currentConversation?.projectId ?? pendingProject.id ?? undefined,
diff --git a/ui/src/pages/chat/useChat.ts b/ui/src/pages/chat/useChat.ts
index 4163896d..55698c3e 100644
--- a/ui/src/pages/chat/useChat.ts
+++ b/ui/src/pages/chat/useChat.ts
@@ -113,6 +113,11 @@ interface UseChatOptions {
    * Used to build dynamic tool description with schema information.
    */
   dataFiles?: DataFileInfo[];
+  /**
+   * Maximum number of tool execution iterations to prevent infinite loops.
+   * Defaults to 25.
+   */
+  maxToolIterations?: number;
   /**
    * Whether to capture raw SSE events for debugging.
    * When enabled, SSE events are stored in debugStore for inspection.
@@ -203,8 +208,8 @@ interface UseChatReturn {
   editAndRerun: (messageId: string, newContent: string) => void;
 }
 
-/** Maximum number of tool execution iterations to prevent infinite loops */
-const MAX_TOOL_ITERATIONS = 25;
+/** Default maximum number of tool execution iterations to prevent infinite loops */
+const DEFAULT_maxToolIterations = 25;
 
 /** Result from streaming a response, including any tool calls */
 interface StreamResponseResult {
@@ -246,6 +251,7 @@ export function useChat({
   clientSideToolExecution = false,
   enabledTools = [],
   dataFiles = [],
+  maxToolIterations = DEFAULT_maxToolIterations,
   captureRawSSEEvents = false,
   subAgentModel,
   projectId,
@@ -1218,7 +1224,7 @@ export function useChat({
    * 1. Stream the initial response while tracking tool calls
    * 2. If tool calls are detected, execute them using the tool executor system
    * 3. Send the tool results back to continue the conversation
-   * 4. Repeat until no more tool calls or MAX_TOOL_ITERATIONS is reached
+   * 4. Repeat until no more tool calls or maxToolIterations is reached
    *
    * Also builds a ToolExecutionRound timeline for progressive disclosure UI.
    *
@@ -1302,7 +1308,7 @@ export function useChat({
       // Track execution rounds locally (also mirrored in store for real-time UI)
       const executionRounds: ToolExecutionRound[] = [];
 
-      while (iterations < MAX_TOOL_ITERATIONS) {
+      while (iterations < maxToolIterations) {
         iterations++;
         currentDebugRound = iterations;
 
@@ -1629,6 +1635,7 @@ export function useChat({
       token,
       streamingStore,
       debugStore,
+      maxToolIterations,
       captureRawSSEEvents,
       subAgentModel,
     ]
diff --git a/ui/src/stores/chatUIStore.ts b/ui/src/stores/chatUIStore.ts
index 17107195..f51f5a36 100644
--- a/ui/src/stores/chatUIStore.ts
+++ b/ui/src/stores/chatUIStore.ts
@@ -146,6 +146,11 @@ interface ChatUIState {
    * Files are registered in-memory and reset on page reload.
    */
   dataFiles: DataFile[];
+  /**
+   * Maximum number of tool execution iterations before stopping.
+   * Prevents infinite loops when client-side tool execution is enabled.
+   */
+  maxToolIterations: number;
   /**
    * Whether to capture raw SSE events during streaming for debugging.
    * Disabled by default as it can generate significant data.
@@ -283,6 +288,8 @@ interface ChatUIActions {
   ) => void;
   /** Clear all data files */
   clearDataFiles: () => void;
+  /** Set maximum tool execution iterations */
+  setMaxToolIterations: (iterations: number) => void;
   /** Set whether to capture raw SSE events */
   setCaptureRawSSEEvents: (capture: boolean) => void;
   /** Hide a specific response by groupId and instanceId */
@@ -376,6 +383,7 @@ const initialState: ChatUIState = {
   clientSideRAG: false,
   enabledTools: [],
   dataFiles: [],
+  maxToolIterations: 25,
   captureRawSSEEvents: false,
   hiddenResponseIds: new Set<string>(),
   ttsActiveResponseId: null,
@@ -566,6 +574,8 @@ export const useChatUIStore = create<ChatUIStore>((set) => ({
 
   clearDataFiles: () => set({ dataFiles: [] }),
 
+  setMaxToolIterations: (iterations) => set({ maxToolIterations: iterations }),
+
   setCaptureRawSSEEvents: (capture) => set({ captureRawSSEEvents: capture }),
 
   hideResponse: (groupId, instanceId) =>
@@ -732,6 +742,10 @@ export const useIsToolEnabled = (toolId: string) =>
 /** Get data files registered with DuckDB */
 export const useDataFiles = () => useChatUIStore((state: ChatUIState) => state.dataFiles);
 
+/** Get maximum tool execution iterations */
+export const useMaxToolIterations = () =>
+  useChatUIStore((state: ChatUIState) => state.maxToolIterations);
+
 /** Get whether to capture raw SSE events */
 export const useCaptureRawSSEEvents = () =>
   useChatUIStore((state: ChatUIState) => state.captureRawSSEEvents);

From 1164c54906ec726304de3522a8c8eb37eb2c9ea7 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Wed, 25 Mar 2026 17:49:31 +1000
Subject: [PATCH 14/25] Fix single-round rendering with small models

---
 .../MultiModelResponse/MultiModelResponse.tsx          | 10 +++++-----
 ui/src/pages/chat/useChat.ts                           |  9 +++++++++
 2 files changed, 14 insertions(+), 5 deletions(-)

diff --git a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
index 4011050f..3b0a680b 100644
--- a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
+++ b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
@@ -925,7 +925,9 @@ const ModelResponseCard = memo(function ModelResponseCard({
           <>
             {/* Content: unified rendering via ContentRound for all responses */}
             {(() => {
-              // Detect in-flight content that hasn't been captured in a completed round yet
+              // Detect in-flight content that hasn't been captured in a completed round yet.
+              // completedRounds is always populated (even for single-round responses),
+              // so this only shows content actively streaming in the current round.
               const currentReasoning =
                 response.isStreaming &&
                 response.reasoningContent &&
@@ -934,9 +936,7 @@ const ModelResponseCard = memo(function ModelResponseCard({
                   : null;
               const currentContent =
                 response.isStreaming && response.content?.trim() ? response.content : null;
-              // During first streaming frame (no rounds yet), show in-flight content directly
-              const showInFlight =
-                completedRounds.length === 0 || currentReasoning || currentContent;
+              const showInFlight = currentReasoning || currentContent;
               return (
                 <div className="space-y-3">
                   {completedRounds.map((round, i) => (
@@ -964,7 +964,7 @@ const ModelResponseCard = memo(function ModelResponseCard({
                       allOutputArtifacts={allOutputArtifacts}
                     />
                   ))}
-                  {showInFlight && completedRounds.length > 0 && (
+                  {showInFlight && (
                     <ContentRound
                       reasoning={currentReasoning}
                       content={currentContent}
diff --git a/ui/src/pages/chat/useChat.ts b/ui/src/pages/chat/useChat.ts
index 55698c3e..78f4ecc1 100644
--- a/ui/src/pages/chat/useChat.ts
+++ b/ui/src/pages/chat/useChat.ts
@@ -1296,6 +1296,15 @@ export function useChat({
           debugStore.endDebugRound(messageId, model, 1);
           debugStore.completeDebugCapture(messageId, model, true);
         }
+        // Push a completed round so the UI always has completedRounds to render
+        // (same as the tool-execution loop does after each iteration)
+        if (result) {
+          const roundData: CompletedRound = {};
+          if (result.reasoningContent) roundData.reasoning = result.reasoningContent;
+          if (result.hasOutputText && result.content?.trim()) roundData.content = result.content;
+          streamingStore.pushCompletedRound(storeKey, roundData);
+          result.completedRounds = [roundData];
+        }
         return result;
       }
 

From 6d5c7005ee867d7ba73adf515338186069ba74f9 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Wed, 25 Mar 2026 17:54:17 +1000
Subject: [PATCH 15/25] Nicer toolsbar

---
 ui/src/components/ToolsBar/ToolsBar.tsx | 32 +++++++++++++++++++++----
 1 file changed, 28 insertions(+), 4 deletions(-)

diff --git a/ui/src/components/ToolsBar/ToolsBar.tsx b/ui/src/components/ToolsBar/ToolsBar.tsx
index b18656a7..201cdeef 100644
--- a/ui/src/components/ToolsBar/ToolsBar.tsx
+++ b/ui/src/components/ToolsBar/ToolsBar.tsx
@@ -8,6 +8,7 @@
 
 import { ChevronDown, Loader2, Plus, Settings, Wrench, X } from "lucide-react";
 import { useState, useEffect, useCallback, useMemo, useRef } from "react";
+import { useIsMobile } from "@/hooks/useMediaQuery";
 
 import type { VectorStoreOwnerType } from "@/api/generated/types.gen";
 import { Button } from "@/components/Button/Button";
@@ -390,7 +391,7 @@ export function ToolsBar({
       // Delay exiting stable mode to allow popover transitions
       stableModeTimeoutRef.current = setTimeout(() => {
         setIsStableMode(false);
-      }, 300);
+      }, 3000);
     }
     return () => {
       if (stableModeTimeoutRef.current) {
@@ -443,8 +444,22 @@ export function ToolsBar({
     [enabledTools, onEnabledToolsChange]
   );
 
+  const isMobile = useIsMobile();
   const hasEnabledTools = enabledTools.length > 0;
 
+  /** Toggle all implemented tools on/off (desktop only) */
+  const toggleAllTools = useCallback(() => {
+    if (isMobile) return;
+    if (hasEnabledTools) {
+      onEnabledToolsChange([]);
+    } else {
+      const allImplemented = VISIBLE_TOOLS.filter((t) => t.implemented && canEnableTool(t.id)).map(
+        (t) => t.id
+      );
+      onEnabledToolsChange(allImplemented);
+    }
+  }, [isMobile, hasEnabledTools, onEnabledToolsChange, canEnableTool]);
+
   // All tools with metadata and icons
   const allToolsData = useMemo(
     (): ToolWithIcon[] =>
@@ -559,12 +574,21 @@ export function ToolsBar({
       onMouseEnter={() => setIsHovering(true)}
       onMouseLeave={() => setIsHovering(false)}
     >
-      {/* Wrench icon - always visible */}
-      <div className="flex items-center justify-center w-8 h-8 shrink-0">
+      {/* Wrench icon - always visible, toggles all tools on click (desktop only) */}
+      <button
+        type="button"
+        onClick={toggleAllTools}
+        aria-label={hasEnabledTools ? "Disable all tools" : "Enable all tools"}
+        className={cn(
+          "flex items-center justify-center w-8 h-8 shrink-0 rounded-md",
+          "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
+          !isMobile && "cursor-pointer hover:bg-accent-foreground/5"
+        )}
+      >
         <Wrench
           className={cn("h-4 w-4", hasEnabledTools ? "text-primary" : "text-muted-foreground")}
         />
-      </div>
+      </button>
 
       {/* Plus icon when no tools enabled and collapsed */}
       {!hasEnabledTools && !isStableMode && (

From dfe2f73a0ee5b80a4f6651b80647c5e3b5c1baf8 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Wed, 25 Mar 2026 18:01:08 +1000
Subject: [PATCH 16/25] Allow MCP servers in CSP

---
 docs/content/docs/configuration/server.mdx | 24 +++++++++++-----------
 docs/public/config-schema.json             |  6 +++---
 src/config/server.rs                       |  7 ++++---
 3 files changed, 19 insertions(+), 18 deletions(-)

diff --git a/docs/content/docs/configuration/server.mdx b/docs/content/docs/configuration/server.mdx
index b59296c0..108d6859 100644
--- a/docs/content/docs/configuration/server.mdx
+++ b/docs/content/docs/configuration/server.mdx
@@ -140,25 +140,25 @@ permissions_policy = "geolocation=(), microphone=()"
 
 ### Default Content Security Policy
 
-Hadrian ships a default CSP tailored for the web UI's frontend tools (Python, JavaScript, SQL, charts, Wikipedia, and Wikidata via WASM and external APIs):
+Hadrian ships a default CSP tailored for the web UI's frontend tools and MCP server connections:
 
 ```
 default-src 'self'; script-src 'self' blob: 'unsafe-eval' https://cdn.jsdelivr.net;
 style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:;
-connect-src 'self' https://cdn.jsdelivr.net https://*.wikipedia.org https://www.wikidata.org;
+connect-src 'self' https: http: wss: ws:;
 worker-src 'self' blob:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'
 ```
 
-| Directive     | Value                                                                              | Reason                                                                                                                                    |
-| ------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
-| `script-src`  | `'self' blob: 'unsafe-eval' https://cdn.jsdelivr.net`                              | WASM workers as blob URLs; `unsafe-eval` for Pyodide bytecode execution and Vega `Function()` evaluation; CDN for Pyodide/DuckDB modules. |
-| `style-src`   | `'self' 'unsafe-inline'`                                                           | Tailwind CSS injects styles dynamically.                                                                                                  |
-| `worker-src`  | `'self' blob:`                                                                     | Web Workers run sandboxed code execution.                                                                                                 |
-| `frame-src`   | `'self' blob:`                                                                     | HTML artifact previews render in sandboxed iframes.                                                                                       |
-| `img-src`     | `'self' data: blob:`                                                               | Generated charts and inline images use data/blob URIs.                                                                                    |
-| `connect-src` | `'self' https://cdn.jsdelivr.net https://*.wikipedia.org https://www.wikidata.org` | Pyodide/DuckDB fetch WASM and packages from CDN; Wikipedia and Wikidata tools query REST APIs.                                            |
-| `object-src`  | `'none'`                                                                           | Blocks plugin-based content (Flash, Java applets).                                                                                        |
-| `base-uri`    | `'self'`                                                                           | Prevents `<base>` tag injection attacks.                                                                                                  |
+| Directive     | Value                                                 | Reason                                                                                                                                    |
+| ------------- | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
+| `script-src`  | `'self' blob: 'unsafe-eval' https://cdn.jsdelivr.net` | WASM workers as blob URLs; `unsafe-eval` for Pyodide bytecode execution and Vega `Function()` evaluation; CDN for Pyodide/DuckDB modules. |
+| `style-src`   | `'self' 'unsafe-inline'`                              | Tailwind CSS injects styles dynamically.                                                                                                  |
+| `worker-src`  | `'self' blob:`                                        | Web Workers run sandboxed code execution.                                                                                                 |
+| `frame-src`   | `'self' blob:`                                        | HTML artifact previews render in sandboxed iframes.                                                                                       |
+| `img-src`     | `'self' data: blob:`                                  | Generated charts and inline images use data/blob URIs.                                                                                    |
+| `connect-src` | `'self' https: http: wss: ws:`                        | MCP servers are user-configured at arbitrary URLs; also covers CDN fetches for Pyodide/DuckDB and REST API queries for frontend tools.    |
+| `object-src`  | `'none'`                                              | Blocks plugin-based content (Flash, Java applets).                                                                                        |
+| `base-uri`    | `'self'`                                              | Prevents `<base>` tag injection attacks.                                                                                                  |
 
 Override this by setting `content_security_policy` explicitly. Set to an empty string to disable the CSP header entirely.
 
diff --git a/docs/public/config-schema.json b/docs/public/config-schema.json
index 20061182..5e8445cf 100644
--- a/docs/public/config-schema.json
+++ b/docs/public/config-schema.json
@@ -396,7 +396,7 @@
         "max_response_body_bytes": 104857600,
         "port": 8080,
         "security_headers": {
-          "content_security_policy": "default-src 'self'; script-src 'self' blob: 'unsafe-eval' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' blob:; connect-src 'self' https://cdn.jsdelivr.net https://*.wikipedia.org https://www.wikidata.org; worker-src 'self' blob:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'",
+          "content_security_policy": "default-src 'self'; script-src 'self' blob: 'unsafe-eval' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' blob:; connect-src 'self' https: http: wss: ws:; worker-src 'self' blob:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'",
           "content_type_options": "nosniff",
           "enabled": true,
           "frame_options": "DENY",
@@ -8011,7 +8011,7 @@
       "properties": {
         "content_security_policy": {
           "description": "Content-Security-Policy header value. Controls resource loading to prevent XSS attacks.",
-          "default": "default-src 'self'; script-src 'self' blob: 'unsafe-eval' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' blob:; connect-src 'self' https://cdn.jsdelivr.net https://*.wikipedia.org https://www.wikidata.org; worker-src 'self' blob:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'",
+          "default": "default-src 'self'; script-src 'self' blob: 'unsafe-eval' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' blob:; connect-src 'self' https: http: wss: ws:; worker-src 'self' blob:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'",
           "type": [
             "string",
             "null"
@@ -8302,7 +8302,7 @@
         "security_headers": {
           "description": "Security headers configuration.",
           "default": {
-            "content_security_policy": "default-src 'self'; script-src 'self' blob: 'unsafe-eval' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' blob:; connect-src 'self' https://cdn.jsdelivr.net https://*.wikipedia.org https://www.wikidata.org; worker-src 'self' blob:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'",
+            "content_security_policy": "default-src 'self'; script-src 'self' blob: 'unsafe-eval' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' blob:; connect-src 'self' https: http: wss: ws:; worker-src 'self' blob:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'",
             "content_type_options": "nosniff",
             "enabled": true,
             "frame_options": "DENY",
diff --git a/src/config/server.rs b/src/config/server.rs
index 7548f2db..def455f6 100644
--- a/src/config/server.rs
+++ b/src/config/server.rs
@@ -469,12 +469,13 @@ fn default_frame_options() -> Option<String> {
 /// - `frame-src blob:` — HTML artifact preview iframes
 /// - `img-src data: blob:` — Generated charts/images and inline assets
 /// - `media-src blob:` — Audio playback from generated TTS blob URLs
-/// - `connect-src https://cdn.jsdelivr.net https://*.wikipedia.org https://www.wikidata.org` —
-///   Pyodide/DuckDB fetch WASM/packages from CDN; Wikipedia and Wikidata tools query REST APIs
+/// - `connect-src https: http: wss: ws:` — MCP servers are user-configured at arbitrary URLs
+///   and discovered at runtime (stored in localStorage), so connect-src must allow all schemes.
+///   Also covers Pyodide/DuckDB CDN fetches and Wikipedia/Wikidata tool queries.
 /// - `object-src 'none'` — Blocks plugins (Flash, Java applets)
 /// - `base-uri 'self'` — Prevents `<base>` tag injection
 fn default_csp() -> Option<String> {
-    Some("default-src 'self'; script-src 'self' blob: 'unsafe-eval' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' blob:; connect-src 'self' https://cdn.jsdelivr.net https://*.wikipedia.org https://www.wikidata.org; worker-src 'self' blob:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'".to_string())
+    Some("default-src 'self'; script-src 'self' blob: 'unsafe-eval' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' blob:; connect-src 'self' https: http: wss: ws:; worker-src 'self' blob:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'".to_string())
 }
 
 fn default_xss_protection() -> Option<String> {

From ffd31183cedec9818702ec38cb842c08820ac069 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Wed, 25 Mar 2026 18:07:32 +1000
Subject: [PATCH 17/25] Update default config to allow connecting to local
 providers (eg. ollama)

---
 src/cli/mod.rs | 2 ++
 src/wizard.rs  | 6 ++++++
 2 files changed, 8 insertions(+)

diff --git a/src/cli/mod.rs b/src/cli/mod.rs
index dc2b4d4d..9a13fa31 100644
--- a/src/cli/mod.rs
+++ b/src/cli/mod.rs
@@ -176,6 +176,8 @@ pub(crate) fn default_config_toml() -> &'static str {
 [server]
 host = "127.0.0.1"
 port = 8080
+# Allow providers on localhost (e.g. Ollama)
+allow_loopback_urls = true
 
 # CORS: Allow local development origins
 [server.cors]
diff --git a/src/wizard.rs b/src/wizard.rs
index 395e53ce..03be4016 100644
--- a/src/wizard.rs
+++ b/src/wizard.rs
@@ -971,6 +971,10 @@ fn generate_config(mode: DeploymentMode, wizard_config: &WizardConfig) -> String
     config.push_str("[server]\n");
     config.push_str("host = \"0.0.0.0\"\n");
     config.push_str("port = 8080\n");
+    if matches!(mode, DeploymentMode::LocalDev) {
+        config.push_str("# Allow providers on localhost (e.g. Ollama)\n");
+        config.push_str("allow_loopback_urls = true\n");
+    }
     config.push('\n');
 
     // CORS (always enabled for UI)
@@ -1179,6 +1183,7 @@ mod tests {
         let config = generate_config(DeploymentMode::LocalDev, &wizard_config);
 
         assert!(config.contains("[server]"));
+        assert!(config.contains("allow_loopback_urls = true"));
         assert!(config.contains("[database]"));
         assert!(config.contains("type = \"sqlite\""));
         assert!(config.contains("[cache]"));
@@ -1222,6 +1227,7 @@ mod tests {
 
         let config = generate_config(DeploymentMode::SingleNode, &wizard_config);
 
+        assert!(!config.contains("allow_loopback_urls"));
         assert!(config.contains("[auth.mode]"));
         assert!(config.contains("type = \"api_key\""));
         assert!(config.contains("[auth.api_key]"));

From b8bc927d4bab0d73b208f6d69bffa03cf7cd3bf9 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Wed, 25 Mar 2026 21:16:47 +1000
Subject: [PATCH 18/25] MCP artifacts

---
 .../ToolExecution/ToolExecutionStep.tsx       | 12 ++++-
 ui/src/components/ToolIcons/ToolIcons.tsx     | 11 ++++
 ui/src/pages/chat/utils/toolExecutors.ts      | 53 +++++++++++++++++++
 3 files changed, 74 insertions(+), 2 deletions(-)

diff --git a/ui/src/components/ToolExecution/ToolExecutionStep.tsx b/ui/src/components/ToolExecution/ToolExecutionStep.tsx
index 1acda31d..4962fd76 100644
--- a/ui/src/components/ToolExecution/ToolExecutionStep.tsx
+++ b/ui/src/components/ToolExecution/ToolExecutionStep.tsx
@@ -12,6 +12,7 @@ import {
   Database,
   BarChart3,
   Eye,
+  Plug,
   Maximize2,
 } from "lucide-react";
 import type {
@@ -109,8 +110,15 @@ function ToolExecutionStepComponent({
 }: ToolExecutionStepProps) {
   const [isFullCodeExpanded, setIsFullCodeExpanded] = useState(defaultInputExpanded);
 
-  const Icon = TOOL_ICONS[execution.toolName] || Terminal;
-  const displayName = TOOL_NAMES[execution.toolName] || execution.toolName;
+  const isMCP = execution.toolName.startsWith("mcp_");
+  const Icon = isMCP ? Plug : TOOL_ICONS[execution.toolName] || Terminal;
+  const displayName = isMCP
+    ? (() => {
+        const withoutPrefix = execution.toolName.slice(4);
+        const idx = withoutPrefix.indexOf("_");
+        return idx !== -1 ? withoutPrefix.slice(idx + 1) : withoutPrefix;
+      })()
+    : TOOL_NAMES[execution.toolName] || execution.toolName;
 
   // Filter out display_selection artifacts - they're meta-artifacts for controlling display
   const visibleOutputArtifacts = useMemo(
diff --git a/ui/src/components/ToolIcons/ToolIcons.tsx b/ui/src/components/ToolIcons/ToolIcons.tsx
index e0804c58..ac2c5e23 100644
--- a/ui/src/components/ToolIcons/ToolIcons.tsx
+++ b/ui/src/components/ToolIcons/ToolIcons.tsx
@@ -204,6 +204,7 @@ export const TOOL_ICON_MAP: Record<string, ToolIconComponent> = {
 
 /** Get icon for a tool ID */
 export function getToolIcon(toolId: string): ToolIconComponent {
+  if (toolId.startsWith("mcp_")) return TOOL_ICON_MAP.mcp;
   return TOOL_ICON_MAP[toolId] || Wrench;
 }
 
@@ -226,6 +227,12 @@ export const TOOL_SHORT_NAMES: Record<string, string> = {
 
 /** Get short display name for a tool */
 export function getToolShortName(toolId: string): string {
+  if (toolId.startsWith("mcp_")) {
+    // Parse "mcp_{serverId}_{toolName}" → toolName
+    const withoutPrefix = toolId.slice(4);
+    const underscoreIndex = withoutPrefix.indexOf("_");
+    return underscoreIndex !== -1 ? withoutPrefix.slice(underscoreIndex + 1) : withoutPrefix;
+  }
   return TOOL_SHORT_NAMES[toolId] || toolId;
 }
 
@@ -246,5 +253,9 @@ const TOOL_STATUS_LABELS: Record<string, string> = {
 
 /** Get human-readable status label for a running tool */
 export function getToolStatusLabel(toolId: string, toolName?: string): string {
+  if (toolId.startsWith("mcp_")) {
+    const shortName = getToolShortName(toolId);
+    return `Calling ${shortName}`;
+  }
   return TOOL_STATUS_LABELS[toolId] || (toolName ? `Calling ${toolName}` : "Calling function");
 }
diff --git a/ui/src/pages/chat/utils/toolExecutors.ts b/ui/src/pages/chat/utils/toolExecutors.ts
index 82080c43..b4217b66 100644
--- a/ui/src/pages/chat/utils/toolExecutors.ts
+++ b/ui/src/pages/chat/utils/toolExecutors.ts
@@ -2401,16 +2401,66 @@ const mcpToolExecutor: ToolExecutor = async (toolCall, context) => {
 
   const { serverId, toolName } = parsed;
   const args = toolCall.arguments as Record<string, unknown> | undefined;
+  const toolId = toolCall.id;
 
   // Report status
   context.onStatusMessage?.(toolCall.id, `Calling ${toolName} on MCP server...`);
 
+  // Build artifacts for timeline display
+  const artifacts: Artifact[] = [];
+  let artifactIndex = 0;
+
+  // Input artifact: show the arguments sent to the tool
+  if (args && Object.keys(args).length > 0) {
+    artifacts.push({
+      id: `mcp-input-${toolId}-${artifactIndex++}`,
+      type: "code",
+      title: toolName,
+      role: "input",
+      toolCallId: toolId,
+      data: { language: "json", code: JSON.stringify(args, null, 2) },
+    });
+  }
+
   try {
     const result = await callMCPTool(serverId, toolName, args);
 
     // Clear status message
     context.onStatusMessage?.(toolCall.id, "");
 
+    // Build output artifacts from MCP content
+    for (const item of result.content) {
+      if (item.type === "text" && item.text) {
+        artifacts.push({
+          id: `mcp-output-${toolId}-${artifactIndex++}`,
+          type: "code",
+          title: "Output",
+          role: "output",
+          toolCallId: toolId,
+          data: { language: "text", code: item.text },
+        });
+      } else if (item.type === "image") {
+        artifacts.push({
+          id: `mcp-image-${toolId}-${artifactIndex++}`,
+          type: "image",
+          title: "Image",
+          role: "output",
+          toolCallId: toolId,
+          data: `data:${item.mimeType};base64,${item.data}`,
+          mimeType: item.mimeType,
+        });
+      } else if (item.type === "resource" && item.resource.text) {
+        artifacts.push({
+          id: `mcp-resource-${toolId}-${artifactIndex++}`,
+          type: "code",
+          title: item.resource.uri,
+          role: "output",
+          toolCallId: toolId,
+          data: { language: "text", code: item.resource.text },
+        });
+      }
+    }
+
     // Check for error response
     if (result.isError) {
       const errorOutput = formatMCPContent(result.content);
@@ -2418,6 +2468,7 @@ const mcpToolExecutor: ToolExecutor = async (toolCall, context) => {
         success: false,
         error: errorOutput || "MCP tool returned an error",
         output: JSON.stringify({ error: errorOutput, serverId, toolName }),
+        artifacts,
       };
     }
 
@@ -2428,6 +2479,7 @@ const mcpToolExecutor: ToolExecutor = async (toolCall, context) => {
       success: true,
       output:
         output || JSON.stringify({ result: "Tool executed successfully", serverId, toolName }),
+      artifacts,
     };
   } catch (error) {
     // Clear status message on error
@@ -2443,6 +2495,7 @@ const mcpToolExecutor: ToolExecutor = async (toolCall, context) => {
         serverId,
         toolName,
       }),
+      artifacts,
     };
   }
 };

From e71c4c716656dc09d88799c313c3c81175903cfa Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Wed, 25 Mar 2026 21:35:23 +1000
Subject: [PATCH 19/25] Fix MCP client connection stability and improve config
 modal UX

---
 .../MCPConfigModal/MCPConfigModal.stories.tsx | 126 ++++--
 .../MCPConfigModal/MCPConfigModal.tsx         | 363 +++++++++++++-----
 ui/src/services/mcp/client.ts                 |  97 ++++-
 ui/src/stores/mcpStore.ts                     |  85 +++-
 4 files changed, 518 insertions(+), 153 deletions(-)

diff --git a/ui/src/components/MCPConfigModal/MCPConfigModal.stories.tsx b/ui/src/components/MCPConfigModal/MCPConfigModal.stories.tsx
index 06c2e949..74858d76 100644
--- a/ui/src/components/MCPConfigModal/MCPConfigModal.stories.tsx
+++ b/ui/src/components/MCPConfigModal/MCPConfigModal.stories.tsx
@@ -24,34 +24,86 @@ type Story = StoryObj<typeof meta>;
 const mockTools: MCPToolDefinition[] = [
   {
     name: "github_search",
-    description: "Search GitHub repositories and code",
-    inputSchema: { type: "object", properties: { query: { type: "string" } } },
+    description:
+      "Search GitHub repositories, code, issues, and pull requests using the GitHub API. Supports advanced query syntax including qualifiers for language, stars, forks, and more.",
+    inputSchema: {
+      type: "object",
+      properties: {
+        query: {
+          type: "string",
+          description: "Search query using GitHub search syntax",
+        },
+        scope: {
+          type: "string",
+          description: "What to search",
+          enum: ["repositories", "code", "issues", "commits"],
+        },
+        per_page: {
+          type: "number",
+          description: "Results per page (max 100)",
+        },
+      },
+      required: ["query"],
+    },
   },
   {
     name: "github_issues",
     description: "Create, list, and manage GitHub issues",
-    inputSchema: { type: "object", properties: { repo: { type: "string" } } },
+    inputSchema: {
+      type: "object",
+      properties: {
+        repo: {
+          type: "string",
+          description: "Repository in owner/repo format",
+        },
+        action: {
+          type: "string",
+          enum: ["list", "create", "update", "close"],
+        },
+        title: { type: "string" },
+        body: { type: "string" },
+        labels: { type: "array", description: "Issue labels to apply" },
+      },
+      required: ["repo", "action"],
+    },
   },
   {
     name: "github_pr",
     description: "Create and review pull requests",
-    inputSchema: { type: "object", properties: { repo: { type: "string" } } },
+    inputSchema: {
+      type: "object",
+      properties: {
+        repo: { type: "string", description: "Repository in owner/repo format" },
+      },
+      required: ["repo"],
+    },
   },
 ];
 
 const mockSlackTools: MCPToolDefinition[] = [
   {
     name: "slack_send",
-    description: "Send messages to Slack channels",
+    description: "Send messages to Slack channels or users via the Slack Web API",
     inputSchema: {
       type: "object",
-      properties: { channel: { type: "string" }, message: { type: "string" } },
+      properties: {
+        channel: { type: "string", description: "Channel name or ID" },
+        message: { type: "string", description: "Message text (supports Slack markdown)" },
+        thread_ts: { type: "string", description: "Thread timestamp to reply in a thread" },
+      },
+      required: ["channel", "message"],
     },
   },
   {
     name: "slack_search",
     description: "Search Slack messages and files",
-    inputSchema: { type: "object", properties: { query: { type: "string" } } },
+    inputSchema: {
+      type: "object",
+      properties: {
+        query: { type: "string", description: "Search query" },
+      },
+      required: ["query"],
+    },
   },
 ];
 
@@ -153,7 +205,7 @@ export const WithConnectedServers: Story = {
     <div className="space-y-4">
       <p className="text-sm text-muted-foreground max-w-md">
         Two connected servers with 5 tools total. Expand each server to see and toggle individual
-        tools.
+        tools. Click a tool name to expand its full description and parameter schema.
       </p>
       <ModalWrapper initialServers={createMockServers()} />
     </div>
@@ -164,7 +216,7 @@ export const WithDisconnectedServer: Story = {
   render: () => (
     <div className="space-y-4">
       <p className="text-sm text-muted-foreground max-w-md">
-        One connected server and one disconnected. The disconnected server shows no tools.
+        One connected server and one disconnected. Toggle the switch to connect or disconnect.
       </p>
       <ModalWrapper
         initialServers={createMockServers([
@@ -173,6 +225,7 @@ export const WithDisconnectedServer: Story = {
             id: "mcp-notion",
             name: "Notion API",
             url: "https://mcp.notion.so",
+            enabled: false,
             status: "disconnected",
             tools: [],
           },
@@ -195,6 +248,7 @@ export const WithConnectionError: Story = {
             id: "mcp-broken",
             name: "Broken Server",
             url: "https://mcp.broken.com",
+            enabled: true,
             status: "error",
             error: "Connection refused: ECONNREFUSED 127.0.0.1:8080",
             tools: [],
@@ -227,35 +281,12 @@ export const WithConnectingServer: Story = {
   ),
 };
 
-export const WithDisabledServer: Story = {
-  render: () => (
-    <div className="space-y-4">
-      <p className="text-sm text-muted-foreground max-w-md">
-        A server that is disabled. Disabled servers don&apos;t show connect button and their tools
-        are not available.
-      </p>
-      <ModalWrapper
-        initialServers={createMockServers([
-          { status: "connected", tools: mockTools },
-          {
-            id: "mcp-disabled",
-            name: "Disabled Server",
-            url: "https://mcp.disabled.com",
-            enabled: false,
-            status: "disconnected",
-            tools: [],
-          },
-        ])}
-      />
-    </div>
-  ),
-};
-
 export const WithManyTools: Story = {
   render: () => (
     <div className="space-y-4">
       <p className="text-sm text-muted-foreground max-w-md">
-        A server with many tools. Demonstrates scrolling behavior in the tools list.
+        A server with many tools. Demonstrates scrolling behavior in the tools list. Click any tool
+        to expand its description and parameters.
       </p>
       <ModalWrapper
         initialServers={[
@@ -266,8 +297,31 @@ export const WithManyTools: Story = {
             enabled: true,
             status: "connected",
             tools: [
-              { name: "file_read", description: "Read file contents", inputSchema: {} },
-              { name: "file_write", description: "Write to files", inputSchema: {} },
+              {
+                name: "file_read",
+                description: "Read the contents of a file at the given path",
+                inputSchema: {
+                  type: "object",
+                  properties: {
+                    path: { type: "string", description: "Absolute file path" },
+                    encoding: { type: "string", enum: ["utf-8", "base64", "binary"] },
+                  },
+                  required: ["path"],
+                },
+              },
+              {
+                name: "file_write",
+                description: "Write content to a file, creating it if it does not exist",
+                inputSchema: {
+                  type: "object",
+                  properties: {
+                    path: { type: "string", description: "Absolute file path" },
+                    content: { type: "string", description: "Content to write" },
+                    append: { type: "boolean", description: "Append instead of overwrite" },
+                  },
+                  required: ["path", "content"],
+                },
+              },
               { name: "file_delete", description: "Delete files", inputSchema: {} },
               { name: "directory_list", description: "List directory contents", inputSchema: {} },
               { name: "http_get", description: "Make HTTP GET requests", inputSchema: {} },
diff --git a/ui/src/components/MCPConfigModal/MCPConfigModal.tsx b/ui/src/components/MCPConfigModal/MCPConfigModal.tsx
index d3afe631..36bb0a20 100644
--- a/ui/src/components/MCPConfigModal/MCPConfigModal.tsx
+++ b/ui/src/components/MCPConfigModal/MCPConfigModal.tsx
@@ -3,28 +3,28 @@
  *
  * Allows users to:
  * - Add/edit/remove MCP server configurations
- * - Connect/disconnect from servers
- * - Enable/disable servers and individual tools
- * - View connection status and discovered tools
+ * - Test connections before saving
+ * - Connect/disconnect from servers via a single toggle
+ * - Enable/disable individual tools with expandable descriptions
  */
 
-import { useState, useCallback } from "react";
+import { useState, useCallback, useEffect } from "react";
 import { useForm } from "react-hook-form";
 import { zodResolver } from "@hookform/resolvers/zod";
 import { z } from "zod";
 import {
   AlertCircle,
-  Check,
+  CheckCircle2,
   ChevronDown,
   ChevronRight,
   Loader2,
   Pencil,
   Plug,
   Plus,
-  Power,
-  PowerOff,
   Trash2,
+  Wifi,
   Wrench,
+  XCircle,
 } from "lucide-react";
 
 import { Button } from "@/components/Button/Button";
@@ -41,7 +41,8 @@ import {
 import { Switch } from "@/components/Switch/Switch";
 import { cn } from "@/utils/cn";
 import { useMCPStore, useMCPServers } from "@/stores/mcpStore";
-import type { MCPServerState, MCPConnectionStatus } from "@/services/mcp";
+import { MCPClient, type MCPServerState, type MCPConnectionStatus } from "@/services/mcp";
+import type { MCPToolDefinition, JSONSchema } from "@/services/mcp";
 
 // =============================================================================
 // Types
@@ -71,7 +72,7 @@ type ServerFormValues = z.infer<typeof serverFormSchema>;
 /** Status badge for connection state */
 function StatusBadge({ status }: { status: MCPConnectionStatus }) {
   const config = {
-    disconnected: { color: "bg-muted text-muted-foreground", label: "Disconnected" },
+    disconnected: { color: "bg-muted text-muted-foreground", label: "Off" },
     connecting: { color: "bg-primary/10 text-primary", label: "Connecting" },
     connected: { color: "bg-success/10 text-success", label: "Connected" },
     error: { color: "bg-destructive/10 text-destructive", label: "Error" },
@@ -85,7 +86,119 @@ function StatusBadge({ status }: { status: MCPConnectionStatus }) {
   );
 }
 
-/** Individual server card with controls */
+// =============================================================================
+// Tool Item (expandable description + schema)
+// =============================================================================
+
+interface ToolItemProps {
+  tool: MCPToolDefinition;
+  isEnabled: boolean;
+  onToggle: (toolName: string, enabled: boolean) => void;
+}
+
+/** Render a JSON Schema property list */
+function SchemaProperties({ schema }: { schema: JSONSchema }) {
+  const properties = schema.properties;
+  if (!properties || Object.keys(properties).length === 0) {
+    return <span className="text-xs text-muted-foreground italic">No parameters</span>;
+  }
+
+  return (
+    <div className="bg-muted/50 rounded p-2 space-y-1.5">
+      {Object.entries(properties).map(([name, prop]) => {
+        const propSchema = prop as JSONSchema;
+        const isRequired = schema.required?.includes(name);
+        return (
+          <div key={name} className="text-xs">
+            <div className="flex items-baseline gap-1.5 flex-wrap">
+              <code className="font-mono font-medium text-foreground">{name}</code>
+              {propSchema.type && (
+                <span className="text-muted-foreground font-mono text-[11px]">
+                  {String(propSchema.type)}
+                </span>
+              )}
+              {isRequired && (
+                <span className="text-destructive text-[10px] font-semibold">required</span>
+              )}
+            </div>
+            {propSchema.description && (
+              <p className="text-muted-foreground mt-0.5 pl-0.5">{propSchema.description}</p>
+            )}
+            {propSchema.enum && (
+              <p className="text-muted-foreground mt-0.5 pl-0.5 font-mono text-[11px]">
+                enum: {propSchema.enum.map(String).join(" | ")}
+              </p>
+            )}
+          </div>
+        );
+      })}
+    </div>
+  );
+}
+
+function ToolItem({ tool, isEnabled, onToggle }: ToolItemProps) {
+  const [expanded, setExpanded] = useState(false);
+  const hasSchema =
+    tool.inputSchema?.properties && Object.keys(tool.inputSchema.properties).length > 0;
+  const hasDetails = !!tool.description || hasSchema;
+
+  return (
+    <div className="rounded hover:bg-muted/50">
+      <div className="flex items-center gap-3 p-2">
+        <Switch
+          checked={isEnabled}
+          onChange={() => onToggle(tool.name, !isEnabled)}
+          aria-label={`Toggle ${tool.name} tool`}
+          label=""
+          className="shrink-0"
+        />
+        <button
+          type="button"
+          className="flex-1 min-w-0 text-left cursor-pointer"
+          onClick={() => hasDetails && setExpanded(!expanded)}
+          aria-expanded={expanded}
+        >
+          <div className="flex items-center gap-1.5">
+            {hasDetails && (
+              <span className="shrink-0 text-muted-foreground">
+                {expanded ? (
+                  <ChevronDown className="h-3 w-3" />
+                ) : (
+                  <ChevronRight className="h-3 w-3" />
+                )}
+              </span>
+            )}
+            <span className="text-sm font-medium">{tool.name}</span>
+          </div>
+          {tool.description && !expanded && (
+            <div className="text-xs text-muted-foreground truncate mt-0.5">{tool.description}</div>
+          )}
+        </button>
+      </div>
+
+      {expanded && hasDetails && (
+        <div className="px-2 pb-2 pl-12 space-y-2">
+          {tool.description && (
+            <p className="text-xs text-muted-foreground leading-relaxed">{tool.description}</p>
+          )}
+          {hasSchema && (
+            <div className="space-y-1">
+              <div className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
+                Parameters
+              </div>
+              <SchemaProperties schema={tool.inputSchema} />
+            </div>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}
+
+// =============================================================================
+// Server Card
+// =============================================================================
+
 interface ServerCardProps {
   server: MCPServerState;
   onEdit: (server: MCPServerState) => void;
@@ -94,27 +207,31 @@ interface ServerCardProps {
 
 function ServerCard({ server, onEdit, onDelete }: ServerCardProps) {
   const [expanded, setExpanded] = useState(false);
-  const { connectServer, disconnectServer, toggleServerEnabled, setToolEnabled } = useMCPStore();
-  const [isConnecting, setIsConnecting] = useState(false);
+  const { connectServer, disconnectServer, setToolEnabled } = useMCPStore();
+  const [isToggling, setIsToggling] = useState(false);
 
-  const handleConnect = useCallback(async () => {
-    setIsConnecting(true);
+  // Unified toggle: switch ON = enable + connect, switch OFF = disconnect + disable
+  const handleToggle = useCallback(async () => {
+    if (isToggling) return;
+    setIsToggling(true);
     try {
-      await connectServer(server.id);
-    } catch {
-      // Error is stored in server state
+      if (server.enabled) {
+        // Turning OFF
+        disconnectServer(server.id);
+        useMCPStore.getState().toggleServerEnabled(server.id);
+      } else {
+        // Turning ON: enable first, then connect
+        useMCPStore.getState().toggleServerEnabled(server.id);
+        try {
+          await connectServer(server.id);
+        } catch {
+          // Error is stored in server state and shown inline
+        }
+      }
     } finally {
-      setIsConnecting(false);
+      setIsToggling(false);
     }
-  }, [connectServer, server.id]);
-
-  const handleDisconnect = useCallback(() => {
-    disconnectServer(server.id);
-  }, [disconnectServer, server.id]);
-
-  const handleToggleEnabled = useCallback(() => {
-    toggleServerEnabled(server.id);
-  }, [toggleServerEnabled, server.id]);
+  }, [server.enabled, server.id, isToggling, connectServer, disconnectServer]);
 
   const handleToolToggle = useCallback(
     (toolName: string, enabled: boolean) => {
@@ -123,8 +240,7 @@ function ServerCard({ server, onEdit, onDelete }: ServerCardProps) {
     [setToolEnabled, server.id]
   );
 
-  const isConnected = server.status === "connected";
-  const isConnectingStatus = server.status === "connecting" || isConnecting;
+  const isConnectingStatus = server.status === "connecting" || isToggling;
   const hasTools = server.tools.length > 0;
 
   return (
@@ -147,9 +263,16 @@ function ServerCard({ server, onEdit, onDelete }: ServerCardProps) {
             )}
           </button>
 
-          {/* Server icon */}
+          {/* Server icon — color reflects connection status */}
           <Plug
-            className={cn("h-4 w-4", server.enabled ? "text-primary" : "text-muted-foreground")}
+            className={cn(
+              "h-4 w-4 shrink-0",
+              server.status === "connected"
+                ? "text-primary"
+                : server.status === "connecting"
+                  ? "text-primary/50"
+                  : "text-muted-foreground"
+            )}
           />
 
           {/* Name and URL */}
@@ -168,41 +291,16 @@ function ServerCard({ server, onEdit, onDelete }: ServerCardProps) {
             </span>
           )}
 
-          {/* Enable/disable switch */}
+          {/* Single toggle: enable+connect / disconnect+disable */}
           <Switch
             checked={server.enabled}
-            onChange={handleToggleEnabled}
-            aria-label={`Toggle ${server.name} server`}
+            onChange={handleToggle}
+            disabled={isConnectingStatus}
+            aria-label={server.enabled ? `Disconnect ${server.name}` : `Connect ${server.name}`}
             label=""
             className="shrink-0"
           />
 
-          {/* Connect/Disconnect button */}
-          {server.enabled && (
-            <Button
-              variant="ghost"
-              size="sm"
-              onClick={isConnected ? handleDisconnect : handleConnect}
-              disabled={isConnectingStatus}
-              className="shrink-0"
-              aria-label={
-                isConnectingStatus
-                  ? "Connecting"
-                  : isConnected
-                    ? "Disconnect server"
-                    : "Connect server"
-              }
-            >
-              {isConnectingStatus ? (
-                <Loader2 className="h-4 w-4 animate-spin" />
-              ) : isConnected ? (
-                <PowerOff className="h-4 w-4" />
-              ) : (
-                <Power className="h-4 w-4" />
-              )}
-            </Button>
-          )}
-
           {/* Edit button */}
           <Button
             variant="ghost"
@@ -243,33 +341,15 @@ function ServerCard({ server, onEdit, onDelete }: ServerCardProps) {
               <Wrench className="h-3 w-3" />
               Available Tools
             </div>
-            <div className="space-y-1">
-              {server.tools.map((tool) => {
-                const isEnabled = server.toolsEnabled[tool.name] !== false;
-                return (
-                  <div
-                    key={tool.name}
-                    className="flex items-center gap-3 p-2 rounded hover:bg-muted/50"
-                  >
-                    <Switch
-                      checked={isEnabled}
-                      onChange={() => handleToolToggle(tool.name, !isEnabled)}
-                      aria-label={`Toggle ${tool.name} tool`}
-                      label=""
-                      className="shrink-0"
-                    />
-                    <div className="flex-1 min-w-0">
-                      <div className="text-sm font-medium">{tool.name}</div>
-                      {tool.description && (
-                        <div className="text-xs text-muted-foreground truncate">
-                          {tool.description}
-                        </div>
-                      )}
-                    </div>
-                    {isEnabled && <Check className="h-4 w-4 text-green-500 shrink-0" />}
-                  </div>
-                );
-              })}
+            <div className="space-y-0.5">
+              {server.tools.map((tool) => (
+                <ToolItem
+                  key={tool.name}
+                  tool={tool}
+                  isEnabled={server.toolsEnabled[tool.name] !== false}
+                  onToggle={handleToolToggle}
+                />
+              ))}
             </div>
           </div>
         </div>
@@ -278,13 +358,18 @@ function ServerCard({ server, onEdit, onDelete }: ServerCardProps) {
   );
 }
 
-/** Form for adding/editing a server */
+// =============================================================================
+// Server Form (with test connection)
+// =============================================================================
+
 interface ServerFormProps {
   editingServer?: MCPServerState | null;
   onSubmit: (values: ServerFormValues) => void;
   onCancel: () => void;
 }
 
+type TestStatus = "idle" | "testing" | "success" | "error";
+
 function ServerForm({ editingServer, onSubmit, onCancel }: ServerFormProps) {
   const form = useForm<ServerFormValues>({
     resolver: zodResolver(serverFormSchema),
@@ -295,6 +380,65 @@ function ServerForm({ editingServer, onSubmit, onCancel }: ServerFormProps) {
     },
   });
 
+  const [testStatus, setTestStatus] = useState<TestStatus>("idle");
+  const [testMessage, setTestMessage] = useState<string>();
+  const [testLatency, setTestLatency] = useState<number>();
+
+  // Reset test results when URL or headers change
+  const watchedUrl = form.watch("url");
+  const watchedHeaders = form.watch("headers");
+  useEffect(() => {
+    if (testStatus !== "idle" && testStatus !== "testing") {
+      setTestStatus("idle");
+      setTestMessage(undefined);
+      setTestLatency(undefined);
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps -- only reset on field changes
+  }, [watchedUrl, watchedHeaders]);
+
+  const handleTestConnection = useCallback(async () => {
+    // Validate URL field first
+    const valid = await form.trigger("url");
+    if (!valid) return;
+
+    const values = form.getValues();
+    let headers: Record<string, string> | undefined;
+    if (values.headers) {
+      try {
+        headers = JSON.parse(values.headers);
+      } catch {
+        setTestStatus("error");
+        setTestMessage("Invalid JSON in headers field");
+        return;
+      }
+    }
+
+    setTestStatus("testing");
+    setTestMessage(undefined);
+    setTestLatency(undefined);
+
+    const client = new MCPClient({ url: values.url, headers, timeout: 10000 });
+    const start = performance.now();
+
+    try {
+      await client.connect();
+      const elapsed = Math.round(performance.now() - start);
+      const info = client.getServerInfo();
+      setTestStatus("success");
+      setTestLatency(elapsed);
+      setTestMessage(info ? `${info.name} v${info.version}` : "Connection successful");
+    } catch (err) {
+      setTestStatus("error");
+      setTestMessage(err instanceof Error ? err.message : String(err));
+    } finally {
+      try {
+        await client.disconnect();
+      } catch {
+        // ignore
+      }
+    }
+  }, [form]);
+
   const handleSubmit = form.handleSubmit((values) => {
     onSubmit(values);
   });
@@ -334,11 +478,50 @@ function ServerForm({ editingServer, onSubmit, onCancel }: ServerFormProps) {
         />
       </FormField>
 
-      <div className="flex justify-end gap-2 pt-2">
+      {/* Test connection result */}
+      {testStatus !== "idle" && (
+        <div role="status" aria-live="polite">
+          {testStatus === "testing" && (
+            <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
+              <Loader2 className="h-3 w-3 animate-spin" />
+              Testing connection...
+            </div>
+          )}
+          {testStatus === "success" && (
+            <div className="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400">
+              <CheckCircle2 className="h-3 w-3" />
+              {testMessage}
+              {testLatency != null && (
+                <span className="text-muted-foreground">({testLatency}ms)</span>
+              )}
+            </div>
+          )}
+          {testStatus === "error" && (
+            <div className="flex items-start gap-1.5 text-xs text-destructive">
+              <XCircle className="h-3 w-3 shrink-0 mt-0.5" />
+              <span>{testMessage ?? "Connection failed"}</span>
+            </div>
+          )}
+        </div>
+      )}
+
+      <div className="flex justify-between pt-2">
         <Button type="button" variant="ghost" onClick={onCancel}>
           Cancel
         </Button>
-        <Button type="submit">{editingServer ? "Save" : "Add Server"}</Button>
+        <div className="flex gap-2">
+          <Button
+            type="button"
+            variant="outline"
+            onClick={handleTestConnection}
+            isLoading={testStatus === "testing"}
+            disabled={testStatus === "testing"}
+          >
+            <Wifi className="h-4 w-4 mr-1.5" />
+            Test
+          </Button>
+          <Button type="submit">{editingServer ? "Save" : "Add Server"}</Button>
+        </div>
       </div>
     </form>
   );
diff --git a/ui/src/services/mcp/client.ts b/ui/src/services/mcp/client.ts
index 995b7653..91649e64 100644
--- a/ui/src/services/mcp/client.ts
+++ b/ui/src/services/mcp/client.ts
@@ -97,6 +97,12 @@ export class MCPClient {
   private eventSource?: EventSource;
   private lastEventId?: string;
 
+  // SSE reconnection state
+  private reconnectTimer?: ReturnType<typeof setTimeout>;
+  private sseErrorCount = 0;
+  private static readonly MAX_SSE_RECONNECT_ATTEMPTS = 5;
+  private static readonly SSE_RECONNECT_BASE_DELAY = 1000;
+
   constructor(config: MCPClientConfig) {
     this.config = {
       timeout: 30000,
@@ -169,6 +175,12 @@ export class MCPClient {
       return;
     }
 
+    // Clean up any stale state from previous connection attempts
+    this.closeEventSource();
+    this.clearReconnectTimer();
+    this.sessionId = undefined;
+    this.sseErrorCount = 0;
+
     this.setStatus("connecting");
 
     try {
@@ -216,11 +228,8 @@ export class MCPClient {
    * Disconnect from the server
    */
   async disconnect(): Promise<void> {
-    // Close SSE stream
-    if (this.eventSource) {
-      this.eventSource.close();
-      this.eventSource = undefined;
-    }
+    this.closeEventSource();
+    this.clearReconnectTimer();
 
     // Send DELETE to terminate session if we have a session ID
     if (this.sessionId) {
@@ -241,9 +250,28 @@ export class MCPClient {
     this.lastEventId = undefined;
     this.serverInfo = undefined;
     this.capabilities = undefined;
+    this.sseErrorCount = 0;
     this.setStatus("disconnected");
   }
 
+  /** Close the SSE EventSource if open */
+  private closeEventSource(): void {
+    if (this.eventSource) {
+      this.eventSource.onmessage = null;
+      this.eventSource.onerror = null;
+      this.eventSource.close();
+      this.eventSource = undefined;
+    }
+  }
+
+  /** Clear any pending reconnection timer */
+  private clearReconnectTimer(): void {
+    if (this.reconnectTimer) {
+      clearTimeout(this.reconnectTimer);
+      this.reconnectTimer = undefined;
+    }
+  }
+
   /**
    * Check if connected
    */
@@ -260,6 +288,10 @@ export class MCPClient {
    *
    * In Streamable HTTP transport, clients can GET the endpoint to open
    * an SSE stream for receiving server notifications and requests.
+   *
+   * We disable the browser's native EventSource auto-reconnect and handle
+   * reconnection ourselves with exponential backoff so we can detect when
+   * the session has expired and perform a full re-initialization.
    */
   private setupServerMessageStream(): void {
     // Only set up if server supports notifications
@@ -272,9 +304,10 @@ export class MCPClient {
       return;
     }
 
+    // Close existing EventSource before opening a new one
+    this.closeEventSource();
+
     try {
-      // Build URL with session ID as query param if needed
-      // (EventSource doesn't support custom headers)
       const url = new URL(this.config.url);
       if (this.sessionId) {
         url.searchParams.set("sessionId", this.sessionId);
@@ -283,10 +316,15 @@ export class MCPClient {
         url.searchParams.set("lastEventId", this.lastEventId);
       }
 
-      this.eventSource = new EventSource(url.toString());
+      const es = new EventSource(url.toString());
+      this.eventSource = es;
 
-      this.eventSource.onmessage = (event) => {
-        // Track event ID for resumability
+      es.onopen = () => {
+        // Reset error count on successful connection
+        this.sseErrorCount = 0;
+      };
+
+      es.onmessage = (event) => {
         if (event.lastEventId) {
           this.lastEventId = event.lastEventId;
         }
@@ -301,13 +339,35 @@ export class MCPClient {
         }
       };
 
-      this.eventSource.onerror = () => {
-        // SSE connection error - attempt to reconnect with lastEventId
-        console.warn("MCP SSE connection error, will attempt reconnection");
-        // EventSource automatically reconnects, and we've set lastEventId
+      es.onerror = () => {
+        // Close the EventSource immediately to prevent browser auto-reconnect
+        // We manage reconnection ourselves to handle session expiry properly
+        this.closeEventSource();
+
+        this.sseErrorCount++;
+
+        if (this.sseErrorCount > MCPClient.MAX_SSE_RECONNECT_ATTEMPTS) {
+          console.warn(
+            `MCP SSE: giving up after ${MCPClient.MAX_SSE_RECONNECT_ATTEMPTS} reconnect attempts`
+          );
+          // SSE is not critical — the connection is still usable for requests.
+          // Just stop trying to reopen the notification stream.
+          return;
+        }
+
+        // Exponential backoff: 1s, 2s, 4s, 8s, 16s
+        const delay = MCPClient.SSE_RECONNECT_BASE_DELAY * Math.pow(2, this.sseErrorCount - 1);
+        console.debug(`MCP SSE: reconnecting in ${delay}ms (attempt ${this.sseErrorCount})`);
+
+        this.clearReconnectTimer();
+        this.reconnectTimer = setTimeout(() => {
+          // Only reconnect if we're still supposed to be connected
+          if (this.status === "connected") {
+            this.setupServerMessageStream();
+          }
+        }, delay);
       };
     } catch {
-      // SSE setup failed - not critical, continue without real-time notifications
       console.warn("Failed to set up MCP server message stream");
     }
   }
@@ -358,6 +418,13 @@ export class MCPClient {
       });
 
       if (!response.ok) {
+        // 404/410 means the session expired — the caller should reconnect
+        if (response.status === 404 || response.status === 410) {
+          this.sessionId = undefined;
+          this.closeEventSource();
+          this.setStatus("disconnected");
+          throw new Error("MCP session expired — please reconnect");
+        }
         throw new Error(`HTTP ${response.status}: ${response.statusText}`);
       }
 
diff --git a/ui/src/stores/mcpStore.ts b/ui/src/stores/mcpStore.ts
index d5115eca..61360fcb 100644
--- a/ui/src/stores/mcpStore.ts
+++ b/ui/src/stores/mcpStore.ts
@@ -85,6 +85,9 @@ export type MCPStore = MCPState & MCPActions;
 /** Map of server ID to MCPClient instance */
 const clients = new Map<string, MCPClient>();
 
+/** Map of server ID to cleanup functions for listeners */
+const listenerCleanups = new Map<string, Array<() => void>>();
+
 /** Get or create a client for a server */
 function getClient(server: MCPServerConfig): MCPClient {
   let client = clients.get(server.id);
@@ -99,8 +102,18 @@ function getClient(server: MCPServerConfig): MCPClient {
   return client;
 }
 
+/** Remove listener subscriptions for a server */
+function cleanupListeners(serverId: string): void {
+  const cleanups = listenerCleanups.get(serverId);
+  if (cleanups) {
+    cleanups.forEach((fn) => fn());
+    listenerCleanups.delete(serverId);
+  }
+}
+
 /** Remove and disconnect a client */
 function removeClient(serverId: string): void {
+  cleanupListeners(serverId);
   const client = clients.get(serverId);
   if (client) {
     client.disconnect();
@@ -170,16 +183,44 @@ export const useMCPStore = create<MCPStore>()(
           throw new Error(`Server not found: ${serverId}`);
         }
 
+        // Clean up any existing listeners before (re)connecting
+        cleanupListeners(serverId);
+
         // Update status to connecting
         get()._setServerStatus(serverId, "connecting");
 
         try {
           const client = getClient(server);
 
-          // Set up status listener
-          client.onStatusChange((status, error) => {
-            get()._setServerStatus(serverId, status, error);
-          });
+          // Set up status listener (tracked for cleanup)
+          const cleanups: Array<() => void> = [];
+
+          cleanups.push(
+            client.onStatusChange((status, error) => {
+              get()._setServerStatus(serverId, status, error);
+            })
+          );
+
+          // Set up notification listener so tool/resource/prompt changes
+          // are automatically re-discovered
+          cleanups.push(
+            client.onNotification(async (method) => {
+              const c = clients.get(serverId);
+              if (!c?.isConnected()) return;
+
+              try {
+                if (method === "notifications/tools/list_changed") {
+                  const tools = await c.listAllTools();
+                  get()._setServerTools(serverId, tools);
+                }
+                // Future: handle resources/prompts list_changed similarly
+              } catch (err) {
+                console.debug("MCP notification handler error:", err);
+              }
+            })
+          );
+
+          listenerCleanups.set(serverId, cleanups);
 
           // Connect to server
           await client.connect();
@@ -188,14 +229,17 @@ export const useMCPStore = create<MCPStore>()(
           const tools = await client.listAllTools();
           get()._setServerTools(serverId, tools);
 
-          // Enable all tools by default
+          // Enable all tools by default (preserve existing preferences)
           set((state) => ({
             servers: state.servers.map((s) => {
               if (s.id !== serverId) return s;
-              const toolsEnabled: Record<string, boolean> = {};
-              tools.forEach((t) => {
-                toolsEnabled[t.name] = true;
-              });
+              const toolsEnabled: Record<string, boolean> = { ...s.toolsEnabled };
+              for (const t of tools) {
+                // Only set default for tools we haven't seen before
+                if (!(t.name in toolsEnabled)) {
+                  toolsEnabled[t.name] = true;
+                }
+              }
               return { ...s, toolsEnabled };
             }),
           }));
@@ -292,7 +336,7 @@ export const useMCPStore = create<MCPStore>()(
       disconnectAll: () => {
         const { servers } = get();
         for (const server of servers) {
-          if (server.status === "connected") {
+          if (server.status !== "disconnected") {
             removeClient(server.id);
           }
         }
@@ -411,7 +455,7 @@ export function getMCPClient(serverId: string): MCPClient | undefined {
   return clients.get(serverId);
 }
 
-/** Call a tool on an MCP server */
+/** Call a tool on an MCP server, auto-reconnecting on session expiry */
 export async function callMCPTool(
   serverId: string,
   toolName: string,
@@ -424,5 +468,22 @@ export async function callMCPTool(
   if (!client.isConnected()) {
     throw new Error(`Server not connected: ${serverId}`);
   }
-  return client.callTool(toolName, args);
+
+  try {
+    return await client.callTool(toolName, args);
+  } catch (err) {
+    // Auto-reconnect once on session expiry, then retry the call
+    if (err instanceof Error && err.message.includes("session expired")) {
+      console.debug("MCP session expired during tool call, reconnecting…");
+      const store = useMCPStore.getState();
+      await store.connectServer(serverId);
+      // Retry with the (possibly new) client
+      const newClient = clients.get(serverId);
+      if (!newClient?.isConnected()) {
+        throw new Error(`Reconnection failed for server: ${serverId}`);
+      }
+      return newClient.callTool(toolName, args);
+    }
+    throw err;
+  }
 }

From 72cde53051566f035202bb3d7738d55c205cbcc7 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Wed, 25 Mar 2026 22:03:45 +1000
Subject: [PATCH 20/25] Simple MCP auth

---
 .../MCPConfigModal/MCPConfigModal.tsx         | 62 ++++++++++++++++---
 1 file changed, 55 insertions(+), 7 deletions(-)

diff --git a/ui/src/components/MCPConfigModal/MCPConfigModal.tsx b/ui/src/components/MCPConfigModal/MCPConfigModal.tsx
index 36bb0a20..a6d21c8b 100644
--- a/ui/src/components/MCPConfigModal/MCPConfigModal.tsx
+++ b/ui/src/components/MCPConfigModal/MCPConfigModal.tsx
@@ -17,6 +17,8 @@ import {
   CheckCircle2,
   ChevronDown,
   ChevronRight,
+  Eye,
+  EyeOff,
   Loader2,
   Pencil,
   Plug,
@@ -60,6 +62,7 @@ export interface MCPConfigModalProps {
 const serverFormSchema = z.object({
   name: z.string().min(1, "Name is required"),
   url: z.string().url("Must be a valid URL"),
+  bearerToken: z.string(),
   headers: z.string(),
 });
 
@@ -371,12 +374,25 @@ interface ServerFormProps {
 type TestStatus = "idle" | "testing" | "success" | "error";
 
 function ServerForm({ editingServer, onSubmit, onCancel }: ServerFormProps) {
+  const [showToken, setShowToken] = useState(false);
+
+  // Extract bearer token from existing headers, pass the rest as extra headers
+  const existingHeaders = editingServer?.headers ?? {};
+  const existingBearer = (() => {
+    const auth = existingHeaders["Authorization"] ?? existingHeaders["authorization"] ?? "";
+    return auth.startsWith("Bearer ") ? auth.slice(7) : "";
+  })();
+  const extraHeaders = Object.fromEntries(
+    Object.entries(existingHeaders).filter(([k]) => k.toLowerCase() !== "authorization")
+  );
+
   const form = useForm<ServerFormValues>({
     resolver: zodResolver(serverFormSchema),
     defaultValues: {
       name: editingServer?.name ?? "",
       url: editingServer?.url ?? "",
-      headers: editingServer?.headers ? JSON.stringify(editingServer.headers, null, 2) : "",
+      bearerToken: existingBearer,
+      headers: Object.keys(extraHeaders).length > 0 ? JSON.stringify(extraHeaders, null, 2) : "",
     },
   });
 
@@ -465,15 +481,40 @@ function ServerForm({ editingServer, onSubmit, onCancel }: ServerFormProps) {
       </FormField>
 
       <FormField
-        label="Headers (JSON)"
+        label="Authorization"
+        htmlFor="server-bearer-token"
+        helpText="Bearer token for authenticating with the MCP server"
+        error={form.formState.errors.bearerToken?.message}
+      >
+        <div className="relative">
+          <Input
+            id="server-bearer-token"
+            type={showToken ? "text" : "password"}
+            {...form.register("bearerToken")}
+            placeholder="your-api-key"
+            className="pr-10 font-mono"
+          />
+          <button
+            type="button"
+            onClick={() => setShowToken(!showToken)}
+            className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted text-muted-foreground"
+            aria-label={showToken ? "Hide token" : "Show token"}
+          >
+            {showToken ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
+          </button>
+        </div>
+      </FormField>
+
+      <FormField
+        label="Additional Headers (JSON)"
         htmlFor="server-headers"
-        helpText="Optional HTTP headers for authentication (e.g., API keys)"
+        helpText="Optional extra HTTP headers beyond authorization"
         error={form.formState.errors.headers?.message}
       >
         <textarea
           id="server-headers"
           {...form.register("headers")}
-          placeholder='{"Authorization": "Bearer your-api-key"}'
+          placeholder='{"X-Custom-Header": "value"}'
           className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono min-h-[80px]"
         />
       </FormField>
@@ -554,13 +595,20 @@ export function MCPConfigModal({ open, onClose }: MCPConfigModalProps) {
 
   const handleFormSubmit = useCallback(
     (values: ServerFormValues) => {
-      // Parse headers JSON if provided
+      // Merge bearer token + extra headers
       let headers: Record<string, string> | undefined;
+      const extra: Record<string, string> = {};
       if (values.headers) {
         try {
-          headers = JSON.parse(values.headers);
+          Object.assign(extra, JSON.parse(values.headers));
         } catch {
-          // Invalid JSON - ignore headers
+          // Invalid JSON - ignore extra headers
+        }
+      }
+      if (values.bearerToken || Object.keys(extra).length > 0) {
+        headers = { ...extra };
+        if (values.bearerToken) {
+          headers["Authorization"] = `Bearer ${values.bearerToken}`;
         }
       }
 

From bf8c21f1e498e9019384d34938397e446b52c2a5 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Wed, 25 Mar 2026 22:14:43 +1000
Subject: [PATCH 21/25] Add truncate to tavily

---
 src/routes/api/tools.rs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/routes/api/tools.rs b/src/routes/api/tools.rs
index 85c201cd..848b878e 100644
--- a/src/routes/api/tools.rs
+++ b/src/routes/api/tools.rs
@@ -158,7 +158,7 @@ pub async fn execute_web_search(
                 .map(|r| WebSearchResult {
                     title: r.title,
                     url: r.url,
-                    content: r.content,
+                    content: truncate(r.content),
                     score: r.score,
                 })
                 .collect())

From d8fc920ab3e10570db36d195fd8ce55e4b686d41 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Wed, 25 Mar 2026 22:26:47 +1000
Subject: [PATCH 22/25] Upgrade testcontainers

---
 Cargo.lock | 39 ++++++++++++++-------------------------
 Cargo.toml |  2 +-
 2 files changed, 15 insertions(+), 26 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 6a4bdd0c..5b6befcd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -290,9 +290,9 @@ dependencies = [
 
 [[package]]
 name = "astral-tokio-tar"
-version = "0.5.6"
+version = "0.6.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ec179a06c1769b1e42e1e2cbe74c7dcdb3d6383c838454d063eaac5bbb7ebbe5"
+checksum = "3c23f3af104b40a3430ccb90ed5f7bd877a8dc5c26fc92fde51a22b40890dcf9"
 dependencies = [
  "filetime",
  "futures-core",
@@ -1319,9 +1319,9 @@ dependencies = [
 
 [[package]]
 name = "bollard"
-version = "0.19.4"
+version = "0.20.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "87a52479c9237eb04047ddb94788c41ca0d26eaff8b697ecfbb4c32f7fdc3b1b"
+checksum = "ee04c4c84f1f811b017f2fbb7dd8815c976e7ca98593de9c1e2afad0f636bff4"
 dependencies = [
  "async-stream",
  "base64 0.22.1",
@@ -1329,7 +1329,6 @@ dependencies = [
  "bollard-buildkit-proto",
  "bollard-stubs",
  "bytes",
- "chrono",
  "futures-core",
  "futures-util",
  "hex",
@@ -1347,14 +1346,13 @@ dependencies = [
  "rand 0.9.2",
  "rustls 0.23.36",
  "rustls-native-certs",
- "rustls-pemfile",
  "rustls-pki-types",
  "serde",
  "serde_derive",
  "serde_json",
- "serde_repr",
  "serde_urlencoded",
  "thiserror 2.0.18",
+ "time",
  "tokio",
  "tokio-stream",
  "tokio-util",
@@ -1379,19 +1377,18 @@ dependencies = [
 
 [[package]]
 name = "bollard-stubs"
-version = "1.49.1-rc.28.4.0"
+version = "1.52.1-rc.29.1.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5731fe885755e92beff1950774068e0cae67ea6ec7587381536fca84f1779623"
+checksum = "0f0a8ca8799131c1837d1282c3f81f31e76ceb0ce426e04a7fe1ccee3287c066"
 dependencies = [
  "base64 0.22.1",
  "bollard-buildkit-proto",
  "bytes",
- "chrono",
  "prost 0.14.3",
  "serde",
  "serde_json",
  "serde_repr",
- "serde_with",
+ "time",
 ]
 
 [[package]]
@@ -7640,15 +7637,6 @@ dependencies = [
  "security-framework 3.5.1",
 ]
 
-[[package]]
-name = "rustls-pemfile"
-version = "2.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
-dependencies = [
- "rustls-pki-types",
-]
-
 [[package]]
 name = "rustls-pki-types"
 version = "1.14.0"
@@ -8857,9 +8845,9 @@ dependencies = [
 
 [[package]]
 name = "testcontainers"
-version = "0.26.3"
+version = "0.27.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a81ec0158db5fbb9831e09d1813fe5ea9023a2b5e6e8e0a5fe67e2a820733629"
+checksum = "0bd36b06a2a6c0c3c81a83be1ab05fe86460d054d4d51bf513bc56b3e15bdc22"
 dependencies = [
  "astral-tokio-tar",
  "async-trait",
@@ -8870,6 +8858,7 @@ dependencies = [
  "etcetera 0.11.0",
  "ferroid",
  "futures",
+ "http 1.4.0",
  "itertools 0.14.0",
  "log",
  "memchr",
@@ -8887,9 +8876,9 @@ dependencies = [
 
 [[package]]
 name = "testcontainers-modules"
-version = "0.14.0"
+version = "0.15.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5e75e78ff453128a2c7da9a5d5a3325ea34ea214d4bf51eab3417de23a4e5147"
+checksum = "e5985fde5befe4ffa77a052e035e16c2da86e8bae301baa9f9904ad3c494d357"
 dependencies = [
  "testcontainers",
 ]
@@ -10325,7 +10314,7 @@ version = "0.1.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
 dependencies = [
- "windows-sys 0.48.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
diff --git a/Cargo.toml b/Cargo.toml
index 93691daa..aee52f32 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -384,7 +384,7 @@ rstest = "0.24"
 serial_test = "3.2"
 temp-env = "0.3"
 tempfile = "3.23.0"
-testcontainers-modules = { version = "0.14", features = ["postgres", "redis"] }
+testcontainers-modules = { version = "0.15", features = ["postgres", "redis"] }
 tokio-stream = "0.1.17"
 wiremock = "0.6"
 

From 3792fc6b9aba83b59af6cb3e21fc68c297e7bb01 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Wed, 25 Mar 2026 22:31:43 +1000
Subject: [PATCH 23/25] Review fixes

---
 src/routes/api/tools.rs                             | 1 -
 ui/src/components/MCPConfigModal/MCPConfigModal.tsx | 9 ++++++++-
 2 files changed, 8 insertions(+), 2 deletions(-)

diff --git a/src/routes/api/tools.rs b/src/routes/api/tools.rs
index 848b878e..063d2999 100644
--- a/src/routes/api/tools.rs
+++ b/src/routes/api/tools.rs
@@ -164,7 +164,6 @@ pub async fn execute_web_search(
                 .collect())
         }
         WebSearchProvider::Exa => {
-            let max_chars = config.max_content_chars;
             let req = ExaSearchRequest {
                 query: query.to_string(),
                 num_results: max_results,
diff --git a/ui/src/components/MCPConfigModal/MCPConfigModal.tsx b/ui/src/components/MCPConfigModal/MCPConfigModal.tsx
index a6d21c8b..8e82229a 100644
--- a/ui/src/components/MCPConfigModal/MCPConfigModal.tsx
+++ b/ui/src/components/MCPConfigModal/MCPConfigModal.tsx
@@ -419,15 +419,22 @@ function ServerForm({ editingServer, onSubmit, onCancel }: ServerFormProps) {
 
     const values = form.getValues();
     let headers: Record<string, string> | undefined;
+    const extra: Record<string, string> = {};
     if (values.headers) {
       try {
-        headers = JSON.parse(values.headers);
+        Object.assign(extra, JSON.parse(values.headers));
       } catch {
         setTestStatus("error");
         setTestMessage("Invalid JSON in headers field");
         return;
       }
     }
+    if (values.bearerToken || Object.keys(extra).length > 0) {
+      headers = { ...extra };
+      if (values.bearerToken) {
+        headers["Authorization"] = `Bearer ${values.bearerToken}`;
+      }
+    }
 
     setTestStatus("testing");
     setTestMessage(undefined);

From 72dd93a5f87c8eb5f4a17ab4b50d0ee6028df336 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Wed, 25 Mar 2026 23:44:49 +1000
Subject: [PATCH 24/25] Fix storbook errors

---
 .../ChatMessage/ChatMessage.stories.tsx       |  11 +-
 .../ChatMessageList.stories.tsx               |   5 +-
 .../components/ChatView/ChatView.stories.tsx  |  11 +-
 .../MultiModelResponse.stories.tsx            | 162 +++++++++++++++++-
 .../MultiModelResponse/MultiModelResponse.tsx |  33 +++-
 5 files changed, 201 insertions(+), 21 deletions(-)

diff --git a/ui/src/components/ChatMessage/ChatMessage.stories.tsx b/ui/src/components/ChatMessage/ChatMessage.stories.tsx
index d62726d9..9f722411 100644
--- a/ui/src/components/ChatMessage/ChatMessage.stories.tsx
+++ b/ui/src/components/ChatMessage/ChatMessage.stories.tsx
@@ -135,8 +135,6 @@ export const StreamingMessage: Story = {
     isStreaming: true,
   },
   play: async ({ canvasElement }) => {
-    const canvas = within(canvasElement);
-
     // Verify streaming indicator has aria-live for accessibility
     const streamingContent = canvasElement.querySelector('[aria-live="polite"]');
     await expect(streamingContent).toBeInTheDocument();
@@ -145,12 +143,9 @@ export const StreamingMessage: Story = {
     const busyElement = canvasElement.querySelector('[aria-busy="true"]');
     await expect(busyElement).toBeInTheDocument();
 
-    // Verify blinking cursor is present (screen reader hidden)
-    const cursor = canvasElement.querySelector('[aria-hidden="true"]');
-    await expect(cursor).toBeInTheDocument();
-
-    // Verify screen reader text for streaming state
-    await expect(canvas.getByText("Generating response...")).toBeInTheDocument();
+    // Verify StreamingMarkdown component renders the content container
+    const markdownContainer = canvasElement.querySelector(".markdown-content");
+    await expect(markdownContainer).toBeInTheDocument();
   },
 };
 
diff --git a/ui/src/components/ChatMessageList/ChatMessageList.stories.tsx b/ui/src/components/ChatMessageList/ChatMessageList.stories.tsx
index a42b1431..5174edb0 100644
--- a/ui/src/components/ChatMessageList/ChatMessageList.stories.tsx
+++ b/ui/src/components/ChatMessageList/ChatMessageList.stories.tsx
@@ -339,8 +339,9 @@ export const Streaming: Story = {
     // Verify user message is rendered
     await expect(canvas.getByText("What's the meaning of life?")).toBeInTheDocument();
 
-    // Verify streaming content appears (one model has content, one doesn't)
-    await expect(canvas.getByText("I'm thinking about your question...")).toBeInTheDocument();
+    // Verify streaming content container is rendered (Streamdown animates text in)
+    const markdownContainers = canvasElement.querySelectorAll(".markdown-content");
+    await expect(markdownContainers.length).toBeGreaterThan(0);
 
     // Verify typing indicator for model with no content yet (shows "Thinking...")
     await expect(canvas.getByText(/Thinking/)).toBeInTheDocument();
diff --git a/ui/src/components/ChatView/ChatView.stories.tsx b/ui/src/components/ChatView/ChatView.stories.tsx
index b1df3365..a9fcedfb 100644
--- a/ui/src/components/ChatView/ChatView.stories.tsx
+++ b/ui/src/components/ChatView/ChatView.stories.tsx
@@ -402,9 +402,9 @@ export const Streaming: Story = {
   play: async ({ canvasElement, args }) => {
     const canvas = within(canvasElement);
 
-    // Verify streaming content from both models
-    await expect(canvas.getByText(/key differences/)).toBeInTheDocument();
-    await expect(canvas.getByText(/Redux and React Context/)).toBeInTheDocument();
+    // Verify streaming content containers are rendered (Streamdown animates text in)
+    const markdownContainers = canvasElement.querySelectorAll(".markdown-content");
+    await expect(markdownContainers.length).toBeGreaterThan(0);
 
     // Verify stop button is visible during streaming
     const stopButton = canvas.getByRole("button", { name: /stop/i });
@@ -997,7 +997,8 @@ export const ExecuteCode: Story = {
     await expect(canvas.getByText(/butterfly/)).toBeInTheDocument();
     await expect(canvas.getByText(/strange attractors/)).toBeInTheDocument();
 
-    // Verify tool execution block is rendered (2 tools across 2 rounds)
-    await expect(canvas.getByText(/2 tools/)).toBeInTheDocument();
+    // Verify tool execution blocks are rendered (1 tool per round, 2 rounds)
+    const toolBars = canvas.getAllByText(/1 tool\b/);
+    await expect(toolBars.length).toBe(2);
   },
 };
diff --git a/ui/src/components/MultiModelResponse/MultiModelResponse.stories.tsx b/ui/src/components/MultiModelResponse/MultiModelResponse.stories.tsx
index 672b68ab..3c72d1be 100644
--- a/ui/src/components/MultiModelResponse/MultiModelResponse.stories.tsx
+++ b/ui/src/components/MultiModelResponse/MultiModelResponse.stories.tsx
@@ -51,6 +51,12 @@ export const SingleResponse: Story = {
         model: "anthropic/claude-3-opus",
         content: "Hello! I'm Claude, an AI assistant made by Anthropic. How can I help you today?",
         isStreaming: false,
+        completedRounds: [
+          {
+            content:
+              "Hello! I'm Claude, an AI assistant made by Anthropic. How can I help you today?",
+          },
+        ],
       },
     ],
     timestamp: new Date(),
@@ -87,12 +93,24 @@ export const MultipleResponses: Story = {
         content:
           "Here's a solution using a recursive approach:\n\n```python\ndef factorial(n):\n    if n <= 1:\n        return 1\n    return n * factorial(n - 1)\n```\n\nThis is elegant but may hit recursion limits for large numbers.",
         isStreaming: false,
+        completedRounds: [
+          {
+            content:
+              "Here's a solution using a recursive approach:\n\n```python\ndef factorial(n):\n    if n <= 1:\n        return 1\n    return n * factorial(n - 1)\n```\n\nThis is elegant but may hit recursion limits for large numbers.",
+          },
+        ],
       },
       {
         model: "openai/gpt-4",
         content:
           "I'd recommend an iterative approach:\n\n```python\ndef factorial(n):\n    result = 1\n    for i in range(2, n + 1):\n        result *= i\n    return result\n```\n\nThis avoids stack overflow and is more memory efficient.",
         isStreaming: false,
+        completedRounds: [
+          {
+            content:
+              "I'd recommend an iterative approach:\n\n```python\ndef factorial(n):\n    result = 1\n    for i in range(2, n + 1):\n        result *= i\n    return result\n```\n\nThis avoids stack overflow and is more memory efficient.",
+          },
+        ],
       },
     ],
     timestamp: new Date(),
@@ -143,8 +161,9 @@ export const Streaming: Story = {
   play: async ({ canvasElement }) => {
     const canvas = within(canvasElement);
 
-    // Verify first model shows partial content
-    await expect(canvas.getByText(/I'm thinking about your question/i)).toBeInTheDocument();
+    // Verify first model's StreamingMarkdown container is rendered (content animates in via Streamdown)
+    const markdownContainers = canvasElement.querySelectorAll(".markdown-content");
+    await expect(markdownContainers.length).toBeGreaterThan(0);
 
     // Verify second model shows "Thinking" indicator (empty content during streaming)
     await expect(canvas.getByText("Thinking")).toBeInTheDocument();
@@ -169,6 +188,7 @@ export const WithError: Story = {
         model: "anthropic/claude-3-opus",
         content: "Here's my response to your question...",
         isStreaming: false,
+        completedRounds: [{ content: "Here's my response to your question..." }],
       },
       {
         model: "openai/gpt-4",
@@ -212,21 +232,39 @@ export const FourModels: Story = {
         content:
           "Claude's response: This is a comprehensive answer from Anthropic's flagship model.",
         isStreaming: false,
+        completedRounds: [
+          {
+            content:
+              "Claude's response: This is a comprehensive answer from Anthropic's flagship model.",
+          },
+        ],
       },
       {
         model: "openai/gpt-4-turbo",
         content: "GPT-4's response: Here's OpenAI's perspective on the question.",
         isStreaming: false,
+        completedRounds: [
+          { content: "GPT-4's response: Here's OpenAI's perspective on the question." },
+        ],
       },
       {
         model: "google/gemini-1.5-pro",
         content: "Gemini's response: Google's take on the problem at hand.",
         isStreaming: false,
+        completedRounds: [
+          { content: "Gemini's response: Google's take on the problem at hand." },
+        ],
       },
       {
         model: "mistral/mistral-large",
         content: "Mistral's response: An alternative viewpoint from the open-source leader.",
         isStreaming: false,
+        completedRounds: [
+          {
+            content:
+              "Mistral's response: An alternative viewpoint from the open-source leader.",
+          },
+        ],
       },
     ],
     timestamp: new Date(),
@@ -260,11 +298,13 @@ export const ViewModeToggle: Story = {
         model: "anthropic/claude-3-opus",
         content: "First model response for layout testing.",
         isStreaming: false,
+        completedRounds: [{ content: "First model response for layout testing." }],
       },
       {
         model: "openai/gpt-4",
         content: "Second model response for layout testing.",
         isStreaming: false,
+        completedRounds: [{ content: "Second model response for layout testing." }],
       },
     ],
     timestamp: new Date(),
@@ -306,6 +346,7 @@ export const WithTokenUsage: Story = {
         model: "anthropic/claude-3-opus",
         content: "Response with token usage information displayed.",
         isStreaming: false,
+        completedRounds: [{ content: "Response with token usage information displayed." }],
         usage: {
           inputTokens: 50,
           outputTokens: 100,
@@ -317,6 +358,7 @@ export const WithTokenUsage: Story = {
         model: "openai/gpt-4",
         content: "Another response with different usage stats.",
         isStreaming: false,
+        completedRounds: [{ content: "Another response with different usage stats." }],
         usage: {
           inputTokens: 45,
           outputTokens: 80,
@@ -361,11 +403,13 @@ export const WithSelectedBest: Story = {
         model: "anthropic/claude-3-opus",
         content: "This response was selected as the best.",
         isStreaming: false,
+        completedRounds: [{ content: "This response was selected as the best." }],
       },
       {
         model: "openai/gpt-4",
         content: "This response was not selected.",
         isStreaming: false,
+        completedRounds: [{ content: "This response was not selected." }],
       },
     ],
     timestamp: new Date(),
@@ -404,11 +448,13 @@ export const WithActionCallbacks: Story = {
         model: "anthropic/claude-3-opus",
         content: "Response with available actions.",
         isStreaming: false,
+        completedRounds: [{ content: "Response with available actions." }],
       },
       {
         model: "openai/gpt-4",
         content: "Another response with actions.",
         isStreaming: false,
+        completedRounds: [{ content: "Another response with actions." }],
       },
     ],
     timestamp: new Date(),
@@ -441,11 +487,13 @@ export const WithHistoryModeBadge: Story = {
         model: "anthropic/claude-3-opus",
         content: "Response generated with same-model history mode.",
         isStreaming: false,
+        completedRounds: [{ content: "Response generated with same-model history mode." }],
       },
       {
         model: "openai/gpt-4",
         content: "Each model only saw its own previous responses.",
         isStreaming: false,
+        completedRounds: [{ content: "Each model only saw its own previous responses." }],
       },
     ],
     timestamp: new Date(),
@@ -476,6 +524,13 @@ export const WithReasoningContent: Story = {
         reasoningContent:
           "Let me think about this step by step... First, I need to consider the question carefully...",
         isStreaming: false,
+        completedRounds: [
+          {
+            reasoning:
+              "Let me think about this step by step... First, I need to consider the question carefully...",
+            content: "The answer is 42.",
+          },
+        ],
         usage: {
           inputTokens: 20,
           outputTokens: 50,
@@ -558,7 +613,7 @@ export const WithToolCallAndContent: Story = {
       {
         model: "anthropic/claude-3-opus",
         content: "Based on my search of your documents, I found the following...",
-        isStreaming: true,
+        isStreaming: false,
         toolExecutionRounds: [
           {
             round: 1,
@@ -578,6 +633,28 @@ export const WithToolCallAndContent: Story = {
             ],
           },
         ],
+        completedRounds: [
+          {
+            toolExecution: {
+              round: 1,
+              executions: [
+                {
+                  id: "tc_2",
+                  toolName: "file_search",
+                  status: "success" as const,
+                  startTime: Date.now() - 1000,
+                  endTime: Date.now(),
+                  duration: 1000,
+                  input: { query: "test query" },
+                  inputArtifacts: [],
+                  outputArtifacts: [],
+                  round: 1,
+                },
+              ],
+            },
+            content: "Based on my search of your documents, I found the following...",
+          },
+        ],
       },
     ],
     timestamp: new Date(),
@@ -589,8 +666,8 @@ export const WithToolCallAndContent: Story = {
     // Verify content is shown
     await expect(canvas.getByText(/Based on my search of your documents/i)).toBeInTheDocument();
 
-    // Verify tool execution block is rendered
-    await expect(canvas.getByText("File Search")).toBeInTheDocument();
+    // Verify tool execution summary bar is rendered (shows "1 tool" collapsed)
+    await expect(canvas.getByText(/1 tool\b/)).toBeInTheDocument();
   },
 };
 
@@ -635,6 +712,40 @@ export const WithMultipleToolCalls: Story = {
             ],
           },
         ],
+        completedRounds: [
+          {
+            toolExecution: {
+              round: 1,
+              executions: [
+                {
+                  id: "tc_3",
+                  toolName: "file_search",
+                  status: "success" as const,
+                  startTime: Date.now() - 2000,
+                  endTime: Date.now() - 1000,
+                  duration: 1000,
+                  input: { query: "test query" },
+                  inputArtifacts: [],
+                  outputArtifacts: [],
+                  round: 1,
+                },
+                {
+                  id: "tc_4",
+                  toolName: "web_search",
+                  status: "success" as const,
+                  startTime: Date.now() - 1000,
+                  endTime: Date.now(),
+                  duration: 1000,
+                  input: { query: "web query" },
+                  inputArtifacts: [],
+                  outputArtifacts: [],
+                  round: 1,
+                },
+              ],
+            },
+            content: "Here are the results from my research...",
+          },
+        ],
       },
     ],
     timestamp: new Date(),
@@ -669,11 +780,13 @@ export const WithHideCallback: Story = {
         model: "anthropic/claude-3-opus",
         content: "Response that can be hidden.",
         isStreaming: false,
+        completedRounds: [{ content: "Response that can be hidden." }],
       },
       {
         model: "openai/gpt-4",
         content: "Another response for hide testing.",
         isStreaming: false,
+        completedRounds: [{ content: "Another response for hide testing." }],
       },
     ],
     timestamp: new Date(),
@@ -728,6 +841,7 @@ export const WithHiddenResponses: Story = {
         model: "anthropic/claude-3-opus",
         content: "This response is visible.",
         isStreaming: false,
+        completedRounds: [{ content: "This response is visible." }],
       },
     ],
     hiddenResponses: [
@@ -792,11 +906,13 @@ export const WithSingleHiddenResponse: Story = {
         model: "anthropic/claude-3-opus",
         content: "This response is visible.",
         isStreaming: false,
+        completedRounds: [{ content: "This response is visible." }],
       },
       {
         model: "openai/gpt-4",
         content: "Another visible response.",
         isStreaming: false,
+        completedRounds: [{ content: "Another visible response." }],
       },
     ],
     hiddenResponses: [
@@ -849,33 +965,66 @@ export const SixModelsScrollNavigation: Story = {
         content:
           "Claude Opus response: A thorough analysis considering multiple perspectives and edge cases.",
         isStreaming: false,
+        completedRounds: [
+          {
+            content:
+              "Claude Opus response: A thorough analysis considering multiple perspectives and edge cases.",
+          },
+        ],
       },
       {
         model: "openai/gpt-4-turbo",
         content: "GPT-4 Turbo response: A fast, detailed answer with practical code examples.",
         isStreaming: false,
+        completedRounds: [
+          {
+            content:
+              "GPT-4 Turbo response: A fast, detailed answer with practical code examples.",
+          },
+        ],
       },
       {
         model: "google/gemini-1.5-pro",
         content:
           "Gemini Pro response: Leveraging multimodal understanding for comprehensive analysis.",
         isStreaming: false,
+        completedRounds: [
+          {
+            content:
+              "Gemini Pro response: Leveraging multimodal understanding for comprehensive analysis.",
+          },
+        ],
       },
       {
         model: "mistral/mistral-large",
         content:
           "Mistral Large response: An efficient, open-weight model perspective on the topic.",
         isStreaming: false,
+        completedRounds: [
+          {
+            content:
+              "Mistral Large response: An efficient, open-weight model perspective on the topic.",
+          },
+        ],
       },
       {
         model: "anthropic/claude-3-haiku",
         content: "Claude Haiku response: A concise and rapid answer to your question.",
         isStreaming: false,
+        completedRounds: [
+          { content: "Claude Haiku response: A concise and rapid answer to your question." },
+        ],
       },
       {
         model: "openai/gpt-4o",
         content: "GPT-4o response: Combining speed and intelligence for a balanced answer.",
         isStreaming: false,
+        completedRounds: [
+          {
+            content:
+              "GPT-4o response: Combining speed and intelligence for a balanced answer.",
+          },
+        ],
       },
     ],
     timestamp: new Date(),
@@ -912,12 +1061,14 @@ export const WithEditButton: Story = {
         messageId: "msg-1",
         content: "This response can be edited by the user.",
         isStreaming: false,
+        completedRounds: [{ content: "This response can be edited by the user." }],
       },
       {
         model: "openai/gpt-4",
         messageId: "msg-2",
         content: "This response can also be edited.",
         isStreaming: false,
+        completedRounds: [{ content: "This response can also be edited." }],
       },
     ],
     timestamp: new Date(),
@@ -954,6 +1105,7 @@ export const WithoutEditButtonWhenNoMessageId: Story = {
         // No messageId provided
         content: "This response cannot be edited (no messageId).",
         isStreaming: false,
+        completedRounds: [{ content: "This response cannot be edited (no messageId)." }],
       },
     ],
     timestamp: new Date(),
diff --git a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
index 3b0a680b..68cedeb9 100644
--- a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
+++ b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
@@ -705,7 +705,38 @@ const ModelResponseCard = memo(function ModelResponseCard({
   }, [streamingToolExecutionRounds, response.toolExecutionRounds]);
   const hasToolExecutionRounds = toolExecutionRounds.length > 0;
 
-  const completedRounds = response.completedRounds ?? [];
+  // completedRounds is always populated for streaming responses. For committed messages
+  // loaded from the database or provided directly (e.g., stories), synthesize rounds
+  // from the response's content/reasoning/toolExecutionRounds so rendering still works.
+  const completedRounds = useMemo(() => {
+    if (response.completedRounds?.length) return response.completedRounds;
+    if (response.isStreaming) return [];
+    const hasContent = !!response.content?.trim();
+    const hasReasoning = !!response.reasoningContent;
+    const toolRounds = response.toolExecutionRounds ?? [];
+    if (!hasContent && !hasReasoning && !toolRounds.length) return [];
+    if (toolRounds.length <= 1) {
+      return [
+        {
+          reasoning: response.reasoningContent || undefined,
+          content: response.content || undefined,
+          toolExecution: toolRounds[0],
+        },
+      ];
+    }
+    // Multiple tool rounds: each round gets its own entry, content goes on the last
+    return toolRounds.map((tr, i) => ({
+      reasoning: i === 0 ? response.reasoningContent || undefined : undefined,
+      content: i === toolRounds.length - 1 ? response.content || undefined : undefined,
+      toolExecution: tr,
+    }));
+  }, [
+    response.completedRounds,
+    response.isStreaming,
+    response.content,
+    response.reasoningContent,
+    response.toolExecutionRounds,
+  ]);
 
   // All output artifacts across all rounds (for resolving display_artifacts selections)
   const allOutputArtifacts = useMemo(() => {

From 7ffb45b85e825a0725f0581501d1a12e5de47214 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Wed, 25 Mar 2026 23:54:52 +1000
Subject: [PATCH 25/25] Enable server when added

---
 .../MultiModelResponse.stories.tsx            | 13 +++----
 ui/src/pages/chat/useChat.ts                  |  2 ++
 ui/src/stores/mcpStore.ts                     | 36 +++++++++++++++++++
 3 files changed, 42 insertions(+), 9 deletions(-)

diff --git a/ui/src/components/MultiModelResponse/MultiModelResponse.stories.tsx b/ui/src/components/MultiModelResponse/MultiModelResponse.stories.tsx
index 3c72d1be..4b423370 100644
--- a/ui/src/components/MultiModelResponse/MultiModelResponse.stories.tsx
+++ b/ui/src/components/MultiModelResponse/MultiModelResponse.stories.tsx
@@ -251,9 +251,7 @@ export const FourModels: Story = {
         model: "google/gemini-1.5-pro",
         content: "Gemini's response: Google's take on the problem at hand.",
         isStreaming: false,
-        completedRounds: [
-          { content: "Gemini's response: Google's take on the problem at hand." },
-        ],
+        completedRounds: [{ content: "Gemini's response: Google's take on the problem at hand." }],
       },
       {
         model: "mistral/mistral-large",
@@ -261,8 +259,7 @@ export const FourModels: Story = {
         isStreaming: false,
         completedRounds: [
           {
-            content:
-              "Mistral's response: An alternative viewpoint from the open-source leader.",
+            content: "Mistral's response: An alternative viewpoint from the open-source leader.",
           },
         ],
       },
@@ -978,8 +975,7 @@ export const SixModelsScrollNavigation: Story = {
         isStreaming: false,
         completedRounds: [
           {
-            content:
-              "GPT-4 Turbo response: A fast, detailed answer with practical code examples.",
+            content: "GPT-4 Turbo response: A fast, detailed answer with practical code examples.",
           },
         ],
       },
@@ -1021,8 +1017,7 @@ export const SixModelsScrollNavigation: Story = {
         isStreaming: false,
         completedRounds: [
           {
-            content:
-              "GPT-4o response: Combining speed and intelligence for a balanced answer.",
+            content: "GPT-4o response: Combining speed and intelligence for a balanced answer.",
           },
         ],
       },
diff --git a/ui/src/pages/chat/useChat.ts b/ui/src/pages/chat/useChat.ts
index 78f4ecc1..ea1c81a3 100644
--- a/ui/src/pages/chat/useChat.ts
+++ b/ui/src/pages/chat/useChat.ts
@@ -771,6 +771,8 @@ export function useChat({
 
         // Add MCP tools from connected servers
         if (enabledTools.includes("mcp")) {
+          // Ensure enabled servers are connected before building tools list
+          await useMCPStore.getState().ensureConnected();
           const mcpState = useMCPStore.getState();
           for (const server of mcpState.servers) {
             // Skip disabled or disconnected servers
diff --git a/ui/src/stores/mcpStore.ts b/ui/src/stores/mcpStore.ts
index 61360fcb..28f6eeb1 100644
--- a/ui/src/stores/mcpStore.ts
+++ b/ui/src/stores/mcpStore.ts
@@ -72,6 +72,8 @@ interface MCPActions {
   _setServerTools: (serverId: string, tools: MCPToolDefinition[]) => void;
   /** Get all enabled tools across all connected servers */
   getEnabledTools: () => Array<{ server: MCPServerState; tool: MCPToolDefinition }>;
+  /** Connect all enabled servers that aren't already connected/connecting */
+  ensureConnected: () => Promise<void>;
   /** Disconnect all servers */
   disconnectAll: () => void;
 }
@@ -155,6 +157,15 @@ export const useMCPStore = create<MCPStore>()(
           servers: [...state.servers, serverState],
         }));
 
+        // Auto-connect if enabled
+        if (serverConfig.enabled) {
+          get()
+            .connectServer(id)
+            .catch((err) => {
+              console.debug("MCP auto-connect failed:", err);
+            });
+        }
+
         return id;
       },
 
@@ -286,6 +297,15 @@ export const useMCPStore = create<MCPStore>()(
             s.id === serverId ? { ...s, enabled: newEnabled } : s
           ),
         }));
+
+        // If enabling, auto-connect
+        if (newEnabled) {
+          get()
+            .connectServer(serverId)
+            .catch((err) => {
+              console.debug("MCP auto-connect failed:", err);
+            });
+        }
       },
 
       setToolEnabled: (serverId, toolName, enabled) => {
@@ -333,6 +353,22 @@ export const useMCPStore = create<MCPStore>()(
         return result;
       },
 
+      ensureConnected: async () => {
+        const { servers } = get();
+        const needsConnect = servers.filter(
+          (s) => s.enabled && s.status !== "connected" && s.status !== "connecting"
+        );
+        await Promise.all(
+          needsConnect.map((s) =>
+            get()
+              .connectServer(s.id)
+              .catch((err) => {
+                console.debug(`MCP auto-connect failed for ${s.name}:`, err);
+              })
+          )
+        );
+      },
+
       disconnectAll: () => {
         const { servers } = get();
         for (const server of servers) {
