main sync

This commit is contained in:
Pavel committed 2026-09-28 17:41:29 +04:00
commit cdbc4695ea
33 files changed
+3042 -727

No files matched your search

+150 -136
View File
@@ -21,6 +21,8 @@
"lodash": "^4.18.1",
"lucide-react": "^1.45.0",
"mermaid": "^12.0.0",
"micromark-util-character": "^2.1.1",
"micromark-util-symbol": "^2.0.1",
"radix-ui": "^1.6.7",
"react": "^19.3.0",
"react-chartjs-2": "^5.3.1",
@@ -38,7 +40,11 @@
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"tailwind-merge": "^3.6.0"
"remark-parse": "^11.0.0",
"remend": "^1.3.1",
"tailwind-merge": "^3.6.0",
"unified": "^11.0.5",
"unist-util-visit": "^5.1.0"
},
"devDependencies": {
"@eslint/js": "^9.39.5",
@@ -46,6 +52,7 @@
"@tailwindcss/postcss": "^4.3.3",
"@types/d3-force": "^3.0.10",
"@types/lodash": "^4.17.25",
"@types/mdast": "^4.0.4",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@types/react-syntax-highlighter": "^15.5.13",
@@ -64,6 +71,7 @@
"happy-dom": "^20.14.5",
"husky": "^9.1.7",
"lint-staged": "^17.5.1",
"micromark-util-types": "^2.0.3",
"postcss": "^8.5.28",
"prettier": "^3.9.6",
"prettier-plugin-tailwindcss": "^0.8.1",
@@ -72,7 +80,7 @@
"typescript": "^6.0.3",
"vite": "^8.3.0",
"vite-plugin-svgr": "^5.2.0",
"vitest": "^4.1.11"
"vitest": "^5.0.0"
},
"engines": {
"node": ">=22 <23"
@@ -3624,6 +3632,72 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
@@ -4390,34 +4464,17 @@
}
}
},
"node_modules/@vitest/expect": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz",
"integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.11",
"@jridgewell/trace-mapping": "0.3.31",
"@vitest/spy": "5.0.0",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
"magic-string": "^1.2.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
@@ -4445,74 +4502,26 @@
"@types/estree": "^1.0.0"
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
"node_modules/@vitest/mocker/node_modules/magic-string": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.3.1.tgz",
"integrity": "sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.11",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.11",
"@vitest/utils": "4.1.11",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
"@jridgewell/sourcemap-codec": "^1.6.0"
}
},
"node_modules/@vitest/spy": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz",
"integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.11",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/accessor-fn": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz",
@@ -9910,9 +9919,9 @@
"license": "MIT"
},
"node_modules/micromark-util-types": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz",
"integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.3.tgz",
"integrity": "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==",
"funding": [
{
"type": "GitHub Sponsors",
@@ -10417,13 +10426,6 @@
"node": ">=8"
}
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -11277,6 +11279,12 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/remend": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/remend/-/remend-1.3.1.tgz",
"integrity": "sha512-N3DiY5qbRPoa5vkxn1oDLMyXOVTeo6Hp+XOj6SIqJAYUgLS0Q587gILPMom/qm86AQ/ZrcOdwEIzCz8V3J0nxQ==",
"license": "Apache-2.0"
},
"node_modules/reselect": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.3.0.tgz",
@@ -11939,11 +11947,14 @@
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz",
"integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==",
"dev": true,
"license": "MIT"
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/tinycolor2": {
"version": "1.6.0",
@@ -11977,16 +11988,6 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyrainbow": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
"integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
@@ -12807,38 +12808,31 @@
}
},
"node_modules/vitest": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz",
"integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.11",
"@vitest/mocker": "4.1.11",
"@vitest/pretty-format": "4.1.11",
"@vitest/runner": "4.1.11",
"@vitest/snapshot": "4.1.11",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.1.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"@types/chai": "^5.2.2",
"@vitest/mocker": "5.0.0",
"chai": "^6.2.2",
"es-module-lexer": "^2.3.2",
"expect-type": "^1.4.0",
"magic-string": "^1.2.3",
"obug": "^2.1.4",
"picomatch": "^4.0.7",
"std-env": "^4.2.0",
"tinybench": "6.1.4",
"tinyexec": "1.3.0",
"tinyglobby": "^0.2.17",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
"node": "^22.12.0 || ^24.0.0 || >=26.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
@@ -12846,16 +12840,16 @@
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.11",
"@vitest/browser-preview": "4.1.11",
"@vitest/browser-webdriverio": "4.1.11",
"@vitest/coverage-istanbul": "4.1.11",
"@vitest/coverage-v8": "4.1.11",
"@vitest/ui": "4.1.11",
"@types/node": "^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "5.0.0",
"@vitest/browser-preview": "5.0.0",
"@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
"@vitest/coverage-istanbul": "5.0.0",
"@vitest/coverage-v8": "5.0.0",
"@vitest/ui": "5.0.0",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
@@ -12896,6 +12890,26 @@
}
}
},
"node_modules/vitest/node_modules/magic-string": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.3.1.tgz",
"integrity": "sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.6.0"
}
},
"node_modules/vitest/node_modules/tinyexec": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
"integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/web-namespaces": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
+10 -2
View File
@@ -38,6 +38,8 @@
"lodash": "^4.18.1",
"lucide-react": "^1.45.0",
"mermaid": "^12.0.0",
"micromark-util-character": "^2.1.1",
"micromark-util-symbol": "^2.0.1",
"radix-ui": "^1.6.7",
"react": "^19.3.0",
"react-chartjs-2": "^5.3.1",
@@ -55,7 +57,11 @@
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"tailwind-merge": "^3.6.0"
"remark-parse": "^11.0.0",
"remend": "^1.3.1",
"tailwind-merge": "^3.6.0",
"unified": "^11.0.5",
"unist-util-visit": "^5.1.0"
},
"devDependencies": {
"@eslint/js": "^9.39.5",
@@ -63,6 +69,7 @@
"@tailwindcss/postcss": "^4.3.3",
"@types/d3-force": "^3.0.10",
"@types/lodash": "^4.17.25",
"@types/mdast": "^4.0.4",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@types/react-syntax-highlighter": "^15.5.13",
@@ -81,6 +88,7 @@
"happy-dom": "^20.14.5",
"husky": "^9.1.7",
"lint-staged": "^17.5.1",
"micromark-util-types": "^2.0.3",
"postcss": "^8.5.28",
"prettier": "^3.9.6",
"prettier-plugin-tailwindcss": "^0.8.1",
@@ -89,7 +97,7 @@
"typescript": "^6.0.3",
"vite": "^8.3.0",
"vite-plugin-svgr": "^5.2.0",
"vitest": "^4.1.11"
"vitest": "^5.0.0"
},
"overrides": {
"lodash-es": "^4.18.1"
@@ -0,0 +1,265 @@
import { configureStore } from '@reduxjs/toolkit';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { Provider } from 'react-redux';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
// The upload modal pulls in the whole ingest UI; the composer never opens it here.
vi.mock('../upload/Upload', () => ({ default: () => null }));
import notificationsReducer from '../notifications/notificationsSlice';
import { prefSlice } from '../preferences/preferenceSlice';
import type { RootState } from '../store';
import uploadReducer, {
addAttachment,
selectCompletedAttachments,
type Attachment,
} from '../upload/uploadSlice';
import MessageInput from './MessageInput';
import { UPLOAD_STALL_TIMEOUT_MS } from './message-input/uploadStallGuard';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const makeStore = () =>
configureStore({
reducer: {
preference: prefSlice.reducer,
upload: uploadReducer,
notifications: notificationsReducer,
},
});
type TestStore = ReturnType<typeof makeStore>;
const att = (over: Partial<Attachment> = {}): Attachment => ({
id: 'ok-1',
fileName: 'notes.pdf',
progress: 100,
status: 'completed',
taskId: 't1',
...over,
});
/** Just enough of XMLHttpRequest for the composer's upload paths. */
class FakeXHR extends EventTarget {
static instances: FakeXHR[] = [];
upload = new EventTarget();
status = 0;
responseText = '';
timeout = 0;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
onabort: (() => void) | null = null;
ontimeout: (() => void) | null = null;
open() {}
setRequestHeader() {}
send() {
FakeXHR.instances.push(this);
}
private finish(handler: (() => void) | null, type: string) {
handler?.();
this.dispatchEvent(new Event(type));
this.dispatchEvent(new Event('loadend'));
}
/** What Android Chrome reported: no response, status 0. */
failNetwork() {
this.status = 0;
this.finish(this.onerror, 'error');
}
abort() {
this.finish(this.onabort, 'abort');
}
}
describe('MessageInput send with a failed attachment', () => {
let container: HTMLDivElement;
let root: Root;
let store: TestStore;
let onSubmit: ReturnType<typeof vi.fn<(text: string) => void>>;
const realXHR = globalThis.XMLHttpRequest;
beforeEach(() => {
localStorage.clear();
FakeXHR.instances = [];
globalThis.XMLHttpRequest = FakeXHR as unknown as typeof XMLHttpRequest;
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
store = makeStore();
onSubmit = vi.fn<(text: string) => void>();
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
globalThis.XMLHttpRequest = realXHR;
vi.useRealTimers();
});
const render = async (loading = false) => {
await act(async () => {
root.render(
<Provider store={store}>
<MessageInput
onSubmit={onSubmit}
loading={loading}
showSourceButton={false}
showToolButton={false}
autoFocus={false}
/>
</Provider>,
);
});
};
const textarea = () =>
container.querySelector<HTMLTextAreaElement>('#message-input')!;
const type = async (text: string) => {
const el = textarea();
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
'value',
)!.set!;
setter.call(el, text);
el.dispatchEvent(new Event('input', { bubbles: true }));
});
};
const pressEnter = async () => {
await act(async () => {
textarea().dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
);
});
};
const attachFile = async (name: string) => {
const input = container.querySelector<HTMLInputElement>(
'label input[type="file"]',
)!;
const file = new File(['%PDF-1.4'], name, { type: 'application/pdf' });
Object.defineProperty(input, 'files', {
value: [file],
configurable: true,
});
const sent = FakeXHR.instances.length;
await act(async () => {
input.dispatchEvent(new Event('change', { bubbles: true }));
});
// partitionAttachmentFiles sniffs the file asynchronously.
await act(async () => {
await vi.waitFor(() =>
expect(FakeXHR.instances.length).toBeGreaterThan(sent),
);
});
};
it('sends the question and drops the failed file', async () => {
store.dispatch(addAttachment(att()));
store.dispatch(
addAttachment(
att({ id: 'bad-1', fileName: 'broken.pdf', status: 'failed' }),
),
);
let idsAtSubmit: string[] = [];
onSubmit.mockImplementation(() => {
idsAtSubmit = selectCompletedAttachments(
store.getState() as unknown as RootState,
).map((a) => a.id);
});
await render();
await type('What does the contract say?');
await pressEnter();
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith('What does the contract say?');
expect(idsAtSubmit).toEqual(['ok-1']);
expect(store.getState().upload.attachments.map((a) => a.id)).toEqual([
'ok-1',
]);
expect(textarea().value).toBe('');
});
it('sends when the only attachment failed on the network', async () => {
await render();
await attachFile('scan.pdf');
expect(FakeXHR.instances).toHaveLength(1);
await act(async () => FakeXHR.instances[0].failNetwork());
const [chip] = store.getState().upload.attachments;
expect(chip.status).toBe('failed');
expect(chip.errorMessage).toBe('conversation.attachments.uploadFailed');
await type('Summarise this');
await pressEnter();
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith('Summarise this');
expect(store.getState().upload.attachments).toEqual([]);
});
it('shows no send-blocked alert after a failed send attempt', async () => {
store.dispatch(addAttachment(att({ id: 'bad-1', status: 'failed' })));
await render();
await type('hello');
await pressEnter();
expect(container.querySelector('[role="alert"]')).toBeNull();
});
it('fails a stalled upload and then flushes the queued send', async () => {
vi.useFakeTimers();
await render();
await attachFile('huge.pdf');
expect(store.getState().upload.attachments[0].status).toBe('uploading');
await type('Is it done?');
await pressEnter();
// Pending: the send is queued, not dropped.
expect(onSubmit).not.toHaveBeenCalled();
await act(async () => {
vi.advanceTimersByTime(UPLOAD_STALL_TIMEOUT_MS);
});
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith('Is it done?');
expect(store.getState().upload.attachments).toEqual([]);
});
it('keeps a queued question while another answer is streaming', async () => {
store.dispatch(addAttachment(att({ status: 'processing', progress: 30 })));
await render();
await type('Queued question');
await pressEnter();
expect(onSubmit).not.toHaveBeenCalled();
// A retry starts streaming, then the file settles.
await render(true);
await act(async () => {
store.dispatch({
type: 'upload/updateAttachment',
payload: { id: 'ok-1', updates: { status: 'completed' } },
});
});
expect(onSubmit).not.toHaveBeenCalled();
expect(textarea().value).toBe('Queued question');
// The stream ends: the held send goes out.
await render(false);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith('Queued question');
});
});
+67 -38
View File
@@ -51,6 +51,7 @@ import {
ToolsTrigger,
} from './message-input';
import { useArmedSend } from './message-input/armedSend';
import { guardUploadStall } from './message-input/uploadStallGuard';
import { cannotReadAttachment } from './message-input/attachmentReadability';
import { handleAbort } from '../conversation/conversationSlice';
import {
@@ -555,6 +556,7 @@ export default function MessageInput({
const files = supported;
const apiHost = envVar('VITE_API_HOST');
const uploadFailedMessage = t('conversation.attachments.uploadFailed');
if (files.length > 1) {
const formData = new FormData();
@@ -830,20 +832,29 @@ export default function MessageInput({
}
};
xhr.onerror = () => {
console.error('Upload network error');
Object.values(indexToUiId).forEach((id) =>
dispatch(
updateAttachment({
id,
updates: { status: 'failed' },
}),
),
);
};
// No response at all (status 0): a file the browser couldn't read,
// a dropped connection, or a stall the guard aborted.
xhr.onerror =
xhr.onabort =
xhr.ontimeout =
() => {
console.error('Upload network error');
Object.values(indexToUiId).forEach((id) =>
dispatch(
updateAttachment({
id,
updates: {
status: 'failed',
errorMessage: uploadFailedMessage,
},
}),
),
);
};
xhr.open('POST', `${apiHost}${endpoints.USER.STORE_ATTACHMENT}`);
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
guardUploadStall(xhr);
xhr.send(formData);
return;
}
@@ -951,17 +962,24 @@ export default function MessageInput({
}
};
xhr.onerror = () => {
dispatch(
updateAttachment({
id: uniqueId,
updates: { status: 'failed' },
}),
);
};
xhr.onerror =
xhr.onabort =
xhr.ontimeout =
() => {
dispatch(
updateAttachment({
id: uniqueId,
updates: {
status: 'failed',
errorMessage: uploadFailedMessage,
},
}),
);
};
xhr.open('POST', `${apiHost}${endpoints.USER.STORE_ATTACHMENT}`);
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
guardUploadStall(xhr);
xhr.send(formData);
});
},
@@ -1578,17 +1596,30 @@ export default function MessageInput({
};
// When ``allowSendWithoutText`` is set, an attachment-only submit is
// permitted as long as at least one attachment exists; a still-pending
// one arms the send instead of submitting (see handleSubmit).
// permitted as long as at least one attachment can still go out; a
// still-pending one arms the send instead of submitting (see handleSubmit).
// A failed one doesn't count: it is dropped at submit time.
const hasSubmittableContent =
Boolean(value.trim()) || (allowSendWithoutText && attachments.length > 0);
const canSubmit =
hasSubmittableContent &&
Boolean(value.trim()) ||
(allowSendWithoutText && attachments.some((a) => a.status !== 'failed'));
const composerIdle =
!loading &&
recordingState !== 'recording' &&
recordingState !== 'transcribing';
const canSubmit = hasSubmittableContent && composerIdle;
const submitNow = () => {
const failed = attachments.filter((a) => a.status === 'failed');
const hasContent =
Boolean(value.trim()) ||
(allowSendWithoutText &&
attachments.some((a) => a.status === 'completed'));
// An attachment-only send whose files all failed has nothing left to
// send; keep the failed chips so the user can see why.
if (!hasContent) return;
// A failed file will never succeed, so it must never cost the user their
// question: drop it and send with whatever did upload.
failed.forEach((a) => dispatch(removeAttachment(a.id)));
onSubmit(value);
setValue('');
if (isTouch) {
@@ -1607,7 +1638,14 @@ export default function MessageInput({
readiness: sendReadiness,
arm: armSend,
cancel: cancelArmedSend,
} = useArmedSend({ attachments, onFlush: submitNow });
} = useArmedSend({
attachments,
onFlush: submitNow,
// A queued send that settles while another answer streams would be
// refused by the consumer after the composer was already cleared; hold
// it until the composer can take a submit again.
canFlush: composerIdle,
});
// Adopt a question queued outside the composer: seed the input, arm,
// and hand the wait to the standard banner. If the attachments already
@@ -1621,9 +1659,10 @@ export default function MessageInput({
const handleSubmit = () => {
if (!canSubmit) return;
// Attachments still uploading/parsing (or failed) must never be
// silently dropped from the payload: hold the send in the composer
// until every attachment resolves, then flush automatically.
// Attachments still uploading/parsing must never be silently dropped
// from the payload: hold the send in the composer until every
// attachment resolves, then flush automatically. Failed ones don't hold
// it; submitNow drops them.
if (sendReadiness.state !== 'ready') {
armSend();
return;
@@ -1722,16 +1761,6 @@ export default function MessageInput({
</Button>
</div>
)}
{sendArmed && sendReadiness.state === 'blocked' && (
<div
className="text-destructive px-2 pb-1 text-xs sm:px-3"
role="alert"
>
{t('conversation.attachments.sendBlockedByFailed', {
names: sendReadiness.failedNames.join(', '),
})}
</div>
)}
{voiceError && (
<div className="text-destructive px-2 pb-1 text-xs sm:px-3">
{voiceError}
@@ -0,0 +1,122 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import type { Attachment } from '../../upload/uploadSlice';
import AttachmentChipList from './AttachmentChipList';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const att = (over: Partial<Attachment> = {}): Attachment => ({
id: 'a1',
fileName: 'scan.pdf',
progress: 0,
status: 'failed',
taskId: '',
errorMessage: 'Upload failed. The file could not be read.',
...over,
});
describe('AttachmentChipList failed chip', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
vi.useFakeTimers();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.useRealTimers();
});
const render = async (attachments: Attachment[]) => {
await act(async () => {
root.render(
<AttachmentChipList
attachments={attachments}
draggingId={null}
onRemove={() => {}}
onDragStart={() => {}}
onDragOver={() => {}}
onDropOn={() => {}}
/>,
);
});
};
const tooltip = () =>
document.body.querySelector('[data-slot="tooltip-content"]');
it('shows no inline failure line under the chips', async () => {
await render([att()]);
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(container.querySelector('.text-destructive')).toBeNull();
});
it('shows the reason in a tooltip when the chip is hovered', async () => {
await render([att()]);
expect(tooltip()).toBeNull();
// The remove X is an IconButton with its own tooltip; the chip is the div.
const chip = container.querySelector('div[data-slot="tooltip-trigger"]')!;
await act(async () => {
chip.dispatchEvent(
new PointerEvent('pointermove', {
bubbles: true,
pointerType: 'mouse',
}),
);
vi.advanceTimersByTime(500);
});
// The full name, since two long names can truncate to the same text.
expect(tooltip()?.textContent).toContain(
'scan.pdf: Upload failed. The file could not be read.',
);
});
it('announces failures in a persistent polite live region', async () => {
await render([att({ status: 'uploading', errorMessage: undefined })]);
const region = container.querySelector('[role="status"]');
expect(region?.textContent).toBe('');
await render([att()]);
// Same node, updated in place, so screen readers announce the change.
expect(container.querySelector('[role="status"]')).toBe(region);
expect(region?.textContent).toBe(
'scan.pdf: Upload failed. The file could not be read.',
);
});
it('describes the focusable remove button with the reason', async () => {
await render([att()]);
const remove = container.querySelector(
'button[aria-label="conversation.attachments.remove"]',
)!;
const describedBy = remove.getAttribute('aria-describedby');
expect(describedBy).toBeTruthy();
// Named, since the button's own label doesn't say which file.
expect(document.getElementById(describedBy!)?.textContent).toBe(
'scan.pdf: Upload failed. The file could not be read.',
);
});
it('adds no tooltip to a chip that did not fail', async () => {
await render([att({ status: 'completed', errorMessage: undefined })]);
expect(
container.querySelector('div[data-slot="tooltip-trigger"]'),
).toBeNull();
});
});
@@ -1,8 +1,10 @@
import { Paperclip, TriangleAlert, X } from 'lucide-react';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import type { Attachment } from '../../upload/uploadSlice';
import { IconButton } from '../ui/icon-button';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { cn } from '@/lib/utils';
type AttachmentChipListProps = {
@@ -29,13 +31,10 @@ export default function AttachmentChipList({
modelName,
}: AttachmentChipListProps) {
const { t } = useTranslation();
const reasonIdPrefix = useId();
const failureReasonOf = (attachment: Attachment) =>
attachment.errorMessage ?? t('conversation.attachments.failed');
// A tooltip is the one place a touch user can never look, and this list is
// where a phone picker's unsupported file lands. Show the reason inline,
// as soon as it is known, rather than only once a send is attempted.
const failures = attachments.filter(
(attachment) => attachment.status === 'failed' && attachment.errorMessage,
);
// Not a failure: the file is kept and still sends. It only warns that the
// model picked right now would receive nothing it can read.
const unreadable = modelName
@@ -46,7 +45,14 @@ export default function AttachmentChipList({
<>
<div className="flex flex-wrap gap-1.5 px-2 py-2 sm:gap-2 sm:px-3">
{attachments.map((attachment) => {
return (
const failed = attachment.status === 'failed';
// A failed file never blocks the send (it is dropped then), so its
// reason stays out of the way: the chip's warning icon marks it, the
// reason is a hover tooltip, and it describes the remove button (the
// chip's one focusable control) for keyboard and screen readers.
const failureReason = failed ? failureReasonOf(attachment) : null;
const reasonId = `${reasonIdPrefix}-${attachment.id}`;
const chip = (
<div
key={attachment.id}
draggable={true}
@@ -62,11 +68,6 @@ export default function AttachmentChipList({
: 'opacity-100',
draggingId === attachment.id && 'ring-primary/30 ring-2',
)}
title={
attachment.status === 'failed' && attachment.errorMessage
? `${attachment.fileName}: ${attachment.errorMessage}`
: attachment.fileName
}
>
<div className="bg-primary mr-2 flex size-8 items-center justify-center rounded-md p-1">
{attachment.status === 'completed' && (
@@ -115,9 +116,17 @@ export default function AttachmentChipList({
)}
</div>
<span className="max-w-[120px] truncate font-medium sm:max-w-[150px]">
<span
className="max-w-[120px] truncate font-medium sm:max-w-[150px]"
title={failed ? undefined : attachment.fileName}
>
{attachment.fileName}
</span>
{failureReason && (
<span id={reasonId} hidden>
{attachment.fileName}: {failureReason}
</span>
)}
<IconButton
label={t('conversation.attachments.remove')}
@@ -125,6 +134,7 @@ export default function AttachmentChipList({
size="icon-xs"
shape="pill"
className="ml-1.5"
aria-describedby={failureReason ? reasonId : undefined}
onClick={() => {
onRemove(attachment.id);
}}
@@ -133,21 +143,29 @@ export default function AttachmentChipList({
</IconButton>
</div>
);
if (!failureReason) return chip;
return (
<Tooltip key={attachment.id}>
<TooltipTrigger asChild>{chip}</TooltipTrigger>
<TooltipContent>
{attachment.fileName}: {failureReason}
</TooltipContent>
</Tooltip>
);
})}
</div>
{failures.length > 0 && (
<div
className="text-destructive flex flex-col gap-0.5 px-2 pb-1 text-xs sm:px-3"
role="alert"
>
{failures.map((attachment) => (
<span key={attachment.id}>
{attachment.fileName}: {attachment.errorMessage}
</span>
))}
</div>
)}
{/* Always mounted, so a chip turning failed is announced: the reason
itself is only on hover, which a screen reader never sees. */}
<div className="sr-only" role="status" aria-live="polite">
{attachments
.filter((attachment) => attachment.status === 'failed')
.map(
(attachment) =>
`${attachment.fileName}: ${failureReasonOf(attachment)}`,
)
.join(' ')}
</div>
{unreadable.length > 0 && (
<div
@@ -36,22 +36,28 @@ describe('getSendReadiness', () => {
).toEqual({ state: 'waiting', pendingCount: 2 });
});
it('blocks on a failed attachment, listing its name', () => {
it('ignores a failed attachment: it is dropped at send time', () => {
expect(
getSendReadiness([
att(),
att({ id: 'a2', status: 'failed', fileName: 'broken.pdf' }),
]),
).toEqual({ state: 'blocked', failedNames: ['broken.pdf'] });
).toEqual({ state: 'ready' });
});
it('failed takes precedence over pending', () => {
it('is ready when every attachment failed', () => {
expect(getSendReadiness([att({ status: 'failed' })])).toEqual({
state: 'ready',
});
});
it('still waits on a pending file next to a failed one', () => {
expect(
getSendReadiness([
att({ status: 'processing' }),
att({ status: 'uploading', progress: 5 }),
att({ id: 'a2', status: 'failed', fileName: 'broken.pdf' }),
]),
).toEqual({ state: 'blocked', failedNames: ['broken.pdf'] });
).toEqual({ state: 'waiting', pendingCount: 1 });
});
});
@@ -60,13 +66,15 @@ type HookApi = ReturnType<typeof useArmedSend>;
function Host({
attachments,
onFlush,
canFlush,
api,
}: {
attachments: Attachment[];
onFlush: () => void;
canFlush?: boolean;
api: { current: HookApi | null };
}) {
const hook = useArmedSend({ attachments, onFlush });
const hook = useArmedSend({ attachments, onFlush, canFlush });
api.current = hook;
return null;
}
@@ -90,10 +98,15 @@ describe('useArmedSend', () => {
container.remove();
});
const render = async (attachments: Attachment[]) => {
const render = async (attachments: Attachment[], canFlush?: boolean) => {
await act(async () => {
root.render(
<Host attachments={attachments} onFlush={onFlush} api={api} />,
<Host
attachments={attachments}
onFlush={onFlush}
canFlush={canFlush}
api={api}
/>,
);
});
};
@@ -120,19 +133,30 @@ describe('useArmedSend', () => {
expect(onFlush).toHaveBeenCalledTimes(1);
});
it('holds the flush while a file is failed and resumes when it is removed', async () => {
it('flushes once when the pending file fails instead of completing', async () => {
await render([att({ status: 'uploading', progress: 5 })]);
await act(async () => api.current!.arm());
expect(onFlush).not.toHaveBeenCalled();
await render([att({ status: 'failed', fileName: 'broken.pdf' })]);
expect(onFlush).toHaveBeenCalledTimes(1);
expect(api.current!.armed).toBe(false);
await render([att({ status: 'failed', fileName: 'broken.pdf' })]);
expect(onFlush).toHaveBeenCalledTimes(1);
});
it('holds a ready flush until canFlush turns true', async () => {
await render([att({ status: 'processing' })]);
await act(async () => api.current!.arm());
await render([att({ status: 'failed', fileName: 'broken.pdf' })]);
// Files resolved, but another answer is streaming: flushing now would
// clear the composer while the submit is refused.
await render([att()], false);
expect(onFlush).not.toHaveBeenCalled();
expect(api.current!.armed).toBe(true);
expect(api.current!.readiness).toEqual({
state: 'blocked',
failedNames: ['broken.pdf'],
});
await render([]);
await render([att()], true);
expect(onFlush).toHaveBeenCalledTimes(1);
expect(api.current!.armed).toBe(false);
});
@@ -3,16 +3,11 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import type { Attachment } from '../../upload/uploadSlice';
export type SendReadiness =
| { state: 'ready' }
| { state: 'waiting'; pendingCount: number }
| { state: 'blocked'; failedNames: string[] };
{ state: 'ready' } | { state: 'waiting'; pendingCount: number };
// A failed file never resolves, so it never holds the send: the composer
// drops it at submit time and sends the question with whatever succeeded.
export function getSendReadiness(attachments: Attachment[]): SendReadiness {
const failedNames = attachments
.filter((a) => a.status === 'failed')
.map((a) => a.fileName);
if (failedNames.length > 0) return { state: 'blocked', failedNames };
const pendingCount = attachments.filter(
(a) => a.status === 'uploading' || a.status === 'processing',
).length;
@@ -24,9 +19,13 @@ export function getSendReadiness(attachments: Attachment[]): SendReadiness {
export function useArmedSend({
attachments,
onFlush,
canFlush = true,
}: {
attachments: Attachment[];
onFlush: () => void;
// False while the composer can't take a submit (an answer is streaming):
// the armed send holds until it can, instead of flushing into a refusal.
canFlush?: boolean;
}) {
const [armed, setArmed] = useState(false);
// Latest-closure ref so the flush submits the current composer value,
@@ -42,12 +41,12 @@ export function useArmedSend({
flushedRef.current = false;
return;
}
if (readiness.state === 'ready' && !flushedRef.current) {
if (readiness.state === 'ready' && canFlush && !flushedRef.current) {
flushedRef.current = true;
setArmed(false);
flushRef.current();
}
}, [armed, readiness.state]);
}, [armed, readiness.state, canFlush]);
const arm = useCallback(() => setArmed(true), []);
const cancel = useCallback(() => setArmed(false), []);
@@ -0,0 +1,54 @@
import { guardUploadStall, UPLOAD_STALL_TIMEOUT_MS } from './uploadStallGuard';
class FakeXHR extends EventTarget {
upload = new EventTarget();
abort = vi.fn(() => {
this.dispatchEvent(new Event('abort'));
this.dispatchEvent(new Event('loadend'));
});
}
const make = () => {
const xhr = new FakeXHR();
guardUploadStall(xhr as unknown as XMLHttpRequest);
return xhr;
};
describe('guardUploadStall', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('aborts an upload that never reports progress', () => {
const xhr = make();
vi.advanceTimersByTime(UPLOAD_STALL_TIMEOUT_MS - 1);
expect(xhr.abort).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(xhr.abort).toHaveBeenCalledTimes(1);
});
it('lets a slow upload run while it keeps making progress', () => {
const xhr = make();
for (let i = 0; i < 5; i += 1) {
vi.advanceTimersByTime(UPLOAD_STALL_TIMEOUT_MS - 1);
xhr.upload.dispatchEvent(new Event('progress'));
}
expect(xhr.abort).not.toHaveBeenCalled();
});
it('gives the server a fresh window once the body is sent', () => {
const xhr = make();
vi.advanceTimersByTime(UPLOAD_STALL_TIMEOUT_MS - 1);
xhr.upload.dispatchEvent(new Event('load'));
vi.advanceTimersByTime(UPLOAD_STALL_TIMEOUT_MS - 1);
expect(xhr.abort).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(xhr.abort).toHaveBeenCalledTimes(1);
});
it('stops watching once the request settles', () => {
const xhr = make();
xhr.dispatchEvent(new Event('loadend'));
vi.advanceTimersByTime(UPLOAD_STALL_TIMEOUT_MS * 2);
expect(xhr.abort).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,33 @@
// How long an attachment upload may go without any sign of life. It is an
// idle window, not a total cap: a large file on a slow link keeps resetting
// it with progress events, so only a silent stall is cut off.
export const UPLOAD_STALL_TIMEOUT_MS = 120_000;
/**
* Abort ``xhr`` once it makes no progress for ``idleMs``.
*
* ``xhr.timeout`` would cap the whole request, which a large file on a slow
* connection can legitimately exceed. This resets on every upload progress
* event, and again when the body finishes sending so the server gets its own
* window to answer. The abort fires the request's ``onabort`` handler, which
* is where the caller marks the attachment failed.
*
* Args:
* xhr: The request to watch. Call before ``send()``.
* idleMs: The longest allowed gap between signs of progress.
*/
export function guardUploadStall(
xhr: XMLHttpRequest,
idleMs: number = UPLOAD_STALL_TIMEOUT_MS,
): void {
let timer: ReturnType<typeof setTimeout> | undefined;
const restart = () => {
clearTimeout(timer);
timer = setTimeout(() => xhr.abort(), idleMs);
};
xhr.upload.addEventListener('progress', restart);
xhr.upload.addEventListener('load', restart);
xhr.addEventListener('progress', restart);
xhr.addEventListener('loadend', () => clearTimeout(timer));
restart();
}
+7
View File
@@ -26,6 +26,9 @@ type AnswerFlowProps = {
// Absent on reload, where the order is synthesized from the flat fields.
segments?: AnswerSegment[];
isStreaming?: boolean;
/** How many sources the answer can cite; see `MarkdownAnswer`. */
sourceCount?: number;
onOpenSources?: () => void;
agentId?: string;
/** Set when the bubble already carries its own progress UI (a research run). */
suppressStatusLine?: boolean;
@@ -51,6 +54,8 @@ export default function AnswerFlow({
toolCalls,
segments,
isStreaming,
sourceCount,
onOpenSources,
agentId,
suppressStatusLine,
artifacts,
@@ -136,6 +141,8 @@ export default function AnswerFlow({
<MarkdownAnswer
content={message}
isStreaming={isStreaming}
sourceCount={sourceCount}
onOpenSources={onOpenSources}
artifacts={artifacts}
turnArtifacts={turnArtifacts}
onOpenArtifact={onOpenArtifact}
+6 -4
View File
@@ -224,9 +224,9 @@ export default function Conversation() {
});
} else if (getSendReadiness(attachments).state !== 'ready') {
// Direct new sends (hero suggestion cards) bypass MessageInput's
// submit gate. With files still uploading/parsing (or failed),
// sending now would silently drop them — route the question into
// the composer instead, where the armed-send banner takes over.
// submit gate. With files still uploading/parsing, sending now
// would silently drop them — route the question into the composer
// instead, where the armed-send banner takes over.
setQueuedQuestion(trimmedQuestion);
} else {
const filesAttached = completedAttachments
@@ -247,7 +247,9 @@ export default function Conversation() {
index,
attachmentIds: filesAttached.map((f) => f.id),
});
if (filesAttached.length > 0) dispatch(clearAttachments());
// Clears the sent files and drops any failed ones, which never
// hold a send (nothing is pending here, so nothing else remains).
if (attachments.length > 0) dispatch(clearAttachments());
}
},
[dispatch, handleFetchAnswer, completedAttachments, attachments, queries],
@@ -9,3 +9,18 @@
.list li > .list {
margin-top: 0.5em;
}
/* A display formula wider than the answer column scrolls in its own box, as
fenced code and tables do, rather than pushing the column sideways. The
padding keeps tall symbols clear of the clipped edge. */
.answer :global(.katex-display) {
overflow-x: auto;
overflow-y: hidden;
padding-block: 0.25em;
}
/* Table headers are set in capitals, but a formula keeps its case: `n` and
`N` are different variables. */
.answer thead :global(.katex) {
text-transform: none;
}
@@ -16,7 +16,14 @@ import {
ThumbsUp,
ExternalLink,
} from 'lucide-react';
import { forwardRef, Fragment, useEffect, useRef, useState } from 'react';
import {
forwardRef,
Fragment,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
@@ -126,6 +133,7 @@ const ConversationBubble = forwardRef<
const [shouldShowToggle, setShouldShowToggle] = useState(false);
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(false);
const openSources = useCallback(() => setIsSidebarOpen(true), []);
const editableQueryRef = useRef<HTMLDivElement>(null);
const [isQuestionCollapsed, setIsQuestionCollapsed] = useState(true);
@@ -279,15 +287,18 @@ const ConversationBubble = forwardRef<
</div>
);
} else {
const showSources = !(
DisableSourceFE ||
type === 'ERROR' ||
sources?.length === 0 ||
sources?.some((source) => source.link === 'None')
);
bubble = (
<div
ref={ref}
className={cn('flex flex-wrap self-start', className, 'group flex-col')}
>
{DisableSourceFE ||
type === 'ERROR' ||
sources?.length === 0 ||
sources?.some((source) => source.link === 'None')
{!showSources
? null
: sources && (
// Stretched, not shrink-to-fit: the grid below sizes off this box,
@@ -446,6 +457,10 @@ const ConversationBubble = forwardRef<
toolCalls={toolCalls}
segments={segments}
isStreaming={isStreaming}
// A citation pill jumps to a source card, so an answer whose
// sources are not shown has nothing to cite.
sourceCount={showSources ? (sources?.length ?? 0) : 0}
onOpenSources={openSources}
agentId={agentId}
// A research run already narrates itself above; the status line
// would be a second live indicator away from the point of action.
@@ -1,194 +0,0 @@
/**
* Code must survive the markdown string pre-passes.
*
* ``processMarkdownContent`` rewrote every ``[N]`` as ``[N](#cite-N)`` and
* ``preprocessLaTeX`` every ``\[…\]`` as ``$$…$$``, neither with any awareness
* of code, so an index subscript or a bash prompt escape came out corrupted in
* the rendered answer — and in what the copy button copied.
*
* Neither rewrite can move into a remark plugin, where a ``code`` node would be
* distinct from a ``text`` one: remark-math tokenises ``$``/``$$`` while
* parsing, so the LaTeX pass has to happen on the raw string. Hence the shared
* ``applyOutsideCode`` masking helper rather than an AST visitor.
*/
import { describe, expect, it } from 'vitest';
import {
applyOutsideCode,
preprocessLaTeX,
processMarkdownContent,
} from './MarkdownAnswer';
/** The rendered text of a single-segment answer. */
const text = (source: string) => processMarkdownContent(source)[0].content;
describe('citation rewriting spares code', () => {
it('leaves an index subscript alone inside a fence', () => {
const source = '```python\nfor row in cur:\n print(row[0])\n```';
expect(text(source)).toBe(source);
});
it('leaves an index subscript alone inside an inline span', () => {
expect(text('Use `items[2]` here.')).toBe('Use `items[2]` here.');
});
it('leaves a mid-sentence triple-backtick span alone', () => {
expect(text('inline ```code[0]``` end')).toBe('inline ```code[0]``` end');
});
it('leaves a triple-backtick span that starts a line alone', () => {
// A backtick fence's info string cannot contain backticks, so this opens a
// code span, not an unclosed fence that swallows the rest of the answer.
expect(text('```code[0]``` and see [1]')).toBe(
'```code[0]``` and see [1](#cite-1)',
);
});
it('closes an inline span only on a run of its own length', () => {
// The `` run does not close the ` span, so `b[1]` is still code.
const source = 'Use `a`` b[1] more` end';
expect(text(source)).toBe(source);
});
it('recognises a fence closed on a CRLF line', () => {
expect(text('```js\r\nconst a = arr[0];\r\n```\r\nSee [1].')).toBe(
'```js\r\nconst a = arr[0];\r\n```\r\nSee [1](#cite-1).',
);
});
it('does not carry an inline span across a blank line', () => {
// The blank line ends the paragraph, so the backticks never pair and
// `bar[1]` is prose — which is how remark parses it too.
expect(text('a `foo\n\nbar[1]` end')).toBe(
'a `foo\n\nbar[1](#cite-1)` end',
);
});
it('leaves an inline span that wraps a line alone', () => {
expect(text('use `arr[0]\nnext` here')).toBe('use `arr[0]\nnext` here');
});
it('leaves a tilde fence alone', () => {
const source = '~~~python\nprint(row[0])\n~~~';
expect(text(source)).toBe(source);
});
it('leaves a fence nested in a longer one alone', () => {
// A ``` run does not close a ```` fence, so the inner block is content.
const source = '````markdown\n```js\nconst a = arr[0];\n```\n````';
expect(text(source)).toBe(source);
});
it('leaves a fence indented under a list item alone', () => {
const source =
'1. Step:\n\n ```js\n const a = arr[0];\n\n const b = arr[1];\n ```';
expect(text(source)).toBe(source);
});
it('leaves an unterminated fence alone while the answer is still streaming', () => {
const source = '```python\nprint(row[0]';
expect(text(source)).toBe(source);
});
it('leaves an unterminated inline span alone while streaming', () => {
expect(text('Use `items[2')).toBe('Use `items[2');
});
it('leaves mermaid node labels alone', () => {
// The cite pass runs before the ```mermaid split, so diagram source was
// corrupted too and the render failed.
const source = '```mermaid\nflowchart TD\n A[0] --> B[1]\n```';
expect(processMarkdownContent(source)[0].content).toBe(
'flowchart TD\n A[0] --> B[1]',
);
});
it('still links a real citation in prose', () => {
expect(text('See source [1] for details.')).toBe(
'See source [1](#cite-1) for details.',
);
});
it('links prose citations on either side of a fence without touching it', () => {
expect(
text('Per [1], run:\n```js\nconst a = arr[0];\n```\nthen [2].'),
).toBe(
'Per [1](#cite-1), run:\n```js\nconst a = arr[0];\n```\nthen [2](#cite-2).',
);
});
it('still links a citation on an indented list continuation line', () => {
// Four-space indents are list content far more often than they are code,
// so they are prose to the masker.
expect(text('- one\n - nested [1]')).toBe(
'- one\n - nested [1](#cite-1)',
);
});
it('leaves an already-linked reference alone', () => {
expect(text('see [1](#cite-1) ok')).toBe('see [1](#cite-1) ok');
});
});
describe('LaTeX preprocessing spares code', () => {
it('leaves bash prompt escapes alone', () => {
// `\[`/`\]` are non-printing-sequence markers in PS1; the block rule turned
// them into `$$` delimiters and broke the prompt.
const source = '```bash\nPS1="\\[\\e[0m\\]$ "\n```';
expect(preprocessLaTeX(source)).toBe(source);
});
it('leaves bracket character classes alone in an inline span', () => {
expect(preprocessLaTeX('match `\\[0-9\\]` here')).toBe(
'match `\\[0-9\\]` here',
);
});
it('still converts real inline math in prose', () => {
expect(preprocessLaTeX('The value \\(x^2\\) is fine.')).toBe(
'The value $x^2$ is fine.',
);
});
it('still converts real block math in prose', () => {
expect(preprocessLaTeX('Given \\[a+b\\] we get')).toBe(
'Given $$a+b$$ we get',
);
});
it('does not link a subscript inside the math it just produced', () => {
// `$a[1](#cite-1)$` is not something KaTeX can render.
expect(text('value \\(a[1]\\) end')).toBe('value $a[1]$ end');
expect(text('given \\[a[1]+b\\] we get [2]')).toBe(
'given $$a[1]+b$$ we get [2](#cite-2)',
);
});
});
describe('applyOutsideCode', () => {
const shout = (segment: string) => segment.toUpperCase();
it('transforms prose either side of every code span', () => {
expect(applyOutsideCode('a `b` c ```d``` e', shout)).toBe(
'A `b` C ```d``` E',
);
});
it('handles several inline spans on one line', () => {
expect(applyOutsideCode('x `a` y `b` z', shout)).toBe('X `a` Y `b` Z');
});
it('treats a double-backtick span containing a backtick as one span', () => {
expect(applyOutsideCode('x ``a`b`` y', shout)).toBe('X ``a`b`` Y');
});
it('does not close a long fence on a shorter run', () => {
expect(applyOutsideCode('````\na\n```\nb\n````\nc', shout)).toBe(
'````\na\n```\nb\n````\nC',
);
});
it('passes an empty string through', () => {
expect(applyOutsideCode('', shout)).toBe('');
});
});
@@ -0,0 +1,209 @@
/**
* Code must survive citation linking and math parsing.
*
* Both used to be regex passes over the raw answer, and both corrupted the
* user's own code: `print(row[0])` rendered as `print(row[0](#cite-0))`, and
* `PS1="\[\e[0m\]"` as `PS1="$$\e[0m$$"`, in the answer and in what the copy
* button copied. Math is now parsed by the markdown parser and citations are
* linked on its syntax tree, where code is never plain text; these cases pin
* that down on the rendered answer.
*/
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
vi.mock('../hooks', () => ({ useDarkTheme: () => [false, vi.fn()] }));
vi.mock('../components/MermaidRenderer', () => ({
default: ({ code }: { code: string }) => (
<div data-testid="mermaid">{code}</div>
),
}));
vi.mock('../components/CopyButton', () => ({ default: () => null }));
import MarkdownAnswer from './MarkdownAnswer';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
function render(content: string, isStreaming = false) {
act(() =>
root.render(<MarkdownAnswer content={content} isStreaming={isStreaming} />),
);
}
/** Text of every code block and inline code span, in order. */
function code() {
return Array.from(container.querySelectorAll('code')).map(
(node) => node.textContent,
);
}
/** Citation pills, the only buttons these answers render. */
function citations() {
return Array.from(container.querySelectorAll('button')).map(
(node) => node.textContent,
);
}
function math() {
return container.querySelectorAll('.katex, .katex-error').length;
}
describe('citation linking spares code', () => {
it('leaves an index subscript alone inside a fence', () => {
render('```python\nfor row in cur:\n print(row[0])\n```');
expect(code()).toEqual(['for row in cur:\n print(row[0])']);
expect(citations()).toEqual([]);
});
it('leaves an index subscript alone inside an inline span', () => {
render('Use `items[2]` here.');
expect(code()).toEqual(['items[2]']);
expect(citations()).toEqual([]);
});
it('leaves a mid-sentence triple-backtick span alone', () => {
render('inline ```code[0]``` end');
expect(code()).toEqual(['code[0]']);
expect(citations()).toEqual([]);
});
it('leaves a triple-backtick span that starts a line alone', () => {
// A backtick fence's info string cannot contain backticks, so this opens a
// code span, not an unclosed fence that swallows the rest of the answer.
render('```code[0]``` and see [1]');
expect(code()).toEqual(['code[0]']);
expect(citations()).toEqual(['1']);
});
it('closes an inline span only on a run of its own length', () => {
// The `` run does not close the ` span, so `b[1]` is still code.
render('Use `a`` b[1] more` end');
expect(code()).toEqual(['a`` b[1] more']);
expect(citations()).toEqual([]);
});
it('recognises a fence closed on a CRLF line', () => {
render('```js\r\nconst a = arr[0];\r\n```\r\nSee [1].');
expect(code()).toEqual(['const a = arr[0];']);
expect(citations()).toEqual(['1']);
});
it('does not carry an inline span across a blank line', () => {
// The blank line ends the paragraph, so the backticks never pair and
// `bar[1]` is prose.
render('a `foo\n\nbar[1]` end');
expect(code()).toEqual([]);
expect(citations()).toEqual(['1']);
});
it('leaves an inline span that wraps a line alone', () => {
render('use `arr[0]\nnext` here');
expect(code()).toEqual(['arr[0] next']);
expect(citations()).toEqual([]);
});
it('leaves a tilde fence alone', () => {
render('~~~python\nprint(row[0])\n~~~');
expect(code()).toEqual(['print(row[0])']);
expect(citations()).toEqual([]);
});
it('leaves a fence nested in a longer one alone', () => {
// A ``` run does not close a ```` fence, so the inner block is content.
render('````markdown\n```js\nconst a = arr[0];\n```\n````');
expect(code()).toEqual(['```js\nconst a = arr[0];\n```']);
expect(citations()).toEqual([]);
});
it('leaves a fence indented under a list item alone', () => {
render(
'1. Step:\n\n ```js\n const a = arr[0];\n\n const b = arr[1];\n ```',
);
expect(code()).toEqual(['const a = arr[0];\n\nconst b = arr[1];']);
expect(citations()).toEqual([]);
});
it('leaves an unterminated fence alone while the answer is still streaming', () => {
render('```python\nprint(row[0]', true);
expect(code()).toEqual(['print(row[0]']);
expect(citations()).toEqual([]);
});
it('leaves an unterminated inline span alone while streaming', () => {
render('Use `items[2', true);
expect(code()).toEqual(['items[2']);
expect(citations()).toEqual([]);
});
it('leaves mermaid node labels alone', () => {
render('```mermaid\nflowchart TD\n A[0] --> B[1]\n```');
expect(
container.querySelector('[data-testid="mermaid"]')?.textContent,
).toBe('flowchart TD\n A[0] --> B[1]');
expect(citations()).toEqual([]);
});
it('still links a real citation in prose', () => {
render('See source [1] for details.');
expect(citations()).toEqual(['1']);
expect(container.textContent).toBe('See source 1 for details.');
});
it('links prose citations on either side of a fence without touching it', () => {
render('Per [1], run:\n```js\nconst a = arr[0];\n```\nthen [2].');
expect(code()).toEqual(['const a = arr[0];']);
expect(citations()).toEqual(['1', '2']);
});
it('still links a citation on an indented list continuation line', () => {
render('- one\n - nested [1]');
expect(citations()).toEqual(['1']);
});
it('leaves an already-linked reference alone', () => {
render('see [1](#cite-1) ok');
expect(citations()).toEqual(['1']);
});
});
describe('math parsing spares code', () => {
it('leaves bash prompt escapes alone', () => {
// `\[`/`\]` are non-printing-sequence markers in PS1; the old block rule
// turned them into `$$` delimiters and broke the prompt.
render('```bash\nPS1="\\[\\e[0m\\]$ "\n```');
expect(code()).toEqual(['PS1="\\[\\e[0m\\]$ "']);
expect(math()).toBe(0);
});
it('leaves bracket character classes alone in an inline span', () => {
render('match `\\[0-9\\]` here');
expect(code()).toEqual(['\\[0-9\\]']);
expect(math()).toBe(0);
});
it('leaves dollars alone in an inline span', () => {
render('Run `echo $HOME and $$` then `$x$`.');
expect(code()).toEqual(['echo $HOME and $$', '$x$']);
expect(math()).toBe(0);
});
it('does not link a subscript inside math', () => {
render('value \\(a[1]\\) end, given \\[a[1]+b\\] we get [2]');
expect(math()).toBe(2);
expect(container.querySelectorAll('.katex-error').length).toBe(0);
expect(citations()).toEqual(['2']);
});
});
@@ -0,0 +1,561 @@
/**
* Math must render as math, and only math.
*
* ``processMarkdownContent`` rewrote inline ``\(x\)`` to ``$x$`` while
* remark-math runs with ``singleDollarTextMath: false``, so every inline span
* rendered as the literal text ``$x$`` (students asked "what does $ mean").
* A one-line ``\[ … \]`` became ``$$ … $$`` mid-paragraph, which remark-math
* reads as *inline* math, so display equations shrank into the sentence. And a
* citation pass over the raw string turned ``$$a[1]$$`` into a link inside the
* formula. Math is now parsed by the markdown parser itself; these tests only
* look at what renders.
*/
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
// Records the markdown each ReactMarkdown instance renders.
const markdownRenders = vi.hoisted(() => vi.fn());
vi.mock('react-markdown', async (importOriginal) => {
const actual = await importOriginal<typeof import('react-markdown')>();
return {
...actual,
default: (props: Parameters<typeof actual.default>[0]) => {
markdownRenders(props.children);
return actual.default(props);
},
};
});
vi.mock('../hooks', () => ({ useDarkTheme: () => [false, vi.fn()] }));
vi.mock('../components/MermaidRenderer', () => ({
default: ({ code }: { code: string }) => (
<div data-testid="mermaid">{code}</div>
),
}));
vi.mock('../components/CopyButton', () => ({ default: () => null }));
import MarkdownAnswer from './MarkdownAnswer';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
type RenderOptions = {
isStreaming?: boolean;
sourceCount?: number;
onOpenSources?: () => void;
};
function render(content: string, options: RenderOptions = {}) {
act(() => root.render(<MarkdownAnswer content={content} {...options} />));
const all = container.querySelectorAll('.katex').length;
const display = container.querySelectorAll('.katex-display').length;
const clone = container.cloneNode(true) as HTMLElement;
clone
.querySelectorAll('.katex, .katex-error')
.forEach((node) => node.remove());
return {
inline: all - display,
display,
errors: container.querySelectorAll('.katex-error').length,
text: clone.textContent ?? '',
};
}
/** TeX source of every rendered formula, in order. */
function formulas() {
return Array.from(
container.querySelectorAll(
'.katex annotation[encoding="application/x-tex"]',
),
).map((node) => node.textContent);
}
/** Citation pills, the only buttons these answers render. */
function citations() {
return Array.from(container.querySelectorAll('button')).map(
(node) => node.textContent,
);
}
describe('backslash delimiters', () => {
it('renders inline \\( \\) math as inline KaTeX, not a literal $', () => {
const out = render('The velocity is \\(v = u + at\\) at time t.');
expect(out.inline).toBe(1);
expect(out.display).toBe(0);
expect(out.text).not.toContain('$');
expect(out.text).toContain('at time t.');
});
it.each([
['at the start of a line', '\\(v\\) is the final velocity.'],
['in a list item', '- speed \\(v\\) in metres'],
['in a table cell', '| q | f |\n|---|---|\n| v | \\(v=u+at\\) |'],
['wrapping a line', 'where \\(a +\nb\\) holds'],
['in a heading', '## Speed \\(v\\)'],
['inside bold', '**the speed \\(v\\)**'],
])('renders inline math %s', (_, source) => {
const out = render(source);
expect(out.inline).toBe(1);
expect(out.errors).toBe(0);
expect(out.text).not.toContain('$');
});
it('keeps a line break inside inline math as whitespace', () => {
// `\alpha` followed by a newline must not fuse into `\alphab`.
render('then \\(\\alpha\nb\\) holds');
expect(formulas()).toEqual(['\\alpha\nb']);
});
it('renders a dollar sign inside inline math without a KaTeX error', () => {
const out = render('cost \\(C = \\$5 n\\) here');
expect(out.inline).toBe(1);
expect(out.errors).toBe(0);
});
it('renders a one-line \\[ \\] on its own line as display math', () => {
const out = render(
'Displacement:\n\\[ s = ut + \\tfrac12 at^2 \\]\nwhere u is speed.',
);
expect(out.display).toBe(1);
expect(out.inline).toBe(0);
expect(out.text).toContain('Displacement:');
expect(out.text).toContain('where u is speed.');
});
it('renders a one-line \\[ \\] inside a list item as display math', () => {
const out = render(
'1. Step one:\n\n \\[ v^2 = u^2 + 2as \\]\n\n2. Step two',
);
expect(out.display).toBe(1);
expect(container.querySelectorAll('ol > li').length).toBe(2);
});
it('renders multi-line \\[ \\] as display math', () => {
expect(render('Then:\n\n\\[\ns = ut\n\\]\n\nDone.').display).toBe(1);
expect(render('\\[\na+b=c\n\\]').display).toBe(1);
});
it('does not read math lines as markdown structure', () => {
// A lone `=` under a line is a setext heading and a `- ` line a list item,
// outside math.
const out = render('\\[\nx\n=\ny\n- z\n\\]');
expect(out.display).toBe(1);
expect(container.querySelector('h1, ul')).toBeNull();
expect(formulas()).toEqual(['x\n=\ny\n- z']);
});
it('renders mid-sentence \\[ \\] as math', () => {
const out = render('So \\[ s = ut \\] holds.');
expect(out.inline + out.display).toBe(1);
expect(out.text).toContain('holds.');
});
it('keeps the rest of the answer when a line opens with \\[ \\] and goes on', () => {
// A line-start `\[` is a display block only when its `\]` ends the line;
// otherwise it must not swallow everything after it.
const out = render('\\[a+b=c\\] and more text\n\nNext paragraph.');
expect(out.inline).toBe(1);
expect(out.display).toBe(0);
expect(out.text).toContain('and more text');
expect(container.querySelectorAll('p').length).toBe(2);
});
it.each([
['\\[x=1\\tag{1}\\]', '\\tag'],
['\\[\\begin{aligned}x&=1\\end{aligned}\\]', 'aligned'],
['where \\(x = 1 \\tag{2}\\) holds', 'inline \\tag'],
])('renders %s as display math', (source) => {
const out = render(source);
expect(out.display).toBe(1);
expect(out.errors).toBe(0);
});
it('renders display math inside a blockquote', () => {
const out = render('> \\[\n> x\n> =\n> y\n> \\]');
expect(out.display).toBe(1);
expect(container.querySelector('blockquote .katex-display')).not.toBeNull();
expect(formulas()).toEqual(['x\n=\ny']);
});
it('renders display math inside a list item', () => {
const out = render('- \\[\n x\n =\n y\n \\]');
expect(out.display).toBe(1);
expect(container.querySelector('li .katex-display')).not.toBeNull();
});
it('does not close a display block across the end of a blockquote', () => {
const out = render('> \\[\n> x\n> =\n\ny\n\\]\n\nNext section\n=');
expect(out.inline + out.display + out.errors).toBe(0);
});
it('does not render an unclosed \\( as math', () => {
const out = render('Using \\(v = u');
expect(out.inline + out.display + out.errors).toBe(0);
});
it('does not render an unclosed \\[ block as math', () => {
const out = render('Then\n\n\\[\nv = u\n\nand the rest.');
expect(out.inline + out.display + out.errors).toBe(0);
expect(out.text).toContain('and the rest.');
});
it('keeps an escaped task checkbox as text', () => {
const out = render('- \\[ \\] todo\n- done');
expect(out.inline + out.display + out.errors).toBe(0);
expect(out.text).toContain('todo');
expect(container.querySelectorAll('li').length).toBe(2);
});
it('keeps escaped index brackets as text', () => {
const out = render('arr\\[0\\] and arr\\[1\\]');
expect(out.inline + out.display).toBe(0);
expect(out.text).toContain('arr[0] and arr[1]');
});
it('keeps nested \\( \\) in one span', () => {
// KaTeX cannot typeset a nested `\(`, but the span must not end at the
// inner `\)` and leak the outer one into the text.
const out = render('\\(a + \\(b + c\\)\\) end');
expect(out.inline + out.errors).toBe(1);
expect(out.text.trim()).toBe('end');
});
it('does not mistake a \\[ \\] after inline code for its own line', () => {
const out = render('Use `f` \\[x\\]\nnext');
expect(out.inline + out.display).toBe(1);
expect(out.text).not.toContain('$');
});
it('leaves \\( \\) inside code untouched', () => {
const out = render('Type `\\(x\\)` literally.');
expect(out.inline).toBe(0);
expect(container.querySelector('code')?.textContent).toBe('\\(x\\)');
});
it('leaves bash prompt escapes in a fence untouched', () => {
const out = render('```bash\nPS1="\\[\\e[0m\\]$ "\n```');
expect(out.inline + out.display).toBe(0);
expect(container.textContent).toContain('PS1="\\[\\e[0m\\]$ "');
});
});
describe('dollar delimiters', () => {
it.each([
'from $2bn to at least $4bn per operation starting Sept 9.',
'Price is $50 and $100',
'$50 is $20 + $30',
'Total: $29.50 plus tax',
'Revenue: $5M to $10M, funding: $1.5B, price: $5K',
'$250k is 25% of $1M',
'Bitcoin: $0.00001234, Gas: $3.999, Rate: $1.234567890',
'The total is $1157.90 (existing) + $500 (new investment) = $1657.90.',
'a $100-$200 range',
'a $100–$200 range',
'in the $10k-$20k band',
'- **Total Savings**: $500 + $200 + $150 = $850',
'Cela coûte 100$ et 200$ en Europe',
'A single $ sign should not be converted',
'The price hit $79,455 on',
'Currency $100 and\nthen $200 later',
'The cost is $',
'Use $variable in the code',
'| Date | Price | Note |\n|---|---|---|\n| Aug 21 | $77,300, peak $79,455 | White House Clarity Act push |',
'It costs $5 and $10 in total.',
'Plans range between $5-$10 per seat.',
'Price: $19.99/month or $199/year.',
'Revenue grew from $3.2B to $4.1B.',
'Price is $5 (was $10).',
'| Plan | Price |\n|---|---|\n| Basic | $5 |\n| Pro | $10 |',
])('leaves currency as text: %s', (source) => {
const out = render(source);
expect(out.inline + out.display + out.errors).toBe(0);
const amount = source.match(/\$\d[\d.,]*|\d+\$/);
if (amount) expect(out.text).toContain(amount[0]);
});
it.each([
['Inline math: $x^2 + y^2 = z^2$', 1],
['the answer is $3$.', 1],
['an eigenvalue of $-1$ is expected', 1],
['the $n$th term', 1],
['The set is defined as $\\{x | x > 0\\}$.', 1],
['Calculate $\\text{Total} = \\$500 + \\$200$', 1],
['The equation $12 \\times 12 = 144$ is simple', 1],
['Price $100 then equation $x + y = z$ then another price $50', 1],
['Formula $x^2$ costs $25', 1],
])('renders %s as math', (source, count) => {
const out = render(source);
expect(out.inline).toBe(count);
expect(out.errors).toBe(0);
});
it('keeps the price beside a formula', () => {
render('Cost is $5 per unit and $x$ units.');
expect(formulas()).toEqual(['x']);
expect(container.textContent).toContain('$5 per unit');
});
it('does not form emphasis out of math', () => {
const out = render('terms $a_1 + b_2$ and $c_{i}^{*}$ here');
expect(out.inline).toBe(2);
expect(container.querySelector('em, strong')).toBeNull();
});
it('renders chemistry with mhchem', () => {
const out = render('$\\ce{H2O}$ and $\\pu{123 J}$');
expect(out.inline).toBe(2);
expect(out.errors).toBe(0);
});
it.each([
[
'a code span',
'The error "invalid $lookup namespace" occurs when using `$lookup` operator',
],
['escaped dollars', 'Already escaped \\$50 and \\$100'],
['a line break', 'This has $x\ny$ which spans lines'],
['an unbalanced brace', 'weird $a}b$ y'],
[
'shell PIDs in a fence',
'```bash\npstree -p $$\necho $$\n```\n\nSome text after.',
],
])('renders no math across %s', (_, source) => {
const out = render(source);
expect(out.inline + out.display + out.errors).toBe(0);
});
it('renders math only outside a fence', () => {
const out = render('```\n$100\n$variable\n```\n\nOutside $x^2$');
expect(out.inline).toBe(1);
expect(container.querySelector('code')?.textContent).toContain('$variable');
});
it('renders a $$ paragraph as display math', () => {
const out = render('text\n\n$$E=mc^2$$\n\nmore');
expect(out.display).toBe(1);
expect(out.text).toContain('more');
});
it('keeps mid-line $$ math inline', () => {
const out = render('so $$E=mc^2$$ holds');
expect(out.inline).toBe(1);
expect(out.display).toBe(0);
});
it('renders $$ with \\begin on the fence line', () => {
const out = render(
'$$\\begin{aligned}\na&=b\\\\\nc&=d\n\\end{aligned}$$\n\nAfter.',
);
expect(out.display).toBe(1);
expect(out.errors).toBe(0);
expect(out.text).toContain('After.');
});
});
describe('citations', () => {
it('links citations next to inline math and renders the math', () => {
const out = render('Newton: \\(F=ma\\) [1] and \\(p=mv\\)[2].');
expect(out.inline).toBe(2);
expect(citations()).toEqual(['1', '2']);
});
it('never links a bracket inside math', () => {
const out = render('$$a[1]$$ and [3]');
expect(formulas()).toEqual(['a[1]']);
expect(out.errors).toBe(0);
expect(citations()).toEqual(['3']);
});
it('never links a bracket inside single-dollar math', () => {
const out = render('value $a[1]$ end [2]');
expect(citations()).toEqual(['2']);
expect(out.errors).toBe(0);
});
it.each([
['**[1]** and [2][3]', ['1', '2', '3']],
['see `row[0]` and [4]', ['4']],
['[link](https://x.com) [1]', ['1']],
['- one\n - nested [1]', ['1']],
['Per [1], run:\n```js\nconst a = arr[0];\n```\nthen [2].', ['1', '2']],
])('links %s', (source, expected) => {
render(source);
expect(citations()).toEqual(expected);
});
it('leaves code and diagram labels alone', () => {
render('```python\nprint(row[0])\n```');
expect(container.textContent).toContain('print(row[0])');
expect(citations()).toEqual([]);
});
it('keeps a reference-style link a link', () => {
render('See [the docs][1].\n\n[1]: https://example.com');
expect(
container.querySelector('a[href="https://example.com"]'),
).not.toBeNull();
expect(citations()).toEqual([]);
});
it('links only citations within the number of sources', () => {
render('Per [1], [2] and [0] and [7].', { sourceCount: 2 });
expect(citations()).toEqual(['1', '2']);
expect(container.textContent).toContain('[0] and [7]');
});
it('links no citation when the answer has no sources', () => {
render('Per [1].', { sourceCount: 0 });
expect(citations()).toEqual([]);
expect(container.textContent).toContain('[1]');
});
});
describe('citation pills', () => {
function click(label: string) {
const pill = Array.from(container.querySelectorAll('button')).find(
(node) => node.textContent === label,
);
act(() => pill?.click());
}
it('scrolls to the source card when it is shown', () => {
const card = document.createElement('div');
card.id = 'source-1';
card.scrollIntoView = vi.fn();
document.body.appendChild(card);
const onOpenSources = vi.fn();
try {
render('Per [2].', { sourceCount: 5, onOpenSources });
click('2');
expect(card.scrollIntoView).toHaveBeenCalled();
expect(onOpenSources).not.toHaveBeenCalled();
} finally {
card.remove();
}
});
it('opens the sources list for a source with no card', () => {
// Only the first three sources get a card; `[5]` has none to scroll to.
const onOpenSources = vi.fn();
render('Per [5].', { sourceCount: 5, onOpenSources });
click('5');
expect(onOpenSources).toHaveBeenCalledTimes(1);
});
});
describe('mermaid', () => {
it('renders a closed mermaid fence as a diagram, source untouched', () => {
render(
'Intro [1]\n\n```mermaid\nflowchart TD\n A[0] --> B[1]\n```\n\nOutro',
);
expect(
container.querySelector('[data-testid="mermaid"]')?.textContent,
).toBe('flowchart TD\n A[0] --> B[1]');
expect(citations()).toEqual(['1']);
expect(container.textContent).toContain('Outro');
});
it('shows an unclosed mermaid fence as code while it streams', () => {
render('```mermaid\nflowchart TD\n A --> B', { isStreaming: true });
expect(container.querySelector('[data-testid="mermaid"]')).toBeNull();
});
});
describe('streaming', () => {
it('closes an open $$ block while streaming', () => {
const out = render('$$\nx = 1\ny = 2', { isStreaming: true });
expect(out.display).toBe(1);
});
it('closes an open inline $$ while streaming', () => {
const out = render('Text with $$formula', { isStreaming: true });
expect(out.inline).toBe(1);
expect(out.text).not.toContain('$');
});
it('closes an open \\[ block while streaming', () => {
const out = render('Then:\n\n\\[\nv = u', { isStreaming: true });
expect(out.display).toBe(1);
});
it('closes open bold before an open formula, not after it', () => {
// Appended to the end, `**` turned the closing fence into `$$**`.
const out = render('Energy is **the work it takes\n$$\nE = mc', {
isStreaming: true,
});
expect(out.display).toBe(1);
expect(out.errors).toBe(0);
expect(formulas()).toEqual(['E = mc']);
});
it('holds back an inline $$ with nothing after it yet', () => {
const out = render('Then **bold** and $$', { isStreaming: true });
expect(out.inline + out.display + out.errors).toBe(0);
expect(out.text).not.toContain('$');
});
it('does not render an unclosed \\( while streaming', () => {
const out = render('Using \\(v = u', { isStreaming: true });
expect(out.inline + out.display + out.errors).toBe(0);
});
it('leaves a trailing $ alone', () => {
const out = render('The cost is $', { isStreaming: true });
expect(out.inline + out.display).toBe(0);
expect(out.text).toContain('The cost is $');
});
it('closes open bold while streaming', () => {
render('Text **bold', { isStreaming: true });
expect(container.querySelector('strong')?.textContent).toBe('bold');
});
it('does not add $$ inside an open code fence', () => {
render('```bash\necho $$', { isStreaming: true });
expect(container.querySelector('code')?.textContent).toBe('echo $$');
});
it('does not heal once the answer is complete', () => {
render('Text **bold');
expect(container.querySelector('strong')).toBeNull();
});
it('shows a formula that cannot render yet in the muted colour', () => {
const out = render('$$\n\\frac{a}{', { isStreaming: true });
expect(out.errors).toBe(1);
const error = container.querySelector<HTMLElement>('.katex-error');
expect(error?.getAttribute('style')).toContain('var(--muted-foreground)');
});
});
describe('block rendering', () => {
it('re-renders only the block that changed', () => {
render('First \\(x^2\\) [1].\n\nSecond', { isStreaming: true });
markdownRenders.mockClear();
render('First \\(x^2\\) [1].\n\nSecond paragraph grows', {
isStreaming: true,
});
// The first paragraph, formula and all, is not parsed or typeset again.
expect(markdownRenders.mock.calls).toEqual([['Second paragraph grows']]);
expect(container.textContent).toContain('Second paragraph grows');
});
it('keeps footnotes working across blocks', () => {
render('Claim[^1].\n\nMore.\n\n[^1]: The note.');
expect(
container.querySelector('a[href="#user-content-fn-1"]'),
).not.toBeNull();
});
});
+272 -297
View File
@@ -1,16 +1,17 @@
import 'katex/dist/katex.min.css';
// Registers `\ce` and `\pu` with the KaTeX that rehype-katex renders with.
import 'katex/contrib/mhchem';
import { Fragment, type ReactNode, useMemo } from 'react';
import { Fragment, memo, type ReactNode, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import ReactMarkdown from 'react-markdown';
import ReactMarkdown, { type Components } from 'react-markdown';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import {
oneLight,
vscDarkPlus,
} from 'react-syntax-highlighter/dist/cjs/styles/prism';
import rehypeKatex from 'rehype-katex';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import type { PluggableList } from 'unified';
import { markdownHeadings, markdownTables } from '@/lib/markdown';
@@ -24,6 +25,14 @@ import {
} from '../components/ui/tooltip';
import { useDarkTheme } from '../hooks';
import classes from './ConversationBubble.module.css';
import {
answerSyntaxPlugins,
healStreamingMarkdown,
normalizeMathFences,
splitAnswerBlocks,
} from './markdown/answerMarkdown';
import { remarkCitations } from './markdown/remarkCitations';
import { remarkDisplayMath } from './markdown/remarkDisplayMath';
import {
resolveSandboxLink,
type SandboxArtifact,
@@ -31,118 +40,63 @@ import {
} from './sandboxLinks';
import { cn } from '@/lib/utils';
// One fenced block or inline code span. Backtick runs are length-matched, so a
// ```` fence closes only on ```` and nested fences stay masked. The
// unterminated alternatives keep an open span matched too: answers stream in,
// so a fence is open for most of its life and needs protecting the whole time,
// not just once the closing fence arrives. A backtick fence's info string may
// not itself contain backticks, so a line that opens with an inline span stays
// an inline span. Four-space indented blocks are deliberately not masked —
// list continuation lines are indented the same way, and masking those would
// drop real citations out of nested lists.
const CODE_SPAN =
/(?<![^\n])[ \t]*(`{3,})[^`\n]*(?![^\n])(?:[\s\S]*?\n[ \t]*\1`*[ \t]*\r?(?![^\n])|[\s\S]*$)|(?<![^\n])[ \t]*(~{3,})[^\n]*(?:[\s\S]*?\n[ \t]*\2~*[ \t]*\r?(?![^\n])|[\s\S]*$)|(`+)(?:[^\n]|\n(?![ \t\r]*\n))*?(?<!`)\3(?!`)|`+[^`\n]*$/g;
// A formula KaTeX cannot typeset (a half-streamed one, most often) shows its
// source in the muted text colour rather than KaTeX's red.
const rehypePlugins: PluggableList = [
[rehypeKatex, { errorColor: 'var(--muted-foreground)' }],
];
// ``\[ \]`` and ``\( \)`` LaTeX, which remark-math does not recognise.
const LATEX_SPAN = /\\\[[\s\S]*?\\\]|\\\([\s\S]*?\\\)/g;
// Applies `transform` to everything outside the regions `mask` matches, and
// `onMasked` to those regions themselves.
function transformOutside(
content: string,
mask: RegExp,
transform: (segment: string) => string,
onMasked: (segment: string) => string = (segment) => segment,
): string {
const pattern = new RegExp(mask.source, mask.flags); // its own lastIndex
let result = '';
let index = 0;
let match: RegExpExecArray | null;
while ((match = pattern.exec(content)) !== null) {
result += transform(content.slice(index, match.index)) + onMasked(match[0]);
index = pattern.lastIndex;
}
return result + transform(content.slice(index));
}
// Runs `transform` over the prose in `content`, leaving code untouched.
//
// The rewrites below are plain regex over the whole document, and both used to
// corrupt the user's own code: `print(row[0])` rendered as
// `print(row[0](#cite-0))`, and `PS1="\[\e[0m\]"` as `PS1="$$\e[0m$$"`. Neither
// can move into a remark plugin: remark-math tokenises `$`/`$$` while parsing,
// so the LaTeX pass has to run on the raw string before remark ever sees it.
export function applyOutsideCode(
content: string,
transform: (segment: string) => string,
): string {
return transformOutside(content, CODE_SPAN, transform);
}
// Rewrites one LaTeX span into the ``$$``/``$`` form remark-math parses.
function toDollarMath(span: string): string {
const equation = span.slice(2, -2);
return span.startsWith('\\[') ? `$$${equation}$$` : `$${equation}$`;
}
// Replaces block-level ``\[ \]`` and inline ``\( \)`` LaTeX delimiters with the
// ``$$``/``$`` forms remark-math understands.
export function preprocessLaTeX(content: string): string {
return applyOutsideCode(content, (prose) =>
prose.replace(LATEX_SPAN, toDollarMath),
// One top-level block of the answer. Memoised, so while an answer streams only
// the block that is still growing is parsed and typeset again.
const MarkdownBlock = memo(function MarkdownBlock({
content,
remarkPlugins,
components,
}: {
content: string;
remarkPlugins: PluggableList;
components: Components;
}) {
return (
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
urlTransform={sandboxUrlTransform}
components={components}
>
{content}
</ReactMarkdown>
);
}
});
// Turns citation references ``[N]`` into ``[N](#cite-N)`` links so
// ReactMarkdown renders them as <a> tags we can style. The lookarounds skip
// references that are already links.
function linkCitations(prose: string): string {
return prose.replace(
/(?<!\[)\[(\d+)\](?!\()/g,
(_, num) => `[${num}](#cite-${num})`,
// Artifact lists are rebuilt on every render of the bubble; keep one identity
// per content so the memoised blocks are not re-rendered for nothing.
function useStableArtifacts(list?: SandboxArtifact[]) {
const signature = JSON.stringify(
list?.map(({ id, label, toolName, ref }) => [id, label, toolName, ref]),
);
return useMemo(() => list, [signature]);
}
type ContentSegment = { type: 'text' | 'mermaid'; content: string };
export function processMarkdownContent(content: string): ContentSegment[] {
// Citations are linked outside code — an index subscript like `row[0]` is not
// a citation — and outside the math this same pass produces. Math the answer
// already wrote as ``$…$`` stays unmasked, since ``$`` is also a currency
// sign. This has to run before the ```mermaid split below, so diagram source
// needs the same guard.
const processedContent = applyOutsideCode(content, (prose) =>
transformOutside(prose, LATEX_SPAN, linkCitations, toDollarMath),
);
const contentSegments: ContentSegment[] = [];
let lastIndex = 0;
const regex = /```mermaid\n([\s\S]*?)```/g;
let match;
while ((match = regex.exec(processedContent)) !== null) {
const textBefore = processedContent.substring(lastIndex, match.index);
if (textBefore) contentSegments.push({ type: 'text', content: textBefore });
contentSegments.push({ type: 'mermaid', content: match[1].trim() });
lastIndex = match.index + match[0].length;
}
const textAfter = processedContent.substring(lastIndex);
if (textAfter) contentSegments.push({ type: 'text', content: textAfter });
return contentSegments;
}
type AnswerGroup =
{ type: 'markdown'; blocks: string[] } | { type: 'mermaid'; content: string };
export default function MarkdownAnswer({
content,
isStreaming,
artifacts,
turnArtifacts,
sourceCount,
artifacts: artifactsProp,
turnArtifacts: turnArtifactsProp,
onOpenArtifact,
onOpenSources,
}: {
content: string;
isStreaming?: boolean;
/**
* How many sources the answer cites from; ``[N]`` beyond it is left as
* text, with no source to jump to. Unset links every ``[N]``.
*/
sourceCount?: number;
/**
* Every artifact in the conversation, in creation order (``A1`` is the
* first) — refs are conversation-scoped, so a link may name an earlier turn's
@@ -152,214 +106,235 @@ export default function MarkdownAnswer({
/** This turn's own artifacts; the filename fallback prefers them. */
turnArtifacts?: SandboxArtifact[];
onOpenArtifact?: (artifact: { id: string; toolName: string }) => void;
/** Opens the full sources list, for a citation whose card is not shown. */
onOpenSources?: () => void;
}) {
const { t } = useTranslation();
const [isDarkTheme] = useDarkTheme();
// Re-runs on every streamed token otherwise.
const contentSegments = useMemo(
() => processMarkdownContent(content),
[content],
const artifacts = useStableArtifacts(artifactsProp);
const turnArtifacts = useStableArtifacts(turnArtifactsProp);
const groups = useMemo(() => {
const normalized = normalizeMathFences(content);
const blocks = splitAnswerBlocks(
isStreaming ? healStreamingMarkdown(normalized) : normalized,
);
// Consecutive markdown blocks share one column; a diagram breaks it.
const grouped: AnswerGroup[] = [];
for (const block of blocks) {
const last = grouped[grouped.length - 1];
if (block.type === 'mermaid') grouped.push(block);
else if (last?.type === 'markdown') last.blocks.push(block.content);
else grouped.push({ type: 'markdown', blocks: [block.content] });
}
return grouped;
}, [content, isStreaming]);
const remarkPlugins = useMemo<PluggableList>(
() => [
...answerSyntaxPlugins,
remarkDisplayMath,
[remarkCitations, { sourceCount }],
],
[sourceCount],
);
// Shared by the `a` and `img` renderers: a generated file is already on the
// turn as a download chip, so both point at the chip rather than at a URL no
// browser can open.
const renderArtifactChip = (
artifact: SandboxArtifact,
content: ReactNode,
) => {
if (!onOpenArtifact) return <>{content}</>;
return (
<Button
type="button"
variant="link"
size="inline"
onClick={() =>
onOpenArtifact({
id: artifact.id,
toolName: artifact.toolName ?? '',
})
const components = useMemo<Components>(() => {
// Shared by the `a` and `img` renderers: a generated file is already on the
// turn as a download chip, so both point at the chip rather than at a URL no
// browser can open.
const renderArtifactChip = (
artifact: SandboxArtifact,
content: ReactNode,
) => {
if (!onOpenArtifact) return <>{content}</>;
return (
<Button
type="button"
variant="link"
size="inline"
onClick={() =>
onOpenArtifact({
id: artifact.id,
toolName: artifact.toolName ?? '',
})
}
/* Sits mid-sentence, so it must wrap with the surrounding text. */
className="whitespace-normal"
>
{content}
</Button>
);
};
return {
...markdownHeadings,
...markdownTables,
a({ href, children }) {
// A generated file is already on the turn as a download
// chip, but the model links it with a `sandbox:`/`artifact:`
// URL no browser can open. Point the link at the chip
// instead, and never leave a dead anchor behind.
const sandboxLink = resolveSandboxLink(href, artifacts, turnArtifacts);
if (sandboxLink.kind === 'plain') {
return <>{children}</>;
}
/* Sits mid-sentence, so it must wrap with the surrounding text. */
className="whitespace-normal"
>
{content}
</Button>
);
};
if (sandboxLink.kind === 'artifact') {
return renderArtifactChip(sandboxLink.artifact, children);
}
if (href?.startsWith('#cite-')) {
const num = href.replace('#cite-', '');
const sourceIdx = parseInt(num, 10) - 1;
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="secondary"
size="xs"
shape="pill"
onClick={() => {
const el = document.getElementById(`source-${sourceIdx}`);
if (el) {
el.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
el.classList.add('ring-3', 'ring-primary');
setTimeout(
() => el.classList.remove('ring-3', 'ring-primary'),
2000,
);
} else {
// Only the first few sources get a card to scroll to.
onOpenSources?.();
}
}}
className="mx-0.5 h-5 min-w-5"
>
{num}
</Button>
</TooltipTrigger>
<TooltipContent>
{t('conversation.jumpToSource', { num })}
</TooltipContent>
</Tooltip>
);
}
return (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
);
},
img({ src, alt }) {
// `![chart](sandbox:/mnt/data/chart.png)` is how a model
// announces a plot it produced. react-markdown runs
// urlTransform over `src` too, so without this the invented
// scheme survives and renders a broken-image box beside the
// chip that opens the very same file.
const source = typeof src === 'string' ? src : undefined;
const sandboxLink = resolveSandboxLink(
source,
artifacts,
turnArtifacts,
);
if (sandboxLink.kind === 'artifact') {
const { artifact } = sandboxLink;
return renderArtifactChip(
artifact,
alt || artifact.label || t('conversation.openFile'),
);
}
if (sandboxLink.kind === 'plain') {
return <>{alt ?? ''}</>;
}
return <img src={source} alt={alt} className="max-w-full" />;
},
code(props) {
const { children, className, node, ref, ...rest } = props;
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : '';
return match ? (
<div className="group border-border relative overflow-hidden rounded-xl border">
<div className="bg-muted flex items-center justify-between px-2 py-1">
<span className="text-foreground text-xs font-medium">
{language}
</span>
<CopyButton
textToCopy={String(children).replace(/\n$/, '')}
side="bottom"
/>
</div>
<SyntaxHighlighter
{...rest}
PreTag="div"
language={language}
/* eslint-disable-next-line shadcn/no-inline-styles -- SyntaxHighlighter's style prop is its Prism theme object (oneLight / vscDarkPlus), picked by theme at runtime; it is not CSS. See DESIGN.md, Approved exceptions. */
style={isDarkTheme ? vscDarkPlus : oneLight}
className="mt-0!"
customStyle={{ margin: 0, borderRadius: 0 }}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
</div>
) : (
<code className="bg-accent text-foreground rounded-md px-2 py-1 text-xs font-normal whitespace-pre-line">
{children}
</code>
);
},
ul({ children }) {
return (
<ul
className={cn(
'list-inside list-disc pl-4 whitespace-normal',
classes.list,
)}
>
{children}
</ul>
);
},
ol({ children }) {
return (
<ol
className={cn(
'list-inside list-decimal pl-4 whitespace-normal',
classes.list,
)}
>
{children}
</ol>
);
},
};
}, [t, isDarkTheme, artifacts, turnArtifacts, onOpenArtifact, onOpenSources]);
return (
<>
{contentSegments.map((segment, index) => (
{groups.map((group, index) => (
<Fragment key={index}>
{segment.type === 'text' ? (
<div className="animate-in fade-in flex flex-col gap-3 leading-normal wrap-break-word whitespace-pre-wrap duration-160 ease-out motion-reduce:animate-none">
<ReactMarkdown
remarkPlugins={[
remarkGfm,
[remarkMath, { singleDollarTextMath: false }],
]}
rehypePlugins={[rehypeKatex]}
urlTransform={sandboxUrlTransform}
components={{
...markdownHeadings,
...markdownTables,
a({ href, children }) {
// A generated file is already on the turn as a download
// chip, but the model links it with a `sandbox:`/`artifact:`
// URL no browser can open. Point the link at the chip
// instead, and never leave a dead anchor behind.
const sandboxLink = resolveSandboxLink(
href,
artifacts,
turnArtifacts,
);
if (sandboxLink.kind === 'plain') {
return <>{children}</>;
}
if (sandboxLink.kind === 'artifact') {
return renderArtifactChip(sandboxLink.artifact, children);
}
if (href?.startsWith('#cite-')) {
const num = href.replace('#cite-', '');
const sourceIdx = parseInt(num, 10) - 1;
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="secondary"
size="xs"
shape="pill"
onClick={() => {
const el = document.getElementById(
`source-${sourceIdx}`,
);
if (el) {
el.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
el.classList.add('ring-3', 'ring-primary');
setTimeout(
() =>
el.classList.remove(
'ring-3',
'ring-primary',
),
2000,
);
}
}}
className="mx-0.5 h-5 min-w-5"
>
{num}
</Button>
</TooltipTrigger>
<TooltipContent>
{t('conversation.jumpToSource', { num })}
</TooltipContent>
</Tooltip>
);
}
return (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
);
},
img({ src, alt }) {
// `![chart](sandbox:/mnt/data/chart.png)` is how a model
// announces a plot it produced. react-markdown runs
// urlTransform over `src` too, so without this the invented
// scheme survives and renders a broken-image box beside the
// chip that opens the very same file.
const source = typeof src === 'string' ? src : undefined;
const sandboxLink = resolveSandboxLink(
source,
artifacts,
turnArtifacts,
);
if (sandboxLink.kind === 'artifact') {
const { artifact } = sandboxLink;
return renderArtifactChip(
artifact,
alt || artifact.label || t('conversation.openFile'),
);
}
if (sandboxLink.kind === 'plain') {
return <>{alt ?? ''}</>;
}
return (
<img src={source} alt={alt} className="max-w-full" />
);
},
code(props) {
const { children, className, node, ref, ...rest } = props;
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : '';
return match ? (
<div className="group border-border relative overflow-hidden rounded-xl border">
<div className="bg-muted flex items-center justify-between px-2 py-1">
<span className="text-foreground text-xs font-medium">
{language}
</span>
<CopyButton
textToCopy={String(children).replace(/\n$/, '')}
side="bottom"
/>
</div>
<SyntaxHighlighter
{...rest}
PreTag="div"
language={language}
/* eslint-disable-next-line shadcn/no-inline-styles -- SyntaxHighlighter's style prop is its Prism theme object (oneLight / vscDarkPlus), picked by theme at runtime; it is not CSS. See DESIGN.md, Approved exceptions. */
style={isDarkTheme ? vscDarkPlus : oneLight}
className="mt-0!"
customStyle={{ margin: 0, borderRadius: 0 }}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
</div>
) : (
<code className="bg-accent text-foreground rounded-md px-2 py-1 text-xs font-normal whitespace-pre-line">
{children}
</code>
);
},
ul({ children }) {
return (
<ul
className={cn(
'list-inside list-disc pl-4 whitespace-normal',
classes.list,
)}
>
{children}
</ul>
);
},
ol({ children }) {
return (
<ol
className={cn(
'list-inside list-decimal pl-4 whitespace-normal',
classes.list,
)}
>
{children}
</ol>
);
},
}}
>
{segment.content}
</ReactMarkdown>
{group.type === 'markdown' ? (
<div
className={cn(
'animate-in fade-in flex flex-col gap-3 leading-normal wrap-break-word whitespace-pre-wrap duration-160 ease-out motion-reduce:animate-none',
classes.answer,
)}
>
{group.blocks.map((block, blockIndex) => (
<MarkdownBlock
key={blockIndex}
content={block}
remarkPlugins={remarkPlugins}
components={components}
/>
))}
</div>
) : (
<div className="my-4 w-full min-w-full">
<MermaidRenderer code={segment.content} isLoading={isStreaming} />
<MermaidRenderer code={group.content} isLoading={isStreaming} />
</div>
)}
</Fragment>
@@ -274,6 +274,24 @@ describe('fetchAnswer — attachment ids on the wire', () => {
'c-2',
]);
});
it('leaves a failed composer upload out of the fallback ids', async () => {
const store = makeStore();
store.dispatch(addQuery({ prompt: 'q' }));
store.dispatch(addAttachment(completedAtt));
store.dispatch(
addAttachment({
id: 'f-3',
fileName: 'broken.pdf',
progress: 0,
status: 'failed',
taskId: '',
}),
);
await store.dispatch(fetchAnswer({ question: 'q', indx: 0 }));
expect(vi.mocked(handleFetchAnswer).mock.calls[0][8]).toEqual(['srv-1']);
});
});
describe('fetchAnswer.rejected', () => {
@@ -0,0 +1,241 @@
/**
* The string-level steps of rendering an answer, around the markdown parser.
*
* Math, code and citations are told apart by the parser (see `texMath.ts`,
* `singleDollarMath.ts` and the remark plugins beside them). What is left
* here works on the raw string only where the parser cannot help:
*
* - `normalizeMathFences` moves formula text off a ``$$`` fence line, which
* remark-math would read as the fence's metadata;
* - `healStreamingMarkdown` closes what a half-streamed answer has left open;
* - `splitAnswerBlocks` cuts the answer into its top-level blocks, so a
* streaming answer re-renders only the block that is still growing.
*/
import type { Root, RootContent } from 'mdast';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import remarkParse from 'remark-parse';
import remend from 'remend';
import { type PluggableList, unified } from 'unified';
import { remarkSingleDollarMath } from './singleDollarMath';
import { remarkTexMath } from './texMath';
/** Syntax plugins; every parse of an answer, split or render, uses them. */
export const answerSyntaxPlugins: PluggableList = [
remarkGfm,
[remarkMath, { singleDollarTextMath: false }],
remarkTexMath,
remarkSingleDollarMath,
];
// A line's container prefix (blockquote markers, indentation) and the rest.
const LINE = /^((?:[ \t]*>)*[ \t]*)([\s\S]*)$/;
const FENCE_OPEN = /^(`{3,}|~{3,})/;
const DOLLAR_FENCE = /^\$\$\s*$/;
function splitLine(line: string): [prefix: string, rest: string] {
const [, prefix, rest] = LINE.exec(line) ?? ['', '', line];
return [prefix, rest];
}
function closesFence(rest: string, fence: string): boolean {
const run = rest.trim();
return (
run.length >= fence.length && run[0] === fence[0] && /^(?:`+|~+)$/.test(run)
);
}
/**
* Puts a ``$$`` fence's formula on its own line: ``$$\begin{aligned}`` opens a
* block whose first line remark-math reads as metadata and drops, and a
* closing ``\end{aligned}$$`` never closes it, so the block ran to the end of
* the answer. Code fences are left alone.
*/
export function normalizeMathFences(content: string): string {
if (!content.includes('$$')) return content;
const out: string[] = [];
let fence: string | null = null;
let inMath = false;
for (const line of content.split('\n')) {
const [prefix, rest] = splitLine(line);
if (fence) {
if (closesFence(rest, fence)) fence = null;
out.push(line);
continue;
}
if (inMath) {
const closing = /^(.*\S)[ \t]*\$\$\s*$/.exec(rest);
if (DOLLAR_FENCE.test(rest)) {
inMath = false;
} else if (closing) {
out.push(prefix + closing[1], `${prefix}$$`);
inMath = false;
continue;
}
out.push(line);
continue;
}
const codeFence = FENCE_OPEN.exec(rest);
const opening = /^\$\$(?!\$)(.*\S)\s*$/.exec(rest);
if (codeFence) {
fence = codeFence[1];
} else if (DOLLAR_FENCE.test(rest)) {
inMath = true;
} else if (opening && !opening[1].includes('$$')) {
out.push(`${prefix}$$`, prefix + opening[1].trimStart());
inMath = true;
continue;
}
out.push(line);
}
return out.join('\n');
}
// Inline code in a stretch of prose, open spans included, so a `$$` in it is
// not counted as math.
const INLINE_CODE = /(`+)[\s\S]*?(?:\1|$)/g;
// Splits a half-streamed answer at the math it is in the middle of writing:
// `head` is everything before that formula, `math` the formula closed. A ``$$``
// or line-start ``\[`` block gets its closing fence, an inline ``$$`` its
// closing ``$$``, and one with nothing after it yet is held back. Inside a code
// fence nothing is touched: a shell's `$$` is not math.
function splitOpenMath(content: string): { head: string; math: string } {
let fence: string | null = null;
let block: { close: string; prefix: string; start: number } | null = null;
let offset = 0;
for (const line of content.split('\n')) {
const [prefix, rest] = splitLine(line);
if (fence) {
if (closesFence(rest, fence)) fence = null;
} else if (block) {
const closed =
block.close === '$$' ? DOLLAR_FENCE.test(rest) : /\\\]\s*$/.test(rest);
if (closed) block = null;
} else if (FENCE_OPEN.test(rest)) {
fence = FENCE_OPEN.exec(rest)![1];
} else if (DOLLAR_FENCE.test(rest)) {
block = { close: '$$', prefix, start: offset };
} else if (rest.startsWith('\\[') && !rest.includes('\\]')) {
block = { close: '\\]', prefix, start: offset };
}
offset += line.length + 1;
}
if (fence) return { head: content, math: '' };
if (block) {
return {
head: content.slice(0, block.start),
math: `${content.slice(block.start)}\n${block.prefix}${block.close}`,
};
}
const paragraphStart = content.lastIndexOf('\n\n') + 1;
const prose = content
.slice(paragraphStart)
.replace(INLINE_CODE, '')
.replace(/\\\$/g, '');
const dollars = prose.match(/\$\$/g)?.length ?? 0;
if (dollars % 2 === 0) return { head: content, math: '' };
const opener = content.lastIndexOf('$$');
const formula = content.slice(opener);
return {
head: content.slice(0, opener),
math: formula.slice(2).trim() ? `${formula}$$` : '',
};
}
const REMEND_OPTIONS = {
// Math is closed above, code-aware; remend's own pass appends `$$` inside
// an open code fence.
katex: false,
inlineKatex: false,
// A half-typed `[1` or `\[` is not a link, and remend would strip its `[`.
links: false,
images: false,
// These two rewrite text that is already complete, so the answer would
// change once it stops streaming.
comparisonOperators: false,
singleTilde: false,
};
/**
* Closes what a half-streamed answer leaves open (``**bold``, `` `code``,
* ``$$`` and ``\[`` math), so it renders as it will once complete instead of
* flashing raw markdown. Only ever applied while the answer streams.
*/
export function healStreamingMarkdown(content: string): string {
// Emphasis is closed before the open formula, never after it: appended to
// the closing fence (``$$**``), it would stop the fence from closing.
const { head, math } = splitOpenMath(content);
if (!math) return remend(head, REMEND_OPTIONS);
const lineBreak = head.match(/\n*$/)![0];
return (
remend(head.slice(0, head.length - lineBreak.length), REMEND_OPTIONS) +
lineBreak +
math
);
}
export type AnswerBlock =
{ type: 'markdown'; content: string } | { type: 'mermaid'; content: string };
const parser = unified().use(remarkParse).use(answerSyntaxPlugins);
// Where a block's first line starts, so an indented block keeps its indent.
function lineStart(content: string, offset: number): number {
const start = content.lastIndexOf('\n', offset - 1) + 1;
return /^[ \t]*$/.test(content.slice(start, offset)) ? start : offset;
}
// A mermaid fence is drawn once it is closed; until then it streams as code.
function isDiagram(node: RootContent, source: string): boolean {
if (node.type !== 'code' || node.lang !== 'mermaid') return false;
const lines = source.trimEnd().split('\n');
const opening = FENCE_OPEN.exec(lines[0].trim());
return (
lines.length > 1 &&
opening !== null &&
closesFence(lines[lines.length - 1], opening[1])
);
}
/**
* Cuts an answer into its top-level blocks, each rendered on its own, and its
* closed mermaid fences, drawn as diagrams. An answer with link or footnote
* definitions stays whole between diagrams, since a reference resolves only
* against definitions in the same parse.
*/
export function splitAnswerBlocks(content: string): AnswerBlock[] {
const tree: Root = parser.parse(content);
const keepWhole = tree.children.some(
(node) => node.type === 'definition' || node.type === 'footnoteDefinition',
);
const blocks: AnswerBlock[] = [];
let groupStart: number | null = null;
for (const node of tree.children) {
const start = lineStart(content, node.position?.start.offset ?? 0);
const end = node.position?.end.offset ?? content.length;
if (node.type === 'code' && isDiagram(node, content.slice(start, end))) {
if (groupStart !== null) {
blocks.push({
type: 'markdown',
content: content.slice(groupStart, start),
});
groupStart = null;
}
blocks.push({ type: 'mermaid', content: node.value.trim() });
} else if (keepWhole) {
groupStart ??= start;
} else {
blocks.push({ type: 'markdown', content: content.slice(start, end) });
}
}
if (groupStart !== null) {
blocks.push({ type: 'markdown', content: content.slice(groupStart) });
}
return blocks;
}
@@ -0,0 +1,25 @@
import type { Extension } from 'micromark-util-types';
import type { Processor } from 'unified';
// The tokens `remark-math`'s `mathFromMarkdown` turns into math nodes, which
// the constructs here emit too.
declare module 'micromark-util-types' {
interface TokenTypeMap {
mathFlow: 'mathFlow';
mathFlowFence: 'mathFlowFence';
mathFlowFenceSequence: 'mathFlowFenceSequence';
mathFlowValue: 'mathFlowValue';
mathText: 'mathText';
mathTextData: 'mathTextData';
mathTextSequence: 'mathTextSequence';
}
}
/** Registers a micromark syntax extension from inside a remark plugin. */
export function addMicromarkExtension(
processor: Processor,
extension: Extension,
): void {
const data = processor.data() as { micromarkExtensions?: Extension[] };
(data.micromarkExtensions ??= []).push(extension);
}
@@ -0,0 +1,100 @@
/**
* Links source citations, ``[N]``, in the prose of an answer.
*
* Runs on the syntax tree, so only plain text is ever linked: code, inline
* code and math are their own nodes, never `text`, and link text is skipped.
* An index like `row[0]` in code, or `a[1]` in a formula, stays what it is.
* A bare ``[1]`` with no matching definition is text to the parser; a
* reference link ``[docs][1]`` with a ``[1]: url`` definition stays a link,
* and brackets the answer escaped, ``arr\[1\]``, stay brackets.
*
* The link targets ``#cite-N``, which `MarkdownAnswer` renders as the "jump to
* source" pill.
*/
import type { PhrasingContent, Root, Text } from 'mdast';
import { asciiPunctuation } from 'micromark-util-character';
import { SKIP, visit } from 'unist-util-visit';
export type CitationOptions = {
/**
* How many sources the answer has; only ``[1]`` to ``[sourceCount]`` are
* linked, since any other number has no source to jump to. Unset links
* every ``[N]``.
*/
sourceCount?: number;
};
const CITATION = /\[(\d+)\]/g;
// The offsets in `node.value` of characters the source wrote as a backslash
// escape. The parser resolves escapes, so ``\[1\]`` and ``[1]`` are the same
// text; only the source tells them apart. Anything else that makes the value
// differ from the source (an entity, a quote marker) ends the walk, and the
// rest of the node counts as unescaped.
function escapedOffsets(node: Text, source: string): Set<number> {
const escaped = new Set<number>();
const start = node.position?.start.offset;
const end = node.position?.end.offset;
if (start === undefined || end === undefined) return escaped;
const raw = source.slice(start, end);
if (!raw.includes('\\')) return escaped;
const { value } = node;
let i = 0;
for (let j = 0; j < value.length && i < raw.length; j++) {
// Indentation the parser dropped from a continuation line.
while (raw[i] !== value[j] && (raw[i] === ' ' || raw[i] === '\t')) i++;
if (
raw[i] === '\\' &&
raw[i + 1] === value[j] &&
asciiPunctuation(value.charCodeAt(j))
) {
escaped.add(j);
i += 2;
} else if (raw[i] === value[j]) {
i++;
} else {
break;
}
}
return escaped;
}
export function remarkCitations({ sourceCount }: CitationOptions = {}) {
const citable = (n: number) =>
sourceCount === undefined || (n >= 1 && n <= sourceCount);
return (tree: Root, file: { value?: unknown }) => {
const source = String(file.value ?? '');
visit(tree, (node, index, parent) => {
if (node.type === 'link' || node.type === 'linkReference') return SKIP;
if (node.type !== 'text' || !parent || index === undefined) return;
const escaped = escapedOffsets(node, source);
const parts: PhrasingContent[] = [];
let last = 0;
for (const match of node.value.matchAll(CITATION)) {
if (!citable(Number(match[1])) || escaped.has(match.index)) continue;
if (match.index > last) {
parts.push({
type: 'text',
value: node.value.slice(last, match.index),
});
}
parts.push({
type: 'link',
url: `#cite-${match[1]}`,
children: [{ type: 'text', value: match[1] }],
});
last = match.index + match[0].length;
}
if (parts.length === 0) return;
if (last < node.value.length) {
parts.push({ type: 'text', value: node.value.slice(last) });
}
parent.children.splice(index, 1, ...parts);
return [SKIP, index + parts.length];
});
};
}
@@ -0,0 +1,134 @@
/**
* Decides display versus inline math on the syntax tree, where the parser has
* already told math, code and prose apart.
*
* An inline formula becomes display math when
* - it is written with a block delimiter (``$$`` or ``\[``) and sits on a line
* of its own: ``$$E=mc^2$$`` alone in a paragraph, or a ``\[ … \]`` line the
* flow construct did not take (a lazy line inside a quote, say); or
* - KaTeX can only typeset it in display mode (``\tag``, or an environment such
* as ``align``); inline, it would be a red error instead of an equation.
*
* A promoted formula splits its paragraph: the text before and after stays a
* paragraph of its own. Where no paragraph can be split (a table cell, a
* heading) a display-only formula is still typeset in display mode, in place.
*/
import type { Paragraph, PhrasingContent, Root, RootContent } from 'mdast';
import { SKIP, visit } from 'unist-util-visit';
type InlineMath = Extract<PhrasingContent, { type: 'inlineMath' }>;
// `\tag` and the environments KaTeX refuses outside display mode.
const DISPLAY_ONLY =
/\\tag\b|\\begin\{(?:equation|align|alignat|gather|split|CD)\*?\}/;
const displayClasses = ['language-math', 'math-display'];
function isInlineMath(node: PhrasingContent): node is InlineMath {
return node.type === 'inlineMath';
}
function blockDelimited(node: InlineMath, source: string): boolean {
const start = node.position?.start.offset;
if (start === undefined) return false;
const opener = source.slice(start, start + 2);
return opener === '$$' || opener === '\\[';
}
// Whether the siblings either side of `index` end and start a line.
function aloneOnLine(children: PhrasingContent[], index: number): boolean {
const before = children[index - 1];
const after = children[index + 1];
const endsLine =
!before ||
before.type === 'break' ||
(before.type === 'text' && /(?:^|\n)[ \t]*$/.test(before.value));
const startsLine =
!after ||
after.type === 'break' ||
(after.type === 'text' && /^[ \t]*(?:\n|$)/.test(after.value));
return endsLine && startsLine;
}
function toDisplay(node: InlineMath): RootContent {
return {
type: 'math',
meta: null,
value: node.value,
position: node.position,
data: {
hName: 'pre',
hChildren: [
{
type: 'element',
tagName: 'code',
properties: { className: displayClasses },
children: [{ type: 'text', value: node.value }],
},
],
},
};
}
// The paragraph around each promoted formula, cut at it, with the line breaks
// that separated them dropped.
function splitParagraph(
paragraph: Paragraph,
promote: (child: PhrasingContent, index: number) => boolean,
): RootContent[] {
const blocks: RootContent[] = [];
let run: PhrasingContent[] = [];
let trimNext = false;
const flush = () => {
const last = run[run.length - 1];
if (last?.type === 'break') run.pop();
else if (last?.type === 'text') last.value = last.value.trimEnd();
run = run.filter((child) => child.type !== 'text' || child.value !== '');
if (run.length > 0) blocks.push({ type: 'paragraph', children: run });
run = [];
};
paragraph.children.forEach((child, index) => {
if (isInlineMath(child) && promote(child, index)) {
flush();
blocks.push(toDisplay(child));
trimNext = true;
return;
}
if (trimNext) {
trimNext = false;
if (child.type === 'break') return;
if (child.type === 'text') child.value = child.value.trimStart();
}
run.push(child);
});
flush();
return blocks;
}
export function remarkDisplayMath() {
return (tree: Root, file: { value?: unknown }) => {
const source = String(file.value);
visit(tree, 'paragraph', (node, index, parent) => {
if (!parent || index === undefined) return;
const promote = (child: PhrasingContent, at: number) =>
isInlineMath(child) &&
(DISPLAY_ONLY.test(child.value) ||
(blockDelimited(child, source) && aloneOnLine(node.children, at)));
if (!node.children.some(promote)) return;
const blocks = splitParagraph(node, promote);
// A paragraph's parent (the root, a list item, a quote) holds blocks.
(parent.children as RootContent[]).splice(index, 1, ...blocks);
return [SKIP, index + blocks.length];
});
// Nowhere to split (a table cell, a heading, inside bold): typeset a
// display-only formula in display mode where it stands.
visit(tree, 'inlineMath', (node) => {
if (!DISPLAY_ONLY.test(node.value)) return;
node.data = { ...node.data, hProperties: { className: displayClasses } };
});
};
}
@@ -0,0 +1,160 @@
/**
* Single-dollar inline math, ``$x$``, that leaves prices alone.
*
* Ported from LibreChat's `client/src/utils/latex.ts`
* (https://github.com/danny-avila/LibreChat), MIT License,
* Copyright (c) 2026 LibreChat.
*
* `$...$` is ambiguous with prices ("from $2bn to at least $4bn"), and only
* the tokenizer can decide a span without rewriting the message: code spans,
* fences and autolinks are structurally excluded, and a rejected span stays
* byte-identical text. `remark-math` keeps running with
* `singleDollarTextMath: false`; this construct is the only single-dollar path.
*
* A `$...$` span becomes math only when all of Pandoc's boundary rules hold:
* - the opening `$` is immediately followed by a non-space character;
* - the closing `$` is immediately preceded by a non-space character and not
* immediately followed by a digit (rejects ranges like "$100-$200");
* - the span stays on one line, contains no backtick, treats `\`-pairs as
* opaque (`\$` stays inside the span), and closes with balanced braces.
*
* A failed close abandons the whole attempt instead of scanning further, so
* the next `$` in "Price is $50 and $100" can never silently extend a span;
* micromark then retries the construct at that `$` on its own merits. Pandoc
* has the same known false positive: `$HOME/$USER` in prose (not code) is math.
*/
import {
asciiDigit,
markdownLineEnding,
markdownSpace,
} from 'micromark-util-character';
import { codes, types } from 'micromark-util-symbol';
import type {
Code,
Construct,
Effects,
Extension,
State,
TokenizeContext,
} from 'micromark-util-types';
import type { Processor } from 'unified';
import { addMicromarkExtension } from './micromarkExtension';
function tokenizeMathSpan(
this: TokenizeContext,
effects: Effects,
ok: State,
nok: State,
): State {
let previousCode: Code = null;
let braceDepth = 0;
return start;
function start(code: Code): State | undefined {
effects.enter('mathText');
effects.enter('mathTextSequence');
effects.consume(code);
effects.exit('mathTextSequence');
return open;
}
function open(code: Code): State | undefined {
if (
code === codes.eof ||
code === codes.dollarSign ||
code === codes.graveAccent ||
markdownSpace(code) ||
markdownLineEnding(code)
) {
return nok(code);
}
effects.enter('mathTextData');
return content(code);
}
function content(code: Code): State | undefined {
if (
code === codes.eof ||
code === codes.graveAccent ||
markdownLineEnding(code)
) {
return nok(code);
}
if (code === codes.dollarSign) {
if (markdownSpace(previousCode) || braceDepth !== 0) {
return nok(code);
}
effects.exit('mathTextData');
effects.enter('mathTextSequence');
effects.consume(code);
return close;
}
if (code === codes.backslash) {
effects.consume(code);
return escape;
}
if (code === codes.leftCurlyBrace) {
braceDepth++;
}
if (code === codes.rightCurlyBrace) {
if (braceDepth === 0) {
return nok(code);
}
braceDepth--;
}
previousCode = code;
effects.consume(code);
return content;
}
function escape(code: Code): State | undefined {
if (code === codes.eof || markdownLineEnding(code)) {
return nok(code);
}
previousCode = code;
effects.consume(code);
return content;
}
function close(code: Code): State | undefined {
if (code === codes.dollarSign || asciiDigit(code)) {
return nok(code);
}
effects.exit('mathTextSequence');
effects.exit('mathText');
return ok(code);
}
}
/** Mirrors `micromark-extension-math`: a `$` opener is valid unless it follows an unescaped `$`. */
function previous(this: TokenizeContext, code: Code): boolean {
return (
code !== codes.dollarSign ||
this.events[this.events.length - 1][1].type === types.characterEscape
);
}
const mathSpan: Construct = {
name: 'mathSpanSingleDollar',
tokenize: tokenizeMathSpan,
previous,
};
/**
* micromark syntax extension adding currency-safe single-dollar inline math.
* It emits the same `mathText`/`mathTextData` tokens as
* `micromark-extension-math`, so `remark-math`'s `mathFromMarkdown` handlers
* turn its spans into regular `inlineMath` nodes. Registration order relative
* to `remark-math` is immaterial: this construct rejects `$$` openers and
* `remark-math` (with `singleDollarTextMath: false`) rejects single `$` openers.
*/
export const singleDollarMath: Extension = {
text: { [codes.dollarSign]: mathSpan },
};
/** remark plugin for {@link singleDollarMath}; `remark-math` must run alongside it. */
export function remarkSingleDollarMath(this: Processor) {
addMicromarkExtension(this, singleDollarMath);
}
@@ -0,0 +1,398 @@
/**
* LaTeX's own math delimiters, ``\( … \)`` and ``\[ … \]``, as micromark
* constructs. Models write math this way far more often than with dollars, and
* remark-math only knows dollars.
*
* The constructs emit the same tokens as `micromark-extension-math`, so
* `remark-math`'s `mathFromMarkdown` turns them into ordinary `inlineMath` and
* `math` nodes and rehype-katex renders them. `remark-math` has to run too.
*
* - Text: ``\(x\)`` and ``\[x\]`` anywhere in a paragraph become inline math.
* The span may wrap lines but not cross a paragraph; ``\\`` pairs are opaque
* (a LaTeX line break), a nested ``\( … \)`` of the same kind is kept whole,
* and whitespace-only content is not math, so an escaped task box
* ``- \[ \] todo`` stays text. ``\[`` right after a letter or digit is an
* escaped index (``arr\[0\]``), not math.
* - Flow: a ``\[`` that starts a line opens display math, closed by a ``\]``
* that ends a line (the same line or a later one). Its lines are not parsed
* as markdown, so a lone ``=`` is not a setext underline. If a ``\]`` is
* followed by more text on its line, or the block is never closed, it is not
* a display block, and the text construct gets the ``\[`` instead: one line
* such as ``\[a\] and more`` must not swallow the rest of the answer.
*
* Written for this app rather than aliasing `micromark-extension-llm-math`
* over `micromark-extension-math`, whose flow construct does exactly that
* swallowing. Its structure follows that package and `micromark-extension-math`
* (both MIT).
*/
import {
asciiAlphanumeric,
markdownLineEnding,
markdownSpace,
} from 'micromark-util-character';
import { codes, types } from 'micromark-util-symbol';
import type {
Code,
Construct,
Effects,
Extension,
State,
TokenizeContext,
} from 'micromark-util-types';
import type { Processor } from 'unified';
import { addMicromarkExtension } from './micromarkExtension';
function tokenizeTexText(
this: TokenizeContext,
effects: Effects,
ok: State,
nok: State,
): State {
// What precedes the opening backslash, read before it is consumed.
const before = this.previous;
let open: Code = null;
let close: Code = null;
let depth = 0;
let hasContent = false;
const closing: Construct = { tokenize: tokenizeClosing, partial: true };
return start;
function start(code: Code): State | undefined {
effects.enter('mathText');
effects.enter('mathTextSequence');
effects.consume(code);
return sequenceOpen;
}
function sequenceOpen(code: Code): State | undefined {
if (code === codes.leftParenthesis) {
close = codes.rightParenthesis;
} else if (code === codes.leftSquareBracket && !asciiAlphanumeric(before)) {
close = codes.rightSquareBracket;
} else {
return nok(code);
}
open = code;
effects.consume(code);
effects.exit('mathTextSequence');
return between;
}
function between(code: Code): State | undefined {
if (code === codes.eof) return nok(code);
if (code === codes.backslash) {
return effects.check(closing, closeSequence, escape)(code);
}
// A line ending is kept as data: TeX reads it as a space, and dropping it
// would fuse ``\alpha`` with the next line's first letter.
if (markdownLineEnding(code)) {
effects.enter('mathTextData');
effects.consume(code);
effects.exit('mathTextData');
return between;
}
effects.enter('mathTextData');
return data(code);
}
function data(code: Code): State | undefined {
if (
code === codes.eof ||
code === codes.backslash ||
markdownLineEnding(code)
) {
effects.exit('mathTextData');
return between(code);
}
if (!markdownSpace(code)) hasContent = true;
effects.consume(code);
return data;
}
function escape(code: Code): State | undefined {
effects.enter('mathTextData');
effects.consume(code);
hasContent = true;
return escaped;
}
function escaped(code: Code): State | undefined {
if (code === codes.eof || markdownLineEnding(code)) {
effects.exit('mathTextData');
return between(code);
}
if (code === open) depth++;
// Only reached for a nested close: the outermost one is `closing`.
if (code === close) depth--;
effects.consume(code);
return data;
}
function closeSequence(code: Code): State | undefined {
if (!hasContent) return nok(code);
effects.enter('mathTextSequence');
effects.consume(code);
return closeBracket;
}
function closeBracket(code: Code): State | undefined {
effects.consume(code);
return done;
}
function done(code: Code): State | undefined {
effects.exit('mathTextSequence');
effects.exit('mathText');
return ok(code);
}
function tokenizeClosing(effects: Effects, ok: State, nok: State): State {
return (code) => {
effects.enter('mathTextSequence');
effects.consume(code);
return (next) => {
if (next !== close || depth !== 0) return nok(next);
effects.exit('mathTextSequence');
return ok(next);
};
};
}
}
/** A ``\(`` or ``\[`` opens math unless its backslash is itself escaped. */
function previousTex(this: TokenizeContext, code: Code): boolean {
return (
code !== codes.backslash ||
this.events[this.events.length - 1][1].type === types.characterEscape
);
}
const texText: Construct = {
name: 'mathTexText',
tokenize: tokenizeTexText,
previous: previousTex,
};
function tokenizeTexFlow(
this: TokenizeContext,
effects: Effects,
ok: State,
nok: State,
): State {
// Set while micromark asks whether the block may interrupt a paragraph.
const interrupt = this.interrupt;
// Whether the line being entered is a lazy continuation.
const lazyLine = () => this.parser.lazy[this.now().line];
const tail = this.events[this.events.length - 1];
// The fence's own indent, stripped from the lines inside it too.
const initialSize =
tail && tail[1].type === types.linePrefix
? tail[2].sliceSerialize(tail[1], true).length
: 0;
const closingFence: Construct = {
tokenize: tokenizeClosingFence,
partial: true,
};
const nonLazyLine: Construct = {
tokenize: tokenizeNonLazyLine,
partial: true,
};
return start;
function start(code: Code): State | undefined {
effects.enter('mathFlow');
effects.enter('mathFlowFence');
effects.enter('mathFlowFenceSequence');
effects.consume(code);
return sequenceOpen;
}
function sequenceOpen(code: Code): State | undefined {
if (code !== codes.leftSquareBracket) return nok(code);
effects.consume(code);
effects.exit('mathFlowFenceSequence');
return fenceWhitespace;
}
function fenceWhitespace(code: Code): State | undefined {
if (!markdownSpace(code)) return fenceEnd(code);
effects.enter(types.whitespace);
return fenceWhitespaceInside(code);
}
function fenceWhitespaceInside(code: Code): State | undefined {
if (markdownSpace(code)) {
effects.consume(code);
return fenceWhitespaceInside;
}
effects.exit(types.whitespace);
return fenceEnd(code);
}
function fenceEnd(code: Code): State | undefined {
effects.exit('mathFlowFence');
return lineContent(code);
}
// Anywhere in a line of the block, outside a value chunk.
function lineContent(code: Code): State | undefined {
if (code === codes.eof) return nok(code);
if (markdownLineEnding(code)) {
// To interrupt a paragraph only the opening line has to hold up.
if (interrupt) return ok(code);
return effects.attempt(nonLazyLine, lineStart, nok)(code);
}
if (code === codes.backslash) {
return effects.check(
{ tokenize: tokenizeBracketClose, partial: true },
atClose,
valueEscape,
)(code);
}
effects.enter('mathFlowValue');
return value(code);
}
function lineStart(code: Code): State | undefined {
if (initialSize === 0 || !markdownSpace(code)) return lineContent(code);
effects.enter(types.linePrefix);
return linePrefix(code, 0);
}
function linePrefix(code: Code, size: number): State | undefined {
if (markdownSpace(code) && size < initialSize) {
effects.consume(code);
return (next: Code) => linePrefix(next, size + 1);
}
effects.exit(types.linePrefix);
return lineContent(code);
}
function value(code: Code): State | undefined {
if (
code === codes.eof ||
code === codes.backslash ||
markdownLineEnding(code)
) {
effects.exit('mathFlowValue');
return lineContent(code);
}
effects.consume(code);
return value;
}
// A ``\]`` closes the block only when the rest of its line is blank; with
// anything after it, this is a line of prose, not a display block.
function atClose(code: Code): State | undefined {
return effects.attempt(closingFence, after, nok)(code);
}
function after(code: Code): State | undefined {
effects.exit('mathFlow');
return ok(code);
}
// ``\\`` and ``\x`` pairs are content, so ``\\]`` is not a close.
function valueEscape(code: Code): State | undefined {
effects.enter('mathFlowValue');
effects.consume(code);
return valueEscaped;
}
function valueEscaped(code: Code): State | undefined {
if (code === codes.eof || markdownLineEnding(code)) {
effects.exit('mathFlowValue');
return lineContent(code);
}
effects.consume(code);
return value;
}
function tokenizeBracketClose(
effects: Effects,
ok: State,
nok: State,
): State {
return (code) => {
effects.enter('mathFlowFenceSequence');
effects.consume(code);
return (next) => {
if (next !== codes.rightSquareBracket) return nok(next);
effects.exit('mathFlowFenceSequence');
return ok(next);
};
};
}
function tokenizeClosingFence(
effects: Effects,
ok: State,
nok: State,
): State {
return (code) => {
effects.enter('mathFlowFence');
effects.enter('mathFlowFenceSequence');
effects.consume(code);
return (bracket) => {
effects.consume(bracket);
effects.exit('mathFlowFenceSequence');
return afterSequence;
};
};
function afterSequence(code: Code): State | undefined {
if (markdownSpace(code)) {
effects.enter(types.whitespace);
return trailing(code);
}
return end(code);
}
function trailing(code: Code): State | undefined {
if (markdownSpace(code)) {
effects.consume(code);
return trailing;
}
effects.exit(types.whitespace);
return end(code);
}
function end(code: Code): State | undefined {
if (code !== codes.eof && !markdownLineEnding(code)) return nok(code);
effects.exit('mathFlowFence');
return ok(code);
}
}
// Past a line ending, unless the next line is a lazy continuation, which
// means the container around the block (a quote, a list item) has ended.
function tokenizeNonLazyLine(effects: Effects, ok: State, nok: State): State {
return (code) => {
effects.enter(types.lineEnding);
effects.consume(code);
effects.exit(types.lineEnding);
return (next) => (lazyLine() ? nok(next) : ok(next));
};
}
}
const texFlow: Construct = {
name: 'mathTexFlow',
tokenize: tokenizeTexFlow,
concrete: true,
};
export const texMath: Extension = {
flow: { [codes.backslash]: texFlow },
text: { [codes.backslash]: texText },
};
/** remark plugin for {@link texMath}; `remark-math` must run alongside it. */
export function remarkTexMath(this: Processor) {
addMicromarkExtension(this, texMath);
}
+1
View File
@@ -1456,6 +1456,7 @@
"attachments": {
"attach": "Anhängen",
"remove": "Anhang entfernen",
"uploadFailed": "Upload fehlgeschlagen. Die Datei konnte nicht gelesen werden oder die Verbindung wurde unterbrochen.",
"attached": "Anhang",
"failed": "Fehlgeschlagen",
"attachment": "Anhang"
+1 -1
View File
@@ -1462,10 +1462,10 @@
"attachments": {
"attach": "Attach",
"remove": "Remove attachment",
"uploadFailed": "Upload failed. The file couldn't be read or the connection dropped.",
"waitingToSend_one": "Will send when {{count}} file finishes processing…",
"waitingToSend_other": "Will send when {{count}} files finish processing…",
"cancelQueuedSend": "Cancel",
"sendBlockedByFailed": "{{names}} couldn't be processed — remove to send",
"unsupportedType": "Not a supported file type ({{extension}})",
"unreadableByModel": "{{model}} can't read {{name}} — switch to a model that supports images or PDFs",
"attached": "Attachment",
+1
View File
@@ -1456,6 +1456,7 @@
"attachments": {
"attach": "Adjuntar",
"remove": "Eliminar adjunto",
"uploadFailed": "Error al subir. No se pudo leer el archivo o se perdió la conexión.",
"attached": "Adjunto",
"failed": "Error",
"attachment": "Adjunto"
+1
View File
@@ -1445,6 +1445,7 @@
"attachments": {
"attach": "添付",
"remove": "添付ファイルを削除",
"uploadFailed": "アップロードに失敗しました。ファイルを読み取れなかったか、接続が切断されました。",
"attached": "添付ファイル",
"failed": "失敗",
"attachment": "添付ファイル"
+1
View File
@@ -1516,6 +1516,7 @@
"attachments": {
"attach": "Прикрепить",
"remove": "Удалить вложение",
"uploadFailed": "Не удалось загрузить. Файл не удалось прочитать или соединение прервалось.",
"attached": "Вложение",
"failed": "Ошибка",
"attachment": "Вложение"
+1
View File
@@ -1445,6 +1445,7 @@
"attachments": {
"attach": "附件",
"remove": "刪除附件",
"uploadFailed": "上傳失敗。無法讀取檔案或連線已中斷。",
"attached": "附件",
"failed": "失敗",
"attachment": "附件"
+1
View File
@@ -1445,6 +1445,7 @@
"attachments": {
"attach": "附件",
"remove": "删除附件",
"uploadFailed": "上传失败。无法读取文件或连接已断开。",
"attached": "附件",
"failed": "失败",
"attachment": "附件"
@@ -14,6 +14,10 @@
* file arrives bound to the turn (`conversation_messages.attachments[]`)
* instead of being silently dropped from the payload.
*
* 3. Send with an attachment that failed. A failed file never resolves,
* so it must never hold the question: Send drops the failed chip and
* sends the text with whatever did upload.
*
* UI-driven on purpose: the surface under test IS the composer. The
* attachments spec avoids the file picker for API-direct upload tests;
* here `setInputFiles` on the dropzone input is the point.
@@ -246,4 +250,77 @@ test.describe("tier-a · composer resilience", () => {
await context.close();
}
});
test("send with a failed attachment drops the file and sends the question", async ({
browser,
}) => {
const { context, sub } = await newUserContext(browser);
try {
const page = await context.newPage();
await openComposer(page);
const streamPosts: string[] = [];
page.on("request", (req) => {
if (req.url().includes("/stream") && req.method() === "POST") {
streamPosts.push(req.postData() ?? "");
}
});
// No response at all (status 0), the shape of the Android file-read
// failure seen in production.
await page.route("**/api/store_attachment", (route) =>
route.abort("failed"),
);
await page
.locator('input[type="file"]')
.first()
.setInputFiles(SMALL_FIXTURE_PATH);
// The failed chip is marked by its warning icon; the reason is a
// hover tooltip, not a line of text in the composer.
const failedIcon = page.getByLabel("Failed", { exact: true });
await expect(failedIcon).toBeVisible({ timeout: 5_000 });
await failedIcon.hover();
await expect(page.getByRole("tooltip")).toContainText(/upload failed/i);
const question = "does this still send? e2e-failed-attachment";
const textarea = page.locator("#message-input");
await textarea.fill(question);
const streamDone = page.waitForResponse(
(r) => r.url().includes("/stream") && r.request().method() === "POST",
{ timeout: 30_000 },
);
await textarea.press("Enter");
expect((await streamDone).status()).toBe(200);
// Exactly one send, with no attachment ids.
expect(streamPosts).toHaveLength(1);
const payload = JSON.parse(streamPosts[0]) as { attachments?: string[] };
expect(payload.attachments ?? []).toHaveLength(0);
// The question is in the thread; the chip and its reason are gone.
await expect(page.getByText(question)).toBeVisible();
await expect(failedIcon).toHaveCount(0);
await expect(page.getByText(/upload failed/i)).toHaveCount(0);
await expect(page.getByText("notes.txt")).toBeHidden();
await expect(textarea).toHaveValue("");
const { rows } = await pg.query<{
prompt: string | null;
attachment_count: number | string | null;
}>(
`SELECT cm.prompt,
COALESCE(array_length(cm.attachments, 1), 0) AS attachment_count
FROM conversation_messages cm
JOIN conversations c ON c.id = cm.conversation_id
WHERE c.user_id = $1`,
[sub],
);
expect(rows).toHaveLength(1);
expect(rows[0].prompt).toBe(question);
expect(Number(rows[0].attachment_count)).toBe(0);
} finally {
await context.close();
}
});
});