Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6cfdeb6f0 | ||
|
|
a13108b840 | ||
|
|
27b4fd7698 | ||
|
|
fb3e138ea2 | ||
|
|
a7c5d1a2aa | ||
|
|
3313738990 | ||
|
|
ce86f56d37 |
@@ -31,6 +31,7 @@ Page views: always via `Tracker` / `tracker.js` on prod + beta.
|
||||
| Message Rated | `MESSAGE_RATED` | MessageActions | `{ rating: 'up' \| 'down' \| 'cleared' }` |
|
||||
| Message Rating Reason | `MESSAGE_RATING_REASON` | MessageActions | `{ reason, hasComment }` |
|
||||
| Message Exported | `MESSAGE_EXPORTED` | MessageActions, AsyncDashboard2 | `{ format, scope }` |
|
||||
| Activity Stage Completed | `ACTIVITY_STAGE_COMPLETED` | MessageContext | `{ stage, durationMs }` (no label/detail text) |
|
||||
|
||||
## Identify
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
|
||||
| Runtime | Checkout / manage | Ledger |
|
||||
|---------|-------------------|--------|
|
||||
| Web (`!isNativePlatform`) | Stripe Checkout + Customer Portal via `/finance/...` | Stripe webhooks |
|
||||
| Web (`!isNativePlatform`) | Stripe Checkout + Customer Portal via `/monetization/...` | Stripe webhooks |
|
||||
| Native Capacitor | RevenueCat IAP (`@revenuecat/purchases-capacitor`) | RevenueCat webhooks → same invoice/payment tables |
|
||||
|
||||
FE displays whatever `/finance/invoices/` (and subscription) returns after refresh. Provider badge uses `provider` + `revenuecat_store`.
|
||||
FE displays whatever `/monetization/invoices/` (and subscription) returns after refresh. Provider badge uses `provider` + `revenuecat_store`.
|
||||
|
||||
## App user id
|
||||
|
||||
|
||||
Generated
+22
-165
@@ -34,7 +34,6 @@
|
||||
"bootstrap": "^5.3.3",
|
||||
"brace-expansion": "file:vendor/brace-expansion-compat",
|
||||
"chroma-js": "^3.1.2",
|
||||
"docx": "^9.7.1",
|
||||
"formik": "^2.4.6",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
@@ -2507,7 +2506,7 @@
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
||||
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "0.3.9"
|
||||
@@ -2520,7 +2519,7 @@
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
|
||||
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.0.3",
|
||||
@@ -5609,7 +5608,6 @@
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
@@ -6002,35 +6000,34 @@
|
||||
"version": "1.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
|
||||
"integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node12": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
|
||||
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node14": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
|
||||
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node16": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
|
||||
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/aria-query": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
@@ -6443,7 +6440,7 @@
|
||||
"version": "18.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.5.tgz",
|
||||
"integrity": "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
@@ -9054,7 +9051,7 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
@@ -9923,61 +9920,10 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/docx": {
|
||||
"version": "9.7.1",
|
||||
"resolved": "https://registry.npmjs.org/docx/-/docx-9.7.1.tgz",
|
||||
"integrity": "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "^25.2.3",
|
||||
"hash.js": "^1.1.7",
|
||||
"jszip": "^3.10.1",
|
||||
"nanoid": "^5.1.3",
|
||||
"xml": "^1.0.1",
|
||||
"xml-js": "^1.6.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/docx/node_modules/@types/node": {
|
||||
"version": "25.9.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz",
|
||||
"integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
}
|
||||
},
|
||||
"node_modules/docx/node_modules/nanoid": {
|
||||
"version": "5.1.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz",
|
||||
"integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18 || >=20"
|
||||
}
|
||||
},
|
||||
"node_modules/docx/node_modules/undici-types": {
|
||||
"version": "7.24.6",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-converter": {
|
||||
@@ -12469,16 +12415,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hash.js": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
|
||||
"integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"minimalistic-assert": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
@@ -12875,12 +12811,6 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "9.0.21",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
|
||||
@@ -15798,54 +15728,6 @@
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip/node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jszip/node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jszip/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jwt-decode": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
|
||||
@@ -15932,15 +15814,6 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
|
||||
@@ -16138,7 +16011,6 @@
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
@@ -16181,7 +16053,7 @@
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
|
||||
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/makeerror": {
|
||||
@@ -19091,7 +18963,6 @@
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -19302,7 +19173,6 @@
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -20750,7 +20620,6 @@
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
@@ -21018,12 +20887,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
@@ -21847,6 +21710,12 @@
|
||||
"postcss": "^8.2.15"
|
||||
}
|
||||
},
|
||||
"node_modules/stylis": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
|
||||
"integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/stylis-plugin-rtl": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/stylis-plugin-rtl/-/stylis-plugin-rtl-2.1.1.tgz",
|
||||
@@ -22431,7 +22300,7 @@
|
||||
"version": "10.9.2",
|
||||
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
|
||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "^0.8.0",
|
||||
@@ -22475,7 +22344,7 @@
|
||||
"version": "8.3.5",
|
||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
|
||||
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.11.0"
|
||||
@@ -22488,14 +22357,14 @@
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
||||
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ts-node/node_modules/diff": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
|
||||
"integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
@@ -22680,7 +22549,6 @@
|
||||
"version": "4.9.5",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
|
||||
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -22928,7 +22796,7 @@
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
||||
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/v8-to-istanbul": {
|
||||
@@ -23932,16 +23800,11 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xml": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
|
||||
"integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xml-js": {
|
||||
"version": "1.6.11",
|
||||
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
|
||||
"integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sax": "^1.2.4"
|
||||
@@ -24082,7 +23945,7 @@
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
||||
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -24125,12 +23988,6 @@
|
||||
"name": "brace-expansion",
|
||||
"version": "5.0.8",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stylis": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
|
||||
"integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==",
|
||||
"peer": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
"bootstrap": "^5.3.3",
|
||||
"brace-expansion": "file:vendor/brace-expansion-compat",
|
||||
"chroma-js": "^3.1.2",
|
||||
"docx": "^9.7.1",
|
||||
"formik": "^2.4.6",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import React from 'react';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
import ActivityIndicator from './ActivityIndicator';
|
||||
import type { ActivityHistoryEntry } from '../../utils/wsFrames';
|
||||
|
||||
const theme = {
|
||||
main: '#336699',
|
||||
focus: '#224466',
|
||||
darkMode: true,
|
||||
colors: {
|
||||
text: '#ffffff',
|
||||
cardBackground: 'rgba(0,0,0,0.3)',
|
||||
cardBorder: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
};
|
||||
|
||||
const renderIndicator = (
|
||||
props: React.ComponentProps<typeof ActivityIndicator>,
|
||||
) =>
|
||||
render(
|
||||
<ThemeProvider theme={theme as never}>
|
||||
<ActivityIndicator {...props} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
describe('ActivityIndicator (#96)', () => {
|
||||
it('has status role and polite live region regardless of state', () => {
|
||||
renderIndicator({ stage: null });
|
||||
const status = screen.getByRole('status');
|
||||
expect(status).toHaveAttribute('aria-live', 'polite');
|
||||
});
|
||||
|
||||
it('falls back to three dots when stage is null', () => {
|
||||
renderIndicator({ stage: null });
|
||||
expect(screen.getByTestId('activity-dots-fallback')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the label verbatim for unknown/future stage values', () => {
|
||||
renderIndicator({ stage: 'some_future_stage', label: 'Doing something new' });
|
||||
expect(screen.getByText('Doing something new')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('activity-dots-fallback')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows optional detail text', () => {
|
||||
renderIndicator({
|
||||
stage: 'searching',
|
||||
label: 'Searching the web',
|
||||
detail: 'query: best pizza in town',
|
||||
});
|
||||
expect(screen.getByText('Searching the web')).toBeInTheDocument();
|
||||
expect(screen.getByText('query: best pizza in town')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders completed stages above with check marks', () => {
|
||||
const history: ActivityHistoryEntry[] = [
|
||||
{ stage: 'searching', label: 'Searched the web', startedAt: 0, finishedAt: 100 },
|
||||
{ stage: 'reading', label: 'Read 3 sources', startedAt: 100, finishedAt: 400 },
|
||||
];
|
||||
renderIndicator({
|
||||
stage: 'writing',
|
||||
label: null,
|
||||
history,
|
||||
});
|
||||
expect(screen.getByText('Searched the web')).toBeInTheDocument();
|
||||
expect(screen.getByText('Read 3 sources')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('✓')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('hides everything when interrupted', () => {
|
||||
const { container } = renderIndicator({
|
||||
stage: 'searching',
|
||||
label: 'Searching the web',
|
||||
interrupted: true,
|
||||
});
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('shows a generic label when stage is present but label is cleared (writing stage)', () => {
|
||||
renderIndicator({ stage: 'writing', label: null });
|
||||
expect(screen.getByText('Working…')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('prefers-reduced-motion', () => {
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalMatchMedia,
|
||||
});
|
||||
});
|
||||
|
||||
it('disables the spinner animation when the user prefers reduced motion', () => {
|
||||
const matchMediaMock = jest.fn().mockImplementation((query: string) => ({
|
||||
matches: query.includes('prefers-reduced-motion'),
|
||||
media: query,
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
}));
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: matchMediaMock,
|
||||
});
|
||||
|
||||
renderIndicator({ stage: 'searching', label: 'Searching the web' });
|
||||
const spinner = screen.getByTestId('activity-spinner');
|
||||
expect(spinner).toHaveAttribute('data-reduced-motion', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows elapsed time after ~10s and "Still working…" after ~30s of silence', () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
renderIndicator({ stage: 'searching', label: 'Searching the web' });
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(11_000);
|
||||
});
|
||||
expect(screen.getByText('11s')).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(20_000);
|
||||
});
|
||||
expect(screen.getByText('Still working…')).toBeInTheDocument();
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
import React, { useEffect, useReducer, useRef, useState } from 'react';
|
||||
import styled, { css, keyframes } from 'styled-components';
|
||||
import type { ActivityHistoryEntry } from '../../utils/wsFrames';
|
||||
|
||||
/** Minimum time a single stage stays visible before advancing to a queued update (#96). */
|
||||
const MIN_DISPLAY_MS = 400;
|
||||
const ELAPSED_THRESHOLD_MS = 10_000;
|
||||
const SILENCE_THRESHOLD_MS = 30_000;
|
||||
|
||||
function usePrefersReducedMotion(): boolean {
|
||||
const [reduced, setReduced] = useState<boolean>(() => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
|
||||
return window.matchMedia('(prefers-reduced-motion: reduce)')?.matches ?? false;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return undefined;
|
||||
const mql = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
if (!mql) return undefined;
|
||||
const handler = (e: MediaQueryListEvent) => setReduced(e.matches);
|
||||
if (typeof mql.addEventListener === 'function') {
|
||||
mql.addEventListener('change', handler);
|
||||
return () => mql.removeEventListener('change', handler);
|
||||
}
|
||||
// Safari < 14 fallback
|
||||
mql.addListener(handler);
|
||||
return () => mql.removeListener(handler);
|
||||
}, []);
|
||||
|
||||
return reduced;
|
||||
}
|
||||
|
||||
type QueueItem = { stage: string; label: string | null; detail: string | null };
|
||||
|
||||
type IndicatorState = {
|
||||
current: QueueItem | null;
|
||||
queue: QueueItem[];
|
||||
lastIncomingStage: string | null;
|
||||
};
|
||||
|
||||
type IndicatorAction =
|
||||
| { type: 'INCOMING'; item: QueueItem | null }
|
||||
| { type: 'ADVANCE' }
|
||||
| { type: 'RESET' };
|
||||
|
||||
const initialIndicatorState: IndicatorState = {
|
||||
current: null,
|
||||
queue: [],
|
||||
lastIncomingStage: null,
|
||||
};
|
||||
|
||||
function indicatorReducer(state: IndicatorState, action: IndicatorAction): IndicatorState {
|
||||
switch (action.type) {
|
||||
case 'RESET':
|
||||
return initialIndicatorState;
|
||||
case 'INCOMING': {
|
||||
if (!action.item) return initialIndicatorState;
|
||||
// Same stage as the last update seen — refresh content in place (label/detail tweak),
|
||||
// no need to enforce the min-display queue since it isn't a stage transition.
|
||||
if (action.item.stage === state.lastIncomingStage) {
|
||||
if (state.queue.length > 0) {
|
||||
const queue = [...state.queue];
|
||||
queue[queue.length - 1] = action.item;
|
||||
return { ...state, queue };
|
||||
}
|
||||
return { ...state, current: action.item };
|
||||
}
|
||||
if (!state.current) {
|
||||
return { current: action.item, queue: [], lastIncomingStage: action.item.stage };
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
queue: [...state.queue, action.item],
|
||||
lastIncomingStage: action.item.stage,
|
||||
};
|
||||
}
|
||||
case 'ADVANCE': {
|
||||
if (state.queue.length === 0) return state;
|
||||
const [next, ...rest] = state.queue;
|
||||
return { ...state, current: next, queue: rest };
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
const spin = keyframes`
|
||||
to { transform: rotate(360deg); }
|
||||
`;
|
||||
|
||||
const bounce = keyframes`
|
||||
0%, 80%, 100% { transform: scale(0); }
|
||||
40% { transform: scale(1); }
|
||||
`;
|
||||
|
||||
const Root = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
color: ${({ theme }) => theme.colors.text};
|
||||
font-size: 0.9rem;
|
||||
|
||||
@media (max-width: 360px) {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
`;
|
||||
|
||||
const HistoryList = styled.ul`
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
opacity: 0.6;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const HistoryItem = styled.li`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
min-width: 0;
|
||||
|
||||
span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const CheckMark = styled.span`
|
||||
flex-shrink: 0;
|
||||
color: ${({ theme }) => (theme.darkMode ? '#8ee6a0' : '#2e7d32')};
|
||||
font-weight: 700;
|
||||
`;
|
||||
|
||||
const CurrentRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const Spinner = styled.span<{ $reducedMotion: boolean }>`
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 50%;
|
||||
border: 2px solid currentColor;
|
||||
border-top-color: transparent;
|
||||
opacity: 0.7;
|
||||
${({ $reducedMotion }) =>
|
||||
$reducedMotion
|
||||
? css`
|
||||
animation: none;
|
||||
`
|
||||
: css`
|
||||
animation: ${spin} 0.8s linear infinite;
|
||||
`}
|
||||
`;
|
||||
|
||||
const TextColumn = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const LabelText = styled.span`
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
const DetailText = styled.span`
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.65;
|
||||
margin-top: 0.1rem;
|
||||
`;
|
||||
|
||||
const MetaText = styled.span`
|
||||
flex-shrink: 0;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.55;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const DotsRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.15rem 0;
|
||||
`;
|
||||
|
||||
const Dot = styled.span<{ $delay: string; $reducedMotion: boolean }>`
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin: 0 4px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
${({ $reducedMotion, $delay }) =>
|
||||
$reducedMotion
|
||||
? css`
|
||||
opacity: 0.6;
|
||||
`
|
||||
: css`
|
||||
animation: ${bounce} 1.4s infinite ease-in-out both;
|
||||
animation-delay: ${$delay};
|
||||
`}
|
||||
`;
|
||||
|
||||
export type ActivityIndicatorProps = {
|
||||
/** Current stage key from the latest "status" frame, or null before the first update. */
|
||||
stage: string | null;
|
||||
label?: string | null;
|
||||
detail?: string | null;
|
||||
/** Previously completed stages for this turn, oldest first. */
|
||||
history?: ActivityHistoryEntry[];
|
||||
/** Hides the indicator entirely once a stream is interrupted (#96). */
|
||||
interrupted?: boolean;
|
||||
};
|
||||
|
||||
const ActivityIndicator = ({
|
||||
stage,
|
||||
label = null,
|
||||
detail = null,
|
||||
history = [],
|
||||
interrupted = false,
|
||||
}: ActivityIndicatorProps): JSX.Element | null => {
|
||||
const [state, dispatch] = useReducer(indicatorReducer, initialIndicatorState);
|
||||
const reducedMotion = usePrefersReducedMotion();
|
||||
|
||||
const overallStartRef = useRef<number | null>(null);
|
||||
const lastUpdateRef = useRef<number>(Date.now());
|
||||
const currentSinceRef = useRef<number>(Date.now());
|
||||
const advanceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [, forceTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (interrupted || stage == null) {
|
||||
overallStartRef.current = null;
|
||||
dispatch({ type: 'RESET' });
|
||||
return;
|
||||
}
|
||||
if (overallStartRef.current == null) {
|
||||
overallStartRef.current = Date.now();
|
||||
}
|
||||
lastUpdateRef.current = Date.now();
|
||||
dispatch({ type: 'INCOMING', item: { stage, label, detail } });
|
||||
}, [stage, label, detail, interrupted]);
|
||||
|
||||
// Reset the per-stage clock whenever the displayed stage changes.
|
||||
useEffect(() => {
|
||||
currentSinceRef.current = Date.now();
|
||||
}, [state.current?.stage]);
|
||||
|
||||
// Enforce the minimum display time per stage before advancing the queue.
|
||||
useEffect(() => {
|
||||
if (advanceTimerRef.current) {
|
||||
clearTimeout(advanceTimerRef.current);
|
||||
advanceTimerRef.current = null;
|
||||
}
|
||||
if (state.queue.length === 0) return undefined;
|
||||
const elapsed = Date.now() - currentSinceRef.current;
|
||||
const remaining = Math.max(MIN_DISPLAY_MS - elapsed, 0);
|
||||
advanceTimerRef.current = setTimeout(() => {
|
||||
dispatch({ type: 'ADVANCE' });
|
||||
}, remaining);
|
||||
return () => {
|
||||
if (advanceTimerRef.current) clearTimeout(advanceTimerRef.current);
|
||||
};
|
||||
}, [state.queue.length]);
|
||||
|
||||
// Tick once a second while active so elapsed/"still working" copy stays fresh.
|
||||
useEffect(() => {
|
||||
if (!state.current) return undefined;
|
||||
const id = setInterval(() => forceTick((n) => n + 1), 1000);
|
||||
return () => clearInterval(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [Boolean(state.current)]);
|
||||
|
||||
if (interrupted) return null;
|
||||
|
||||
const now = Date.now();
|
||||
const elapsedMs = overallStartRef.current != null ? now - overallStartRef.current : 0;
|
||||
const silentMs = now - lastUpdateRef.current;
|
||||
const showElapsed = state.current != null && elapsedMs >= ELAPSED_THRESHOLD_MS;
|
||||
const stillWorking = state.current != null && silentMs >= SILENCE_THRESHOLD_MS;
|
||||
const elapsedLabel = showElapsed ? `${Math.floor(elapsedMs / 1000)}s` : null;
|
||||
|
||||
return (
|
||||
<Root role="status" aria-live="polite">
|
||||
{history.length > 0 && (
|
||||
<HistoryList>
|
||||
{history.map((entry, i) => (
|
||||
<HistoryItem key={`${entry.stage}-${entry.startedAt}-${i}`}>
|
||||
<CheckMark aria-hidden="true">✓</CheckMark>
|
||||
<span>{entry.label}</span>
|
||||
</HistoryItem>
|
||||
))}
|
||||
</HistoryList>
|
||||
)}
|
||||
|
||||
{state.current ? (
|
||||
<CurrentRow>
|
||||
<Spinner
|
||||
aria-hidden="true"
|
||||
$reducedMotion={reducedMotion}
|
||||
data-testid="activity-spinner"
|
||||
data-reduced-motion={reducedMotion}
|
||||
/>
|
||||
<TextColumn>
|
||||
<LabelText>
|
||||
{stillWorking ? 'Still working…' : state.current.label || 'Working…'}
|
||||
</LabelText>
|
||||
{state.current.detail && (
|
||||
<DetailText title={state.current.detail}>{state.current.detail}</DetailText>
|
||||
)}
|
||||
</TextColumn>
|
||||
{elapsedLabel && !stillWorking && <MetaText>{elapsedLabel}</MetaText>}
|
||||
</CurrentRow>
|
||||
) : (
|
||||
<DotsRow data-testid="activity-dots-fallback">
|
||||
<Dot $delay="-0.32s" $reducedMotion={reducedMotion} />
|
||||
<Dot $delay="-0.16s" $reducedMotion={reducedMotion} />
|
||||
<Dot $delay="0s" $reducedMotion={reducedMotion} />
|
||||
</DotsRow>
|
||||
)}
|
||||
</Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivityIndicator;
|
||||
@@ -157,16 +157,16 @@ const mockFinanceGets = ({
|
||||
plans?: unknown[];
|
||||
} = {}) => {
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/finance/invoices/') {
|
||||
if (url === '/monetization/invoices/') {
|
||||
return Promise.resolve({ data: invoices });
|
||||
}
|
||||
if (url === '/finance/payments/') {
|
||||
if (url === '/monetization/payments/') {
|
||||
return Promise.resolve({ data: payments });
|
||||
}
|
||||
if (url === '/finance/subscription/') {
|
||||
if (url === '/monetization/subscription/') {
|
||||
return Promise.resolve({ data: subscription });
|
||||
}
|
||||
if (url === '/finance/plans/') {
|
||||
if (url === '/monetization/plans/') {
|
||||
return Promise.resolve({ data: plans });
|
||||
}
|
||||
return Promise.reject(new Error(`unexpected GET ${url}`));
|
||||
@@ -246,7 +246,7 @@ describe('BillingSection', () => {
|
||||
await user.click(screen.getByRole('button', { name: /Change plan/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPost).toHaveBeenCalledWith('/finance/portal/', {
|
||||
expect(mockPost).toHaveBeenCalledWith('/monetization/portal/', {
|
||||
return_url: 'http://localhost/account/',
|
||||
});
|
||||
});
|
||||
@@ -321,7 +321,7 @@ describe('BillingSection', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
'/finance/checkout/',
|
||||
'/monetization/checkout/',
|
||||
expect.objectContaining({
|
||||
success_url: expect.stringContaining('/billing/success'),
|
||||
cancel_url: expect.stringContaining('/billing/cancel'),
|
||||
@@ -378,7 +378,7 @@ describe('BillingSection', () => {
|
||||
expect(mockPurchasePlan).toHaveBeenCalled();
|
||||
});
|
||||
expect(mockPost).not.toHaveBeenCalledWith(
|
||||
'/finance/checkout/',
|
||||
'/monetization/checkout/',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
@@ -292,10 +292,10 @@ const BillingSection = (): JSX.Element => {
|
||||
try {
|
||||
const [invoiceResponse, paymentResponse, subscriptionResponse, plansResponse] =
|
||||
await Promise.all([
|
||||
axiosInstance.get<FinanceInvoice[]>('/finance/invoices/'),
|
||||
axiosInstance.get<FinancePayment[]>('/finance/payments/'),
|
||||
axiosInstance.get<SubscriptionMe>('/finance/subscription/'),
|
||||
axiosInstance.get<SubscriptionPlanInfo[]>('/finance/plans/'),
|
||||
axiosInstance.get<FinanceInvoice[]>('/monetization/invoices/'),
|
||||
axiosInstance.get<FinancePayment[]>('/monetization/payments/'),
|
||||
axiosInstance.get<SubscriptionMe>('/monetization/subscription/'),
|
||||
axiosInstance.get<SubscriptionPlanInfo[]>('/monetization/plans/'),
|
||||
]);
|
||||
setInvoices(Array.isArray(invoiceResponse.data) ? invoiceResponse.data : []);
|
||||
setPayments(Array.isArray(paymentResponse.data) ? paymentResponse.data : []);
|
||||
@@ -385,7 +385,7 @@ const BillingSection = (): JSX.Element => {
|
||||
try {
|
||||
const returnUrl = `${window.location.origin}/account/`;
|
||||
const response = await axiosInstance.post<{ portal_url: string }>(
|
||||
'/finance/portal/',
|
||||
'/monetization/portal/',
|
||||
{ return_url: returnUrl }
|
||||
);
|
||||
const portalUrl = response.data?.portal_url;
|
||||
@@ -461,7 +461,7 @@ const BillingSection = (): JSX.Element => {
|
||||
...(planSlug ? { plan_slug: planSlug } : {}),
|
||||
});
|
||||
const response = await axiosInstance.post<{ checkout_url: string }>(
|
||||
'/finance/checkout/',
|
||||
'/monetization/checkout/',
|
||||
{
|
||||
success_url,
|
||||
cancel_url,
|
||||
|
||||
+10
-16
@@ -36,7 +36,7 @@ describe('ConversationDetailCard citations (#98)', () => {
|
||||
expect(out).toContain('```\n[9]\n```');
|
||||
});
|
||||
|
||||
it('renders Sources list from citations prop', () => {
|
||||
it('does not render Sources list card', () => {
|
||||
renderCard({
|
||||
message: 'Answer with [1]',
|
||||
user_created: false,
|
||||
@@ -49,24 +49,13 @@ describe('ConversationDetailCard citations (#98)', () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(screen.getByLabelText('Sources')).toBeInTheDocument();
|
||||
const link = screen.getByRole('link', { name: 'Example Source' });
|
||||
expect(link).toHaveAttribute('href', 'https://example.com/a');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
});
|
||||
|
||||
it('renders nothing for Sources when citations empty', () => {
|
||||
renderCard({
|
||||
message: 'No sources here',
|
||||
user_created: false,
|
||||
citations: [],
|
||||
});
|
||||
expect(screen.queryByLabelText('Sources')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Source 1' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('highlights source when inline citation clicked', async () => {
|
||||
it('opens citation URL in a new tab when inline marker clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);
|
||||
renderCard({
|
||||
message: 'See [1]',
|
||||
user_created: false,
|
||||
@@ -75,6 +64,11 @@ describe('ConversationDetailCard citations (#98)', () => {
|
||||
],
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: 'Source 1' }));
|
||||
expect(document.getElementById('citation-source-1')).toBeInTheDocument();
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://example.com',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
);
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,4 +116,34 @@ describe('ConversationDetailCard', () => {
|
||||
expect(agentBubble).not.toBeNull();
|
||||
expect(getComputedStyle(agentBubble!).color).toBe('rgb(255, 255, 255)');
|
||||
});
|
||||
|
||||
it('renders activity label instead of dots when stage is set (#96)', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ThemeProvider theme={darkTheme as never}>
|
||||
<ConversationDetailCard
|
||||
message=""
|
||||
user_created={false}
|
||||
activityStage="searching"
|
||||
activityLabel="Searching the web"
|
||||
activityDetail="Taylor Swift wedding"
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(screen.getByText('Searching the web')).toBeInTheDocument();
|
||||
expect(screen.getByText('Taylor Swift wedding')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('activity-dots-fallback')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to dots when empty message has no activity stage (#96)', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ThemeProvider theme={darkTheme as never}>
|
||||
<ConversationDetailCard message="" user_created={false} />
|
||||
</ThemeProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(screen.getByTestId('activity-dots-fallback')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import Markdown from 'markdown-to-jsx';
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled, { keyframes } from 'styled-components';
|
||||
import { isRagFeatureNotAllowed, parseChatErrorPayload } from '../../utils/chatErrors';
|
||||
import type { Citation } from '../../utils/wsFrames';
|
||||
import type { ActivityHistoryEntry, Citation } from '../../utils/wsFrames';
|
||||
import type { PromptRating } from '../../utils/promptFeedback';
|
||||
import CustomPreBlock from '../CustomPreBlock/CustomPreBlock';
|
||||
import SourcesList from '../SourcesList/SourcesList';
|
||||
import MessageActions from '../MessageActions/MessageActions';
|
||||
import ActivityIndicator from '../ActivityIndicator/ActivityIndicator';
|
||||
|
||||
const fadeIn = keyframes`
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
@@ -95,30 +95,6 @@ const Bubble = styled.div<{ $isUser: boolean }>`
|
||||
}
|
||||
`;
|
||||
|
||||
const LoadingDot = styled.div`
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: currentColor;
|
||||
border-radius: 50%;
|
||||
margin: 0 4px;
|
||||
animation: bounce 1.4s infinite ease-in-out both;
|
||||
|
||||
&:nth-child(1) { animation-delay: -0.32s; }
|
||||
&:nth-child(2) { animation-delay: -0.16s; }
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 80%, 100% { transform: scale(0); }
|
||||
40% { transform: scale(1); }
|
||||
}
|
||||
`;
|
||||
|
||||
const LoadingContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem;
|
||||
`;
|
||||
|
||||
const UpgradeNotice = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -188,6 +164,12 @@ type ConversationDetailCardProps = {
|
||||
createdTimestamp?: Date | string | null;
|
||||
initialRating?: PromptRating | null;
|
||||
onRatingChange?: (rating: PromptRating | null) => void;
|
||||
/** Live activity status for the empty-message (streaming placeholder) bubble (#96). */
|
||||
activityStage?: string | null;
|
||||
activityLabel?: string | null;
|
||||
activityDetail?: string | null;
|
||||
activityHistory?: ActivityHistoryEntry[];
|
||||
activityInterrupted?: boolean;
|
||||
};
|
||||
|
||||
const MyPlot = ({ format, image }: { format: string; image: string }) => {
|
||||
@@ -220,9 +202,12 @@ const ConversationDetailCard = ({
|
||||
createdTimestamp = null,
|
||||
initialRating = null,
|
||||
onRatingChange,
|
||||
activityStage = null,
|
||||
activityLabel = null,
|
||||
activityDetail = null,
|
||||
activityHistory = [],
|
||||
activityInterrupted = false,
|
||||
}: ConversationDetailCardProps): JSX.Element => {
|
||||
const [highlightIndex, setHighlightIndex] = useState<number | null>(null);
|
||||
|
||||
const CitationMark = useCallback(
|
||||
({ indices }: { indices?: string }) => {
|
||||
const list = (indices || '')
|
||||
@@ -241,12 +226,9 @@ const ConversationDetailCard = ({
|
||||
aria-label={`Source ${index}`}
|
||||
title={citation?.title || `Source ${index}`}
|
||||
onClick={() => {
|
||||
setHighlightIndex(index);
|
||||
if (citation?.url) {
|
||||
// Prefer scroll/highlight; URL still available from Sources list
|
||||
window.open(citation.url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
const el = document.getElementById(`citation-source-${index}`);
|
||||
el?.scrollIntoView?.({ behavior: 'smooth', block: 'nearest' });
|
||||
}}
|
||||
>
|
||||
[{index}]
|
||||
@@ -269,11 +251,13 @@ const ConversationDetailCard = ({
|
||||
return (
|
||||
<MessageContainer $isUser={false}>
|
||||
<Bubble $isUser={false}>
|
||||
<LoadingContainer>
|
||||
<LoadingDot />
|
||||
<LoadingDot />
|
||||
<LoadingDot />
|
||||
</LoadingContainer>
|
||||
<ActivityIndicator
|
||||
stage={activityStage}
|
||||
label={activityLabel}
|
||||
detail={activityDetail}
|
||||
history={activityHistory}
|
||||
interrupted={activityInterrupted}
|
||||
/>
|
||||
</Bubble>
|
||||
</MessageContainer>
|
||||
);
|
||||
@@ -356,10 +340,6 @@ const ConversationDetailCard = ({
|
||||
</Markdown>
|
||||
</Bubble>
|
||||
|
||||
{!user_created && citations.length > 0 && (
|
||||
<SourcesList citations={citations} highlightIndex={highlightIndex} />
|
||||
)}
|
||||
|
||||
<MessageActions
|
||||
promptId={promptId}
|
||||
rawMarkdown={rawForCopy}
|
||||
|
||||
@@ -86,7 +86,7 @@ describe('Header2 (#81 subscription-aware Documents nav link)', () => {
|
||||
|
||||
renderHeader();
|
||||
|
||||
await waitFor(() => expect(mockGet).toHaveBeenCalledWith('/finance/subscription/'));
|
||||
await waitFor(() => expect(mockGet).toHaveBeenCalledWith('/monetization/subscription/'));
|
||||
expect(screen.queryByText('Documents')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -309,7 +309,6 @@ const MessageActions = ({
|
||||
{(
|
||||
[
|
||||
['pdf', 'PDF'],
|
||||
['docx', 'Word (.docx)'],
|
||||
['csv', 'CSV'],
|
||||
['xlsx', 'Excel (.xlsx)'],
|
||||
['txt', 'Plain text'],
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import type { Citation } from '../../utils/wsFrames';
|
||||
|
||||
const SourcesRoot = styled.aside`
|
||||
margin-top: 0.65rem;
|
||||
max-width: 80%;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
||||
background: ${({ theme }) =>
|
||||
theme.darkMode ? 'rgba(255, 255, 255, 0.04)' : 'rgba(0, 0, 0, 0.03)'};
|
||||
color: ${({ theme }) => theme.colors.text};
|
||||
|
||||
@media (max-width: 768px) {
|
||||
max-width: 92%;
|
||||
}
|
||||
`;
|
||||
|
||||
const SourcesTitle = styled.h4`
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.75;
|
||||
`;
|
||||
|
||||
const SourceList = styled.ol`
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
`;
|
||||
|
||||
const SourceItem = styled.li<{ $highlight: boolean }>`
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
border-radius: 0.35rem;
|
||||
padding: 0.15rem 0.25rem;
|
||||
outline: ${({ $highlight, theme }) =>
|
||||
$highlight ? `2px solid ${theme.main}` : 'none'};
|
||||
background: ${({ $highlight, theme }) =>
|
||||
$highlight
|
||||
? theme.darkMode
|
||||
? 'rgba(255,255,255,0.08)'
|
||||
: 'rgba(0,0,0,0.06)'
|
||||
: 'transparent'};
|
||||
transition: background 0.2s ease, outline 0.2s ease;
|
||||
`;
|
||||
|
||||
const SourceLink = styled.a`
|
||||
color: ${({ theme }) => (theme.darkMode ? '#a0c4ff' : theme.main)};
|
||||
text-decoration: underline;
|
||||
word-break: break-word;
|
||||
`;
|
||||
|
||||
const SourceMeta = styled.span`
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.65;
|
||||
margin-top: 0.1rem;
|
||||
`;
|
||||
|
||||
function domainFromUrl(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
type SourcesListProps = {
|
||||
citations: Citation[];
|
||||
highlightIndex?: number | null;
|
||||
};
|
||||
|
||||
const SourcesList = ({
|
||||
citations,
|
||||
highlightIndex = null,
|
||||
}: SourcesListProps): JSX.Element | null => {
|
||||
const itemRefs = useRef<Record<number, HTMLLIElement | null>>({});
|
||||
const [active, setActive] = useState<number | null>(highlightIndex);
|
||||
|
||||
useEffect(() => {
|
||||
setActive(highlightIndex ?? null);
|
||||
if (highlightIndex == null) return;
|
||||
const el = itemRefs.current[highlightIndex];
|
||||
el?.scrollIntoView?.({ behavior: 'smooth', block: 'nearest' });
|
||||
}, [highlightIndex]);
|
||||
|
||||
if (!citations.length) return null;
|
||||
|
||||
const sorted = [...citations].sort((a, b) => a.index - b.index);
|
||||
|
||||
return (
|
||||
<SourcesRoot aria-label="Sources">
|
||||
<SourcesTitle>Sources</SourcesTitle>
|
||||
<SourceList>
|
||||
{sorted.map((citation) => {
|
||||
const domain = domainFromUrl(citation.url);
|
||||
return (
|
||||
<SourceItem
|
||||
key={citation.index}
|
||||
id={`citation-source-${citation.index}`}
|
||||
$highlight={active === citation.index}
|
||||
ref={(node) => {
|
||||
itemRefs.current[citation.index] = node;
|
||||
}}
|
||||
>
|
||||
{citation.url ? (
|
||||
<SourceLink
|
||||
href={citation.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{citation.title || `Source ${citation.index}`}
|
||||
</SourceLink>
|
||||
) : (
|
||||
<span>{citation.title || `Source ${citation.index}`}</span>
|
||||
)}
|
||||
{(citation.published_at || domain) && (
|
||||
<SourceMeta>
|
||||
{[citation.published_at, domain].filter(Boolean).join(' · ')}
|
||||
</SourceMeta>
|
||||
)}
|
||||
</SourceItem>
|
||||
);
|
||||
})}
|
||||
</SourceList>
|
||||
</SourcesRoot>
|
||||
);
|
||||
};
|
||||
|
||||
export default SourcesList;
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import InfoOutlined from '@mui/icons-material/InfoOutlined';
|
||||
import {
|
||||
useMaterialUIController,
|
||||
setDarkMode,
|
||||
@@ -48,6 +50,16 @@ const SettingLabel = styled.span`
|
||||
font-size: 1.1rem;
|
||||
color: ${({ theme }) => theme.colors.text};
|
||||
font-weight: 500;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
`;
|
||||
|
||||
const InfoIcon = styled(InfoOutlined)`
|
||||
font-size: 1rem !important;
|
||||
opacity: 0.7;
|
||||
cursor: help;
|
||||
vertical-align: middle;
|
||||
`;
|
||||
|
||||
const ToggleSwitch = styled.label`
|
||||
@@ -93,6 +105,11 @@ const Checkbox = styled.input`
|
||||
&:checked + ${Slider}:before {
|
||||
transform: translateX(26px);
|
||||
}
|
||||
|
||||
&:disabled + ${Slider} {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
|
||||
const ColorPickerContainer = styled.div`
|
||||
@@ -115,6 +132,23 @@ const ColorButton = styled.button<{ color: string; isSelected: boolean }>`
|
||||
}
|
||||
`;
|
||||
|
||||
const ErrorText = styled.p`
|
||||
color: #ff6b6b;
|
||||
margin: 0.75rem 0 0 0;
|
||||
font-size: 0.95rem;
|
||||
`;
|
||||
|
||||
const CONTEXT_TOOLTIP =
|
||||
'This will use previous conversations to better customize the experience.';
|
||||
|
||||
function prefsErrorMessage(error: unknown, fallback: string): string {
|
||||
const axiosError = error as {
|
||||
response?: { data?: { detail?: string } };
|
||||
message?: string;
|
||||
};
|
||||
return axiosError.response?.data?.detail || axiosError.message || fallback;
|
||||
}
|
||||
|
||||
type PreferencesValues = {
|
||||
order: boolean;
|
||||
}
|
||||
@@ -128,6 +162,10 @@ const ThemeSettingsCard = (): JSX.Element => {
|
||||
const themeColors = Object.keys(palettes);
|
||||
|
||||
const [order, setOrder] = useState<boolean>(true);
|
||||
const [useConversationContext, setUseConversationContext] = useState<boolean>(false);
|
||||
const [prefsLoading, setPrefsLoading] = useState<boolean>(true);
|
||||
const [contextSaving, setContextSaving] = useState<boolean>(false);
|
||||
const [prefsError, setPrefsError] = useState<string>('');
|
||||
const { updatePreferences } = useContext(PreferenceContext);
|
||||
|
||||
const handleConversationOrder = async ({ order }: PreferencesValues): Promise<void> => {
|
||||
@@ -141,17 +179,50 @@ const ThemeSettingsCard = (): JSX.Element => {
|
||||
}
|
||||
}
|
||||
|
||||
async function getConversationOrder() {
|
||||
async function getConversationPreferences() {
|
||||
setPrefsLoading(true);
|
||||
setPrefsError('');
|
||||
try {
|
||||
const { data, }: AxiosResponse<PreferencesType> = await axiosInstance.get(`/conversation_preferences`);
|
||||
const { data }: AxiosResponse<PreferencesType> = await axiosInstance.get(
|
||||
`/conversation_preferences`
|
||||
);
|
||||
setOrder(data.order);
|
||||
setUseConversationContext(Boolean(data.use_conversation_context));
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
setPrefsError(
|
||||
prefsErrorMessage(error, 'Could not load account preferences.')
|
||||
);
|
||||
} finally {
|
||||
setPrefsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleConversationContext = async (enabled: boolean): Promise<void> => {
|
||||
const previous = useConversationContext;
|
||||
setUseConversationContext(enabled);
|
||||
setContextSaving(true);
|
||||
setPrefsError('');
|
||||
try {
|
||||
const { data }: AxiosResponse<PreferencesType> = await axiosInstance.post(
|
||||
'/conversation_preferences',
|
||||
{ use_conversation_context: enabled }
|
||||
);
|
||||
setUseConversationContext(Boolean(data.use_conversation_context));
|
||||
} catch (error) {
|
||||
setUseConversationContext(previous);
|
||||
setPrefsError(
|
||||
prefsErrorMessage(
|
||||
error,
|
||||
'Could not update conversation context setting. Try again.'
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
setContextSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getConversationOrder();
|
||||
getConversationPreferences();
|
||||
}, [])
|
||||
|
||||
const handleDarkMode = () => setDarkMode(dispatch, !darkMode);
|
||||
@@ -193,6 +264,7 @@ const ThemeSettingsCard = (): JSX.Element => {
|
||||
<Checkbox
|
||||
type="checkbox"
|
||||
checked={order}
|
||||
disabled={prefsLoading}
|
||||
onChange={() => {
|
||||
setOrder(!order);
|
||||
handleConversationOrder({ order: !order })
|
||||
@@ -202,6 +274,30 @@ const ThemeSettingsCard = (): JSX.Element => {
|
||||
</ToggleSwitch>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow>
|
||||
<SettingLabel>
|
||||
Use previous conversations
|
||||
<Tooltip title={CONTEXT_TOOLTIP}>
|
||||
<span>
|
||||
<InfoIcon aria-label={CONTEXT_TOOLTIP} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</SettingLabel>
|
||||
<ToggleSwitch>
|
||||
<Checkbox
|
||||
type="checkbox"
|
||||
checked={useConversationContext}
|
||||
disabled={prefsLoading || contextSaving}
|
||||
onChange={() => {
|
||||
handleConversationContext(!useConversationContext);
|
||||
}}
|
||||
/>
|
||||
<Slider />
|
||||
</ToggleSwitch>
|
||||
</SettingRow>
|
||||
|
||||
{prefsError ? <ErrorText role="alert">{prefsError}</ErrorText> : null}
|
||||
|
||||
</GlassCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -174,8 +174,8 @@ const UsageSummaryCard = (): JSX.Element => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [subscriptionResponse, invoiceResponse] = await Promise.all([
|
||||
axiosInstance.get<SubscriptionMe>('/finance/subscription/'),
|
||||
axiosInstance.get<FinanceInvoice[]>('/finance/invoices/'),
|
||||
axiosInstance.get<SubscriptionMe>('/monetization/subscription/'),
|
||||
axiosInstance.get<FinanceInvoice[]>('/monetization/invoices/'),
|
||||
]);
|
||||
setSubscription(subscriptionResponse.data || null);
|
||||
setHasPortalAccess(
|
||||
@@ -231,7 +231,7 @@ const UsageSummaryCard = (): JSX.Element => {
|
||||
try {
|
||||
const returnUrl = `${window.location.origin}/account/`;
|
||||
const response = await axiosInstance.post<{ portal_url: string }>(
|
||||
'/finance/portal/',
|
||||
'/monetization/portal/',
|
||||
{ return_url: returnUrl }
|
||||
);
|
||||
if (response.data?.portal_url) {
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { MessageContext, MessageProvider } from './MessageContext';
|
||||
import { WebSocketContext } from './WebSocketContext';
|
||||
import { AccountContext } from './AccountContext';
|
||||
import { ConversationContext } from './ConversationContext';
|
||||
import { trackEvent } from '../utils/analytics';
|
||||
|
||||
jest.mock('../../axiosApi', () => ({
|
||||
axiosInstance: {
|
||||
get: jest.fn().mockResolvedValue({ data: [] }),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../utils/analytics', () => ({
|
||||
AnalyticsEvents: {
|
||||
CONVERSATION_CREATED: 'Conversation Created',
|
||||
ACTIVITY_STAGE_COMPLETED: 'Activity Stage Completed',
|
||||
},
|
||||
trackEvent: jest.fn(),
|
||||
}));
|
||||
|
||||
type Capture = { callback: ((message: string) => void) | null };
|
||||
|
||||
const buildHarness = () => {
|
||||
const capture: Capture = { callback: null };
|
||||
const subscribe = (_channel: string, cb: (message: string) => void) => {
|
||||
capture.callback = cb;
|
||||
};
|
||||
const unsubscribe = jest.fn();
|
||||
|
||||
const Harness = ({ reconnectGeneration = 0 }: { reconnectGeneration?: number }) => (
|
||||
<AccountContext.Provider
|
||||
value={{ account: { email: 'test@example.com' } as never, setAccount: () => {} }}
|
||||
>
|
||||
<ConversationContext.Provider
|
||||
value={{
|
||||
conversations: [],
|
||||
setConversations: () => {},
|
||||
selectedConversation: 1,
|
||||
setSelectedConversation: () => {},
|
||||
deleteConversation: () => {},
|
||||
}}
|
||||
>
|
||||
<WebSocketContext.Provider
|
||||
value={[subscribe, unsubscribe, null, jest.fn(), true, 'CONNECTED', reconnectGeneration] as never}
|
||||
>
|
||||
<MessageProvider>
|
||||
<Probe />
|
||||
</MessageProvider>
|
||||
</WebSocketContext.Provider>
|
||||
</ConversationContext.Provider>
|
||||
</AccountContext.Provider>
|
||||
);
|
||||
|
||||
return { Harness, capture };
|
||||
};
|
||||
|
||||
const Probe = () => {
|
||||
const ctx = useContext(MessageContext);
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="stage">{ctx.activityStage ?? 'null'}</span>
|
||||
<span data-testid="label">{ctx.activityLabel ?? 'null'}</span>
|
||||
<span data-testid="detail">{ctx.activityDetail ?? 'null'}</span>
|
||||
<span data-testid="history-count">{ctx.activityHistory.length}</span>
|
||||
<span data-testid="stream-interrupted">{ctx.streamInterrupted ? 'yes' : 'no'}</span>
|
||||
<button type="button" onClick={() => ctx.clearActivity()}>
|
||||
clear
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const send = (capture: Capture, message: string) => {
|
||||
act(() => {
|
||||
capture.callback?.(message);
|
||||
});
|
||||
};
|
||||
|
||||
const sendStatus = (
|
||||
capture: Capture,
|
||||
stage: string,
|
||||
label: string,
|
||||
detail: string | null = null,
|
||||
) => {
|
||||
send(
|
||||
capture,
|
||||
JSON.stringify({ v: 1, type: 'status', data: { stage, label, detail } }),
|
||||
);
|
||||
};
|
||||
|
||||
describe('MessageContext activity status (#96)', () => {
|
||||
beforeEach(() => {
|
||||
(trackEvent as jest.Mock).mockClear();
|
||||
});
|
||||
|
||||
it('applies the first status frame as the current stage/label/detail', async () => {
|
||||
const { Harness, capture } = buildHarness();
|
||||
render(<Harness />);
|
||||
await act(async () => {});
|
||||
|
||||
sendStatus(capture, 'searching', 'Searching the web', 'query: cats');
|
||||
|
||||
expect(screen.getByTestId('stage')).toHaveTextContent('searching');
|
||||
expect(screen.getByTestId('label')).toHaveTextContent('Searching the web');
|
||||
expect(screen.getByTestId('detail')).toHaveTextContent('query: cats');
|
||||
expect(screen.getByTestId('history-count')).toHaveTextContent('0');
|
||||
});
|
||||
|
||||
it('rolls a completed stage into history and tracks ACTIVITY_STAGE_COMPLETED (no label/detail text)', async () => {
|
||||
const { Harness, capture } = buildHarness();
|
||||
render(<Harness />);
|
||||
await act(async () => {});
|
||||
|
||||
sendStatus(capture, 'searching', 'Searching the web');
|
||||
sendStatus(capture, 'reading', 'Reading sources');
|
||||
|
||||
expect(screen.getByTestId('stage')).toHaveTextContent('reading');
|
||||
expect(screen.getByTestId('label')).toHaveTextContent('Reading sources');
|
||||
expect(screen.getByTestId('history-count')).toHaveTextContent('1');
|
||||
|
||||
expect(trackEvent).toHaveBeenCalledWith(
|
||||
'Activity Stage Completed',
|
||||
expect.objectContaining({ stage: 'searching', durationMs: expect.any(Number) }),
|
||||
);
|
||||
const call = (trackEvent as jest.Mock).mock.calls.find(
|
||||
([name]) => name === 'Activity Stage Completed',
|
||||
);
|
||||
expect(Object.keys(call![1])).toEqual(['stage', 'durationMs']);
|
||||
});
|
||||
|
||||
it('clears the label display (but not the stage) once the writing stage starts', async () => {
|
||||
const { Harness, capture } = buildHarness();
|
||||
render(<Harness />);
|
||||
await act(async () => {});
|
||||
|
||||
sendStatus(capture, 'searching', 'Searching the web');
|
||||
sendStatus(capture, 'writing', 'Writing the answer');
|
||||
|
||||
expect(screen.getByTestId('stage')).toHaveTextContent('writing');
|
||||
expect(screen.getByTestId('label')).toHaveTextContent('null');
|
||||
});
|
||||
|
||||
it('clears activity on END_OF_THE_STREAM', async () => {
|
||||
const { Harness, capture } = buildHarness();
|
||||
render(<Harness />);
|
||||
await act(async () => {});
|
||||
|
||||
sendStatus(capture, 'searching', 'Searching the web');
|
||||
sendStatus(capture, 'writing', 'Writing the answer');
|
||||
send(capture, 'END_OF_THE_STREAM_ENDER_GAME_42');
|
||||
|
||||
expect(screen.getByTestId('stage')).toHaveTextContent('null');
|
||||
expect(screen.getByTestId('label')).toHaveTextContent('null');
|
||||
expect(screen.getByTestId('detail')).toHaveTextContent('null');
|
||||
expect(screen.getByTestId('history-count')).toHaveTextContent('0');
|
||||
});
|
||||
|
||||
it('ignores malformed status frames without throwing', async () => {
|
||||
const { Harness, capture } = buildHarness();
|
||||
render(<Harness />);
|
||||
await act(async () => {});
|
||||
|
||||
send(
|
||||
capture,
|
||||
JSON.stringify({ v: 1, type: 'status', data: { stage: 'searching' } }),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('stage')).toHaveTextContent('null');
|
||||
});
|
||||
|
||||
it('clears activity via clearActivity()', async () => {
|
||||
const { Harness, capture } = buildHarness();
|
||||
render(<Harness />);
|
||||
await act(async () => {});
|
||||
|
||||
sendStatus(capture, 'searching', 'Searching the web');
|
||||
expect(screen.getByTestId('stage')).toHaveTextContent('searching');
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'clear' }).click();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('stage')).toHaveTextContent('null');
|
||||
});
|
||||
|
||||
it('clears activity on the streamInterrupted reconnect path', async () => {
|
||||
const { Harness, capture } = buildHarness();
|
||||
const { rerender } = render(<Harness reconnectGeneration={0} />);
|
||||
await act(async () => {});
|
||||
|
||||
// Simulate an in-flight generating turn so the reconnect effect treats it as interrupted.
|
||||
send(capture, 'CONVERSATION_ID');
|
||||
sendStatus(capture, 'searching', 'Searching the web');
|
||||
expect(screen.getByTestId('stage')).toHaveTextContent('searching');
|
||||
|
||||
rerender(<Harness reconnectGeneration={1} />);
|
||||
await act(async () => {});
|
||||
|
||||
expect(screen.getByTestId('stream-interrupted')).toHaveTextContent('yes');
|
||||
expect(screen.getByTestId('stage')).toHaveTextContent('null');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, ReactNode, useContext, useEffect, useRef, useState } from "react";
|
||||
import { createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
import { WebSocketContext } from "./WebSocketContext";
|
||||
import { AccountContext } from "./AccountContext";
|
||||
import { ConversationContext } from "./ConversationContext";
|
||||
@@ -6,7 +6,12 @@ import { ConversationPrompt, ConversationPromptType } from "../data";
|
||||
import { axiosInstance } from "../../axiosApi";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { AnalyticsEvents, trackEvent } from "../utils/analytics";
|
||||
import { parseCitationsData, parseVersionedFrame } from "../utils/wsFrames";
|
||||
import {
|
||||
ActivityHistoryEntry,
|
||||
parseCitationsData,
|
||||
parseStatusData,
|
||||
parseVersionedFrame,
|
||||
} from "../utils/wsFrames";
|
||||
|
||||
type MessageProviderProps ={
|
||||
children? : ReactNode;
|
||||
@@ -21,6 +26,12 @@ type IMessageContext = {
|
||||
/** True when a stream was interrupted by a socket drop; cleared on refetch/retry. */
|
||||
streamInterrupted: boolean;
|
||||
clearStreamInterrupted: () => void;
|
||||
/** Live activity status from versioned "status" WS frames (#96). */
|
||||
activityStage: string | null;
|
||||
activityLabel: string | null;
|
||||
activityDetail: string | null;
|
||||
activityHistory: ActivityHistoryEntry[];
|
||||
clearActivity: () => void;
|
||||
}
|
||||
|
||||
const initialValues = {
|
||||
@@ -31,6 +42,11 @@ const initialValues = {
|
||||
isGeneratingMessage: false,
|
||||
streamInterrupted: false,
|
||||
clearStreamInterrupted: () => {},
|
||||
activityStage: null,
|
||||
activityLabel: null,
|
||||
activityDetail: null,
|
||||
activityHistory: [],
|
||||
clearActivity: () => {},
|
||||
}
|
||||
|
||||
const MessageContext = createContext<IMessageContext>(initialValues);
|
||||
@@ -47,6 +63,10 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
const [conversationDetails, setConversationDetails] = useState<ConversationPrompt[]>([])
|
||||
const [isGeneratingMessage, setIsGeneratingMessage] = useState<boolean>(false)
|
||||
const [streamInterrupted, setStreamInterrupted] = useState<boolean>(false)
|
||||
const [activityStage, setActivityStage] = useState<string | null>(null)
|
||||
const [activityLabel, setActivityLabel] = useState<string | null>(null)
|
||||
const [activityDetail, setActivityDetail] = useState<string | null>(null)
|
||||
const [activityHistory, setActivityHistory] = useState<ActivityHistoryEntry[]>([])
|
||||
|
||||
const messageRef = useRef('')
|
||||
const messageResponsePart = useRef(0);
|
||||
@@ -55,9 +75,19 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
const isGeneratingRef = useRef(false)
|
||||
const prevReconnectGenerationRef = useRef(0)
|
||||
const refetchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
/** Tracks the in-progress activity stage so we can roll it into history with a duration. */
|
||||
const activeStageRef = useRef<{ stage: string; label: string; startedAt: number } | null>(null)
|
||||
|
||||
const clearStreamInterrupted = () => setStreamInterrupted(false)
|
||||
|
||||
const clearActivity = () => {
|
||||
activeStageRef.current = null
|
||||
setActivityStage(null)
|
||||
setActivityLabel(null)
|
||||
setActivityDetail(null)
|
||||
setActivityHistory([])
|
||||
}
|
||||
|
||||
async function GetConversationDetails(conversationId: number | undefined) {
|
||||
if (!conversationId) {
|
||||
setConversationDetails([])
|
||||
@@ -98,7 +128,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
}
|
||||
}
|
||||
|
||||
const schedulePostStreamRefetch = () => {
|
||||
const schedulePostStreamRefetch = useCallback(() => {
|
||||
if (refetchTimerRef.current) clearTimeout(refetchTimerRef.current)
|
||||
const conversationId = selectedConversationRef.current
|
||||
if (!conversationId) return
|
||||
@@ -107,7 +137,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
refetchTimerRef.current = setTimeout(() => {
|
||||
void GetConversationDetails(conversationId)
|
||||
}, 800)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
GetConversationDetails(selectedConversation)
|
||||
@@ -142,6 +172,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
setIsGeneratingMessage(false)
|
||||
setStateMessage('')
|
||||
setStreamInterrupted(true)
|
||||
clearActivity()
|
||||
|
||||
const details = [...conversationRef.current]
|
||||
if (details.length > 0) {
|
||||
@@ -187,8 +218,41 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
conversationRef.current = details
|
||||
setConversationDetails(details)
|
||||
schedulePostStreamRefetch()
|
||||
} else if (frame.type === 'status') {
|
||||
const statusData = parseStatusData(frame.data)
|
||||
if (statusData) {
|
||||
const now = Date.now()
|
||||
const previous = activeStageRef.current
|
||||
if (previous && previous.stage !== statusData.stage) {
|
||||
const durationMs = now - previous.startedAt
|
||||
setActivityHistory((history) => [
|
||||
...history,
|
||||
{
|
||||
stage: previous.stage,
|
||||
label: previous.label,
|
||||
startedAt: previous.startedAt,
|
||||
finishedAt: now,
|
||||
},
|
||||
])
|
||||
trackEvent(AnalyticsEvents.ACTIVITY_STAGE_COMPLETED, {
|
||||
stage: previous.stage,
|
||||
durationMs,
|
||||
})
|
||||
}
|
||||
activeStageRef.current = {
|
||||
stage: statusData.stage,
|
||||
label: statusData.label,
|
||||
startedAt: previous && previous.stage === statusData.stage
|
||||
? previous.startedAt
|
||||
: now,
|
||||
}
|
||||
setActivityStage(statusData.stage)
|
||||
// Tokens take over the label once the model starts writing.
|
||||
setActivityLabel(statusData.stage === 'writing' ? null : statusData.label)
|
||||
setActivityDetail(statusData.detail ?? null)
|
||||
}
|
||||
}
|
||||
// status / unknown types: no-op (compatible with #96)
|
||||
// other unknown types: no-op
|
||||
return
|
||||
}
|
||||
|
||||
@@ -208,6 +272,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
isGeneratingRef.current = false
|
||||
setIsGeneratingMessage(false)
|
||||
setStreamInterrupted(false)
|
||||
clearActivity()
|
||||
schedulePostStreamRefetch()
|
||||
}
|
||||
else if (message === 'START_OF_THE_STREAM_ENDER_GAME_42'){
|
||||
@@ -251,7 +316,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
/* unsubscribe from channel during cleanup */
|
||||
unsubscribe(channelName)
|
||||
}
|
||||
}, [account, subscribe, unsubscribe, conversationDetails, selectedConversation, setSelectedConversation])
|
||||
}, [account, subscribe, unsubscribe, conversationDetails, selectedConversation, setSelectedConversation, schedulePostStreamRefetch])
|
||||
|
||||
|
||||
return(
|
||||
@@ -263,6 +328,11 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
isGeneratingMessage,
|
||||
streamInterrupted,
|
||||
clearStreamInterrupted,
|
||||
activityStage,
|
||||
activityLabel,
|
||||
activityDetail,
|
||||
activityHistory,
|
||||
clearActivity,
|
||||
}}>
|
||||
{children}
|
||||
</MessageContext.Provider>
|
||||
|
||||
@@ -269,6 +269,7 @@ export interface AccountType {
|
||||
|
||||
export interface PreferencesType {
|
||||
order: boolean;
|
||||
use_conversation_context?: boolean;
|
||||
}
|
||||
|
||||
export class Account {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { planAllowsRag, SubscriptionMe } from '../utils/finance';
|
||||
|
||||
/**
|
||||
* Module-level cache so every component that calls useSubscription() during the
|
||||
* same session shares one /finance/subscription/ request instead of each
|
||||
* same session shares one /monetization/subscription/ request instead of each
|
||||
* mounting its own (e.g. Header2 + DocumentStoragePage on the same page).
|
||||
*/
|
||||
let cachedSubscription: SubscriptionMe | null = null;
|
||||
@@ -18,7 +18,7 @@ async function loadSubscription(): Promise<SubscriptionMe | null> {
|
||||
}
|
||||
if (!inFlightRequest) {
|
||||
inFlightRequest = axiosInstance
|
||||
.get<SubscriptionMe>('/finance/subscription/')
|
||||
.get<SubscriptionMe>('/monetization/subscription/')
|
||||
.then((response: AxiosResponse<SubscriptionMe>) => {
|
||||
cachedSubscription = response.data || null;
|
||||
return cachedSubscription;
|
||||
@@ -44,7 +44,7 @@ export type UseSubscriptionResult = {
|
||||
refresh: () => Promise<void>;
|
||||
};
|
||||
|
||||
/** Fetches /finance/subscription/ once per session (shared across callers) and exposes plan-derived flags. */
|
||||
/** Fetches /monetization/subscription/ once per session (shared across callers) and exposes plan-derived flags. */
|
||||
export function useSubscription(): UseSubscriptionResult {
|
||||
const { authenticated } = useContext(AuthContext);
|
||||
const [subscription, setSubscription] = useState<SubscriptionMe | null>(cachedSubscription);
|
||||
|
||||
@@ -252,32 +252,6 @@ const IconButton = styled.button`
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledSelect = styled.select`
|
||||
background: transparent;
|
||||
border: none;
|
||||
flex-shrink: 0;
|
||||
max-width: 7rem;
|
||||
color: ${({ theme }) => theme.colors.text};
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
margin-right: 0.5rem;
|
||||
border-right: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
||||
|
||||
@media (max-width: 768px) {
|
||||
max-width: 5.5rem;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.4rem 0.2rem;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
option {
|
||||
background: ${({ theme }) => theme.colors.background || '#1a1a1a'};
|
||||
color: ${({ theme }) => theme.colors.text};
|
||||
}
|
||||
`;
|
||||
|
||||
const ConversationItem = styled.div<{ $active: boolean }>`
|
||||
padding: 0.8rem 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
@@ -343,11 +317,13 @@ const validationSchema = Yup.object().shape({
|
||||
.required("This is required"),
|
||||
});
|
||||
|
||||
/** Fixed until product re-exposes a model picker (#106). */
|
||||
const DEFAULT_MODEL_NAME = "THINKING";
|
||||
|
||||
type PromptValues = {
|
||||
prompt: string;
|
||||
file: Blob | null;
|
||||
fileType: string | null;
|
||||
modelName: string;
|
||||
};
|
||||
|
||||
const AlwaysScrollToBottom = ({ trigger }: { trigger: string | number }): JSX.Element => {
|
||||
@@ -370,6 +346,11 @@ const AsyncDashboardInner = (): JSX.Element => {
|
||||
setConversationDetails,
|
||||
stateMessage,
|
||||
streamInterrupted,
|
||||
activityStage,
|
||||
activityLabel,
|
||||
activityDetail,
|
||||
activityHistory,
|
||||
clearActivity,
|
||||
} = useContext(MessageContext);
|
||||
|
||||
const conversationRef = useRef(conversationDetails);
|
||||
@@ -442,7 +423,7 @@ const AsyncDashboardInner = (): JSX.Element => {
|
||||
})();
|
||||
|
||||
const handlePromptSubmit = async (
|
||||
{ prompt, file, fileType, modelName }: PromptValues,
|
||||
{ prompt, file, fileType }: PromptValues,
|
||||
{ resetForm }: any,
|
||||
): Promise<void> => {
|
||||
const trimmedPrompt = (prompt ?? "").trim();
|
||||
@@ -450,6 +431,7 @@ const AsyncDashboardInner = (): JSX.Element => {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
clearActivity();
|
||||
const tempConversations: ConversationPrompt[] = [
|
||||
...conversationDetails,
|
||||
new ConversationPrompt({ message: trimmedPrompt, user_created: true }),
|
||||
@@ -458,7 +440,7 @@ const AsyncDashboardInner = (): JSX.Element => {
|
||||
|
||||
conversationRef.current = tempConversations;
|
||||
setConversationDetails(tempConversations);
|
||||
sendMessage(trimmedPrompt, selectedConversation, file, fileType, modelName);
|
||||
sendMessage(trimmedPrompt, selectedConversation, file, fileType, DEFAULT_MODEL_NAME);
|
||||
trackEvent(AnalyticsEvents.MESSAGE_SENT, {
|
||||
hasConversation: Boolean(selectedConversation),
|
||||
hasAttachment: Boolean(file),
|
||||
@@ -468,7 +450,6 @@ const AsyncDashboardInner = (): JSX.Element => {
|
||||
prompt: "",
|
||||
file: null,
|
||||
fileType: null,
|
||||
modelName: modelName, // Keep the selected model
|
||||
}
|
||||
});
|
||||
|
||||
@@ -569,7 +550,6 @@ const AsyncDashboardInner = (): JSX.Element => {
|
||||
{(
|
||||
[
|
||||
['pdf', 'PDF'],
|
||||
['docx', 'Word (.docx)'],
|
||||
['csv', 'CSV'],
|
||||
['xlsx', 'Excel (.xlsx)'],
|
||||
['txt', 'Plain text'],
|
||||
@@ -606,6 +586,11 @@ const AsyncDashboardInner = (): JSX.Element => {
|
||||
onRatingChange={(rating) =>
|
||||
updatePromptRating(convo_detail.id, rating)
|
||||
}
|
||||
activityStage={isLiveStream ? activityStage : null}
|
||||
activityLabel={isLiveStream ? activityLabel : null}
|
||||
activityDetail={isLiveStream ? activityDetail : null}
|
||||
activityHistory={isLiveStream ? activityHistory : []}
|
||||
activityInterrupted={isLiveStream && streamInterrupted}
|
||||
/>
|
||||
);
|
||||
})
|
||||
@@ -630,7 +615,6 @@ const AsyncDashboardInner = (): JSX.Element => {
|
||||
prompt: "",
|
||||
file: null,
|
||||
fileType: null,
|
||||
modelName: "FAST",
|
||||
}}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={handlePromptSubmit}
|
||||
@@ -681,14 +665,6 @@ const AsyncDashboardInner = (): JSX.Element => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Field
|
||||
as={StyledSelect}
|
||||
name="modelName"
|
||||
>
|
||||
<option value="FAST">FAST</option>
|
||||
<option value="THINKING">THINKING</option>
|
||||
</Field>
|
||||
|
||||
<Field name="prompt">
|
||||
{({ field }: any) => (
|
||||
<StyledInput
|
||||
|
||||
@@ -133,7 +133,7 @@ describe('AuthCallback', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
'/finance/checkout/',
|
||||
'/monetization/checkout/',
|
||||
expect.objectContaining({
|
||||
success_url: expect.stringContaining('/billing/success'),
|
||||
cancel_url: expect.stringContaining('/billing/cancel'),
|
||||
|
||||
@@ -181,7 +181,7 @@ const AuthCallback = (): JSX.Element => {
|
||||
setStatusText('Starting checkout…');
|
||||
const { success_url, cancel_url } = checkoutReturnUrls();
|
||||
trackEvent(AnalyticsEvents.CHECKOUT_STARTED, { source: 'sso_signup' });
|
||||
const checkoutResponse = await axiosInstance.post('/finance/checkout/', {
|
||||
const checkoutResponse = await axiosInstance.post('/monetization/checkout/', {
|
||||
success_url,
|
||||
cancel_url,
|
||||
});
|
||||
|
||||
@@ -60,7 +60,7 @@ const subscriptionResponse = (rag: boolean) => ({
|
||||
|
||||
const mockGetByUrl = (overrides: { rag: boolean }) => {
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/finance/subscription/') {
|
||||
if (url === '/monetization/subscription/') {
|
||||
return Promise.resolve(subscriptionResponse(overrides.rag));
|
||||
}
|
||||
if (typeof url === 'string' && url.startsWith('/documents/')) {
|
||||
|
||||
@@ -139,7 +139,7 @@ describe('SignUp', () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPost).toHaveBeenCalledWith('/finance/checkout/', {
|
||||
expect(mockPost).toHaveBeenCalledWith('/monetization/checkout/', {
|
||||
success_url: 'http://localhost/billing/success?session_id={CHECKOUT_SESSION_ID}',
|
||||
cancel_url: 'http://localhost/billing/cancel',
|
||||
});
|
||||
|
||||
@@ -271,7 +271,7 @@ const SignUp = (): JSX.Element => {
|
||||
|
||||
const { success_url, cancel_url } = checkoutReturnUrls();
|
||||
trackEvent(AnalyticsEvents.CHECKOUT_STARTED, { source: 'signup' });
|
||||
const checkoutResponse = await axiosInstance.post('/finance/checkout/', {
|
||||
const checkoutResponse = await axiosInstance.post('/monetization/checkout/', {
|
||||
success_url,
|
||||
cancel_url,
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ const queue: QueuedCall[] = [];
|
||||
* | Subscription Cancel Started | Cancel intent (#75) |
|
||||
* | Account Delete Started / Success / Failed | Self-delete (#34 companion) |
|
||||
* | Message Copied / Rated / Rating Reason / Exported | Message actions (#97) |
|
||||
* | Activity Stage Completed | Live activity status stage rolled over (#96) |
|
||||
*/
|
||||
export const AnalyticsEvents = {
|
||||
LOGIN_SUCCESS: 'Login Success',
|
||||
@@ -51,6 +52,7 @@ export const AnalyticsEvents = {
|
||||
MESSAGE_RATED: 'Message Rated',
|
||||
MESSAGE_RATING_REASON: 'Message Rating Reason',
|
||||
MESSAGE_EXPORTED: 'Message Exported',
|
||||
ACTIVITY_STAGE_COMPLETED: 'Activity Stage Completed',
|
||||
} as const;
|
||||
|
||||
export type AnalyticsEventName = (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents];
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type MdBlock,
|
||||
} from './markdownUtils';
|
||||
|
||||
export type ExportFormat = 'pdf' | 'docx' | 'csv' | 'xlsx' | 'txt';
|
||||
export type ExportFormat = 'pdf' | 'csv' | 'xlsx' | 'txt';
|
||||
|
||||
export type ExportTurn = {
|
||||
role: 'user' | 'assistant';
|
||||
@@ -52,7 +52,7 @@ function citationFootnotes(citations?: Citation[]): string {
|
||||
|
||||
export async function exportChat(options: ExportOptions): Promise<void> {
|
||||
const { format, scope, title, turns } = options;
|
||||
const filename = buildExportFilename(title, format === 'docx' ? 'docx' : format);
|
||||
const filename = buildExportFilename(title, format);
|
||||
const exportedAt = new Date().toISOString();
|
||||
|
||||
// Yield so large conversations don't freeze the main thread (#97).
|
||||
@@ -71,9 +71,6 @@ export async function exportChat(options: ExportOptions): Promise<void> {
|
||||
case 'pdf':
|
||||
await exportPdf(title, turns, exportedAt, filename);
|
||||
break;
|
||||
case 'docx':
|
||||
await exportDocx(title, turns, exportedAt, filename);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported export format: ${format}`);
|
||||
}
|
||||
@@ -304,169 +301,6 @@ async function exportPdf(
|
||||
});
|
||||
}
|
||||
|
||||
async function exportDocx(
|
||||
title: string,
|
||||
turns: ExportTurn[],
|
||||
exportedAt: string,
|
||||
filename: string,
|
||||
): Promise<void> {
|
||||
const {
|
||||
Document,
|
||||
Packer,
|
||||
Paragraph,
|
||||
TextRun,
|
||||
HeadingLevel,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
WidthType,
|
||||
Header,
|
||||
} = await import('docx');
|
||||
|
||||
const children: InstanceType<typeof Paragraph | typeof Table>[] = [
|
||||
new Paragraph({
|
||||
text: title,
|
||||
heading: HeadingLevel.TITLE,
|
||||
}),
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({ text: `Exported ${exportedAt}`, italics: true, size: 18, color: '666666' }),
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
for (let i = 0; i < turns.length; i += 1) {
|
||||
if (i > 0 && i % 20 === 0) await yieldToUi();
|
||||
const turn = turns[i];
|
||||
if (turns.length > 1) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: `${roleLabel(turn.role)}${turn.timestamp ? ` · ${formatTs(turn.timestamp)}` : ''}`,
|
||||
bold: true,
|
||||
}),
|
||||
],
|
||||
spacing: { before: 240 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const block of parseMarkdownBlocks(turn.message)) {
|
||||
switch (block.type) {
|
||||
case 'heading': {
|
||||
const level =
|
||||
block.level === 1
|
||||
? HeadingLevel.HEADING_1
|
||||
: block.level === 2
|
||||
? HeadingLevel.HEADING_2
|
||||
: HeadingLevel.HEADING_3;
|
||||
children.push(new Paragraph({ text: block.text, heading: level }));
|
||||
break;
|
||||
}
|
||||
case 'paragraph':
|
||||
children.push(new Paragraph({ text: block.text }));
|
||||
break;
|
||||
case 'code':
|
||||
children.push(
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: block.text, font: 'Courier New', size: 18 })],
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case 'list':
|
||||
block.items.forEach((item, idx) => {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
text: block.ordered ? `${idx + 1}. ${item}` : `• ${item}`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
break;
|
||||
case 'table':
|
||||
children.push(
|
||||
new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
rows: [
|
||||
new TableRow({
|
||||
children: block.headers.map(
|
||||
(h) =>
|
||||
new TableCell({
|
||||
children: [
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: h, bold: true })],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
),
|
||||
}),
|
||||
...block.rows.map(
|
||||
(row) =>
|
||||
new TableRow({
|
||||
children: block.headers.map(
|
||||
(_, col) =>
|
||||
new TableCell({
|
||||
children: [new Paragraph({ text: row[col] ?? '' })],
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
],
|
||||
}),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (turn.citations?.length) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
text: 'Sources',
|
||||
heading: HeadingLevel.HEADING_3,
|
||||
}),
|
||||
);
|
||||
turn.citations
|
||||
.slice()
|
||||
.sort((a, b) => a.index - b.index)
|
||||
.forEach((c) => {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
text: `[${c.index}] ${c.title}${c.url ? ` — ${c.url}` : ''}`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const doc = new Document({
|
||||
sections: [
|
||||
{
|
||||
headers: {
|
||||
default: new Header({
|
||||
children: [
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: `${title} · ${exportedAt}`,
|
||||
size: 16,
|
||||
color: '888888',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
children,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const blob = await Packer.toBlob(doc);
|
||||
downloadBlob(blob, filename);
|
||||
}
|
||||
|
||||
/** Decide CSV/XLSX matrix strategy — exported for unit tests. */
|
||||
export function selectTabularStrategy(
|
||||
scope: ExportScope,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
parseCitationsData,
|
||||
parseStatusData,
|
||||
parseVersionedFrame,
|
||||
} from './wsFrames';
|
||||
|
||||
@@ -49,4 +50,49 @@ describe('wsFrames', () => {
|
||||
expect(parseCitationsData(null)).toEqual([]);
|
||||
expect(parseCitationsData('nope')).toEqual([]);
|
||||
});
|
||||
|
||||
it('parses a status frame', () => {
|
||||
const raw = JSON.stringify({
|
||||
v: 1,
|
||||
type: 'status',
|
||||
data: { stage: 'searching', label: 'Searching the web', detail: 'query: cats' },
|
||||
});
|
||||
const frame = parseVersionedFrame(raw);
|
||||
expect(frame?.type).toBe('status');
|
||||
expect(parseStatusData(frame!.data)).toEqual({
|
||||
stage: 'searching',
|
||||
label: 'Searching the web',
|
||||
detail: 'query: cats',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses status data with no detail as null', () => {
|
||||
expect(parseStatusData({ stage: 'writing', label: 'Writing the answer' })).toEqual({
|
||||
stage: 'writing',
|
||||
label: 'Writing the answer',
|
||||
detail: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes through unknown/future stage values verbatim', () => {
|
||||
expect(
|
||||
parseStatusData({ stage: 'some_future_stage', label: 'Doing something new' }),
|
||||
).toEqual({
|
||||
stage: 'some_future_stage',
|
||||
label: 'Doing something new',
|
||||
detail: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for malformed status data without throwing', () => {
|
||||
expect(parseStatusData(null)).toBeNull();
|
||||
expect(parseStatusData(undefined)).toBeNull();
|
||||
expect(parseStatusData('nope')).toBeNull();
|
||||
expect(parseStatusData([])).toBeNull();
|
||||
expect(parseStatusData({})).toBeNull();
|
||||
expect(parseStatusData({ stage: 'searching' })).toBeNull();
|
||||
expect(parseStatusData({ label: 'Searching the web' })).toBeNull();
|
||||
expect(parseStatusData({ stage: 123, label: 'Searching' })).toBeNull();
|
||||
expect(parseStatusData({ stage: 'searching', label: 42 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,6 +39,29 @@ export function parseVersionedFrame(raw: string): VersionedFrame | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export type StatusFrameData = {
|
||||
stage: string;
|
||||
label: string;
|
||||
detail?: string | null;
|
||||
};
|
||||
|
||||
export type ActivityHistoryEntry = {
|
||||
stage: string;
|
||||
label: string;
|
||||
startedAt: number;
|
||||
finishedAt: number;
|
||||
};
|
||||
|
||||
export function parseStatusData(data: unknown): StatusFrameData | null {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
const obj = data as Record<string, unknown>;
|
||||
if (typeof obj.stage !== 'string' || !obj.stage) return null;
|
||||
// label may be empty (e.g. stage "writing" — tokens take over)
|
||||
if (typeof obj.label !== 'string') return null;
|
||||
const detail = typeof obj.detail === 'string' ? obj.detail : null;
|
||||
return { stage: obj.stage, label: obj.label, detail };
|
||||
}
|
||||
|
||||
export function parseCitationsData(data: unknown): Citation[] {
|
||||
if (!Array.isArray(data)) return [];
|
||||
return data
|
||||
|
||||
@@ -3,3 +3,16 @@
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
// Capacitor plugin ships ESM; Jest cannot parse it when AccountContext
|
||||
// (or pages that import it) pull in utils/revenueCat. Stub globally.
|
||||
jest.mock('@revenuecat/purchases-capacitor', () => ({
|
||||
Purchases: {
|
||||
configure: jest.fn(() => Promise.resolve()),
|
||||
logIn: jest.fn(() => Promise.resolve({ customerInfo: {} })),
|
||||
logOut: jest.fn(() => Promise.resolve({ customerInfo: {} })),
|
||||
getOfferings: jest.fn(() => Promise.resolve({ current: null, all: {} })),
|
||||
purchasePackage: jest.fn(() => Promise.resolve({ customerInfo: {} })),
|
||||
restorePurchases: jest.fn(() => Promise.resolve({ customerInfo: {} })),
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user