Merge upstream/main
This commit is contained in:
commit
3ce0ba098a
40 changed files with 3416 additions and 268 deletions
32
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
32
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: "[BUG]"
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: "[FEAT]"
|
||||
labels: feature request
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
|
|
@ -112,7 +112,7 @@
|
|||
"eslint-plugin-sonarjs": "^3.0.6",
|
||||
"eslint-plugin-tailwindcss": "^3.18.2",
|
||||
"globals": "^17.2.0",
|
||||
"happy-dom": "^17.4.6",
|
||||
"happy-dom": "^20.0.2",
|
||||
"knip": "^5.82.1",
|
||||
"postcss": "^8.4.35",
|
||||
"prettier": "^3.8.1",
|
||||
|
|
|
|||
204
pnpm-lock.yaml
204
pnpm-lock.yaml
|
|
@ -137,7 +137,7 @@ importers:
|
|||
version: 4.7.0(vite@5.4.21(@types/node@25.0.7)(terser@5.46.0))
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^3.1.4
|
||||
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@17.6.3)(terser@5.46.0))
|
||||
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.0.2)(terser@5.46.0))
|
||||
autoprefixer:
|
||||
specifier: ^10.4.17
|
||||
version: 10.4.23(postcss@8.5.6)
|
||||
|
|
@ -193,8 +193,8 @@ importers:
|
|||
specifier: ^17.2.0
|
||||
version: 17.2.0
|
||||
happy-dom:
|
||||
specifier: ^17.4.6
|
||||
version: 17.6.3
|
||||
specifier: ^20.0.2
|
||||
version: 20.0.2
|
||||
knip:
|
||||
specifier: ^5.82.1
|
||||
version: 5.82.1(@types/node@25.0.7)(typescript@5.9.3)
|
||||
|
|
@ -224,7 +224,7 @@ importers:
|
|||
version: 5.4.21(@types/node@25.0.7)(terser@5.46.0)
|
||||
vitest:
|
||||
specifier: ^3.1.4
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@17.6.3)(terser@5.46.0)
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.0.2)(terser@5.46.0)
|
||||
|
||||
packages:
|
||||
|
||||
|
|
@ -817,10 +817,6 @@ packages:
|
|||
resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
'@isaacs/brace-expansion@5.0.0':
|
||||
resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
'@isaacs/brace-expansion@5.0.1':
|
||||
resolution: {integrity: sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
|
@ -1558,6 +1554,9 @@ packages:
|
|||
'@types/node@18.19.130':
|
||||
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
|
||||
|
||||
'@types/node@20.19.33':
|
||||
resolution: {integrity: sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==}
|
||||
|
||||
'@types/node@24.10.12':
|
||||
resolution: {integrity: sha512-68e+T28EbdmLSTkPgs3+UacC6rzmqrcWFPQs1C8mwJhI/r5Uxr0yEuQotczNRROd1gq30NGxee+fo0rSIxpyAw==}
|
||||
|
||||
|
|
@ -1593,6 +1592,9 @@ packages:
|
|||
'@types/verror@1.10.11':
|
||||
resolution: {integrity: sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==}
|
||||
|
||||
'@types/whatwg-mimetype@3.0.2':
|
||||
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
|
||||
|
||||
|
|
@ -1817,6 +1819,11 @@ packages:
|
|||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
acorn@8.16.0:
|
||||
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
agent-base@6.0.2:
|
||||
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
|
||||
engines: {node: '>= 6.0.0'}
|
||||
|
|
@ -1846,11 +1853,11 @@ packages:
|
|||
peerDependencies:
|
||||
ajv: ^6.9.1
|
||||
|
||||
ajv@6.12.6:
|
||||
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
|
||||
ajv@6.14.0:
|
||||
resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==}
|
||||
|
||||
ajv@8.17.1:
|
||||
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
|
||||
ajv@8.18.0:
|
||||
resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==}
|
||||
|
||||
ansi-regex@5.0.1:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
|
|
@ -2032,6 +2039,10 @@ packages:
|
|||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
|
||||
balanced-match@4.0.3:
|
||||
resolution: {integrity: sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
|
|
@ -2065,6 +2076,10 @@ packages:
|
|||
brace-expansion@2.0.2:
|
||||
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
|
||||
|
||||
brace-expansion@5.0.2:
|
||||
resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
braces@3.0.3:
|
||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||
engines: {node: '>=8'}
|
||||
|
|
@ -2961,6 +2976,7 @@ packages:
|
|||
|
||||
glob@10.5.0:
|
||||
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
hasBin: true
|
||||
|
||||
glob@13.0.2:
|
||||
|
|
@ -3008,8 +3024,8 @@ packages:
|
|||
engines: {node: '>=0.4.7'}
|
||||
hasBin: true
|
||||
|
||||
happy-dom@17.6.3:
|
||||
resolution: {integrity: sha512-UVIHeVhxmxedbWPCfgS55Jg2rDfwf2BCKeylcPSqazLz5w3Kri7Q4xdBJubsr/+VUzFLh0VjIvh13RaDA2/Xug==}
|
||||
happy-dom@20.0.2:
|
||||
resolution: {integrity: sha512-pYOyu624+6HDbY+qkjILpQGnpvZOusItCk+rvF5/V+6NkcgTKnbOldpIy22tBnxoaLtlM9nXgoqAcW29/B7CIw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
has-bigints@1.1.0:
|
||||
|
|
@ -3713,19 +3729,19 @@ packages:
|
|||
resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
minimatch@10.1.2:
|
||||
resolution: {integrity: sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==}
|
||||
engines: {node: 20 || >=22}
|
||||
minimatch@10.2.2:
|
||||
resolution: {integrity: sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
minimatch@3.1.2:
|
||||
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
|
||||
minimatch@3.1.3:
|
||||
resolution: {integrity: sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==}
|
||||
|
||||
minimatch@5.1.6:
|
||||
resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==}
|
||||
minimatch@5.1.7:
|
||||
resolution: {integrity: sha512-FjiwU9HaHW6YB3H4a1sFudnv93lvydNjz2lmyUXR6IwKhGI+bgL3SOZrBGn6kvvX2pJvhEkGSGjyTHN47O4rqA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
minimatch@9.0.5:
|
||||
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
|
||||
minimatch@9.0.6:
|
||||
resolution: {integrity: sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
minimist@1.2.8:
|
||||
|
|
@ -4405,6 +4421,11 @@ packages:
|
|||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
semver@7.7.4:
|
||||
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
serialize-error@7.0.1:
|
||||
resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -4800,6 +4821,9 @@ packages:
|
|||
undici-types@5.26.5:
|
||||
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
|
||||
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
undici-types@7.16.0:
|
||||
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
|
||||
|
||||
|
|
@ -4961,10 +4985,6 @@ packages:
|
|||
wcwidth@1.0.1:
|
||||
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
|
||||
|
||||
webidl-conversions@7.0.0:
|
||||
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
whatwg-mimetype@3.0.0:
|
||||
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
|
||||
engines: {node: '>=12'}
|
||||
|
|
@ -5227,8 +5247,8 @@ snapshots:
|
|||
|
||||
'@develar/schema-utils@2.6.5':
|
||||
dependencies:
|
||||
ajv: 6.12.6
|
||||
ajv-keywords: 3.5.2(ajv@6.12.6)
|
||||
ajv: 6.14.0
|
||||
ajv-keywords: 3.5.2(ajv@6.14.0)
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1(react@18.3.1)':
|
||||
dependencies:
|
||||
|
|
@ -5259,7 +5279,7 @@ snapshots:
|
|||
dependencies:
|
||||
commander: 5.1.0
|
||||
glob: 7.2.3
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.3
|
||||
|
||||
'@electron/get@2.0.3':
|
||||
dependencies:
|
||||
|
|
@ -5340,7 +5360,7 @@ snapshots:
|
|||
debug: 4.4.3
|
||||
dir-compare: 3.3.0
|
||||
fs-extra: 9.1.0
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.3
|
||||
plist: 3.1.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
|
@ -5352,7 +5372,7 @@ snapshots:
|
|||
debug: 4.4.3
|
||||
dir-compare: 4.2.0
|
||||
fs-extra: 11.3.3
|
||||
minimatch: 9.0.5
|
||||
minimatch: 9.0.6
|
||||
plist: 3.1.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
|
@ -5537,7 +5557,7 @@ snapshots:
|
|||
dependencies:
|
||||
'@eslint/object-schema': 2.1.7
|
||||
debug: 4.4.3
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
|
@ -5551,14 +5571,14 @@ snapshots:
|
|||
|
||||
'@eslint/eslintrc@3.3.3':
|
||||
dependencies:
|
||||
ajv: 6.12.6
|
||||
ajv: 6.14.0
|
||||
debug: 4.4.3
|
||||
espree: 10.4.0
|
||||
globals: 14.0.0
|
||||
ignore: 5.3.2
|
||||
import-fresh: 3.3.1
|
||||
js-yaml: 4.1.1
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.3
|
||||
strip-json-comments: 3.1.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
|
@ -5576,8 +5596,8 @@ snapshots:
|
|||
|
||||
'@fastify/ajv-compiler@4.0.5':
|
||||
dependencies:
|
||||
ajv: 8.17.1
|
||||
ajv-formats: 3.0.1(ajv@8.17.1)
|
||||
ajv: 8.18.0
|
||||
ajv-formats: 3.0.1(ajv@8.18.0)
|
||||
fast-uri: 3.1.0
|
||||
|
||||
'@fastify/cors@11.2.0':
|
||||
|
|
@ -5651,10 +5671,6 @@ snapshots:
|
|||
|
||||
'@isaacs/balanced-match@4.0.1': {}
|
||||
|
||||
'@isaacs/brace-expansion@5.0.0':
|
||||
dependencies:
|
||||
'@isaacs/balanced-match': 4.0.1
|
||||
|
||||
'@isaacs/brace-expansion@5.0.1':
|
||||
dependencies:
|
||||
'@isaacs/balanced-match': 4.0.1
|
||||
|
|
@ -6309,6 +6325,10 @@ snapshots:
|
|||
dependencies:
|
||||
undici-types: 5.26.5
|
||||
|
||||
'@types/node@20.19.33':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/node@24.10.12':
|
||||
dependencies:
|
||||
undici-types: 7.16.0
|
||||
|
|
@ -6349,6 +6369,8 @@ snapshots:
|
|||
'@types/verror@1.10.11':
|
||||
optional: true
|
||||
|
||||
'@types/whatwg-mimetype@3.0.2': {}
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
dependencies:
|
||||
'@types/node': 25.0.7
|
||||
|
|
@ -6421,7 +6443,7 @@ snapshots:
|
|||
'@typescript-eslint/types': 8.54.0
|
||||
'@typescript-eslint/visitor-keys': 8.54.0
|
||||
debug: 4.4.3
|
||||
minimatch: 9.0.5
|
||||
minimatch: 9.0.6
|
||||
semver: 7.7.3
|
||||
tinyglobby: 0.2.15
|
||||
ts-api-utils: 2.4.0(typescript@5.9.3)
|
||||
|
|
@ -6518,7 +6540,7 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@17.6.3)(terser@5.46.0))':
|
||||
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.0.2)(terser@5.46.0))':
|
||||
dependencies:
|
||||
'@ampproject/remapping': 2.3.0
|
||||
'@bcoe/v8-coverage': 1.0.2
|
||||
|
|
@ -6533,7 +6555,7 @@ snapshots:
|
|||
std-env: 3.10.0
|
||||
test-exclude: 7.0.1
|
||||
tinyrainbow: 2.0.0
|
||||
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@17.6.3)(terser@5.46.0)
|
||||
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.0.2)(terser@5.46.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
|
@ -6591,6 +6613,9 @@ snapshots:
|
|||
|
||||
acorn@8.15.0: {}
|
||||
|
||||
acorn@8.16.0:
|
||||
optional: true
|
||||
|
||||
agent-base@6.0.2:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
|
|
@ -6608,22 +6633,22 @@ snapshots:
|
|||
clean-stack: 2.2.0
|
||||
indent-string: 4.0.0
|
||||
|
||||
ajv-formats@3.0.1(ajv@8.17.1):
|
||||
ajv-formats@3.0.1(ajv@8.18.0):
|
||||
optionalDependencies:
|
||||
ajv: 8.17.1
|
||||
ajv: 8.18.0
|
||||
|
||||
ajv-keywords@3.5.2(ajv@6.12.6):
|
||||
ajv-keywords@3.5.2(ajv@6.14.0):
|
||||
dependencies:
|
||||
ajv: 6.12.6
|
||||
ajv: 6.14.0
|
||||
|
||||
ajv@6.12.6:
|
||||
ajv@6.14.0:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-json-stable-stringify: 2.1.0
|
||||
json-schema-traverse: 0.4.1
|
||||
uri-js: 4.4.1
|
||||
|
||||
ajv@8.17.1:
|
||||
ajv@8.18.0:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-uri: 3.1.0
|
||||
|
|
@ -6676,10 +6701,10 @@ snapshots:
|
|||
isbinaryfile: 5.0.7
|
||||
js-yaml: 4.1.1
|
||||
lazy-val: 1.0.5
|
||||
minimatch: 5.1.6
|
||||
minimatch: 5.1.7
|
||||
read-config-file: 6.3.2
|
||||
sanitize-filename: 1.6.3
|
||||
semver: 7.7.3
|
||||
semver: 7.7.4
|
||||
tar: 6.2.1
|
||||
temp-file: 3.4.0
|
||||
transitivePeerDependencies:
|
||||
|
|
@ -6715,7 +6740,7 @@ snapshots:
|
|||
js-yaml: 4.1.1
|
||||
json5: 2.2.3
|
||||
lazy-val: 1.0.5
|
||||
minimatch: 10.1.2
|
||||
minimatch: 10.2.2
|
||||
resedit: 1.7.2
|
||||
sanitize-filename: 1.6.3
|
||||
semver: 7.7.3
|
||||
|
|
@ -6903,6 +6928,8 @@ snapshots:
|
|||
|
||||
balanced-match@1.0.2: {}
|
||||
|
||||
balanced-match@4.0.3: {}
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
baseline-browser-mapping@2.9.14: {}
|
||||
|
|
@ -6937,6 +6964,10 @@ snapshots:
|
|||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
|
||||
brace-expansion@5.0.2:
|
||||
dependencies:
|
||||
balanced-match: 4.0.3
|
||||
|
||||
braces@3.0.3:
|
||||
dependencies:
|
||||
fill-range: 7.1.1
|
||||
|
|
@ -7344,11 +7375,11 @@ snapshots:
|
|||
dir-compare@3.3.0:
|
||||
dependencies:
|
||||
buffer-equal: 1.0.1
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.3
|
||||
|
||||
dir-compare@4.2.0:
|
||||
dependencies:
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.3
|
||||
p-limit: 3.1.0
|
||||
|
||||
dlv@1.1.3: {}
|
||||
|
|
@ -7372,7 +7403,7 @@ snapshots:
|
|||
dependencies:
|
||||
'@types/plist': 3.0.5
|
||||
'@types/verror': 1.10.11
|
||||
ajv: 6.12.6
|
||||
ajv: 6.14.0
|
||||
crc: 3.8.0
|
||||
iconv-corefoundation: 1.1.7
|
||||
plist: 3.1.0
|
||||
|
|
@ -7752,7 +7783,7 @@ snapshots:
|
|||
hasown: 2.0.2
|
||||
is-core-module: 2.16.1
|
||||
is-glob: 4.0.3
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.3
|
||||
object.fromentries: 2.0.8
|
||||
object.groupby: 1.0.3
|
||||
object.values: 1.2.1
|
||||
|
|
@ -7780,7 +7811,7 @@ snapshots:
|
|||
hasown: 2.0.2
|
||||
jsx-ast-utils: 3.3.5
|
||||
language-tags: 1.0.9
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.3
|
||||
object.fromentries: 2.0.8
|
||||
safe-regex-test: 1.1.0
|
||||
string.prototype.includes: 2.0.1
|
||||
|
|
@ -7812,7 +7843,7 @@ snapshots:
|
|||
estraverse: 5.3.0
|
||||
hasown: 2.0.2
|
||||
jsx-ast-utils: 3.3.5
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.3
|
||||
object.entries: 1.1.9
|
||||
object.fromentries: 2.0.8
|
||||
object.values: 1.2.1
|
||||
|
|
@ -7873,7 +7904,7 @@ snapshots:
|
|||
'@humanwhocodes/module-importer': 1.0.1
|
||||
'@humanwhocodes/retry': 0.4.3
|
||||
'@types/estree': 1.0.8
|
||||
ajv: 6.12.6
|
||||
ajv: 6.14.0
|
||||
chalk: 4.1.2
|
||||
cross-spawn: 7.0.6
|
||||
debug: 4.4.3
|
||||
|
|
@ -7892,7 +7923,7 @@ snapshots:
|
|||
is-glob: 4.0.3
|
||||
json-stable-stringify-without-jsonify: 1.0.1
|
||||
lodash.merge: 4.6.2
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.3
|
||||
natural-compare: 1.4.0
|
||||
optionator: 0.9.4
|
||||
optionalDependencies:
|
||||
|
|
@ -7960,8 +7991,8 @@ snapshots:
|
|||
fast-json-stringify@6.3.0:
|
||||
dependencies:
|
||||
'@fastify/merge-json-schemas': 0.2.1
|
||||
ajv: 8.17.1
|
||||
ajv-formats: 3.0.1(ajv@8.17.1)
|
||||
ajv: 8.18.0
|
||||
ajv-formats: 3.0.1(ajv@8.18.0)
|
||||
fast-uri: 3.1.0
|
||||
json-schema-ref-resolver: 3.0.0
|
||||
rfdc: 1.4.1
|
||||
|
|
@ -8016,7 +8047,7 @@ snapshots:
|
|||
|
||||
filelist@1.0.4:
|
||||
dependencies:
|
||||
minimatch: 5.1.6
|
||||
minimatch: 5.1.7
|
||||
|
||||
fill-range@7.1.1:
|
||||
dependencies:
|
||||
|
|
@ -8177,14 +8208,14 @@ snapshots:
|
|||
dependencies:
|
||||
foreground-child: 3.3.1
|
||||
jackspeak: 3.4.3
|
||||
minimatch: 9.0.5
|
||||
minimatch: 9.0.6
|
||||
minipass: 7.1.2
|
||||
package-json-from-dist: 1.0.1
|
||||
path-scurry: 1.11.1
|
||||
|
||||
glob@13.0.2:
|
||||
dependencies:
|
||||
minimatch: 10.1.2
|
||||
minimatch: 10.2.2
|
||||
minipass: 7.1.2
|
||||
path-scurry: 2.0.1
|
||||
|
||||
|
|
@ -8193,7 +8224,7 @@ snapshots:
|
|||
fs.realpath: 1.0.0
|
||||
inflight: 1.0.6
|
||||
inherits: 2.0.4
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.3
|
||||
once: 1.4.0
|
||||
path-is-absolute: 1.0.1
|
||||
|
||||
|
|
@ -8202,7 +8233,7 @@ snapshots:
|
|||
fs.realpath: 1.0.0
|
||||
inflight: 1.0.6
|
||||
inherits: 2.0.4
|
||||
minimatch: 5.1.6
|
||||
minimatch: 5.1.7
|
||||
once: 1.4.0
|
||||
|
||||
global-agent@3.0.0:
|
||||
|
|
@ -8251,9 +8282,10 @@ snapshots:
|
|||
optionalDependencies:
|
||||
uglify-js: 3.19.3
|
||||
|
||||
happy-dom@17.6.3:
|
||||
happy-dom@20.0.2:
|
||||
dependencies:
|
||||
webidl-conversions: 7.0.0
|
||||
'@types/node': 20.19.33
|
||||
'@types/whatwg-mimetype': 3.0.2
|
||||
whatwg-mimetype: 3.0.0
|
||||
|
||||
has-bigints@1.1.0: {}
|
||||
|
|
@ -9177,24 +9209,24 @@ snapshots:
|
|||
mimic-response@3.1.0: {}
|
||||
|
||||
minimatch@10.1.1:
|
||||
dependencies:
|
||||
'@isaacs/brace-expansion': 5.0.0
|
||||
|
||||
minimatch@10.1.2:
|
||||
dependencies:
|
||||
'@isaacs/brace-expansion': 5.0.1
|
||||
|
||||
minimatch@3.1.2:
|
||||
minimatch@10.2.2:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.2
|
||||
|
||||
minimatch@3.1.3:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.12
|
||||
|
||||
minimatch@5.1.6:
|
||||
minimatch@5.1.7:
|
||||
dependencies:
|
||||
brace-expansion: 2.0.2
|
||||
|
||||
minimatch@9.0.5:
|
||||
minimatch@9.0.6:
|
||||
dependencies:
|
||||
brace-expansion: 2.0.2
|
||||
brace-expansion: 5.0.2
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
|
|
@ -9685,7 +9717,7 @@ snapshots:
|
|||
|
||||
readdir-glob@1.1.3:
|
||||
dependencies:
|
||||
minimatch: 5.1.6
|
||||
minimatch: 5.1.7
|
||||
|
||||
readdirp@3.6.0:
|
||||
dependencies:
|
||||
|
|
@ -9910,6 +9942,8 @@ snapshots:
|
|||
|
||||
semver@7.7.3: {}
|
||||
|
||||
semver@7.7.4: {}
|
||||
|
||||
serialize-error@7.0.1:
|
||||
dependencies:
|
||||
type-fest: 0.13.1
|
||||
|
|
@ -10242,7 +10276,7 @@ snapshots:
|
|||
terser@5.46.0:
|
||||
dependencies:
|
||||
'@jridgewell/source-map': 0.3.11
|
||||
acorn: 8.15.0
|
||||
acorn: 8.16.0
|
||||
commander: 2.20.3
|
||||
source-map-support: 0.5.21
|
||||
optional: true
|
||||
|
|
@ -10251,7 +10285,7 @@ snapshots:
|
|||
dependencies:
|
||||
'@istanbuljs/schema': 0.1.3
|
||||
glob: 10.5.0
|
||||
minimatch: 9.0.5
|
||||
minimatch: 9.0.6
|
||||
|
||||
thenify-all@1.6.0:
|
||||
dependencies:
|
||||
|
|
@ -10393,6 +10427,8 @@ snapshots:
|
|||
|
||||
undici-types@5.26.5: {}
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
undici-types@7.16.0: {}
|
||||
|
||||
unified@11.0.5:
|
||||
|
|
@ -10542,7 +10578,7 @@ snapshots:
|
|||
fsevents: 2.3.3
|
||||
terser: 5.46.0
|
||||
|
||||
vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@17.6.3)(terser@5.46.0):
|
||||
vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.0.2)(terser@5.46.0):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/expect': 3.2.4
|
||||
|
|
@ -10570,7 +10606,7 @@ snapshots:
|
|||
optionalDependencies:
|
||||
'@types/debug': 4.1.12
|
||||
'@types/node': 25.0.7
|
||||
happy-dom: 17.6.3
|
||||
happy-dom: 20.0.2
|
||||
transitivePeerDependencies:
|
||||
- less
|
||||
- lightningcss
|
||||
|
|
@ -10588,8 +10624,6 @@ snapshots:
|
|||
dependencies:
|
||||
defaults: 1.0.4
|
||||
|
||||
webidl-conversions@7.0.0: {}
|
||||
|
||||
whatwg-mimetype@3.0.0: {}
|
||||
|
||||
which-boxed-primitive@1.1.1:
|
||||
|
|
|
|||
|
|
@ -47,4 +47,32 @@ export function registerSearchRoutes(app: FastifyInstance, services: HttpService
|
|||
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{
|
||||
Querystring: { q?: string; maxResults?: string };
|
||||
}>('/api/search', async (request) => {
|
||||
const query = request.query.q ?? '';
|
||||
|
||||
try {
|
||||
const validatedQuery = validateSearchQuery(query);
|
||||
if (!validatedQuery.valid) {
|
||||
logger.error(`GET global search rejected: ${validatedQuery.error ?? 'Invalid query'}`);
|
||||
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
|
||||
}
|
||||
|
||||
const maxResults = coerceSearchMaxResults(
|
||||
request.query.maxResults ? Number(request.query.maxResults) : undefined,
|
||||
50
|
||||
);
|
||||
|
||||
const result = await services.projectScanner.searchAllProjects(
|
||||
validatedQuery.value!,
|
||||
maxResults
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('Error in GET global search:', error);
|
||||
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { createLogger } from '@shared/utils/logger';
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { type ClaudeMdFileInfo, readAllClaudeMdFiles, readDirectoryClaudeMd } from '../services';
|
||||
import { type ClaudeMdFileInfo, readAgentConfigs, readAllClaudeMdFiles, readDirectoryClaudeMd } from '../services';
|
||||
import { validateFilePath } from '../utils/pathValidation';
|
||||
import { countTokens } from '../utils/tokenizer';
|
||||
|
||||
|
|
@ -123,4 +123,15 @@ export function registerUtilityRoutes(app: FastifyInstance): void {
|
|||
app.post<{ Body: { url: string } }>('/api/open-external', async () => {
|
||||
return { success: false, error: 'Not available in browser mode' };
|
||||
});
|
||||
|
||||
// Read agent configs
|
||||
app.post<{ Body: { projectRoot: string } }>('/api/read-agent-configs', async (request) => {
|
||||
try {
|
||||
const { projectRoot } = request.body;
|
||||
return await readAgentConfigs(projectRoot);
|
||||
} catch (error) {
|
||||
logger.error('Error in POST /api/read-agent-configs:', error);
|
||||
return {};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -339,7 +339,7 @@ function initializeServices(): void {
|
|||
});
|
||||
|
||||
ipcMain.handle(HTTP_SERVER_GET_STATUS, () => {
|
||||
return { running: httpServer.isRunning(), port: httpServer.getPort() };
|
||||
return { success: true, data: { running: httpServer.isRunning(), port: httpServer.getPort() } };
|
||||
});
|
||||
|
||||
// Forward SSH state changes to renderer and HTTP SSE clients
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export function initializeSearchHandlers(contextRegistry: ServiceContextRegistry
|
|||
*/
|
||||
export function registerSearchHandlers(ipcMain: IpcMain): void {
|
||||
ipcMain.handle('search-sessions', handleSearchSessions);
|
||||
ipcMain.handle('search-all-projects', handleSearchAllProjects);
|
||||
|
||||
logger.info('Search handlers registered');
|
||||
}
|
||||
|
|
@ -40,6 +41,7 @@ export function registerSearchHandlers(ipcMain: IpcMain): void {
|
|||
*/
|
||||
export function removeSearchHandlers(ipcMain: IpcMain): void {
|
||||
ipcMain.removeHandler('search-sessions');
|
||||
ipcMain.removeHandler('search-all-projects');
|
||||
|
||||
logger.info('Search handlers removed');
|
||||
}
|
||||
|
|
@ -81,3 +83,29 @@ async function handleSearchSessions(
|
|||
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for 'search-all-projects' IPC call.
|
||||
* Searches sessions across all projects for a query string.
|
||||
*/
|
||||
async function handleSearchAllProjects(
|
||||
_event: IpcMainInvokeEvent,
|
||||
query: string,
|
||||
maxResults?: number
|
||||
): Promise<SearchSessionsResult> {
|
||||
try {
|
||||
const validatedQuery = validateSearchQuery(query);
|
||||
if (!validatedQuery.valid) {
|
||||
logger.error(`search-all-projects rejected: ${validatedQuery.error ?? 'Invalid query'}`);
|
||||
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
|
||||
}
|
||||
|
||||
const { projectScanner } = registry.getActive();
|
||||
const safeMaxResults = coerceSearchMaxResults(maxResults, 50);
|
||||
const result = await projectScanner.searchAllProjects(validatedQuery.value!, safeMaxResults);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('Error in search-all-projects:', error);
|
||||
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ import { createLogger } from '@shared/utils/logger';
|
|||
import { app, type IpcMain, type IpcMainInvokeEvent, shell } from 'electron';
|
||||
import * as fs from 'fs';
|
||||
|
||||
import { type ClaudeMdFileInfo, readAllClaudeMdFiles, readDirectoryClaudeMd } from '../services';
|
||||
import { type ClaudeMdFileInfo, readAgentConfigs, readAllClaudeMdFiles, readDirectoryClaudeMd } from '../services';
|
||||
|
||||
import type { AgentConfig } from '@shared/types/api';
|
||||
|
||||
const logger = createLogger('IPC:utility');
|
||||
import { validateFilePath, validateOpenPath } from '../utils/pathValidation';
|
||||
|
|
@ -28,6 +30,7 @@ export function registerUtilityHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.handle('read-claude-md-files', handleReadClaudeMdFiles);
|
||||
ipcMain.handle('read-directory-claude-md', handleReadDirectoryClaudeMd);
|
||||
ipcMain.handle('read-mentioned-file', handleReadMentionedFile);
|
||||
ipcMain.handle('read-agent-configs', handleReadAgentConfigs);
|
||||
|
||||
logger.info('Utility handlers registered');
|
||||
}
|
||||
|
|
@ -42,6 +45,7 @@ export function removeUtilityHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler('read-claude-md-files');
|
||||
ipcMain.removeHandler('read-directory-claude-md');
|
||||
ipcMain.removeHandler('read-mentioned-file');
|
||||
ipcMain.removeHandler('read-agent-configs');
|
||||
|
||||
logger.info('Utility handlers removed');
|
||||
}
|
||||
|
|
@ -228,3 +232,19 @@ async function handleReadMentionedFile(
|
|||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for 'read-agent-configs' IPC call.
|
||||
* Reads agent definitions from project's .claude/agents/ directory.
|
||||
*/
|
||||
async function handleReadAgentConfigs(
|
||||
_event: IpcMainInvokeEvent,
|
||||
projectRoot: string
|
||||
): Promise<Record<string, AgentConfig>> {
|
||||
try {
|
||||
return await readAgentConfigs(projectRoot);
|
||||
} catch (error) {
|
||||
logger.error('Error in read-agent-configs:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1073,6 +1073,74 @@ export class ProjectScanner {
|
|||
return this.sessionSearcher.searchSessions(projectId, query, maxResults);
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches sessions across all projects for a query string.
|
||||
* Filters out noise messages and returns matching content.
|
||||
*
|
||||
* @param query - Search query string
|
||||
* @param maxResults - Maximum number of results to return (default 50)
|
||||
*/
|
||||
async searchAllProjects(query: string, maxResults: number = 50): Promise<SearchSessionsResult> {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
if (!query || query.trim().length === 0) {
|
||||
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
|
||||
}
|
||||
|
||||
// Get all projects
|
||||
const projects = await this.scan();
|
||||
|
||||
if (projects.length === 0) {
|
||||
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
|
||||
}
|
||||
|
||||
// Search across all projects with bounded concurrency
|
||||
const allResults: SearchSessionsResult[] = [];
|
||||
const searchBatchSize = this.fsProvider.type === 'ssh' ? 2 : 4;
|
||||
|
||||
for (let i = 0; i < projects.length; i += searchBatchSize) {
|
||||
const batch = projects.slice(i, i + searchBatchSize);
|
||||
const batchResults = await Promise.allSettled(
|
||||
batch.map((project) => this.sessionSearcher.searchSessions(project.id, query, maxResults))
|
||||
);
|
||||
|
||||
for (const result of batchResults) {
|
||||
if (result.status === 'fulfilled') {
|
||||
allResults.push(result.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we have enough results already
|
||||
const totalMatches = allResults.reduce((sum, r) => sum + r.totalMatches, 0);
|
||||
if (totalMatches >= maxResults) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Merge results from all projects
|
||||
const mergedResults = allResults.flatMap((r) => r.results);
|
||||
const totalSessionsSearched = allResults.reduce((sum, r) => sum + r.sessionsSearched, 0);
|
||||
|
||||
// Sort by timestamp (most recent first) and limit to maxResults
|
||||
mergedResults.sort((a, b) => b.timestamp - a.timestamp);
|
||||
const limitedResults = mergedResults.slice(0, maxResults);
|
||||
|
||||
logger.debug(
|
||||
`Global search completed: ${limitedResults.length} results from ${totalSessionsSearched} sessions across ${projects.length} projects in ${Date.now() - startedAt}ms`
|
||||
);
|
||||
|
||||
return {
|
||||
results: limitedResults,
|
||||
totalMatches: limitedResults.length,
|
||||
sessionsSearched: totalSessionsSearched,
|
||||
query,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error searching all projects:', error);
|
||||
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve best-available file timestamps from directory entry metadata or stat fallback.
|
||||
*/
|
||||
|
|
|
|||
95
src/main/services/discovery/SearchTextCache.ts
Normal file
95
src/main/services/discovery/SearchTextCache.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
/**
|
||||
* SearchTextCache - LRU cache for extracted search text with mtime invalidation.
|
||||
*
|
||||
* Caches SearchTextResult per session file path. Entries are small (~1KB each,
|
||||
* just text + metadata), so 200 entries is a reasonable default.
|
||||
*
|
||||
* Invalidation: mtime comparison on get(). If the file's mtime has changed
|
||||
* since caching, the entry is considered stale and undefined is returned.
|
||||
* No TTL needed — mtime check is sufficient.
|
||||
*/
|
||||
|
||||
import type { SearchableEntry } from './SearchTextExtractor';
|
||||
|
||||
interface CacheEntry {
|
||||
entries: SearchableEntry[];
|
||||
sessionTitle: string | undefined;
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
export class SearchTextCache {
|
||||
private readonly cache = new Map<string, CacheEntry>();
|
||||
private readonly maxSize: number;
|
||||
|
||||
constructor(maxSize: number = 200) {
|
||||
this.maxSize = maxSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached entries for a file path if the mtime matches.
|
||||
* Returns undefined if not cached or stale.
|
||||
*/
|
||||
get(
|
||||
filePath: string,
|
||||
mtimeMs: number
|
||||
): { entries: SearchableEntry[]; sessionTitle: string | undefined } | undefined {
|
||||
const entry = this.cache.get(filePath);
|
||||
if (!entry) return undefined;
|
||||
|
||||
// Stale — file was modified since we cached it
|
||||
if (entry.mtimeMs !== mtimeMs) {
|
||||
this.cache.delete(filePath);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// LRU: delete and re-insert to move to end (most recent)
|
||||
this.cache.delete(filePath);
|
||||
this.cache.set(filePath, entry);
|
||||
|
||||
return { entries: entry.entries, sessionTitle: entry.sessionTitle };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache extracted entries for a file path.
|
||||
*/
|
||||
set(
|
||||
filePath: string,
|
||||
mtimeMs: number,
|
||||
entries: SearchableEntry[],
|
||||
sessionTitle: string | undefined
|
||||
): void {
|
||||
// If already exists, delete first to update position
|
||||
this.cache.delete(filePath);
|
||||
|
||||
// Evict oldest if at capacity
|
||||
if (this.cache.size >= this.maxSize) {
|
||||
const oldest = this.cache.keys().next().value;
|
||||
if (oldest !== undefined) {
|
||||
this.cache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
this.cache.set(filePath, { entries, sessionTitle, mtimeMs });
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a specific entry from the cache.
|
||||
*/
|
||||
invalidate(filePath: string): void {
|
||||
this.cache.delete(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached entries.
|
||||
*/
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Current number of cached entries.
|
||||
*/
|
||||
get size(): number {
|
||||
return this.cache.size;
|
||||
}
|
||||
}
|
||||
159
src/main/services/discovery/SearchTextExtractor.ts
Normal file
159
src/main/services/discovery/SearchTextExtractor.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
/**
|
||||
* SearchTextExtractor - Lightweight text extraction for search.
|
||||
*
|
||||
* Mirrors ChunkBuilder's classification loop (classifyMessages → buffer flush)
|
||||
* but only extracts searchable text + metadata, skipping all expensive operations:
|
||||
* - No tool execution building
|
||||
* - No semantic step extraction
|
||||
* - No subagent linking
|
||||
* - No timeline gap filling
|
||||
* - No metrics calculation
|
||||
*/
|
||||
|
||||
import { classifyMessages } from '@main/services/parsing/MessageClassifier';
|
||||
import { sanitizeDisplayContent } from '@shared/utils/contentSanitizer';
|
||||
|
||||
import type { ParsedMessage } from '@main/types';
|
||||
|
||||
/**
|
||||
* A lightweight entry containing only the data needed for search matching.
|
||||
*/
|
||||
export interface SearchableEntry {
|
||||
text: string;
|
||||
groupId: string;
|
||||
messageType: 'user' | 'assistant';
|
||||
itemType: 'user' | 'ai';
|
||||
timestamp: number;
|
||||
messageUuid: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of extracting searchable text from a session's messages.
|
||||
*/
|
||||
export interface SearchTextResult {
|
||||
entries: SearchableEntry[];
|
||||
sessionTitle: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract searchable text entries from parsed messages.
|
||||
*
|
||||
* Algorithm mirrors ChunkBuilder.buildChunks() lines 78-151:
|
||||
* - Filter to main thread (!m.isSidechain)
|
||||
* - classifyMessages() — cheap type guard checks
|
||||
* - Walk classified messages with an aiBuffer:
|
||||
* - hardNoise → skip
|
||||
* - compact / system / user → flush AI buffer, then handle
|
||||
* - ai → push to buffer
|
||||
* - Flush remaining buffer at end
|
||||
*/
|
||||
export function extractSearchableEntries(messages: ParsedMessage[]): SearchTextResult {
|
||||
const entries: SearchableEntry[] = [];
|
||||
let sessionTitle: string | undefined;
|
||||
|
||||
// Filter to main thread messages (non-sidechain) — same as ChunkBuilder line 82
|
||||
const mainMessages = messages.filter((m) => !m.isSidechain);
|
||||
const classified = classifyMessages(mainMessages);
|
||||
|
||||
let aiBuffer: ParsedMessage[] = [];
|
||||
|
||||
for (const { message, category } of classified) {
|
||||
switch (category) {
|
||||
case 'hardNoise':
|
||||
// Skip — filtered out
|
||||
break;
|
||||
|
||||
case 'compact':
|
||||
case 'system':
|
||||
// Flush AI buffer, but compact/system messages have no searchable text
|
||||
if (aiBuffer.length > 0) {
|
||||
const aiEntry = extractAIEntry(aiBuffer);
|
||||
if (aiEntry) entries.push(aiEntry);
|
||||
aiBuffer = [];
|
||||
}
|
||||
break;
|
||||
|
||||
case 'user': {
|
||||
// Flush AI buffer
|
||||
if (aiBuffer.length > 0) {
|
||||
const aiEntry = extractAIEntry(aiBuffer);
|
||||
if (aiEntry) entries.push(aiEntry);
|
||||
aiBuffer = [];
|
||||
}
|
||||
// Extract user text
|
||||
const userText = extractUserText(message);
|
||||
if (userText) {
|
||||
if (!sessionTitle) {
|
||||
sessionTitle = userText.slice(0, 100);
|
||||
}
|
||||
entries.push({
|
||||
text: userText,
|
||||
groupId: `user-${message.uuid}`,
|
||||
messageType: 'user',
|
||||
itemType: 'user',
|
||||
timestamp: message.timestamp.getTime(),
|
||||
messageUuid: message.uuid,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ai':
|
||||
aiBuffer.push(message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining AI buffer
|
||||
if (aiBuffer.length > 0) {
|
||||
const aiEntry = extractAIEntry(aiBuffer);
|
||||
if (aiEntry) entries.push(aiEntry);
|
||||
}
|
||||
|
||||
return { entries, sessionTitle };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the last text output from an AI message buffer.
|
||||
* Scans backward for the last assistant message with a text content block.
|
||||
*/
|
||||
function extractAIEntry(buffer: ParsedMessage[]): SearchableEntry | null {
|
||||
// Scan backward for last assistant message with text content
|
||||
for (let i = buffer.length - 1; i >= 0; i--) {
|
||||
const msg = buffer[i];
|
||||
if (msg.role !== 'assistant' || !Array.isArray(msg.content)) continue;
|
||||
|
||||
// Find the last text block in this message
|
||||
for (let j = msg.content.length - 1; j >= 0; j--) {
|
||||
const block = msg.content[j];
|
||||
if (block.type === 'text' && block.text) {
|
||||
return {
|
||||
text: block.text,
|
||||
groupId: `ai-${buffer[0].uuid}`,
|
||||
messageType: 'assistant',
|
||||
itemType: 'ai',
|
||||
timestamp: msg.timestamp.getTime(),
|
||||
messageUuid: msg.uuid,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract searchable text from a user message.
|
||||
* Shared logic previously in SessionSearcher.extractUserSearchableText().
|
||||
*/
|
||||
export function extractUserText(message: ParsedMessage): string {
|
||||
let rawText = '';
|
||||
if (typeof message.content === 'string') {
|
||||
rawText = message.content;
|
||||
} else if (Array.isArray(message.content)) {
|
||||
rawText = message.content
|
||||
.filter((block) => block.type === 'text')
|
||||
.map((block) => block.text)
|
||||
.join('');
|
||||
}
|
||||
return sanitizeDisplayContent(rawText);
|
||||
}
|
||||
|
|
@ -6,21 +6,14 @@
|
|||
* - Search within a single session file
|
||||
* - Restrict matching scope to User text + AI last text output
|
||||
* - Extract context around each match occurrence
|
||||
*
|
||||
* Uses SearchTextExtractor for lightweight text extraction (skips ChunkBuilder)
|
||||
* and SearchTextCache for mtime-based caching of extracted entries.
|
||||
*/
|
||||
|
||||
import { ChunkBuilder } from '@main/services/analysis/ChunkBuilder';
|
||||
import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFileSystemProvider';
|
||||
import {
|
||||
isEnhancedAIChunk,
|
||||
isUserChunk,
|
||||
type ParsedMessage,
|
||||
type SearchResult,
|
||||
type SearchSessionsResult,
|
||||
type SemanticStep,
|
||||
} from '@main/types';
|
||||
import { parseJsonlFile } from '@main/utils/jsonl';
|
||||
import { extractBaseDir, extractSessionId } from '@main/utils/pathDecoder';
|
||||
import { sanitizeDisplayContent } from '@shared/utils/contentSanitizer';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import {
|
||||
extractMarkdownPlainText,
|
||||
|
|
@ -28,36 +21,31 @@ import {
|
|||
} from '@shared/utils/markdownTextSearch';
|
||||
import * as path from 'path';
|
||||
|
||||
import { SearchTextCache } from './SearchTextCache';
|
||||
import { extractSearchableEntries } from './SearchTextExtractor';
|
||||
import { subprojectRegistry } from './SubprojectRegistry';
|
||||
|
||||
import type { SearchableEntry } from './SearchTextExtractor';
|
||||
import type { FileSystemProvider } from '@main/services/infrastructure/FileSystemProvider';
|
||||
import type { SearchResult, SearchSessionsResult } from '@main/types';
|
||||
|
||||
const logger = createLogger('Discovery:SessionSearcher');
|
||||
const SSH_FAST_SEARCH_STAGE_LIMITS = [40, 140, 320] as const;
|
||||
const SSH_FAST_SEARCH_MIN_RESULTS = 8;
|
||||
const SSH_FAST_SEARCH_TIME_BUDGET_MS = 4500;
|
||||
|
||||
interface SearchableEntry {
|
||||
text: string;
|
||||
groupId: string;
|
||||
messageType: 'user' | 'assistant';
|
||||
itemType: 'user' | 'ai';
|
||||
timestamp: number;
|
||||
messageUuid: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SessionSearcher provides methods for searching sessions.
|
||||
*/
|
||||
export class SessionSearcher {
|
||||
private readonly projectsDir: string;
|
||||
private readonly chunkBuilder: ChunkBuilder;
|
||||
private readonly fsProvider: FileSystemProvider;
|
||||
private readonly searchCache: SearchTextCache;
|
||||
|
||||
constructor(projectsDir: string, fsProvider?: FileSystemProvider) {
|
||||
this.projectsDir = projectsDir;
|
||||
this.chunkBuilder = new ChunkBuilder();
|
||||
this.fsProvider = fsProvider ?? new LocalFileSystemProvider();
|
||||
this.searchCache = new SearchTextCache();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -151,7 +139,8 @@ export class SessionSearcher {
|
|||
sessionId,
|
||||
file.filePath,
|
||||
normalizedQuery,
|
||||
maxResults
|
||||
maxResults,
|
||||
file.mtimeMs
|
||||
);
|
||||
})
|
||||
);
|
||||
|
|
@ -207,11 +196,15 @@ export class SessionSearcher {
|
|||
/**
|
||||
* Searches a single session file for a query string.
|
||||
*
|
||||
* Uses SearchTextExtractor for lightweight text extraction (no ChunkBuilder)
|
||||
* and SearchTextCache for mtime-based caching.
|
||||
*
|
||||
* @param projectId - The project ID
|
||||
* @param sessionId - The session ID
|
||||
* @param filePath - Path to the session file
|
||||
* @param query - Normalized search query (lowercase)
|
||||
* @param maxResults - Maximum number of results to return
|
||||
* @param mtimeMs - File modification time for cache invalidation
|
||||
* @returns Array of search results
|
||||
*/
|
||||
async searchSessionFile(
|
||||
|
|
@ -219,71 +212,35 @@ export class SessionSearcher {
|
|||
sessionId: string,
|
||||
filePath: string,
|
||||
query: string,
|
||||
maxResults: number
|
||||
maxResults: number,
|
||||
mtimeMs: number
|
||||
): Promise<SearchResult[]> {
|
||||
const results: SearchResult[] = [];
|
||||
let sessionTitle: string | undefined;
|
||||
const messages = await parseJsonlFile(filePath, this.fsProvider);
|
||||
const chunks = this.chunkBuilder.buildChunks(messages, []);
|
||||
|
||||
for (const chunk of chunks) {
|
||||
if (results.length >= maxResults) {
|
||||
break;
|
||||
}
|
||||
// Check cache first
|
||||
let cached = this.searchCache.get(filePath, mtimeMs);
|
||||
if (!cached) {
|
||||
// Cache miss — parse and extract
|
||||
const messages = await parseJsonlFile(filePath, this.fsProvider);
|
||||
const extracted = extractSearchableEntries(messages);
|
||||
this.searchCache.set(filePath, mtimeMs, extracted.entries, extracted.sessionTitle);
|
||||
cached = extracted;
|
||||
}
|
||||
|
||||
if (isUserChunk(chunk)) {
|
||||
const userText = this.extractUserSearchableText(chunk.userMessage);
|
||||
if (!sessionTitle && userText) {
|
||||
sessionTitle = userText.slice(0, 100);
|
||||
}
|
||||
if (!userText) {
|
||||
continue;
|
||||
}
|
||||
const searchableEntry: SearchableEntry = {
|
||||
text: userText,
|
||||
groupId: chunk.id,
|
||||
messageType: 'user',
|
||||
itemType: 'user',
|
||||
timestamp: chunk.userMessage.timestamp.getTime(),
|
||||
messageUuid: chunk.userMessage.uuid,
|
||||
};
|
||||
this.collectMatchesForEntry(
|
||||
searchableEntry,
|
||||
query,
|
||||
results,
|
||||
maxResults,
|
||||
projectId,
|
||||
sessionId,
|
||||
sessionTitle
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const { entries, sessionTitle } = cached;
|
||||
|
||||
if (isEnhancedAIChunk(chunk)) {
|
||||
const lastOutputStep = this.findLastOutputTextStep(chunk.semanticSteps);
|
||||
const outputText = lastOutputStep?.content.outputText;
|
||||
if (!lastOutputStep || !outputText) {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (results.length >= maxResults) break;
|
||||
|
||||
const searchableEntry: SearchableEntry = {
|
||||
text: outputText,
|
||||
groupId: chunk.id,
|
||||
messageType: 'assistant',
|
||||
itemType: 'ai',
|
||||
timestamp: lastOutputStep.startTime.getTime(),
|
||||
messageUuid: lastOutputStep.sourceMessageId ?? chunk.responses[0]?.uuid ?? '',
|
||||
};
|
||||
this.collectMatchesForEntry(
|
||||
searchableEntry,
|
||||
query,
|
||||
results,
|
||||
maxResults,
|
||||
projectId,
|
||||
sessionId,
|
||||
sessionTitle
|
||||
);
|
||||
}
|
||||
this.collectMatchesForEntry(
|
||||
entry,
|
||||
query,
|
||||
results,
|
||||
maxResults,
|
||||
projectId,
|
||||
sessionId,
|
||||
sessionTitle
|
||||
);
|
||||
}
|
||||
|
||||
return results;
|
||||
|
|
@ -342,29 +299,6 @@ export class SessionSearcher {
|
|||
}
|
||||
}
|
||||
|
||||
private extractUserSearchableText(message: ParsedMessage): string {
|
||||
let rawText = '';
|
||||
if (typeof message.content === 'string') {
|
||||
rawText = message.content;
|
||||
} else if (Array.isArray(message.content)) {
|
||||
rawText = message.content
|
||||
.filter((block) => block.type === 'text')
|
||||
.map((block) => block.text)
|
||||
.join('');
|
||||
}
|
||||
return sanitizeDisplayContent(rawText);
|
||||
}
|
||||
|
||||
private findLastOutputTextStep(steps: SemanticStep[]): SemanticStep | null {
|
||||
for (let i = steps.length - 1; i >= 0; i--) {
|
||||
const step = steps[i];
|
||||
if (step.type === 'output' && step.content.outputText) {
|
||||
return step;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async collectFulfilledInBatches<T, R>(
|
||||
items: T[],
|
||||
batchSize: number,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@
|
|||
|
||||
export * from './ProjectPathResolver';
|
||||
export * from './ProjectScanner';
|
||||
export * from './SearchTextCache';
|
||||
export * from './SearchTextExtractor';
|
||||
export * from './SessionContentFilter';
|
||||
export * from './SessionSearcher';
|
||||
export * from './SubagentLocator';
|
||||
|
|
|
|||
|
|
@ -43,11 +43,25 @@ export class LocalFileSystemProvider implements FileSystemProvider {
|
|||
|
||||
async readdir(dirPath: string): Promise<FsDirent[]> {
|
||||
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
||||
return entries.map((entry) => ({
|
||||
name: entry.name,
|
||||
isFile: () => entry.isFile(),
|
||||
isDirectory: () => entry.isDirectory(),
|
||||
}));
|
||||
// Stat all entries concurrently to populate mtimeMs, used by SessionSearcher's
|
||||
// mtime-based cache invalidation. Failures are silently ignored (mtimeMs stays undefined).
|
||||
return Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
let mtimeMs: number | undefined;
|
||||
try {
|
||||
const stat = await fs.promises.stat(`${dirPath}/${entry.name}`);
|
||||
mtimeMs = stat.mtimeMs;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return {
|
||||
name: entry.name,
|
||||
mtimeMs,
|
||||
isFile: () => entry.isFile(),
|
||||
isDirectory: () => entry.isDirectory(),
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
createReadStream(filePath: string, opts?: ReadStreamOptions): fs.ReadStream {
|
||||
|
|
|
|||
75
src/main/services/parsing/AgentConfigReader.ts
Normal file
75
src/main/services/parsing/AgentConfigReader.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* Agent Config Reader
|
||||
*
|
||||
* Reads `.claude/agents/*.md` files from a project directory and extracts
|
||||
* frontmatter metadata (name, color) for use in subagent visualization.
|
||||
*/
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import type { AgentConfig } from '@shared/types/api';
|
||||
|
||||
const logger = createLogger('AgentConfigReader');
|
||||
|
||||
/**
|
||||
* Parse simple YAML frontmatter from markdown content.
|
||||
* Only extracts top-level scalar key: value pairs between --- delimiters.
|
||||
*/
|
||||
function parseFrontmatter(content: string): Record<string, string> {
|
||||
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content);
|
||||
if (!match) return {};
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
for (const line of match[1].split('\n')) {
|
||||
const colonIdx = line.indexOf(':');
|
||||
if (colonIdx === -1) continue;
|
||||
const key = line.slice(0, colonIdx).trim();
|
||||
let value = line.slice(colonIdx + 1).trim();
|
||||
// Strip surrounding quotes
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (key) result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read agent config files from a project's `.claude/agents/` directory.
|
||||
* Returns a map of agent name → config (with optional color).
|
||||
*/
|
||||
export async function readAgentConfigs(
|
||||
projectRoot: string
|
||||
): Promise<Record<string, AgentConfig>> {
|
||||
const agentsDir = path.join(projectRoot, '.claude', 'agents');
|
||||
const result: Record<string, AgentConfig> = {};
|
||||
|
||||
try {
|
||||
const entries = await fs.promises.readdir(agentsDir);
|
||||
const mdFiles = entries.filter((f) => f.endsWith('.md'));
|
||||
|
||||
await Promise.all(
|
||||
mdFiles.map(async (filename) => {
|
||||
try {
|
||||
const content = await fs.promises.readFile(path.join(agentsDir, filename), 'utf8');
|
||||
const frontmatter = parseFrontmatter(content);
|
||||
const name = frontmatter.name || filename.replace(/\.md$/, '');
|
||||
const config: AgentConfig = { name };
|
||||
if (frontmatter.color) {
|
||||
config.color = frontmatter.color;
|
||||
}
|
||||
result[name] = config;
|
||||
} catch {
|
||||
// Skip unreadable files
|
||||
}
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
// Directory doesn't exist or unreadable — normal for projects without custom agents
|
||||
logger.debug(`No agents directory at ${agentsDir}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
* - GitIdentityResolver: Resolves git identities from sessions
|
||||
*/
|
||||
|
||||
export * from './AgentConfigReader';
|
||||
export * from './ClaudeMdReader';
|
||||
export * from './GitIdentityResolver';
|
||||
export * from './MessageClassifier';
|
||||
|
|
|
|||
|
|
@ -156,6 +156,8 @@ const electronAPI: ElectronAPI = {
|
|||
) => ipcRenderer.invoke('get-sessions-paginated', projectId, cursor, limit, options),
|
||||
searchSessions: (projectId: string, query: string, maxResults?: number) =>
|
||||
ipcRenderer.invoke('search-sessions', projectId, query, maxResults),
|
||||
searchAllProjects: (query: string, maxResults?: number) =>
|
||||
ipcRenderer.invoke('search-all-projects', query, maxResults),
|
||||
getSessionDetail: (projectId: string, sessionId: string) =>
|
||||
ipcRenderer.invoke('get-session-detail', projectId, sessionId),
|
||||
getSessionMetrics: (projectId: string, sessionId: string) =>
|
||||
|
|
@ -188,6 +190,10 @@ const electronAPI: ElectronAPI = {
|
|||
readMentionedFile: (absolutePath: string, projectRoot: string, maxTokens?: number) =>
|
||||
ipcRenderer.invoke('read-mentioned-file', absolutePath, projectRoot, maxTokens),
|
||||
|
||||
// Agent config reading
|
||||
readAgentConfigs: (projectRoot: string) =>
|
||||
ipcRenderer.invoke('read-agent-configs', projectRoot),
|
||||
|
||||
// Notifications API
|
||||
notifications: {
|
||||
get: (options?: { limit?: number; offset?: number }) =>
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ import type {
|
|||
WaterfallData,
|
||||
WslClaudeRootCandidate,
|
||||
} from '@shared/types';
|
||||
import type { AgentConfig } from '@shared/types/api';
|
||||
|
||||
export class HttpAPIClient implements ElectronAPI {
|
||||
private baseUrl: string;
|
||||
|
|
@ -228,6 +229,12 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
);
|
||||
};
|
||||
|
||||
searchAllProjects = (query: string, maxResults?: number): Promise<SearchSessionsResult> => {
|
||||
const params = new URLSearchParams({ q: query });
|
||||
if (maxResults) params.set('maxResults', String(maxResults));
|
||||
return this.get<SearchSessionsResult>(`/api/search?${params}`);
|
||||
};
|
||||
|
||||
getSessionDetail = (projectId: string, sessionId: string): Promise<SessionDetail | null> =>
|
||||
this.get<SessionDetail | null>(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}`
|
||||
|
|
@ -317,6 +324,13 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
maxTokens,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent config reading
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
readAgentConfigs = (projectRoot: string): Promise<Record<string, AgentConfig>> =>
|
||||
this.post<Record<string, AgentConfig>>('/api/read-agent-configs', { projectRoot });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Notifications (nested API)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -215,6 +215,17 @@ export const ContextBadge = ({
|
|||
[newTaskCoordinationInjections]
|
||||
);
|
||||
|
||||
// Compute actual item counts (not injection-object counts) for accurate badge display
|
||||
const toolOutputCount = useMemo(
|
||||
() => newToolOutputInjections.reduce((sum, inj) => sum + inj.toolCount, 0),
|
||||
[newToolOutputInjections]
|
||||
);
|
||||
|
||||
const taskCoordinationCount = useMemo(
|
||||
() => newTaskCoordinationInjections.reduce((sum, inj) => sum + inj.breakdown.length, 0),
|
||||
[newTaskCoordinationInjections]
|
||||
);
|
||||
|
||||
const userMessageTokens = useMemo(
|
||||
() => newUserMessageInjections.reduce((sum, inj) => sum + inj.estimatedTokens, 0),
|
||||
[newUserMessageInjections]
|
||||
|
|
@ -478,10 +489,7 @@ export const ContextBadge = ({
|
|||
{newToolOutputInjections.length > 0 && (
|
||||
<PopoverSection
|
||||
title="Tool Outputs"
|
||||
count={newToolOutputInjections.reduce(
|
||||
(sum, inj) => sum + inj.toolBreakdown.length,
|
||||
0
|
||||
)}
|
||||
count={toolOutputCount}
|
||||
tokenCount={toolOutputTokens}
|
||||
>
|
||||
{newToolOutputInjections.map((injection) =>
|
||||
|
|
@ -504,10 +512,7 @@ export const ContextBadge = ({
|
|||
{newTaskCoordinationInjections.length > 0 && (
|
||||
<PopoverSection
|
||||
title="Task Coordination"
|
||||
count={newTaskCoordinationInjections.reduce(
|
||||
(sum, inj) => sum + inj.breakdown.length,
|
||||
0
|
||||
)}
|
||||
count={taskCoordinationCount}
|
||||
tokenCount={taskCoordinationTokens}
|
||||
>
|
||||
{newTaskCoordinationInjections.map((injection) =>
|
||||
|
|
|
|||
|
|
@ -11,11 +11,8 @@ import {
|
|||
CARD_TEXT_LIGHTER,
|
||||
COLOR_TEXT_MUTED,
|
||||
COLOR_TEXT_SECONDARY,
|
||||
TAG_BG,
|
||||
TAG_BORDER,
|
||||
TAG_TEXT,
|
||||
} from '@renderer/constants/cssVariables';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { getSubagentTypeColorSet, getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useTabUI } from '@renderer/hooks/useTabUI';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { buildDisplayItemsFromMessages, buildSummary } from '@renderer/utils/aiGroupEnhancer';
|
||||
|
|
@ -78,8 +75,13 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
|
|||
const subagentType = subagent.subagentType ?? 'Task';
|
||||
const truncatedDesc = description.length > 60 ? description.slice(0, 60) + '...' : description;
|
||||
|
||||
// Agent configs from .claude/agents/ for color lookup
|
||||
const agentConfigs = useStore((s) => s.agentConfigs);
|
||||
|
||||
// Team member colors (when this subagent is a team member)
|
||||
const teamColors = subagent.team ? getTeamColorSet(subagent.team.memberColor) : null;
|
||||
// Type-based colors for non-team subagents (from agent config or deterministic hash)
|
||||
const typeColors = !teamColors ? getSubagentTypeColorSet(subagentType, agentConfigs) : null;
|
||||
|
||||
// Detect shutdown-only team activations (trivial: just a shutdown_response)
|
||||
const isShutdownOnly = useMemo(() => {
|
||||
|
|
@ -285,11 +287,11 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
|
|||
style={{ color: CARD_ICON_MUTED }}
|
||||
/>
|
||||
|
||||
{/* Icon - colored dot for team members, Bot icon for regular subagents */}
|
||||
{teamColors ? (
|
||||
{/* Icon - colored dot for team members/typed subagents, Bot icon for generic */}
|
||||
{teamColors || typeColors ? (
|
||||
<span
|
||||
className="size-3.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: teamColors.border }}
|
||||
style={{ backgroundColor: (teamColors ?? typeColors)!.border }}
|
||||
/>
|
||||
) : (
|
||||
<Bot
|
||||
|
|
@ -298,7 +300,7 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Type badge - team member name or generic type */}
|
||||
{/* Type badge - team member name or typed subagent */}
|
||||
{teamColors && subagent.team ? (
|
||||
<span
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
|
||||
|
|
@ -314,9 +316,9 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
|
|||
<span
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide"
|
||||
style={{
|
||||
backgroundColor: TAG_BG,
|
||||
color: TAG_TEXT,
|
||||
border: `1px solid ${TAG_BORDER}`,
|
||||
backgroundColor: typeColors!.badge,
|
||||
color: typeColors!.text,
|
||||
border: `1px solid ${typeColors!.border}40`,
|
||||
}}
|
||||
>
|
||||
{subagentType}
|
||||
|
|
|
|||
142
src/renderer/components/common/ExportDropdown.tsx
Normal file
142
src/renderer/components/common/ExportDropdown.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
/**
|
||||
* ExportDropdown - Download icon button with dropdown for exporting session data.
|
||||
*
|
||||
* Supports three formats: Markdown (.md), JSON (.json), Plain Text (.txt).
|
||||
* Follows the same close-on-outside-click / Escape patterns as RepositoryDropdown.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { triggerDownload } from '@renderer/utils/sessionExporter';
|
||||
import { Braces, Download, FileText, Type } from 'lucide-react';
|
||||
|
||||
import type { SessionDetail } from '@renderer/types/data';
|
||||
import type { ExportFormat } from '@renderer/utils/sessionExporter';
|
||||
|
||||
interface ExportDropdownProps {
|
||||
sessionDetail: SessionDetail;
|
||||
}
|
||||
|
||||
interface FormatOption {
|
||||
format: ExportFormat;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
ext: string;
|
||||
}
|
||||
|
||||
const FORMAT_OPTIONS: FormatOption[] = [
|
||||
{ format: 'markdown', label: 'Markdown', icon: FileText, ext: '.md' },
|
||||
{ format: 'json', label: 'JSON', icon: Braces, ext: '.json' },
|
||||
{ format: 'plaintext', label: 'Plain Text', icon: Type, ext: '.txt' },
|
||||
];
|
||||
|
||||
export const ExportDropdown = ({
|
||||
sessionDetail,
|
||||
}: Readonly<ExportDropdownProps>): React.JSX.Element => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [buttonHover, setButtonHover] = useState(false);
|
||||
const [hoveredFormat, setHoveredFormat] = useState<ExportFormat | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent): void => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [isOpen]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleEscape = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleExport = useCallback(
|
||||
(format: ExportFormat) => {
|
||||
triggerDownload(sessionDetail, format);
|
||||
setIsOpen(false);
|
||||
},
|
||||
[sessionDetail]
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
{/* Trigger button */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
onMouseEnter={() => setButtonHover(true)}
|
||||
onMouseLeave={() => setButtonHover(false)}
|
||||
className="rounded-md p-2 transition-colors"
|
||||
style={{
|
||||
color: buttonHover || isOpen ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
backgroundColor: buttonHover || isOpen ? 'var(--color-surface-raised)' : 'transparent',
|
||||
}}
|
||||
title="Export session"
|
||||
>
|
||||
<Download className="size-4" />
|
||||
</button>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="absolute right-0 top-full z-50 mt-1 w-48 overflow-hidden rounded-md border shadow-lg"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-overlay)',
|
||||
borderColor: 'var(--color-border)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="px-3 py-2 text-xs font-medium"
|
||||
style={{
|
||||
color: 'var(--color-text-secondary)',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
}}
|
||||
>
|
||||
Export Session
|
||||
</div>
|
||||
|
||||
{/* Format options */}
|
||||
{FORMAT_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.format}
|
||||
onClick={() => handleExport(option.format)}
|
||||
onMouseEnter={() => setHoveredFormat(option.format)}
|
||||
onMouseLeave={() => setHoveredFormat(null)}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-xs transition-colors"
|
||||
style={{
|
||||
color:
|
||||
hoveredFormat === option.format
|
||||
? 'var(--color-text)'
|
||||
: 'var(--color-text-secondary)',
|
||||
backgroundColor:
|
||||
hoveredFormat === option.format ? 'var(--color-surface-raised)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<option.icon className="size-3.5" />
|
||||
<span className="flex-1">{option.label}</span>
|
||||
<span className="text-[10px]" style={{ color: 'var(--color-text-muted)' }}>
|
||||
{option.ext}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -17,6 +17,8 @@ import { useStore } from '@renderer/store';
|
|||
import { Bell, PanelLeft, Plus, RefreshCw, Search, Settings, Users } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { ExportDropdown } from '../common/ExportDropdown';
|
||||
|
||||
import { SortableTab } from './SortableTab';
|
||||
import { TabContextMenu } from './TabContextMenu';
|
||||
|
||||
|
|
@ -51,6 +53,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
|
|||
pinnedSessionIds,
|
||||
toggleHideSession,
|
||||
hiddenSessionIds,
|
||||
tabSessionData,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
pane: s.paneLayout.panes.find((p) => p.id === paneId),
|
||||
|
|
@ -78,6 +81,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
|
|||
pinnedSessionIds: s.pinnedSessionIds,
|
||||
toggleHideSession: s.toggleHideSession,
|
||||
hiddenSessionIds: s.hiddenSessionIds,
|
||||
tabSessionData: s.tabSessionData,
|
||||
}))
|
||||
);
|
||||
|
||||
|
|
@ -91,6 +95,11 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
|
|||
// Derive stable tab IDs array for SortableContext
|
||||
const tabIds = useMemo(() => openTabs.map((t) => t.id), [openTabs]);
|
||||
|
||||
// Derive session detail for the active tab (used by export dropdown)
|
||||
const activeTabSessionDetail = activeTabId
|
||||
? (tabSessionData[activeTabId]?.sessionDetail ?? null)
|
||||
: null;
|
||||
|
||||
// Hover states for buttons
|
||||
const [expandHover, setExpandHover] = useState(false);
|
||||
const [refreshHover, setRefreshHover] = useState(false);
|
||||
|
|
@ -375,6 +384,11 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
|
|||
<Search className="size-4" />
|
||||
</button>
|
||||
|
||||
{/* Export dropdown - show only for session tabs with loaded data */}
|
||||
{activeTab?.type === 'session' && activeTabSessionDetail && (
|
||||
<ExportDropdown sessionDetail={activeTabSessionDetail} />
|
||||
)}
|
||||
|
||||
{/* Notifications bell icon */}
|
||||
<button
|
||||
onClick={openNotificationsTab}
|
||||
|
|
|
|||
|
|
@ -11,12 +11,23 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||
|
||||
import { api } from '@renderer/api';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { formatModifierShortcut } from '@renderer/utils/keyboardUtils';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
const logger = createLogger('Component:CommandPalette');
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { Bot, FileText, FolderGit2, Loader2, MessageSquare, Search, User, X } from 'lucide-react';
|
||||
import {
|
||||
Bot,
|
||||
FileText,
|
||||
FolderGit2,
|
||||
Globe,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Search,
|
||||
User,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { RepositoryGroup, SearchResult } from '@renderer/types/data';
|
||||
|
||||
|
|
@ -83,6 +94,8 @@ interface SessionResultItemProps {
|
|||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
highlightMatch: (context: string, matchedText: string) => React.ReactNode;
|
||||
showProjectName?: boolean;
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
const SessionResultItemInner = ({
|
||||
|
|
@ -90,6 +103,8 @@ const SessionResultItemInner = ({
|
|||
isSelected,
|
||||
onClick,
|
||||
highlightMatch,
|
||||
showProjectName = false,
|
||||
projectName,
|
||||
}: Readonly<SessionResultItemProps>): React.JSX.Element => {
|
||||
return (
|
||||
<button
|
||||
|
|
@ -107,6 +122,12 @@ const SessionResultItemInner = ({
|
|||
{result.messageType === 'user' ? <User className="size-4" /> : <Bot className="size-4" />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{showProjectName && projectName && (
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<FolderGit2 className="size-3 text-blue-400" />
|
||||
<span className="truncate text-xs font-medium text-blue-400">{projectName}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<FileText className="size-3 text-text-muted" />
|
||||
<span className="truncate text-xs text-text-muted">
|
||||
|
|
@ -161,10 +182,11 @@ export const CommandPalette = (): React.JSX.Element | null => {
|
|||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [totalMatches, setTotalMatches] = useState(0);
|
||||
const [searchIsPartial, setSearchIsPartial] = useState(false);
|
||||
const [globalSearchEnabled, setGlobalSearchEnabled] = useState(false);
|
||||
const latestSearchRequestRef = useRef(0);
|
||||
|
||||
// Determine search mode based on whether a project is selected
|
||||
const searchMode: SearchMode = selectedProjectId ? 'sessions' : 'projects';
|
||||
// Determine search mode based on whether a project is selected OR global search is enabled
|
||||
const searchMode: SearchMode = selectedProjectId || globalSearchEnabled ? 'sessions' : 'projects';
|
||||
|
||||
// Filter projects for project search mode
|
||||
const filteredProjects = useMemo(() => {
|
||||
|
|
@ -188,10 +210,20 @@ export const CommandPalette = (): React.JSX.Element | null => {
|
|||
|
||||
// Fetch repository groups if needed
|
||||
useEffect(() => {
|
||||
if (commandPaletteOpen && searchMode === 'projects' && repositoryGroups.length === 0) {
|
||||
if (
|
||||
commandPaletteOpen &&
|
||||
(searchMode === 'projects' || globalSearchEnabled) &&
|
||||
repositoryGroups.length === 0
|
||||
) {
|
||||
void fetchRepositoryGroups();
|
||||
}
|
||||
}, [commandPaletteOpen, searchMode, repositoryGroups.length, fetchRepositoryGroups]);
|
||||
}, [
|
||||
commandPaletteOpen,
|
||||
searchMode,
|
||||
globalSearchEnabled,
|
||||
repositoryGroups.length,
|
||||
fetchRepositoryGroups,
|
||||
]);
|
||||
|
||||
// Focus input when palette opens
|
||||
useEffect(() => {
|
||||
|
|
@ -202,29 +234,33 @@ export const CommandPalette = (): React.JSX.Element | null => {
|
|||
setSelectedIndex(0);
|
||||
setTotalMatches(0);
|
||||
setSearchIsPartial(false);
|
||||
setGlobalSearchEnabled(false);
|
||||
}
|
||||
}, [commandPaletteOpen]);
|
||||
|
||||
// Search sessions with debounce (only in session mode)
|
||||
useEffect(() => {
|
||||
if (
|
||||
!commandPaletteOpen ||
|
||||
searchMode !== 'sessions' ||
|
||||
!selectedProjectId ||
|
||||
query.trim().length < 2
|
||||
) {
|
||||
// Only clear results when query is too short or palette is closed
|
||||
if (!commandPaletteOpen || query.trim().length < 2) {
|
||||
setSessionResults([]);
|
||||
setTotalMatches(0);
|
||||
setSearchIsPartial(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Early return without clearing if we're not in the right mode
|
||||
if (searchMode !== 'sessions' || (!globalSearchEnabled && !selectedProjectId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(async () => {
|
||||
const requestId = latestSearchRequestRef.current + 1;
|
||||
latestSearchRequestRef.current = requestId;
|
||||
setLoading(true);
|
||||
try {
|
||||
const searchResult = await api.searchSessions(selectedProjectId, query.trim(), 50);
|
||||
const searchResult = globalSearchEnabled
|
||||
? await api.searchAllProjects(query.trim(), 50)
|
||||
: await api.searchSessions(selectedProjectId!, query.trim(), 50);
|
||||
if (latestSearchRequestRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -248,7 +284,7 @@ export const CommandPalette = (): React.JSX.Element | null => {
|
|||
}, 200);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [query, selectedProjectId, commandPaletteOpen, searchMode]);
|
||||
}, [query, selectedProjectId, commandPaletteOpen, searchMode, globalSearchEnabled]);
|
||||
|
||||
// Reset selected index when results change
|
||||
useEffect(() => {
|
||||
|
|
@ -294,6 +330,12 @@ export const CommandPalette = (): React.JSX.Element | null => {
|
|||
// Handle keyboard navigation
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'g' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
setGlobalSearchEnabled((prev) => !prev);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeCommandPalette();
|
||||
|
|
@ -383,23 +425,48 @@ export const CommandPalette = (): React.JSX.Element | null => {
|
|||
<div className="w-full max-w-2xl overflow-hidden rounded-xl border border-border bg-surface shadow-2xl">
|
||||
{/* Mode indicator */}
|
||||
<div className="bg-surface-raised/50 border-b border-border px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{searchMode === 'projects' ? (
|
||||
<>
|
||||
<FolderGit2 className="size-3.5 text-text-muted" />
|
||||
<span className="text-xs text-text-muted">Search projects</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MessageSquare className="size-3.5 text-text-muted" />
|
||||
<span className="text-xs text-text-muted">Search in projects</span>
|
||||
<span className="text-text-muted/50 mx-1 text-xs">·</span>
|
||||
<span className="truncate text-xs text-text-secondary">
|
||||
{repositoryGroups.find((r) => r.worktrees.some((w) => w.id === selectedProjectId))
|
||||
?.name ?? 'Current project'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{searchMode === 'projects' ? (
|
||||
<>
|
||||
<FolderGit2 className="size-3.5 text-text-muted" />
|
||||
<span className="text-xs text-text-muted">Search projects</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MessageSquare className="size-3.5 text-text-muted" />
|
||||
<span className="text-xs text-text-muted">
|
||||
{globalSearchEnabled ? 'Search across all projects' : 'Search in project'}
|
||||
</span>
|
||||
{!globalSearchEnabled && (
|
||||
<>
|
||||
<span className="text-text-muted/50 mx-1 text-xs">·</span>
|
||||
<span className="truncate text-xs text-text-secondary">
|
||||
{repositoryGroups.find((r) =>
|
||||
r.worktrees.some((w) => w.id === selectedProjectId)
|
||||
)?.name ?? 'Current project'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setGlobalSearchEnabled(!globalSearchEnabled)}
|
||||
className={`flex items-center gap-1.5 rounded px-2 py-1 text-xs transition-colors ${
|
||||
globalSearchEnabled
|
||||
? 'bg-blue-500/20 text-blue-400 hover:bg-blue-500/30'
|
||||
: 'text-text-muted hover:bg-surface-raised hover:text-text'
|
||||
}`}
|
||||
title={
|
||||
!globalSearchEnabled
|
||||
? `Search across all projects (${formatModifierShortcut('G')})`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Globe className="size-3" />
|
||||
<span>Global</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -459,15 +526,25 @@ export const CommandPalette = (): React.JSX.Element | null => {
|
|||
</div>
|
||||
) : (
|
||||
<div className="py-2">
|
||||
{sessionResults.map((result, index) => (
|
||||
<SessionResultItem
|
||||
key={`${result.sessionId}-${index}`}
|
||||
result={result}
|
||||
isSelected={index === selectedIndex}
|
||||
onClick={() => handleSessionResultClick(result)}
|
||||
highlightMatch={highlightMatch}
|
||||
/>
|
||||
))}
|
||||
{sessionResults.map((result, index) => {
|
||||
// Find project name for this result when in global search mode
|
||||
const projectName = globalSearchEnabled
|
||||
? repositoryGroups.find((r) => r.worktrees.some((w) => w.id === result.projectId))
|
||||
?.name
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<SessionResultItem
|
||||
key={`${result.sessionId}-${index}`}
|
||||
result={result}
|
||||
isSelected={index === selectedIndex}
|
||||
onClick={() => handleSessionResultClick(result)}
|
||||
highlightMatch={highlightMatch}
|
||||
showProjectName={globalSearchEnabled}
|
||||
projectName={projectName}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -478,7 +555,7 @@ export const CommandPalette = (): React.JSX.Element | null => {
|
|||
{searchMode === 'projects'
|
||||
? `${filteredProjects.length} project${filteredProjects.length !== 1 ? 's' : ''}`
|
||||
: totalMatches > 0
|
||||
? `${totalMatches} ${searchIsPartial ? 'fast ' : ''}result${totalMatches !== 1 ? 's' : ''}`
|
||||
? `${totalMatches} ${searchIsPartial ? 'fast ' : ''}result${totalMatches !== 1 ? 's' : ''}${globalSearchEnabled ? ' across all projects' : ''}`
|
||||
: 'Type to search'}
|
||||
</span>
|
||||
<div className="flex items-center gap-4">
|
||||
|
|
@ -490,6 +567,12 @@ export const CommandPalette = (): React.JSX.Element | null => {
|
|||
<kbd className="rounded bg-surface-overlay px-1.5 py-0.5 text-[10px]">↵</kbd>{' '}
|
||||
{searchMode === 'projects' ? 'select' : 'open'}
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="rounded bg-surface-overlay px-1.5 py-0.5 text-[10px]">
|
||||
{formatModifierShortcut('G')}
|
||||
</kbd>{' '}
|
||||
global
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="rounded bg-surface-overlay px-1.5 py-0.5 text-[10px]">esc</kbd> close
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,30 @@ const DEFAULT_COLOR: TeamColorSet = TEAMMATE_COLORS.blue;
|
|||
* Get a TeamColorSet from a color name or hex string.
|
||||
* Falls back to blue if unrecognized.
|
||||
*/
|
||||
const COLOR_NAMES = Object.keys(TEAMMATE_COLORS);
|
||||
|
||||
function hashString(str: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = (hash * 31 + str.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
export function getSubagentTypeColorSet(
|
||||
subagentType: string,
|
||||
agentConfigs?: Record<string, { color?: string }>
|
||||
): TeamColorSet {
|
||||
// Use color from agent config if available
|
||||
const configColor = agentConfigs?.[subagentType]?.color;
|
||||
if (configColor) {
|
||||
return getTeamColorSet(configColor);
|
||||
}
|
||||
// Fallback: deterministic hash-based color
|
||||
const index = hashString(subagentType) % COLOR_NAMES.length;
|
||||
return TEAMMATE_COLORS[COLOR_NAMES[index]];
|
||||
}
|
||||
|
||||
export function getTeamColorSet(colorName: string): TeamColorSet {
|
||||
if (!colorName) return DEFAULT_COLOR;
|
||||
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ export const createProjectSlice: StateCreator<AppState, [], [], ProjectSlice> =
|
|||
selectProject: (id: string) => {
|
||||
set({
|
||||
selectedProjectId: id,
|
||||
sidebarCollapsed: false, // Ensure session list is visible when a project is selected
|
||||
...getSessionResetState(),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ export const createRepositorySlice: StateCreator<AppState, [], [], RepositorySli
|
|||
selectedWorktreeId: worktreeToSelect.id,
|
||||
selectedProjectId: worktreeToSelect.id,
|
||||
activeProjectId: worktreeToSelect.id,
|
||||
sidebarCollapsed: false, // Ensure session list is visible when a project is selected
|
||||
...getSessionResetState(),
|
||||
});
|
||||
// Fetch sessions for this worktree
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ const sessionRefreshGeneration = new Map<string, number>();
|
|||
const sessionRefreshInFlight = new Set<string>();
|
||||
const sessionRefreshQueued = new Set<string>();
|
||||
let sessionDetailFetchGeneration = 0;
|
||||
let agentConfigsCachedForProject = '';
|
||||
|
||||
import { getAllTabs } from '../utils/paneHelpers';
|
||||
|
||||
|
|
@ -37,6 +38,7 @@ import type {
|
|||
} from '@renderer/types/contextInjection';
|
||||
import type { ClaudeMdFileInfo, SessionDetail } from '@renderer/types/data';
|
||||
import type { AIGroup, SessionConversation } from '@renderer/types/groups';
|
||||
import type { AgentConfig } from '@shared/types/api';
|
||||
import type { StateCreator } from 'zustand';
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -92,6 +94,9 @@ export interface SessionDetailSlice {
|
|||
// Context phase info (compaction boundaries)
|
||||
sessionPhaseInfo: ContextPhaseInfo | null;
|
||||
|
||||
// Agent configs from .claude/agents/ (keyed by agent name)
|
||||
agentConfigs: Record<string, AgentConfig>;
|
||||
|
||||
// Visible AI Group
|
||||
visibleAIGroupId: string | null;
|
||||
selectedAIGroup: AIGroup | null;
|
||||
|
|
@ -133,6 +138,8 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
|
|||
// Context phase info (compaction boundaries)
|
||||
sessionPhaseInfo: null,
|
||||
|
||||
agentConfigs: {},
|
||||
|
||||
visibleAIGroupId: null,
|
||||
selectedAIGroup: null,
|
||||
|
||||
|
|
@ -190,6 +197,21 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
|
|||
let claudeMdStats: Map<string, ClaudeMdStats> | null = null;
|
||||
let contextStats: Map<string, ContextStats> | null = null;
|
||||
let phaseInfo: ContextPhaseInfo | null = null;
|
||||
// Fetch agent configs from .claude/agents/ (only when project changes).
|
||||
// Fire-and-forget: don't block transcript rendering — color badges update async.
|
||||
if (connectionMode !== 'ssh' && projectRoot && projectRoot !== agentConfigsCachedForProject) {
|
||||
agentConfigsCachedForProject = projectRoot; // Optimistic set to prevent duplicate fetches
|
||||
api
|
||||
.readAgentConfigs(projectRoot)
|
||||
.then((configs) => {
|
||||
set({ agentConfigs: configs });
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error('Failed to read agent configs:', err);
|
||||
agentConfigsCachedForProject = ''; // Reset so it retries next time
|
||||
});
|
||||
}
|
||||
|
||||
if (connectionMode !== 'ssh' && conversation?.items) {
|
||||
// Fetch real CLAUDE.md token data
|
||||
let claudeMdTokenData: Record<string, ClaudeMdFileInfo> = {};
|
||||
|
|
|
|||
|
|
@ -649,11 +649,6 @@ export const createTabSlice: StateCreator<AppState, [], [], TabSlice> = (set, ge
|
|||
) => {
|
||||
const state = get();
|
||||
|
||||
// If different project, select it first
|
||||
if (state.selectedProjectId !== projectId) {
|
||||
state.selectProject(projectId);
|
||||
}
|
||||
|
||||
// Check if session tab is already open in any pane
|
||||
const allTabs = getAllTabs(state.paneLayout);
|
||||
const existingTab =
|
||||
|
|
@ -703,6 +698,9 @@ export const createTabSlice: StateCreator<AppState, [], [], TabSlice> = (set, ge
|
|||
const newState = get();
|
||||
const newTabId = newState.activeTabId;
|
||||
if (newTabId) {
|
||||
// Re-focus tab via setActiveTab for proper sidebar sync
|
||||
state.setActiveTab(newTabId);
|
||||
|
||||
const searchPayload = {
|
||||
query: searchContext.query,
|
||||
messageTimestamp: searchContext.messageTimestamp,
|
||||
|
|
@ -731,10 +729,5 @@ export const createTabSlice: StateCreator<AppState, [], [], TabSlice> = (set, ge
|
|||
const newTabIdForFetch = get().activeTabId ?? undefined;
|
||||
void state.fetchSessionDetail(projectId, sessionId, newTabIdForFetch);
|
||||
}
|
||||
|
||||
// If opened from search, clear sidebar selection to deselect
|
||||
if (fromSearch) {
|
||||
set({ selectedSessionId: null });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
|||
38
src/renderer/utils/keyboardUtils.ts
Normal file
38
src/renderer/utils/keyboardUtils.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* Keyboard utility functions for platform-aware shortcuts
|
||||
*/
|
||||
|
||||
/**
|
||||
* Detect if running on macOS
|
||||
*/
|
||||
export function isMacOS(): boolean {
|
||||
return navigator.userAgent.toLowerCase().includes('mac');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the primary modifier key name for the current platform
|
||||
* @returns 'Cmd' on macOS, 'Ctrl' on other platforms
|
||||
*/
|
||||
export function getModifierKeyName(): string {
|
||||
return isMacOS() ? 'Cmd' : 'Ctrl';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the primary modifier key symbol for the current platform
|
||||
* @returns '⌘' on macOS, 'Ctrl' on other platforms
|
||||
*/
|
||||
export function getModifierKeySymbol(): string {
|
||||
return isMacOS() ? '⌘' : 'Ctrl';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a keyboard shortcut for display
|
||||
* @param key - The key to press (e.g., 'K', 'G', 'Enter')
|
||||
* @param useSymbol - Whether to use symbols (⌘) or text (Cmd)
|
||||
* @returns Formatted shortcut string (e.g., '⌘K' or 'Ctrl+K')
|
||||
*/
|
||||
export function formatModifierShortcut(key: string, useSymbol = true): string {
|
||||
const modifier = useSymbol ? getModifierKeySymbol() : getModifierKeyName();
|
||||
const separator = useSymbol && isMacOS() ? '' : '+';
|
||||
return `${modifier}${separator}${key}`;
|
||||
}
|
||||
427
src/renderer/utils/sessionExporter.ts
Normal file
427
src/renderer/utils/sessionExporter.ts
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
/**
|
||||
* Session export utilities for claude-devtools.
|
||||
*
|
||||
* Provides formatters to export session data as plain text, Markdown, or JSON,
|
||||
* and a download trigger for browser-based file saving.
|
||||
*/
|
||||
|
||||
import type { Chunk, SessionDetail } from '@renderer/types/data';
|
||||
import type { ContentBlock } from '@shared/types';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export type ExportFormat = 'markdown' | 'json' | 'plaintext';
|
||||
|
||||
interface ExtractOptions {
|
||||
includeThinking?: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helpers (not exported)
|
||||
// =============================================================================
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
return n.toLocaleString('en-US');
|
||||
}
|
||||
|
||||
function formatCost(cost?: number): string {
|
||||
if (cost == null) return 'N/A';
|
||||
return `$${cost.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatTimestamp(date: Date): string {
|
||||
return date
|
||||
.toISOString()
|
||||
.replace('T', ' ')
|
||||
.replace(/\.\d{3}Z$/, ' UTC');
|
||||
}
|
||||
|
||||
function formatDurationForExport(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const secs = Math.floor(ms / 1000);
|
||||
const mins = Math.floor(secs / 60);
|
||||
const remainSecs = secs % 60;
|
||||
if (mins === 0) return `${secs}s`;
|
||||
return `${mins}m ${remainSecs}s`;
|
||||
}
|
||||
|
||||
function truncate(text: string, maxLen: number): string {
|
||||
if (text.length <= maxLen) return text;
|
||||
return text.slice(0, maxLen) + '...';
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// extractTextFromContent
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Extract readable text from message content (string or ContentBlock[]).
|
||||
*
|
||||
* @param content - String content or array of ContentBlocks
|
||||
* @param options - Options controlling extraction behavior
|
||||
* @returns Extracted text with newlines between blocks
|
||||
*/
|
||||
export function extractTextFromContent(
|
||||
content: string | ContentBlock[],
|
||||
options?: ExtractOptions
|
||||
): string {
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
|
||||
if (!Array.isArray(content) || content.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const block of content) {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
parts.push(block.text);
|
||||
break;
|
||||
case 'thinking':
|
||||
if (options?.includeThinking) {
|
||||
parts.push(block.thinking);
|
||||
}
|
||||
break;
|
||||
case 'tool_use':
|
||||
parts.push(`Tool: ${block.name}\nInput: ${JSON.stringify(block.input, null, 2)}`);
|
||||
break;
|
||||
case 'tool_result': {
|
||||
const resultContent = block.content;
|
||||
if (typeof resultContent === 'string') {
|
||||
parts.push(resultContent);
|
||||
} else if (Array.isArray(resultContent)) {
|
||||
// Recursively extract from nested content blocks
|
||||
const nested = extractTextFromContent(resultContent);
|
||||
if (nested) parts.push(nested);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'image':
|
||||
parts.push('[Image]');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Plain Text Chunk Formatters
|
||||
// =============================================================================
|
||||
|
||||
function formatToolExecutionPlainText(exec: {
|
||||
toolCall: { name: string; input: Record<string, unknown> };
|
||||
result?: { content: string | unknown[]; isError: boolean };
|
||||
}): string[] {
|
||||
const lines: string[] = [];
|
||||
lines.push(` TOOL: ${exec.toolCall.name}`);
|
||||
lines.push(` Input: ${JSON.stringify(exec.toolCall.input)}`);
|
||||
if (exec.result) {
|
||||
const prefix = exec.result.isError ? ' [ERROR] Result: ' : ' Result: ';
|
||||
const resultText =
|
||||
typeof exec.result.content === 'string'
|
||||
? exec.result.content
|
||||
: JSON.stringify(exec.result.content);
|
||||
lines.push(`${prefix}${truncate(resultText, 500)}`);
|
||||
} else {
|
||||
lines.push(' [No result]');
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function formatChunkPlainText(chunk: Chunk): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
switch (chunk.chunkType) {
|
||||
case 'user': {
|
||||
lines.push(`USER: ${extractTextFromContent(chunk.userMessage.content)}`);
|
||||
break;
|
||||
}
|
||||
case 'ai': {
|
||||
// Render thinking blocks first, then text
|
||||
for (const response of chunk.responses) {
|
||||
if (Array.isArray(response.content)) {
|
||||
// Check for thinking blocks
|
||||
for (const block of response.content) {
|
||||
if (block.type === 'thinking') {
|
||||
lines.push(`THINKING: ${block.thinking}`);
|
||||
}
|
||||
}
|
||||
// Then text
|
||||
const text = extractTextFromContent(response.content);
|
||||
if (text) {
|
||||
lines.push(`ASSISTANT: ${text}`);
|
||||
}
|
||||
} else if (typeof response.content === 'string') {
|
||||
lines.push(`ASSISTANT: ${response.content}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Tool executions
|
||||
for (const exec of chunk.toolExecutions) {
|
||||
lines.push(...formatToolExecutionPlainText(exec));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'system': {
|
||||
lines.push(`SYSTEM: ${chunk.commandOutput}`);
|
||||
break;
|
||||
}
|
||||
case 'compact': {
|
||||
lines.push('[Context compacted]');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Markdown Chunk Formatters
|
||||
// =============================================================================
|
||||
|
||||
function formatToolExecutionMarkdown(exec: {
|
||||
toolCall: { name: string; input: Record<string, unknown> };
|
||||
result?: { content: string | unknown[]; isError: boolean };
|
||||
}): string[] {
|
||||
const lines: string[] = [];
|
||||
lines.push(`**Tool:** \`${exec.toolCall.name}\``);
|
||||
lines.push('');
|
||||
lines.push('```json');
|
||||
lines.push(JSON.stringify(exec.toolCall.input, null, 2));
|
||||
lines.push('```');
|
||||
lines.push('');
|
||||
|
||||
if (exec.result) {
|
||||
if (exec.result.isError) {
|
||||
lines.push('**Error:**');
|
||||
} else {
|
||||
lines.push('**Result:**');
|
||||
}
|
||||
lines.push('');
|
||||
const resultText =
|
||||
typeof exec.result.content === 'string'
|
||||
? exec.result.content
|
||||
: JSON.stringify(exec.result.content, null, 2);
|
||||
lines.push('```');
|
||||
lines.push(truncate(resultText, 2000));
|
||||
lines.push('```');
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function formatChunkMarkdown(chunk: Chunk, turnNum: number): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
switch (chunk.chunkType) {
|
||||
case 'user': {
|
||||
lines.push(`### User (Turn ${turnNum})`);
|
||||
lines.push('');
|
||||
lines.push(extractTextFromContent(chunk.userMessage.content));
|
||||
lines.push('');
|
||||
break;
|
||||
}
|
||||
case 'ai': {
|
||||
lines.push(`### Assistant (Turn ${turnNum})`);
|
||||
lines.push('');
|
||||
|
||||
for (const response of chunk.responses) {
|
||||
if (Array.isArray(response.content)) {
|
||||
// Thinking blocks as blockquotes
|
||||
for (const block of response.content) {
|
||||
if (block.type === 'thinking') {
|
||||
lines.push('> *Thinking:*');
|
||||
for (const thinkLine of block.thinking.split('\n')) {
|
||||
lines.push(`> ${thinkLine}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
// Text content
|
||||
const text = extractTextFromContent(response.content);
|
||||
if (text) {
|
||||
lines.push(text);
|
||||
lines.push('');
|
||||
}
|
||||
} else if (typeof response.content === 'string') {
|
||||
lines.push(response.content);
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
// Tool executions
|
||||
for (const exec of chunk.toolExecutions) {
|
||||
lines.push(...formatToolExecutionMarkdown(exec));
|
||||
lines.push('');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'system': {
|
||||
lines.push(`### System (Turn ${turnNum})`);
|
||||
lines.push('');
|
||||
lines.push(chunk.commandOutput);
|
||||
lines.push('');
|
||||
break;
|
||||
}
|
||||
case 'compact': {
|
||||
lines.push('---');
|
||||
lines.push('');
|
||||
lines.push('*Context compacted*');
|
||||
lines.push('');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Export Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Export session as plain text transcript.
|
||||
*
|
||||
* Produces a flat text format with clear labels (USER:, ASSISTANT:, TOOL:, etc.)
|
||||
* and separator lines between sections.
|
||||
*/
|
||||
export function exportAsPlainText(detail: SessionDetail): string {
|
||||
const { session, metrics, chunks } = detail;
|
||||
const lines: string[] = [];
|
||||
|
||||
// Header
|
||||
lines.push('═'.repeat(60));
|
||||
lines.push('SESSION EXPORT');
|
||||
lines.push('═'.repeat(60));
|
||||
lines.push(`Session: ${session.id}`);
|
||||
lines.push(`Project: ${session.projectPath}`);
|
||||
if (session.gitBranch) {
|
||||
lines.push(`Branch: ${session.gitBranch}`);
|
||||
}
|
||||
lines.push(`Date: ${formatTimestamp(new Date(session.createdAt))}`);
|
||||
lines.push('');
|
||||
|
||||
// Metrics
|
||||
lines.push('─'.repeat(40));
|
||||
lines.push('METRICS');
|
||||
lines.push('─'.repeat(40));
|
||||
lines.push(`Duration: ${formatDurationForExport(metrics.durationMs)}`);
|
||||
lines.push(`Total Tokens: ${formatNumber(metrics.totalTokens)}`);
|
||||
lines.push(`Input Tokens: ${formatNumber(metrics.inputTokens)}`);
|
||||
lines.push(`Output Tokens: ${formatNumber(metrics.outputTokens)}`);
|
||||
lines.push(`Cache Read: ${formatNumber(metrics.cacheReadTokens)}`);
|
||||
lines.push(`Cache Created: ${formatNumber(metrics.cacheCreationTokens)}`);
|
||||
lines.push(`Messages: ${formatNumber(metrics.messageCount)}`);
|
||||
lines.push(`Cost: ${formatCost(metrics.costUsd)}`);
|
||||
lines.push('');
|
||||
|
||||
// Conversation
|
||||
lines.push('═'.repeat(60));
|
||||
lines.push('CONVERSATION');
|
||||
lines.push('═'.repeat(60));
|
||||
lines.push('');
|
||||
|
||||
for (const chunk of chunks) {
|
||||
lines.push('─'.repeat(40));
|
||||
lines.push(...formatChunkPlainText(chunk));
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Export session as structured Markdown.
|
||||
*
|
||||
* Produces Markdown with headings, tables, code blocks, and blockquotes
|
||||
* suitable for viewing in any Markdown renderer.
|
||||
*/
|
||||
export function exportAsMarkdown(detail: SessionDetail): string {
|
||||
const { session, metrics, chunks } = detail;
|
||||
const lines: string[] = [];
|
||||
|
||||
// Title
|
||||
lines.push('# Session Export');
|
||||
lines.push('');
|
||||
|
||||
// Property table
|
||||
lines.push('| Property | Value |');
|
||||
lines.push('|----------|-------|');
|
||||
lines.push(`| Session | \`${session.id}\` |`);
|
||||
lines.push(`| Project | \`${session.projectPath}\` |`);
|
||||
if (session.gitBranch) {
|
||||
lines.push(`| Branch | \`${session.gitBranch}\` |`);
|
||||
}
|
||||
lines.push(`| Date | ${formatTimestamp(new Date(session.createdAt))} |`);
|
||||
lines.push('');
|
||||
|
||||
// Metrics table
|
||||
lines.push('## Metrics');
|
||||
lines.push('');
|
||||
lines.push('| Metric | Value |');
|
||||
lines.push('|--------|-------|');
|
||||
lines.push(`| Duration | ${formatDurationForExport(metrics.durationMs)} |`);
|
||||
lines.push(`| Total Tokens | ${formatNumber(metrics.totalTokens)} |`);
|
||||
lines.push(`| Input Tokens | ${formatNumber(metrics.inputTokens)} |`);
|
||||
lines.push(`| Output Tokens | ${formatNumber(metrics.outputTokens)} |`);
|
||||
lines.push(`| Cache Read | ${formatNumber(metrics.cacheReadTokens)} |`);
|
||||
lines.push(`| Cache Created | ${formatNumber(metrics.cacheCreationTokens)} |`);
|
||||
lines.push(`| Messages | ${formatNumber(metrics.messageCount)} |`);
|
||||
lines.push(`| Cost | ${formatCost(metrics.costUsd)} |`);
|
||||
lines.push('');
|
||||
|
||||
// Conversation
|
||||
lines.push('## Conversation');
|
||||
lines.push('');
|
||||
|
||||
let turnNum = 0;
|
||||
for (const chunk of chunks) {
|
||||
turnNum++;
|
||||
lines.push(...formatChunkMarkdown(chunk, turnNum));
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Export session as pretty-printed JSON.
|
||||
*/
|
||||
export function exportAsJson(detail: SessionDetail): string {
|
||||
return JSON.stringify(detail, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a browser file download for the given session in the specified format.
|
||||
*
|
||||
* Creates a Blob, generates an object URL, and simulates an anchor click
|
||||
* to initiate the download.
|
||||
*/
|
||||
export function triggerDownload(detail: SessionDetail, format: ExportFormat): void {
|
||||
const formatters: Record<
|
||||
ExportFormat,
|
||||
{ fn: (d: SessionDetail) => string; ext: string; mime: string }
|
||||
> = {
|
||||
markdown: { fn: exportAsMarkdown, ext: 'md', mime: 'text/markdown;charset=utf-8' },
|
||||
json: { fn: exportAsJson, ext: 'json', mime: 'application/json;charset=utf-8' },
|
||||
plaintext: { fn: exportAsPlainText, ext: 'txt', mime: 'text/plain;charset=utf-8' },
|
||||
};
|
||||
|
||||
const { fn, ext, mime } = formatters[format];
|
||||
const content = fn(detail);
|
||||
const blob = new Blob([content], { type: mime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = `session-${detail.session.id}.${ext}`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
|
@ -22,7 +22,7 @@ const MACOS_TRAFFIC_LIGHT_GROUP_HEIGHT = 16;
|
|||
const MACOS_TRAFFIC_LIGHT_GROUP_WIDTH = 52;
|
||||
|
||||
/** Visual gap between traffic lights and first left-aligned content */
|
||||
const MACOS_TRAFFIC_LIGHT_CONTENT_GAP = 8;
|
||||
const MACOS_TRAFFIC_LIGHT_CONTENT_GAP = 16;
|
||||
|
||||
const MIN_ZOOM_FACTOR = 0.25;
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,15 @@ import type {
|
|||
SubagentDetail,
|
||||
} from '@main/types';
|
||||
|
||||
// =============================================================================
|
||||
// Agent Config
|
||||
// =============================================================================
|
||||
|
||||
export interface AgentConfig {
|
||||
name: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Notifications API
|
||||
// =============================================================================
|
||||
|
|
@ -368,6 +377,7 @@ export interface ElectronAPI {
|
|||
query: string,
|
||||
maxResults?: number
|
||||
) => Promise<SearchSessionsResult>;
|
||||
searchAllProjects: (query: string, maxResults?: number) => Promise<SearchSessionsResult>;
|
||||
getSessionDetail: (projectId: string, sessionId: string) => Promise<SessionDetail | null>;
|
||||
getSessionMetrics: (projectId: string, sessionId: string) => Promise<SessionMetrics | null>;
|
||||
getWaterfallData: (projectId: string, sessionId: string) => Promise<WaterfallData | null>;
|
||||
|
|
@ -406,6 +416,9 @@ export interface ElectronAPI {
|
|||
maxTokens?: number
|
||||
) => Promise<ClaudeMdFileInfo | null>;
|
||||
|
||||
// Agent config reading
|
||||
readAgentConfigs: (projectRoot: string) => Promise<Record<string, AgentConfig>>;
|
||||
|
||||
// Notifications API
|
||||
notifications: NotificationsAPI;
|
||||
|
||||
|
|
|
|||
405
test/main/ipc/globalSearch.test.ts
Normal file
405
test/main/ipc/globalSearch.test.ts
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ProjectScanner } from '../../../src/main/services/discovery/ProjectScanner';
|
||||
|
||||
import type { Project, SearchSessionsResult } from '../../../src/main/types';
|
||||
|
||||
/**
|
||||
* Tests for global search functionality across all projects
|
||||
*/
|
||||
describe('Global Search - ProjectScanner.searchAllProjects', () => {
|
||||
let projectScanner: ProjectScanner;
|
||||
let mockScan: ReturnType<typeof vi.fn>;
|
||||
let mockSearchSessions: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a real ProjectScanner instance
|
||||
projectScanner = new ProjectScanner();
|
||||
|
||||
// Mock the scan() method
|
||||
mockScan = vi.fn();
|
||||
projectScanner.scan = mockScan;
|
||||
|
||||
// Mock the sessionSearcher.searchSessions() method
|
||||
mockSearchSessions = vi.fn();
|
||||
// @ts-expect-error - Accessing private property for testing
|
||||
projectScanner.sessionSearcher = {
|
||||
searchSessions: mockSearchSessions,
|
||||
};
|
||||
});
|
||||
|
||||
describe('searchAllProjects', () => {
|
||||
it('should return empty results for empty query', async () => {
|
||||
const result = await projectScanner.searchAllProjects('', 50);
|
||||
|
||||
expect(result.results).toEqual([]);
|
||||
expect(result.totalMatches).toBe(0);
|
||||
expect(result.sessionsSearched).toBe(0);
|
||||
expect(mockScan).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return empty results for whitespace query', async () => {
|
||||
const result = await projectScanner.searchAllProjects(' ', 50);
|
||||
|
||||
expect(result.results).toEqual([]);
|
||||
expect(result.totalMatches).toBe(0);
|
||||
expect(result.sessionsSearched).toBe(0);
|
||||
expect(mockScan).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return empty results when no projects exist', async () => {
|
||||
mockScan.mockResolvedValue([]);
|
||||
|
||||
const result = await projectScanner.searchAllProjects('test', 50);
|
||||
|
||||
expect(result.results).toEqual([]);
|
||||
expect(result.totalMatches).toBe(0);
|
||||
expect(result.sessionsSearched).toBe(0);
|
||||
expect(mockScan).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should search across multiple projects and merge results', async () => {
|
||||
const now = Date.now();
|
||||
|
||||
// Mock scan() to return 2 projects
|
||||
const mockProjects: Project[] = [
|
||||
{
|
||||
id: 'project1',
|
||||
path: '/path/to/project1',
|
||||
name: 'Project 1',
|
||||
sessions: ['session1'],
|
||||
createdAt: now - 10000,
|
||||
mostRecentSession: now,
|
||||
},
|
||||
{
|
||||
id: 'project2',
|
||||
path: '/path/to/project2',
|
||||
name: 'Project 2',
|
||||
sessions: ['session2'],
|
||||
createdAt: now - 20000,
|
||||
mostRecentSession: now - 1000,
|
||||
},
|
||||
];
|
||||
mockScan.mockResolvedValue(mockProjects);
|
||||
|
||||
// Mock searchSessions() to return different results for each project
|
||||
mockSearchSessions.mockImplementation((projectId: string) => {
|
||||
if (projectId === 'project1') {
|
||||
return Promise.resolve({
|
||||
results: [
|
||||
{
|
||||
projectId: 'project1',
|
||||
sessionId: 'session1',
|
||||
sessionTitle: 'Test Session 1',
|
||||
context: 'This is a test message',
|
||||
matchedText: 'test',
|
||||
messageType: 'user' as const,
|
||||
timestamp: now,
|
||||
groupId: 'group1',
|
||||
matchIndexInItem: 0,
|
||||
matchStartOffset: 10,
|
||||
messageUuid: 'uuid1',
|
||||
},
|
||||
],
|
||||
totalMatches: 1,
|
||||
sessionsSearched: 5,
|
||||
query: 'test',
|
||||
} satisfies SearchSessionsResult);
|
||||
} else {
|
||||
return Promise.resolve({
|
||||
results: [
|
||||
{
|
||||
projectId: 'project2',
|
||||
sessionId: 'session2',
|
||||
sessionTitle: 'Test Session 2',
|
||||
context: 'Another test message',
|
||||
matchedText: 'test',
|
||||
messageType: 'assistant' as const,
|
||||
timestamp: now - 1000,
|
||||
groupId: 'group2',
|
||||
matchIndexInItem: 0,
|
||||
matchStartOffset: 8,
|
||||
messageUuid: 'uuid2',
|
||||
},
|
||||
],
|
||||
totalMatches: 1,
|
||||
sessionsSearched: 3,
|
||||
query: 'test',
|
||||
} satisfies SearchSessionsResult);
|
||||
}
|
||||
});
|
||||
|
||||
const result = await projectScanner.searchAllProjects('test', 50);
|
||||
|
||||
expect(mockScan).toHaveBeenCalledOnce();
|
||||
expect(mockSearchSessions).toHaveBeenCalledTimes(2);
|
||||
expect(mockSearchSessions).toHaveBeenCalledWith('project1', 'test', 50);
|
||||
expect(mockSearchSessions).toHaveBeenCalledWith('project2', 'test', 50);
|
||||
|
||||
expect(result.results).toHaveLength(2);
|
||||
expect(result.totalMatches).toBe(2);
|
||||
expect(result.sessionsSearched).toBe(8); // 5 + 3
|
||||
|
||||
// Verify results from different projects
|
||||
expect(result.results[0].projectId).toBe('project1');
|
||||
expect(result.results[1].projectId).toBe('project2');
|
||||
});
|
||||
|
||||
it('should sort results by timestamp (most recent first)', async () => {
|
||||
const now = Date.now();
|
||||
|
||||
const mockProjects: Project[] = [
|
||||
{
|
||||
id: 'project1',
|
||||
path: '/path/to/project1',
|
||||
name: 'Project 1',
|
||||
sessions: ['session1'],
|
||||
createdAt: now - 10000,
|
||||
},
|
||||
{
|
||||
id: 'project2',
|
||||
path: '/path/to/project2',
|
||||
name: 'Project 2',
|
||||
sessions: ['session2'],
|
||||
createdAt: now - 20000,
|
||||
},
|
||||
];
|
||||
mockScan.mockResolvedValue(mockProjects);
|
||||
|
||||
// Project1 has older result, Project2 has newer result
|
||||
mockSearchSessions.mockImplementation((projectId: string) => {
|
||||
if (projectId === 'project1') {
|
||||
return Promise.resolve({
|
||||
results: [
|
||||
{
|
||||
projectId: 'project1',
|
||||
sessionId: 'session1',
|
||||
sessionTitle: 'Old Session',
|
||||
context: 'test',
|
||||
matchedText: 'test',
|
||||
messageType: 'user' as const,
|
||||
timestamp: now - 10000, // Older
|
||||
groupId: 'group1',
|
||||
matchIndexInItem: 0,
|
||||
matchStartOffset: 0,
|
||||
messageUuid: 'uuid1',
|
||||
},
|
||||
],
|
||||
totalMatches: 1,
|
||||
sessionsSearched: 5,
|
||||
query: 'test',
|
||||
} satisfies SearchSessionsResult);
|
||||
} else {
|
||||
return Promise.resolve({
|
||||
results: [
|
||||
{
|
||||
projectId: 'project2',
|
||||
sessionId: 'session2',
|
||||
sessionTitle: 'New Session',
|
||||
context: 'test',
|
||||
matchedText: 'test',
|
||||
messageType: 'user' as const,
|
||||
timestamp: now, // Newer
|
||||
groupId: 'group2',
|
||||
matchIndexInItem: 0,
|
||||
matchStartOffset: 0,
|
||||
messageUuid: 'uuid2',
|
||||
},
|
||||
],
|
||||
totalMatches: 1,
|
||||
sessionsSearched: 3,
|
||||
query: 'test',
|
||||
} satisfies SearchSessionsResult);
|
||||
}
|
||||
});
|
||||
|
||||
const result = await projectScanner.searchAllProjects('test', 50);
|
||||
|
||||
// Should be sorted newest first
|
||||
expect(result.results[0].sessionTitle).toBe('New Session');
|
||||
expect(result.results[1].sessionTitle).toBe('Old Session');
|
||||
expect(result.results[0].timestamp).toBeGreaterThan(result.results[1].timestamp);
|
||||
});
|
||||
|
||||
it('should respect maxResults limit', async () => {
|
||||
const now = Date.now();
|
||||
|
||||
const mockProjects: Project[] = [
|
||||
{
|
||||
id: 'project1',
|
||||
path: '/path/to/project1',
|
||||
name: 'Project 1',
|
||||
sessions: ['session1'],
|
||||
createdAt: now,
|
||||
},
|
||||
];
|
||||
mockScan.mockResolvedValue(mockProjects);
|
||||
|
||||
// Return 30 results from search
|
||||
const mockResults = Array.from({ length: 30 }, (_, i) => ({
|
||||
projectId: 'project1',
|
||||
sessionId: `session${i}`,
|
||||
sessionTitle: `Session ${i}`,
|
||||
context: 'test context',
|
||||
matchedText: 'test',
|
||||
messageType: 'user' as const,
|
||||
timestamp: now - i * 1000,
|
||||
groupId: `group${i}`,
|
||||
matchIndexInItem: 0,
|
||||
matchStartOffset: 0,
|
||||
messageUuid: `uuid${i}`,
|
||||
}));
|
||||
|
||||
mockSearchSessions.mockResolvedValue({
|
||||
results: mockResults,
|
||||
totalMatches: 30,
|
||||
sessionsSearched: 50,
|
||||
query: 'test',
|
||||
} satisfies SearchSessionsResult);
|
||||
|
||||
const result = await projectScanner.searchAllProjects('test', 25);
|
||||
|
||||
expect(result.results.length).toBe(25); // Limited to maxResults
|
||||
expect(mockSearchSessions).toHaveBeenCalledWith('project1', 'test', 25);
|
||||
});
|
||||
|
||||
it('should handle search errors gracefully', async () => {
|
||||
const now = Date.now();
|
||||
|
||||
const mockProjects: Project[] = [
|
||||
{
|
||||
id: 'project1',
|
||||
path: '/path/to/project1',
|
||||
name: 'Project 1',
|
||||
sessions: ['session1'],
|
||||
createdAt: now,
|
||||
},
|
||||
{
|
||||
id: 'project2',
|
||||
path: '/path/to/project2',
|
||||
name: 'Project 2',
|
||||
sessions: ['session2'],
|
||||
createdAt: now - 1000,
|
||||
},
|
||||
];
|
||||
mockScan.mockResolvedValue(mockProjects);
|
||||
|
||||
// First project fails, second succeeds
|
||||
mockSearchSessions.mockImplementation((projectId: string) => {
|
||||
if (projectId === 'project1') {
|
||||
return Promise.reject(new Error('Search failed'));
|
||||
} else {
|
||||
return Promise.resolve({
|
||||
results: [
|
||||
{
|
||||
projectId: 'project2',
|
||||
sessionId: 'session2',
|
||||
sessionTitle: 'Test Session 2',
|
||||
context: 'test',
|
||||
matchedText: 'test',
|
||||
messageType: 'user' as const,
|
||||
timestamp: now,
|
||||
groupId: 'group2',
|
||||
matchIndexInItem: 0,
|
||||
matchStartOffset: 0,
|
||||
messageUuid: 'uuid2',
|
||||
},
|
||||
],
|
||||
totalMatches: 1,
|
||||
sessionsSearched: 3,
|
||||
query: 'test',
|
||||
} satisfies SearchSessionsResult);
|
||||
}
|
||||
});
|
||||
|
||||
const result = await projectScanner.searchAllProjects('test', 50);
|
||||
|
||||
// Should still return results from successful project
|
||||
expect(result.results).toHaveLength(1);
|
||||
expect(result.results[0].projectId).toBe('project2');
|
||||
expect(result.totalMatches).toBe(1);
|
||||
expect(result.sessionsSearched).toBe(3);
|
||||
});
|
||||
|
||||
it('should use batched concurrency for local FS', async () => {
|
||||
const now = Date.now();
|
||||
|
||||
// Create 10 projects to test batching (local uses batch size 4)
|
||||
const mockProjects: Project[] = Array.from({ length: 10 }, (_, i) => ({
|
||||
id: `project${i}`,
|
||||
path: `/path/to/project${i}`,
|
||||
name: `Project ${i}`,
|
||||
sessions: [`session${i}`],
|
||||
createdAt: now - i * 1000,
|
||||
}));
|
||||
mockScan.mockResolvedValue(mockProjects);
|
||||
|
||||
// Track call order to verify batching
|
||||
const callOrder: string[] = [];
|
||||
mockSearchSessions.mockImplementation((projectId: string) => {
|
||||
callOrder.push(projectId);
|
||||
return Promise.resolve({
|
||||
results: [],
|
||||
totalMatches: 0,
|
||||
sessionsSearched: 1,
|
||||
query: 'test',
|
||||
} satisfies SearchSessionsResult);
|
||||
});
|
||||
|
||||
await projectScanner.searchAllProjects('test', 50);
|
||||
|
||||
// All 10 projects should be searched
|
||||
expect(mockSearchSessions).toHaveBeenCalledTimes(10);
|
||||
expect(callOrder).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('should stop searching when enough results are found', async () => {
|
||||
const now = Date.now();
|
||||
|
||||
// Create 10 projects
|
||||
const mockProjects: Project[] = Array.from({ length: 10 }, (_, i) => ({
|
||||
id: `project${i}`,
|
||||
path: `/path/to/project${i}`,
|
||||
name: `Project ${i}`,
|
||||
sessions: [`session${i}`],
|
||||
createdAt: now - i * 1000,
|
||||
}));
|
||||
mockScan.mockResolvedValue(mockProjects);
|
||||
|
||||
// Each project returns 10 results (total would be 100)
|
||||
mockSearchSessions.mockImplementation((projectId: string) => {
|
||||
const results = Array.from({ length: 10 }, (_, i) => ({
|
||||
projectId,
|
||||
sessionId: `session${i}`,
|
||||
sessionTitle: `Session ${i}`,
|
||||
context: 'test',
|
||||
matchedText: 'test',
|
||||
messageType: 'user' as const,
|
||||
timestamp: now - i * 1000,
|
||||
groupId: `group${i}`,
|
||||
matchIndexInItem: 0,
|
||||
matchStartOffset: 0,
|
||||
messageUuid: `uuid${i}`,
|
||||
}));
|
||||
|
||||
return Promise.resolve({
|
||||
results,
|
||||
totalMatches: 10,
|
||||
sessionsSearched: 1,
|
||||
query: 'test',
|
||||
} satisfies SearchSessionsResult);
|
||||
});
|
||||
|
||||
const result = await projectScanner.searchAllProjects('test', 50);
|
||||
|
||||
// Should stop after getting enough results (checks after each batch of 4)
|
||||
// Batch 1 (4 projects): 40 matches < 50, continue
|
||||
// Batch 2 (4 projects): 80 matches >= 50, stop
|
||||
expect(mockSearchSessions.mock.calls.length).toBeGreaterThanOrEqual(4);
|
||||
expect(mockSearchSessions.mock.calls.length).toBeLessThanOrEqual(8);
|
||||
|
||||
// Result should be limited to maxResults
|
||||
expect(result.results.length).toBe(50);
|
||||
});
|
||||
});
|
||||
});
|
||||
119
test/main/services/discovery/SearchTextCache.test.ts
Normal file
119
test/main/services/discovery/SearchTextCache.test.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { SearchTextCache } from '../../../../src/main/services/discovery/SearchTextCache';
|
||||
|
||||
import type { SearchableEntry } from '../../../../src/main/services/discovery/SearchTextExtractor';
|
||||
|
||||
function makeEntry(text: string, groupId: string): SearchableEntry {
|
||||
return {
|
||||
text,
|
||||
groupId,
|
||||
messageType: 'user',
|
||||
itemType: 'user',
|
||||
timestamp: Date.now(),
|
||||
messageUuid: groupId,
|
||||
};
|
||||
}
|
||||
|
||||
describe('SearchTextCache', () => {
|
||||
it('returns cached entry on mtime match', () => {
|
||||
const cache = new SearchTextCache();
|
||||
const entries = [makeEntry('hello', 'user-1')];
|
||||
cache.set('/path/a.jsonl', 1000, entries, 'Title A');
|
||||
|
||||
const result = cache.get('/path/a.jsonl', 1000);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.entries).toEqual(entries);
|
||||
expect(result!.sessionTitle).toBe('Title A');
|
||||
});
|
||||
|
||||
it('returns undefined on mtime mismatch (stale)', () => {
|
||||
const cache = new SearchTextCache();
|
||||
const entries = [makeEntry('hello', 'user-1')];
|
||||
cache.set('/path/a.jsonl', 1000, entries, 'Title A');
|
||||
|
||||
const result = cache.get('/path/a.jsonl', 2000);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for uncached paths', () => {
|
||||
const cache = new SearchTextCache();
|
||||
const result = cache.get('/path/missing.jsonl', 1000);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('evicts oldest entry when at max capacity', () => {
|
||||
const cache = new SearchTextCache(3);
|
||||
|
||||
cache.set('/path/1.jsonl', 100, [makeEntry('one', 'u1')], 'One');
|
||||
cache.set('/path/2.jsonl', 200, [makeEntry('two', 'u2')], 'Two');
|
||||
cache.set('/path/3.jsonl', 300, [makeEntry('three', 'u3')], 'Three');
|
||||
|
||||
expect(cache.size).toBe(3);
|
||||
|
||||
// Adding a 4th entry should evict the oldest (1.jsonl)
|
||||
cache.set('/path/4.jsonl', 400, [makeEntry('four', 'u4')], 'Four');
|
||||
|
||||
expect(cache.size).toBe(3);
|
||||
expect(cache.get('/path/1.jsonl', 100)).toBeUndefined();
|
||||
expect(cache.get('/path/4.jsonl', 400)).toBeDefined();
|
||||
});
|
||||
|
||||
it('LRU access moves entry to end, preserving it from eviction', () => {
|
||||
const cache = new SearchTextCache(3);
|
||||
|
||||
cache.set('/path/1.jsonl', 100, [makeEntry('one', 'u1')], 'One');
|
||||
cache.set('/path/2.jsonl', 200, [makeEntry('two', 'u2')], 'Two');
|
||||
cache.set('/path/3.jsonl', 300, [makeEntry('three', 'u3')], 'Three');
|
||||
|
||||
// Access entry 1, moving it to end
|
||||
cache.get('/path/1.jsonl', 100);
|
||||
|
||||
// Adding a 4th should now evict entry 2 (oldest after LRU access)
|
||||
cache.set('/path/4.jsonl', 400, [makeEntry('four', 'u4')], 'Four');
|
||||
|
||||
expect(cache.get('/path/1.jsonl', 100)).toBeDefined();
|
||||
expect(cache.get('/path/2.jsonl', 200)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('invalidate() removes a specific entry', () => {
|
||||
const cache = new SearchTextCache();
|
||||
cache.set('/path/a.jsonl', 1000, [makeEntry('hello', 'u1')], 'Title');
|
||||
|
||||
cache.invalidate('/path/a.jsonl');
|
||||
expect(cache.get('/path/a.jsonl', 1000)).toBeUndefined();
|
||||
expect(cache.size).toBe(0);
|
||||
});
|
||||
|
||||
it('clear() empties the cache', () => {
|
||||
const cache = new SearchTextCache();
|
||||
cache.set('/path/1.jsonl', 100, [makeEntry('one', 'u1')], 'One');
|
||||
cache.set('/path/2.jsonl', 200, [makeEntry('two', 'u2')], 'Two');
|
||||
|
||||
expect(cache.size).toBe(2);
|
||||
cache.clear();
|
||||
expect(cache.size).toBe(0);
|
||||
});
|
||||
|
||||
it('handles undefined sessionTitle', () => {
|
||||
const cache = new SearchTextCache();
|
||||
cache.set('/path/a.jsonl', 1000, [], undefined);
|
||||
|
||||
const result = cache.get('/path/a.jsonl', 1000);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.sessionTitle).toBeUndefined();
|
||||
expect(result!.entries).toEqual([]);
|
||||
});
|
||||
|
||||
it('updates existing entry on re-set', () => {
|
||||
const cache = new SearchTextCache();
|
||||
cache.set('/path/a.jsonl', 1000, [makeEntry('old', 'u1')], 'Old');
|
||||
cache.set('/path/a.jsonl', 2000, [makeEntry('new', 'u2')], 'New');
|
||||
|
||||
const result = cache.get('/path/a.jsonl', 2000);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.entries[0].text).toBe('new');
|
||||
expect(result!.sessionTitle).toBe('New');
|
||||
expect(cache.size).toBe(1);
|
||||
});
|
||||
});
|
||||
230
test/main/services/discovery/SearchTextExtractor.test.ts
Normal file
230
test/main/services/discovery/SearchTextExtractor.test.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
extractSearchableEntries,
|
||||
extractUserText,
|
||||
} from '../../../../src/main/services/discovery/SearchTextExtractor';
|
||||
|
||||
import type { ParsedMessage } from '../../../../src/main/types';
|
||||
|
||||
function makeUserMessage(
|
||||
uuid: string,
|
||||
content: string,
|
||||
timestamp = '2026-01-01T00:00:00.000Z'
|
||||
): ParsedMessage {
|
||||
return {
|
||||
uuid,
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
content,
|
||||
timestamp: new Date(timestamp),
|
||||
isMeta: false,
|
||||
isSidechain: false,
|
||||
} as ParsedMessage;
|
||||
}
|
||||
|
||||
function makeAssistantMessage(
|
||||
uuid: string,
|
||||
textContent: string,
|
||||
timestamp = '2026-01-01T00:00:01.000Z'
|
||||
): ParsedMessage {
|
||||
return {
|
||||
uuid,
|
||||
type: 'assistant',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: textContent }],
|
||||
timestamp: new Date(timestamp),
|
||||
isMeta: false,
|
||||
isSidechain: false,
|
||||
} as ParsedMessage;
|
||||
}
|
||||
|
||||
function makeAssistantWithThinking(
|
||||
uuid: string,
|
||||
thinking: string,
|
||||
textContent: string,
|
||||
timestamp = '2026-01-01T00:00:01.000Z'
|
||||
): ParsedMessage {
|
||||
return {
|
||||
uuid,
|
||||
type: 'assistant',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'thinking', thinking },
|
||||
{ type: 'text', text: textContent },
|
||||
],
|
||||
timestamp: new Date(timestamp),
|
||||
isMeta: false,
|
||||
isSidechain: false,
|
||||
} as ParsedMessage;
|
||||
}
|
||||
|
||||
function makeToolResultMessage(
|
||||
uuid: string,
|
||||
timestamp = '2026-01-01T00:00:01.500Z'
|
||||
): ParsedMessage {
|
||||
return {
|
||||
uuid,
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
content: [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'result text' }],
|
||||
timestamp: new Date(timestamp),
|
||||
isMeta: true,
|
||||
isSidechain: false,
|
||||
} as ParsedMessage;
|
||||
}
|
||||
|
||||
describe('SearchTextExtractor', () => {
|
||||
describe('extractSearchableEntries', () => {
|
||||
it('produces user-{uuid} groupIds for user messages', () => {
|
||||
const messages = [makeUserMessage('u1', 'hello world')];
|
||||
const result = extractSearchableEntries(messages);
|
||||
|
||||
expect(result.entries).toHaveLength(1);
|
||||
expect(result.entries[0].groupId).toBe('user-u1');
|
||||
expect(result.entries[0].itemType).toBe('user');
|
||||
expect(result.entries[0].messageType).toBe('user');
|
||||
expect(result.entries[0].text).toBe('hello world');
|
||||
});
|
||||
|
||||
it('produces ai-{uuid} groupIds for AI groups (using first buffer message uuid)', () => {
|
||||
const messages = [
|
||||
makeUserMessage('u1', 'question'),
|
||||
makeToolResultMessage('tr1', '2026-01-01T00:00:01.000Z'),
|
||||
makeAssistantMessage('a1', 'thinking...', '2026-01-01T00:00:02.000Z'),
|
||||
makeAssistantMessage('a2', 'final answer', '2026-01-01T00:00:03.000Z'),
|
||||
];
|
||||
const result = extractSearchableEntries(messages);
|
||||
|
||||
const aiEntries = result.entries.filter((e) => e.itemType === 'ai');
|
||||
expect(aiEntries).toHaveLength(1);
|
||||
// groupId uses the first message in the AI buffer
|
||||
expect(aiEntries[0].groupId).toMatch(/^ai-/);
|
||||
// Text is from the last assistant message with text
|
||||
expect(aiEntries[0].text).toBe('final answer');
|
||||
});
|
||||
|
||||
it('extracts last AI text output correctly (backward scan)', () => {
|
||||
const messages = [
|
||||
makeUserMessage('u1', 'question'),
|
||||
makeAssistantMessage('a1', 'older output', '2026-01-01T00:00:01.000Z'),
|
||||
makeAssistantMessage('a2', 'latest output', '2026-01-01T00:00:02.000Z'),
|
||||
];
|
||||
const result = extractSearchableEntries(messages);
|
||||
|
||||
const aiEntries = result.entries.filter((e) => e.itemType === 'ai');
|
||||
expect(aiEntries).toHaveLength(1);
|
||||
expect(aiEntries[0].text).toBe('latest output');
|
||||
});
|
||||
|
||||
it('handles assistant messages with thinking + text blocks', () => {
|
||||
const messages = [
|
||||
makeUserMessage('u1', 'question'),
|
||||
makeAssistantWithThinking('a1', 'internal reasoning', 'visible answer'),
|
||||
];
|
||||
const result = extractSearchableEntries(messages);
|
||||
|
||||
const aiEntries = result.entries.filter((e) => e.itemType === 'ai');
|
||||
expect(aiEntries).toHaveLength(1);
|
||||
expect(aiEntries[0].text).toBe('visible answer');
|
||||
});
|
||||
|
||||
it('skips sidechain messages', () => {
|
||||
const sidechain: ParsedMessage = {
|
||||
...makeUserMessage('u-side', 'sidechain text'),
|
||||
isSidechain: true,
|
||||
} as ParsedMessage;
|
||||
const messages = [sidechain, makeUserMessage('u1', 'main thread')];
|
||||
const result = extractSearchableEntries(messages);
|
||||
|
||||
expect(result.entries).toHaveLength(1);
|
||||
expect(result.entries[0].text).toBe('main thread');
|
||||
});
|
||||
|
||||
it('extracts sessionTitle from first user message (truncated to 100 chars)', () => {
|
||||
const longText = 'a'.repeat(200);
|
||||
const messages = [
|
||||
makeUserMessage('u1', longText),
|
||||
makeUserMessage('u2', 'second message'),
|
||||
];
|
||||
const result = extractSearchableEntries(messages);
|
||||
|
||||
expect(result.sessionTitle).toBe('a'.repeat(100));
|
||||
});
|
||||
|
||||
it('handles empty messages array', () => {
|
||||
const result = extractSearchableEntries([]);
|
||||
expect(result.entries).toHaveLength(0);
|
||||
expect(result.sessionTitle).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles messages with no user messages', () => {
|
||||
const messages = [
|
||||
makeAssistantMessage('a1', 'just AI talking'),
|
||||
];
|
||||
const result = extractSearchableEntries(messages);
|
||||
|
||||
expect(result.sessionTitle).toBeUndefined();
|
||||
const aiEntries = result.entries.filter((e) => e.itemType === 'ai');
|
||||
expect(aiEntries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('handles AI buffer with no text content', () => {
|
||||
const noTextAssistant: ParsedMessage = {
|
||||
uuid: 'a1',
|
||||
type: 'assistant',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'thinking', thinking: 'just thinking' }],
|
||||
timestamp: new Date('2026-01-01T00:00:01.000Z'),
|
||||
isMeta: false,
|
||||
isSidechain: false,
|
||||
} as ParsedMessage;
|
||||
const messages = [makeUserMessage('u1', 'question'), noTextAssistant];
|
||||
const result = extractSearchableEntries(messages);
|
||||
|
||||
const aiEntries = result.entries.filter((e) => e.itemType === 'ai');
|
||||
expect(aiEntries).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('flushes AI buffer on user messages', () => {
|
||||
const messages = [
|
||||
makeUserMessage('u1', 'first question'),
|
||||
makeAssistantMessage('a1', 'first answer', '2026-01-01T00:00:01.000Z'),
|
||||
makeUserMessage('u2', 'second question', '2026-01-01T00:00:02.000Z'),
|
||||
makeAssistantMessage('a2', 'second answer', '2026-01-01T00:00:03.000Z'),
|
||||
];
|
||||
const result = extractSearchableEntries(messages);
|
||||
|
||||
expect(result.entries).toHaveLength(4);
|
||||
const userEntries = result.entries.filter((e) => e.itemType === 'user');
|
||||
const aiEntries = result.entries.filter((e) => e.itemType === 'ai');
|
||||
expect(userEntries).toHaveLength(2);
|
||||
expect(aiEntries).toHaveLength(2);
|
||||
expect(aiEntries[0].text).toBe('first answer');
|
||||
expect(aiEntries[1].text).toBe('second answer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractUserText', () => {
|
||||
it('extracts string content', () => {
|
||||
const msg = makeUserMessage('u1', 'hello world');
|
||||
expect(extractUserText(msg)).toBe('hello world');
|
||||
});
|
||||
|
||||
it('extracts array content with text blocks', () => {
|
||||
const msg: ParsedMessage = {
|
||||
uuid: 'u1',
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'part one' },
|
||||
{ type: 'text', text: ' part two' },
|
||||
],
|
||||
timestamp: new Date(),
|
||||
isMeta: false,
|
||||
isSidechain: false,
|
||||
} as ParsedMessage;
|
||||
expect(extractUserText(msg)).toBe('part one part two');
|
||||
});
|
||||
});
|
||||
});
|
||||
95
test/main/services/parsing/AgentConfigReader.test.ts
Normal file
95
test/main/services/parsing/AgentConfigReader.test.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
import { readAgentConfigs } from '@main/services/parsing/AgentConfigReader';
|
||||
|
||||
describe('readAgentConfigs', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-config-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeAgent(filename: string, content: string): void {
|
||||
const agentsDir = path.join(tmpDir, '.claude', 'agents');
|
||||
fs.mkdirSync(agentsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(agentsDir, filename), content);
|
||||
}
|
||||
|
||||
it('returns empty object when .claude/agents/ does not exist', async () => {
|
||||
const result = await readAgentConfigs(tmpDir);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('parses agent with name and color from frontmatter', async () => {
|
||||
writeAgent('test-agent.md', `---
|
||||
name: test-agent
|
||||
color: red
|
||||
---
|
||||
# Test agent
|
||||
`);
|
||||
const result = await readAgentConfigs(tmpDir);
|
||||
expect(result).toEqual({
|
||||
'test-agent': { name: 'test-agent', color: 'red' },
|
||||
});
|
||||
});
|
||||
|
||||
it('uses filename as name when frontmatter has no name field', async () => {
|
||||
writeAgent('my-agent.md', `---
|
||||
color: blue
|
||||
---
|
||||
# My Agent
|
||||
`);
|
||||
const result = await readAgentConfigs(tmpDir);
|
||||
expect(result['my-agent']).toBeDefined();
|
||||
expect(result['my-agent'].color).toBe('blue');
|
||||
});
|
||||
|
||||
it('handles agents without color field', async () => {
|
||||
writeAgent('plain.md', `---
|
||||
name: plain
|
||||
description: "A plain agent"
|
||||
---
|
||||
Content
|
||||
`);
|
||||
const result = await readAgentConfigs(tmpDir);
|
||||
expect(result.plain).toEqual({ name: 'plain' });
|
||||
expect(result.plain.color).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles agents without frontmatter', async () => {
|
||||
writeAgent('no-front.md', '# Just markdown\nNo frontmatter here.');
|
||||
const result = await readAgentConfigs(tmpDir);
|
||||
expect(result['no-front']).toEqual({ name: 'no-front' });
|
||||
});
|
||||
|
||||
it('reads multiple agents', async () => {
|
||||
writeAgent('a.md', `---\nname: a\ncolor: green\n---\n`);
|
||||
writeAgent('b.md', `---\nname: b\ncolor: purple\n---\n`);
|
||||
const result = await readAgentConfigs(tmpDir);
|
||||
expect(Object.keys(result)).toHaveLength(2);
|
||||
expect(result.a.color).toBe('green');
|
||||
expect(result.b.color).toBe('purple');
|
||||
});
|
||||
|
||||
it('strips quotes from frontmatter values', async () => {
|
||||
writeAgent('quoted.md', `---\nname: "quoted-agent"\ncolor: 'cyan'\n---\n`);
|
||||
const result = await readAgentConfigs(tmpDir);
|
||||
expect(result['quoted-agent']).toEqual({ name: 'quoted-agent', color: 'cyan' });
|
||||
});
|
||||
|
||||
it('ignores non-md files', async () => {
|
||||
const agentsDir = path.join(tmpDir, '.claude', 'agents');
|
||||
fs.mkdirSync(agentsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(agentsDir, 'readme.txt'), 'not an agent');
|
||||
writeAgent('real.md', `---\nname: real\ncolor: red\n---\n`);
|
||||
const result = await readAgentConfigs(tmpDir);
|
||||
expect(Object.keys(result)).toEqual(['real']);
|
||||
});
|
||||
});
|
||||
116
test/renderer/constants/teamColors.test.ts
Normal file
116
test/renderer/constants/teamColors.test.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getSubagentTypeColorSet, getTeamColorSet, TeamColorSet } from '@renderer/constants/teamColors';
|
||||
|
||||
function isValidColorSet(cs: TeamColorSet): boolean {
|
||||
return typeof cs.border === 'string' && typeof cs.badge === 'string' && typeof cs.text === 'string';
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// getTeamColorSet
|
||||
// =============================================================================
|
||||
|
||||
describe('getTeamColorSet', () => {
|
||||
it('returns blue (default) for empty string', () => {
|
||||
const result = getTeamColorSet('');
|
||||
expect(result.border).toBe('#3b82f6');
|
||||
});
|
||||
|
||||
it('resolves named colors', () => {
|
||||
expect(getTeamColorSet('green').border).toBe('#22c55e');
|
||||
expect(getTeamColorSet('red').border).toBe('#ef4444');
|
||||
expect(getTeamColorSet('purple').border).toBe('#a855f7');
|
||||
});
|
||||
|
||||
it('is case-insensitive for named colors', () => {
|
||||
expect(getTeamColorSet('Green')).toEqual(getTeamColorSet('green'));
|
||||
expect(getTeamColorSet('BLUE')).toEqual(getTeamColorSet('blue'));
|
||||
});
|
||||
|
||||
it('generates a color set from hex strings', () => {
|
||||
const result = getTeamColorSet('#ff5500');
|
||||
expect(result.border).toBe('#ff5500');
|
||||
expect(result.badge).toBe('#ff550026');
|
||||
expect(result.text).toBe('#ff5500');
|
||||
});
|
||||
|
||||
it('falls back to blue for unknown non-hex strings', () => {
|
||||
const result = getTeamColorSet('nonexistent');
|
||||
expect(result.border).toBe('#3b82f6');
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// getSubagentTypeColorSet
|
||||
// =============================================================================
|
||||
|
||||
describe('getSubagentTypeColorSet', () => {
|
||||
it('always returns a valid TeamColorSet without agent configs', () => {
|
||||
const types = ['test-agent', 'quality-fixer', 'Explore', 'Plan', 'my-custom-agent', 'anything'];
|
||||
for (const t of types) {
|
||||
const result = getSubagentTypeColorSet(t);
|
||||
expect(isValidColorSet(result)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('is deterministic — same input always returns same color', () => {
|
||||
const a = getSubagentTypeColorSet('my-custom-agent');
|
||||
const b = getSubagentTypeColorSet('my-custom-agent');
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
it('different types can produce different colors', () => {
|
||||
const results = new Set(
|
||||
['Explore', 'Plan', 'test-agent', 'quality-fixer', 'claude-md-auditor', 'Bash', 'general-purpose', 'statusline-setup']
|
||||
.map((t) => getSubagentTypeColorSet(t).border)
|
||||
);
|
||||
expect(results.size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('uses color from agent config when available', () => {
|
||||
const configs = {
|
||||
'test-agent': { name: 'test-agent', color: 'red' },
|
||||
};
|
||||
const result = getSubagentTypeColorSet('test-agent', configs);
|
||||
// Should use the named "red" color from getTeamColorSet
|
||||
expect(result.border).toBe('#ef4444');
|
||||
expect(result.text).toBe('#f87171');
|
||||
});
|
||||
|
||||
it('uses hex color from agent config', () => {
|
||||
const configs = {
|
||||
'my-agent': { name: 'my-agent', color: '#ff00ff' },
|
||||
};
|
||||
const result = getSubagentTypeColorSet('my-agent', configs);
|
||||
expect(result.border).toBe('#ff00ff');
|
||||
});
|
||||
|
||||
it('falls back to hash when agent config has no color', () => {
|
||||
const configs = {
|
||||
'my-agent': { name: 'my-agent' },
|
||||
};
|
||||
const withConfig = getSubagentTypeColorSet('my-agent', configs);
|
||||
const withoutConfig = getSubagentTypeColorSet('my-agent');
|
||||
// Should be the same — both use hash fallback
|
||||
expect(withConfig).toEqual(withoutConfig);
|
||||
});
|
||||
|
||||
it('falls back to hash when agent type not in configs', () => {
|
||||
const configs = {
|
||||
'other-agent': { name: 'other-agent', color: 'green' },
|
||||
};
|
||||
const withConfig = getSubagentTypeColorSet('unknown-agent', configs);
|
||||
const withoutConfig = getSubagentTypeColorSet('unknown-agent');
|
||||
expect(withConfig).toEqual(withoutConfig);
|
||||
});
|
||||
|
||||
it('does not interfere with getTeamColorSet', () => {
|
||||
const teamGreen = getTeamColorSet('green');
|
||||
expect(teamGreen.border).toBe('#22c55e');
|
||||
|
||||
const configs = { green: { name: 'green', color: 'purple' } };
|
||||
getSubagentTypeColorSet('green', configs);
|
||||
// Team API remains unaffected
|
||||
expect(getTeamColorSet('green').border).toBe('#22c55e');
|
||||
});
|
||||
});
|
||||
161
test/renderer/utils/keyboardUtils.test.ts
Normal file
161
test/renderer/utils/keyboardUtils.test.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
formatModifierShortcut,
|
||||
getModifierKeyName,
|
||||
getModifierKeySymbol,
|
||||
isMacOS,
|
||||
} from '../../../src/renderer/utils/keyboardUtils';
|
||||
|
||||
describe('keyboardUtils', () => {
|
||||
describe('isMacOS', () => {
|
||||
beforeEach(() => {
|
||||
// Reset userAgent before each test
|
||||
Object.defineProperty(navigator, 'userAgent', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return true when userAgent contains "mac"', () => {
|
||||
Object.defineProperty(navigator, 'userAgent', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
|
||||
});
|
||||
expect(isMacOS()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when userAgent does not contain "mac"', () => {
|
||||
Object.defineProperty(navigator, 'userAgent', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
|
||||
});
|
||||
expect(isMacOS()).toBe(false);
|
||||
});
|
||||
|
||||
it('should be case-insensitive', () => {
|
||||
Object.defineProperty(navigator, 'userAgent', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 'Mozilla/5.0 (MAC OS)',
|
||||
});
|
||||
expect(isMacOS()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getModifierKeyName', () => {
|
||||
it('should return "Cmd" on macOS', () => {
|
||||
Object.defineProperty(navigator, 'userAgent', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
|
||||
});
|
||||
expect(getModifierKeyName()).toBe('Cmd');
|
||||
});
|
||||
|
||||
it('should return "Ctrl" on Windows', () => {
|
||||
Object.defineProperty(navigator, 'userAgent', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
|
||||
});
|
||||
expect(getModifierKeyName()).toBe('Ctrl');
|
||||
});
|
||||
|
||||
it('should return "Ctrl" on Linux', () => {
|
||||
Object.defineProperty(navigator, 'userAgent', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 'Mozilla/5.0 (X11; Linux x86_64)',
|
||||
});
|
||||
expect(getModifierKeyName()).toBe('Ctrl');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getModifierKeySymbol', () => {
|
||||
it('should return "⌘" on macOS', () => {
|
||||
Object.defineProperty(navigator, 'userAgent', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
|
||||
});
|
||||
expect(getModifierKeySymbol()).toBe('⌘');
|
||||
});
|
||||
|
||||
it('should return "Ctrl" on Windows', () => {
|
||||
Object.defineProperty(navigator, 'userAgent', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
|
||||
});
|
||||
expect(getModifierKeySymbol()).toBe('Ctrl');
|
||||
});
|
||||
|
||||
it('should return "Ctrl" on Linux', () => {
|
||||
Object.defineProperty(navigator, 'userAgent', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 'Mozilla/5.0 (X11; Linux x86_64)',
|
||||
});
|
||||
expect(getModifierKeySymbol()).toBe('Ctrl');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatModifierShortcut', () => {
|
||||
describe('macOS', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(navigator, 'userAgent', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
|
||||
});
|
||||
});
|
||||
|
||||
it('should format with symbol by default', () => {
|
||||
expect(formatModifierShortcut('K')).toBe('⌘K');
|
||||
});
|
||||
|
||||
it('should format with text when useSymbol is false', () => {
|
||||
expect(formatModifierShortcut('K', false)).toBe('Cmd+K');
|
||||
});
|
||||
|
||||
it('should work with different keys', () => {
|
||||
expect(formatModifierShortcut('G')).toBe('⌘G');
|
||||
expect(formatModifierShortcut('S')).toBe('⌘S');
|
||||
expect(formatModifierShortcut('Enter')).toBe('⌘Enter');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Windows/Linux', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(navigator, 'userAgent', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
|
||||
});
|
||||
});
|
||||
|
||||
it('should format with symbol by default', () => {
|
||||
expect(formatModifierShortcut('K')).toBe('Ctrl+K');
|
||||
});
|
||||
|
||||
it('should format with text when useSymbol is false', () => {
|
||||
expect(formatModifierShortcut('K', false)).toBe('Ctrl+K');
|
||||
});
|
||||
|
||||
it('should work with different keys', () => {
|
||||
expect(formatModifierShortcut('G')).toBe('Ctrl+G');
|
||||
expect(formatModifierShortcut('S')).toBe('Ctrl+S');
|
||||
expect(formatModifierShortcut('Enter')).toBe('Ctrl+Enter');
|
||||
});
|
||||
|
||||
it('should always include + separator', () => {
|
||||
expect(formatModifierShortcut('K')).toContain('+');
|
||||
expect(formatModifierShortcut('K', false)).toContain('+');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
716
test/renderer/utils/sessionExporter.test.ts
Normal file
716
test/renderer/utils/sessionExporter.test.ts
Normal file
|
|
@ -0,0 +1,716 @@
|
|||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
import {
|
||||
extractTextFromContent,
|
||||
exportAsPlainText,
|
||||
exportAsMarkdown,
|
||||
exportAsJson,
|
||||
triggerDownload,
|
||||
type ExportFormat,
|
||||
} from '@renderer/utils/sessionExporter';
|
||||
|
||||
// =============================================================================
|
||||
// Test Fixtures
|
||||
// =============================================================================
|
||||
|
||||
function makeMetrics(overrides = {}) {
|
||||
return {
|
||||
durationMs: 60000,
|
||||
totalTokens: 5000,
|
||||
inputTokens: 3000,
|
||||
outputTokens: 2000,
|
||||
cacheReadTokens: 500,
|
||||
cacheCreationTokens: 100,
|
||||
messageCount: 10,
|
||||
costUsd: 0.05,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeSession(overrides = {}) {
|
||||
return {
|
||||
id: 'test-session-123',
|
||||
projectId: '-Users-test-project',
|
||||
projectPath: '/Users/test/project',
|
||||
createdAt: new Date('2025-01-15T10:00:00Z').getTime(),
|
||||
hasSubagents: false,
|
||||
messageCount: 10,
|
||||
firstMessage: 'Hello, help me debug this',
|
||||
gitBranch: 'main',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeMessage(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
uuid: 'msg-1',
|
||||
parentUuid: null,
|
||||
type: 'user' as const,
|
||||
timestamp: new Date('2025-01-15T10:00:00Z'),
|
||||
content: 'Hello world',
|
||||
isMeta: false,
|
||||
isSidechain: false,
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeUserChunk(overrides: Record<string, unknown> = {}) {
|
||||
const msg = makeMessage();
|
||||
return {
|
||||
id: 'chunk-user-1',
|
||||
chunkType: 'user' as const,
|
||||
startTime: new Date('2025-01-15T10:00:00Z'),
|
||||
endTime: new Date('2025-01-15T10:00:01Z'),
|
||||
durationMs: 1000,
|
||||
metrics: makeMetrics({ messageCount: 1 }),
|
||||
userMessage: msg,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeAIChunk(overrides: Record<string, unknown> = {}) {
|
||||
const response = makeMessage({
|
||||
uuid: 'msg-2',
|
||||
type: 'assistant',
|
||||
content: [{ type: 'text', text: 'Here is the answer' }],
|
||||
});
|
||||
return {
|
||||
id: 'chunk-ai-1',
|
||||
chunkType: 'ai' as const,
|
||||
startTime: new Date('2025-01-15T10:00:01Z'),
|
||||
endTime: new Date('2025-01-15T10:00:05Z'),
|
||||
durationMs: 4000,
|
||||
metrics: makeMetrics({ messageCount: 2 }),
|
||||
responses: [response],
|
||||
processes: [],
|
||||
sidechainMessages: [],
|
||||
toolExecutions: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeSystemChunk(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'chunk-system-1',
|
||||
chunkType: 'system' as const,
|
||||
startTime: new Date('2025-01-15T10:00:06Z'),
|
||||
endTime: new Date('2025-01-15T10:00:07Z'),
|
||||
durationMs: 1000,
|
||||
metrics: makeMetrics({ messageCount: 1 }),
|
||||
message: makeMessage({ type: 'user', content: 'command output here' }),
|
||||
commandOutput: 'Set model to sonnet',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCompactChunk(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'chunk-compact-1',
|
||||
chunkType: 'compact' as const,
|
||||
startTime: new Date('2025-01-15T10:01:00Z'),
|
||||
endTime: new Date('2025-01-15T10:01:00Z'),
|
||||
durationMs: 0,
|
||||
metrics: makeMetrics({ messageCount: 0 }),
|
||||
message: makeMessage({ type: 'summary', content: 'Summary of conversation' }),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeSessionDetail(overrides: Record<string, unknown> = {}) {
|
||||
const userChunk = makeUserChunk();
|
||||
const aiChunk = makeAIChunk();
|
||||
return {
|
||||
session: makeSession(),
|
||||
messages: [userChunk.userMessage as any, (aiChunk.responses as any)[0]],
|
||||
chunks: [userChunk, aiChunk],
|
||||
processes: [],
|
||||
metrics: makeMetrics(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// extractTextFromContent
|
||||
// =============================================================================
|
||||
|
||||
describe('extractTextFromContent', () => {
|
||||
it('returns string content directly', () => {
|
||||
expect(extractTextFromContent('Hello world')).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('returns empty string for empty string', () => {
|
||||
expect(extractTextFromContent('')).toBe('');
|
||||
});
|
||||
|
||||
it('extracts text from TextContent blocks', () => {
|
||||
const blocks = [
|
||||
{ type: 'text', text: 'First part.' },
|
||||
{ type: 'text', text: 'Second part.' },
|
||||
];
|
||||
expect(extractTextFromContent(blocks as any)).toBe('First part.\nSecond part.');
|
||||
});
|
||||
|
||||
it('includes thinking content when option is set', () => {
|
||||
const blocks = [
|
||||
{ type: 'thinking', thinking: 'Let me think about this...' },
|
||||
{ type: 'text', text: 'Answer here.' },
|
||||
];
|
||||
expect(extractTextFromContent(blocks as any, { includeThinking: true })).toBe(
|
||||
'Let me think about this...\nAnswer here.'
|
||||
);
|
||||
});
|
||||
|
||||
it('excludes thinking content by default', () => {
|
||||
const blocks = [
|
||||
{ type: 'thinking', thinking: 'Let me think...' },
|
||||
{ type: 'text', text: 'Answer here.' },
|
||||
];
|
||||
expect(extractTextFromContent(blocks as any)).toBe('Answer here.');
|
||||
});
|
||||
|
||||
it('extracts tool_use content as formatted string', () => {
|
||||
const blocks = [{ type: 'tool_use', id: 'tu-1', name: 'Read', input: { path: '/foo.ts' } }];
|
||||
const result = extractTextFromContent(blocks as any);
|
||||
expect(result).toContain('Tool: Read');
|
||||
expect(result).toContain('/foo.ts');
|
||||
});
|
||||
|
||||
it('extracts tool_result content', () => {
|
||||
const blocks = [{ type: 'tool_result', tool_use_id: 'tu-1', content: 'file contents here' }];
|
||||
const result = extractTextFromContent(blocks as any);
|
||||
expect(result).toContain('file contents here');
|
||||
});
|
||||
|
||||
it('handles tool_result with array content', () => {
|
||||
const blocks = [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tu-1',
|
||||
content: [{ type: 'text', text: 'result text' }],
|
||||
},
|
||||
];
|
||||
const result = extractTextFromContent(blocks as any);
|
||||
expect(result).toContain('result text');
|
||||
});
|
||||
|
||||
it('skips image blocks gracefully', () => {
|
||||
const blocks = [
|
||||
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: '...' } },
|
||||
{ type: 'text', text: 'Caption' },
|
||||
];
|
||||
expect(extractTextFromContent(blocks as any)).toBe('[Image]\nCaption');
|
||||
});
|
||||
|
||||
it('returns empty string for empty array', () => {
|
||||
expect(extractTextFromContent([])).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// exportAsPlainText
|
||||
// =============================================================================
|
||||
|
||||
describe('exportAsPlainText', () => {
|
||||
it('includes session header with metadata', () => {
|
||||
const detail = makeSessionDetail();
|
||||
const result = exportAsPlainText(detail as any);
|
||||
|
||||
expect(result).toContain('SESSION EXPORT');
|
||||
expect(result).toContain('test-session-123');
|
||||
expect(result).toContain('/Users/test/project');
|
||||
});
|
||||
|
||||
it('includes metrics section', () => {
|
||||
const detail = makeSessionDetail();
|
||||
const result = exportAsPlainText(detail as any);
|
||||
|
||||
expect(result).toContain('METRICS');
|
||||
expect(result).toContain('5,000');
|
||||
expect(result).toContain('$0.05');
|
||||
});
|
||||
|
||||
it('renders user chunks with USER: label', () => {
|
||||
const detail = makeSessionDetail();
|
||||
const result = exportAsPlainText(detail as any);
|
||||
|
||||
expect(result).toContain('USER:');
|
||||
expect(result).toContain('Hello world');
|
||||
});
|
||||
|
||||
it('renders AI chunks with ASSISTANT: label', () => {
|
||||
const detail = makeSessionDetail();
|
||||
const result = exportAsPlainText(detail as any);
|
||||
|
||||
expect(result).toContain('ASSISTANT:');
|
||||
expect(result).toContain('Here is the answer');
|
||||
});
|
||||
|
||||
it('renders system chunks with SYSTEM: label', () => {
|
||||
const detail = makeSessionDetail({
|
||||
chunks: [makeSystemChunk()],
|
||||
});
|
||||
const result = exportAsPlainText(detail as any);
|
||||
|
||||
expect(result).toContain('SYSTEM:');
|
||||
expect(result).toContain('Set model to sonnet');
|
||||
});
|
||||
|
||||
it('renders compact chunks as [Context compacted]', () => {
|
||||
const detail = makeSessionDetail({
|
||||
chunks: [makeCompactChunk()],
|
||||
});
|
||||
const result = exportAsPlainText(detail as any);
|
||||
|
||||
expect(result).toContain('[Context compacted]');
|
||||
});
|
||||
|
||||
it('renders tool executions with TOOL: label', () => {
|
||||
const aiChunk = makeAIChunk({
|
||||
toolExecutions: [
|
||||
{
|
||||
toolCall: {
|
||||
id: 'tu-1',
|
||||
name: 'Read',
|
||||
input: { file_path: '/src/main.ts' },
|
||||
isTask: false,
|
||||
},
|
||||
result: { toolUseId: 'tu-1', content: 'file content', isError: false },
|
||||
startTime: new Date('2025-01-15T10:00:02Z'),
|
||||
endTime: new Date('2025-01-15T10:00:03Z'),
|
||||
durationMs: 1000,
|
||||
},
|
||||
],
|
||||
});
|
||||
const detail = makeSessionDetail({ chunks: [makeUserChunk(), aiChunk] });
|
||||
const result = exportAsPlainText(detail as any);
|
||||
|
||||
expect(result).toContain('TOOL: Read');
|
||||
expect(result).toContain('/src/main.ts');
|
||||
expect(result).toContain('file content');
|
||||
});
|
||||
|
||||
it('renders thinking blocks with THINKING: label', () => {
|
||||
const aiChunk = makeAIChunk({
|
||||
responses: [
|
||||
makeMessage({
|
||||
uuid: 'msg-think',
|
||||
type: 'assistant',
|
||||
content: [
|
||||
{ type: 'thinking', thinking: 'Let me reason about this...' },
|
||||
{ type: 'text', text: 'Final answer.' },
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
const detail = makeSessionDetail({ chunks: [makeUserChunk(), aiChunk] });
|
||||
const result = exportAsPlainText(detail as any);
|
||||
|
||||
expect(result).toContain('THINKING:');
|
||||
expect(result).toContain('Let me reason about this...');
|
||||
expect(result).toContain('Final answer.');
|
||||
});
|
||||
|
||||
it('handles tool execution with error result', () => {
|
||||
const aiChunk = makeAIChunk({
|
||||
toolExecutions: [
|
||||
{
|
||||
toolCall: { id: 'tu-1', name: 'Bash', input: { command: 'rm -rf /' }, isTask: false },
|
||||
result: { toolUseId: 'tu-1', content: 'Permission denied', isError: true },
|
||||
startTime: new Date('2025-01-15T10:00:02Z'),
|
||||
},
|
||||
],
|
||||
});
|
||||
const detail = makeSessionDetail({ chunks: [makeUserChunk(), aiChunk] });
|
||||
const result = exportAsPlainText(detail as any);
|
||||
|
||||
expect(result).toContain('TOOL: Bash');
|
||||
expect(result).toContain('[ERROR]');
|
||||
expect(result).toContain('Permission denied');
|
||||
});
|
||||
|
||||
it('uses separator lines between chunks', () => {
|
||||
const detail = makeSessionDetail();
|
||||
const result = exportAsPlainText(detail as any);
|
||||
|
||||
// Should contain horizontal rule separators
|
||||
expect(result).toMatch(/─{20,}/);
|
||||
});
|
||||
|
||||
it('formats cost as N/A when undefined', () => {
|
||||
const detail = makeSessionDetail({
|
||||
metrics: makeMetrics({ costUsd: undefined }),
|
||||
});
|
||||
const result = exportAsPlainText(detail as any);
|
||||
|
||||
expect(result).toContain('N/A');
|
||||
});
|
||||
|
||||
it('includes branch info when available', () => {
|
||||
const detail = makeSessionDetail();
|
||||
const result = exportAsPlainText(detail as any);
|
||||
|
||||
expect(result).toContain('main');
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// exportAsMarkdown
|
||||
// =============================================================================
|
||||
|
||||
describe('exportAsMarkdown', () => {
|
||||
it('starts with # Session Export heading', () => {
|
||||
const detail = makeSessionDetail();
|
||||
const result = exportAsMarkdown(detail as any);
|
||||
|
||||
expect(result).toMatch(/^# Session Export/);
|
||||
});
|
||||
|
||||
it('includes property table with session info', () => {
|
||||
const detail = makeSessionDetail();
|
||||
const result = exportAsMarkdown(detail as any);
|
||||
|
||||
expect(result).toContain('| Property | Value |');
|
||||
expect(result).toContain('test-session-123');
|
||||
expect(result).toContain('/Users/test/project');
|
||||
expect(result).toContain('main');
|
||||
});
|
||||
|
||||
it('includes ## Metrics table', () => {
|
||||
const detail = makeSessionDetail();
|
||||
const result = exportAsMarkdown(detail as any);
|
||||
|
||||
expect(result).toContain('## Metrics');
|
||||
expect(result).toContain('| Metric | Value |');
|
||||
expect(result).toContain('5,000');
|
||||
expect(result).toContain('$0.05');
|
||||
});
|
||||
|
||||
it('includes ## Conversation section', () => {
|
||||
const detail = makeSessionDetail();
|
||||
const result = exportAsMarkdown(detail as any);
|
||||
|
||||
expect(result).toContain('## Conversation');
|
||||
});
|
||||
|
||||
it('renders user chunks with ### User heading', () => {
|
||||
const detail = makeSessionDetail();
|
||||
const result = exportAsMarkdown(detail as any);
|
||||
|
||||
expect(result).toContain('### User');
|
||||
expect(result).toContain('Hello world');
|
||||
});
|
||||
|
||||
it('renders AI chunks with ### Assistant heading', () => {
|
||||
const detail = makeSessionDetail();
|
||||
const result = exportAsMarkdown(detail as any);
|
||||
|
||||
expect(result).toContain('### Assistant');
|
||||
expect(result).toContain('Here is the answer');
|
||||
});
|
||||
|
||||
it('renders system chunks with ### System heading', () => {
|
||||
const detail = makeSessionDetail({
|
||||
chunks: [makeSystemChunk()],
|
||||
});
|
||||
const result = exportAsMarkdown(detail as any);
|
||||
|
||||
expect(result).toContain('### System');
|
||||
expect(result).toContain('Set model to sonnet');
|
||||
});
|
||||
|
||||
it('renders compact chunks with --- and italic text', () => {
|
||||
const detail = makeSessionDetail({
|
||||
chunks: [makeCompactChunk()],
|
||||
});
|
||||
const result = exportAsMarkdown(detail as any);
|
||||
|
||||
expect(result).toContain('---');
|
||||
expect(result).toContain('*Context compacted*');
|
||||
});
|
||||
|
||||
it('renders tool calls with **Tool:** and code blocks', () => {
|
||||
const aiChunk = makeAIChunk({
|
||||
toolExecutions: [
|
||||
{
|
||||
toolCall: {
|
||||
id: 'tu-1',
|
||||
name: 'Read',
|
||||
input: { file_path: '/src/app.ts' },
|
||||
isTask: false,
|
||||
},
|
||||
result: { toolUseId: 'tu-1', content: 'export default App;', isError: false },
|
||||
startTime: new Date('2025-01-15T10:00:02Z'),
|
||||
},
|
||||
],
|
||||
});
|
||||
const detail = makeSessionDetail({ chunks: [makeUserChunk(), aiChunk] });
|
||||
const result = exportAsMarkdown(detail as any);
|
||||
|
||||
expect(result).toContain('**Tool:** `Read`');
|
||||
expect(result).toContain('```json');
|
||||
expect(result).toContain('file_path');
|
||||
expect(result).toContain('```');
|
||||
expect(result).toContain('export default App;');
|
||||
});
|
||||
|
||||
it('renders thinking as blockquotes', () => {
|
||||
const aiChunk = makeAIChunk({
|
||||
responses: [
|
||||
makeMessage({
|
||||
uuid: 'msg-think',
|
||||
type: 'assistant',
|
||||
content: [
|
||||
{ type: 'thinking', thinking: 'Deep thought here' },
|
||||
{ type: 'text', text: 'Output text' },
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
const detail = makeSessionDetail({ chunks: [makeUserChunk(), aiChunk] });
|
||||
const result = exportAsMarkdown(detail as any);
|
||||
|
||||
expect(result).toContain('> *Thinking:*');
|
||||
expect(result).toContain('> Deep thought here');
|
||||
});
|
||||
|
||||
it('marks error tool results', () => {
|
||||
const aiChunk = makeAIChunk({
|
||||
toolExecutions: [
|
||||
{
|
||||
toolCall: { id: 'tu-1', name: 'Bash', input: { command: 'fail' }, isTask: false },
|
||||
result: { toolUseId: 'tu-1', content: 'Error: not found', isError: true },
|
||||
startTime: new Date('2025-01-15T10:00:02Z'),
|
||||
},
|
||||
],
|
||||
});
|
||||
const detail = makeSessionDetail({ chunks: [makeUserChunk(), aiChunk] });
|
||||
const result = exportAsMarkdown(detail as any);
|
||||
|
||||
expect(result).toContain('**Error:**');
|
||||
});
|
||||
|
||||
it('numbers turns sequentially', () => {
|
||||
const detail = makeSessionDetail({
|
||||
chunks: [
|
||||
makeUserChunk(),
|
||||
makeAIChunk(),
|
||||
makeUserChunk({ id: 'chunk-user-2' }),
|
||||
makeAIChunk({ id: 'chunk-ai-2' }),
|
||||
],
|
||||
});
|
||||
const result = exportAsMarkdown(detail as any);
|
||||
|
||||
// Check that turn numbers appear (Turn 1, Turn 2, etc.)
|
||||
const turnMatches = result.match(/### (User|Assistant)/g);
|
||||
expect(turnMatches).toBeTruthy();
|
||||
expect(turnMatches!.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// exportAsJson
|
||||
// =============================================================================
|
||||
|
||||
describe('exportAsJson', () => {
|
||||
it('returns valid JSON', () => {
|
||||
const detail = makeSessionDetail();
|
||||
const result = exportAsJson(detail as any);
|
||||
|
||||
expect(() => JSON.parse(result)).not.toThrow();
|
||||
});
|
||||
|
||||
it('returns pretty-printed JSON with 2-space indentation', () => {
|
||||
const detail = makeSessionDetail();
|
||||
const result = exportAsJson(detail as any);
|
||||
|
||||
// Pretty-printed JSON has newlines and indentation
|
||||
expect(result).toContain('\n');
|
||||
expect(result).toContain(' ');
|
||||
});
|
||||
|
||||
it('preserves session data', () => {
|
||||
const detail = makeSessionDetail();
|
||||
const result = exportAsJson(detail as any);
|
||||
const parsed = JSON.parse(result);
|
||||
|
||||
expect(parsed.session.id).toBe('test-session-123');
|
||||
expect(parsed.session.projectPath).toBe('/Users/test/project');
|
||||
});
|
||||
|
||||
it('preserves metrics', () => {
|
||||
const detail = makeSessionDetail();
|
||||
const result = exportAsJson(detail as any);
|
||||
const parsed = JSON.parse(result);
|
||||
|
||||
expect(parsed.metrics.totalTokens).toBe(5000);
|
||||
expect(parsed.metrics.costUsd).toBe(0.05);
|
||||
});
|
||||
|
||||
it('preserves chunks array', () => {
|
||||
const detail = makeSessionDetail();
|
||||
const result = exportAsJson(detail as any);
|
||||
const parsed = JSON.parse(result);
|
||||
|
||||
expect(parsed.chunks).toBeDefined();
|
||||
expect(Array.isArray(parsed.chunks)).toBe(true);
|
||||
expect(parsed.chunks.length).toBe(2);
|
||||
});
|
||||
|
||||
it('preserves messages array', () => {
|
||||
const detail = makeSessionDetail();
|
||||
const result = exportAsJson(detail as any);
|
||||
const parsed = JSON.parse(result);
|
||||
|
||||
expect(parsed.messages).toBeDefined();
|
||||
expect(Array.isArray(parsed.messages)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// triggerDownload
|
||||
// =============================================================================
|
||||
|
||||
describe('triggerDownload', () => {
|
||||
let createElementSpy: ReturnType<typeof vi.spyOn>;
|
||||
let mockAnchor: { href: string; download: string; click: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockAnchor = {
|
||||
href: '',
|
||||
download: '',
|
||||
click: vi.fn(),
|
||||
};
|
||||
createElementSpy = vi.spyOn(document, 'createElement').mockReturnValue(mockAnchor as any);
|
||||
vi.spyOn(document.body, 'appendChild').mockReturnValue(mockAnchor as any);
|
||||
vi.spyOn(document.body, 'removeChild').mockReturnValue(mockAnchor as any);
|
||||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock-url');
|
||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('creates anchor element and triggers click for markdown', () => {
|
||||
const detail = makeSessionDetail();
|
||||
triggerDownload(detail as any, 'markdown');
|
||||
|
||||
expect(createElementSpy).toHaveBeenCalledWith('a');
|
||||
expect(mockAnchor.download).toBe('session-test-session-123.md');
|
||||
expect(mockAnchor.click).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses .json extension for json format', () => {
|
||||
const detail = makeSessionDetail();
|
||||
triggerDownload(detail as any, 'json');
|
||||
|
||||
expect(mockAnchor.download).toBe('session-test-session-123.json');
|
||||
});
|
||||
|
||||
it('uses .txt extension for plaintext format', () => {
|
||||
const detail = makeSessionDetail();
|
||||
triggerDownload(detail as any, 'plaintext');
|
||||
|
||||
expect(mockAnchor.download).toBe('session-test-session-123.txt');
|
||||
});
|
||||
|
||||
it('creates and revokes object URL', () => {
|
||||
const detail = makeSessionDetail();
|
||||
triggerDownload(detail as any, 'markdown');
|
||||
|
||||
expect(URL.createObjectURL).toHaveBeenCalled();
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
|
||||
});
|
||||
|
||||
it('appends and removes anchor from body', () => {
|
||||
const detail = makeSessionDetail();
|
||||
triggerDownload(detail as any, 'plaintext');
|
||||
|
||||
expect(document.body.appendChild).toHaveBeenCalled();
|
||||
expect(document.body.removeChild).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Edge cases
|
||||
// =============================================================================
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles empty chunks array', () => {
|
||||
const detail = makeSessionDetail({ chunks: [], messages: [] });
|
||||
|
||||
expect(() => exportAsPlainText(detail as any)).not.toThrow();
|
||||
expect(() => exportAsMarkdown(detail as any)).not.toThrow();
|
||||
expect(() => exportAsJson(detail as any)).not.toThrow();
|
||||
});
|
||||
|
||||
it('handles AI chunk with no tool executions', () => {
|
||||
const aiChunk = makeAIChunk({ toolExecutions: [] });
|
||||
const detail = makeSessionDetail({ chunks: [makeUserChunk(), aiChunk] });
|
||||
|
||||
const text = exportAsPlainText(detail as any);
|
||||
expect(text).toContain('ASSISTANT:');
|
||||
expect(text).not.toContain('TOOL:');
|
||||
});
|
||||
|
||||
it('handles AI chunk with no responses', () => {
|
||||
const aiChunk = makeAIChunk({ responses: [] });
|
||||
const detail = makeSessionDetail({ chunks: [aiChunk] });
|
||||
|
||||
expect(() => exportAsPlainText(detail as any)).not.toThrow();
|
||||
expect(() => exportAsMarkdown(detail as any)).not.toThrow();
|
||||
});
|
||||
|
||||
it('handles tool execution without result', () => {
|
||||
const aiChunk = makeAIChunk({
|
||||
toolExecutions: [
|
||||
{
|
||||
toolCall: { id: 'tu-1', name: 'Bash', input: { command: 'ls' }, isTask: false },
|
||||
startTime: new Date('2025-01-15T10:00:02Z'),
|
||||
},
|
||||
],
|
||||
});
|
||||
const detail = makeSessionDetail({ chunks: [makeUserChunk(), aiChunk] });
|
||||
|
||||
const text = exportAsPlainText(detail as any);
|
||||
expect(text).toContain('TOOL: Bash');
|
||||
expect(text).toContain('[No result]');
|
||||
});
|
||||
|
||||
it('handles mixed chunk types in sequence', () => {
|
||||
const detail = makeSessionDetail({
|
||||
chunks: [
|
||||
makeUserChunk(),
|
||||
makeAIChunk(),
|
||||
makeSystemChunk(),
|
||||
makeCompactChunk(),
|
||||
makeUserChunk({ id: 'chunk-user-2' }),
|
||||
makeAIChunk({ id: 'chunk-ai-2' }),
|
||||
],
|
||||
});
|
||||
|
||||
const text = exportAsPlainText(detail as any);
|
||||
expect(text).toContain('USER:');
|
||||
expect(text).toContain('ASSISTANT:');
|
||||
expect(text).toContain('SYSTEM:');
|
||||
expect(text).toContain('[Context compacted]');
|
||||
|
||||
const md = exportAsMarkdown(detail as any);
|
||||
expect(md).toContain('### User');
|
||||
expect(md).toContain('### Assistant');
|
||||
expect(md).toContain('### System');
|
||||
expect(md).toContain('*Context compacted*');
|
||||
});
|
||||
|
||||
it('handles content blocks with mixed types', () => {
|
||||
const blocks = [
|
||||
{ type: 'thinking', thinking: 'Hmm...' },
|
||||
{ type: 'tool_use', id: 'tu-1', name: 'Read', input: { path: '/a.ts' } },
|
||||
{ type: 'text', text: 'Result text' },
|
||||
{ type: 'tool_result', tool_use_id: 'tu-1', content: 'file data' },
|
||||
];
|
||||
const result = extractTextFromContent(blocks as any, { includeThinking: true });
|
||||
expect(result).toContain('Hmm...');
|
||||
expect(result).toContain('Tool: Read');
|
||||
expect(result).toContain('Result text');
|
||||
expect(result).toContain('file data');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue