diff --git a/package.json b/package.json index 80353f9a..125253e7 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,7 @@ "cmdk": "1.0.4", "date-fns": "^3.6.0", "diff": "^8.0.3", + "dompurify": "^3.3.1", "electron-updater": "^6.7.3", "fastify": "^5.7.4", "highlight.js": "^11.11.1", @@ -117,12 +118,14 @@ "isbinaryfile": "^6.0.0", "lucide-react": "^0.562.0", "mdast-util-to-hast": "^13.2.1", + "mermaid": "^11.12.3", "node-diff3": "^3.2.0", "node-pty": "^1.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", "rehype-highlight": "^7.0.2", + "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "simple-git": "^3.32.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5385a1c..346399b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -158,6 +158,9 @@ importers: diff: specifier: ^8.0.3 version: 8.0.3 + dompurify: + specifier: ^3.3.1 + version: 3.3.1 electron-updater: specifier: ^6.7.3 version: 6.7.3 @@ -179,6 +182,9 @@ importers: mdast-util-to-hast: specifier: ^13.2.1 version: 13.2.1 + mermaid: + specifier: ^11.12.3 + version: 11.12.3 node-diff3: specifier: ^3.2.0 version: 3.2.0 @@ -197,6 +203,9 @@ importers: rehype-highlight: specifier: ^7.0.2 version: 7.0.2 + rehype-raw: + specifier: ^7.0.0 + version: 7.0.0 remark-gfm: specifier: ^4.0.1 version: 4.0.1 @@ -368,6 +377,9 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@babel/code-frame@7.28.6': resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==} engines: {node: '>=6.9.0'} @@ -465,6 +477,24 @@ packages: resolution: {integrity: sha512-DnGHL+v36YVMoWhWZqyJYVZ9dapNm7h4N3/P0lDPirJj0CHVPkjChMCCotj74cg6LW7iPJZFGrdEfh0X0g2bmQ==} engines: {node: '>=18.18'} + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + + '@chevrotain/cst-dts-gen@11.1.2': + resolution: {integrity: sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q==} + + '@chevrotain/gast@11.1.2': + resolution: {integrity: sha512-Z9zfXR5jNZb1Hlsd/p+4XWeUFugrHirq36bKzPWDSIacV+GPSVXdk+ahVWZTwjhNwofAWg/sZg58fyucKSQx5g==} + + '@chevrotain/regexp-to-ast@11.1.2': + resolution: {integrity: sha512-nMU3Uj8naWer7xpZTYJdxbAs6RIv/dxYzkYU8GSwgUtcAAlzjcPfX1w+RKRcYG8POlzMeayOQ/znfwxEGo5ulw==} + + '@chevrotain/types@11.1.2': + resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + + '@chevrotain/utils@11.1.2': + resolution: {integrity: sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==} + '@codemirror/autocomplete@6.20.0': resolution: {integrity: sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==} @@ -1043,6 +1073,12 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.0': + resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==} + '@isaacs/balanced-match@4.0.1': resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} engines: {node: 20 || >=22} @@ -1158,6 +1194,9 @@ packages: '@marijn/find-cluster-break@1.0.2': resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} + '@mermaid-js/parser@1.0.0': + resolution: {integrity: sha512-vvK0Hi/VWndxoh03Mmz6wa1KDriSPjS2XMZL/1l19HFwygiObEEoEwSDxOqyLzzAI6J2PU3261JjTMTO7x+BPw==} + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -1895,6 +1934,99 @@ 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-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@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-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.3': + resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@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/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -1910,6 +2042,9 @@ packages: '@types/fs-extra@9.0.13': resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -1963,6 +2098,9 @@ packages: '@types/ssh2@1.15.5': resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -2599,6 +2737,14 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + chevrotain-allstar@0.3.1: + resolution: {integrity: sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==} + peerDependencies: + chevrotain: ^11.0.0 + + chevrotain@11.1.2: + resolution: {integrity: sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -2706,6 +2852,14 @@ packages: resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} engines: {node: '>= 6'} + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + compare-version@0.1.2: resolution: {integrity: sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==} engines: {node: '>=0.10.0'} @@ -2717,6 +2871,9 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + config-file-ts@0.2.6: resolution: {integrity: sha512-6boGVaglwblBgJqGyxm4+xCmEGcWgnWHSWHY5jad58awQhB6gftq0G8HbzU39YqCIYHMLAiL1yjwiZ36m/CL8w==} @@ -2743,6 +2900,12 @@ packages: core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + cpu-features@0.0.10: resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} engines: {node: '>=10.0.0'} @@ -2774,6 +2937,162 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.33.1: + resolution: {integrity: sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + 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'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + + dagre-d3-es@7.0.13: + resolution: {integrity: sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==} + damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -2792,6 +3111,9 @@ packages: date-fns@3.6.0: resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==} + dayjs@1.11.19: + resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -2838,6 +3160,9 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + delaunator@5.0.1: + resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -2895,6 +3220,9 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dompurify@3.3.1: + resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==} + dotenv-expand@11.0.7: resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} engines: {node: '>=12'} @@ -2973,6 +3301,10 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -3468,6 +3800,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + handlebars@4.7.8: resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} engines: {node: '>=0.4.7'} @@ -3507,18 +3842,33 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + hast-util-is-element@3.0.0: resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-raw@9.1.0: + resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + hast-util-to-parse5@8.0.1: + resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + hast-util-to-text@4.0.2: resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + hermes-estree@0.25.1: resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} @@ -3539,6 +3889,9 @@ packages: html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} @@ -3626,6 +3979,13 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + 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'} @@ -3897,9 +4257,16 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + katex@0.16.33: + resolution: {integrity: sha512-q3N5u+1sY9Bu7T4nlXoiRBXWfwSefNGoKeOwekV+gw0cAXQlz2Ww6BLcmBxVDeXBMUDQv6fK5bcNaJLxob3ZQA==} + hasBin: true + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + knip@5.82.1: resolution: {integrity: sha512-1nQk+5AcnkqL40kGQXfouzAEXkTR+eSrgo/8m1d0BMei4eAzFwghoXC4gOKbACgBiCof7hE8wkBVDsEvznf85w==} engines: {node: '>=18.18.0'} @@ -3908,6 +4275,10 @@ packages: '@types/node': '>=18' typescript: '>=5.0.4 <7' + langium@4.2.1: + resolution: {integrity: sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ==} + engines: {node: '>=20.10.0', npm: '>=10.2.3'} + language-subtag-registry@0.3.23: resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} @@ -3915,6 +4286,12 @@ packages: resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} engines: {node: '>=0.10'} + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + lazy-val@1.0.5: resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} @@ -3949,6 +4326,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash-es@4.17.23: + resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} + lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} @@ -4046,6 +4426,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + matcher@3.0.0: resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} engines: {node: '>=10'} @@ -4103,6 +4488,9 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + mermaid@11.12.3: + resolution: {integrity: sha512-wN5ZSgJQIC+CHJut9xaKWsknLxaFBwCPwPkGTSUYrTiHORWvpT8RxGk849HPnpUAQ+/9BPRqYb80jTpearrHzQ==} + micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -4300,6 +4688,9 @@ packages: engines: {node: '>=10'} hasBin: true + mlly@1.8.0: + resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -4486,6 +4877,9 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -4493,6 +4887,12 @@ packages: parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -4564,10 +4964,19 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + plist@3.1.0: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -4847,6 +5256,9 @@ packages: rehype-highlight@7.0.2: resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==} + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -4925,14 +5337,23 @@ packages: resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} engines: {node: '>=8.0'} + robust-predicates@3.0.2: + resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} + rollup@4.55.1: resolution: {integrity: sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + safe-array-concat@1.1.3: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} @@ -5237,6 +5658,9 @@ packages: style-to-object@1.0.14: resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + stylis@4.3.6: + resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==} + sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} @@ -5312,6 +5736,10 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -5362,6 +5790,10 @@ packages: peerDependencies: typescript: '>=4.8.4' + ts-dedent@2.2.0: + resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} + engines: {node: '>=6.10'} + ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} @@ -5415,6 +5847,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + uglify-js@3.19.3: resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} engines: {node: '>=0.8.0'} @@ -5521,10 +5956,17 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + verror@1.10.1: resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} engines: {node: '>=0.6.0'} + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -5595,6 +6037,26 @@ packages: jsdom: optional: true + vscode-jsonrpc@8.2.0: + resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.17.5: + resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} + + vscode-languageserver-textdocument@1.0.12: + resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} + + vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + + vscode-languageserver@9.0.1: + resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} + hasBin: true + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} @@ -5605,6 +6067,9 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + whatwg-mimetype@3.0.0: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} engines: {node: '>=12'} @@ -5745,6 +6210,11 @@ snapshots: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.6.0 + tinyexec: 1.0.2 + '@babel/code-frame@7.28.6': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -5878,6 +6348,25 @@ snapshots: - eslint-import-resolver-webpack - supports-color + '@braintree/sanitize-url@7.1.2': {} + + '@chevrotain/cst-dts-gen@11.1.2': + dependencies: + '@chevrotain/gast': 11.1.2 + '@chevrotain/types': 11.1.2 + lodash-es: 4.17.23 + + '@chevrotain/gast@11.1.2': + dependencies: + '@chevrotain/types': 11.1.2 + lodash-es: 4.17.23 + + '@chevrotain/regexp-to-ast@11.1.2': {} + + '@chevrotain/types@11.1.2': {} + + '@chevrotain/utils@11.1.2': {} + '@codemirror/autocomplete@6.20.0': dependencies: '@codemirror/language': 6.12.1 @@ -6583,6 +7072,14 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.0': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + mlly: 1.8.0 + '@isaacs/balanced-match@4.0.1': {} '@isaacs/brace-expansion@5.0.1': @@ -6751,6 +7248,10 @@ snapshots: '@marijn/find-cluster-break@1.0.2': {} + '@mermaid-js/parser@1.0.0': + dependencies: + langium: 4.2.1 + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.8.1 @@ -7429,6 +7930,123 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.3': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.3 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -7445,6 +8063,8 @@ snapshots: dependencies: '@types/node': 25.0.7 + '@types/geojson@7946.0.16': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -7506,6 +8126,9 @@ snapshots: dependencies: '@types/node': 18.19.130 + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -7765,8 +8388,7 @@ snapshots: acorn@8.15.0: {} - acorn@8.16.0: - optional: true + acorn@8.16.0: {} agent-base@6.0.2: dependencies: @@ -8317,6 +8939,20 @@ snapshots: check-error@2.1.3: {} + chevrotain-allstar@0.3.1(chevrotain@11.1.2): + dependencies: + chevrotain: 11.1.2 + lodash-es: 4.17.23 + + chevrotain@11.1.2: + dependencies: + '@chevrotain/cst-dts-gen': 11.1.2 + '@chevrotain/gast': 11.1.2 + '@chevrotain/regexp-to-ast': 11.1.2 + '@chevrotain/types': 11.1.2 + '@chevrotain/utils': 11.1.2 + lodash-es: 4.17.23 + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -8419,6 +9055,10 @@ snapshots: commander@5.1.0: {} + commander@7.2.0: {} + + commander@8.3.0: {} + compare-version@0.1.2: {} compress-commons@4.1.2: @@ -8430,6 +9070,8 @@ snapshots: concat-map@0.0.1: {} + confbox@0.1.8: {} + config-file-ts@0.2.6: dependencies: glob: 10.5.0 @@ -8453,6 +9095,14 @@ snapshots: core-util-is@1.0.3: {} + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + cpu-features@0.0.10: dependencies: buildcheck: 0.0.7 @@ -8483,6 +9133,190 @@ snapshots: csstype@3.2.3: {} + cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.1): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.33.1 + + cytoscape-fcose@2.2.0(cytoscape@3.33.1): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.33.1 + + cytoscape@3.33.1: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.0.1 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + 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-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + 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: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dagre-d3-es@7.0.13: + dependencies: + d3: 7.9.0 + lodash-es: 4.17.23 + damerau-levenshtein@1.0.8: {} data-view-buffer@1.0.2: @@ -8505,6 +9339,8 @@ snapshots: date-fns@3.6.0: {} + dayjs@1.11.19: {} + debug@3.2.7: dependencies: ms: 2.1.3 @@ -8543,6 +9379,10 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + delaunator@5.0.1: + dependencies: + robust-predicates: 3.0.2 + delayed-stream@1.0.0: {} delegates@1.0.0: {} @@ -8609,6 +9449,10 @@ snapshots: dependencies: esutils: 2.0.3 + dompurify@3.3.1: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dotenv-expand@11.0.7: dependencies: dotenv: 16.6.1 @@ -8732,6 +9576,8 @@ snapshots: dependencies: once: 1.4.0 + entities@6.0.1: {} + env-paths@2.2.1: {} environment@1.1.0: {} @@ -9479,6 +10325,8 @@ snapshots: graceful-fs@4.2.11: {} + hachure-fill@0.5.2: {} + handlebars@4.7.8: dependencies: minimist: 1.2.8 @@ -9518,10 +10366,41 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.1.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + hast-util-is-element@3.0.0: dependencies: '@types/hast': 3.0.4 + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-raw@9.1.0: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + '@ungap/structured-clone': 1.3.0 + hast-util-from-parse5: 8.0.3 + hast-util-to-parse5: 8.0.1 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + parse5: 7.3.0 + unist-util-position: 5.0.0 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 @@ -9542,6 +10421,16 @@ snapshots: transitivePeerDependencies: - supports-color + hast-util-to-parse5@8.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + hast-util-to-text@4.0.2: dependencies: '@types/hast': 3.0.4 @@ -9553,6 +10442,14 @@ snapshots: dependencies: '@types/hast': 3.0.4 + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + hermes-estree@0.25.1: {} hermes-parser@0.25.1: @@ -9569,6 +10466,8 @@ snapshots: html-url-attributes@3.0.1: {} + html-void-elements@3.0.0: {} + http-cache-semantics@4.2.0: {} http-errors@2.0.1: @@ -9663,6 +10562,10 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + internmap@1.0.1: {} + + internmap@2.0.3: {} + ip-address@10.1.0: {} ipaddr.js@2.3.0: {} @@ -9922,10 +10825,16 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 + katex@0.16.33: + dependencies: + commander: 8.3.0 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 + khroma@2.1.0: {} + knip@5.82.1(@types/node@25.0.7)(typescript@5.9.3): dependencies: '@nodelib/fs.walk': 1.2.8 @@ -9943,12 +10852,24 @@ snapshots: typescript: 5.9.3 zod: 4.3.6 + langium@4.2.1: + dependencies: + chevrotain: 11.1.2 + chevrotain-allstar: 0.3.1(chevrotain@11.1.2) + vscode-languageserver: 9.0.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + language-subtag-registry@0.3.23: {} language-tags@1.0.9: dependencies: language-subtag-registry: 0.3.23 + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + lazy-val@1.0.5: {} lazystream@1.0.1: @@ -9993,6 +10914,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash-es@4.17.23: {} + lodash.defaults@4.2.0: {} lodash.difference@4.5.0: {} @@ -10112,6 +11035,8 @@ snapshots: markdown-table@3.0.4: {} + marked@16.4.2: {} + matcher@3.0.0: dependencies: escape-string-regexp: 4.0.0 @@ -10274,6 +11199,29 @@ snapshots: merge2@1.4.1: {} + mermaid@11.12.3: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.0 + '@mermaid-js/parser': 1.0.0 + '@types/d3': 7.4.3 + cytoscape: 3.33.1 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.33.1) + cytoscape-fcose: 2.2.0(cytoscape@3.33.1) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.13 + dayjs: 1.11.19 + dompurify: 3.3.1 + katex: 0.16.33 + khroma: 2.1.0 + lodash-es: 4.17.23 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.3.6 + ts-dedent: 2.2.0 + uuid: 11.1.0 + micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.2.0 @@ -10565,6 +11513,13 @@ snapshots: mkdirp@1.0.4: {} + mlly@1.8.0: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + ms@2.1.3: {} mz@2.7.0: @@ -10792,6 +11747,8 @@ snapshots: package-json-from-dist@1.0.1: {} + package-manager-detector@1.6.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -10806,6 +11763,12 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + path-data-parser@0.1.0: {} + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -10864,12 +11827,25 @@ snapshots: pirates@4.0.7: {} + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.0 + pathe: 2.0.3 + plist@3.1.0: dependencies: '@xmldom/xmldom': 0.8.11 base64-js: 1.5.1 xmlbuilder: 15.1.1 + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + possible-typed-array-names@1.1.0: {} postcss-import@15.1.0(postcss@8.5.6): @@ -11107,6 +12083,12 @@ snapshots: unist-util-visit: 5.0.0 vfile: 6.0.3 + rehype-raw@7.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-raw: 9.1.0 + vfile: 6.0.3 + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 @@ -11203,6 +12185,8 @@ snapshots: sprintf-js: 1.1.3 optional: true + robust-predicates@3.0.2: {} + rollup@4.55.1: dependencies: '@types/estree': 1.0.8 @@ -11234,10 +12218,19 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.55.1 fsevents: 2.3.3 + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 + rw@1.3.3: {} + safe-array-concat@1.1.3: dependencies: call-bind: 1.0.8 @@ -11591,6 +12584,8 @@ snapshots: dependencies: inline-style-parser: 0.2.7 + stylis@4.3.6: {} + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -11709,6 +12704,8 @@ snapshots: tinyexec@0.3.2: {} + tinyexec@1.0.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -11746,6 +12743,8 @@ snapshots: dependencies: typescript: 5.9.3 + ts-dedent@2.2.0: {} + ts-interface-checker@0.1.13: {} tsconfig-paths@3.15.0: @@ -11819,6 +12818,8 @@ snapshots: typescript@5.9.3: {} + ufo@1.6.3: {} + uglify-js@3.19.3: optional: true @@ -11950,6 +12951,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@11.1.0: {} + verror@1.10.1: dependencies: assert-plus: 1.0.0 @@ -11957,6 +12960,11 @@ snapshots: extsprintf: 1.4.1 optional: true + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 @@ -12035,6 +13043,23 @@ snapshots: - supports-color - terser + vscode-jsonrpc@8.2.0: {} + + vscode-languageserver-protocol@3.17.5: + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.5 + + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.17.5: {} + + vscode-languageserver@9.0.1: + dependencies: + vscode-languageserver-protocol: 3.17.5 + + vscode-uri@3.1.0: {} + w3c-keyname@2.2.8: {} walk-up-path@4.0.0: {} @@ -12043,6 +13068,8 @@ snapshots: dependencies: defaults: 1.0.4 + web-namespaces@2.0.1: {} + whatwg-mimetype@3.0.0: {} which-boxed-primitive@1.1.1: diff --git a/src/main/index.ts b/src/main/index.ts index 38699eee..20fc9ded 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -182,19 +182,23 @@ function extractNotificationContent(text: string): { summary: string; body: stri } async function notifyNewInboxMessages(teamName: string, detail: string): Promise { - // Check config toggle + // Check global toggle const config = configManager.getConfig(); - if (!config.notifications.enabled || !config.notifications.notifyOnInboxMessages) return; + if (!config.notifications.enabled) return; // detail is like "inboxes/carol.json" — extract member name const match = /^inboxes\/(.+)\.json$/.exec(detail); if (!match) return; const memberName = match[1]; - // Only notify for the lead's inbox (messages addressed to the human user). - // CLI doesn't set msg.to, so we filter by inbox file name instead. + // Determine inbox type and check per-inbox toggle const leadName = teamDataService ? await teamDataService.getLeadMemberName(teamName) : null; - if (leadName !== null && memberName !== leadName && memberName !== 'user') return; + const isLeadInbox = leadName !== null && memberName === leadName; + const isUserInbox = memberName === 'user'; + + if (isLeadInbox && !config.notifications.notifyOnLeadInbox) return; + if (isUserInbox && !config.notifications.notifyOnUserInbox) return; + if (!isLeadInbox && !isUserInbox) return; const key = `${teamName}:${memberName}`; @@ -400,7 +404,7 @@ function wireFileWatcherEvents(context: ServiceContext): void { // These don't go through inbox files — they're held in-memory by TeamProvisioningService. if (detail === 'lead-process-reply' || detail === 'lead-direct-reply') { const cfg = configManager.getConfig(); - if (cfg.notifications.enabled && cfg.notifications.notifyOnInboxMessages) { + if (cfg.notifications.enabled && cfg.notifications.notifyOnUserInbox) { const messages = teamProvisioningService.getLiveLeadProcessMessages(teamName); const latest = messages.length > 0 ? messages[messages.length - 1] : undefined; // Only notify for messages addressed to the human user, skip noise diff --git a/src/main/ipc/editor.ts b/src/main/ipc/editor.ts index 56f34ff9..e125fe2c 100644 --- a/src/main/ipc/editor.ts +++ b/src/main/ipc/editor.ts @@ -22,6 +22,7 @@ import { EDITOR_READ_FILE, EDITOR_RENAME_FILE, EDITOR_SEARCH_IN_FILES, + EDITOR_SET_WATCHED_DIRS, EDITOR_SET_WATCHED_FILES, EDITOR_WATCH_DIR, EDITOR_WRITE_FILE, @@ -366,6 +367,19 @@ async function handleEditorSetWatchedFiles( }); } +/** + * Update watched directory list (shallow, depth=0). + */ +async function handleEditorSetWatchedDirs( + _event: IpcMainInvokeEvent, + dirPaths: string[] +): Promise> { + return wrapHandler('setWatchedDirs', async () => { + if (!activeProjectRoot) throw new Error('Editor not initialized'); + editorFileWatcher.setWatchedDirs(Array.isArray(dirPaths) ? dirPaths : []); + }); +} + // ============================================================================= // Registration // ============================================================================= @@ -399,6 +413,7 @@ export function registerEditorHandlers(ipcMain: IpcMain): void { ipcMain.handle(EDITOR_GIT_STATUS, handleEditorGitStatus); ipcMain.handle(EDITOR_WATCH_DIR, handleEditorWatchDir); ipcMain.handle(EDITOR_SET_WATCHED_FILES, handleEditorSetWatchedFiles); + ipcMain.handle(EDITOR_SET_WATCHED_DIRS, handleEditorSetWatchedDirs); } export function removeEditorHandlers(ipcMain: IpcMain): void { @@ -418,6 +433,7 @@ export function removeEditorHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(EDITOR_GIT_STATUS); ipcMain.removeHandler(EDITOR_WATCH_DIR); ipcMain.removeHandler(EDITOR_SET_WATCHED_FILES); + ipcMain.removeHandler(EDITOR_SET_WATCHED_DIRS); } /** diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index c0d45af6..f033393d 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -1313,6 +1313,14 @@ async function handleCreateConfig( if (payload.color !== undefined && typeof payload.color !== 'string') { return { success: false, error: 'color must be a string' }; } + if (payload.cwd !== undefined) { + if (typeof payload.cwd !== 'string' || payload.cwd.trim().length === 0) { + return { success: false, error: 'cwd must be a non-empty string if provided' }; + } + if (!path.isAbsolute(payload.cwd.trim())) { + return { success: false, error: 'cwd must be an absolute path' }; + } + } const seenNames = new Set(); const members: TeamCreateConfigRequest['members'] = []; @@ -1344,6 +1352,7 @@ async function handleCreateConfig( description: payload.description?.trim() || undefined, color: typeof payload.color === 'string' ? payload.color.trim() || undefined : undefined, members, + cwd: typeof payload.cwd === 'string' ? payload.cwd.trim() || undefined : undefined, }) ); } @@ -1377,7 +1386,12 @@ async function handleGetLogsForTask( _event: IpcMainInvokeEvent, teamName: unknown, taskId: unknown, - options?: { owner?: string; status?: string } + options?: { + owner?: string; + status?: string; + intervals?: { startedAt: string; completedAt?: string }[]; + since?: string; + } ): Promise> { const vTeam = validateTeamName(teamName); if (!vTeam.valid) { @@ -1392,6 +1406,17 @@ async function handleGetLogsForTask( ? { owner: typeof options.owner === 'string' ? options.owner : undefined, status: typeof options.status === 'string' ? options.status : undefined, + since: typeof options.since === 'string' ? options.since : undefined, + intervals: Array.isArray(options.intervals) + ? (options.intervals as unknown[]).filter( + (i): i is { startedAt: string; completedAt?: string } => + Boolean(i) && + typeof i === 'object' && + typeof (i as Record).startedAt === 'string' && + ((i as Record).completedAt === undefined || + typeof (i as Record).completedAt === 'string') + ) + : undefined, } : undefined; return wrapTeamHandler('getLogsForTask', () => diff --git a/src/main/services/editor/EditorFileWatcher.ts b/src/main/services/editor/EditorFileWatcher.ts index 97e26f09..ae0c7a2b 100644 --- a/src/main/services/editor/EditorFileWatcher.ts +++ b/src/main/services/editor/EditorFileWatcher.ts @@ -31,6 +31,7 @@ const MAX_EMITTED_EVENTS_PER_FLUSH = 300; export class EditorFileWatcher { private watcher: FSWatcher | null = null; + private dirWatcher: FSWatcher | null = null; private projectRoot: string | null = null; private pendingEvents = new Map(); private flushTimer: ReturnType | null = null; @@ -39,6 +40,7 @@ export class EditorFileWatcher { private readonly DEBOUNCE_MS = 350; private ignoreChangeUntilMs = 0; private watchedFilesKey = ''; + private watchedDirsKey = ''; /** * Initialize watcher context for a project root. @@ -51,6 +53,7 @@ export class EditorFileWatcher { this.projectRoot = projectRoot; this.ignoreChangeUntilMs = Date.now() + STARTUP_IGNORE_CHANGE_MS; this.watchedFilesKey = ''; + this.watchedDirsKey = ''; log.info('Starting file watcher (open files only) for:', projectRoot); this.onChangeCallback = onChange; @@ -62,7 +65,7 @@ export class EditorFileWatcher { */ setWatchedFiles(filePaths: string[]): void { if (!this.projectRoot) { - throw new Error('Watcher not initialized'); + return; // Watcher not initialized yet — will sync when start() is called } const normalized = filePaths @@ -113,6 +116,60 @@ export class EditorFileWatcher { }); } + /** + * Update list of watched directory paths (shallow: depth=0). + * Watches only immediate children changes (create/delete/rename) in those folders. + */ + setWatchedDirs(dirPaths: string[]): void { + if (!this.projectRoot) { + return; // Watcher not initialized yet — will sync when start() is called + } + + const normalized = dirPaths + .filter((p): p is string => typeof p === 'string' && p.length > 0) + .filter((p) => isPathWithinRoot(p, this.projectRoot!)); + + normalized.sort(); + const key = normalized.join('\n'); + if (key === this.watchedDirsKey) return; + this.watchedDirsKey = key; + + if (this.dirWatcher) { + void this.dirWatcher.close(); + this.dirWatcher = null; + } + + if (normalized.length === 0) { + return; + } + + this.dirWatcher = watch(normalized, { + ignoreInitial: true, + ignorePermissionErrors: true, + followSymlinks: false, + depth: 0, + }); + + const emitSafe = (type: EditorFileChangeEvent['type'], filePath: string): void => { + if (!isPathWithinRoot(filePath, this.projectRoot!)) { + log.warn('Watcher event outside project root, ignoring:', filePath); + return; + } + this.pendingEvents.set(filePath, type); + this.scheduleFlush(); + }; + + // For directories, we only care about structural changes. + this.dirWatcher.on('add', (p) => emitSafe('create', p)); + this.dirWatcher.on('unlink', (p) => emitSafe('delete', p)); + this.dirWatcher.on('addDir', (p) => emitSafe('create', p)); + this.dirWatcher.on('unlinkDir', (p) => emitSafe('delete', p)); + + this.dirWatcher.on('error', (error) => { + log.error('Dir watcher error:', error); + }); + } + /** * Stop watching. Safe to call multiple times. */ @@ -125,11 +182,17 @@ export class EditorFileWatcher { this.onChangeCallback = null; this.ignoreChangeUntilMs = 0; this.watchedFilesKey = ''; + this.watchedDirsKey = ''; if (this.watcher) { log.info('Stopping file watcher'); void this.watcher.close(); this.watcher = null; } + if (this.dirWatcher) { + log.info('Stopping directory watcher'); + void this.dirWatcher.close(); + this.dirWatcher = null; + } this.projectRoot = null; } diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts index ee54eb61..0fb40631 100644 --- a/src/main/services/infrastructure/ConfigManager.ts +++ b/src/main/services/infrastructure/ConfigManager.ts @@ -40,8 +40,10 @@ export interface NotificationConfig { snoozeMinutes: number; // Default snooze duration /** Whether to include errors from subagent sessions */ includeSubagentErrors: boolean; - /** Whether to show native OS notifications for team inbox messages */ - notifyOnInboxMessages: boolean; + /** Whether to show native OS notifications when teammates send messages to the team lead */ + notifyOnLeadInbox: boolean; + /** Whether to show native OS notifications when teammates send messages to you (the user) */ + notifyOnUserInbox: boolean; /** Whether to show native OS notifications when a task needs user clarification */ notifyOnClarifications: boolean; /** Notification triggers - define when to generate notifications */ @@ -249,7 +251,8 @@ const DEFAULT_CONFIG: AppConfig = { snoozedUntil: null, snoozeMinutes: 30, includeSubagentErrors: true, - notifyOnInboxMessages: true, + notifyOnLeadInbox: false, + notifyOnUserInbox: true, notifyOnClarifications: true, triggers: DEFAULT_TRIGGERS, }, @@ -416,6 +419,7 @@ export class ConfigManager { private mergeWithDefaults(loaded: Partial): AppConfig { const loadedNotifications = loaded.notifications ?? ({} as Partial); const loadedTriggers = loadedNotifications.triggers ?? []; + const mergedGeneral: GeneralConfig = { ...DEFAULT_CONFIG.general, ...(loaded.general ?? {}), diff --git a/src/main/services/team/TeamAgentToolsInstaller.ts b/src/main/services/team/TeamAgentToolsInstaller.ts index d2cb1445..5bc3ab57 100644 --- a/src/main/services/team/TeamAgentToolsInstaller.ts +++ b/src/main/services/team/TeamAgentToolsInstaller.ts @@ -195,10 +195,37 @@ function writeTask(taskPath, task) { return verify; } +function applyWorkIntervalsForStatusTransition(task, prevStatus, nextStatus, now) { + var wasInProgress = prevStatus === 'in_progress'; + var isInProgress = nextStatus === 'in_progress'; + var intervals = Array.isArray(task.workIntervals) ? task.workIntervals.slice() : []; + var last = intervals.length ? intervals[intervals.length - 1] : null; + + if (!wasInProgress && isInProgress) { + if (!last || typeof last.completedAt === 'string') { + intervals.push({ startedAt: now }); + } + } else if (wasInProgress && !isInProgress) { + // Close the most recent open interval (if any). + for (var i = intervals.length - 1; i >= 0; i--) { + if (intervals[i] && typeof intervals[i].startedAt === 'string' && !intervals[i].completedAt) { + intervals[i].completedAt = now; + break; + } + } + } + + if (intervals.length > 0) task.workIntervals = intervals; + else delete task.workIntervals; +} + function setTaskStatus(paths, taskId, status) { const normalized = normalizeStatus(status); if (!normalized) die('Invalid status: ' + String(status)); const { taskPath, task } = readTask(paths, taskId); + var prev = task.status; + var now = nowIso(); + applyWorkIntervalsForStatusTransition(task, prev, normalized, now); task.status = normalized; writeTask(taskPath, task); } @@ -244,6 +271,7 @@ function addTaskComment(paths, taskId, flags) { id: commentId, author: from, text: text, + type: 'regular', createdAt: nowIso(), }; task.comments = existing.concat([comment]); @@ -413,7 +441,6 @@ function createTask(paths, flags) { : ''; const owner = typeof flags.owner === 'string' && flags.owner.trim() ? flags.owner.trim() : undefined; const explicitStatus = typeof flags.status === 'string' ? flags.status : ''; - const status = normalizeStatus(explicitStatus) || (owner ? 'in_progress' : 'pending'); const activeForm = typeof flags.activeForm === 'string' ? flags.activeForm @@ -423,6 +450,13 @@ function createTask(paths, flags) { var blockedByIds = parseIdList(flags['blocked-by']); var relatedIds = parseIdList(flags.related); + // Default status rule: + // - explicit --status always wins + // - tasks with dependencies should start as pending, even if assigned (owner) + // - otherwise, assigned tasks default to in_progress, unassigned to pending + const status = + normalizeStatus(explicitStatus) || + (blockedByIds.length > 0 ? 'pending' : owner ? 'in_progress' : 'pending'); for (var v = 0; v < blockedByIds.length; v++) { if (!taskExists(paths, blockedByIds[v])) die('Blocked-by task not found: #' + blockedByIds[v]); } for (var w = 0; w < relatedIds.length; w++) { if (!taskExists(paths, relatedIds[w])) die('Related task not found: #' + relatedIds[w]); } @@ -434,6 +468,7 @@ function createTask(paths, flags) { while (true) { nextId = getNextTaskId(paths); taskPath = path.join(paths.tasksDir, String(nextId) + '.json'); + var createdAt = nowIso(); task = { id: nextId, subject, @@ -442,6 +477,8 @@ function createTask(paths, flags) { owner, createdBy: from, status, + createdAt: createdAt, + workIntervals: status === 'in_progress' ? [{ startedAt: createdAt }] : undefined, blocks: [], blockedBy: blockedByIds, related: relatedIds.length > 0 ? relatedIds : undefined, @@ -563,18 +600,32 @@ function sendInboxMessage(paths, teamName, flags) { function reviewApprove(paths, teamName, taskId, flags) { setKanbanColumn(paths, teamName, taskId, 'approved'); - const notify = flags.notify === true || flags['notify-owner'] === true; - if (!notify) return; - const { task } = readTask(paths, taskId); - if (!task.owner) return; + const { taskPath, task } = readTask(paths, taskId); const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths); const note = typeof flags.note === 'string' ? flags.note.trim() : ''; - const text = note + + // Record review comment in task.comments + var existing = Array.isArray(task.comments) ? task.comments : []; + var reviewCommentId = crypto.randomUUID + ? crypto.randomUUID() + : String(Date.now()) + '-' + String(Math.random()); + task.comments = existing.concat([{ + id: reviewCommentId, + author: from, + text: note || 'Approved', + type: 'review_approved', + createdAt: nowIso(), + }]); + writeTask(taskPath, task); + + const notify = flags.notify === true || flags['notify-owner'] === true; + if (!notify || !task.owner) return; + const inboxText = note ? 'Task #' + String(taskId) + ' approved.\n\n' + note : 'Task #' + String(taskId) + ' approved.'; sendInboxMessage(paths, teamName, { to: task.owner, - text, + text: inboxText, summary: 'Approved #' + String(taskId), from, }); @@ -585,12 +636,29 @@ function reviewRequestChanges(paths, teamName, taskId, flags) { const { taskPath, task } = readTask(paths, taskId); if (!task.owner) die('No owner found for task ' + String(taskId)); + const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths); + clearKanban(paths, teamName, taskId); + var now = nowIso(); + applyWorkIntervalsForStatusTransition(task, task.status, 'in_progress', now); task.status = 'in_progress'; + + // Record review comment in task.comments + var existing = Array.isArray(task.comments) ? task.comments : []; + var reviewCommentId = crypto.randomUUID + ? crypto.randomUUID() + : String(Date.now()) + '-' + String(Math.random()); + task.comments = existing.concat([{ + id: reviewCommentId, + author: from, + text: comment || 'Reviewer requested changes.', + type: 'review_request', + createdAt: now, + }]); + writeTask(taskPath, task); - const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths); - const text = + const inboxText = 'Task #' + String(taskId) + ' needs fixes.\n\n' + @@ -599,7 +667,7 @@ function reviewRequestChanges(paths, teamName, taskId, flags) { 'Please fix and mark it as completed when ready.'; sendInboxMessage(paths, teamName, { to: task.owner, - text, + text: inboxText, summary: 'Fix request for #' + String(taskId), from, }); diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 7af56c9d..3223aa54 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -956,11 +956,15 @@ export class TeamDataService { await fs.promises.mkdir(tasksDir, { recursive: true }); const joinedAt = Date.now(); - const config = { + const config: Record = { name: request.displayName?.trim() || request.teamName, description: request.description?.trim() || undefined, color: request.color?.trim() || undefined, }; + if (request.cwd?.trim()) { + config.projectPath = request.cwd.trim(); + config.projectPathHistory = [request.cwd.trim()]; + } await atomicWriteAsync(configPath, JSON.stringify(config, null, 2)); await this.membersMetaStore.writeMembers( diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index e9749189..6345664e 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -110,7 +110,12 @@ export class TeamMemberLogsFinder { async findLogsForTask( teamName: string, taskId: string, - options?: { owner?: string; status?: string } + options?: { + owner?: string; + status?: string; + intervals?: { startedAt: string; completedAt?: string }[]; + since?: string; + } ): Promise { const discovery = await this.discoverProjectSessions(teamName); if (!discovery) return []; @@ -171,6 +176,56 @@ export class TeamMemberLogsFinder { options.owner.trim().length > 0; if (includeOwnerSessions) { const ownerLogs = await this.findMemberLogs(teamName, options.owner!.trim()); + + const TASK_LOG_INTERVAL_GRACE_MS = 10_000; + const fallbackRecentMs = 30 * 60_000; // if caller doesn't supply intervals/since, avoid pulling in old owner history + const now = Date.now(); + + const normalizedIntervals = Array.isArray(options?.intervals) + ? options.intervals + .map((i) => { + const startMs = Date.parse(i.startedAt); + const endMsRaw = + typeof i.completedAt === 'string' ? Date.parse(i.completedAt) : Number.NaN; + const endMs = Number.isFinite(endMsRaw) ? endMsRaw : null; + return Number.isFinite(startMs) ? { startMs, endMs } : null; + }) + .filter((v): v is { startMs: number; endMs: number | null } => v !== null) + : []; + + // Back-compat: single since timestamp -> treat as open interval. + const sinceMsRaw = typeof options?.since === 'string' ? Date.parse(options.since) : NaN; + const sinceMs = Number.isFinite(sinceMsRaw) ? sinceMsRaw : null; + const effectiveIntervals = + normalizedIntervals.length > 0 + ? normalizedIntervals + : sinceMs != null + ? [{ startMs: sinceMs, endMs: null }] + : []; + + const overlapsAnyInterval = (logStartMs: number, logEndMs: number): boolean => { + for (const it of effectiveIntervals) { + const start = it.startMs - TASK_LOG_INTERVAL_GRACE_MS; + const end = (it.endMs ?? now) + TASK_LOG_INTERVAL_GRACE_MS; + if (logStartMs <= end && logEndMs >= start) return true; + } + return false; + }; + + const filteredOwnerLogs = ownerLogs.filter((log) => { + if (log.isOngoing) return true; + const startMs = new Date(log.startTime).getTime(); + if (!Number.isFinite(startMs)) return false; + const durationMs = + typeof log.durationMs === 'number' && log.durationMs > 0 ? log.durationMs : 0; + const endMs = startMs + durationMs; + + if (effectiveIntervals.length > 0) { + return overlapsAnyInterval(startMs, endMs); + } + + return startMs >= now - fallbackRecentMs; + }); const seen = new Set(); for (const log of results) { const key = @@ -179,7 +234,7 @@ export class TeamMemberLogsFinder { : `lead:${log.sessionId}`; seen.add(key); } - for (const log of ownerLogs) { + for (const log of filteredOwnerLogs) { const key = log.kind === 'subagent' ? `subagent:${log.sessionId}:${log.subagentId}` @@ -409,12 +464,17 @@ export class TeamMemberLogsFinder { const patterns: RegExp[] = [ new RegExp(`"task_id"\\s*:\\s*"${escaped}"`, 'i'), new RegExp(`"taskId"\\s*:\\s*"${escaped}"`, 'i'), - new RegExp(`#${escaped}\\b`), ]; if (numericTaskId) { patterns.push( new RegExp(`"task_id"\\s*:\\s*${numericTaskId}\\b`), - new RegExp(`"taskId"\\s*:\\s*${numericTaskId}\\b`) + new RegExp(`"taskId"\\s*:\\s*${numericTaskId}\\b`), + // Support teamctl command lines (may appear in tool output). + // Example: node ".../teamctl.js" --team "t" task start 10 + new RegExp( + `\\bteamctl(?:\\.js)?\\b.{0,250}\\b(?:task|review)\\b.{0,250}\\b${numericTaskId}\\b`, + 'i' + ) ); } try { diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 4675fead..7e5a75ea 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -380,14 +380,18 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string `- Assign/reassign owner: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-owner --notify --from "${leadName}"`, `- Clear owner: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-owner clear`, `- Update status: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-status `, - `- Create with deps: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task create --subject "..." --blocked-by 1,2 --related 3 --owner "" --notify --from "${leadName}"`, + `- Create with deps (blocked work MUST be pending): node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task create --subject "..." --blocked-by 1,2 --related 3 --status pending --owner "" --notify --from "${leadName}"`, `- Link dependency: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task link --blocked-by `, `- Link related: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task link --related `, `- Unlink: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task unlink --blocked-by `, ``, `Dependency guidelines:`, `- Use --blocked-by when a task cannot start until another is done.`, + `- If you set --blocked-by, create the task in pending (use --status pending). Do NOT put blocked tasks into in_progress.`, `- Use --related to link related work (e.g. frontend + backend) without blocking.`, + `- Review tasks: Prefer NOT creating a separate "review task". Reviews apply to the work task (#X) via: review approve/request-changes #X.`, + ` - If you must create a separate review reminder/assignment task, keep it pending and link it to #X with --related (and optionally --blocked-by #X if it truly cannot start yet).`, + ` - Dependencies do not auto-start tasks; the owner must explicitly start it when ready.`, `- Avoid over-specifying. Only add dependencies when execution order matters.`, ``, `Notification policy:`, @@ -554,6 +558,13 @@ ${processRegistration} - Prefer fewer, broader tasks over many micro-tasks. - Avoid duplicate notifications for the same assignment. - When tasks have natural ordering (e.g. setup → implementation → testing), use --blocked-by. + - If a task is blocked (uses --blocked-by), it MUST be created as pending (use --status pending). Do NOT mark blocked tasks in_progress. + - Review guidance: + - Prefer NOT creating a separate "review task". Our workflow reviews the work task itself: run review approve/request-changes on the implementation task #X. + - If you MUST create a separate review reminder/assignment task, create it as pending and link it to the work task: + - Use --related to connect it to #X (non-blocking link). + - If the review truly cannot start until #X is done, ALSO add --blocked-by #X. + - There is no automatic status transition when dependencies resolve — the owner must explicitly start it (task start / set-status in_progress) when ready. - Use --related to connect tasks working on the same feature without blocking. 4) After all steps, output a short summary. @@ -1347,23 +1358,35 @@ export class TeamProvisioningService { configParsed.leadSessionId.trim().length > 0 ) { const candidateId = configParsed.leadSessionId.trim(); - const projectPath = + const storedProjectPath = typeof configParsed.projectPath === 'string' && configParsed.projectPath.trim().length > 0 ? configParsed.projectPath.trim() - : request.cwd; - const projectId = encodePath(projectPath); - const baseDir = extractBaseDir(projectId); - const jsonlPath = path.join(getProjectsBasePath(), baseDir, `${candidateId}.jsonl`); - if (await this.pathExists(jsonlPath)) { - previousSessionId = candidateId; + : null; + + // Sessions are stored per-project (~/.claude/projects/{encodePath(cwd)}/). + // If the project path changed, the old session JSONL won't be found by the CLI + // at the new project directory. Skip resume to avoid passing an invalid --resume arg. + if (storedProjectPath && path.resolve(storedProjectPath) !== path.resolve(request.cwd)) { logger.info( - `[${request.teamName}] Found previous session JSONL for resume: ${candidateId}` + `[${request.teamName}] Project path changed: ${storedProjectPath} → ${request.cwd}. ` + + `Skipping session resume — sessions are per-project.` ); } else { - logger.info( - `[${request.teamName}] Previous session JSONL not found at ${jsonlPath}, starting fresh` - ); + const resumeProjectPath = storedProjectPath ?? request.cwd; + const projectId = encodePath(resumeProjectPath); + const baseDir = extractBaseDir(projectId); + const jsonlPath = path.join(getProjectsBasePath(), baseDir, `${candidateId}.jsonl`); + if (await this.pathExists(jsonlPath)) { + previousSessionId = candidateId; + logger.info( + `[${request.teamName}] Found previous session JSONL for resume: ${candidateId}` + ); + } else { + logger.info( + `[${request.teamName}] Previous session JSONL not found at ${jsonlPath}, starting fresh` + ); + } } } } catch { @@ -1377,6 +1400,11 @@ export class TeamProvisioningService { // Normalize config.json to keep only the team-lead before spawning the CLI, so we get stable names. await this.normalizeTeamConfigForLaunch(request.teamName, configRaw); + // Update projectPath in config IMMEDIATELY so TeamDetailView shows the correct path + // even if provisioning is interrupted or the user stops the team early. + // If launch fails, restorePrelaunchConfig() will revert to the backup (old projectPath). + await this.updateConfigProjectPath(request.teamName, request.cwd); + let claudePath: string | null; try { await ensureCwdExists(request.cwd); @@ -2806,6 +2834,35 @@ export class TeamProvisioningService { return token.length > 0 ? token : null; } + /** + * Immediately update projectPath in config.json at launch start, before CLI spawn. + * Ensures TeamDetailView shows the correct project path even if provisioning + * is interrupted. On failure, restorePrelaunchConfig() reverts to the backup. + */ + private async updateConfigProjectPath(teamName: string, cwd: string): Promise { + const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); + try { + const raw = await fs.promises.readFile(configPath, 'utf8'); + const config = JSON.parse(raw) as Record; + + config.projectPath = cwd; + + const pathHistory = Array.isArray(config.projectPathHistory) + ? (config.projectPathHistory as string[]).filter((p) => typeof p === 'string' && p !== cwd) + : []; + pathHistory.push(cwd); + config.projectPathHistory = pathHistory.slice(-500); + + await atomicWriteAsync(configPath, JSON.stringify(config, null, 2)); + logger.info(`[${teamName}] Updated config.projectPath immediately: ${cwd}`); + } catch (error) { + // Non-fatal: updateConfigPostLaunch will update it later if provisioning succeeds. + logger.warn( + `[${teamName}] Failed to update projectPath early: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + /** * Single atomic read-mutate-write for post-launch config updates. * Combines session history append and projectPath update to avoid diff --git a/src/main/services/team/TeamTaskReader.ts b/src/main/services/team/TeamTaskReader.ts index 96406b62..59166f54 100644 --- a/src/main/services/team/TeamTaskReader.ts +++ b/src/main/services/team/TeamTaskReader.ts @@ -3,7 +3,7 @@ import { createLogger } from '@shared/utils/logger'; import * as fs from 'fs'; import * as path from 'path'; -import type { TaskComment, TeamTask } from '@shared/types'; +import type { TaskComment, TaskWorkInterval, TeamTask } from '@shared/types'; const logger = createLogger('Service:TeamTaskReader'); @@ -97,6 +97,21 @@ export class TeamTaskReader { // `satisfies Record` ensures compile-time // safety: if a field is added to TeamTask but not mapped here, // TypeScript will error. This prevents silently dropping new fields. + const workIntervals: TaskWorkInterval[] | undefined = Array.isArray(parsed.workIntervals) + ? (parsed.workIntervals as unknown[]) + .filter( + (i): i is { startedAt: string; completedAt?: string } => + Boolean(i) && + typeof i === 'object' && + typeof (i as Record).startedAt === 'string' && + ((i as Record).completedAt === undefined || + typeof (i as Record).completedAt === 'string') + ) + .map((i) => ({ + startedAt: i.startedAt, + completedAt: i.completedAt, + })) + : undefined; const task: TeamTask = { id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '', @@ -110,6 +125,7 @@ export class TeamTaskReader { ) ? (parsed.status as TeamTask['status']) : 'pending', + workIntervals, blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as string[]) : undefined, blockedBy: Array.isArray(parsed.blockedBy) ? (parsed.blockedBy as string[]) : undefined, related: Array.isArray(parsed.related) @@ -119,15 +135,22 @@ export class TeamTaskReader { updatedAt, projectPath: typeof parsed.projectPath === 'string' ? parsed.projectPath : undefined, comments: Array.isArray(parsed.comments) - ? (parsed.comments as TaskComment[]).filter( - (c) => - c && - typeof c === 'object' && - typeof c.id === 'string' && - typeof c.author === 'string' && - typeof c.text === 'string' && - typeof c.createdAt === 'string' - ) + ? (parsed.comments as TaskComment[]) + .filter( + (c) => + c && + typeof c === 'object' && + typeof c.id === 'string' && + typeof c.author === 'string' && + typeof c.text === 'string' && + typeof c.createdAt === 'string' + ) + .map((c) => ({ + ...c, + type: (['regular', 'review_request', 'review_approved'] as const).includes(c.type) + ? c.type + : ('regular' as const), + })) : undefined, needsClarification: (['lead', 'user'] as const).includes( parsed.needsClarification as 'lead' | 'user' diff --git a/src/main/services/team/TeamTaskWriter.ts b/src/main/services/team/TeamTaskWriter.ts index b337e27c..e96d81cf 100644 --- a/src/main/services/team/TeamTaskWriter.ts +++ b/src/main/services/team/TeamTaskWriter.ts @@ -5,7 +5,7 @@ import * as path from 'path'; import { atomicWriteAsync } from './atomicWrite'; -import type { TaskComment, TeamTask, TeamTaskStatus } from '@shared/types'; +import type { TaskComment, TaskCommentType, TeamTask, TeamTaskStatus } from '@shared/types'; const taskWriteLocks = new Map>(); @@ -46,13 +46,23 @@ export class TeamTaskWriter { // Ensure CLI-compatible format: description, blocks, blockedBy are required // by Claude Code CLI's Zod schema validation (safeParse fails without them) + const createdAt = task.createdAt ?? new Date().toISOString(); const cliCompatibleTask: TeamTask = { ...task, description: task.description ?? '', blocks: task.blocks ?? [], blockedBy: task.blockedBy ?? [], related: task.related ?? [], - createdAt: task.createdAt ?? new Date().toISOString(), + createdAt, + workIntervals: + task.status === 'in_progress' + ? // Start the first work interval on creation when task starts immediately. + [ + ...(Array.isArray(task.workIntervals) && task.workIntervals.length > 0 + ? task.workIntervals + : [{ startedAt: createdAt }]), + ] + : task.workIntervals, }; await atomicWriteAsync(taskPath, JSON.stringify(cliCompatibleTask, null, 2)); @@ -273,6 +283,29 @@ export class TeamTaskWriter { } const task = JSON.parse(raw) as TeamTask; + const prevStatus = task.status; + const nowIso = new Date().toISOString(); + + // Maintain workIntervals as periods of time where status === 'in_progress'. + const intervals = Array.isArray(task.workIntervals) ? [...task.workIntervals] : []; + const last = intervals.length > 0 ? intervals[intervals.length - 1] : undefined; + + const wasInProgress = prevStatus === 'in_progress'; + const isInProgress = status === 'in_progress'; + + if (!wasInProgress && isInProgress) { + // Entering in_progress: open a new interval if none is open. + if (!last || typeof last.completedAt === 'string') { + intervals.push({ startedAt: nowIso }); + } + } else if (wasInProgress && !isInProgress) { + // Leaving in_progress: close open interval if present. + if (last && last.completedAt === undefined) { + last.completedAt = nowIso; + } + } + + task.workIntervals = intervals.length > 0 ? intervals : undefined; task.status = status; await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2)); @@ -323,8 +356,20 @@ export class TeamTaskWriter { } const task = JSON.parse(raw) as TeamTask; + const nowIso = new Date().toISOString(); + + // Ensure any open in_progress interval is closed on delete. + if (task.status === 'in_progress') { + const intervals = Array.isArray(task.workIntervals) ? [...task.workIntervals] : []; + const last = intervals.length > 0 ? intervals[intervals.length - 1] : undefined; + if (last && last.completedAt === undefined) { + last.completedAt = nowIso; + } + task.workIntervals = intervals.length > 0 ? intervals : task.workIntervals; + } + task.status = 'deleted'; - task.deletedAt = new Date().toISOString(); + task.deletedAt = nowIso; await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2)); const verifyRaw = await fs.promises.readFile(taskPath, 'utf8'); @@ -417,7 +462,7 @@ export class TeamTaskWriter { teamName: string, taskId: string, text: string, - options?: { id?: string; author?: string; createdAt?: string } + options?: { id?: string; author?: string; createdAt?: string; type?: TaskCommentType } ): Promise { const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`); const comment: TaskComment = { @@ -425,6 +470,7 @@ export class TeamTaskWriter { author: options?.author ?? 'user', text, createdAt: options?.createdAt ?? new Date().toISOString(), + type: options?.type ?? 'regular', }; await withTaskLock(taskPath, async () => { diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index a9a110c1..da2d26cf 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -461,6 +461,9 @@ export const EDITOR_WATCH_DIR = 'editor:watchDir'; /** Update list of watched file paths (open tabs) */ export const EDITOR_SET_WATCHED_FILES = 'editor:setWatchedFiles'; +/** Update list of watched directories (shallow: depth=0) */ +export const EDITOR_SET_WATCHED_DIRS = 'editor:setWatchedDirs'; + /** Read binary file as base64 for inline preview */ export const EDITOR_READ_BINARY_PREVIEW = 'editor:readBinaryPreview'; diff --git a/src/preload/index.ts b/src/preload/index.ts index b39019d7..42863099 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -24,6 +24,7 @@ import { EDITOR_READ_FILE, EDITOR_RENAME_FILE, EDITOR_SEARCH_IN_FILES, + EDITOR_SET_WATCHED_DIRS, EDITOR_SET_WATCHED_FILES, EDITOR_WATCH_DIR, EDITOR_WRITE_FILE, @@ -710,7 +711,12 @@ const electronAPI: ElectronAPI = { getLogsForTask: async ( teamName: string, taskId: string, - options?: { owner?: string; status?: string } + options?: { + owner?: string; + status?: string; + intervals?: { startedAt: string; completedAt?: string }[]; + since?: string; + } ) => { return invokeIpcWithResult( TEAM_GET_LOGS_FOR_TASK, @@ -1035,6 +1041,8 @@ const electronAPI: ElectronAPI = { watchDir: (enable: boolean) => invokeIpcWithResult(EDITOR_WATCH_DIR, enable), setWatchedFiles: (filePaths: string[]) => invokeIpcWithResult(EDITOR_SET_WATCHED_FILES, filePaths), + setWatchedDirs: (dirPaths: string[]) => + invokeIpcWithResult(EDITOR_SET_WATCHED_DIRS, dirPaths), onEditorChange: (callback: (event: EditorFileChangeEvent) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, data: EditorFileChangeEvent): void => callback(data); diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index eaca4f50..e68c646a 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -991,6 +991,9 @@ export class HttpAPIClient implements ElectronAPI { setWatchedFiles: async () => { throw new Error('Editor not available in browser mode'); }, + setWatchedDirs: async () => { + throw new Error('Editor not available in browser mode'); + }, onEditorChange: () => { return () => {}; }, diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index a7fc3ae0..745bc528 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -23,7 +23,7 @@ import { PROSE_TABLE_HEADER_BG, } from '@renderer/constants/cssVariables'; import { useStore } from '@renderer/store'; -import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins'; +import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins'; import { FileText } from 'lucide-react'; import remarkGfm from 'remark-gfm'; import { useShallow } from 'zustand/react/shallow'; @@ -34,6 +34,8 @@ import { type SearchContext, } from '../searchHighlightUtils'; +import { MermaidDiagram } from './MermaidDiagram'; + // ============================================================================= // Types // ============================================================================= @@ -49,6 +51,95 @@ interface MarkdownViewerProps { copyable?: boolean; /** When true, renders without wrapper background/border (for embedding inside cards) */ bare?: boolean; + /** Base directory for resolving relative URLs (images, links) via local-resource:// protocol */ + baseDir?: string; +} + +// ============================================================================= +// Helpers +// ============================================================================= + +/** Check if a URL is relative (not absolute, not data, not mailto, not hash) */ +function isRelativeUrl(url: string): boolean { + return ( + !!url && + !url.startsWith('http://') && + !url.startsWith('https://') && + !url.startsWith('data:') && + !url.startsWith('#') && + !url.startsWith('mailto:') + ); +} + +/** Resolve a relative path to an absolute path given a base directory */ +function resolveRelativePath(relativeSrc: string, baseDir: string): string { + const cleaned = relativeSrc.startsWith('./') ? relativeSrc.slice(2) : relativeSrc; + return `${baseDir}/${cleaned}`; +} + +// ============================================================================= +// LocalImage — loads images via IPC (readBinaryPreview) for local file access +// ============================================================================= + +interface LocalImageProps { + src: string; + alt?: string; + baseDir: string; +} + +const LocalImage = React.memo(function LocalImage({ + src, + alt, + baseDir, +}: LocalImageProps): React.ReactElement { + const [dataUrl, setDataUrl] = React.useState(null); + const [error, setError] = React.useState(false); + + React.useEffect(() => { + let cancelled = false; + setDataUrl(null); + setError(false); + + const fullPath = resolveRelativePath(src, baseDir); + window.electronAPI.editor + .readBinaryPreview(fullPath) + .then((result) => { + if (!cancelled) { + setDataUrl(`data:${result.mimeType};base64,${result.base64}`); + } + }) + .catch(() => { + if (!cancelled) setError(true); + }); + + return () => { + cancelled = true; + }; + }, [src, baseDir]); + + if (error) { + return ( + + [Image: {alt || src}] + + ); + } + + if (!dataUrl) { + return ( + + ); + } + + return {alt; +}); + +/** Extract plain text from a hast (HTML AST) node tree */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- hast node shape varies +function hastToText(node: any): string { + if (node.type === 'text') return node.value ?? ''; + if (node.children) return node.children.map(hastToText).join(''); + return ''; } // ============================================================================= @@ -180,18 +271,29 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon ); }, - // Code blocks - pre: ({ children }) => ( -
-        {children}
-      
- ), + // Code blocks — intercept mermaid diagrams at the pre level + pre: ({ children, node }) => { + // Check if this pre contains a mermaid code block + const codeEl = node?.children?.[0]; + if (codeEl && 'tagName' in codeEl && codeEl.tagName === 'code' && 'properties' in codeEl) { + const cls = (codeEl.properties as Record)?.className; + if (Array.isArray(cls) && cls.some((c) => String(c) === 'language-mermaid')) { + return ; + } + } + + return ( +
+          {children}
+        
+ ); + }, // Blockquotes blockquote: ({ children }) => ( @@ -288,6 +390,7 @@ export const MarkdownViewer: React.FC = ({ itemId, copyable = false, bare = false, + baseDir, }) => { const [showRaw, setShowRaw] = React.useState(false); const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS); @@ -435,7 +538,20 @@ export const MarkdownViewer: React.FC = ({ // Create markdown components with optional search highlighting // When search is active, create fresh each render (match counter is stateful and must start at 0) // useMemo would cache stale closures when parent re-renders without search deps changing - const components = searchCtx ? createViewerMarkdownComponents(searchCtx) : defaultComponents; + const baseComponents = searchCtx ? createViewerMarkdownComponents(searchCtx) : defaultComponents; + + // When baseDir is set (editor preview), override img to load local files via IPC + const components = baseDir + ? { + ...baseComponents, + img: ({ src, alt }: { src?: string; alt?: string }) => { + if (src && isRelativeUrl(src)) { + return ; + } + return {alt; + }, + } + : baseComponents; return (
= ({
url} + rehypePlugins={disableHighlight ? REHYPE_PLUGINS_NO_HIGHLIGHT : REHYPE_PLUGINS} components={components} > {content} diff --git a/src/renderer/components/chat/viewers/MermaidDiagram.tsx b/src/renderer/components/chat/viewers/MermaidDiagram.tsx new file mode 100644 index 00000000..41833bdc --- /dev/null +++ b/src/renderer/components/chat/viewers/MermaidDiagram.tsx @@ -0,0 +1,116 @@ +/** + * Renders a Mermaid diagram from code string to SVG. + * + * Lazy-initializes mermaid once with dark theme. + * Each render call uses a unique ID to avoid collisions. + * SVG output is sanitized with DOMPurify before DOM insertion. + * Falls back to raw code display on parse errors. + */ + +import React, { useEffect, useRef, useState } from 'react'; + +import { PROSE_PRE_BG, PROSE_PRE_BORDER } from '@renderer/constants/cssVariables'; +import DOMPurify from 'dompurify'; +import mermaid from 'mermaid'; + +// ============================================================================= +// Mermaid initialization (once per app lifecycle) +// ============================================================================= + +let initialized = false; + +function ensureMermaidInit(): void { + if (initialized) return; + mermaid.initialize({ + startOnLoad: false, + theme: 'dark', + themeVariables: { + darkMode: true, + background: 'transparent', + primaryColor: '#3b82f6', + primaryTextColor: '#fafafa', + primaryBorderColor: '#3b82f6', + lineColor: '#71717a', + secondaryColor: '#27272a', + tertiaryColor: '#1f1f23', + }, + }); + initialized = true; +} + +// Monotonic counter for unique diagram IDs +let idCounter = 0; + +// ============================================================================= +// Component +// ============================================================================= + +interface MermaidDiagramProps { + code: string; +} + +export const MermaidDiagram = React.memo(function MermaidDiagram({ + code, +}: MermaidDiagramProps): React.ReactElement { + const containerRef = useRef(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!code.trim()) return; + + ensureMermaidInit(); + + let cancelled = false; + const diagramId = `mermaid-${++idCounter}`; + + mermaid + .render(diagramId, code.trim()) + .then(({ svg }) => { + if (!cancelled && containerRef.current) { + const sanitized = DOMPurify.sanitize(svg, { + USE_PROFILES: { svg: true, svgFilters: true }, + ADD_TAGS: ['foreignObject'], + }); + containerRef.current.replaceChildren(); + containerRef.current.insertAdjacentHTML('afterbegin', sanitized); + setError(null); + } + }) + .catch((err: unknown) => { + if (!cancelled) { + const msg = err instanceof Error ? err.message : String(err); + setError(msg); + } + }); + + return () => { + cancelled = true; + }; + }, [code]); + + if (error) { + return ( +
+
Mermaid syntax error
+
{code}
+
+ ); + } + + return ( +
+ ); +}); diff --git a/src/renderer/components/settings/hooks/useSettingsConfig.ts b/src/renderer/components/settings/hooks/useSettingsConfig.ts index 6738a658..4ff8b090 100644 --- a/src/renderer/components/settings/hooks/useSettingsConfig.ts +++ b/src/renderer/components/settings/hooks/useSettingsConfig.ts @@ -42,7 +42,8 @@ export interface SafeConfig { snoozedUntil: number | null; snoozeMinutes: number; includeSubagentErrors: boolean; - notifyOnInboxMessages: boolean; + notifyOnLeadInbox: boolean; + notifyOnUserInbox: boolean; notifyOnClarifications: boolean; triggers: AppConfig['notifications']['triggers']; }; @@ -171,7 +172,8 @@ export function useSettingsConfig(): UseSettingsConfigReturn { snoozedUntil: displayConfig?.notifications?.snoozedUntil ?? null, snoozeMinutes: displayConfig?.notifications?.snoozeMinutes ?? 30, includeSubagentErrors: displayConfig?.notifications?.includeSubagentErrors ?? true, - notifyOnInboxMessages: displayConfig?.notifications?.notifyOnInboxMessages ?? true, + notifyOnLeadInbox: displayConfig?.notifications?.notifyOnLeadInbox ?? false, + notifyOnUserInbox: displayConfig?.notifications?.notifyOnUserInbox ?? true, notifyOnClarifications: displayConfig?.notifications?.notifyOnClarifications ?? true, triggers: displayConfig?.notifications?.triggers ?? [], }, diff --git a/src/renderer/components/settings/hooks/useSettingsHandlers.ts b/src/renderer/components/settings/hooks/useSettingsHandlers.ts index b7773f5d..669b8687 100644 --- a/src/renderer/components/settings/hooks/useSettingsHandlers.ts +++ b/src/renderer/components/settings/hooks/useSettingsHandlers.ts @@ -287,7 +287,8 @@ export function useSettingsHandlers({ snoozedUntil: null, snoozeMinutes: 30, includeSubagentErrors: true, - notifyOnInboxMessages: true, + notifyOnLeadInbox: false, + notifyOnUserInbox: true, notifyOnClarifications: true, triggers: defaultTriggers, }, diff --git a/src/renderer/components/settings/sections/NotificationsSection.tsx b/src/renderer/components/settings/sections/NotificationsSection.tsx index 40107e19..5c47e69f 100644 --- a/src/renderer/components/settings/sections/NotificationsSection.tsx +++ b/src/renderer/components/settings/sections/NotificationsSection.tsx @@ -36,7 +36,8 @@ interface NotificationsSectionProps { | 'enabled' | 'soundEnabled' | 'includeSubagentErrors' - | 'notifyOnInboxMessages' + | 'notifyOnLeadInbox' + | 'notifyOnUserInbox' | 'notifyOnClarifications', value: boolean ) => void; @@ -136,12 +137,22 @@ export const NotificationsSection = ({ /> onNotificationToggle('notifyOnInboxMessages', v)} + enabled={safeConfig.notifications.notifyOnLeadInbox} + onChange={(v) => onNotificationToggle('notifyOnLeadInbox', v)} + disabled={saving || !safeConfig.notifications.enabled} + /> + + + onNotificationToggle('notifyOnUserInbox', v)} disabled={saving || !safeConfig.notifications.enabled} /> diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index d63fae53..a321b6a4 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -29,6 +29,7 @@ import { normalizePath } from '@renderer/utils/pathNormalize'; import { getMemberColor } from '@shared/constants/memberColors'; import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react'; +import { ExtendedContextCheckbox } from './ExtendedContextCheckbox'; import { MembersJsonEditor } from './MembersJsonEditor'; import { ProjectPathSelector } from './ProjectPathSelector'; @@ -279,6 +280,7 @@ export const CreateTeamDialog = ({ const [launchTeam, setLaunchTeam] = useState(true); const [teamColor, setTeamColor] = useState(''); const [selectedModel, setSelectedModel] = useState(''); + const [extendedContext, setExtendedContext] = useState(false); const [jsonEditorOpen, setJsonEditorOpen] = useState(false); const [jsonText, setJsonText] = useState(''); const [jsonError, setJsonError] = useState(null); @@ -303,6 +305,7 @@ export const CreateTeamDialog = ({ setCustomCwd(''); setLaunchTeam(true); setSelectedModel(''); + setExtendedContext(false); setJsonEditorOpen(false); setJsonText(''); setJsonError(null); @@ -537,8 +540,13 @@ export const CreateTeamDialog = ({ [members] ); - const effectiveModel = - selectedModel && selectedModel !== '__default__' ? selectedModel : undefined; + const effectiveModel = useMemo(() => { + const base = selectedModel && selectedModel !== '__default__' ? selectedModel : undefined; + if (!extendedContext) return base; + // 1M context is only supported for opus and sonnet + if (base === 'haiku') return base; + return base ? `${base}[1m]` : 'sonnet[1m]'; + }, [selectedModel, extendedContext]); const sanitizedTeamName = sanitizeTeamName(teamName.trim()); @@ -623,6 +631,7 @@ export const CreateTeamDialog = ({ description: request.description, color: request.color, members: request.members, + cwd: effectiveCwd || undefined, }); onOpenTeam(request.teamName, effectiveCwd || undefined); resetFormState(); @@ -909,19 +918,27 @@ export const CreateTeamDialog = ({ />
-
- - +
+
+ + +
+
{canCreate && (prepareState === 'idle' || prepareState === 'loading') ? ( diff --git a/src/renderer/components/team/dialogs/ExtendedContextCheckbox.tsx b/src/renderer/components/team/dialogs/ExtendedContextCheckbox.tsx new file mode 100644 index 00000000..fbfca008 --- /dev/null +++ b/src/renderer/components/team/dialogs/ExtendedContextCheckbox.tsx @@ -0,0 +1,66 @@ +import React from 'react'; + +import { Checkbox } from '@renderer/components/ui/checkbox'; +import { Label } from '@renderer/components/ui/label'; +import { AlertTriangle } from 'lucide-react'; + +interface ExtendedContextCheckboxProps { + id: string; + checked: boolean; + onCheckedChange: (checked: boolean) => void; + disabled?: boolean; +} + +export const ExtendedContextCheckbox: React.FC = ({ + id, + checked, + onCheckedChange, + disabled = false, +}) => ( + <> +
+ onCheckedChange(value === true)} + /> + +
+ {checked && ( +
+
+ +
+

+ Beyond 200K tokens, premium pricing applies: 2x input cost, 1.5x output cost. For + subscribers, extra usage is billed separately. +

+

+ Requires API tier 4+ or extra usage enabled.{' '} + +

+
+
+
+ )} + +); diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 034dc872..a8604b4e 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { api } from '@renderer/api'; +import { ExtendedContextCheckbox } from '@renderer/components/team/dialogs/ExtendedContextCheckbox'; import { Button } from '@renderer/components/ui/button'; import { Checkbox } from '@renderer/components/ui/checkbox'; import { @@ -66,6 +67,7 @@ export const LaunchTeamDialog = ({ const [prepareWarnings, setPrepareWarnings] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); const [selectedModel, setSelectedModel] = useState(''); + const [extendedContext, setExtendedContext] = useState(false); const [clearContext, setClearContext] = useState(false); const resetFormState = (): void => { @@ -78,6 +80,7 @@ export const LaunchTeamDialog = ({ setSelectedProjectPath(''); setCustomCwd(''); setSelectedModel(''); + setExtendedContext(false); setClearContext(false); }; @@ -240,7 +243,12 @@ export const LaunchTeamDialog = ({ teamName, cwd: effectiveCwd, prompt: promptDraft.value.trim() || undefined, - model: selectedModel || undefined, + model: (() => { + if (!extendedContext) return selectedModel || undefined; + // 1M context is only supported for opus and sonnet + if (selectedModel === 'haiku') return selectedModel; + return selectedModel ? `${selectedModel}[1m]` : 'sonnet[1m]'; + })(), clearContext: clearContext || undefined, }); resetFormState(); @@ -353,30 +361,38 @@ export const LaunchTeamDialog = ({ />
-
- -
- {[ - { value: '', label: 'Default' }, - { value: 'opus', label: 'Opus 4.6' }, - { value: 'sonnet', label: 'Sonnet 4.5' }, - { value: 'haiku', label: 'Haiku 4.5' }, - ].map((opt) => ( - - ))} +
+
+ +
+ {[ + { value: '', label: 'Default' }, + { value: 'opus', label: 'Opus 4.6' }, + { value: 'sonnet', label: 'Sonnet 4.5' }, + { value: 'haiku', label: 'Haiku 4.5' }, + ].map((opt) => ( + + ))} +
+
diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index 69206e0c..ad35b64b 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { Button } from '@renderer/components/ui/button'; import { @@ -30,6 +30,9 @@ import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { X } from 'lucide-react'; +import { MarkdownViewer } from '../../chat/viewers/MarkdownViewer'; +import { MemberBadge } from '../MemberBadge'; + import type { InlineChip } from '@renderer/types/inlineChip'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { ResolvedTeamMember, SendMessageResult } from '@shared/types'; @@ -72,6 +75,7 @@ export const SendMessageDialog = ({ }: SendMessageDialogProps): React.JSX.Element => { const colorMap = useMemo(() => buildMemberColorMap(members), [members]); const [quote, setQuote] = useState(undefined); + const [quoteExpanded, setQuoteExpanded] = useState(false); const [member, setMember] = useState(''); const textDraft = useDraftPersistence({ key: 'sendMessage:text' }); const chipDraft = useChipDraftPersistence('sendMessage:chips'); @@ -84,6 +88,7 @@ export const SendMessageDialog = ({ setMember(defaultRecipient ?? ''); setSummary(''); setQuote(quotedMessage); + setQuoteExpanded(false); setPrevResult(lastResult); if (defaultChip) { const token = chipToken(defaultChip); @@ -117,6 +122,9 @@ export const SendMessageDialog = ({ // eslint-disable-next-line react-hooks/exhaustive-deps -- only trigger on pendingAutoClose flag }, [pendingAutoClose]); + const QUOTE_COLLAPSE_THRESHOLD = 120; + const isQuoteLong = (quote?.text.length ?? 0) > QUOTE_COLLAPSE_THRESHOLD; + const mentionSuggestions = useMemo( () => members.map((m) => ({ @@ -204,47 +212,71 @@ export const SendMessageDialog = ({
- {quote ? ( -
- - - - - Remove quote - - - Replying to @{quote.from} - -

- {quote.text} -

-
- ) : null} -
- Draft saved - ) : null - } - /> +
+ {quote ? ( +
+ {/* Decorative quotation mark */} + + “ + + + + + + + Remove quote + + +
+ Replying to + +
+
+ +
+ {isQuoteLong ? ( + + ) : null} +
+ ) : null} + Draft saved + ) : null + } + /> +
diff --git a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx index 4cb94fad..a978c96f 100644 --- a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx @@ -14,7 +14,16 @@ import { getModifierKeyName } from '@renderer/utils/keyboardUtils'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { formatDistanceToNow } from 'date-fns'; -import { ChevronDown, ChevronUp, MessageSquare, Reply, Send, X } from 'lucide-react'; +import { + CheckCircle2, + ChevronDown, + ChevronUp, + MessageCircleWarning, + MessageSquare, + Reply, + Send, + X, +} from 'lucide-react'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { ResolvedTeamMember, TaskComment } from '@shared/types'; @@ -145,8 +154,23 @@ export const TaskCommentsSection = ({ ) : null} {visibleComments.map((comment) => ( -
+
+ {comment.type === 'review_approved' && ( + + )} + {comment.type === 'review_request' && ( + + )} {comment.author} + {comment.type === 'review_approved' && ( + + Approved + + )} + {comment.type === 'review_request' && ( + + Changes requested + + )} {(() => { const date = new Date(comment.createdAt); diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 3b1e5d8d..1b9c5402 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -598,6 +598,7 @@ export const TaskDetailDialog = ({ taskId={currentTask.id} taskOwner={currentTask.owner} taskStatus={currentTask.status} + taskWorkIntervals={currentTask.workIntervals} />
diff --git a/src/renderer/components/team/editor/CodeMirrorEditor.tsx b/src/renderer/components/team/editor/CodeMirrorEditor.tsx index fb900c0b..d8830b36 100644 --- a/src/renderer/components/team/editor/CodeMirrorEditor.tsx +++ b/src/renderer/components/team/editor/CodeMirrorEditor.tsx @@ -9,7 +9,13 @@ import { useCallback, useEffect, useRef } from 'react'; import { defaultKeymap, history, historyKeymap, redo, undo } from '@codemirror/commands'; -import { bracketMatching, indentOnInput, syntaxHighlighting } from '@codemirror/language'; +import { + bracketMatching, + foldGutter, + foldKeymap, + indentOnInput, + syntaxHighlighting, +} from '@codemirror/language'; import { gotoLine, search, searchKeymap } from '@codemirror/search'; import { Compartment, EditorState } from '@codemirror/state'; import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark'; @@ -67,6 +73,8 @@ interface CodeMirrorEditorProps { onDraftRecovered?: (filePath: string) => void; /** Called when text selection changes (for floating action menu) */ onSelectionChange?: (info: EditorSelectionInfo | null) => void; + /** Called with the current document text on changes (debounced, for live preview) */ + onDocChange?: (content: string) => void; } // ============================================================================= @@ -95,6 +103,7 @@ function buildEditableExtensions( highlightActiveLineGutter(), bracketMatching(), indentOnInput(), + foldGutter(), // History history(), @@ -129,6 +138,7 @@ function buildEditableExtensions( ...defaultKeymap, ...historyKeymap, ...searchKeymap.filter((k) => k.run !== gotoLine), + ...foldKeymap, ]), // Update listener for dirty flag + cursor position + selection @@ -220,6 +230,8 @@ function enforceDraftLimit(): void { // Component // ============================================================================= +const DOC_CHANGE_DEBOUNCE_MS = 150; + export const CodeMirrorEditor = ({ filePath, content, @@ -228,6 +240,7 @@ export const CodeMirrorEditor = ({ onCursorChange, onDraftRecovered, onSelectionChange, + onDocChange, }: CodeMirrorEditorProps): React.ReactElement => { const containerRef = useRef(null); const viewRef = useRef(null); @@ -241,6 +254,8 @@ export const CodeMirrorEditor = ({ const autosaveTimerRef = useRef | null>(null); // Selection debounce const selectionTimerRef = useRef | null>(null); + // Doc change debounce (live preview) + const docChangeTimerRef = useRef | null>(null); const markFileModified = useStore((s) => s.markFileModified); const discardChanges = useStore((s) => s.discardChanges); @@ -260,6 +275,9 @@ export const CodeMirrorEditor = ({ const onSelectionChangeRef = useRef(onSelectionChange); onSelectionChangeRef.current = onSelectionChange; + const onDocChangeRef = useRef(onDocChange); + onDocChangeRef.current = onDocChange; + const lineWrapRef = useRef(lineWrap); lineWrapRef.current = lineWrap; @@ -282,6 +300,13 @@ export const CodeMirrorEditor = ({ saveDraft(filePathRef.current, view.state.doc.toString()); } }, AUTOSAVE_DELAY_MS); + + // Live content callback for markdown preview + if (docChangeTimerRef.current) clearTimeout(docChangeTimerRef.current); + docChangeTimerRef.current = setTimeout(() => { + const view = viewRef.current; + if (view) onDocChangeRef.current?.(view.state.doc.toString()); + }, DOC_CHANGE_DEBOUNCE_MS); }, [markFileModified]); const handleCursorMove = useCallback((line: number, col: number) => { @@ -421,6 +446,7 @@ export const CodeMirrorEditor = ({ const dirtyTimer = dirtyTimerRef; const autosaveTimer = autosaveTimerRef; const selectionTimer = selectionTimerRef; + const docChangeTimer = docChangeTimerRef; return () => { // Save scroll position before destroying @@ -433,6 +459,7 @@ export const CodeMirrorEditor = ({ if (dirtyTimer.current) clearTimeout(dirtyTimer.current); if (autosaveTimer.current) clearTimeout(autosaveTimer.current); if (selectionTimer.current) clearTimeout(selectionTimer.current); + if (docChangeTimer.current) clearTimeout(docChangeTimer.current); view.destroy(); viewRef.current = null; @@ -475,5 +502,5 @@ export const CodeMirrorEditor = ({ }; }, []); - return
; + return
; }; diff --git a/src/renderer/components/team/editor/EditorShortcutsHelp.tsx b/src/renderer/components/team/editor/EditorShortcutsHelp.tsx index 1ad1a608..eb79fa90 100644 --- a/src/renderer/components/team/editor/EditorShortcutsHelp.tsx +++ b/src/renderer/components/team/editor/EditorShortcutsHelp.tsx @@ -64,6 +64,13 @@ const SHORTCUT_GROUPS: { title: string; shortcuts: ShortcutDef[] }[] = [ { mac: '⌘ /', other: 'Ctrl+/', description: 'Toggle Comment' }, ], }, + { + title: 'Markdown', + shortcuts: [ + { mac: '⌘ ⇧ M', other: 'Ctrl+Shift+M', description: 'Split Preview' }, + { mac: '⌘ ⇧ V', other: 'Ctrl+Shift+V', description: 'Full Preview' }, + ], + }, { title: 'General', shortcuts: [{ mac: 'Esc', other: 'Esc', description: 'Close Editor' }], diff --git a/src/renderer/components/team/editor/EditorToolbar.tsx b/src/renderer/components/team/editor/EditorToolbar.tsx index 45e3e4c9..bc0123ec 100644 --- a/src/renderer/components/team/editor/EditorToolbar.tsx +++ b/src/renderer/components/team/editor/EditorToolbar.tsx @@ -10,14 +10,32 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui import { useStore } from '@renderer/store'; import { editorBridge } from '@renderer/utils/editorBridge'; import { shortcutLabel } from '@renderer/utils/platformKeys'; -import { Redo2, Save, Undo2, WrapText } from 'lucide-react'; +import { Columns2, Eye, Redo2, Save, Undo2, WrapText } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; +// ============================================================================= +// Types +// ============================================================================= + +export type MdPreviewMode = 'off' | 'split' | 'preview'; + // ============================================================================= // Component // ============================================================================= -export const EditorToolbar = (): React.ReactElement | null => { +interface EditorToolbarProps { + isMarkdown?: boolean; + mdPreviewMode?: MdPreviewMode; + onToggleSplit?: () => void; + onToggleFullPreview?: () => void; +} + +export const EditorToolbar = ({ + isMarkdown = false, + mdPreviewMode = 'off', + onToggleSplit, + onToggleFullPreview, +}: EditorToolbarProps): React.ReactElement | null => { const { activeTabId, modifiedFiles, saving, lineWrap } = useStore( useShallow((s) => ({ activeTabId: s.editorActiveTabId, @@ -77,6 +95,25 @@ export const EditorToolbar = (): React.ReactElement | null => { onClick={toggleLineWrap} active={lineWrap} /> + {isMarkdown && ( + <> +
+ } + label={mdPreviewMode === 'split' ? 'Close split preview' : 'Split preview'} + shortcut={shortcutLabel('⌘ ⇧ M', 'Ctrl+Shift+M')} + onClick={onToggleSplit ?? (() => {})} + active={mdPreviewMode === 'split'} + /> + } + label={mdPreviewMode === 'preview' ? 'Close preview' : 'Full preview'} + shortcut={shortcutLabel('⌘ ⇧ V', 'Ctrl+Shift+V')} + onClick={onToggleFullPreview ?? (() => {})} + active={mdPreviewMode === 'preview'} + /> + + )}
); }; diff --git a/src/renderer/components/team/editor/MarkdownPreviewPane.tsx b/src/renderer/components/team/editor/MarkdownPreviewPane.tsx new file mode 100644 index 00000000..4de17436 --- /dev/null +++ b/src/renderer/components/team/editor/MarkdownPreviewPane.tsx @@ -0,0 +1,53 @@ +/** + * Scrollable markdown preview pane for the editor split view. + * + * Wraps MarkdownViewer in a scrollable container with ref access + * for external scroll synchronization (code ↔ preview). + */ + +import React from 'react'; + +import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; + +// ============================================================================= +// Types +// ============================================================================= + +interface MarkdownPreviewPaneProps { + content: string; + className?: string; + scrollRef?: React.RefObject; + onScroll?: () => void; + /** Base directory for resolving relative image/link URLs */ + baseDir?: string; +} + +// ============================================================================= +// Component +// ============================================================================= + +export const MarkdownPreviewPane = React.memo(function MarkdownPreviewPane({ + content, + className = '', + scrollRef, + onScroll, + baseDir, +}: MarkdownPreviewPaneProps): React.ReactElement { + // Callback ref to wire scrollRef (RefObject) to the div + const setRef = React.useCallback( + (el: HTMLDivElement | null) => { + if (scrollRef && 'current' in scrollRef) { + (scrollRef as React.MutableRefObject).current = el; + } + }, + [scrollRef] + ); + + return ( +
+
+ +
+
+ ); +}); diff --git a/src/renderer/components/team/editor/MarkdownSplitView.tsx b/src/renderer/components/team/editor/MarkdownSplitView.tsx new file mode 100644 index 00000000..d2f83052 --- /dev/null +++ b/src/renderer/components/team/editor/MarkdownSplitView.tsx @@ -0,0 +1,128 @@ +/** + * Right-side panel for markdown split/preview mode. + * + * In split mode: renders a drag-resizable handle + MarkdownPreviewPane. + * In preview mode: renders MarkdownPreviewPane at full width (no handle). + * + * CodeMirrorEditor is NOT rendered here — it stays in ProjectEditorOverlay + * and is controlled via CSS display/width. + */ + +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +import { useMarkdownScrollSync } from '@renderer/hooks/useMarkdownScrollSync'; + +import { MarkdownPreviewPane } from './MarkdownPreviewPane'; + +// ============================================================================= +// Types +// ============================================================================= + +interface MarkdownSplitViewProps { + content: string; + mode: 'split' | 'preview'; + splitRatio: number; + onSplitRatioChange: (ratio: number) => void; + /** Key that changes when the EditorView changes (e.g. activeTabId) — triggers scroll re-attach */ + viewKey?: string | null; + /** Base directory for resolving relative image/link URLs */ + baseDir?: string; +} + +// ============================================================================= +// Constants +// ============================================================================= + +const MIN_RATIO = 0.2; +const MAX_RATIO = 0.8; +const HANDLE_WIDTH = 4; // px + +// ============================================================================= +// Component +// ============================================================================= + +export const MarkdownSplitView = React.memo(function MarkdownSplitView({ + content, + mode, + splitRatio, + onSplitRatioChange, + viewKey, + baseDir, +}: MarkdownSplitViewProps): React.ReactElement { + const [isResizing, setIsResizing] = useState(false); + const containerRef = useRef(null); + + // Scroll sync auto-manages its own listener lifecycle via viewKey + const scrollSync = useMarkdownScrollSync(mode === 'split', viewKey); + + // --- Resize drag logic --- + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + const parent = containerRef.current?.parentElement; + if (!parent) return; + + const parentRect = parent.getBoundingClientRect(); + const relativeX = e.clientX - parentRect.left; + const newRatio = Math.min(MAX_RATIO, Math.max(MIN_RATIO, relativeX / parentRect.width)); + onSplitRatioChange(newRatio); + }, + [onSplitRatioChange] + ); + + const handleMouseUp = useCallback(() => { + setIsResizing(false); + }, []); + + useEffect(() => { + if (!isResizing) return; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + }, [isResizing, handleMouseMove, handleMouseUp]); + + const handleMouseDown = (e: React.MouseEvent): void => { + e.preventDefault(); + setIsResizing(true); + }; + + // --- Preview width --- + + const previewWidth = + mode === 'preview' ? '100%' : `calc(${(1 - splitRatio) * 100}% - ${HANDLE_WIDTH}px)`; + + return ( +
+ {/* Resize handle — only in split mode */} + {mode === 'split' && ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- resize handle +
+ )} + + {/* Preview pane */} +
+ +
+
+ ); +}); diff --git a/src/renderer/components/team/editor/ProjectEditorOverlay.tsx b/src/renderer/components/team/editor/ProjectEditorOverlay.tsx index 528ac79b..b979fc77 100644 --- a/src/renderer/components/team/editor/ProjectEditorOverlay.tsx +++ b/src/renderer/components/team/editor/ProjectEditorOverlay.tsx @@ -45,9 +45,11 @@ import { EditorStatusBar } from './EditorStatusBar'; import { EditorTabBar } from './EditorTabBar'; import { EditorToolbar } from './EditorToolbar'; import { GoToLineDialog } from './GoToLineDialog'; +import { MarkdownSplitView } from './MarkdownSplitView'; import { QuickOpenDialog } from './QuickOpenDialog'; import { SearchInFilesPanel } from './SearchInFilesPanel'; +import type { MdPreviewMode } from './EditorToolbar'; import type { EditorSelectionAction, EditorSelectionInfo, @@ -121,6 +123,18 @@ export const ProjectEditorOverlay = ({ const editorContentRef = useRef(null); const [containerRect, setContainerRect] = useState(() => new DOMRect()); + // Markdown preview state + const [mdPreviewMode, setMdPreviewMode] = useState('off'); + const [liveContent, setLiveContent] = useState(''); + const [splitRatio, setSplitRatio] = useState(() => { + try { + const stored = localStorage.getItem('editor:mdSplitRatio'); + return stored ? Math.max(0.2, Math.min(0.8, Number(stored))) : 0.5; + } catch { + return 0.5; + } + }); + // Iter-4: New state const [quickOpenVisible, setQuickOpenVisible] = useState(false); const [searchPanelVisible, setSearchPanelVisible] = useState(false); @@ -141,6 +155,48 @@ export const ProjectEditorOverlay = ({ // Active tab metadata const activeTab = openTabs.find((t) => t.id === activeTabId) ?? null; + const isMarkdown = activeTab?.language === 'Markdown'; + + // Auto-enable split preview for markdown tabs, reset for non-markdown + useEffect(() => { + if (isMarkdown) { + setMdPreviewMode((m) => (m === 'off' ? 'split' : m)); + } else { + setMdPreviewMode('off'); + } + }, [isMarkdown, activeTabId]); + + // Persist split ratio + const handleSplitRatioChange = useCallback((ratio: number) => { + setSplitRatio(ratio); + try { + localStorage.setItem('editor:mdSplitRatio', String(ratio)); + } catch { + // localStorage unavailable + } + }, []); + + const handleLiveContent = useCallback((content: string) => { + setLiveContent(content); + }, []); + + const toggleMdSplit = useCallback(() => { + setMdPreviewMode((m) => (m === 'split' ? 'off' : 'split')); + }, []); + + const toggleMdPreview = useCallback(() => { + setMdPreviewMode((m) => (m === 'preview' ? 'off' : 'preview')); + }, []); + + // Initialize live content when entering preview mode or switching files + useEffect(() => { + if (mdPreviewMode !== 'off' && fileContent?.content) { + setLiveContent(fileContent.content); + } + }, [mdPreviewMode, fileContent?.content]); + + // Content for preview: use live content when available, fallback to file content + const previewContent = liveContent || fileContent?.content || ''; const loadFileContent = useCallback( async (filePath: string) => { @@ -444,6 +500,8 @@ export const ProjectEditorOverlay = ({ onToggleGoToLine: toggleGoToLine, onToggleSidebar: toggleSidebar, onClose: handleCloseRequest, + onToggleMdSplit: isMarkdown ? toggleMdSplit : undefined, + onToggleMdPreview: isMarkdown ? toggleMdPreview : undefined, }); const projectName = projectPath.split('/').pop() ?? projectPath; @@ -580,7 +638,12 @@ export const ProjectEditorOverlay = ({ {/* Toolbar */} - + {/* Draft recovery banner */} {draftRecoveredFile && activeTabId === draftRecoveredFile && ( @@ -678,18 +741,42 @@ export const ProjectEditorOverlay = ({ )} {fileContent && !fileContent.isBinary && activeTabId && ( - - - +
+ {/* Code editor — always mounted, hidden via display:none in preview mode */} +
+ + + +
+ + {/* Resize handle + Preview pane */} + {mdPreviewMode !== 'off' && ( + + )} +
)} {!fileLoading && !fileError && !fileContent && !activeTabId && } diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index 39dec4ac..f7ac46aa 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -32,7 +32,13 @@ export const MemberList = ({ onAssignTask, onOpenTask, }: MemberListProps): React.JSX.Element => { - const activeMembers = members.filter((m) => !m.removedAt); + const activeMembers = members + .filter((m) => !m.removedAt) + .sort((a, b) => { + if (a.agentType === 'team-lead') return -1; + if (b.agentType === 'team-lead') return 1; + return 0; + }); const removedMembers = members.filter((m) => m.removedAt); const colorMap = buildMemberColorMap(members); diff --git a/src/renderer/components/team/members/MemberLogsTab.tsx b/src/renderer/components/team/members/MemberLogsTab.tsx index b553227a..bd9d5108 100644 --- a/src/renderer/components/team/members/MemberLogsTab.tsx +++ b/src/renderer/components/team/members/MemberLogsTab.tsx @@ -24,6 +24,8 @@ interface MemberLogsTabProps { /** When viewing task logs: include owner's sessions when task is in_progress */ taskOwner?: string; taskStatus?: string; + /** Persisted work intervals for filtering owner sessions (avoid unrelated tasks) */ + taskWorkIntervals?: { startedAt: string; completedAt?: string }[]; } export const MemberLogsTab = ({ @@ -32,6 +34,7 @@ export const MemberLogsTab = ({ taskId, taskOwner, taskStatus, + taskWorkIntervals, }: MemberLogsTabProps): React.JSX.Element => { const [logs, setLogs] = useState([]); const [loading, setLoading] = useState(true); @@ -61,6 +64,7 @@ export const MemberLogsTab = ({ ? await api.teams.getLogsForTask(teamName, taskId, { owner: taskOwner, status: taskStatus, + intervals: taskWorkIntervals, }) : await api.teams.getMemberLogs(teamName, memberName!); if (!cancelled) { @@ -86,7 +90,7 @@ export const MemberLogsTab = ({ cancelled = true; if (interval) clearInterval(interval); }; - }, [teamName, memberName, taskId, taskOwner, taskStatus]); + }, [teamName, memberName, taskId, taskOwner, taskStatus, taskWorkIntervals]); const handleExpand = useCallback( async (log: MemberLogSummary) => { diff --git a/src/renderer/components/team/review/CodeMirrorDiffView.tsx b/src/renderer/components/team/review/CodeMirrorDiffView.tsx index adefaa7f..e1d532e7 100644 --- a/src/renderer/components/team/review/CodeMirrorDiffView.tsx +++ b/src/renderer/components/team/review/CodeMirrorDiffView.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useRef } from 'react'; import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'; -import { indentUnit, syntaxHighlighting } from '@codemirror/language'; +import { foldGutter, foldKeymap, indentUnit, syntaxHighlighting } from '@codemirror/language'; import { goToNextChunk, goToPreviousChunk, unifiedMergeView } from '@codemirror/merge'; import { Compartment, EditorState, type Extension } from '@codemirror/state'; import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark'; @@ -395,6 +395,7 @@ export const CodeMirrorDiffView = ({ ...(isEffectivelyEmptyOriginal ? [emptyOriginalOverrideTheme] : []), lineNumbers(), syntaxHighlighting(oneDarkHighlightStyle), + foldGutter(), EditorView.editable.of(!readOnly), EditorState.readOnly.of(readOnly), ]; @@ -405,7 +406,9 @@ export const CodeMirrorDiffView = ({ extensions.push(mergeUndoSupport); extensions.push(mirrorEditsAfterResolve); extensions.push(indentUnit.of(' ')); - extensions.push(keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap])); + extensions.push( + keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap, ...foldKeymap]) + ); } // Language placeholder — actual language injected async via compartment reconfigure @@ -426,6 +429,7 @@ export const CodeMirrorDiffView = ({ key: 'Ctrl-Alt-ArrowUp', run: goToPreviousChunk, }, + ...foldKeymap, ]) ); diff --git a/src/renderer/hooks/useEditorKeyboardShortcuts.ts b/src/renderer/hooks/useEditorKeyboardShortcuts.ts index db690f94..97ffe083 100644 --- a/src/renderer/hooks/useEditorKeyboardShortcuts.ts +++ b/src/renderer/hooks/useEditorKeyboardShortcuts.ts @@ -25,6 +25,8 @@ interface UseEditorKeyboardShortcutsOptions { onToggleGoToLine: () => void; onToggleSidebar: () => void; onClose: () => void; + onToggleMdSplit?: () => void; + onToggleMdPreview?: () => void; } /** Dependencies injected into the key handler for testability. */ @@ -40,6 +42,8 @@ export interface EditorKeyHandlerDeps { onToggleGoToLine: () => void; onToggleSidebar: () => void; onToggleLineWrap: () => void; + onToggleMdSplit?: () => void; + onToggleMdPreview?: () => void; getEditorView: () => { dispatch: unknown } | null; } @@ -108,6 +112,22 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard return; } + // Cmd+Shift+M: Toggle markdown split preview + if (key === 'm' && e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + deps.onToggleMdSplit?.(); + return; + } + + // Cmd+Shift+V: Toggle markdown full preview + if (key === 'v' && e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + deps.onToggleMdPreview?.(); + return; + } + // Cmd+Shift+W: Toggle line wrap if (key === 'w' && e.shiftKey && !e.altKey) { e.preventDefault(); @@ -190,6 +210,8 @@ export function useEditorKeyboardShortcuts({ onToggleGoToLine, onToggleSidebar, onClose: _onClose, + onToggleMdSplit, + onToggleMdPreview, }: UseEditorKeyboardShortcutsOptions): void { const { openTabs, activeTabId } = useStore( useShallow((s) => ({ @@ -217,6 +239,8 @@ export function useEditorKeyboardShortcuts({ onToggleGoToLine, onToggleSidebar, onToggleLineWrap: toggleLineWrap, + onToggleMdSplit, + onToggleMdPreview, getEditorView: () => editorBridge.getView(), }; diff --git a/src/renderer/hooks/useMarkdownScrollSync.ts b/src/renderer/hooks/useMarkdownScrollSync.ts new file mode 100644 index 00000000..b2fd8ad7 --- /dev/null +++ b/src/renderer/hooks/useMarkdownScrollSync.ts @@ -0,0 +1,147 @@ +/** + * Proportional scroll synchronization between CodeMirror and a preview pane. + * + * Uses the fraction-based approach: fraction = scrollTop / (scrollHeight - clientHeight). + * Feedback loop prevention via ref-based ignore flags reset with requestAnimationFrame. + * + * The hook auto-attaches/detaches the CodeMirror scroll listener internally: + * - Retry logic handles CodeMirror mount delay (up to 500ms) + * - `viewKey` triggers re-attachment when the EditorView changes (e.g. file switch) + * - Full cleanup on disable/unmount + */ + +import { useCallback, useEffect, useRef } from 'react'; + +import { editorBridge } from '@renderer/utils/editorBridge'; + +// ============================================================================= +// Constants +// ============================================================================= + +/** Max attempts to find CodeMirror scrollDOM before giving up */ +const MAX_ATTACH_ATTEMPTS = 10; +/** Interval between retry attempts (ms) */ +const ATTACH_RETRY_INTERVAL = 50; + +// ============================================================================= +// Types +// ============================================================================= + +export interface UseMarkdownScrollSyncResult { + previewScrollRef: React.RefObject; + /** Attach to preview div's onScroll */ + handlePreviewScroll: () => void; +} + +// ============================================================================= +// Hook +// ============================================================================= + +/** + * Bidirectional scroll sync between CodeMirror and a preview pane. + * + * @param enabled - Whether sync is active (typically `mode === 'split'`) + * @param viewKey - Changes when the underlying EditorView changes (e.g. `activeTabId`). + * Triggers re-attachment of the code scroll listener. + */ +export function useMarkdownScrollSync( + enabled: boolean, + viewKey?: string | null +): UseMarkdownScrollSyncResult { + const previewScrollRef = useRef(null); + const ignoreCodeScroll = useRef(false); + const ignorePreviewScroll = useRef(false); + const codeRafRef = useRef(0); + const previewRafRef = useRef(0); + + // Code → Preview: proportional scroll + const handleCodeScroll = useCallback(() => { + if (!enabled) return; + if (ignoreCodeScroll.current) { + ignoreCodeScroll.current = false; + return; + } + + const scrollDOM = editorBridge.getView()?.scrollDOM; + const preview = previewScrollRef.current; + if (!scrollDOM || !preview) return; + + const maxCode = scrollDOM.scrollHeight - scrollDOM.clientHeight; + if (maxCode <= 0) return; + + const fraction = scrollDOM.scrollTop / maxCode; + const maxPreview = preview.scrollHeight - preview.clientHeight; + if (maxPreview <= 0) return; + + cancelAnimationFrame(previewRafRef.current); + previewRafRef.current = requestAnimationFrame(() => { + ignorePreviewScroll.current = true; + preview.scrollTop = fraction * maxPreview; + }); + }, [enabled]); + + // Preview → Code: proportional scroll + const handlePreviewScroll = useCallback(() => { + if (!enabled) return; + if (ignorePreviewScroll.current) { + ignorePreviewScroll.current = false; + return; + } + + const scrollDOM = editorBridge.getView()?.scrollDOM; + const preview = previewScrollRef.current; + if (!scrollDOM || !preview) return; + + const maxPreview = preview.scrollHeight - preview.clientHeight; + if (maxPreview <= 0) return; + + const fraction = preview.scrollTop / maxPreview; + const maxCode = scrollDOM.scrollHeight - scrollDOM.clientHeight; + if (maxCode <= 0) return; + + cancelAnimationFrame(codeRafRef.current); + codeRafRef.current = requestAnimationFrame(() => { + ignoreCodeScroll.current = true; + scrollDOM.scrollTop = fraction * maxCode; + }); + }, [enabled]); + + // Auto-attach code scroll listener with retry on mount/viewKey change + useEffect(() => { + if (!enabled) return; + + let scrollCleanup: (() => void) | undefined; + let retryTimer: ReturnType; + let attempts = 0; + + const tryAttach = (): void => { + const scrollDOM = editorBridge.getView()?.scrollDOM; + if (!scrollDOM) { + if (attempts < MAX_ATTACH_ATTEMPTS) { + attempts++; + retryTimer = setTimeout(tryAttach, ATTACH_RETRY_INTERVAL); + } + return; + } + + scrollDOM.addEventListener('scroll', handleCodeScroll, { passive: true }); + scrollCleanup = () => { + scrollDOM.removeEventListener('scroll', handleCodeScroll); + }; + }; + + tryAttach(); + + return () => { + clearTimeout(retryTimer); + scrollCleanup?.(); + cancelAnimationFrame(codeRafRef.current); + cancelAnimationFrame(previewRafRef.current); + }; + }, [enabled, viewKey, handleCodeScroll]); + + return { + previewScrollRef, + handlePreviewScroll, + }; +} diff --git a/src/renderer/store/slices/editorSlice.ts b/src/renderer/store/slices/editorSlice.ts index 75408a85..be3a2999 100644 --- a/src/renderer/store/slices/editorSlice.ts +++ b/src/renderer/store/slices/editorSlice.ts @@ -67,26 +67,58 @@ let watcherEventCounts: Record = { let watchedFilesSyncTimer: ReturnType | null = null; let lastWatchedFilesKey = ''; +let watchedDirsSyncTimer: ReturnType | null = null; +let lastWatchedDirsKey = ''; +const WATCHED_DIRS_DEBOUNCE_MS = 250; +const MAX_WATCHED_DIRS = 120; function scheduleSyncWatchedFiles(get: () => AppState): void { - if (!api.editor) return; + // Editor watcher is Electron-only. In browser mode, api.editor exists but throws. + if (!window.electronAPI?.editor) return; const state = get(); if (!state.editorWatcherEnabled) return; - if (!state.editorProjectPath) return; + const projectPath = state.editorProjectPath; + if (!projectPath) return; const filePaths = state.editorOpenTabs.map((t) => t.filePath).filter(Boolean); filePaths.sort(); - const key = filePaths.join('\n'); + const key = `${projectPath}\n${filePaths.join('\n')}`; if (key === lastWatchedFilesKey) return; lastWatchedFilesKey = key; if (watchedFilesSyncTimer) clearTimeout(watchedFilesSyncTimer); watchedFilesSyncTimer = setTimeout(() => { watchedFilesSyncTimer = null; - void api.editor.setWatchedFiles(filePaths); + void window.electronAPI.editor.setWatchedFiles(filePaths); }, 150); } +function scheduleSyncWatchedDirs(get: () => AppState): void { + if (!window.electronAPI?.editor) return; + const state = get(); + if (!state.editorWatcherEnabled) return; + const projectPath = state.editorProjectPath; + if (!projectPath) return; + + const expanded = Object.entries(state.editorExpandedDirs) + .filter(([, v]) => v === true) + .map(([k]) => k); + + // Always include root (depth=0), plus expanded folders (depth=0). + // Cap to protect chokidar from too many watched paths if user expands a lot. + const dirs = [projectPath, ...expanded].slice(0, MAX_WATCHED_DIRS); + dirs.sort(); + const key = `${projectPath}\n${dirs.join('\n')}`; + if (key === lastWatchedDirsKey) return; + lastWatchedDirsKey = key; + + if (watchedDirsSyncTimer) clearTimeout(watchedDirsSyncTimer); + watchedDirsSyncTimer = setTimeout(() => { + watchedDirsSyncTimer = null; + void window.electronAPI.editor.setWatchedDirs(dirs); + }, WATCHED_DIRS_DEBOUNCE_MS); +} + /** * Open request sequence for editor initialization. * Cancels stale async work (notably React 18 StrictMode dev effect mount/unmount). @@ -103,11 +135,16 @@ const MOVE_COOLDOWN_MS = 2000; function scheduleIdleWork(cb: () => void): void { // Prefer requestIdleCallback when available; fall back to a short timeout. // This keeps editor open responsive for large repos. + // timeout ensures the callback fires within 2s even if the event loop is busy + // (without it, requestIdleCallback can be delayed indefinitely). try { - const ric = (window as unknown as { requestIdleCallback?: (fn: () => void) => number }) - .requestIdleCallback; + const ric = ( + window as unknown as { + requestIdleCallback?: (fn: () => void, opts?: { timeout: number }) => number; + } + ).requestIdleCallback; if (typeof ric === 'function') { - ric(cb); + ric(cb, { timeout: 2000 }); return; } } catch { @@ -330,8 +367,7 @@ export const createEditorSlice: StateCreator = (s scheduleIdleWork(() => { if (editorOpenSeq !== openSeq || get().editorProjectPath !== projectPath) return; - // TODO: temporarily disabled file watcher — re-enable when stabilized - if (watcherDesired) void get().toggleWatcher(false); + if (watcherDesired) void get().toggleWatcher(true); // Defer initial git status a bit more — it can be expensive on large repos. setTimeout(() => { if (editorOpenSeq !== openSeq || get().editorProjectPath !== projectPath) return; @@ -355,6 +391,18 @@ export const createEditorSlice: StateCreator = (s closeEditor: () => { // Cancel any in-flight openEditor async work editorOpenSeq++; + // Cancel any pending watcher sync (avoid calling into main after close) + if (watchedFilesSyncTimer) { + clearTimeout(watchedFilesSyncTimer); + watchedFilesSyncTimer = null; + } + if (watchedDirsSyncTimer) { + clearTimeout(watchedDirsSyncTimer); + watchedDirsSyncTimer = null; + } + lastWatchedFilesKey = ''; + lastWatchedDirsKey = ''; + // Clear cooldown timestamps (no stale entries across editor sessions) recentSaveTimestamps.clear(); recentMoveTimestamps.clear(); @@ -428,6 +476,7 @@ export const createEditorSlice: StateCreator = (s set({ editorExpandedDirs: { ...editorExpandedDirs, [dirPath]: true }, }); + scheduleSyncWatchedDirs(get); } try { @@ -456,6 +505,7 @@ export const createEditorSlice: StateCreator = (s collapseDirectory: (dirPath: string) => { const { editorExpandedDirs } = get(); set({ editorExpandedDirs: omitKey(editorExpandedDirs, dirPath) }); + scheduleSyncWatchedDirs(get); }, // ═══════════════════════════════════════════════════════ @@ -1027,9 +1077,13 @@ export const createEditorSlice: StateCreator = (s } if (enable) { scheduleSyncWatchedFiles(get); + scheduleSyncWatchedDirs(get); } else { // Ensure main process stops watching files promptly. lastWatchedFilesKey = ''; + lastWatchedDirsKey = ''; + void api.editor.setWatchedFiles([]); + void api.editor.setWatchedDirs([]); } } catch (error) { log.error('Failed to toggle watcher:', error); diff --git a/src/renderer/utils/codemirrorTheme.ts b/src/renderer/utils/codemirrorTheme.ts index be76b321..270bc360 100644 --- a/src/renderer/utils/codemirrorTheme.ts +++ b/src/renderer/utils/codemirrorTheme.ts @@ -14,6 +14,7 @@ export const baseEditorTheme = EditorView.theme({ color: 'var(--color-text)', fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace', fontSize: '13px', + height: '100%', }, '&.cm-focused': { outline: 'none', diff --git a/src/renderer/utils/markdownPlugins.ts b/src/renderer/utils/markdownPlugins.ts index 74fda2c5..9540ea30 100644 --- a/src/renderer/utils/markdownPlugins.ts +++ b/src/renderer/utils/markdownPlugins.ts @@ -1,8 +1,15 @@ /** * Rehype plugins for markdown rendering (used with react-markdown). - * Rehype runs after remark; rehype-highlight adds syntax highlighting to code blocks. + * + * - rehype-raw: parse and render inline HTML in markdown + * - rehype-highlight: syntax highlighting for code blocks */ import rehypeHighlight from 'rehype-highlight'; +import rehypeRaw from 'rehype-raw'; -export const REHYPE_PLUGINS = [rehypeHighlight]; +/** Full plugin chain: raw HTML + syntax highlighting */ +export const REHYPE_PLUGINS = [rehypeRaw, rehypeHighlight]; + +/** Lightweight chain: raw HTML only (used when highlighting is disabled for large content) */ +export const REHYPE_PLUGINS_NO_HIGHLIGHT = [rehypeRaw]; diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index e0775528..b3991b75 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -426,7 +426,14 @@ export interface TeamsAPI { getLogsForTask: ( teamName: string, taskId: string, - options?: { owner?: string; status?: string } + options?: { + owner?: string; + status?: string; + /** Persisted work intervals (preferred for reliable owner-log attribution). */ + intervals?: { startedAt: string; completedAt?: string }[]; + /** Back-compat: single since timestamp (deprecated). */ + since?: string; + } ) => Promise; getMemberStats: (teamName: string, memberName: string) => Promise; launchTeam: (request: TeamLaunchRequest) => Promise; diff --git a/src/shared/types/editor.ts b/src/shared/types/editor.ts index 471ead12..704329ec 100644 --- a/src/shared/types/editor.ts +++ b/src/shared/types/editor.ts @@ -201,6 +201,12 @@ export interface EditorAPI { * Intended as a performance optimization: avoids watching the whole project tree. */ setWatchedFiles: (filePaths: string[]) => Promise; + /** + * Provide the list of directories to watch shallowly (depth=0). + * Intended to keep the explorer tree in sync with external changes without + * recursively watching the whole project. + */ + setWatchedDirs: (dirPaths: string[]) => Promise; /** Subscribe to file change events (main → renderer). Returns cleanup function. */ onEditorChange: (callback: (event: EditorFileChangeEvent) => void) => () => void; } diff --git a/src/shared/types/notifications.ts b/src/shared/types/notifications.ts index 98a1e709..343ecf8e 100644 --- a/src/shared/types/notifications.ts +++ b/src/shared/types/notifications.ts @@ -247,8 +247,10 @@ export interface AppConfig { snoozeMinutes: number; /** Whether to include errors from subagent sessions */ includeSubagentErrors: boolean; - /** Whether to show native OS notifications for team inbox messages */ - notifyOnInboxMessages: boolean; + /** Whether to show native OS notifications when teammates send messages to the team lead */ + notifyOnLeadInbox: boolean; + /** Whether to show native OS notifications when teammates send messages to you (the user) */ + notifyOnUserInbox: boolean; /** Whether to show native OS notifications when a task needs user clarification */ notifyOnClarifications: boolean; /** Notification triggers - define when to generate notifications */ diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index c11d44cc..eda87d33 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -55,11 +55,21 @@ export interface TeamSummary { export type TeamTaskStatus = 'pending' | 'in_progress' | 'completed' | 'deleted'; +export interface TaskWorkInterval { + /** ISO timestamp when task entered in_progress */ + startedAt: string; + /** ISO timestamp when task left in_progress (optional for active interval) */ + completedAt?: string; +} + +export type TaskCommentType = 'regular' | 'review_request' | 'review_approved'; + export interface TaskComment { id: string; author: string; text: string; createdAt: string; + type: TaskCommentType; } // Fields are validated in TeamTaskReader.getTasks() using `satisfies Record`. @@ -72,6 +82,11 @@ export interface TeamTask { owner?: string; createdBy?: string; status: TeamTaskStatus; + /** + * One task can be worked on in multiple disjoint periods (e.g. review sends it back to in_progress). + * We persist intervals for reliable log attribution without relying on heuristics. + */ + workIntervals?: TaskWorkInterval[]; blocks?: string[]; blockedBy?: string[]; /** @@ -271,6 +286,7 @@ export interface TeamCreateConfigRequest { description?: string; color?: string; members: TeamProvisioningMemberInput[]; + cwd?: string; } export interface TeamCreateResponse { diff --git a/test/main/ipc/editor.test.ts b/test/main/ipc/editor.test.ts index 487a4fa8..7a21dd01 100644 --- a/test/main/ipc/editor.test.ts +++ b/test/main/ipc/editor.test.ts @@ -45,6 +45,7 @@ vi.mock('@preload/constants/ipcChannels', () => ({ EDITOR_GIT_STATUS: 'editor:gitStatus', EDITOR_WATCH_DIR: 'editor:watchDir', EDITOR_SET_WATCHED_FILES: 'editor:setWatchedFiles', + EDITOR_SET_WATCHED_DIRS: 'editor:setWatchedDirs', EDITOR_CHANGE: 'editor:change', })); @@ -148,8 +149,8 @@ describe('Editor IPC handlers', () => { }); describe('registration', () => { - it('registers all 16 editor channels', () => { - expect(mockIpc.handle).toHaveBeenCalledTimes(16); + it('registers all 17 editor channels', () => { + expect(mockIpc.handle).toHaveBeenCalledTimes(17); expect(mockIpc._handlers.has('editor:open')).toBe(true); expect(mockIpc._handlers.has('editor:close')).toBe(true); expect(mockIpc._handlers.has('editor:readDir')).toBe(true); @@ -166,11 +167,12 @@ describe('Editor IPC handlers', () => { expect(mockIpc._handlers.has('editor:gitStatus')).toBe(true); expect(mockIpc._handlers.has('editor:watchDir')).toBe(true); expect(mockIpc._handlers.has('editor:setWatchedFiles')).toBe(true); + expect(mockIpc._handlers.has('editor:setWatchedDirs')).toBe(true); }); it('removeEditorHandlers clears all channels', () => { removeEditorHandlers(mockIpc as unknown as IpcMain); - expect(mockIpc.removeHandler).toHaveBeenCalledTimes(16); + expect(mockIpc.removeHandler).toHaveBeenCalledTimes(17); }); }); diff --git a/test/main/services/editor/EditorFileWatcher.test.ts b/test/main/services/editor/EditorFileWatcher.test.ts index 10939745..aaffa720 100644 --- a/test/main/services/editor/EditorFileWatcher.test.ts +++ b/test/main/services/editor/EditorFileWatcher.test.ts @@ -173,6 +173,22 @@ describe('EditorFileWatcher', () => { }); }); + describe('setWatchedFiles before start', () => { + it('returns silently when watcher not initialized', () => { + // Should NOT throw — graceful no-op when projectRoot is null + expect(() => watcher.setWatchedFiles(['/some/file.ts'])).not.toThrow(); + expect(watch).not.toHaveBeenCalled(); + }); + }); + + describe('setWatchedDirs before start', () => { + it('returns silently when watcher not initialized', () => { + // Should NOT throw — graceful no-op when projectRoot is null + expect(() => watcher.setWatchedDirs(['/some/dir'])).not.toThrow(); + expect(watch).not.toHaveBeenCalled(); + }); + }); + describe('isWatching', () => { it('returns false when not started', () => { expect(watcher.isWatching()).toBe(false); diff --git a/test/main/services/team/TeamMemberLogsFinder.test.ts b/test/main/services/team/TeamMemberLogsFinder.test.ts index 2fad859d..39ad0a1a 100644 --- a/test/main/services/team/TeamMemberLogsFinder.test.ts +++ b/test/main/services/team/TeamMemberLogsFinder.test.ts @@ -245,4 +245,186 @@ describe('TeamMemberLogsFinder', () => { // Full file has 200 messages — must NOT be capped at 50 or 100 expect(carolLogs[0]?.messageCount).toBe(200); }); + + it('findLogsForTask does not treat arbitrary "#" as a task reference', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-task-logs-')); + setClaudeBasePathOverride(tmpDir); + + const teamName = 't4'; + const projectPath = '/Users/test/proj4'; + const projectId = '-Users-test-proj4'; + const leadSessionId = 's4'; + + await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true }); + await fs.writeFile( + path.join(tmpDir, 'teams', teamName, 'config.json'), + JSON.stringify({ + name: teamName, + projectPath, + leadSessionId, + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'bob', agentType: 'general-purpose' }, + ], + }), + 'utf8' + ); + + const projectRoot = path.join(tmpDir, 'projects', projectId); + await fs.mkdir(path.join(projectRoot, leadSessionId, 'subagents'), { recursive: true }); + + // Lead session mentions "PR #1" but NOT a task reference + await fs.writeFile( + path.join(projectRoot, `${leadSessionId}.jsonl`), + [ + JSON.stringify({ + timestamp: '2026-01-01T00:00:00.000Z', + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'Fix PR #1 please' }] }, + }), + ].join('\n') + '\n', + 'utf8' + ); + + // Subagent session includes a structured taskId reference (should match) + await fs.writeFile( + path.join(projectRoot, leadSessionId, 'subagents', 'agent-abc111.jsonl'), + [ + JSON.stringify({ + timestamp: '2026-01-01T00:00:01.000Z', + type: 'user', + message: { role: 'user', content: 'You are bob, a developer on team "t4" (t4).' }, + }), + JSON.stringify({ + timestamp: '2026-01-01T00:00:02.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + name: 'TaskUpdate', + input: { taskId: '1', status: 'in_progress' }, + }, + ], + }, + }), + ].join('\n') + '\n', + 'utf8' + ); + + const finder = new TeamMemberLogsFinder(); + const logs = await finder.findLogsForTask(teamName, '1'); + + // Should include the subagent log, but must NOT include the lead session just because it had "PR #1" + expect(logs.some((l) => l.kind === 'lead_session')).toBe(false); + expect(logs.some((l) => l.kind === 'subagent')).toBe(true); + }); + + it('findLogsForTask includes only owner sessions overlapping workIntervals', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-task-owner-since-')); + setClaudeBasePathOverride(tmpDir); + + const teamName = 't5'; + const projectPath = '/Users/test/proj5'; + const projectId = '-Users-test-proj5'; + const leadSessionId = 's5'; + + await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true }); + await fs.writeFile( + path.join(tmpDir, 'teams', teamName, 'config.json'), + JSON.stringify({ + name: teamName, + projectPath, + leadSessionId, + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'bob', agentType: 'general-purpose' }, + { name: 'alice', agentType: 'general-purpose' }, + ], + }), + 'utf8' + ); + + const projectRoot = path.join(tmpDir, 'projects', projectId); + await fs.mkdir(path.join(projectRoot, leadSessionId, 'subagents'), { recursive: true }); + + // Alice file references taskId 10 via structured tool input (so results is non-empty). + await fs.writeFile( + path.join(projectRoot, leadSessionId, 'subagents', 'agent-alice10.jsonl'), + [ + JSON.stringify({ + timestamp: '2026-01-01T00:00:01.000Z', + type: 'user', + message: { role: 'user', content: 'You are alice, a developer on team "t5" (t5).' }, + }), + JSON.stringify({ + timestamp: '2026-01-01T00:00:02.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: [ + { type: 'tool_use', name: 'TaskUpdate', input: { taskId: '10', status: 'pending' } }, + ], + }, + }), + ].join('\n') + '\n', + 'utf8' + ); + + // Bob has an old session (should NOT be pulled in by owner include). + await fs.writeFile( + path.join(projectRoot, leadSessionId, 'subagents', 'agent-bob-old.jsonl'), + [ + JSON.stringify({ + timestamp: '2025-12-31T00:00:00.000Z', + type: 'user', + message: { role: 'user', content: 'You are bob, a developer on team "t5" (t5).' }, + }), + JSON.stringify({ + timestamp: '2025-12-31T00:00:01.000Z', + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'Old work' }] }, + }), + ].join('\n') + '\n', + 'utf8' + ); + + // Bob has a recent session within workIntervals (should be included). + await fs.writeFile( + path.join(projectRoot, leadSessionId, 'subagents', 'agent-bob-new.jsonl'), + [ + JSON.stringify({ + timestamp: '2026-01-01T12:00:00.000Z', + type: 'user', + message: { role: 'user', content: 'You are bob, a developer on team "t5" (t5).' }, + }), + JSON.stringify({ + timestamp: '2026-01-01T12:00:01.000Z', + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'New work' }] }, + }), + ].join('\n') + '\n', + 'utf8' + ); + + const finder = new TeamMemberLogsFinder(); + const logs = await finder.findLogsForTask(teamName, '10', { + owner: 'bob', + status: 'in_progress', + intervals: [ + { startedAt: '2026-01-01T10:00:00.000Z', completedAt: '2026-01-01T13:00:00.000Z' }, + ], + }); + + const bobDescriptions = logs + .filter((l) => l.kind === 'subagent' && l.memberName?.toLowerCase() === 'bob') + .map((l) => l.description); + + expect(bobDescriptions.some((d) => d.includes('Old'))).toBe(false); + // At least one bob log should be present (the recent one). + expect(logs.some((l) => l.kind === 'subagent' && l.memberName?.toLowerCase() === 'bob')).toBe( + true + ); + }); }); diff --git a/test/main/services/team/teamctl.test.ts b/test/main/services/team/teamctl.test.ts index 0177da94..4e31e447 100644 --- a/test/main/services/team/teamctl.test.ts +++ b/test/main/services/team/teamctl.test.ts @@ -717,7 +717,7 @@ describe('teamctl.js', () => { }); }); - it('adds a comment with valid ID and timestamp', () => { + it('adds a comment with valid ID, timestamp, and type=regular', () => { const { stdout, exitCode } = run(claudeDir, [ 'task', 'comment', @@ -735,6 +735,7 @@ describe('teamctl.js', () => { expect(comments).toHaveLength(1); expect(comments[0].text).toBe('Hello world'); expect(comments[0].author).toBe('alice'); + expect(comments[0].type).toBe('regular'); expect(String(comments[0].id)).toMatch(UUID_RE); expect(String(comments[0].createdAt)).toMatch(ISO_RE); }); @@ -753,7 +754,7 @@ describe('teamctl.js', () => { expect(readInbox(claudeDir, 'bob').length).toBe(1); // still 1 }); - it('multiple comments accumulate with unique IDs', () => { + it('multiple comments accumulate with unique IDs and type=regular', () => { run(claudeDir, ['task', 'comment', '1', '--text', 'First', '--from', 'alice']); run(claudeDir, ['task', 'comment', '1', '--text', 'Second', '--from', 'bob']); run(claudeDir, ['task', 'comment', '1', '--text', 'Third', '--from', 'alice']); @@ -763,6 +764,7 @@ describe('teamctl.js', () => { expect(comments.map((c) => c.text)).toEqual(['First', 'Second', 'Third']); expect(comments.map((c) => c.author)).toEqual(['alice', 'bob', 'alice']); expect(new Set(comments.map((c) => c.id)).size).toBe(3); + expect(comments.every((c) => c.type === 'regular')).toBe(true); }); it('comment on task without comments array initializes it', () => { @@ -1283,6 +1285,83 @@ describe('teamctl.js', () => { expect(exitCode).not.toBe(0); expect(stderr).toContain('Usage'); }); + + it('approve records review_approved comment in task.comments', () => { + run(claudeDir, ['review', 'approve', '1', '--from', 'alice']); + const task = readTask(claudeDir, '1'); + const comments = task.comments as Record[]; + expect(comments).toHaveLength(1); + expect(comments[0].type).toBe('review_approved'); + expect(comments[0].author).toBe('alice'); + expect(comments[0].text).toBe('Approved'); + expect(String(comments[0].id)).toMatch(UUID_RE); + expect(String(comments[0].createdAt)).toMatch(ISO_RE); + }); + + it('approve records review_approved comment with --note text', () => { + run(claudeDir, [ + 'review', + 'approve', + '1', + '--notify-owner', + '--from', + 'alice', + '--note', + 'Looks great!', + ]); + const comments = readTask(claudeDir, '1').comments as Record[]; + expect(comments).toHaveLength(1); + expect(comments[0].type).toBe('review_approved'); + expect(comments[0].text).toBe('Looks great!'); + }); + + it('request-changes records review_request comment in task.comments', () => { + run(claudeDir, [ + 'review', + 'request-changes', + '1', + '--comment', + 'Fix the edge case', + '--from', + 'alice', + ]); + const comments = readTask(claudeDir, '1').comments as Record[]; + expect(comments).toHaveLength(1); + expect(comments[0].type).toBe('review_request'); + expect(comments[0].author).toBe('alice'); + expect(comments[0].text).toBe('Fix the edge case'); + expect(String(comments[0].id)).toMatch(UUID_RE); + expect(String(comments[0].createdAt)).toMatch(ISO_RE); + }); + + it('request-changes without --comment records default text as review_request', () => { + run(claudeDir, ['review', 'request-changes', '1', '--from', 'alice']); + const comments = readTask(claudeDir, '1').comments as Record[]; + expect(comments).toHaveLength(1); + expect(comments[0].type).toBe('review_request'); + expect(comments[0].text).toBe('Reviewer requested changes.'); + }); + + it('review comments preserve existing task comments', () => { + // Add a regular comment first + run(claudeDir, ['task', 'comment', '1', '--text', 'Working on it', '--from', 'bob']); + // Then request changes + run(claudeDir, [ + 'review', + 'request-changes', + '1', + '--comment', + 'Needs tests', + '--from', + 'alice', + ]); + const comments = readTask(claudeDir, '1').comments as Record[]; + expect(comments).toHaveLength(2); + expect(comments[0].type).toBe('regular'); + expect(comments[0].text).toBe('Working on it'); + expect(comments[1].type).toBe('review_request'); + expect(comments[1].text).toBe('Needs tests'); + }); }); // ========================================================================= @@ -1824,8 +1903,8 @@ describe('teamctl.js', () => { expect(comments[0].text).toBe('Hello'); }); - // --- reviewApprove without --notify-owner creates NO inbox --- - it('review approve without --notify-owner does NOT create inbox', () => { + // --- reviewApprove without --notify-owner creates NO inbox but DOES record comment --- + it('review approve without --notify-owner does NOT create inbox but records comment', () => { writeTask(claudeDir, '1', { id: '1', subject: 'Feature', @@ -1835,10 +1914,14 @@ describe('teamctl.js', () => { run(claudeDir, ['kanban', 'set-column', '1', 'review']); run(claudeDir, ['review', 'approve', '1']); // no --notify-owner expect(readInbox(claudeDir, 'bob')).toEqual([]); + // Comment is still recorded + const comments = readTask(claudeDir, '1').comments as Record[]; + expect(comments).toHaveLength(1); + expect(comments[0].type).toBe('review_approved'); }); - // --- request-changes: verify ALL three side effects --- - it('review request-changes: kanban cleared + status in_progress + inbox sent', () => { + // --- request-changes: verify ALL four side effects --- + it('review request-changes: kanban cleared + status in_progress + comment recorded + inbox sent', () => { writeTask(claudeDir, '1', { id: '1', subject: 'PR', @@ -1859,7 +1942,13 @@ describe('teamctl.js', () => { expect((readKanban(claudeDir).tasks as Record)['1']).toBeUndefined(); // 2) Status changed to in_progress expect(readTask(claudeDir, '1').status).toBe('in_progress'); - // 3) Inbox message sent + // 3) Review comment recorded + const comments = readTask(claudeDir, '1').comments as Record[]; + expect(comments).toHaveLength(1); + expect(comments[0].type).toBe('review_request'); + expect(comments[0].author).toBe('alice'); + expect(comments[0].text).toBe('Missing tests'); + // 4) Inbox message sent const inbox = readInbox(claudeDir, 'bob') as Record[]; expect(inbox).toHaveLength(1); expect(inbox[0].from).toBe('alice'); @@ -2249,8 +2338,8 @@ describe('teamctl.js', () => { expect(after[0].label).toBe('server-1'); }); - // --- review approve also writes to kanban (column=approved) --- - it('review approve sets kanban column to approved with movedAt', () => { + // --- review approve also writes to kanban (column=approved) + comment --- + it('review approve sets kanban column to approved with movedAt and records comment', () => { writeTask(claudeDir, '1', { id: '1', subject: 'PR task', @@ -2262,6 +2351,10 @@ describe('teamctl.js', () => { const entry = (readKanban(claudeDir).tasks as Record>)['1']; expect(entry.column).toBe('approved'); expect(String(entry.movedAt)).toMatch(ISO_RE); + // Review comment recorded + const comments = readTask(claudeDir, '1').comments as Record[]; + expect(comments).toHaveLength(1); + expect(comments[0].type).toBe('review_approved'); }); // --- Task create without --description defaults to subject ---