feat(playground/prompts): enable fulltext search across message windows (#12578)

* feat(playground): enable fulltext search across message windows

* up libs

* fix dep

* fix lock

* fix dependencies

* refactor search to be usable in prompts too

* fix controller
This commit is contained in:
Nimar
2026-03-13 08:06:43 +00:00
committed by GitHub
parent 6a82c24134
commit d5aa2a057c
14 changed files with 1266 additions and 233 deletions
+62 -116
View File
@@ -366,14 +366,17 @@ importers:
specifier: ^6.0.2
version: 6.0.2
'@codemirror/language':
specifier: ^6.11.3
version: 6.11.3
specifier: ^6.12.2
version: 6.12.2
'@codemirror/lint':
specifier: ^6.9.2
version: 6.9.2
specifier: ^6.9.5
version: 6.9.5
'@codemirror/search':
specifier: ^6.6.0
version: 6.6.0
'@codemirror/state':
specifier: ^6.5.2
version: 6.5.2
specifier: ^6.6.0
version: 6.6.0
'@dnd-kit/core':
specifier: ^6.1.0
version: 6.1.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -580,10 +583,10 @@ importers:
version: 11.8.0(typescript@5.9.2)
'@uiw/codemirror-themes':
specifier: ^4.23.7
version: 4.23.7(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.8)
version: 4.23.7(@codemirror/language@6.12.2)(@codemirror/state@6.6.0)(@codemirror/view@6.38.8)
'@uiw/react-codemirror':
specifier: ^4.25.4
version: 4.25.4(@babel/runtime@7.28.6)(@codemirror/autocomplete@6.17.0(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.8)(@lezer/common@1.5.1))(@codemirror/language@6.11.3)(@codemirror/lint@6.9.2)(@codemirror/search@6.5.6)(@codemirror/state@6.5.2)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.38.8)(codemirror@6.0.1(@lezer/common@1.5.1))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
specifier: ^4.25.8
version: 4.25.8(@babel/runtime@7.28.6)(@codemirror/autocomplete@6.17.0(@codemirror/language@6.12.2)(@codemirror/state@6.6.0)(@codemirror/view@6.38.8)(@lezer/common@1.5.1))(@codemirror/language@6.12.2)(@codemirror/lint@6.9.5)(@codemirror/search@6.6.0)(@codemirror/state@6.6.0)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.38.8)(codemirror@6.0.1(@lezer/common@1.5.1))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
ai:
specifier: ^3.4.9
version: 3.4.9(openai@4.104.0(zod@3.25.62))(react@19.2.3)(solid-js@1.8.18)(sswr@2.1.0(svelte@4.2.19))(svelte@4.2.19)(vue@3.4.27(typescript@5.9.2))(zod@3.25.62)
@@ -2066,35 +2069,23 @@ packages:
'@codemirror/view': ^6.0.0
'@lezer/common': ^1.0.0
'@codemirror/commands@6.10.0':
resolution: {integrity: sha512-2xUIc5mHXQzT16JnyOFkh8PvfeXuIut3pslWGfsGOhxP/lpgRm9HOl/mpzLErgt5mXDovqA0d11P21gofRLb9w==}
'@codemirror/commands@6.10.1':
resolution: {integrity: sha512-uWDWFypNdQmz2y1LaNJzK7fL7TYKLeUAU0npEC685OKTF3KcQ2Vu3klIM78D7I6wGhktme0lh3CuQLv0ZCrD9Q==}
'@codemirror/lang-json@6.0.2':
resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==}
'@codemirror/language@6.11.3':
resolution: {integrity: sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==}
'@codemirror/language@6.12.2':
resolution: {integrity: sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg==}
'@codemirror/language@6.12.1':
resolution: {integrity: sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==}
'@codemirror/lint@6.9.5':
resolution: {integrity: sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==}
'@codemirror/lint@6.9.2':
resolution: {integrity: sha512-sv3DylBiIyi+xKwRCJAAsBZZZWo82shJ/RTMymLabAdtbkV5cSKwWDeCgtUq3v8flTaXS2y1kKkICuRYtUswyQ==}
'@codemirror/search@6.6.0':
resolution: {integrity: sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==}
'@codemirror/lint@6.9.3':
resolution: {integrity: sha512-y3YkYhdnhjDBAe0VIA0c4wVoFOvnp8CnAvfLqi0TqotIv92wIlAAP7HELOpLBsKwjAX6W92rSflA6an/2zBvXw==}
'@codemirror/search@6.5.6':
resolution: {integrity: sha512-rpMgcsh7o0GuCDUXKPvww+muLA1pDJaFrpq/CCHtpQJYz8xopu4D1hPcKRoDD0YlF8gZaqTNIRa4VRBWyhyy7Q==}
'@codemirror/state@6.5.2':
resolution: {integrity: sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==}
'@codemirror/state@6.5.4':
resolution: {integrity: sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==}
'@codemirror/state@6.6.0':
resolution: {integrity: sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==}
'@codemirror/theme-one-dark@6.1.2':
resolution: {integrity: sha512-F+sH0X16j/qFLMAfbciKTxVOwkdAS336b7AXTKOZhy8BR3eH/RelsnLgLFINrpST63mmN2OuwUt0W2ndUgYwUA==}
@@ -3132,9 +3123,6 @@ packages:
peerDependencies:
'@langchain/core': ^1.0.0
'@lezer/common@1.4.0':
resolution: {integrity: sha512-DVeMRoGrgn/k45oQNu189BoW4SZwgZFzJ1+1TV5j2NJ/KFC83oa/enRqZSGshyeMk5cPWMhsKs9nx+8o0unwGg==}
'@lezer/common@1.5.1':
resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==}
@@ -3144,9 +3132,6 @@ packages:
'@lezer/json@1.0.3':
resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==}
'@lezer/lr@1.4.4':
resolution: {integrity: sha512-LHL17Mq0OcFXm1pGQssuGTQFPPdxARjKM8f7GA5+sGtHi0K3R84YaSbmche0+RKWHnCsx9asEe5OWOI4FHfe4A==}
'@lezer/lr@1.4.8':
resolution: {integrity: sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==}
@@ -6213,8 +6198,8 @@ packages:
resolution: {integrity: sha512-/G74DRDNrq2pkBjne3aGzycdy2bKa2Hs4DrkLMRq+cDODjyqvHJIZV7hjRRYph3cvGnd0bFd5G6LE5zNp+ap8w==}
hasBin: true
'@uiw/codemirror-extensions-basic-setup@4.25.4':
resolution: {integrity: sha512-YzNwkm0AbPv1EXhCHYR5v0nqfemG2jEB0Z3Att4rBYqKrlG7AA9Rhjc3IyBaOzsBu18wtrp9/+uhTyu7TXSRng==}
'@uiw/codemirror-extensions-basic-setup@4.25.8':
resolution: {integrity: sha512-9Rr+liiBmK4xzZHszL+twNRJApthqmITBwDP3emNTtTrkBFN4gHlqfp+nodKmoVt1+bUH1qQCtyqt+7dbDTHiw==}
peerDependencies:
'@codemirror/autocomplete': '>=6.0.0'
'@codemirror/commands': '>=6.0.0'
@@ -6231,8 +6216,8 @@ packages:
'@codemirror/state': '>=6.0.0'
'@codemirror/view': '>=6.0.0'
'@uiw/react-codemirror@4.25.4':
resolution: {integrity: sha512-ipO067oyfUw+DVaXhQCxkB0ZD9b7RnY+ByrprSYSKCHaULvJ3sqWYC/Zen6zVQ8/XC4o5EPBfatGiX20kC7XGA==}
'@uiw/react-codemirror@4.25.8':
resolution: {integrity: sha512-A0aLOuJZm2yJ+U9GlMFwxwFciztjd5LhcAG4SMqFxdD58wH+sCQXuY4UU5J2hqgS390qAlShtUgREvJPUonbuQ==}
peerDependencies:
'@babel/runtime': '>=7.11.0'
'@codemirror/state': '>=6.0.0'
@@ -14872,93 +14857,60 @@ snapshots:
dependencies:
'@clickhouse/client-common': 1.13.0
'@codemirror/autocomplete@6.17.0(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.8)(@lezer/common@1.5.1)':
'@codemirror/autocomplete@6.17.0(@codemirror/language@6.12.2)(@codemirror/state@6.6.0)(@codemirror/view@6.38.8)(@lezer/common@1.5.1)':
dependencies:
'@codemirror/language': 6.11.3
'@codemirror/state': 6.5.2
'@codemirror/language': 6.12.2
'@codemirror/state': 6.6.0
'@codemirror/view': 6.38.8
'@lezer/common': 1.5.1
'@codemirror/autocomplete@6.17.0(@codemirror/language@6.12.1)(@codemirror/state@6.5.4)(@codemirror/view@6.38.8)(@lezer/common@1.5.1)':
dependencies:
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@codemirror/view': 6.38.8
'@lezer/common': 1.5.1
'@codemirror/commands@6.10.0':
dependencies:
'@codemirror/language': 6.11.3
'@codemirror/state': 6.5.2
'@codemirror/view': 6.38.8
'@lezer/common': 1.4.0
'@codemirror/commands@6.10.1':
dependencies:
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@codemirror/language': 6.12.2
'@codemirror/state': 6.6.0
'@codemirror/view': 6.38.8
'@lezer/common': 1.5.1
'@codemirror/lang-json@6.0.2':
dependencies:
'@codemirror/language': 6.11.3
'@codemirror/language': 6.12.2
'@lezer/json': 1.0.3
'@codemirror/language@6.11.3':
'@codemirror/language@6.12.2':
dependencies:
'@codemirror/state': 6.5.2
'@codemirror/view': 6.38.8
'@lezer/common': 1.4.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.4
style-mod: 4.1.3
'@codemirror/language@6.12.1':
dependencies:
'@codemirror/state': 6.5.4
'@codemirror/state': 6.6.0
'@codemirror/view': 6.38.8
'@lezer/common': 1.5.1
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
style-mod: 4.1.3
'@codemirror/lint@6.9.2':
'@codemirror/lint@6.9.5':
dependencies:
'@codemirror/state': 6.5.2
'@codemirror/state': 6.6.0
'@codemirror/view': 6.38.8
crelt: 1.0.6
'@codemirror/lint@6.9.3':
'@codemirror/search@6.6.0':
dependencies:
'@codemirror/state': 6.5.4
'@codemirror/state': 6.6.0
'@codemirror/view': 6.38.8
crelt: 1.0.6
'@codemirror/search@6.5.6':
dependencies:
'@codemirror/state': 6.5.4
'@codemirror/view': 6.38.8
crelt: 1.0.6
'@codemirror/state@6.5.2':
dependencies:
'@marijn/find-cluster-break': 1.0.2
'@codemirror/state@6.5.4':
'@codemirror/state@6.6.0':
dependencies:
'@marijn/find-cluster-break': 1.0.2
'@codemirror/theme-one-dark@6.1.2':
dependencies:
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@codemirror/language': 6.12.2
'@codemirror/state': 6.6.0
'@codemirror/view': 6.38.8
'@lezer/highlight': 1.2.3
'@codemirror/view@6.38.8':
dependencies:
'@codemirror/state': 6.5.2
'@codemirror/state': 6.6.0
crelt: 1.0.6
style-mod: 4.1.3
w3c-keyname: 2.2.8
@@ -16119,23 +16071,17 @@ snapshots:
transitivePeerDependencies:
- ws
'@lezer/common@1.4.0': {}
'@lezer/common@1.5.1': {}
'@lezer/highlight@1.2.3':
dependencies:
'@lezer/common': 1.4.0
'@lezer/common': 1.5.1
'@lezer/json@1.0.3':
dependencies:
'@lezer/common': 1.4.0
'@lezer/common': 1.5.1
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.4
'@lezer/lr@1.4.4':
dependencies:
'@lezer/common': 1.4.0
'@lezer/lr': 1.4.8
'@lezer/lr@1.4.8':
dependencies:
@@ -19863,30 +19809,30 @@ snapshots:
'@typescript/native-preview-win32-arm64': 7.0.0-dev.20260122.3
'@typescript/native-preview-win32-x64': 7.0.0-dev.20260122.3
'@uiw/codemirror-extensions-basic-setup@4.25.4(@codemirror/autocomplete@6.17.0(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.8)(@lezer/common@1.5.1))(@codemirror/commands@6.10.0)(@codemirror/language@6.11.3)(@codemirror/lint@6.9.2)(@codemirror/search@6.5.6)(@codemirror/state@6.5.2)(@codemirror/view@6.38.8)':
'@uiw/codemirror-extensions-basic-setup@4.25.8(@codemirror/autocomplete@6.17.0(@codemirror/language@6.12.2)(@codemirror/state@6.6.0)(@codemirror/view@6.38.8)(@lezer/common@1.5.1))(@codemirror/commands@6.10.1)(@codemirror/language@6.12.2)(@codemirror/lint@6.9.5)(@codemirror/search@6.6.0)(@codemirror/state@6.6.0)(@codemirror/view@6.38.8)':
dependencies:
'@codemirror/autocomplete': 6.17.0(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.8)(@lezer/common@1.5.1)
'@codemirror/commands': 6.10.0
'@codemirror/language': 6.11.3
'@codemirror/lint': 6.9.2
'@codemirror/search': 6.5.6
'@codemirror/state': 6.5.2
'@codemirror/autocomplete': 6.17.0(@codemirror/language@6.12.2)(@codemirror/state@6.6.0)(@codemirror/view@6.38.8)(@lezer/common@1.5.1)
'@codemirror/commands': 6.10.1
'@codemirror/language': 6.12.2
'@codemirror/lint': 6.9.5
'@codemirror/search': 6.6.0
'@codemirror/state': 6.6.0
'@codemirror/view': 6.38.8
'@uiw/codemirror-themes@4.23.7(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.8)':
'@uiw/codemirror-themes@4.23.7(@codemirror/language@6.12.2)(@codemirror/state@6.6.0)(@codemirror/view@6.38.8)':
dependencies:
'@codemirror/language': 6.11.3
'@codemirror/state': 6.5.2
'@codemirror/language': 6.12.2
'@codemirror/state': 6.6.0
'@codemirror/view': 6.38.8
'@uiw/react-codemirror@4.25.4(@babel/runtime@7.28.6)(@codemirror/autocomplete@6.17.0(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.8)(@lezer/common@1.5.1))(@codemirror/language@6.11.3)(@codemirror/lint@6.9.2)(@codemirror/search@6.5.6)(@codemirror/state@6.5.2)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.38.8)(codemirror@6.0.1(@lezer/common@1.5.1))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
'@uiw/react-codemirror@4.25.8(@babel/runtime@7.28.6)(@codemirror/autocomplete@6.17.0(@codemirror/language@6.12.2)(@codemirror/state@6.6.0)(@codemirror/view@6.38.8)(@lezer/common@1.5.1))(@codemirror/language@6.12.2)(@codemirror/lint@6.9.5)(@codemirror/search@6.6.0)(@codemirror/state@6.6.0)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.38.8)(codemirror@6.0.1(@lezer/common@1.5.1))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@babel/runtime': 7.28.6
'@codemirror/commands': 6.10.0
'@codemirror/state': 6.5.2
'@codemirror/commands': 6.10.1
'@codemirror/state': 6.6.0
'@codemirror/theme-one-dark': 6.1.2
'@codemirror/view': 6.38.8
'@uiw/codemirror-extensions-basic-setup': 4.25.4(@codemirror/autocomplete@6.17.0(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.8)(@lezer/common@1.5.1))(@codemirror/commands@6.10.0)(@codemirror/language@6.11.3)(@codemirror/lint@6.9.2)(@codemirror/search@6.5.6)(@codemirror/state@6.5.2)(@codemirror/view@6.38.8)
'@uiw/codemirror-extensions-basic-setup': 4.25.8(@codemirror/autocomplete@6.17.0(@codemirror/language@6.12.2)(@codemirror/state@6.6.0)(@codemirror/view@6.38.8)(@lezer/common@1.5.1))(@codemirror/commands@6.10.1)(@codemirror/language@6.12.2)(@codemirror/lint@6.9.5)(@codemirror/search@6.6.0)(@codemirror/state@6.6.0)(@codemirror/view@6.38.8)
codemirror: 6.0.1(@lezer/common@1.5.1)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
@@ -20857,12 +20803,12 @@ snapshots:
codemirror@6.0.1(@lezer/common@1.5.1):
dependencies:
'@codemirror/autocomplete': 6.17.0(@codemirror/language@6.12.1)(@codemirror/state@6.5.4)(@codemirror/view@6.38.8)(@lezer/common@1.5.1)
'@codemirror/autocomplete': 6.17.0(@codemirror/language@6.12.2)(@codemirror/state@6.6.0)(@codemirror/view@6.38.8)(@lezer/common@1.5.1)
'@codemirror/commands': 6.10.1
'@codemirror/language': 6.12.1
'@codemirror/lint': 6.9.3
'@codemirror/search': 6.5.6
'@codemirror/state': 6.5.4
'@codemirror/language': 6.12.2
'@codemirror/lint': 6.9.5
'@codemirror/search': 6.6.0
'@codemirror/state': 6.6.0
'@codemirror/view': 6.38.8
transitivePeerDependencies:
- '@lezer/common'
+5 -4
View File
@@ -29,9 +29,10 @@
"@appsignal/opentelemetry-instrumentation-bullmq": "^0.7.3",
"@baselime/trpc-opentelemetry-middleware": "^0.1.2",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/language": "^6.11.3",
"@codemirror/lint": "^6.9.2",
"@codemirror/state": "^6.5.2",
"@codemirror/language": "^6.12.2",
"@codemirror/lint": "^6.9.5",
"@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.6.0",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",
@@ -101,7 +102,7 @@
"@trpc/react-query": "^11.8.0",
"@trpc/server": "^11.8.0",
"@uiw/codemirror-themes": "^4.23.7",
"@uiw/react-codemirror": "^4.25.4",
"@uiw/react-codemirror": "^4.25.8",
"ai": "^3.4.9",
"bcryptjs": "^2.4.3",
"bullmq": "^5.34.10",
@@ -1,6 +1,14 @@
import capitalize from "lodash/capitalize";
import { GripVertical, MinusCircleIcon } from "lucide-react";
import { memo, useState, useCallback } from "react";
import {
memo,
useState,
useCallback,
useEffect,
useRef,
type RefObject,
} from "react";
import { type ReactCodeMirrorRef } from "@uiw/react-codemirror";
import {
type ChatMessage,
ChatMessageRole,
@@ -24,6 +32,11 @@ import {
SelectTrigger,
SelectValue,
} from "@/src/components/ui/select";
import { useOptionalPlaygroundContext } from "@/src/features/playground/page/context";
import {
useOptionalMessageSearchActions,
useOptionalMessageSearchPageId,
} from "./MessageSearch";
type ChatMessageProps = Pick<
MessagesContext,
@@ -83,6 +96,17 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
toolCallIds,
}) => {
const [roleIndex, setRoleIndex] = useState(1);
const playgroundContext = useOptionalPlaygroundContext();
const searchPageId = useOptionalMessageSearchPageId();
const messageSearchActions = useOptionalMessageSearchActions();
const pageId = playgroundContext?.windowId ?? searchPageId;
const registerMessageTarget = messageSearchActions?.registerMessageTarget;
const unregisterMessageTarget = messageSearchActions?.unregisterMessageTarget;
const shouldUseMessageSearch = Boolean(
pageId && registerMessageTarget && unregisterMessageTarget,
);
const rowRef = useRef<HTMLDivElement | null>(null);
const editorRef = useRef<ReactCodeMirrorRef>(null);
const {
attributes,
@@ -93,6 +117,14 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
isDragging,
} = useSortable({ id: message.id });
const setCardRef = useCallback(
(node: HTMLDivElement | null) => {
rowRef.current = node;
setNodeRef(node);
},
[setNodeRef],
);
const toggleRole = () => {
// Only allow role toggling for messages that have a role property (not placeholder messages)
if (!("role" in message)) return;
@@ -190,9 +222,41 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
const showToolCallSelect = message.type === ChatMessageType.ToolResult;
const isPlaceholder = message.type === ChatMessageType.Placeholder;
useEffect(() => {
if (!pageId || !registerMessageTarget || !unregisterMessageTarget) {
return;
}
registerMessageTarget(pageId, message.id, {
rowRef,
editorRef,
});
return () => {
unregisterMessageTarget(pageId, message.id);
};
}, [
editorRef,
message.id,
pageId,
registerMessageTarget,
unregisterMessageTarget,
]);
const handleEditorMount = useCallback(() => {
if (!pageId || !registerMessageTarget) {
return;
}
registerMessageTarget(pageId, message.id, {
rowRef,
editorRef,
});
}, [message.id, pageId, registerMessageTarget]);
return (
<Card
ref={setNodeRef}
ref={setCardRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
@@ -263,12 +327,18 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
value={(message as PlaceholderMessage).name || ""}
onChange={onPlaceholderNameChange}
role={message.type}
editorRef={editorRef}
onEditorMount={handleEditorMount}
enableSearchKeymap={!shouldUseMessageSearch}
/>
) : (
<MemoizedEditor
value={message.content}
onChange={onValueChange}
role={message.role}
editorRef={editorRef}
onEditorMount={handleEditorMount}
enableSearchKeymap={!shouldUseMessageSearch}
/>
)}
</div>
@@ -296,8 +366,18 @@ const MemoizedEditor = memo(function MemoizedEditor(props: {
value: string;
role: ChatMessage["role"];
onChange: (value: string) => void;
editorRef: RefObject<ReactCodeMirrorRef | null>;
onEditorMount: () => void;
enableSearchKeymap: boolean;
}) {
const { value, role, onChange } = props;
const {
value,
role,
onChange,
editorRef,
onEditorMount,
enableSearchKeymap,
} = props;
const placeholder = `Enter ${getRoleNamePlaceholder(role)} here.`;
return (
@@ -309,6 +389,9 @@ const MemoizedEditor = memo(function MemoizedEditor(props: {
editable={true}
lineNumbers={false}
placeholder={placeholder}
editorRef={editorRef}
enableSearchKeymap={enableSearchKeymap}
onEditorMount={onEditorMount}
/>
);
});
@@ -0,0 +1,16 @@
export {
MessageSearchPageProvider,
MessageSearchProvider,
useMessageSearch,
useMessageSearchActions,
useOptionalMessageSearchActions,
useOptionalMessageSearchPageId,
useSyncMessageSearchMessages,
} from "./messageSearch/context";
export { MessageSearchToolbar } from "./messageSearch/toolbar";
export type {
MessageSearchController,
MessageSearchMatch,
MessageSearchPageLabelResolver,
MessageSearchSnapshot,
} from "./messageSearch/controller";
@@ -0,0 +1,167 @@
"use client";
import {
createContext,
useContext,
useEffect,
useState,
useSyncExternalStore,
type ReactNode,
type RefObject,
} from "react";
import { type ChatMessageWithId } from "@langfuse/shared";
import {
createMessageSearchController,
type MessageSearchController,
type MessageSearchPageLabelResolver,
} from "./controller";
const MessageSearchContext = createContext<MessageSearchController | null>(
null,
);
const MessageSearchPageContext = createContext<string | null>(null);
export function MessageSearchProvider({
children,
pageIds,
captureRootRef,
getPageLabel,
}: {
children: ReactNode;
pageIds: string[];
captureRootRef?: RefObject<HTMLElement | null>;
getPageLabel?: MessageSearchPageLabelResolver;
}) {
const [controller] = useState(() =>
createMessageSearchController(pageIds, getPageLabel),
);
useEffect(() => {
controller.setPageIds(pageIds);
}, [controller, pageIds]);
useEffect(() => {
controller.setPageLabelResolver(getPageLabel);
}, [controller, getPageLabel]);
useEffect(() => {
return () => {
controller.dispose();
};
}, [controller]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const captureRoot = captureRootRef?.current;
if (captureRoot) {
const eventTarget = event.target;
const activeElement = document.activeElement;
const targetIsInsideRoot =
eventTarget instanceof Node && captureRoot.contains(eventTarget);
const activeElementIsInsideRoot =
activeElement instanceof Node && captureRoot.contains(activeElement);
if (!targetIsInsideRoot && !activeElementIsInsideRoot) {
return;
}
}
if (
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
!event.shiftKey &&
event.key.toLocaleLowerCase() === "f"
) {
event.preventDefault();
controller.openSearch();
}
};
document.addEventListener("keydown", handleKeyDown, true);
return () => {
document.removeEventListener("keydown", handleKeyDown, true);
};
}, [captureRootRef, controller]);
return (
<MessageSearchContext.Provider value={controller}>
{children}
</MessageSearchContext.Provider>
);
}
function useMessageSearchController() {
const controller = useContext(MessageSearchContext);
if (!controller) {
throw new Error(
"useMessageSearch must be used within MessageSearchProvider",
);
}
return controller;
}
export function useMessageSearchActions() {
return useMessageSearchController();
}
export function useOptionalMessageSearchActions() {
return useContext(MessageSearchContext);
}
export function MessageSearchPageProvider({
children,
pageId,
}: {
children: ReactNode;
pageId: string;
}) {
return (
<MessageSearchPageContext.Provider value={pageId}>
{children}
</MessageSearchPageContext.Provider>
);
}
export function useOptionalMessageSearchPageId() {
return useContext(MessageSearchPageContext);
}
export function useMessageSearch() {
const controller = useMessageSearchController();
const snapshot = useSyncExternalStore(
controller.subscribe,
controller.getSnapshot,
controller.getSnapshot,
);
return {
...snapshot,
openSearch: controller.openSearch,
closeSearch: controller.closeSearch,
setQueryInput: controller.setQueryInput,
nextMatch: controller.nextMatch,
previousMatch: controller.previousMatch,
};
}
export function useSyncMessageSearchMessages(
pageId: string,
messages: ChatMessageWithId[],
) {
const actions = useOptionalMessageSearchActions();
useEffect(() => {
if (!actions) {
return;
}
actions.registerPageMessages(pageId, messages);
return () => {
actions.unregisterPageMessages(pageId);
};
}, [actions, messages, pageId]);
}
@@ -0,0 +1,538 @@
"use client";
import capitalize from "lodash/capitalize";
import { type ReactCodeMirrorRef } from "@uiw/react-codemirror";
import { ChatMessageType, type ChatMessageWithId } from "@langfuse/shared";
import { type RefObject } from "react";
import {
applyCodeMirrorSearchQuery,
selectCodeMirrorRange,
} from "@/src/components/editor";
export type MessageSearchMatch = {
key: string;
pageId: string;
messageId: string;
label: string;
locationLabel: string;
from: number;
to: number;
text: string;
};
export type MessageSearchSnapshot = {
isOpen: boolean;
openRequestCount: number;
queryInput: string;
query: string;
matches: MessageSearchMatch[];
activeMatch: MessageSearchMatch | null;
activeMatchIndex: number;
};
export type MessageSearchPageLabelResolver = (
pageId: string,
pageIndex: number,
) => string | null;
type MessageSearchState = {
isOpen: boolean;
openRequestCount: number;
queryInput: string;
searchQuery: string;
activeMatchKey: string | null;
matches: MessageSearchMatch[];
pageIds: string[];
getPageLabel?: MessageSearchPageLabelResolver;
pageMessagesById: Record<string, ChatMessageWithId[]>;
};
type MessageSearchPageTarget = {
pageRef: RefObject<HTMLDivElement | null>;
};
type MessageSearchMessageTarget = {
rowRef: RefObject<HTMLDivElement | null>;
editorRef: RefObject<ReactCodeMirrorRef | null>;
};
export type MessageSearchController = {
subscribe: (listener: () => void) => () => void;
getSnapshot: () => MessageSearchSnapshot;
dispose: () => void;
openSearch: () => void;
closeSearch: () => void;
setQueryInput: (value: string) => void;
nextMatch: () => void;
previousMatch: () => void;
setPageIds: (pageIds: string[]) => void;
setPageLabelResolver: (getPageLabel?: MessageSearchPageLabelResolver) => void;
registerPageMessages: (pageId: string, messages: ChatMessageWithId[]) => void;
unregisterPageMessages: (pageId: string) => void;
registerPageTarget: (pageId: string, target: MessageSearchPageTarget) => void;
unregisterPageTarget: (pageId: string) => void;
registerMessageTarget: (
pageId: string,
messageId: string,
target: MessageSearchMessageTarget,
) => void;
unregisterMessageTarget: (pageId: string, messageId: string) => void;
};
const SEARCH_INPUT_DEBOUNCE_MS = 150;
function getMessageSearchText(message: ChatMessageWithId) {
if (message.type === ChatMessageType.Placeholder) {
return message.name ?? "";
}
return typeof message.content === "string" ? message.content : "";
}
function getMessageSearchLabel(message: ChatMessageWithId, index: number) {
if (message.type === ChatMessageType.Placeholder) {
return `Placeholder ${index + 1}`;
}
if ("role" in message) {
return `${capitalize(message.role)} message ${index + 1}`;
}
return `Message ${index + 1}`;
}
function getMatchKey(match: Omit<MessageSearchMatch, "key">) {
return `${match.pageId}:${match.messageId}:${match.from}:${match.to}`;
}
function getMessageTargetKey(pageId: string, messageId: string) {
return `${pageId}:${messageId}`;
}
function getCommittedQuery(state: MessageSearchState) {
return state.searchQuery;
}
function getActiveMatchIndex(state: MessageSearchState) {
if (!state.activeMatchKey) {
return -1;
}
return state.matches.findIndex((match) => match.key === state.activeMatchKey);
}
function getActiveMatch(state: MessageSearchState) {
const activeMatchIndex = getActiveMatchIndex(state);
return activeMatchIndex >= 0
? (state.matches[activeMatchIndex] ?? null)
: null;
}
function buildMatches(state: MessageSearchState) {
const searchQuery = getCommittedQuery(state);
if (!searchQuery) {
return [];
}
const lowerQuery = searchQuery.toLocaleLowerCase();
const allMatches: MessageSearchMatch[] = [];
for (const [pageIndex, pageId] of state.pageIds.entries()) {
const pageMessages = state.pageMessagesById[pageId];
if (!pageMessages) {
continue;
}
for (const [messageIndex, message] of pageMessages.entries()) {
const text = getMessageSearchText(message);
if (!text) {
continue;
}
const lowerText = text.toLocaleLowerCase();
let from = lowerText.indexOf(lowerQuery);
while (from !== -1) {
const label = getMessageSearchLabel(message, messageIndex);
const pageLabel = state.getPageLabel?.(pageId, pageIndex);
const matchWithoutKey = {
pageId,
messageId: message.id,
label,
locationLabel: pageLabel ? `${pageLabel} · ${label}` : label,
from,
to: from + searchQuery.length,
text,
};
allMatches.push({
key: getMatchKey(matchWithoutKey),
...matchWithoutKey,
});
from = lowerText.indexOf(
lowerQuery,
from + Math.max(1, lowerQuery.length),
);
}
}
}
return allMatches;
}
function arePageIdsEqual(currentPageIds: string[], nextPageIds: string[]) {
if (currentPageIds.length !== nextPageIds.length) {
return false;
}
return currentPageIds.every((pageId, index) => pageId === nextPageIds[index]);
}
export function createMessageSearchController(
initialPageIds: string[],
initialPageLabelResolver?: MessageSearchPageLabelResolver,
): MessageSearchController {
const state: MessageSearchState = {
isOpen: false,
openRequestCount: 0,
queryInput: "",
searchQuery: "",
activeMatchKey: null,
matches: [],
pageIds: initialPageIds,
getPageLabel: initialPageLabelResolver,
pageMessagesById: {},
};
const listeners = new Set<() => void>();
const pageTargets = new Map<string, MessageSearchPageTarget>();
const messageTargets = new Map<string, MessageSearchMessageTarget>();
let pendingQueryTimeout: number | null = null;
let cachedSnapshot: MessageSearchSnapshot = {
isOpen: state.isOpen,
openRequestCount: state.openRequestCount,
queryInput: state.queryInput,
query: getCommittedQuery(state),
matches: state.matches,
activeMatch: getActiveMatch(state),
activeMatchIndex: getActiveMatchIndex(state),
};
const refreshSnapshot = () => {
cachedSnapshot = {
isOpen: state.isOpen,
openRequestCount: state.openRequestCount,
queryInput: state.queryInput,
query: getCommittedQuery(state),
matches: state.matches,
activeMatch: getActiveMatch(state),
activeMatchIndex: getActiveMatchIndex(state),
};
};
const emit = () => {
refreshSnapshot();
for (const listener of listeners) {
listener();
}
};
const clearPendingQueryTimeout = () => {
if (pendingQueryTimeout === null) {
return;
}
window.clearTimeout(pendingQueryTimeout);
pendingQueryTimeout = null;
};
const syncEditorsToQuery = () => {
const query = getCommittedQuery(state);
for (const target of messageTargets.values()) {
applyCodeMirrorSearchQuery(target.editorRef, query);
}
};
const syncActiveMatchTarget = () => {
const activeMatch = getActiveMatch(state);
if (!activeMatch) {
return;
}
pageTargets.get(activeMatch.pageId)?.pageRef.current?.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "center",
});
const messageTarget = messageTargets.get(
getMessageTargetKey(activeMatch.pageId, activeMatch.messageId),
);
messageTarget?.rowRef.current?.scrollIntoView({
behavior: "smooth",
block: "center",
inline: "nearest",
});
selectCodeMirrorRange(messageTarget?.editorRef, {
from: activeMatch.from,
to: activeMatch.to,
});
};
const recomputeMatches = () => {
const committedQuery = getCommittedQuery(state);
if (!committedQuery) {
if (state.matches.length === 0 && state.activeMatchKey === null) {
return false;
}
state.matches = [];
const previousActiveMatchKey = state.activeMatchKey;
state.activeMatchKey = null;
return previousActiveMatchKey !== null;
}
const previousActiveMatchKey = state.activeMatchKey;
state.matches = buildMatches(state);
if (state.matches.length === 0) {
state.activeMatchKey = null;
} else if (
!state.activeMatchKey ||
!state.matches.some((match) => match.key === state.activeMatchKey)
) {
state.activeMatchKey = state.matches[0]?.key ?? null;
}
return previousActiveMatchKey !== state.activeMatchKey;
};
const refreshSearchResults = (shouldSyncEditors: boolean) => {
const activeMatchChanged = recomputeMatches();
if (shouldSyncEditors) {
syncEditorsToQuery();
}
if (shouldSyncEditors || activeMatchChanged) {
syncActiveMatchTarget();
}
};
const refreshSearchResultsIfSearching = (shouldSyncEditors: boolean) => {
if (!getCommittedQuery(state)) {
return;
}
refreshSearchResults(shouldSyncEditors);
emit();
};
const commitSearchQuery = (nextSearchQuery: string) => {
if (state.searchQuery === nextSearchQuery) {
return false;
}
state.searchQuery = nextSearchQuery;
refreshSearchResults(true);
return true;
};
const flushPendingQuery = () => {
if (pendingQueryTimeout === null) {
return false;
}
clearPendingQueryTimeout();
const queryChanged = commitSearchQuery(state.queryInput.trim());
if (queryChanged) {
emit();
}
return queryChanged;
};
const moveActiveMatch = (direction: 1 | -1) => {
flushPendingQuery();
if (state.matches.length === 0) {
return;
}
if (!state.activeMatchKey) {
state.activeMatchKey =
direction > 0
? (state.matches[0]?.key ?? null)
: (state.matches[state.matches.length - 1]?.key ?? null);
} else {
const currentIndex = state.matches.findIndex(
(match) => match.key === state.activeMatchKey,
);
const fallbackIndex = direction > 0 ? 0 : state.matches.length - 1;
const nextIndex =
currentIndex < 0
? fallbackIndex
: (currentIndex + direction + state.matches.length) %
state.matches.length;
state.activeMatchKey = state.matches[nextIndex]?.key ?? null;
}
syncActiveMatchTarget();
emit();
};
return {
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
getSnapshot() {
return cachedSnapshot;
},
dispose() {
clearPendingQueryTimeout();
listeners.clear();
pageTargets.clear();
messageTargets.clear();
},
openSearch() {
state.isOpen = true;
state.openRequestCount += 1;
emit();
},
closeSearch() {
const hadQuery = getCommittedQuery(state).length > 0;
clearPendingQueryTimeout();
state.isOpen = false;
state.queryInput = "";
state.searchQuery = "";
state.matches = [];
state.activeMatchKey = null;
if (hadQuery) {
syncEditorsToQuery();
}
emit();
},
setQueryInput(value) {
if (state.queryInput === value) {
return;
}
state.queryInput = value;
clearPendingQueryTimeout();
const nextSearchQuery = value.trim();
if (nextSearchQuery === "") {
commitSearchQuery("");
emit();
return;
}
emit();
if (nextSearchQuery === state.searchQuery) {
return;
}
pendingQueryTimeout = window.setTimeout(() => {
pendingQueryTimeout = null;
const queryChanged = commitSearchQuery(nextSearchQuery);
if (queryChanged) {
emit();
}
}, SEARCH_INPUT_DEBOUNCE_MS);
},
nextMatch() {
moveActiveMatch(1);
},
previousMatch() {
moveActiveMatch(-1);
},
setPageIds(pageIds) {
if (arePageIdsEqual(state.pageIds, pageIds)) {
return;
}
state.pageIds = pageIds;
refreshSearchResultsIfSearching(false);
},
setPageLabelResolver(getPageLabel) {
if (state.getPageLabel === getPageLabel) {
return;
}
state.getPageLabel = getPageLabel;
refreshSearchResultsIfSearching(false);
},
registerPageMessages(pageId, messages) {
state.pageMessagesById[pageId] = messages;
refreshSearchResultsIfSearching(false);
},
unregisterPageMessages(pageId) {
if (!(pageId in state.pageMessagesById)) {
return;
}
delete state.pageMessagesById[pageId];
refreshSearchResultsIfSearching(false);
},
registerPageTarget(pageId, target) {
pageTargets.set(pageId, target);
if (getActiveMatch(state)?.pageId === pageId) {
syncActiveMatchTarget();
}
},
unregisterPageTarget(pageId) {
pageTargets.delete(pageId);
},
registerMessageTarget(pageId, messageId, target) {
messageTargets.set(getMessageTargetKey(pageId, messageId), target);
applyCodeMirrorSearchQuery(target.editorRef, getCommittedQuery(state));
const activeMatch = getActiveMatch(state);
if (
activeMatch?.pageId === pageId &&
activeMatch.messageId === messageId
) {
syncActiveMatchTarget();
}
},
unregisterMessageTarget(pageId, messageId) {
messageTargets.delete(getMessageTargetKey(pageId, messageId));
},
};
}
@@ -0,0 +1,144 @@
"use client";
import {
ChevronDown,
ChevronUp,
Search,
X,
type LucideIcon,
} from "lucide-react";
import { useEffect, useRef } from "react";
import { Button } from "@/src/components/ui/button";
import { Input } from "@/src/components/ui/input";
import { cn } from "@/src/utils/tailwind";
import { useMessageSearch } from "./context";
export function MessageSearchToolbar({ className }: { className?: string }) {
const {
isOpen,
openRequestCount,
queryInput,
matches,
activeMatch,
activeMatchIndex,
openSearch,
closeSearch,
setQueryInput,
nextMatch,
previousMatch,
} = useMessageSearch();
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (!isOpen) {
return;
}
inputRef.current?.focus();
inputRef.current?.select();
}, [isOpen, openRequestCount]);
if (!isOpen) {
return (
<Button
variant="outline"
size="sm"
className={cn("h-8 gap-2", className)}
onClick={openSearch}
aria-label="Find in messages"
>
<Search className="h-3.5 w-3.5" />
<span className="hidden lg:inline">Find</span>
</Button>
);
}
const activeCountText =
matches.length === 0 || activeMatchIndex < 0
? "0 / 0"
: `${activeMatchIndex + 1} / ${matches.length}`;
return (
<div
className={cn(
"flex items-center gap-1 rounded-md border bg-background p-1",
className,
)}
>
<Search className="ml-1 h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
<Input
ref={inputRef}
value={queryInput}
onChange={(event) => setQueryInput(event.target.value)}
placeholder="Find in messages"
className="h-6 min-w-40 border-0 px-1 text-xs shadow-none focus-visible:ring-0 sm:min-w-56"
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
if (event.shiftKey) {
previousMatch();
} else {
nextMatch();
}
}
if (event.key === "Escape") {
event.preventDefault();
if (queryInput) {
setQueryInput("");
} else {
closeSearch();
}
}
}}
/>
<div className="min-w-16 px-1 text-right text-xs text-muted-foreground">
{activeCountText}
</div>
<IconButton
icon={ChevronUp}
label="Previous result"
onClick={previousMatch}
disabled={matches.length === 0}
/>
<IconButton
icon={ChevronDown}
label="Next result"
onClick={nextMatch}
disabled={matches.length === 0}
/>
<div className="hidden max-w-48 truncate px-1 text-xs text-muted-foreground lg:block">
{activeMatch?.locationLabel ?? "No matches"}
</div>
<IconButton icon={X} label="Close search" onClick={closeSearch} />
</div>
);
}
function IconButton({
icon: Icon,
label,
onClick,
disabled,
}: {
icon: LucideIcon;
label: string;
onClick: () => void;
disabled?: boolean;
}) {
return (
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={onClick}
disabled={disabled}
aria-label={label}
>
<Icon className="h-3.5 w-3.5" />
</Button>
);
}
+67 -3
View File
@@ -7,11 +7,17 @@ import CodeMirror, {
type ViewUpdate,
} from "@uiw/react-codemirror";
import { RangeSetBuilder } from "@codemirror/state";
import { SearchQuery, search, setSearchQuery } from "@codemirror/search";
import { json, jsonParseLinter } from "@codemirror/lang-json";
import { linter, type Diagnostic } from "@codemirror/lint";
import { useTheme } from "next-themes";
import { cn } from "@/src/utils/tailwind";
import { useState } from "react";
import {
useState,
useCallback,
type MutableRefObject,
type RefObject,
} from "react";
import { LanguageSupport, StreamLanguage } from "@codemirror/language";
import type { StringStream } from "@codemirror/language";
import {
@@ -159,6 +165,44 @@ const bidiSupport = [
),
];
export function applyCodeMirrorSearchQuery(
editorRef: RefObject<ReactCodeMirrorRef | null> | undefined,
searchValue: string,
) {
const view = editorRef?.current?.view;
if (!view) {
return;
}
view.dispatch({
effects: setSearchQuery.of(
new SearchQuery({
search: searchValue,
caseSensitive: false,
literal: true,
}),
),
});
}
export function selectCodeMirrorRange(
editorRef: RefObject<ReactCodeMirrorRef | null> | undefined,
range: { from: number; to: number } | null,
) {
const view = editorRef?.current?.view;
if (!view || !range) {
return;
}
view.dispatch({
selection: {
anchor: range.from,
head: range.to,
},
scrollIntoView: true,
});
}
export function CodeMirrorEditor({
value,
onChange,
@@ -172,6 +216,8 @@ export function CodeMirrorEditor({
maxHeight,
placeholder,
editorRef,
enableSearchKeymap = true,
onEditorMount,
}: {
value: string;
onChange?: (value: string) => void;
@@ -184,7 +230,9 @@ export function CodeMirrorEditor({
minHeight?: number | string;
maxHeight?: number | string;
placeholder?: string;
editorRef?: React.RefObject<ReactCodeMirrorRef | null>;
editorRef?: RefObject<ReactCodeMirrorRef | null>;
enableSearchKeymap?: boolean;
onEditorMount?: () => void;
}) {
const { resolvedTheme } = useTheme();
const codeMirrorTheme = resolvedTheme === "dark" ? darkTheme : lightTheme;
@@ -193,18 +241,34 @@ export function CodeMirrorEditor({
!!value && value !== "",
);
const handleEditorRef = useCallback(
(instance: ReactCodeMirrorRef | null) => {
if (editorRef) {
(editorRef as MutableRefObject<ReactCodeMirrorRef | null>).current =
instance;
}
if (instance) {
onEditorMount?.();
}
},
[editorRef, onEditorMount],
);
return (
<CodeMirror
value={value}
theme={codeMirrorTheme}
ref={editorRef}
ref={editorRef || onEditorMount ? handleEditorRef : undefined}
basicSetup={{
foldGutter: lineNumbers,
highlightActiveLine: false,
lineNumbers: lineNumbers,
searchKeymap: enableSearchKeymap,
}}
lang={mode === "json" ? "json" : undefined}
extensions={[
search(),
// RTL/bidi support - must be early for proper line decoration
...bidiSupport,
// Remove outline if field is focussed
@@ -96,6 +96,7 @@ export const MessagePlaceholderComponent: React.FC<{
editable={true}
lineNumbers={false}
placeholder={`[\n {\n "role": "user",\n "content": "Hello!"\n },\n {\n "role": "assistant",\n "content": "Hi there!"\n }\n]`}
enableSearchKeymap={false}
/>
{hasConflict && (
@@ -8,6 +8,7 @@ import { ModelParameters } from "@/src/components/ModelParameters";
import { usePlaygroundContext } from "../context";
import { Messages } from "@/src/features/playground/page/components/Messages";
import { ConfigurationDropdowns } from "@/src/features/playground/page/components/ConfigurationDropdowns";
import { useMessageSearchActions } from "@/src/components/ChatMessages/MessageSearch";
import {
Tooltip,
TooltipContent,
@@ -153,6 +154,9 @@ function PlaygroundWindowContent({
isMobile?: boolean;
}) {
const playgroundContext = usePlaygroundContext();
const { registerPageTarget, unregisterPageTarget } =
useMessageSearchActions();
const windowContainerRef = useRef<HTMLDivElement | null>(null);
const handleRemove = useCallback(() => {
onRemove(windowId);
@@ -162,8 +166,21 @@ function PlaygroundWindowContent({
onCopy(windowId);
}, [windowId, onCopy]);
useEffect(() => {
registerPageTarget(windowId, {
pageRef: windowContainerRef,
});
return () => {
unregisterPageTarget(windowId);
};
}, [registerPageTarget, unregisterPageTarget, windowId]);
return (
<div className="playground-window flex h-full min-w-0 flex-col rounded-lg border bg-background shadow-sm @container">
<div
ref={windowContainerRef}
className="playground-window flex h-full min-w-0 flex-col rounded-lg border bg-background shadow-sm @container"
>
{/* Window Header */}
<div className="relative flex-shrink-0 border-b bg-muted/50 px-3 py-1">
<div className="flex items-center pr-32 @xl:pr-96">
@@ -69,6 +69,7 @@ export const PromptVariableComponent: React.FC<{
editable={true}
lineNumbers={false}
placeholder={name}
enableSearchKeymap={false}
/>
{hasConflict && (
@@ -47,9 +47,11 @@ import {
getPlaygroundEventBus,
useWindowCoordination,
} from "@/src/features/playground/page/hooks/useWindowCoordination";
import { useSyncMessageSearchMessages } from "@/src/components/ChatMessages/MessageSearch";
import { getFinalModelParams } from "@/src/utils/getFinalModelParams";
type PlaygroundContextType = {
windowId: string;
promptVariables: PromptVariable[];
updatePromptVariableValue: (variable: string, value: string) => void;
deletePromptVariable: (variable: string) => void;
@@ -88,10 +90,13 @@ export const usePlaygroundContext = () => {
return context;
};
export const useOptionalPlaygroundContext = () => useContext(PlaygroundContext);
export const PlaygroundProvider: React.FC<PlaygroundProviderProps> = ({
children,
windowId,
}) => {
const effectiveWindowId = windowId || MULTI_WINDOW_CONFIG.DEFAULT_WINDOW_ID;
const capture = usePostHogClientCapture();
const projectId = useProjectIdFromURL();
const { playgroundCache, setPlaygroundCache } = usePlaygroundCache(windowId);
@@ -233,6 +238,7 @@ export const PlaygroundProvider: React.FC<PlaygroundProviderProps> = ({
}, [messages]);
useEffect(updatePromptVariables, [messages, updatePromptVariables]);
useSyncMessageSearchMessages(effectiveWindowId, messages);
const addMessage: PlaygroundContextType["addMessage"] = useCallback(
(message) => {
@@ -553,7 +559,6 @@ export const PlaygroundProvider: React.FC<PlaygroundProviderProps> = ({
// This effect registers the window with the global coordination system
// and sets up event listeners for global actions like "Run All" and "Stop All"
useEffect(() => {
const effectiveWindowId = windowId || MULTI_WINDOW_CONFIG.DEFAULT_WINDOW_ID;
const playgroundEventBus = getPlaygroundEventBus();
const playgroundHandle: PlaygroundHandle = {
@@ -620,7 +625,7 @@ export const PlaygroundProvider: React.FC<PlaygroundProviderProps> = ({
);
};
}, [
windowId,
effectiveWindowId,
handleSubmit,
registerWindow,
unregisterWindow,
@@ -638,12 +643,12 @@ export const PlaygroundProvider: React.FC<PlaygroundProviderProps> = ({
playgroundEventBus.dispatchEvent(
new CustomEvent(PLAYGROUND_EVENTS.WINDOW_EXECUTION_STATE_CHANGE, {
detail: {
windowId: windowId || MULTI_WINDOW_CONFIG.DEFAULT_WINDOW_ID,
windowId: effectiveWindowId,
isStreaming,
},
}),
);
}, [windowId, isStreaming]);
}, [effectiveWindowId, isStreaming]);
// Notify when model configuration changes
useEffect(() => {
@@ -651,18 +656,19 @@ export const PlaygroundProvider: React.FC<PlaygroundProviderProps> = ({
playgroundEventBus.dispatchEvent(
new CustomEvent(PLAYGROUND_EVENTS.WINDOW_MODEL_CONFIG_CHANGE, {
detail: {
windowId: windowId || MULTI_WINDOW_CONFIG.DEFAULT_WINDOW_ID,
windowId: effectiveWindowId,
hasModel: Boolean(
modelParams.provider.value && modelParams.model.value,
),
},
}),
);
}, [windowId, modelParams.provider.value, modelParams.model.value]);
}, [effectiveWindowId, modelParams.provider.value, modelParams.model.value]);
return (
<PlaygroundContext.Provider
value={{
windowId: effectiveWindowId,
promptVariables,
updatePromptVariableValue,
deletePromptVariable,
+85 -68
View File
@@ -9,6 +9,10 @@ import { type MultiWindowState } from "@/src/features/playground/page/types";
import Page from "@/src/components/layouts/page";
import MultiWindowPlayground from "@/src/features/playground/page/components/MultiWindowPlayground";
import { NoModelConfiguredAlert } from "@/src/features/playground/page/components/NoModelConfiguredAlert";
import {
MessageSearchProvider,
MessageSearchToolbar,
} from "@/src/components/ChatMessages/MessageSearch";
import useProjectIdFromURL from "@/src/hooks/useProjectIdFromURL";
/**
@@ -83,6 +87,12 @@ export default function PlaygroundPage() {
executeAllWindows();
});
const getMessageSearchPageLabel = useCallback(
(_pageId: string, pageIndex: number) =>
windowIds.length > 1 ? `Window ${pageIndex + 1}` : null,
[windowIds.length],
);
// Don't render until window IDs are loaded
if (!isLoaded) {
return (
@@ -117,75 +127,82 @@ export default function PlaygroundPage() {
};
return (
<Page
scrollable={false}
withPadding={false}
headerProps={{
title: "Playground",
help: {
description:
"A sandbox to test and iterate your prompts across multiple windows",
href: "https://langfuse.com/docs/prompt-management/features/playground",
},
actionButtonsRight: (
<div className="flex flex-nowrap items-center gap-2">
{/* Window Count Display - Hidden on mobile */}
<div className="hidden items-center gap-2 text-sm text-muted-foreground md:flex">
<span className="whitespace-nowrap">
{windowIds.length} window
{windowIds.length === 1 ? "" : "s"}
</span>
{executionStatus && (
<>
<span className="hidden sm:inline"></span>
<div className="flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
<span className="hidden whitespace-nowrap sm:inline">
{executionStatus}
</span>
</div>
</>
)}
</div>
{/* Multi-Window Controls - Hidden on mobile */}
<Button
variant="outline"
onClick={handleExecuteAll}
disabled={isRunAllDisabled}
className="hidden flex-shrink-0 gap-1 md:flex"
title={
!hasAnyModelConfigured
? "Please configure a model in Project Settings first"
: "Execute all playground windows simultaneously"
}
>
{globalIsExecutingAll ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Play className="h-3 w-3" />
)}
<span className="hidden lg:inline">Run All (Ctrl + Enter)</span>
</Button>
{/* Reset Playground Button */}
<ResetPlaygroundButton />
</div>
),
}}
<MessageSearchProvider
pageIds={windowIds}
getPageLabel={getMessageSearchPageLabel}
>
<div className="flex h-full flex-col">
{!hasAnyModelConfigured && projectId && (
<NoModelConfiguredAlert projectId={projectId} />
)}
<div className="flex-1 overflow-hidden">
<MultiWindowPlayground
windowState={windowState}
onRemoveWindow={removeWindow}
onAddWindow={addWindow}
/>
<Page
scrollable={false}
withPadding={false}
headerProps={{
title: "Playground",
help: {
description:
"A sandbox to test and iterate your prompts across multiple windows",
href: "https://langfuse.com/docs/prompt-management/features/playground",
},
actionButtonsRight: (
<div className="flex flex-nowrap items-center gap-2">
<MessageSearchToolbar className="max-w-[24rem]" />
{/* Window Count Display - Hidden on mobile */}
<div className="hidden items-center gap-2 text-sm text-muted-foreground md:flex">
<span className="whitespace-nowrap">
{windowIds.length} window
{windowIds.length === 1 ? "" : "s"}
</span>
{executionStatus && (
<>
<span className="hidden sm:inline"></span>
<div className="flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
<span className="hidden whitespace-nowrap sm:inline">
{executionStatus}
</span>
</div>
</>
)}
</div>
{/* Multi-Window Controls - Hidden on mobile */}
<Button
variant="outline"
onClick={handleExecuteAll}
disabled={isRunAllDisabled}
className="hidden flex-shrink-0 gap-1 md:flex"
title={
!hasAnyModelConfigured
? "Please configure a model in Project Settings first"
: "Execute all playground windows simultaneously"
}
>
{globalIsExecutingAll ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Play className="h-3 w-3" />
)}
<span className="hidden lg:inline">Run All (Ctrl + Enter)</span>
</Button>
{/* Reset Playground Button */}
<ResetPlaygroundButton />
</div>
),
}}
>
<div className="flex h-full flex-col">
{!hasAnyModelConfigured && projectId && (
<NoModelConfiguredAlert projectId={projectId} />
)}
<div className="flex-1 overflow-hidden">
<MultiWindowPlayground
windowState={windowState}
onRemoveWindow={removeWindow}
onAddWindow={addWindow}
/>
</div>
</div>
</div>
</Page>
</Page>
</MessageSearchProvider>
);
}
@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback } from "react";
import { useEffect, useState, useCallback, useRef } from "react";
import { v4 as uuidv4 } from "uuid";
import { PlusIcon } from "lucide-react";
@@ -15,6 +15,12 @@ import {
import { type NewPromptFormSchemaType } from "./validation";
import { PromptSelectionDialog } from "../PromptSelectionDialog";
import {
MessageSearchPageProvider,
MessageSearchProvider,
MessageSearchToolbar,
useSyncMessageSearchMessages,
} from "@/src/components/ChatMessages/MessageSearch";
import type { ControllerRenderProps } from "react-hook-form";
import type { MessagesContext } from "@/src/components/ChatMessages/types";
@@ -24,11 +30,24 @@ type PromptChatMessagesProps = ControllerRenderProps<
"chatPrompt"
> & { initialMessages: unknown; projectId: string | undefined };
const PROMPT_CHAT_MESSAGES_SEARCH_PAGE_ID = "prompt-chat-messages";
const PromptChatMessagesSearchSync = ({
messages,
}: {
messages: ChatMessageWithId[];
}) => {
useSyncMessageSearchMessages(PROMPT_CHAT_MESSAGES_SEARCH_PAGE_ID, messages);
return null;
};
export const PromptChatMessages: React.FC<PromptChatMessagesProps> = ({
onChange,
initialMessages,
projectId,
}) => {
const searchRootRef = useRef<HTMLDivElement | null>(null);
const [messages, setMessages] = useState<ChatMessageWithId[]>([]);
const [availableRoles, setAvailableRoles] = useState<string[]>([]);
const [isDialogOpen, setIsDialogOpen] = useState(false);
@@ -123,38 +142,51 @@ export const PromptChatMessages: React.FC<PromptChatMessagesProps> = ({
}, [messages, onChange]);
return (
<div>
<div className="my-2 flex justify-end">
<Button
type="button"
variant="outline"
className="flex items-center gap-1 px-2 py-1"
onClick={() => setIsDialogOpen(true)}
>
<PlusIcon className="h-4 w-4" />
<span className="text-xs">Add prompt reference</span>
</Button>
<MessageSearchProvider
pageIds={[PROMPT_CHAT_MESSAGES_SEARCH_PAGE_ID]}
captureRootRef={searchRootRef}
>
<MessageSearchPageProvider pageId={PROMPT_CHAT_MESSAGES_SEARCH_PAGE_ID}>
<PromptChatMessagesSearchSync messages={messages} />
{projectId && (
<PromptSelectionDialog
isOpen={isDialogOpen}
onClose={() => setIsDialogOpen(false)}
projectId={projectId}
<div ref={searchRootRef}>
<div className="my-2 flex flex-wrap items-center justify-between gap-2">
<MessageSearchToolbar />
<div className="flex justify-end">
<Button
type="button"
variant="outline"
className="flex items-center gap-1 px-2 py-1"
onClick={() => setIsDialogOpen(true)}
>
<PlusIcon className="h-4 w-4" />
<span className="text-xs">Add prompt reference</span>
</Button>
{projectId && (
<PromptSelectionDialog
isOpen={isDialogOpen}
onClose={() => setIsDialogOpen(false)}
projectId={projectId}
/>
)}
</div>
</div>
<ChatMessages
{...{
messages,
addMessage,
setMessages,
deleteMessage,
updateMessage,
replaceMessage,
availableRoles,
}}
/>
)}
</div>
<ChatMessages
{...{
messages,
addMessage,
setMessages,
deleteMessage,
updateMessage,
replaceMessage,
availableRoles,
}}
/>
</div>
</div>
</MessageSearchPageProvider>
</MessageSearchProvider>
);
};