diff --git a/package.json b/package.json index 922e782..2bff61e 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "react-dom": "^19.2.4", "react-markdown": "^10.1.0", "react-resizable-panels": "^4.7.2", + "recharts": "^3.8.0", "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ca9894..08ad7b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: react-resizable-panels: specifier: ^4.7.2 version: 4.7.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + recharts: + specifier: ^3.8.0 + version: 3.8.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@17.0.2)(react@19.2.4)(redux@5.0.1) remark-gfm: specifier: ^4.0.1 version: 4.0.1 @@ -67,7 +70,7 @@ importers: version: 1.4.0 zustand: specifier: ^5.0.11 - version: 5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + version: 5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) devDependencies: '@electron-toolkit/preload': specifier: ^3.0.2 @@ -1353,6 +1356,17 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@reduxjs/toolkit@2.11.2': + resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + '@rollup/rollup-android-arm-eabi@4.59.0': resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} cpu: [arm] @@ -1498,6 +1512,9 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@szmarczak/http-timer@4.0.6': resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} @@ -1631,6 +1648,33 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -1687,6 +1731,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@types/verror@1.10.11': resolution: {integrity: sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==} @@ -2035,6 +2082,50 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -2044,6 +2135,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -2212,6 +2306,9 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + es-toolkit@1.45.1: + resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==} + es6-error@4.1.1: resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} @@ -2243,6 +2340,9 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + expand-template@2.0.3: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} @@ -2475,6 +2575,12 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + + immer@11.1.4: + resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -2496,6 +2602,10 @@ packages: inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + ip-address@10.1.0: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} @@ -3156,6 +3266,18 @@ packages: '@types/react': '>=18' react: '>=18' + react-redux@9.2.0: + resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -3204,10 +3326,26 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + recharts@3.8.0: + resolution: {integrity: sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -3228,6 +3366,9 @@ packages: resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} engines: {node: '>=12', npm: '>=6'} + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resolve-alpn@1.2.1: resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} @@ -3448,6 +3589,9 @@ packages: tiny-async-pool@1.3.0: resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tiny-typed-emitter@2.1.0: resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==} @@ -3587,6 +3731,9 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4989,6 +5136,18 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.4 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.1.1 + optionalDependencies: + react: 19.2.4 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1) + '@rollup/rollup-android-arm-eabi@4.59.0': optional: true @@ -5068,6 +5227,8 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@standard-schema/utils@0.3.0': {} + '@szmarczak/http-timer@4.0.6': dependencies: defer-to-connect: 2.0.1 @@ -5188,6 +5349,30 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -5250,6 +5435,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/use-sync-external-store@0.0.6': {} + '@types/verror@1.10.11': optional: true @@ -5644,10 +5831,50 @@ snapshots: csstype@3.2.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js-light@2.5.1: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -5871,6 +6098,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + es-toolkit@1.45.1: {} + es6-error@4.1.1: optional: true @@ -5945,6 +6174,8 @@ snapshots: dependencies: '@types/estree': 1.0.8 + eventemitter3@5.0.4: {} + expand-template@2.0.3: {} expect-type@1.3.0: {} @@ -6229,6 +6460,10 @@ snapshots: ieee754@1.2.1: {} + immer@10.2.0: {} + + immer@11.1.4: {} + imurmurhash@0.1.4: {} indent-string@4.0.0: {} @@ -6244,6 +6479,8 @@ snapshots: inline-style-parser@0.2.7: {} + internmap@2.0.3: {} + ip-address@10.1.0: {} is-alphabetical@2.0.1: {} @@ -7135,6 +7372,15 @@ snapshots: transitivePeerDependencies: - supports-color + react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + redux: 5.0.1 + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): dependencies: react: 19.2.4 @@ -7181,11 +7427,37 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + recharts@3.8.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@17.0.2)(react@19.2.4)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.45.1 + eventemitter3: 5.0.4 + immer: 10.2.0 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-is: 17.0.2 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@19.2.4) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + redent@3.0.0: dependencies: indent-string: 4.0.0 strip-indent: 3.0.0 + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 @@ -7226,6 +7498,8 @@ snapshots: dependencies: pe-library: 0.4.1 + reselect@5.1.1: {} + resolve-alpn@1.2.1: {} responselike@2.0.1: @@ -7481,6 +7755,8 @@ snapshots: dependencies: semver: 5.7.2 + tiny-invariant@1.3.3: {} + tiny-typed-emitter@2.1.0: {} tinybench@2.9.0: {} @@ -7618,6 +7894,23 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1): dependencies: esbuild: 0.27.4 @@ -7724,9 +8017,10 @@ snapshots: yocto-queue@0.1.0: {} - zustand@5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): + zustand@5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): optionalDependencies: '@types/react': 19.2.14 + immer: 11.1.4 react: 19.2.4 use-sync-external-store: 1.6.0(react@19.2.4) diff --git a/src/main/bootstrap/app-bootstrap.ts b/src/main/bootstrap/app-bootstrap.ts index ab94a69..0e4d0bd 100644 --- a/src/main/bootstrap/app-bootstrap.ts +++ b/src/main/bootstrap/app-bootstrap.ts @@ -17,6 +17,7 @@ import { CapturePipeline } from "../pipeline/capture-pipeline"; import { SessionResolver } from "../pipeline/session-resolver"; import { ExchangeQueryService } from "../queries/exchange-query-service"; import { SessionQueryService } from "../queries/session-query-service"; +import { DashboardQueryService } from "../queries/dashboard-query-service"; import { ExchangeRepository } from "../storage/exchange-repository"; import { AppDataService } from "../storage/app-data-service"; import { HistoryMaintenanceService } from "../storage/history-maintenance-service"; @@ -42,6 +43,7 @@ export interface AppBootstrap { proxyManager: ProxyManager; sessionQueryService: SessionQueryService; exchangeQueryService: ExchangeQueryService; + dashboardQueryService: DashboardQueryService; exportData(filePath: string): AppDataTransferResult; importData(filePath: string): Promise; getProfiles(): ConnectionProfile[]; @@ -148,6 +150,7 @@ export function createAppBootstrap( exchangeRepository, providerCatalog, ); + const dashboardQueryService = new DashboardQueryService(exchangeRepository); function emitProfileStatuses(): void { deps.onProfileStatusChanged?.({ @@ -273,6 +276,7 @@ export function createAppBootstrap( proxyManager, sessionQueryService, exchangeQueryService, + dashboardQueryService, exportData(filePath) { return appDataService.exportToFile(filePath); }, diff --git a/src/main/index.ts b/src/main/index.ts index 6401be7..e1baca4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -137,6 +137,7 @@ app.whenReady().then(async () => { proxyManager: appBootstrap.proxyManager, sessionQueryService: appBootstrap.sessionQueryService, exchangeQueryService: appBootstrap.exchangeQueryService, + dashboardQueryService: appBootstrap.dashboardQueryService, exportData: (filePath) => appBootstrap!.exportData(filePath), importData: (filePath) => appBootstrap!.importData(filePath), clearHistory: () => { diff --git a/src/main/ipc/register-ipc.ts b/src/main/ipc/register-ipc.ts index f6712c5..45bfa09 100644 --- a/src/main/ipc/register-ipc.ts +++ b/src/main/ipc/register-ipc.ts @@ -10,6 +10,7 @@ import type { import type { UpdateService } from "../update/update-service"; import type { ExchangeQueryService } from "../queries/exchange-query-service"; import type { SessionQueryService } from "../queries/session-query-service"; +import type { DashboardQueryService } from "../queries/dashboard-query-service"; import type { ProfileStore } from "../storage/profile-store"; import type { ProxyManager } from "../transport/proxy-manager"; @@ -21,6 +22,7 @@ export interface IpcDependencies { "listSessions" | "getSessionTrace" >; exchangeQueryService: Pick; + dashboardQueryService: Pick; exportData: (filePath: string) => AppDataTransferResult; importData: (filePath: string) => Promise; clearHistory: () => void | Promise; @@ -225,6 +227,13 @@ export function registerIpcHandlers(deps: IpcDependencies): () => void { return deps.exchangeQueryService.getExchangeDetail(exchangeId); }); + ipcMain.handle(IPC.GET_SESSION_DASHBOARD, (_event, sessionId: string) => { + if (typeof sessionId !== "string") { + throw new Error("Invalid sessionId"); + } + return deps.dashboardQueryService.getSessionDashboard(sessionId); + }); + ipcMain.handle(IPC.CLEAR_HISTORY, async () => { await deps.clearHistory(); broadcast(deps.getMainWindow, IPC.TRACE_RESET, { diff --git a/src/main/queries/dashboard-query-service.ts b/src/main/queries/dashboard-query-service.ts new file mode 100644 index 0000000..224ee5c --- /dev/null +++ b/src/main/queries/dashboard-query-service.ts @@ -0,0 +1,212 @@ +import type { + NormalizedExchange, + NormalizedMessage, + SessionDashboardVM, + ModelTokenBreakdown, + ToolCallStat, + ContextInjectionStat, + StopReasonStat, + ExchangeTimePoint, +} from "../../shared/contracts"; +import { ExchangeRepository } from "../storage/exchange-repository"; +import { annotateMessage } from "../providers/protocol-adapters/shared/annotate-blocks"; + +function safeParseJson(json: string | null | undefined, fallback: T): T { + if (!json) return fallback; + try { + return JSON.parse(json) as T; + } catch { + return fallback; + } +} + +const EMPTY_NORMALIZED: NormalizedExchange = { + exchangeId: "", + providerId: "anthropic", + profileId: "", + endpointKind: "messages", + model: null, + request: { instructions: [], tools: [], inputMessages: [], meta: {} }, + response: { + outputMessages: [], + stopReason: null, + usage: null, + error: null, + meta: {}, + }, +}; + +function collectToolCalls( + messages: NormalizedMessage[], + toolMap: Map, +): void { + for (const msg of messages) { + for (const block of msg.blocks) { + if (block.type === "tool-call") { + const entry = toolMap.get(block.name) ?? { + callCount: 0, + errorCount: 0, + }; + entry.callCount++; + toolMap.set(block.name, entry); + } + if (block.type === "tool-result" && block.isError) { + const name = findToolNameByCallId(messages, block.callId); + if (name) { + const entry = toolMap.get(name) ?? { callCount: 0, errorCount: 0 }; + entry.errorCount++; + toolMap.set(name, entry); + } + } + } + } +} + +function findToolNameByCallId( + messages: NormalizedMessage[], + callId: string | undefined, +): string | null { + if (!callId) return null; + for (const msg of messages) { + for (const block of msg.blocks) { + if (block.type === "tool-call" && block.callId === callId) { + return block.name; + } + } + } + return null; +} + +function collectContextInjections( + messages: NormalizedMessage[], + ctxMap: Map, +): void { + for (const msg of messages) { + for (const block of msg.blocks) { + if (block.type === "text" && block.meta?.injected && block.meta.contextType) { + const key = block.meta.contextType; + const entry = ctxMap.get(key) ?? { count: 0, totalChars: 0 }; + entry.count++; + entry.totalChars += block.meta.charCount ?? block.text.length; + ctxMap.set(key, entry); + } + } + } +} + +export class DashboardQueryService { + constructor(private readonly exchangeRepository: ExchangeRepository) {} + + getSessionDashboard(sessionId: string): SessionDashboardVM { + const rows = this.exchangeRepository.listBySessionId(sessionId); + + let totalInputTokens = 0; + let totalOutputTokens = 0; + let totalReasoningTokens = 0; + let totalDurationMs = 0; + let errorCount = 0; + + const modelMap = new Map(); + const toolMap = new Map(); + const ctxMap = new Map(); + const stopReasonMap = new Map(); + const timeline: ExchangeTimePoint[] = []; + + for (const row of rows) { + const normalized = safeParseJson( + row.normalized_json as string, + EMPTY_NORMALIZED, + ); + + const usage = normalized.response.usage; + const input = usage?.inputTokens ?? 0; + const output = usage?.outputTokens ?? 0; + const reasoning = usage?.reasoningTokens ?? 0; + + totalInputTokens += input; + totalOutputTokens += output; + totalReasoningTokens += reasoning; + totalDurationMs += row.duration_ms ?? 0; + + if (normalized.response.error) { + errorCount++; + } + + // Model breakdown + const modelKey = normalized.model ?? "unknown"; + const modelEntry = modelMap.get(modelKey) ?? { + model: modelKey, + inputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + totalTokens: 0, + exchangeCount: 0, + }; + modelEntry.inputTokens += input; + modelEntry.outputTokens += output; + modelEntry.reasoningTokens += reasoning; + modelEntry.totalTokens += input + output + reasoning; + modelEntry.exchangeCount++; + modelMap.set(modelKey, modelEntry); + + // Tool calls from all messages + const allMessages = [ + ...normalized.request.inputMessages, + ...normalized.response.outputMessages, + ]; + collectToolCalls(allMessages, toolMap); + + // Context injections (need annotation since normalized_json doesn't have meta) + const annotatedInput = normalized.request.inputMessages.map(annotateMessage); + collectContextInjections(annotatedInput, ctxMap); + + // Stop reasons + if (normalized.response.stopReason) { + const prev = stopReasonMap.get(normalized.response.stopReason) ?? 0; + stopReasonMap.set(normalized.response.stopReason, prev + 1); + } + + // Timeline point + timeline.push({ + exchangeId: row.exchange_id, + startedAt: row.started_at, + durationMs: row.duration_ms, + inputTokens: input, + outputTokens: output, + reasoningTokens: reasoning, + model: normalized.model, + statusCode: row.status_code, + }); + } + + const toolCalls: ToolCallStat[] = Array.from(toolMap.entries()) + .map(([name, stat]) => ({ name, ...stat })) + .sort((a, b) => b.callCount - a.callCount); + + const contextInjections: ContextInjectionStat[] = Array.from(ctxMap.entries()) + .map(([contextType, stat]) => ({ contextType, ...stat })) + .sort((a, b) => b.totalChars - a.totalChars); + + const stopReasons: StopReasonStat[] = Array.from(stopReasonMap.entries()) + .map(([reason, count]) => ({ reason, count })) + .sort((a, b) => b.count - a.count); + + return { + sessionId, + exchangeCount: rows.length, + totalDurationMs, + tokens: { + inputTokens: totalInputTokens, + outputTokens: totalOutputTokens, + reasoningTokens: totalReasoningTokens, + totalTokens: totalInputTokens + totalOutputTokens + totalReasoningTokens, + }, + modelBreakdown: Array.from(modelMap.values()), + toolCalls, + contextInjections, + stopReasons, + errorCount, + timeline, + }; + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index f0406f7..25bf97f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -5,6 +5,7 @@ import type { ExchangeDetailVM, ProfileStatusChangedEvent, ProfilesChangedEvent, + SessionDashboardVM, SessionListFilter, SessionListItemVM, SessionTraceVM, @@ -51,6 +52,9 @@ export const electronAPI: ElectronAPI = { getExchangeDetail: (exchangeId: string): Promise => ipcRenderer.invoke(IPC.GET_EXCHANGE_DETAIL, exchangeId), + getSessionDashboard: (sessionId: string): Promise => + ipcRenderer.invoke(IPC.GET_SESSION_DASHBOARD, sessionId), + clearHistory: (): Promise => ipcRenderer.invoke(IPC.CLEAR_HISTORY), getUpdateState: (): Promise => diff --git a/src/renderer/src/components/content-tab-bar.tsx b/src/renderer/src/components/content-tab-bar.tsx index d555d70..7929393 100644 --- a/src/renderer/src/components/content-tab-bar.tsx +++ b/src/renderer/src/components/content-tab-bar.tsx @@ -8,6 +8,7 @@ const TABS: { id: ContentTab; label: string }[] = [ { id: "system", label: "System" }, { id: "tools", label: "Tools" }, { id: "other", label: "Other" }, + { id: "dashboard", label: "Dashboard" }, ]; export function ContentTabBar() { diff --git a/src/renderer/src/components/dashboard-charts.tsx b/src/renderer/src/components/dashboard-charts.tsx new file mode 100644 index 0000000..9a33bc1 --- /dev/null +++ b/src/renderer/src/components/dashboard-charts.tsx @@ -0,0 +1,213 @@ +import { useMemo } from "react"; +import { + AreaChart, + Area, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + PieChart, + Pie, + Cell, + BarChart, + Bar, + Legend, +} from "recharts"; +import type { PieLabelRenderProps } from "recharts"; +import type { + ExchangeTimePoint, + ModelTokenBreakdown, + ToolCallStat, + StopReasonStat, +} from "../../../shared/contracts"; + +// Monochrome palette — mid-to-light greys with good contrast on dark bg +const MONO = [ + "#c0c0c0", + "#909090", + "#686868", + "#b0b0b0", + "#808080", + "#585858", + "#a0a0a0", + "#707070", +]; + +const AREA_COLORS = { + input: "#c0c0c0", + output: "#808080", + reasoning: "#505050", +}; + +const TOOLTIP_STYLE = { + backgroundColor: "#252525", + border: "1px solid #555", + borderRadius: 6, + fontSize: 11, + color: "#e5e5e5", +}; + +const AXIS_COLOR = "#666"; + +function formatTime(dateStr: string): string { + const d = new Date(dateStr); + return `${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}`; +} + +function formatTokens(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return String(n); +} + +function ChartCard({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+ {title} + {children} +
+ ); +} + +function pieLabel(props: PieLabelRenderProps): React.ReactElement { + const { x, y, name, percent } = props; + const pct = ((percent ?? 0) * 100).toFixed(0); + return ( + 200 ? "start" : "end"} dominantBaseline="central"> + {`${name ?? ""} ${pct}%`} + + ); +} + +interface DashboardChartsProps { + timeline: ExchangeTimePoint[]; + modelBreakdown: ModelTokenBreakdown[]; + toolCalls: ToolCallStat[]; + stopReasons: StopReasonStat[]; +} + +function TokenTimeline({ timeline }: { timeline: ExchangeTimePoint[] }) { + if (timeline.length === 0) return
No data
; + + const data = timeline.map((p) => ({ + time: formatTime(p.startedAt), + input: p.inputTokens, + output: p.outputTokens, + reasoning: p.reasoningTokens, + })); + + return ( + + + + + + + + + + + + ); +} + +function ModelBreakdownChart({ data }: { data: ModelTokenBreakdown[] }) { + if (data.length === 0) return
No data
; + + const pieData = data.map((m) => ({ name: m.model, value: m.totalTokens })); + + return ( + + + + {pieData.map((_, i) => ( + + ))} + + formatTokens(Number(value))} /> + + + ); +} + +function ToolUsageChart({ data }: { data: ToolCallStat[] }) { + if (data.length === 0) return
No tool calls
; + + const top = data.slice(0, 15); + + const maxNameLen = useMemo( + () => Math.min(200, Math.max(80, ...top.map((t) => t.name.length * 7))), + [top], + ); + + return ( + + + + + + + + + + + ); +} + +function StopReasonsChart({ data }: { data: StopReasonStat[] }) { + if (data.length === 0) return
No data
; + + const pieData = data.map((s) => ({ name: s.reason, value: s.count })); + + return ( + + + + {pieData.map((_, i) => ( + + ))} + + + + + ); +} + +export function DashboardCharts({ timeline, modelBreakdown, toolCalls, stopReasons }: DashboardChartsProps) { + return ( +
+ + + + + + + + + + + + +
+ ); +} diff --git a/src/renderer/src/components/dashboard-stats-cards.tsx b/src/renderer/src/components/dashboard-stats-cards.tsx new file mode 100644 index 0000000..c4ca6a7 --- /dev/null +++ b/src/renderer/src/components/dashboard-stats-cards.tsx @@ -0,0 +1,47 @@ +import type { TokenStats } from "../../../shared/contracts"; + +interface StatsCardsProps { + tokens: TokenStats; + exchangeCount: number; + totalDurationMs: number; + errorCount: number; +} + +function formatNumber(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return n.toLocaleString(); +} + +function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + const seconds = ms / 1000; + if (seconds < 60) return `${seconds.toFixed(1)}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = Math.floor(seconds % 60); + return `${minutes}m ${remainingSeconds}s`; +} + +function StatCard({ label, value, sub }: { label: string; value: string; sub?: string }) { + return ( +
+ {label} + {value} + {sub && {sub}} +
+ ); +} + +export function DashboardStatsCards({ tokens, exchangeCount, totalDurationMs, errorCount }: StatsCardsProps) { + return ( +
+ + + + + + + +
+ ); +} diff --git a/src/renderer/src/components/dashboard-view.tsx b/src/renderer/src/components/dashboard-view.tsx new file mode 100644 index 0000000..fb4ea6b --- /dev/null +++ b/src/renderer/src/components/dashboard-view.tsx @@ -0,0 +1,56 @@ +import { useEffect } from "react"; +import { useSessionStore } from "../stores/session-store"; +import { useDashboardStore } from "../stores/dashboard-store"; +import { useTraceStore } from "../stores/trace-store"; +import { DashboardStatsCards } from "./dashboard-stats-cards"; +import { DashboardCharts } from "./dashboard-charts"; +import { ScrollArea } from "./ui/scroll-area"; + +export function DashboardView() { + const selectedSessionId = useSessionStore((s) => s.selectedSessionId); + const contentTab = useTraceStore((s) => s.contentTab); + const dashboard = useDashboardStore((s) => s.dashboard); + const loading = useDashboardStore((s) => s.loading); + const loadDashboard = useDashboardStore((s) => s.loadDashboard); + + useEffect(() => { + if (contentTab === "dashboard" && selectedSessionId) { + void loadDashboard(selectedSessionId); + } + }, [contentTab, selectedSessionId, loadDashboard]); + + if (loading && !dashboard) { + return ( +
+ Loading... +
+ ); + } + + if (!dashboard) { + return ( +
+ No data available +
+ ); + } + + return ( + +
+ + +
+
+ ); +} diff --git a/src/renderer/src/components/main-content.tsx b/src/renderer/src/components/main-content.tsx index 8c941db..ec82f1b 100644 --- a/src/renderer/src/components/main-content.tsx +++ b/src/renderer/src/components/main-content.tsx @@ -11,6 +11,7 @@ import { ConversationView } from "./conversation-view"; import { SystemView } from "./system-view"; import { ToolsView } from "./tools-view"; import { OtherView } from "./other-view"; +import { DashboardView } from "./dashboard-view"; import { InspectorPanel } from "./inspector-panel"; import { useSessionStore } from "../stores/session-store"; import { useTraceStore } from "../stores/trace-store"; @@ -141,6 +142,7 @@ export function MainContent() {
+
); diff --git a/src/renderer/src/lib/electron-api.ts b/src/renderer/src/lib/electron-api.ts index ecdc4e3..f089cde 100644 --- a/src/renderer/src/lib/electron-api.ts +++ b/src/renderer/src/lib/electron-api.ts @@ -18,6 +18,7 @@ export function getElectronAPI(): ElectronAPI { "listSessions", "getSessionTrace", "getExchangeDetail", + "getSessionDashboard", "clearHistory", "getUpdateState", "checkForUpdates", diff --git a/src/renderer/src/stores/dashboard-store.ts b/src/renderer/src/stores/dashboard-store.ts new file mode 100644 index 0000000..5e21720 --- /dev/null +++ b/src/renderer/src/stores/dashboard-store.ts @@ -0,0 +1,69 @@ +import { create } from "zustand"; +import type { SessionDashboardVM } from "../../../shared/contracts"; +import { getElectronAPI } from "../lib/electron-api"; +import { useSessionStore } from "./session-store"; + +interface DashboardState { + dashboard: SessionDashboardVM | null; + isOpen: boolean; + loading: boolean; + loadDashboard: (sessionId: string) => Promise; + toggleOpen: () => void; + clear: () => void; +} + +export const useDashboardStore = create((set, get) => { + let syncVersion = 0; + + return { + dashboard: null, + isOpen: false, + loading: false, + + loadDashboard: async (sessionId) => { + const nextVersion = ++syncVersion; + set({ loading: true }); + try { + const api = getElectronAPI(); + const dashboard = await api.getSessionDashboard(sessionId); + if (syncVersion !== nextVersion) return; + set({ dashboard, loading: false }); + } catch (err) { + console.error("[DashboardStore] Failed to load dashboard:", err); + if (syncVersion === nextVersion) { + set({ loading: false }); + } + } + }, + + toggleOpen: () => { + const next = !get().isOpen; + set({ isOpen: next }); + if (next) { + const sessionId = useSessionStore.getState().selectedSessionId; + if (sessionId) { + void get().loadDashboard(sessionId); + } + } + }, + + clear: () => { + syncVersion++; + set({ dashboard: null, loading: false }); + }, + }; +}); + +// Auto-clear when session changes; reload if panel is open +useSessionStore.subscribe((state, prevState) => { + if (state.selectedSessionId !== prevState.selectedSessionId) { + const store = useDashboardStore.getState(); + store.clear(); + if (store.isOpen && state.selectedSessionId) { + void store.loadDashboard(state.selectedSessionId); + } + } + if (!state.selectedSessionId && prevState.selectedSessionId) { + useDashboardStore.getState().clear(); + } +}); diff --git a/src/renderer/src/stores/trace-store.ts b/src/renderer/src/stores/trace-store.ts index eeb36b6..799136e 100644 --- a/src/renderer/src/stores/trace-store.ts +++ b/src/renderer/src/stores/trace-store.ts @@ -3,7 +3,7 @@ import type { ExchangeDetailVM, SessionTraceVM } from "../../../shared/contracts import { getElectronAPI } from "../lib/electron-api"; import { useSessionStore } from "./session-store"; -export type ContentTab = "messages" | "system" | "tools" | "other"; +export type ContentTab = "messages" | "system" | "tools" | "other" | "dashboard"; export type MessageOrder = "asc" | "desc"; interface TraceState { diff --git a/src/shared/contracts/dashboard.ts b/src/shared/contracts/dashboard.ts new file mode 100644 index 0000000..e34af1d --- /dev/null +++ b/src/shared/contracts/dashboard.ts @@ -0,0 +1,56 @@ +export interface TokenStats { + inputTokens: number; + outputTokens: number; + reasoningTokens: number; + totalTokens: number; +} + +export interface ModelTokenBreakdown { + model: string; + inputTokens: number; + outputTokens: number; + reasoningTokens: number; + totalTokens: number; + exchangeCount: number; +} + +export interface ToolCallStat { + name: string; + callCount: number; + errorCount: number; +} + +export interface ExchangeTimePoint { + exchangeId: string; + startedAt: string; + durationMs: number | null; + inputTokens: number; + outputTokens: number; + reasoningTokens: number; + model: string | null; + statusCode: number | null; +} + +export interface ContextInjectionStat { + contextType: string; + count: number; + totalChars: number; +} + +export interface StopReasonStat { + reason: string; + count: number; +} + +export interface SessionDashboardVM { + sessionId: string; + exchangeCount: number; + totalDurationMs: number; + tokens: TokenStats; + modelBreakdown: ModelTokenBreakdown[]; + toolCalls: ToolCallStat[]; + contextInjections: ContextInjectionStat[]; + stopReasons: StopReasonStat[]; + errorCount: number; + timeline: ExchangeTimePoint[]; +} diff --git a/src/shared/contracts/index.ts b/src/shared/contracts/index.ts index 2fb6d5d..92e6773 100644 --- a/src/shared/contracts/index.ts +++ b/src/shared/contracts/index.ts @@ -1,4 +1,5 @@ export * from "./capture"; +export * from "./dashboard"; export * from "./events"; export * from "./inspector"; export * from "./normalized"; diff --git a/src/shared/electron-api.ts b/src/shared/electron-api.ts index 0b86b50..b1f4e9b 100644 --- a/src/shared/electron-api.ts +++ b/src/shared/electron-api.ts @@ -3,6 +3,7 @@ import type { ExchangeDetailVM, ProfileStatusChangedEvent, ProfilesChangedEvent, + SessionDashboardVM, SessionListFilter, SessionListItemVM, SessionTraceVM, @@ -26,6 +27,7 @@ export interface ElectronAPI { listSessions(filter?: SessionListFilter): Promise; getSessionTrace(sessionId: string): Promise; getExchangeDetail(exchangeId: string): Promise; + getSessionDashboard(sessionId: string): Promise; clearHistory(): Promise; getUpdateState(): Promise; checkForUpdates(): Promise; diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 9074534..f7c7801 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -10,6 +10,7 @@ export const IPC = { LIST_SESSIONS: "app:list-sessions", GET_SESSION_TRACE: "trace:get-session", GET_EXCHANGE_DETAIL: "trace:get-exchange", + GET_SESSION_DASHBOARD: "trace:get-session-dashboard", CLEAR_HISTORY: "trace:clear-history", GET_UPDATE_STATE: "app:get-update-state", CHECK_FOR_UPDATES: "app:check-for-updates",