diff --git a/desktop/package-lock.json b/desktop/package-lock.json index aa02c67e..02a684ee 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -15,6 +15,8 @@ "react": "^18.3.1", "react-diff-viewer-continued": "^4.2.0", "react-dom": "^18.3.1", + "react-shiki": "^0.9.2", + "shiki": "^4.0.2", "zustand": "^5.0.3" }, "devDependencies": { @@ -27,7 +29,7 @@ "@vitejs/plugin-react": "^4.3.4", "jsdom": "^25.0.1", "tailwindcss": "^4.0.0", - "typescript": "^5.7.3", + "typescript": "^5.9.3", "vite": "^6.0.7", "vitest": "^3.0.4" }, @@ -1441,6 +1443,106 @@ "win32" ] }, + "node_modules/@shikijs/core": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/@shikijs/core/-/core-4.0.2.tgz", + "integrity": "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==", + "license": "MIT", + "dependencies": { + "@shikijs/primitive": "4.0.2", + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/@shikijs/engine-javascript/-/engine-javascript-4.0.2.tgz", + "integrity": "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/@shikijs/engine-oniguruma/-/engine-oniguruma-4.0.2.tgz", + "integrity": "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/langs": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/@shikijs/langs/-/langs-4.0.2.tgz", + "integrity": "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/@shikijs/primitive/-/primitive-4.0.2.tgz", + "integrity": "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/themes": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/@shikijs/themes/-/themes-4.0.2.tgz", + "integrity": "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/types": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/@shikijs/types/-/types-4.0.2.tgz", + "integrity": "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmmirror.com/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.2.2", "resolved": "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.2.2.tgz", @@ -2120,6 +2222,15 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmmirror.com/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -2131,7 +2242,39 @@ "version": "1.0.8", "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, "node_modules/@types/parse-json": { @@ -2168,12 +2311,24 @@ "version": "18.3.7", "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^18.0.0" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -2492,6 +2647,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmmirror.com/chai/-/chai-5.3.3.tgz", @@ -2509,6 +2674,46 @@ "node": ">=18" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/check-error": { "version": "2.1.3", "resolved": "https://registry.npmmirror.com/check-error/-/check-error-2.1.3.tgz", @@ -2547,6 +2752,16 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2663,6 +2878,19 @@ "dev": true, "license": "MIT" }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmmirror.com/deep-eql/-/deep-eql-5.0.2.tgz", @@ -2687,7 +2915,6 @@ "version": "2.0.3", "resolved": "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -2703,6 +2930,19 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dom-accessibility-api": { "version": "0.6.3", "resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", @@ -2888,6 +3128,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", @@ -3083,6 +3333,69 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmmirror.com/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmmirror.com/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmmirror.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -3111,6 +3424,16 @@ "node": ">=18" } }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -3178,6 +3501,36 @@ "node": ">=8" } }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmmirror.com/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -3199,6 +3552,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -3573,6 +3946,16 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz", @@ -3653,12 +4036,607 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmmirror.com/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/memoize-one": { "version": "6.0.0", "resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz", "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", "license": "MIT" }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", @@ -3731,6 +4709,23 @@ "dev": true, "license": "MIT" }, + "node_modules/oniguruma-parser": { + "version": "0.12.1", + "resolved": "https://registry.npmmirror.com/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", + "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.5", + "resolved": "https://registry.npmmirror.com/oniguruma-to-es/-/oniguruma-to-es-4.3.5.tgz", + "integrity": "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.1", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", @@ -3743,6 +4738,31 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmmirror.com/parse-json/-/parse-json-5.2.0.tgz", @@ -3895,6 +4915,16 @@ "react": ">=16.0.0" } }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmmirror.com/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", @@ -3977,6 +5007,33 @@ "node": ">=0.10.0" } }, + "node_modules/react-shiki": { + "version": "0.9.2", + "resolved": "https://registry.npmmirror.com/react-shiki/-/react-shiki-0.9.2.tgz", + "integrity": "sha512-TMj2IYm9mWUhWF+OEffdfnaYEpYLr3vs5b8xRcnuRYR4Dkk2TU2zT9RlbSlktl5mLNdiioDnkEqZYzsGYjYQNA==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "dequal": "^2.0.3", + "hast-util-to-jsx-runtime": "^2.3.6", + "shiki": "^4.0.0", + "unist-util-visit": "^5.1.0" + }, + "peerDependencies": { + "@types/react": ">=16.8.0", + "@types/react-dom": ">=16.8.0", + "react": ">= 16.8.0", + "react-dom": ">= 16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmmirror.com/redent/-/redent-3.0.0.tgz", @@ -3991,6 +5048,30 @@ "node": ">=8" } }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.11.tgz", @@ -4111,6 +5192,25 @@ "semver": "bin/semver.js" } }, + "node_modules/shiki": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/shiki/-/shiki-4.0.2.tgz", + "integrity": "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.0.2", + "@shikijs/engine-javascript": "4.0.2", + "@shikijs/engine-oniguruma": "4.0.2", + "@shikijs/langs": "4.0.2", + "@shikijs/themes": "4.0.2", + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz", @@ -4137,6 +5237,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz", @@ -4151,6 +5261,20 @@ "dev": true, "license": "MIT" }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmmirror.com/strip-indent/-/strip-indent-3.0.0.tgz", @@ -4184,6 +5308,24 @@ "dev": true, "license": "MIT" }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmmirror.com/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmmirror.com/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/stylis": { "version": "4.2.0", "resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.2.0.tgz", @@ -4336,6 +5478,16 @@ "node": ">=18" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", @@ -4350,6 +5502,74 @@ "node": ">=14.17" } }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -4387,6 +5607,34 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "6.4.1", "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.1.tgz", @@ -4728,6 +5976,16 @@ "optional": true } } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/desktop/package.json b/desktop/package.json index 0b90f019..07f1d835 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -35,7 +35,7 @@ "@vitejs/plugin-react": "^4.3.4", "jsdom": "^25.0.1", "tailwindcss": "^4.0.0", - "typescript": "^5.7.3", + "typescript": "^5.9.3", "vite": "^6.0.7", "vitest": "^3.0.4" }, diff --git a/desktop/src/__tests__/pages.test.tsx b/desktop/src/__tests__/pages.test.tsx index 08ad5746..5a3f7479 100644 --- a/desktop/src/__tests__/pages.test.tsx +++ b/desktop/src/__tests__/pages.test.tsx @@ -12,8 +12,10 @@ import { ToolInspection } from '../pages/ToolInspection' // Layout components (chrome is now here, not in pages) import { AppShell } from '../components/layout/AppShell' +import { Sidebar } from '../components/layout/Sidebar' import { UserMessage } from '../components/chat/UserMessage' import { useChatStore } from '../stores/chatStore' +import { useTabStore } from '../stores/tabStore' /** * Core rendering tests: content-only pages must render without crashing @@ -35,21 +37,66 @@ describe('Content-only pages render without errors', () => { }) it('ActiveSession renders with chat components', () => { + const SESSION_ID = 'test-active-session' + useTabStore.setState({ tabs: [{ sessionId: SESSION_ID, title: 'Test', status: 'idle' }], activeTabId: SESSION_ID }) + useChatStore.setState({ + sessions: { + [SESSION_ID]: { + messages: [], + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + elapsedTimer: null, + }, + }, + }) const { container } = render() // Should have the title area and chat input expect(container.innerHTML).toContain('Untitled Session') // ChatInput has a textarea expect(container.querySelector('textarea')).toBeInTheDocument() expect(container.innerHTML).not.toContain('Preview') + // Cleanup + useTabStore.setState({ tabs: [], activeTabId: null }) + useChatStore.setState({ sessions: {} }) }) it('ActiveSession shows a single primary action button while a turn is active', () => { - useChatStore.setState({ chatState: 'thinking' }) + useTabStore.setState({ activeTabId: 'active-tab', tabs: [{ sessionId: 'active-tab', title: 'Test', status: 'idle' }] }) + useChatStore.setState({ + sessions: { + 'active-tab': { + messages: [], + chatState: 'thinking', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + elapsedTimer: null, + }, + }, + }) render() expect(screen.getByRole('button', { name: /stop/i })).toBeInTheDocument() expect(screen.queryByRole('button', { name: /^run$/i })).not.toBeInTheDocument() - useChatStore.setState({ chatState: 'idle' }) + useChatStore.setState({ sessions: {} }) }) it('AgentTeams renders team strip and members', () => { @@ -102,11 +149,11 @@ describe('Chat attachments', () => { describe('AppShell layout renders chrome', () => { it('AppShell renders sidebar and session shell', () => { - const { container } = render() + const { container } = render() expect(container.querySelector('aside')).toBeInTheDocument() expect(container.innerHTML).toContain('New session') expect(container.innerHTML).toContain('Scheduled') - expect(container.innerHTML).toContain('Select a project') + expect(container.innerHTML).toContain('All projects') }) }) diff --git a/desktop/src/components/chat/AskUserQuestion.tsx b/desktop/src/components/chat/AskUserQuestion.tsx index 48c211b2..fc9cb4e3 100644 --- a/desktop/src/components/chat/AskUserQuestion.tsx +++ b/desktop/src/components/chat/AskUserQuestion.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' import { useChatStore } from '../../stores/chatStore' +import { useTabStore } from '../../stores/tabStore' import { useTranslation } from '../../i18n' import { Button } from '../shared/Button' @@ -47,6 +48,7 @@ function parseInput(input: unknown): Question[] { export function AskUserQuestion({ toolUseId: _toolUseId, input }: Props) { const { sendMessage } = useChatStore() + const activeTabId = useTabStore((s) => s.activeTabId) const t = useTranslation() const questions = parseInput(input) const [activeTab, setActiveTab] = useState(0) @@ -82,7 +84,8 @@ export function AskUserQuestion({ toolUseId: _toolUseId, input }: Props) { if (!response) return setSubmitted(true) - sendMessage(response) + if (!activeTabId) return + sendMessage(activeTabId, response) } // All questions must be answered (via selection or free text) to enable submit diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 9da3a06f..e89447c7 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -1,6 +1,7 @@ import { useState, useRef, useEffect, useCallback, useMemo } from 'react' import { useTranslation } from '../../i18n' import { useChatStore } from '../../stores/chatStore' +import { useTabStore } from '../../stores/tabStore' import { useSessionStore } from '../../stores/sessionStore' import { sessionsApi } from '../../api/sessions' import { PermissionModeSelector } from '../controls/PermissionModeSelector' @@ -43,7 +44,11 @@ export function ChatInput() { const slashMenuRef = useRef(null) const fileSearchRef = useRef(null) const slashItemRefs = useRef<(HTMLButtonElement | null)[]>([]) - const { sendMessage, chatState, stopGeneration, slashCommands } = useChatStore() + const { sendMessage, stopGeneration } = useChatStore() + const activeTabId = useTabStore((s) => s.activeTabId) + const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined) + const chatState = sessionState?.chatState ?? 'idle' + const slashCommands = sessionState?.slashCommands ?? [] const activeSessionId = useSessionStore((state) => state.activeSessionId) const activeSession = useSessionStore((state) => state.sessions.find((session) => session.id === state.activeSessionId) ?? null) const [gitInfo, setGitInfo] = useState(null) @@ -213,7 +218,7 @@ export function ChatInput() { mimeType: attachment.mimeType, })) - sendMessage(text, attachmentPayload) + sendMessage(activeTabId!, text, attachmentPayload) setInput('') setAttachments([]) setSlashMenuOpen(false) @@ -494,7 +499,7 @@ export function ChatInput() {
+ )} + +
+ {tabs.map((tab) => ( + setActiveTab(tab.sessionId)} + onClose={() => handleClose(tab.sessionId)} + onContextMenu={(e) => handleContextMenu(e, tab.sessionId)} + /> + ))} +
+ + {canScrollRight && ( + + )} + + {contextMenu && ( +
+ + + +
+ )} + + {closingTabId && ( +
+
+

{t('tabs.closeConfirmTitle')}

+

{t('tabs.closeConfirmMessage')}

+
+ + + +
+
+
+ )} +
+ ) +} + +function TabItem({ tab, isActive, onClick, onClose, onContextMenu }: { + tab: Tab + isActive: boolean + onClick: () => void + onClose: () => void + onContextMenu: (e: React.MouseEvent) => void +}) { + return ( +
+ {tab.status === 'running' && ( + + )} + {tab.status === 'error' && ( + + )} + + + {tab.title || 'Untitled'} + + + +
+ ) +} diff --git a/desktop/src/hooks/useKeyboardShortcuts.ts b/desktop/src/hooks/useKeyboardShortcuts.ts index 6e4c4da9..21f2afdd 100644 --- a/desktop/src/hooks/useKeyboardShortcuts.ts +++ b/desktop/src/hooks/useKeyboardShortcuts.ts @@ -1,6 +1,7 @@ import { useEffect } from 'react' import { useSessionStore } from '../stores/sessionStore' import { useChatStore } from '../stores/chatStore' +import { useTabStore } from '../stores/tabStore' import { useUIStore } from '../stores/uiStore' export function useKeyboardShortcuts() { @@ -9,7 +10,8 @@ export function useKeyboardShortcuts() { const closeModal = useUIStore((s) => s.closeModal) const activeModal = useUIStore((s) => s.activeModal) const stopGeneration = useChatStore((s) => s.stopGeneration) - const chatState = useChatStore((s) => s.chatState) + const activeTabId = useTabStore((s) => s.activeTabId) + const chatState = useChatStore((s) => activeTabId ? s.sessions[activeTabId]?.chatState ?? 'idle' : 'idle') useEffect(() => { const handler = (e: KeyboardEvent) => { @@ -38,14 +40,14 @@ export function useKeyboardShortcuts() { // Cmd+. — Stop generation if (meta && e.key === '.') { - if (chatState !== 'idle') { + if (chatState !== 'idle' && activeTabId) { e.preventDefault() - stopGeneration() + stopGeneration(activeTabId) } } } document.addEventListener('keydown', handler) return () => document.removeEventListener('keydown', handler) - }, [activeModal, chatState, closeModal, setActiveSession, setActiveView, stopGeneration]) + }, [activeModal, activeTabId, chatState, closeModal, setActiveSession, setActiveView, stopGeneration]) } diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 7d22245a..d61dabfb 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -434,6 +434,15 @@ export const en = { 'serverVerb.Thinking': 'Thinking', 'serverVerb.Task started': 'Task started', 'serverVerb.Task in progress': 'Task in progress', + + // ─── Tabs ────────────────────────────────────── + 'tabs.close': 'Close', + 'tabs.closeOthers': 'Close Others', + 'tabs.closeRight': 'Close to the Right', + 'tabs.closeConfirmTitle': 'Session Running', + 'tabs.closeConfirmMessage': 'This session is still running. What would you like to do?', + 'tabs.closeConfirmKeep': 'Keep Running', + 'tabs.closeConfirmStop': 'Stop & Close', } as const export type TranslationKey = keyof typeof en diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 9f5fd3db..9cbe328b 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -436,4 +436,13 @@ export const zh: Record = { 'serverVerb.Thinking': '思考中', 'serverVerb.Task started': '任务已启动', 'serverVerb.Task in progress': '任务进行中', + + // ─── Tabs ────────────────────────────────────── + 'tabs.close': '关闭', + 'tabs.closeOthers': '关闭其他', + 'tabs.closeRight': '关闭右侧', + 'tabs.closeConfirmTitle': '会话运行中', + 'tabs.closeConfirmMessage': '此会话仍在运行,你想要怎么做?', + 'tabs.closeConfirmKeep': '保持运行', + 'tabs.closeConfirmStop': '停止并关闭', } diff --git a/desktop/src/pages/ActiveSession.tsx b/desktop/src/pages/ActiveSession.tsx index f8a30c1e..087e7f20 100644 --- a/desktop/src/pages/ActiveSession.tsx +++ b/desktop/src/pages/ActiveSession.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo } from 'react' +import { useTabStore } from '../stores/tabStore' import { useSessionStore } from '../stores/sessionStore' import { useChatStore } from '../stores/chatStore' import { MessageList } from '../components/chat/MessageList' @@ -7,18 +8,20 @@ import { TeamStatusBar } from '../components/teams/TeamStatusBar' import { SessionTaskBar } from '../components/chat/SessionTaskBar' export function ActiveSession() { - const activeSessionId = useSessionStore((s) => s.activeSessionId) + const activeTabId = useTabStore((s) => s.activeTabId) const sessions = useSessionStore((s) => s.sessions) const connectToSession = useChatStore((s) => s.connectToSession) - const { chatState, tokenUsage } = useChatStore() + const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined) + const chatState = sessionState?.chatState ?? 'idle' + const tokenUsage = sessionState?.tokenUsage ?? { input_tokens: 0, output_tokens: 0 } - const session = sessions.find((s) => s.id === activeSessionId) + const session = sessions.find((s) => s.id === activeTabId) useEffect(() => { - if (activeSessionId) { - connectToSession(activeSessionId) + if (activeTabId) { + connectToSession(activeTabId) } - }, [activeSessionId, connectToSession]) + }, [activeTabId, connectToSession]) const isActive = chatState !== 'idle' const totalTokens = tokenUsage.input_tokens + tokenUsage.output_tokens @@ -32,6 +35,8 @@ export function ActiveSession() { return `${Math.floor(diff / 86400000)}d ago` }, [session?.modifiedAt]) + if (!activeTabId) return null + return (
{/* Session info header */} diff --git a/desktop/src/pages/EmptySession.tsx b/desktop/src/pages/EmptySession.tsx index 3c49d504..8e1df3e1 100644 --- a/desktop/src/pages/EmptySession.tsx +++ b/desktop/src/pages/EmptySession.tsx @@ -3,6 +3,7 @@ import { useTranslation } from '../i18n' import { useSessionStore } from '../stores/sessionStore' import { useChatStore } from '../stores/chatStore' import { useUIStore } from '../stores/uiStore' +import { useTabStore } from '../stores/tabStore' import { DirectoryPicker } from '../components/shared/DirectoryPicker' import { PermissionModeSelector } from '../components/controls/PermissionModeSelector' import { ModelSelector } from '../components/controls/ModelSelector' @@ -122,6 +123,7 @@ export function EmptySession() { try { const sessionId = await createSession(workDir || undefined) setActiveView('code') + useTabStore.getState().openTab(sessionId, 'New Session') connectToSession(sessionId) const attachmentPayload: AttachmentRef[] = attachments.map((attachment) => ({ type: attachment.type, @@ -129,7 +131,7 @@ export function EmptySession() { data: attachment.data, mimeType: attachment.mimeType, })) - sendMessage(text, attachmentPayload) + sendMessage(sessionId, text, attachmentPayload) setInput('') setAttachments([]) } catch (error) { diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index f7fc8a3b..b2320114 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -32,8 +32,39 @@ vi.mock('./teamStore', () => ({ }, })) +vi.mock('./tabStore', () => ({ + useTabStore: { + getState: () => ({ + updateTabStatus: vi.fn(), + updateTabTitle: vi.fn(), + }), + }, +})) + +vi.mock('./sessionStore', () => ({ + useSessionStore: { + getState: () => ({ + updateSessionTitle: vi.fn(), + }), + }, +})) + +vi.mock('./cliTaskStore', () => ({ + useCLITaskStore: { + getState: () => ({ + fetchSessionTasks: vi.fn(), + tasks: [], + clearTasks: vi.fn(), + setTasksFromTodos: vi.fn(), + markCompletedAndDismissed: vi.fn(), + refreshTasks: vi.fn(), + }), + }, +})) + import { mapHistoryMessagesToUiMessages, useChatStore } from './chatStore' +const TEST_SESSION_ID = 'test-session-1' const initialState = useChatStore.getState() describe('chatStore history mapping', () => { @@ -41,19 +72,7 @@ describe('chatStore history mapping', () => { sendMock.mockReset() useChatStore.setState({ ...initialState, - messages: [], - chatState: 'idle', - connectionState: 'disconnected', - streamingText: '', - streamingToolInput: '', - activeToolUseId: null, - activeToolName: null, - activeThinkingId: null, - pendingPermission: null, - tokenUsage: { input_tokens: 0, output_tokens: 0 }, - elapsedSeconds: 0, - connectedSessionId: null, - slashCommands: [], + sessions: {}, }) }) @@ -95,7 +114,29 @@ describe('chatStore history mapping', () => { }) it('keeps parent tool linkage for live tool events', () => { - useChatStore.getState().handleServerMessage({ + // Initialize the session first + useChatStore.setState({ + sessions: { + [TEST_SESSION_ID]: { + messages: [], + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + elapsedTimer: null, + }, + }, + }) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'tool_use_complete', toolName: 'Read', toolUseId: 'tool-1', @@ -103,7 +144,7 @@ describe('chatStore history mapping', () => { parentToolUseId: 'agent-1', }) - useChatStore.getState().handleServerMessage({ + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'tool_result', toolUseId: 'tool-1', content: 'ok', @@ -111,7 +152,7 @@ describe('chatStore history mapping', () => { parentToolUseId: 'agent-1', }) - expect(useChatStore.getState().messages).toMatchObject([ + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([ { type: 'tool_use', toolUseId: 'tool-1', @@ -126,13 +167,32 @@ describe('chatStore history mapping', () => { }) it('sends permission mode updates to the active session only', () => { - useChatStore.getState().setSessionPermissionMode('acceptEdits') + useChatStore.getState().setSessionPermissionMode('nonexistent-session', 'acceptEdits') expect(sendMock).not.toHaveBeenCalled() - useChatStore.setState({ connectedSessionId: 'session-1' }) - useChatStore.getState().setSessionPermissionMode('acceptEdits') + useChatStore.setState({ + sessions: { + 'session-1': { + messages: [], + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + elapsedTimer: null, + }, + }, + }) + useChatStore.getState().setSessionPermissionMode('session-1', 'acceptEdits') - expect(sendMock).toHaveBeenCalledWith({ + expect(sendMock).toHaveBeenCalledWith('session-1', { type: 'set_permission_mode', mode: 'acceptEdits', }) diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index ba9ef9de..eb7a406f 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -4,6 +4,7 @@ import { sessionsApi } from '../api/sessions' import { useTeamStore } from './teamStore' import { useSessionStore } from './sessionStore' import { useCLITaskStore } from './cliTaskStore' +import { useTabStore } from './tabStore' import { randomSpinnerVerb } from '../config/spinnerVerbs' import type { MessageEntry } from '../types/session' import type { PermissionMode } from '../types/settings' @@ -11,7 +12,7 @@ import type { AttachmentRef, ChatState, UIAttachment, UIMessage, ServerMessage, type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' -type ChatStore = { +export type PerSessionState = { messages: UIMessage[] chatState: ChatState connectionState: ConnectionState @@ -29,31 +30,11 @@ type ChatStore = { tokenUsage: TokenUsage elapsedSeconds: number statusVerb: string - connectedSessionId: string | null slashCommands: Array<{ name: string; description: string }> - - // Actions - connectToSession: (sessionId: string) => void - disconnectSession: () => void - sendMessage: (content: string, attachments?: AttachmentRef[]) => void - respondToPermission: (requestId: string, allowed: boolean, rule?: string) => void - setSessionPermissionMode: (mode: PermissionMode) => void - stopGeneration: () => void - loadHistory: (sessionId: string) => Promise - clearMessages: () => void - handleServerMessage: (msg: ServerMessage) => void + elapsedTimer: ReturnType | null } -const TASK_TOOL_NAMES = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TodoWrite']) - -/** Track tool_use IDs for task-related tools, so we can refresh on tool_result */ -const pendingTaskToolUseIds = new Set() - -let msgCounter = 0 -const nextId = () => `msg-${++msgCounter}-${Date.now()}` -let elapsedTimer: ReturnType | null = null - -export const useChatStore = create((set, get) => ({ +const DEFAULT_SESSION_STATE: PerSessionState = { messages: [], chatState: 'idle', connectionState: 'disconnected', @@ -66,97 +47,127 @@ export const useChatStore = create((set, get) => ({ tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', - connectedSessionId: null, slashCommands: [], + elapsedTimer: null, +} - connectToSession: (sessionId: string) => { - const current = get().connectedSessionId - if (current === sessionId) return +function createDefaultSessionState(): PerSessionState { + return { ...DEFAULT_SESSION_STATE, messages: [], tokenUsage: { input_tokens: 0, output_tokens: 0 } } +} - // Disconnect previous - if (current) wsManager.disconnect() +type ChatStore = { + sessions: Record - set({ - connectedSessionId: sessionId, - connectionState: 'connecting', - messages: [], - chatState: 'idle', - streamingText: '', - streamingToolInput: '', - activeToolUseId: null, - activeToolName: null, - activeThinkingId: null, - pendingPermission: null, - elapsedSeconds: 0, - slashCommands: [], - }) + getSession: (sessionId: string) => PerSessionState + connectToSession: (sessionId: string) => void + disconnectSession: (sessionId: string) => void + sendMessage: (sessionId: string, content: string, attachments?: AttachmentRef[]) => void + respondToPermission: (sessionId: string, requestId: string, allowed: boolean, rule?: string) => void + setSessionPermissionMode: (sessionId: string, mode: PermissionMode) => void + stopGeneration: (sessionId: string) => void + loadHistory: (sessionId: string) => Promise + clearMessages: (sessionId: string) => void + handleServerMessage: (sessionId: string, msg: ServerMessage) => void +} - // Clear all previous handlers before registering new ones - // This prevents handler accumulation causing message duplication - wsManager.clearHandlers() +const TASK_TOOL_NAMES = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TodoWrite']) +const pendingTaskToolUseIds = new Set() + +let msgCounter = 0 +const nextId = () => `msg-${++msgCounter}-${Date.now()}` + +/** Helper: immutably update a specific session within the sessions record */ +function updateSessionIn( + sessions: Record, + sessionId: string, + updater: (s: PerSessionState) => Partial, +): Record { + const session = sessions[sessionId] + if (!session) return sessions + return { ...sessions, [sessionId]: { ...session, ...updater(session) } } +} + +export const useChatStore = create((set, get) => ({ + sessions: {}, + + getSession: (sessionId) => get().sessions[sessionId] ?? createDefaultSessionState(), + + connectToSession: (sessionId) => { + const existing = get().sessions[sessionId] + if (existing && existing.connectionState !== 'disconnected') return + + set((s) => ({ + sessions: { + ...s.sessions, + [sessionId]: { + ...createDefaultSessionState(), + connectionState: 'connecting', + messages: existing?.messages ?? [], + }, + }, + })) + + wsManager.clearHandlers(sessionId) wsManager.connect(sessionId) - wsManager.onMessage((msg) => { + wsManager.onMessage(sessionId, (msg) => { if (msg.type === 'connected') { - set({ connectionState: 'connected' }) + set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ connectionState: 'connected' })) })) } - get().handleServerMessage(msg) + get().handleServerMessage(sessionId, msg) }) - // Load history and tasks get().loadHistory(sessionId) useCLITaskStore.getState().fetchSessionTasks(sessionId) sessionsApi.getSlashCommands(sessionId) .then(({ commands }) => { - if (get().connectedSessionId === sessionId) { - set({ slashCommands: commands }) + if (get().sessions[sessionId]) { + set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ slashCommands: commands })) })) } }) .catch(() => { - if (get().connectedSessionId === sessionId) { - set({ slashCommands: [] }) + if (get().sessions[sessionId]) { + set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ slashCommands: [] })) })) } }) }, - disconnectSession: () => { - wsManager.disconnect() - if (elapsedTimer) clearInterval(elapsedTimer) - useCLITaskStore.getState().clearTasks() - set({ connectedSessionId: null, chatState: 'idle' }) + disconnectSession: (sessionId) => { + const session = get().sessions[sessionId] + if (session?.elapsedTimer) clearInterval(session.elapsedTimer) + wsManager.disconnect(sessionId) + set((s) => { + const { [sessionId]: _, ...rest } = s.sessions + return { sessions: rest } + }) }, - sendMessage: (content: string, attachments?: AttachmentRef[]) => { + sendMessage: (sessionId, content, attachments?) => { const userFacingContent = content.trim() const uiAttachments: UIAttachment[] | undefined = attachments && attachments.length > 0 - ? attachments.map((attachment) => ({ - type: attachment.type, - name: attachment.name || attachment.path || attachment.mimeType || attachment.type, - data: attachment.data, - mimeType: attachment.mimeType, + ? attachments.map((a) => ({ + type: a.type, + name: a.name || a.path || a.mimeType || a.type, + data: a.data, + mimeType: a.mimeType, })) : undefined - // If all tasks are completed, inline the task summary before the new user message const taskStore = useCLITaskStore.getState() const allTasksDone = taskStore.tasks.length > 0 && taskStore.tasks.every((t) => t.status === 'completed') - // Add user message to UI (with optional task summary before it) set((s) => { - const newMessages = [...s.messages] + const session = s.sessions[sessionId] + if (!session) return s + + const newMessages = [...session.messages] if (allTasksDone) { newMessages.push({ id: nextId(), type: 'task_summary', - tasks: taskStore.tasks.map((t) => ({ - id: t.id, - subject: t.subject, - status: t.status, - activeForm: t.activeForm, - })), + tasks: taskStore.tasks.map((t) => ({ id: t.id, subject: t.subject, status: t.status, activeForm: t.activeForm })), timestamp: Date.now(), }) - // Clear sticky task bar since we inlined the summary taskStore.clearTasks() } newMessages.push({ @@ -166,64 +177,66 @@ export const useChatStore = create((set, get) => ({ attachments: uiAttachments, timestamp: Date.now(), }) + + if (session.elapsedTimer) clearInterval(session.elapsedTimer) + + const timer = setInterval(() => { + set((st) => ({ sessions: updateSessionIn(st.sessions, sessionId, (sess) => ({ elapsedSeconds: sess.elapsedSeconds + 1 })) })) + }, 1000) + return { - messages: newMessages, - chatState: 'thinking', - elapsedSeconds: 0, - streamingText: '', - statusVerb: randomSpinnerVerb(), + sessions: { + ...s.sessions, + [sessionId]: { + ...session, + messages: newMessages, + chatState: 'thinking', + elapsedSeconds: 0, + streamingText: '', + statusVerb: randomSpinnerVerb(), + elapsedTimer: timer, + }, + }, } }) - // Start elapsed timer - if (elapsedTimer) clearInterval(elapsedTimer) - elapsedTimer = setInterval(() => { - set((s) => ({ elapsedSeconds: s.elapsedSeconds + 1 })) - }, 1000) - - wsManager.send({ type: 'user_message', content, attachments }) + wsManager.send(sessionId, { type: 'user_message', content, attachments }) }, - respondToPermission: (requestId: string, allowed: boolean, rule?: string) => { - wsManager.send({ type: 'permission_response', requestId, allowed, ...(rule ? { rule } : {}) }) - set({ pendingPermission: null, chatState: allowed ? 'tool_executing' : 'idle' }) + respondToPermission: (sessionId, requestId, allowed, rule?) => { + wsManager.send(sessionId, { type: 'permission_response', requestId, allowed, ...(rule ? { rule } : {}) }) + set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ pendingPermission: null, chatState: allowed ? 'tool_executing' : 'idle' })) })) }, - setSessionPermissionMode: (mode) => { - if (!get().connectedSessionId) return - wsManager.send({ type: 'set_permission_mode', mode }) + setSessionPermissionMode: (sessionId, mode) => { + if (!get().sessions[sessionId]) return + wsManager.send(sessionId, { type: 'set_permission_mode', mode }) }, - stopGeneration: () => { - wsManager.send({ type: 'stop_generation' }) - if (elapsedTimer) clearInterval(elapsedTimer) - set({ chatState: 'idle' }) + stopGeneration: (sessionId) => { + wsManager.send(sessionId, { type: 'stop_generation' }) + set((s) => { + const session = s.sessions[sessionId] + if (!session) return s + if (session.elapsedTimer) clearInterval(session.elapsedTimer) + return { sessions: { ...s.sessions, [sessionId]: { ...session, chatState: 'idle', elapsedTimer: null } } } + }) }, - loadHistory: async (sessionId: string) => { + loadHistory: async (sessionId) => { try { const { messages } = await sessionsApi.getMessages(sessionId) const uiMessages = mapHistoryMessagesToUiMessages(messages) - set((state) => { - if (state.connectedSessionId !== sessionId || state.messages.length > 0) { - return state - } - return { ...state, messages: uiMessages } + const session = state.sessions[sessionId] + if (!session || session.messages.length > 0) return state + return { sessions: updateSessionIn(state.sessions, sessionId, () => ({ messages: uiMessages })) } }) - - // Extract the last TodoWrite input from history so TaskBar shows for V1 sessions const lastTodos = extractLastTodoWriteFromHistory(messages) if (lastTodos && lastTodos.length > 0) { const taskStore = useCLITaskStore.getState() - // Only set if V2 task fetch didn't already populate tasks - if (taskStore.tasks.length === 0) { - taskStore.setTasksFromTodos(lastTodos) - } + if (taskStore.tasks.length === 0) taskStore.setTasksFromTodos(lastTodos) } - - // For both V1 and V2: if all tasks completed and the user already - // continued chatting, suppress the sticky TaskBar on page refresh. if (hasUserMessagesAfterTaskCompletion(messages)) { useCLITaskStore.getState().markCompletedAndDismissed() } @@ -232,89 +245,79 @@ export const useChatStore = create((set, get) => ({ } }, - clearMessages: () => set({ messages: [], streamingText: '', chatState: 'idle' }), + clearMessages: (sessionId) => { + set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ messages: [], streamingText: '', chatState: 'idle' })) })) + }, + + handleServerMessage: (sessionId, msg) => { + const update = (updater: (session: PerSessionState) => Partial) => { + set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, updater) })) + } - handleServerMessage: (msg: ServerMessage) => { switch (msg.type) { case 'connected': break case 'status': - set({ + update((session) => ({ chatState: msg.state, - // Only override statusVerb if the server sends something other than - // the generic 'Thinking' — otherwise keep the random verb we picked - // in sendMessage so the user sees fun loading text. ...(msg.verb && msg.verb !== 'Thinking' ? { statusVerb: msg.verb } : {}), - ...(msg.tokens ? { tokenUsage: { ...get().tokenUsage, output_tokens: msg.tokens } } : {}), + ...(msg.tokens ? { tokenUsage: { ...session.tokenUsage, output_tokens: msg.tokens } } : {}), ...(msg.state === 'idle' ? { activeThinkingId: null, statusVerb: '' } : {}), - }) - if (msg.state === 'idle' && elapsedTimer) { - clearInterval(elapsedTimer) - elapsedTimer = null + })) + if (msg.state === 'idle') { + const session = get().sessions[sessionId] + if (session?.elapsedTimer) { + clearInterval(session.elapsedTimer) + update(() => ({ elapsedTimer: null })) + } } + // Sync tab status + useTabStore.getState().updateTabStatus(sessionId, msg.state === 'idle' ? 'idle' : 'running') break case 'content_start': { - // Flush any accumulated streamingText as assistant_text BEFORE - // switching to the next block. This preserves intermediate text - // segments between tool calls (e.g. "项目已经有 node_modules,直接启动"). - const pendingText = get().streamingText.trim() + const session = get().sessions[sessionId] + if (!session) break + const pendingText = session.streamingText.trim() if (pendingText) { - set((s) => ({ - messages: [...s.messages, { - id: nextId(), - type: 'assistant_text', - content: pendingText, - timestamp: Date.now(), - }], + update((s) => ({ + messages: [...s.messages, { id: nextId(), type: 'assistant_text' as const, content: pendingText, timestamp: Date.now() }], streamingText: '', })) } - if (msg.blockType === 'text') { - set({ streamingText: '', chatState: 'streaming', activeThinkingId: null }) + update(() => ({ streamingText: '', chatState: 'streaming', activeThinkingId: null })) } else if (msg.blockType === 'tool_use') { - set({ + update(() => ({ activeToolUseId: msg.toolUseId ?? null, activeToolName: msg.toolName ?? null, streamingToolInput: '', chatState: 'tool_executing', activeThinkingId: null, - }) + })) } break } case 'content_delta': - if (msg.text !== undefined) { - set((s) => ({ streamingText: s.streamingText + msg.text })) - } - if (msg.toolInput !== undefined) { - set((s) => ({ streamingToolInput: s.streamingToolInput + msg.toolInput })) - } + if (msg.text !== undefined) update((s) => ({ streamingText: s.streamingText + msg.text })) + if (msg.toolInput !== undefined) update((s) => ({ streamingToolInput: s.streamingToolInput + msg.toolInput })) break case 'thinking': - // Merge consecutive thinking deltas into one message. - // Also flush any pending streamingText first — otherwise the text - // becomes invisible because MessageList only renders streamingText - // when chatState === 'streaming', and we're about to set it to 'thinking'. - set((s) => { + update((s) => { const pendingText = s.streamingText.trim() const base = pendingText ? [...s.messages, { id: nextId(), type: 'assistant_text' as const, content: pendingText, timestamp: Date.now() }] : s.messages - const last = base[base.length - 1] if (last && last.type === 'thinking') { - // Append to existing thinking message const updated = [...base] updated[updated.length - 1] = { ...last, content: last.content + msg.text } return { messages: updated, chatState: 'thinking', activeThinkingId: last.id, streamingText: '' } } const id = nextId() - // Create new thinking message return { messages: [...base, { id, type: 'thinking', content: msg.text, timestamp: Date.now() }], chatState: 'thinking', @@ -325,49 +328,33 @@ export const useChatStore = create((set, get) => ({ break case 'tool_use_complete': { - const toolName = msg.toolName || get().activeToolName || 'unknown' - set((s) => ({ + const session = get().sessions[sessionId] + const toolName = msg.toolName || session?.activeToolName || 'unknown' + update((s) => ({ messages: [...s.messages, { - id: nextId(), - type: 'tool_use', - toolName, + id: nextId(), type: 'tool_use', toolName, toolUseId: msg.toolUseId || s.activeToolUseId || '', - input: msg.input, - timestamp: Date.now(), - parentToolUseId: msg.parentToolUseId, + input: msg.input, timestamp: Date.now(), parentToolUseId: msg.parentToolUseId, }], - activeToolUseId: null, - activeToolName: null, - activeThinkingId: null, - streamingToolInput: '', + activeToolUseId: null, activeToolName: null, activeThinkingId: null, streamingToolInput: '', })) - // TodoWrite: input contains the full todo list — update tasks immediately if (toolName === 'TodoWrite' && Array.isArray((msg.input as any)?.todos)) { useCLITaskStore.getState().setTasksFromTodos((msg.input as any).todos) } else if (TASK_TOOL_NAMES.has(toolName)) { - // V2 task tools — refresh will happen on tool_result - // when the tool has actually finished executing and written to disk - const useId = msg.toolUseId || get().activeToolUseId + const useId = msg.toolUseId || session?.activeToolUseId if (useId) pendingTaskToolUseIds.add(useId) } break } case 'tool_result': - set((s) => ({ + update((s) => ({ messages: [...s.messages, { - id: nextId(), - type: 'tool_result', - toolUseId: msg.toolUseId, - content: msg.content, - isError: msg.isError, - timestamp: Date.now(), - parentToolUseId: msg.parentToolUseId, + id: nextId(), type: 'tool_result', toolUseId: msg.toolUseId, + content: msg.content, isError: msg.isError, timestamp: Date.now(), parentToolUseId: msg.parentToolUseId, }], - chatState: 'thinking', - activeThinkingId: null, + chatState: 'thinking', activeThinkingId: null, })) - // Refresh tasks after a task tool has finished executing if (pendingTaskToolUseIds.has(msg.toolUseId)) { pendingTaskToolUseIds.delete(msg.toolUseId) useCLITaskStore.getState().refreshTasks() @@ -375,230 +362,118 @@ export const useChatStore = create((set, get) => ({ break case 'permission_request': - set({ - pendingPermission: { - requestId: msg.requestId, - toolName: msg.toolName, - input: msg.input, - description: msg.description, - }, + update((s) => ({ + pendingPermission: { requestId: msg.requestId, toolName: msg.toolName, input: msg.input, description: msg.description }, chatState: 'permission_pending', activeThinkingId: null, - }) - set((s) => ({ messages: [...s.messages, { - id: nextId(), - type: 'permission_request', - requestId: msg.requestId, - toolName: msg.toolName, - input: msg.input, - description: msg.description, - timestamp: Date.now(), + id: nextId(), type: 'permission_request', requestId: msg.requestId, + toolName: msg.toolName, input: msg.input, description: msg.description, timestamp: Date.now(), }], })) break case 'message_complete': { - // Flush streaming text as a message - const text = get().streamingText + const session = get().sessions[sessionId] + if (!session) break + const text = session.streamingText if (text) { - set((s) => ({ + update((s) => ({ messages: [...s.messages, { id: nextId(), type: 'assistant_text', content: text, timestamp: Date.now() }], streamingText: '', })) } - set({ tokenUsage: msg.usage, chatState: 'idle', activeThinkingId: null }) - if (elapsedTimer) { - clearInterval(elapsedTimer) - elapsedTimer = null - } + if (session.elapsedTimer) clearInterval(session.elapsedTimer) + update(() => ({ tokenUsage: msg.usage, chatState: 'idle', activeThinkingId: null, elapsedTimer: null })) break } case 'error': - set((s) => ({ + update((s) => ({ messages: [...s.messages, { id: nextId(), type: 'error', message: msg.message, code: msg.code, timestamp: Date.now() }], - chatState: 'idle', - activeThinkingId: null, + chatState: 'idle', activeThinkingId: null, })) - if (elapsedTimer) { - clearInterval(elapsedTimer) - elapsedTimer = null + useTabStore.getState().updateTabStatus(sessionId, 'error') + { + const session = get().sessions[sessionId] + if (session?.elapsedTimer) { + clearInterval(session.elapsedTimer) + update(() => ({ elapsedTimer: null })) + } } break case 'team_created': useTeamStore.getState().handleTeamCreated(msg.teamName) break - case 'team_update': useTeamStore.getState().handleTeamUpdate(msg.teamName, msg.members) break - case 'team_deleted': useTeamStore.getState().handleTeamDeleted(msg.teamName) break - case 'task_update': break - case 'session_title_updated': useSessionStore.getState().updateSessionTitle(msg.sessionId, msg.title) + useTabStore.getState().updateTabTitle(msg.sessionId, msg.title) break - case 'system_notification': - // Cache slash commands from CLI init if (msg.subtype === 'slash_commands' && Array.isArray(msg.data)) { - set({ slashCommands: msg.data as Array<{ name: string; description: string }> }) + update(() => ({ slashCommands: msg.data as Array<{ name: string; description: string }> })) } break - case 'pong': break } }, })) -type AssistantHistoryBlock = { - type: string - text?: string - thinking?: string - name?: string - id?: string - input?: unknown -} +// ─── History mapping helpers (unchanged from original) ───────── -type UserHistoryBlock = { - type: string - text?: string - tool_use_id?: string - content?: unknown - is_error?: boolean - source?: { data?: string } - mimeType?: string - media_type?: string - name?: string -} +type AssistantHistoryBlock = { type: string; text?: string; thinking?: string; name?: string; id?: string; input?: unknown } +type UserHistoryBlock = { type: string; text?: string; tool_use_id?: string; content?: unknown; is_error?: boolean; source?: { data?: string }; mimeType?: string; media_type?: string; name?: string } export function mapHistoryMessagesToUiMessages(messages: MessageEntry[]): UIMessage[] { const uiMessages: UIMessage[] = [] - for (const msg of messages) { const timestamp = new Date(msg.timestamp).getTime() - if (msg.type === 'user' && typeof msg.content === 'string') { - uiMessages.push({ - id: msg.id || nextId(), - type: 'user_text', - content: msg.content, - timestamp, - }) + uiMessages.push({ id: msg.id || nextId(), type: 'user_text', content: msg.content, timestamp }) continue } - if (msg.type === 'assistant' && typeof msg.content === 'string') { - uiMessages.push({ - id: msg.id || nextId(), - type: 'assistant_text', - content: msg.content, - timestamp, - model: msg.model, - }) + uiMessages.push({ id: msg.id || nextId(), type: 'assistant_text', content: msg.content, timestamp, model: msg.model }) continue } - - // Server marks assistant messages containing tool_use blocks as type 'tool_use', - // but the content array structure is the same as 'assistant' — handle both. if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) { for (const block of msg.content as AssistantHistoryBlock[]) { - if (block.type === 'thinking' && block.thinking) { - uiMessages.push({ - id: nextId(), - type: 'thinking', - content: block.thinking, - timestamp, - }) - } else if (block.type === 'text' && block.text) { - uiMessages.push({ - id: nextId(), - type: 'assistant_text', - content: block.text, - timestamp, - model: msg.model, - }) - } else if (block.type === 'tool_use') { - uiMessages.push({ - id: nextId(), - type: 'tool_use', - toolName: block.name ?? 'unknown', - toolUseId: block.id ?? '', - input: block.input, - timestamp, - parentToolUseId: msg.parentToolUseId, - }) - } + if (block.type === 'thinking' && block.thinking) uiMessages.push({ id: nextId(), type: 'thinking', content: block.thinking, timestamp }) + else if (block.type === 'text' && block.text) uiMessages.push({ id: nextId(), type: 'assistant_text', content: block.text, timestamp, model: msg.model }) + else if (block.type === 'tool_use') uiMessages.push({ id: nextId(), type: 'tool_use', toolName: block.name ?? 'unknown', toolUseId: block.id ?? '', input: block.input, timestamp, parentToolUseId: msg.parentToolUseId }) } continue } - - // Server marks user messages containing tool_result blocks as type 'tool_result', - // but the content array structure is the same as 'user' — handle both. if ((msg.type === 'user' || msg.type === 'tool_result') && Array.isArray(msg.content)) { const textParts: string[] = [] const attachments: UIAttachment[] = [] - for (const block of msg.content as UserHistoryBlock[]) { - if (block.type === 'text' && block.text) { - textParts.push(block.text) - } else if (block.type === 'image') { - attachments.push({ - type: 'image', - name: block.name || 'image', - data: block.source?.data, - mimeType: block.mimeType || block.media_type, - }) - } else if (block.type === 'file') { - attachments.push({ - type: 'file', - name: block.name || 'file', - }) - } else if (block.type === 'tool_result') { - uiMessages.push({ - id: nextId(), - type: 'tool_result', - toolUseId: block.tool_use_id ?? '', - content: block.content, - isError: !!block.is_error, - timestamp, - parentToolUseId: msg.parentToolUseId, - }) - } + if (block.type === 'text' && block.text) textParts.push(block.text) + else if (block.type === 'image') attachments.push({ type: 'image', name: block.name || 'image', data: block.source?.data, mimeType: block.mimeType || block.media_type }) + else if (block.type === 'file') attachments.push({ type: 'file', name: block.name || 'file' }) + else if (block.type === 'tool_result') uiMessages.push({ id: nextId(), type: 'tool_result', toolUseId: block.tool_use_id ?? '', content: block.content, isError: !!block.is_error, timestamp, parentToolUseId: msg.parentToolUseId }) } - if (textParts.length > 0 || attachments.length > 0) { - uiMessages.push({ - id: nextId(), - type: 'user_text', - content: textParts.join('\n'), - attachments: attachments.length > 0 ? attachments : undefined, - timestamp, - }) + uiMessages.push({ id: nextId(), type: 'user_text', content: textParts.join('\n'), attachments: attachments.length > 0 ? attachments : undefined, timestamp }) } } } - return uiMessages } -/** Scan history messages for the last TodoWrite tool_use and return the todos array. - * Returns null if all todos were completed and the user continued chatting after. */ -function extractLastTodoWriteFromHistory( - messages: MessageEntry[], -): Array<{ content: string; status: string; activeForm?: string }> | null { +function extractLastTodoWriteFromHistory(messages: MessageEntry[]): Array<{ content: string; status: string; activeForm?: string }> | null { let foundIndex = -1 let todos: Array<{ content: string; status: string; activeForm?: string }> | null = null - - // Walk backwards to find the most recent TodoWrite for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]! if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) { @@ -617,45 +492,28 @@ function extractLastTodoWriteFromHistory( if (todos) break } } - if (!todos) return null - - // If all todos are completed and there are user messages after, the tasks - // were already inlined — don't re-show the sticky bar. const allDone = todos.every((t) => t.status === 'completed') if (allDone) { for (let i = foundIndex + 1; i < messages.length; i++) { - const msg = messages[i]! - if (msg.type === 'user' && msg.content) return null + if (messages[i]!.type === 'user' && messages[i]!.content) return null } } - return todos } const TASK_RELATED_TOOL_NAMES = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList']) -/** Check if there are user messages after the last task-related tool call. - * Used to determine if completed tasks should be shown as sticky or inline. */ function hasUserMessagesAfterTaskCompletion(messages: MessageEntry[]): boolean { - // Find the index of the last task-related tool call let lastTaskIndex = -1 for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]! if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) { const blocks = msg.content as AssistantHistoryBlock[] - if (blocks.some((b) => b.type === 'tool_use' && TASK_RELATED_TOOL_NAMES.has(b.name ?? ''))) { - lastTaskIndex = i - break - } + if (blocks.some((b) => b.type === 'tool_use' && TASK_RELATED_TOOL_NAMES.has(b.name ?? ''))) { lastTaskIndex = i; break } } } - if (lastTaskIndex < 0) return false - - // Check for user messages after the last task tool - for (let i = lastTaskIndex + 1; i < messages.length; i++) { - if (messages[i]!.type === 'user') return true - } + for (let i = lastTaskIndex + 1; i < messages.length; i++) { if (messages[i]!.type === 'user') return true } return false } diff --git a/desktop/src/stores/tabStore.ts b/desktop/src/stores/tabStore.ts new file mode 100644 index 00000000..3c531e6d --- /dev/null +++ b/desktop/src/stores/tabStore.ts @@ -0,0 +1,128 @@ +import { create } from 'zustand' +import { sessionsApi } from '../api/sessions' + +const TAB_STORAGE_KEY = 'cc-haha-open-tabs' + +export type Tab = { + sessionId: string + title: string + status: 'idle' | 'running' | 'error' +} + +type TabPersistence = { + openTabs: Array<{ sessionId: string; title: string }> + activeTabId: string | null +} + +type TabStore = { + tabs: Tab[] + activeTabId: string | null + + openTab: (sessionId: string, title: string) => void + closeTab: (sessionId: string) => void + setActiveTab: (sessionId: string) => void + updateTabTitle: (sessionId: string, title: string) => void + updateTabStatus: (sessionId: string, status: Tab['status']) => void + + saveTabs: () => void + restoreTabs: () => Promise +} + +export const useTabStore = create((set, get) => ({ + tabs: [], + activeTabId: null, + + openTab: (sessionId, title) => { + const { tabs } = get() + const existing = tabs.find((t) => t.sessionId === sessionId) + if (existing) { + set({ activeTabId: sessionId }) + } else { + set({ + tabs: [...tabs, { sessionId, title, status: 'idle' }], + activeTabId: sessionId, + }) + } + get().saveTabs() + }, + + closeTab: (sessionId) => { + const { tabs, activeTabId } = get() + const index = tabs.findIndex((t) => t.sessionId === sessionId) + if (index < 0) return + + const newTabs = tabs.filter((t) => t.sessionId !== sessionId) + let newActiveId = activeTabId + + if (activeTabId === sessionId) { + if (newTabs.length === 0) { + newActiveId = null + } else if (index >= newTabs.length) { + newActiveId = newTabs[newTabs.length - 1]!.sessionId + } else { + newActiveId = newTabs[index]!.sessionId + } + } + + set({ tabs: newTabs, activeTabId: newActiveId }) + get().saveTabs() + }, + + setActiveTab: (sessionId) => { + set({ activeTabId: sessionId }) + get().saveTabs() + }, + + updateTabTitle: (sessionId, title) => { + set((s) => ({ + tabs: s.tabs.map((t) => (t.sessionId === sessionId ? { ...t, title } : t)), + })) + get().saveTabs() + }, + + updateTabStatus: (sessionId, status) => { + set((s) => ({ + tabs: s.tabs.map((t) => (t.sessionId === sessionId ? { ...t, status } : t)), + })) + }, + + saveTabs: () => { + const { tabs, activeTabId } = get() + const data: TabPersistence = { + openTabs: tabs.map((t) => ({ sessionId: t.sessionId, title: t.title })), + activeTabId, + } + try { + localStorage.setItem(TAB_STORAGE_KEY, JSON.stringify(data)) + } catch { /* noop */ } + }, + + restoreTabs: async () => { + try { + const raw = localStorage.getItem(TAB_STORAGE_KEY) + if (!raw) return + + const data = JSON.parse(raw) as TabPersistence + if (!data.openTabs || data.openTabs.length === 0) return + + const { sessions } = await sessionsApi.list({ limit: 200 }) + const existingIds = new Set(sessions.map((s) => s.id)) + + const validTabs: Tab[] = data.openTabs + .filter((t) => existingIds.has(t.sessionId)) + .map((t) => ({ + sessionId: t.sessionId, + title: sessions.find((s) => s.id === t.sessionId)?.title || t.title, + status: 'idle' as const, + })) + + if (validTabs.length === 0) return + + const activeId = data.activeTabId && validTabs.some((t) => t.sessionId === data.activeTabId) + ? data.activeTabId + : validTabs[0]!.sessionId + + set({ tabs: validTabs, activeTabId: activeId }) + } catch { /* noop */ } + }, +})) diff --git a/docs/superpowers/plans/2026-04-09-multi-tab-sessions.md b/docs/superpowers/plans/2026-04-09-multi-tab-sessions.md new file mode 100644 index 00000000..0af6a9f3 --- /dev/null +++ b/docs/superpowers/plans/2026-04-09-multi-tab-sessions.md @@ -0,0 +1,1814 @@ +# Multi-Tab Sessions Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Desktop webapp 支持多 Tab 并行 Session,每个 Tab 独立 WebSocket 连接和消息状态。 + +**Architecture:** 新增 tabStore 管理 Tab 状态;将 chatStore 从单 session 改为 per-session Map;将 wsManager 从单例连接改为多连接管理器;新增 TabBar 组件放在内容区域顶部;修改 Sidebar 点击行为为 openTab 模式。 + +**Tech Stack:** React 18, Zustand 5, Tailwind CSS 4, TypeScript, Vite + +--- + +### Task 1: WebSocket Manager 多连接重构 + +**Files:** +- Modify: `desktop/src/api/websocket.ts` + +- [ ] **Step 1: 重构 WebSocketManager 为多连接管理器** + +将单例 WS 连接改为 `Map` 模式。每个方法都以 sessionId 为 key。 + +```ts +import type { ClientMessage, ServerMessage } from '../types/chat' +import { getBaseUrl } from './client' + +type MessageHandler = (msg: ServerMessage) => void + +type Connection = { + ws: WebSocket + handlers: Set + reconnectTimer: ReturnType | null + reconnectAttempt: number + pingInterval: ReturnType | null + intentionalClose: boolean + pendingMessages: ClientMessage[] +} + +class WebSocketManager { + private connections = new Map() + + isConnected(sessionId: string): boolean { + const conn = this.connections.get(sessionId) + return conn?.ws.readyState === WebSocket.OPEN + } + + getConnectedSessionIds(): string[] { + return [...this.connections.keys()] + } + + connect(sessionId: string) { + // Already connected or connecting + const existing = this.connections.get(sessionId) + if (existing && !existing.intentionalClose) return + + const conn: Connection = { + ws: null as unknown as WebSocket, + handlers: new Set(), + reconnectTimer: null, + reconnectAttempt: 0, + pingInterval: null, + intentionalClose: false, + pendingMessages: [], + } + this.connections.set(sessionId, conn) + + const wsUrl = getBaseUrl().replace(/^http/, 'ws') + conn.ws = new WebSocket(`${wsUrl}/ws/${sessionId}`) + + conn.ws.onopen = () => { + conn.reconnectAttempt = 0 + this.startPingLoop(sessionId) + while (conn.pendingMessages.length > 0) { + const msg = conn.pendingMessages.shift()! + conn.ws.send(JSON.stringify(msg)) + } + } + + conn.ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data as string) as ServerMessage + for (const handler of conn.handlers) { + handler(msg) + } + } catch { + // Ignore malformed messages + } + } + + conn.ws.onclose = () => { + this.stopPingLoop(sessionId) + if (!conn.intentionalClose) { + this.scheduleReconnect(sessionId) + } + } + + conn.ws.onerror = () => { + // onclose will fire after onerror + } + } + + disconnect(sessionId: string) { + const conn = this.connections.get(sessionId) + if (!conn) return + + conn.intentionalClose = true + this.stopPingLoop(sessionId) + this.clearReconnect(sessionId) + conn.pendingMessages = [] + + if (conn.ws) { + conn.ws.close() + } + this.connections.delete(sessionId) + } + + disconnectAll() { + for (const sessionId of [...this.connections.keys()]) { + this.disconnect(sessionId) + } + } + + send(sessionId: string, message: ClientMessage) { + const conn = this.connections.get(sessionId) + if (!conn) return + + if (conn.ws.readyState === WebSocket.OPEN) { + conn.ws.send(JSON.stringify(message)) + } else if (conn.ws.readyState === WebSocket.CONNECTING) { + conn.pendingMessages.push(message) + } + } + + onMessage(sessionId: string, handler: MessageHandler): () => void { + const conn = this.connections.get(sessionId) + if (!conn) return () => {} + conn.handlers.add(handler) + return () => { conn.handlers.delete(handler) } + } + + clearHandlers(sessionId: string) { + const conn = this.connections.get(sessionId) + if (conn) conn.handlers.clear() + } + + private startPingLoop(sessionId: string) { + this.stopPingLoop(sessionId) + const conn = this.connections.get(sessionId) + if (!conn) return + conn.pingInterval = setInterval(() => { + this.send(sessionId, { type: 'ping' }) + }, 30_000) + } + + private stopPingLoop(sessionId: string) { + const conn = this.connections.get(sessionId) + if (conn?.pingInterval) { + clearInterval(conn.pingInterval) + conn.pingInterval = null + } + } + + private scheduleReconnect(sessionId: string) { + this.clearReconnect(sessionId) + const conn = this.connections.get(sessionId) + if (!conn) return + + const delay = Math.min(1000 * 2 ** conn.reconnectAttempt, 30_000) + conn.reconnectAttempt++ + + conn.reconnectTimer = setTimeout(() => { + if (this.connections.has(sessionId) && !conn.intentionalClose) { + // Re-create connection + this.connections.delete(sessionId) + this.connect(sessionId) + // Re-register handlers from old connection + const newConn = this.connections.get(sessionId) + if (newConn) { + for (const handler of conn.handlers) { + newConn.handlers.add(handler) + } + } + } + }, delay) + } + + private clearReconnect(sessionId: string) { + const conn = this.connections.get(sessionId) + if (conn?.reconnectTimer) { + clearTimeout(conn.reconnectTimer) + conn.reconnectTimer = null + } + } +} + +export const wsManager = new WebSocketManager() +``` + +- [ ] **Step 2: 验证编译通过** + +Run: `cd /Users/nanmi/workspace/myself_code/claude-code-haha/desktop && npx tsc --noEmit 2>&1 | head -30` +Expected: chatStore.ts 会有类型错误(因为调用方式变了),这是预期的,Task 2 会修复。 + +- [ ] **Step 3: Commit** + +```bash +git add desktop/src/api/websocket.ts +git commit -m "refactor: websocket manager supports multiple concurrent connections" +``` + +--- + +### Task 2: chatStore 多 Session 状态隔离 + +**Files:** +- Modify: `desktop/src/stores/chatStore.ts` + +- [ ] **Step 1: 重构 chatStore 为 per-session 状态管理** + +将 `messages`, `chatState`, `streamingText` 等状态从顶层单一值改为按 sessionId 隔离的 Map 结构。 + +```ts +import { create } from 'zustand' +import { wsManager } from '../api/websocket' +import { sessionsApi } from '../api/sessions' +import { useTeamStore } from './teamStore' +import { useSessionStore } from './sessionStore' +import { useCLITaskStore } from './cliTaskStore' +import { randomSpinnerVerb } from '../config/spinnerVerbs' +import type { MessageEntry } from '../types/session' +import type { PermissionMode } from '../types/settings' +import type { AttachmentRef, ChatState, UIAttachment, UIMessage, ServerMessage, TokenUsage } from '../types/chat' + +type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' + +export type PerSessionState = { + messages: UIMessage[] + chatState: ChatState + connectionState: ConnectionState + streamingText: string + streamingToolInput: string + activeToolUseId: string | null + activeToolName: string | null + activeThinkingId: string | null + pendingPermission: { + requestId: string + toolName: string + input: unknown + description?: string + } | null + tokenUsage: TokenUsage + elapsedSeconds: number + statusVerb: string + slashCommands: Array<{ name: string; description: string }> + elapsedTimer: ReturnType | null +} + +function createDefaultSessionState(): PerSessionState { + return { + messages: [], + chatState: 'idle', + connectionState: 'disconnected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + elapsedTimer: null, + } +} + +type ChatStore = { + sessions: Record + + // Actions — all scoped by sessionId + connectToSession: (sessionId: string) => void + disconnectSession: (sessionId: string) => void + sendMessage: (sessionId: string, content: string, attachments?: AttachmentRef[]) => void + respondToPermission: (sessionId: string, requestId: string, allowed: boolean, rule?: string) => void + setSessionPermissionMode: (sessionId: string, mode: PermissionMode) => void + stopGeneration: (sessionId: string) => void + loadHistory: (sessionId: string) => Promise + clearMessages: (sessionId: string) => void + handleServerMessage: (sessionId: string, msg: ServerMessage) => void + + // Helpers + getSession: (sessionId: string) => PerSessionState +} + +const TASK_TOOL_NAMES = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TodoWrite']) +const pendingTaskToolUseIds = new Set() + +let msgCounter = 0 +const nextId = () => `msg-${++msgCounter}-${Date.now()}` + +export const useChatStore = create((set, get) => ({ + sessions: {}, + + getSession: (sessionId: string): PerSessionState => { + return get().sessions[sessionId] ?? createDefaultSessionState() + }, + + connectToSession: (sessionId: string) => { + const existing = get().sessions[sessionId] + if (existing && existing.connectionState !== 'disconnected') return + + // Initialize session state + set((s) => ({ + sessions: { + ...s.sessions, + [sessionId]: { + ...createDefaultSessionState(), + connectionState: 'connecting', + // Preserve messages if reconnecting + messages: existing?.messages ?? [], + }, + }, + })) + + wsManager.clearHandlers(sessionId) + wsManager.connect(sessionId) + wsManager.onMessage(sessionId, (msg) => { + if (msg.type === 'connected') { + set((s) => ({ + sessions: { + ...s.sessions, + [sessionId]: { ...s.sessions[sessionId]!, connectionState: 'connected' }, + }, + })) + } + get().handleServerMessage(sessionId, msg) + }) + + // Load history and tasks + get().loadHistory(sessionId) + useCLITaskStore.getState().fetchSessionTasks(sessionId) + sessionsApi.getSlashCommands(sessionId) + .then(({ commands }) => { + const current = get().sessions[sessionId] + if (current) { + set((s) => ({ + sessions: { + ...s.sessions, + [sessionId]: { ...s.sessions[sessionId]!, slashCommands: commands }, + }, + })) + } + }) + .catch(() => { + const current = get().sessions[sessionId] + if (current) { + set((s) => ({ + sessions: { + ...s.sessions, + [sessionId]: { ...s.sessions[sessionId]!, slashCommands: [] }, + }, + })) + } + }) + }, + + disconnectSession: (sessionId: string) => { + const session = get().sessions[sessionId] + if (session?.elapsedTimer) clearInterval(session.elapsedTimer) + + wsManager.disconnect(sessionId) + + set((s) => { + const { [sessionId]: _, ...rest } = s.sessions + return { sessions: rest } + }) + }, + + sendMessage: (sessionId: string, content: string, attachments?: AttachmentRef[]) => { + const userFacingContent = content.trim() + const uiAttachments: UIAttachment[] | undefined = + attachments && attachments.length > 0 + ? attachments.map((attachment) => ({ + type: attachment.type, + name: attachment.name || attachment.path || attachment.mimeType || attachment.type, + data: attachment.data, + mimeType: attachment.mimeType, + })) + : undefined + + const taskStore = useCLITaskStore.getState() + const allTasksDone = taskStore.tasks.length > 0 && taskStore.tasks.every((t) => t.status === 'completed') + + set((s) => { + const session = s.sessions[sessionId] + if (!session) return s + + const newMessages = [...session.messages] + if (allTasksDone) { + newMessages.push({ + id: nextId(), + type: 'task_summary', + tasks: taskStore.tasks.map((t) => ({ + id: t.id, + subject: t.subject, + status: t.status, + activeForm: t.activeForm, + })), + timestamp: Date.now(), + }) + taskStore.clearTasks() + } + newMessages.push({ + id: nextId(), + type: 'user_text', + content: userFacingContent, + attachments: uiAttachments, + timestamp: Date.now(), + }) + + // Clear old timer + if (session.elapsedTimer) clearInterval(session.elapsedTimer) + + // Start new elapsed timer + const timer = setInterval(() => { + set((st) => { + const sess = st.sessions[sessionId] + if (!sess) return st + return { + sessions: { + ...st.sessions, + [sessionId]: { ...sess, elapsedSeconds: sess.elapsedSeconds + 1 }, + }, + } + }) + }, 1000) + + return { + sessions: { + ...s.sessions, + [sessionId]: { + ...session, + messages: newMessages, + chatState: 'thinking', + elapsedSeconds: 0, + streamingText: '', + statusVerb: randomSpinnerVerb(), + elapsedTimer: timer, + }, + }, + } + }) + + wsManager.send(sessionId, { type: 'user_message', content, attachments }) + }, + + respondToPermission: (sessionId, requestId, allowed, rule?) => { + wsManager.send(sessionId, { type: 'permission_response', requestId, allowed, ...(rule ? { rule } : {}) }) + set((s) => { + const session = s.sessions[sessionId] + if (!session) return s + return { + sessions: { + ...s.sessions, + [sessionId]: { ...session, pendingPermission: null, chatState: allowed ? 'tool_executing' : 'idle' }, + }, + } + }) + }, + + setSessionPermissionMode: (sessionId, mode) => { + if (!get().sessions[sessionId]) return + wsManager.send(sessionId, { type: 'set_permission_mode', mode }) + }, + + stopGeneration: (sessionId) => { + wsManager.send(sessionId, { type: 'stop_generation' }) + set((s) => { + const session = s.sessions[sessionId] + if (!session) return s + if (session.elapsedTimer) clearInterval(session.elapsedTimer) + return { + sessions: { + ...s.sessions, + [sessionId]: { ...session, chatState: 'idle', elapsedTimer: null }, + }, + } + }) + }, + + loadHistory: async (sessionId: string) => { + try { + const { messages } = await sessionsApi.getMessages(sessionId) + const uiMessages = mapHistoryMessagesToUiMessages(messages) + + set((state) => { + const session = state.sessions[sessionId] + if (!session || session.messages.length > 0) return state + return { + sessions: { + ...state.sessions, + [sessionId]: { ...session, messages: uiMessages }, + }, + } + }) + + const lastTodos = extractLastTodoWriteFromHistory(messages) + if (lastTodos && lastTodos.length > 0) { + const taskStore = useCLITaskStore.getState() + if (taskStore.tasks.length === 0) { + taskStore.setTasksFromTodos(lastTodos) + } + } + + if (hasUserMessagesAfterTaskCompletion(messages)) { + useCLITaskStore.getState().markCompletedAndDismissed() + } + } catch { + // Session may not have messages yet + } + }, + + clearMessages: (sessionId) => { + set((s) => { + const session = s.sessions[sessionId] + if (!session) return s + return { + sessions: { + ...s.sessions, + [sessionId]: { ...session, messages: [], streamingText: '', chatState: 'idle' }, + }, + } + }) + }, + + handleServerMessage: (sessionId: string, msg: ServerMessage) => { + const updateSession = (updater: (session: PerSessionState) => Partial) => { + set((s) => { + const session = s.sessions[sessionId] + if (!session) return s + return { + sessions: { + ...s.sessions, + [sessionId]: { ...session, ...updater(session) }, + }, + } + }) + } + + switch (msg.type) { + case 'connected': + break + + case 'status': + updateSession((session) => ({ + chatState: msg.state, + ...(msg.verb && msg.verb !== 'Thinking' ? { statusVerb: msg.verb } : {}), + ...(msg.tokens ? { tokenUsage: { ...session.tokenUsage, output_tokens: msg.tokens } } : {}), + ...(msg.state === 'idle' ? { activeThinkingId: null, statusVerb: '' } : {}), + })) + if (msg.state === 'idle') { + const session = get().sessions[sessionId] + if (session?.elapsedTimer) { + clearInterval(session.elapsedTimer) + updateSession(() => ({ elapsedTimer: null })) + } + } + break + + case 'content_start': { + const session = get().sessions[sessionId] + if (!session) break + + const pendingText = session.streamingText.trim() + if (pendingText) { + updateSession((s) => ({ + messages: [...s.messages, { + id: nextId(), + type: 'assistant_text' as const, + content: pendingText, + timestamp: Date.now(), + }], + streamingText: '', + })) + } + + if (msg.blockType === 'text') { + updateSession(() => ({ streamingText: '', chatState: 'streaming', activeThinkingId: null })) + } else if (msg.blockType === 'tool_use') { + updateSession(() => ({ + activeToolUseId: msg.toolUseId ?? null, + activeToolName: msg.toolName ?? null, + streamingToolInput: '', + chatState: 'tool_executing', + activeThinkingId: null, + })) + } + break + } + + case 'content_delta': + if (msg.text !== undefined) { + updateSession((s) => ({ streamingText: s.streamingText + msg.text })) + } + if (msg.toolInput !== undefined) { + updateSession((s) => ({ streamingToolInput: s.streamingToolInput + msg.toolInput })) + } + break + + case 'thinking': + updateSession((s) => { + const pendingText = s.streamingText.trim() + const base = pendingText + ? [...s.messages, { id: nextId(), type: 'assistant_text' as const, content: pendingText, timestamp: Date.now() }] + : s.messages + + const last = base[base.length - 1] + if (last && last.type === 'thinking') { + const updated = [...base] + updated[updated.length - 1] = { ...last, content: last.content + msg.text } + return { messages: updated, chatState: 'thinking', activeThinkingId: last.id, streamingText: '' } + } + const id = nextId() + return { + messages: [...base, { id, type: 'thinking', content: msg.text, timestamp: Date.now() }], + chatState: 'thinking', + activeThinkingId: id, + streamingText: '', + } + }) + break + + case 'tool_use_complete': { + const session = get().sessions[sessionId] + const toolName = msg.toolName || session?.activeToolName || 'unknown' + updateSession((s) => ({ + messages: [...s.messages, { + id: nextId(), + type: 'tool_use', + toolName, + toolUseId: msg.toolUseId || s.activeToolUseId || '', + input: msg.input, + timestamp: Date.now(), + parentToolUseId: msg.parentToolUseId, + }], + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + streamingToolInput: '', + })) + if (toolName === 'TodoWrite' && Array.isArray((msg.input as any)?.todos)) { + useCLITaskStore.getState().setTasksFromTodos((msg.input as any).todos) + } else if (TASK_TOOL_NAMES.has(toolName)) { + const useId = msg.toolUseId || session?.activeToolUseId + if (useId) pendingTaskToolUseIds.add(useId) + } + break + } + + case 'tool_result': + updateSession((s) => ({ + messages: [...s.messages, { + id: nextId(), + type: 'tool_result', + toolUseId: msg.toolUseId, + content: msg.content, + isError: msg.isError, + timestamp: Date.now(), + parentToolUseId: msg.parentToolUseId, + }], + chatState: 'thinking', + activeThinkingId: null, + })) + if (pendingTaskToolUseIds.has(msg.toolUseId)) { + pendingTaskToolUseIds.delete(msg.toolUseId) + useCLITaskStore.getState().refreshTasks() + } + break + + case 'permission_request': + updateSession((s) => ({ + pendingPermission: { + requestId: msg.requestId, + toolName: msg.toolName, + input: msg.input, + description: msg.description, + }, + chatState: 'permission_pending', + activeThinkingId: null, + messages: [...s.messages, { + id: nextId(), + type: 'permission_request', + requestId: msg.requestId, + toolName: msg.toolName, + input: msg.input, + description: msg.description, + timestamp: Date.now(), + }], + })) + break + + case 'message_complete': { + const session = get().sessions[sessionId] + if (!session) break + const text = session.streamingText + if (text) { + updateSession((s) => ({ + messages: [...s.messages, { id: nextId(), type: 'assistant_text', content: text, timestamp: Date.now() }], + streamingText: '', + })) + } + if (session.elapsedTimer) { + clearInterval(session.elapsedTimer) + } + updateSession(() => ({ tokenUsage: msg.usage, chatState: 'idle', activeThinkingId: null, elapsedTimer: null })) + break + } + + case 'error': + updateSession((s) => ({ + messages: [...s.messages, { id: nextId(), type: 'error', message: msg.message, code: msg.code, timestamp: Date.now() }], + chatState: 'idle', + activeThinkingId: null, + })) + { + const session = get().sessions[sessionId] + if (session?.elapsedTimer) { + clearInterval(session.elapsedTimer) + updateSession(() => ({ elapsedTimer: null })) + } + } + break + + case 'team_created': + useTeamStore.getState().handleTeamCreated(msg.teamName) + break + case 'team_update': + useTeamStore.getState().handleTeamUpdate(msg.teamName, msg.members) + break + case 'team_deleted': + useTeamStore.getState().handleTeamDeleted(msg.teamName) + break + case 'task_update': + break + case 'session_title_updated': + useSessionStore.getState().updateSessionTitle(msg.sessionId, msg.title) + break + case 'system_notification': + if (msg.subtype === 'slash_commands' && Array.isArray(msg.data)) { + updateSession(() => ({ slashCommands: msg.data as Array<{ name: string; description: string }> })) + } + break + case 'pong': + break + } + }, +})) + +// ─── History mapping helpers (unchanged) ───────────────────── + +type AssistantHistoryBlock = { + type: string + text?: string + thinking?: string + name?: string + id?: string + input?: unknown +} + +type UserHistoryBlock = { + type: string + text?: string + tool_use_id?: string + content?: unknown + is_error?: boolean + source?: { data?: string } + mimeType?: string + media_type?: string + name?: string +} + +export function mapHistoryMessagesToUiMessages(messages: MessageEntry[]): UIMessage[] { + const uiMessages: UIMessage[] = [] + for (const msg of messages) { + const timestamp = new Date(msg.timestamp).getTime() + if (msg.type === 'user' && typeof msg.content === 'string') { + uiMessages.push({ id: msg.id || nextId(), type: 'user_text', content: msg.content, timestamp }) + continue + } + if (msg.type === 'assistant' && typeof msg.content === 'string') { + uiMessages.push({ id: msg.id || nextId(), type: 'assistant_text', content: msg.content, timestamp, model: msg.model }) + continue + } + if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) { + for (const block of msg.content as AssistantHistoryBlock[]) { + if (block.type === 'thinking' && block.thinking) { + uiMessages.push({ id: nextId(), type: 'thinking', content: block.thinking, timestamp }) + } else if (block.type === 'text' && block.text) { + uiMessages.push({ id: nextId(), type: 'assistant_text', content: block.text, timestamp, model: msg.model }) + } else if (block.type === 'tool_use') { + uiMessages.push({ + id: nextId(), type: 'tool_use', toolName: block.name ?? 'unknown', + toolUseId: block.id ?? '', input: block.input, timestamp, parentToolUseId: msg.parentToolUseId, + }) + } + } + continue + } + if ((msg.type === 'user' || msg.type === 'tool_result') && Array.isArray(msg.content)) { + const textParts: string[] = [] + const attachments: UIAttachment[] = [] + for (const block of msg.content as UserHistoryBlock[]) { + if (block.type === 'text' && block.text) { + textParts.push(block.text) + } else if (block.type === 'image') { + attachments.push({ type: 'image', name: block.name || 'image', data: block.source?.data, mimeType: block.mimeType || block.media_type }) + } else if (block.type === 'file') { + attachments.push({ type: 'file', name: block.name || 'file' }) + } else if (block.type === 'tool_result') { + uiMessages.push({ + id: nextId(), type: 'tool_result', toolUseId: block.tool_use_id ?? '', + content: block.content, isError: !!block.is_error, timestamp, parentToolUseId: msg.parentToolUseId, + }) + } + } + if (textParts.length > 0 || attachments.length > 0) { + uiMessages.push({ + id: nextId(), type: 'user_text', content: textParts.join('\n'), + attachments: attachments.length > 0 ? attachments : undefined, timestamp, + }) + } + } + } + return uiMessages +} + +function extractLastTodoWriteFromHistory( + messages: MessageEntry[], +): Array<{ content: string; status: string; activeForm?: string }> | null { + let foundIndex = -1 + let todos: Array<{ content: string; status: string; activeForm?: string }> | null = null + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]! + if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) { + const blocks = msg.content as AssistantHistoryBlock[] + for (let j = blocks.length - 1; j >= 0; j--) { + const block = blocks[j]! + if (block.type === 'tool_use' && block.name === 'TodoWrite') { + const input = block.input as { todos?: unknown } | undefined + if (input && Array.isArray(input.todos)) { + todos = input.todos as Array<{ content: string; status: string; activeForm?: string }> + foundIndex = i + break + } + } + } + if (todos) break + } + } + if (!todos) return null + const allDone = todos.every((t) => t.status === 'completed') + if (allDone) { + for (let i = foundIndex + 1; i < messages.length; i++) { + const msg = messages[i]! + if (msg.type === 'user' && msg.content) return null + } + } + return todos +} + +const TASK_RELATED_TOOL_NAMES = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList']) + +function hasUserMessagesAfterTaskCompletion(messages: MessageEntry[]): boolean { + let lastTaskIndex = -1 + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]! + if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) { + const blocks = msg.content as AssistantHistoryBlock[] + if (blocks.some((b) => b.type === 'tool_use' && TASK_RELATED_TOOL_NAMES.has(b.name ?? ''))) { + lastTaskIndex = i + break + } + } + } + if (lastTaskIndex < 0) return false + for (let i = lastTaskIndex + 1; i < messages.length; i++) { + if (messages[i]!.type === 'user') return true + } + return false +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add desktop/src/stores/chatStore.ts +git commit -m "refactor: chatStore supports per-session isolated state" +``` + +--- + +### Task 3: Tab Store + +**Files:** +- Create: `desktop/src/stores/tabStore.ts` + +- [ ] **Step 1: 创建 tabStore** + +```ts +import { create } from 'zustand' +import { sessionsApi } from '../api/sessions' + +const TAB_STORAGE_KEY = 'cc-haha-open-tabs' + +export type Tab = { + sessionId: string + title: string + status: 'idle' | 'running' | 'error' +} + +type TabPersistence = { + openTabs: Array<{ sessionId: string; title: string }> + activeTabId: string | null +} + +type TabStore = { + tabs: Tab[] + activeTabId: string | null + + openTab: (sessionId: string, title: string) => void + closeTab: (sessionId: string) => void + setActiveTab: (sessionId: string) => void + updateTabTitle: (sessionId: string, title: string) => void + updateTabStatus: (sessionId: string, status: Tab['status']) => void + + saveTabs: () => void + restoreTabs: () => Promise +} + +export const useTabStore = create((set, get) => ({ + tabs: [], + activeTabId: null, + + openTab: (sessionId, title) => { + const { tabs } = get() + const existing = tabs.find((t) => t.sessionId === sessionId) + if (existing) { + set({ activeTabId: sessionId }) + } else { + set({ + tabs: [...tabs, { sessionId, title, status: 'idle' }], + activeTabId: sessionId, + }) + } + get().saveTabs() + }, + + closeTab: (sessionId) => { + const { tabs, activeTabId } = get() + const index = tabs.findIndex((t) => t.sessionId === sessionId) + if (index < 0) return + + const newTabs = tabs.filter((t) => t.sessionId !== sessionId) + let newActiveId = activeTabId + + // If closing the active tab, activate the nearest neighbor + if (activeTabId === sessionId) { + if (newTabs.length === 0) { + newActiveId = null + } else if (index >= newTabs.length) { + newActiveId = newTabs[newTabs.length - 1]!.sessionId + } else { + newActiveId = newTabs[index]!.sessionId + } + } + + set({ tabs: newTabs, activeTabId: newActiveId }) + get().saveTabs() + }, + + setActiveTab: (sessionId) => { + set({ activeTabId: sessionId }) + get().saveTabs() + }, + + updateTabTitle: (sessionId, title) => { + set((s) => ({ + tabs: s.tabs.map((t) => (t.sessionId === sessionId ? { ...t, title } : t)), + })) + get().saveTabs() + }, + + updateTabStatus: (sessionId, status) => { + set((s) => ({ + tabs: s.tabs.map((t) => (t.sessionId === sessionId ? { ...t, status } : t)), + })) + }, + + saveTabs: () => { + const { tabs, activeTabId } = get() + const data: TabPersistence = { + openTabs: tabs.map((t) => ({ sessionId: t.sessionId, title: t.title })), + activeTabId, + } + try { + localStorage.setItem(TAB_STORAGE_KEY, JSON.stringify(data)) + } catch { /* noop */ } + }, + + restoreTabs: async () => { + try { + const raw = localStorage.getItem(TAB_STORAGE_KEY) + if (!raw) return + + const data = JSON.parse(raw) as TabPersistence + if (!data.openTabs || data.openTabs.length === 0) return + + // Validate sessions still exist + const { sessions } = await sessionsApi.list({ limit: 200 }) + const existingIds = new Set(sessions.map((s) => s.id)) + + const validTabs: Tab[] = data.openTabs + .filter((t) => existingIds.has(t.sessionId)) + .map((t) => ({ + sessionId: t.sessionId, + title: sessions.find((s) => s.id === t.sessionId)?.title || t.title, + status: 'idle' as const, + })) + + if (validTabs.length === 0) return + + const activeId = data.activeTabId && validTabs.some((t) => t.sessionId === data.activeTabId) + ? data.activeTabId + : validTabs[0]!.sessionId + + set({ tabs: validTabs, activeTabId: activeId }) + } catch { /* noop */ } + }, +})) +``` + +- [ ] **Step 2: Commit** + +```bash +git add desktop/src/stores/tabStore.ts +git commit -m "feat: add tabStore for multi-tab state management" +``` + +--- + +### Task 4: TabBar 组件 + +**Files:** +- Create: `desktop/src/components/layout/TabBar.tsx` + +- [ ] **Step 1: 创建 TabBar 组件** + +固定宽度 Tab + 滚动箭头 + 关闭按钮 + 状态指示 + 右键上下文菜单。 + +```tsx +import { useRef, useState, useEffect, useCallback } from 'react' +import { useTabStore, type Tab } from '../../stores/tabStore' +import { useChatStore } from '../../stores/chatStore' +import { useTranslation } from '../../i18n' + +const TAB_WIDTH = 180 +const ARROW_WIDTH = 28 + +export function TabBar() { + const tabs = useTabStore((s) => s.tabs) + const activeTabId = useTabStore((s) => s.activeTabId) + const setActiveTab = useTabStore((s) => s.setActiveTab) + const closeTab = useTabStore((s) => s.closeTab) + const disconnectSession = useChatStore((s) => s.disconnectSession) + + const scrollRef = useRef(null) + const [canScrollLeft, setCanScrollLeft] = useState(false) + const [canScrollRight, setCanScrollRight] = useState(false) + const [contextMenu, setContextMenu] = useState<{ sessionId: string; x: number; y: number } | null>(null) + const t = useTranslation() + + const updateScrollState = useCallback(() => { + const el = scrollRef.current + if (!el) return + setCanScrollLeft(el.scrollLeft > 0) + setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1) + }, []) + + useEffect(() => { + updateScrollState() + const el = scrollRef.current + if (!el) return + el.addEventListener('scroll', updateScrollState) + const ro = new ResizeObserver(updateScrollState) + ro.observe(el) + return () => { + el.removeEventListener('scroll', updateScrollState) + ro.disconnect() + } + }, [updateScrollState, tabs.length]) + + useEffect(() => { + if (!contextMenu) return + const close = () => setContextMenu(null) + document.addEventListener('click', close) + return () => document.removeEventListener('click', close) + }, [contextMenu]) + + const scroll = (direction: 'left' | 'right') => { + const el = scrollRef.current + if (!el) return + el.scrollBy({ left: direction === 'left' ? -TAB_WIDTH : TAB_WIDTH, behavior: 'smooth' }) + } + + const handleClose = (sessionId: string) => { + disconnectSession(sessionId) + closeTab(sessionId) + } + + const handleContextMenu = (e: React.MouseEvent, sessionId: string) => { + e.preventDefault() + setContextMenu({ sessionId, x: e.clientX, y: e.clientY }) + } + + const handleCloseOthers = (sessionId: string) => { + setContextMenu(null) + const otherIds = tabs.filter((t) => t.sessionId !== sessionId).map((t) => t.sessionId) + for (const id of otherIds) handleClose(id) + } + + const handleCloseRight = (sessionId: string) => { + setContextMenu(null) + const idx = tabs.findIndex((t) => t.sessionId === sessionId) + const rightIds = tabs.slice(idx + 1).map((t) => t.sessionId) + for (const id of rightIds) handleClose(id) + } + + if (tabs.length === 0) return null + + return ( +
+ {/* Left arrow */} + {canScrollLeft && ( + + )} + + {/* Tabs scroll container */} +
+ {tabs.map((tab) => ( + setActiveTab(tab.sessionId)} + onClose={() => handleClose(tab.sessionId)} + onContextMenu={(e) => handleContextMenu(e, tab.sessionId)} + /> + ))} +
+ + {/* Right arrow */} + {canScrollRight && ( + + )} + + {/* Context menu */} + {contextMenu && ( +
+ + + +
+ )} +
+ ) +} + +function TabItem({ tab, isActive, onClick, onClose, onContextMenu }: { + tab: Tab + isActive: boolean + onClick: () => void + onClose: () => void + onContextMenu: (e: React.MouseEvent) => void +}) { + return ( +
+ {/* Status indicator */} + {tab.status === 'running' && ( + + )} + {tab.status === 'error' && ( + + )} + + {/* Title */} + + {tab.title || 'Untitled'} + + + {/* Close button */} + +
+ ) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add desktop/src/components/layout/TabBar.tsx +git commit -m "feat: add TabBar component with scroll overflow and context menu" +``` + +--- + +### Task 5: i18n 翻译 keys + +**Files:** +- Modify: `desktop/src/i18n/locales/en.ts` +- Modify: `desktop/src/i18n/locales/zh.ts` + +- [ ] **Step 1: 在 en.ts 末尾添加 Tab 相关翻译** + +在 `en.ts` 对象末尾(closing `}` 之前)添加: + +```ts + // ─── Tabs ────────────────────────────────────── + 'tabs.close': 'Close', + 'tabs.closeOthers': 'Close Others', + 'tabs.closeRight': 'Close to the Right', + 'tabs.closeConfirmTitle': 'Session Running', + 'tabs.closeConfirmMessage': 'This session is still running. What would you like to do?', + 'tabs.closeConfirmKeep': 'Keep Running', + 'tabs.closeConfirmStop': 'Stop & Close', +``` + +- [ ] **Step 2: 在 zh.ts 末尾添加对应中文翻译** + +```ts + // ─── Tabs ────────────────────────────────────── + 'tabs.close': '关闭', + 'tabs.closeOthers': '关闭其他', + 'tabs.closeRight': '关闭右侧', + 'tabs.closeConfirmTitle': '会话运行中', + 'tabs.closeConfirmMessage': '此会话仍在运行,你想要怎么做?', + 'tabs.closeConfirmKeep': '保持运行', + 'tabs.closeConfirmStop': '停止并关闭', +``` + +- [ ] **Step 3: Commit** + +```bash +git add desktop/src/i18n/locales/en.ts desktop/src/i18n/locales/zh.ts +git commit -m "feat: add i18n translations for multi-tab feature" +``` + +--- + +### Task 6: 布局集成 — AppShell + ContentRouter + +**Files:** +- Modify: `desktop/src/components/layout/AppShell.tsx` +- Modify: `desktop/src/components/layout/ContentRouter.tsx` + +- [ ] **Step 1: AppShell 集成 TabBar 和 Tab 恢复** + +在 `AppShell.tsx` 中 import TabBar,在 bootstrap 后恢复 Tab 状态,并在 `` 之前渲染 ``。 + +修改 import 区域,添加: +```ts +import { TabBar } from './TabBar' +import { useTabStore } from '../../stores/tabStore' +import { useChatStore } from '../../stores/chatStore' +``` + +在 `bootstrap` async 函数中,`setReady(true)` 之前添加 Tab 恢复: +```ts + await useTabStore.getState().restoreTabs() + // Connect active tab's WS + const activeId = useTabStore.getState().activeTabId + if (activeId) { + useChatStore.getState().connectToSession(activeId) + } +``` + +修改 main 内容区 JSX,将 `` 替换为: +```tsx + + +``` + +- [ ] **Step 2: ContentRouter 从 tabStore 获取 activeTabId** + +修改 `ContentRouter.tsx`: + +```tsx +import { useUIStore } from '../../stores/uiStore' +import { useTabStore } from '../../stores/tabStore' +import { useTeamStore } from '../../stores/teamStore' +import { EmptySession } from '../../pages/EmptySession' +import { ActiveSession } from '../../pages/ActiveSession' +import { ScheduledTasks } from '../../pages/ScheduledTasks' +import { Settings } from '../../pages/Settings' +import { AgentTranscript } from '../../pages/AgentTranscript' + +export function ContentRouter() { + const activeView = useUIStore((s) => s.activeView) + const activeTabId = useTabStore((s) => s.activeTabId) + const viewingAgentId = useTeamStore((s) => s.viewingAgentId) + + if (activeView === 'settings') { + return + } + + if (activeView === 'scheduled') { + return + } + + if (activeView === 'terminal') { + return + } + + if (activeView === 'history') { + if (viewingAgentId) { + return + } + return + } + + // Code view + if (!activeTabId) { + return + } + + if (viewingAgentId) { + return + } + + return +} +``` + +(TerminalPlaceholder 和 HistoryPlaceholder 函数保持不变) + +- [ ] **Step 3: Commit** + +```bash +git add desktop/src/components/layout/AppShell.tsx desktop/src/components/layout/ContentRouter.tsx +git commit -m "feat: integrate TabBar into AppShell layout and route by activeTabId" +``` + +--- + +### Task 7: ActiveSession 适配多 Tab + +**Files:** +- Modify: `desktop/src/pages/ActiveSession.tsx` + +- [ ] **Step 1: 改为从 tabStore 和 chatStore.sessions 读取** + +```tsx +import { useEffect, useMemo } from 'react' +import { useTabStore } from '../stores/tabStore' +import { useSessionStore } from '../stores/sessionStore' +import { useChatStore } from '../stores/chatStore' +import { MessageList } from '../components/chat/MessageList' +import { ChatInput } from '../components/chat/ChatInput' +import { TeamStatusBar } from '../components/teams/TeamStatusBar' +import { SessionTaskBar } from '../components/chat/SessionTaskBar' + +export function ActiveSession() { + const activeTabId = useTabStore((s) => s.activeTabId) + const sessions = useSessionStore((s) => s.sessions) + const connectToSession = useChatStore((s) => s.connectToSession) + const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined) + + const session = sessions.find((s) => s.id === activeTabId) + const chatState = sessionState?.chatState ?? 'idle' + const tokenUsage = sessionState?.tokenUsage ?? { input_tokens: 0, output_tokens: 0 } + + useEffect(() => { + if (activeTabId) { + connectToSession(activeTabId) + } + }, [activeTabId, connectToSession]) + + const isActive = chatState !== 'idle' + const totalTokens = tokenUsage.input_tokens + tokenUsage.output_tokens + + const lastUpdated = useMemo(() => { + if (!session?.modifiedAt) return '' + const diff = Date.now() - new Date(session.modifiedAt).getTime() + if (diff < 60000) return 'just now' + if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago` + if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago` + return `${Math.floor(diff / 86400000)}d ago` + }, [session?.modifiedAt]) + + if (!activeTabId) return null + + return ( +
+ {/* Session info header */} +
+
+

+ {session?.title || 'Untitled Session'} +

+
+ {isActive && ( + + + session active + + )} + {totalTokens > 0 && ( + <> + · + {totalTokens.toLocaleString()} t + + )} + {lastUpdated && ( + <> + · + last updated {lastUpdated} + + )} + {session?.messageCount !== undefined && session.messageCount > 0 && ( + <> + · + {session.messageCount} messages + + )} +
+ {session?.workDirExists === false && ( +
+ warning + + Workspace unavailable: {session.workDir || 'directory no longer exists'} + +
+ )} +
+
+ + {/* Message stream */} + + + {/* Session task bar — sticky at bottom */} + + + {/* Team status bar */} + + + {/* Chat input */} + +
+ ) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add desktop/src/pages/ActiveSession.tsx +git commit -m "feat: ActiveSession reads from tabStore and per-session chatStore" +``` + +--- + +### Task 8: Sidebar 集成 — 点击打开 Tab + +**Files:** +- Modify: `desktop/src/components/layout/Sidebar.tsx` + +- [ ] **Step 1: 修改 Sidebar session 点击行为** + +在 Sidebar.tsx 中 import tabStore 和 chatStore: +```ts +import { useTabStore } from '../../stores/tabStore' +import { useChatStore } from '../../stores/chatStore' +``` + +修改 session 按钮的 `onClick`,从: +```ts +onClick={() => { setActiveView('code'); setActiveSession(session.id) }} +``` +改为: +```ts +onClick={() => { + setActiveView('code') + const { openTab } = useTabStore.getState() + openTab(session.id, session.title) + useChatStore.getState().connectToSession(session.id) +}} +``` + +- [ ] **Step 2: Commit** + +```bash +git add desktop/src/components/layout/Sidebar.tsx +git commit -m "feat: sidebar click opens session in tab instead of replacing" +``` + +--- + +### Task 9: EmptySession 集成 — 新建 Session 自动开 Tab + +**Files:** +- Modify: `desktop/src/pages/EmptySession.tsx` + +- [ ] **Step 1: 修改 handleSubmit 创建 Session 后打开 Tab** + +在 EmptySession.tsx 中添加 import: +```ts +import { useTabStore } from '../stores/tabStore' +``` + +在 `handleSubmit` 中,`createSession` 成功后,添加 openTab 调用。修改 `try` 块: +```ts + const sessionId = await createSession(workDir || undefined) + setActiveView('code') + // Open as tab + useTabStore.getState().openTab(sessionId, 'New Session') + connectToSession(sessionId) + const attachmentPayload: AttachmentRef[] = attachments.map((attachment) => ({ + type: attachment.type, + name: attachment.name, + data: attachment.data, + mimeType: attachment.mimeType, + })) + sendMessage(text, attachmentPayload) + setInput('') + setAttachments([]) +``` + +注意:这里 `sendMessage` 现在需要传 sessionId 作为第一个参数: +```ts + sendMessage(sessionId, text, attachmentPayload) +``` + +同时更新 sendMessage 的引用获取: +```ts +const sendMessage = useChatStore((state) => state.sendMessage) +``` + +- [ ] **Step 2: Commit** + +```bash +git add desktop/src/pages/EmptySession.tsx +git commit -m "feat: new session creation automatically opens as tab" +``` + +--- + +### Task 10: MessageList 和 ChatInput 适配 per-session 数据 + +**Files:** +- Modify: `desktop/src/components/chat/MessageList.tsx` +- Modify: `desktop/src/components/chat/ChatInput.tsx` + +- [ ] **Step 1: 修改 MessageList 从 per-session state 读取** + +在 MessageList.tsx 中,找到从 `useChatStore` 读取 `messages`, `chatState`, `streamingText` 等的地方,改为通过 `activeTabId` 从 `sessions[activeTabId]` 读取。 + +添加 import: +```ts +import { useTabStore } from '../../stores/tabStore' +``` + +将所有从 chatStore 直接读取的字段改为通过 helper: +```ts +const activeTabId = useTabStore((s) => s.activeTabId) +const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined) +const messages = sessionState?.messages ?? [] +const chatState = sessionState?.chatState ?? 'idle' +const streamingText = sessionState?.streamingText ?? '' +// ... 等等 +``` + +- [ ] **Step 2: 修改 ChatInput 发送消息时传 sessionId** + +在 ChatInput.tsx 中,找到调用 `sendMessage(content, attachments)` 的地方,改为 `sendMessage(activeTabId, content, attachments)`。 + +同样对 `respondToPermission`, `stopGeneration`, `setSessionPermissionMode` 等调用添加 sessionId 参数。 + +- [ ] **Step 3: Commit** + +```bash +git add desktop/src/components/chat/MessageList.tsx desktop/src/components/chat/ChatInput.tsx +git commit -m "feat: MessageList and ChatInput read from per-session state" +``` + +--- + +### Task 11: Tab 状态同步 — session_title_updated + chatState + +**Files:** +- Modify: `desktop/src/stores/chatStore.ts` + +- [ ] **Step 1: 在 handleServerMessage 中同步 Tab 状态** + +在 `session_title_updated` case 中添加: +```ts + case 'session_title_updated': + useSessionStore.getState().updateSessionTitle(msg.sessionId, msg.title) + // Sync to tab title + import('../stores/tabStore').then(({ useTabStore }) => { + useTabStore.getState().updateTabTitle(msg.sessionId, msg.title) + }) + break +``` + +更简洁的方式:在文件顶部 import tabStore: +```ts +import { useTabStore } from './tabStore' +``` + +然后在 `status` case 中同步 tab status: +```ts + case 'status': + updateSession((session) => ({ ... })) + // Sync tab status + if (msg.state === 'idle') { + useTabStore.getState().updateTabStatus(sessionId, 'idle') + } else { + useTabStore.getState().updateTabStatus(sessionId, 'running') + } + break +``` + +在 `error` case 中: +```ts + useTabStore.getState().updateTabStatus(sessionId, 'error') +``` + +在 `session_title_updated` case 中: +```ts + useTabStore.getState().updateTabTitle(msg.sessionId, msg.title) +``` + +- [ ] **Step 2: Commit** + +```bash +git add desktop/src/stores/chatStore.ts +git commit -m "feat: sync tab status and title from server messages" +``` + +--- + +### Task 12: Tab 关闭确认对话框 + +**Files:** +- Create: `desktop/src/components/layout/TabCloseDialog.tsx` +- Modify: `desktop/src/components/layout/TabBar.tsx` + +- [ ] **Step 1: 创建 TabCloseDialog 组件** + +```tsx +import { Modal } from '../shared/Modal' +import { Button } from '../shared/Button' +import { useTranslation } from '../../i18n' + +type Props = { + open: boolean + onKeepRunning: () => void + onStopAndClose: () => void + onCancel: () => void +} + +export function TabCloseDialog({ open, onKeepRunning, onStopAndClose, onCancel }: Props) { + const t = useTranslation() + + return ( + + + + + + }> +

+ {t('tabs.closeConfirmMessage')} +

+
+ ) +} +``` + +- [ ] **Step 2: 在 TabBar 中集成关闭确认逻辑** + +在 TabBar.tsx 中 import 对话框: +```ts +import { TabCloseDialog } from './TabCloseDialog' +``` + +添加 state: +```ts + const [closingTabId, setClosingTabId] = useState(null) +``` + +修改 `handleClose` 函数,检查 session 是否正在运行: +```ts + const handleClose = (sessionId: string) => { + const sessionState = useChatStore.getState().sessions[sessionId] + const isRunning = sessionState && sessionState.chatState !== 'idle' + + if (isRunning) { + // TODO: check settings for tabCloseWithRunningSession + setClosingTabId(sessionId) + return + } + + disconnectSession(sessionId) + closeTab(sessionId) + } +``` + +在 JSX 末尾(context menu 后面)添加对话框: +```tsx + {closingTabId && ( + { + closeTab(closingTabId) + setClosingTabId(null) + }} + onStopAndClose={() => { + useChatStore.getState().stopGeneration(closingTabId) + disconnectSession(closingTabId) + closeTab(closingTabId) + setClosingTabId(null) + }} + onCancel={() => setClosingTabId(null)} + /> + )} +``` + +- [ ] **Step 3: Commit** + +```bash +git add desktop/src/components/layout/TabCloseDialog.tsx desktop/src/components/layout/TabBar.tsx +git commit -m "feat: add confirmation dialog when closing running session tab" +``` + +--- + +### Task 13: 编译验证和集成测试 + +**Files:** +- All modified files + +- [ ] **Step 1: TypeScript 编译检查** + +Run: `cd /Users/nanmi/workspace/myself_code/claude-code-haha/desktop && npx tsc --noEmit` +Expected: 零错误。如有错误,逐一修复类型问题。 + +- [ ] **Step 2: Vite 开发服务器启动测试** + +Run: `cd /Users/nanmi/workspace/myself_code/claude-code-haha/desktop && npx vite build 2>&1 | tail -20` +Expected: Build 成功,无错误。 + +- [ ] **Step 3: 运行现有测试** + +Run: `cd /Users/nanmi/workspace/myself_code/claude-code-haha/desktop && npx vitest run 2>&1` +Expected: 测试通过(可能需要更新 mock)。 + +- [ ] **Step 4: 修复所有发现的问题** + +根据编译/构建/测试结果修复问题。 + +- [ ] **Step 5: Final commit** + +```bash +git add -A +git commit -m "fix: resolve compilation and integration issues for multi-tab" +``` diff --git a/docs/superpowers/specs/2026-04-08-multi-tab-sessions-design.md b/docs/superpowers/specs/2026-04-08-multi-tab-sessions-design.md new file mode 100644 index 00000000..9bd3c908 --- /dev/null +++ b/docs/superpowers/specs/2026-04-08-multi-tab-sessions-design.md @@ -0,0 +1,221 @@ +# Multi-Tab Sessions Design Spec + +## Overview + +Desktop webapp (Tauri + React + Zustand) 新增多 Tab 功能,允许用户同时打开多个 Session 并行工作。类似 VS Code 的编辑器 Tab 与文件浏览器的关系:Sidebar 浏览所有 Session,Tab 栏管理当前打开的 Session。 + +## Tech Stack + +- **Desktop App**: Tauri v2 + React 18 + Zustand 5 + Tailwind CSS 4 + Vite +- **Backend Server**: `src/server/` — 已支持多 Session 并发 (`maxSessions` 配置) +- **通信协议**: 每个 Session 独立 WebSocket (`/ws/${sessionId}`) + REST API + +## Key Design Decisions + +| 决策 | 选择 | 理由 | +|------|------|------| +| Tab 与 Sidebar 关系 | 独立共存 (VS Code 模式) | Sidebar 浏览全部历史, Tab 管理当前打开的 | +| Tab 栏位置 | 内容区域顶部 (Sidebar 右侧上方) | 不影响 Sidebar 布局 | +| 并发策略 | 每个 Tab 独立 WebSocket 连接 | 真正并行, 后端已支持, WS 连接轻量 | +| Tab 数量限制 | 不限制 | 用户自由管理 | +| Tab 溢出处理 | 固定宽度 + 左右滚动箭头 | VS Code 风格 | +| 关闭运行中 Tab | 可配置 (默认弹确认框) | 配置项: ask / background / stop | +| Tab 持久化 | 可配置 (默认恢复) | 存 localStorage, 启动时恢复 | +| Sidebar 打开行为 | 去重 + 聚焦 | 已打开则跳转, 否则新 Tab | +| 新建 Session 入口 | 仅 Sidebar | 保持单一入口 | + +## Architecture Changes + +### 1. New Tab Store (`tabStore.ts`) + +新增 Zustand store 管理 Tab 状态: + +```ts +interface Tab { + sessionId: string + title: string + status: 'idle' | 'running' | 'error' +} + +interface TabStore { + tabs: Tab[] + activeTabId: string | null // sessionId + + openTab(sessionId: string, title: string): void + closeTab(sessionId: string): void + setActiveTab(sessionId: string): void + updateTabTitle(sessionId: string, title: string): void + updateTabStatus(sessionId: string, status: Tab['status']): void + reorderTabs(fromIndex: number, toIndex: number): void + + // Persistence + saveTabs(): void + restoreTabs(): Tab[] +} +``` + +### 2. chatStore Refactor + +从单 Session 改为多 Session 并发: + +**Before**: 单一 `messages[]` + 单一 `connectedSessionId` +**After**: 按 sessionId 隔离的 session map + +```ts +interface PerSessionState { + messages: UIMessage[] + connectionState: 'connecting' | 'connected' | 'disconnected' + streamingState: StreamingState + permissionRequests: PermissionRequest[] + tasks: TaskState[] +} + +interface ChatStore { + sessions: Record + connectToSession(sessionId: string): void + disconnectSession(sessionId: string): void + sendMessage(sessionId: string, content: string, attachments?: AttachmentRef[]): void + // ... per-session methods all accept sessionId +} +``` + +### 3. WebSocket Manager Refactor + +从单例连接改为多连接管理: + +**Before**: 全局一个 WS 连接 +**After**: 按 sessionId 管理多个独立 WS 连接 + +```ts +class WebSocketManager { + connections: Map + connect(sessionId: string): void + disconnect(sessionId: string): void + disconnectAll(): void + getConnection(sessionId: string): WebSocketConnection | undefined + send(sessionId: string, message: ClientMessage): void +} +``` + +### 4. UI Components + +#### 4.1 TabBar Component (New) + +位置: 内容区域顶部, Sidebar 右侧上方。 + +- 固定 Tab 宽度, 显示 Session 标题 (截断) + 状态指示 + 关闭按钮 +- 超出容器宽度时显示左右滚动箭头 +- 当前活跃 Tab 高亮 +- Tab 运行中显示状态指示 (spinner/dot) +- 右键上下文菜单: 关闭、关闭其他、关闭右侧 + +#### 4.2 Layout Changes (AppShell / ContentRouter) + +``` +Before: +┌────────────────────────────────────────┐ +│ Tauri Drag Region │ +├──────────┬─────────────────────────────┤ +│ Sidebar │ ContentRouter │ +│ │ (single active session) │ +└──────────┴─────────────────────────────┘ + +After: +┌────────────────────────────────────────┐ +│ Tauri Drag Region │ +├──────────┬─────────────────────────────┤ +│ Sidebar │ TabBar │ +│ ├─────────────────────────────┤ +│ │ ContentRouter │ +│ │ (active tab's session) │ +└──────────┴─────────────────────────────┘ +``` + +#### 4.3 ActiveSession Changes + +当前 ActiveSession 组件从 `sessionStore.activeSessionId` 获取 session。 +改为从 `tabStore.activeTabId` 获取, 并从 `chatStore.sessions[activeTabId]` 读取消息。 + +### 5. Settings Additions + +新增两个配置项到 Settings 页面: + +- **关闭运行中 Tab 的行为**: `ask` | `background` | `stop` (默认 `ask`) +- **启动时恢复 Tab**: `boolean` (默认 `true`) + +### 6. Sidebar Integration + +修改 Sidebar 点击行为: +- 点击 Session → 检查 tabStore 是否已打开该 Session + - 已打开: `setActiveTab(sessionId)` 聚焦 + - 未打开: `openTab(sessionId, title)` 创建新 Tab + 连接 WS +- 新建 Session → 创建后自动 `openTab` + +### 7. Tab Close Flow + +``` +User clicks X on tab + → Is session running? + → No: close tab, disconnect WS, cleanup chatStore session state + → Yes: check setting `tabCloseWithRunningSession` + → 'ask': show confirm dialog (3 options: keep running / stop / cancel) + → 'background': close tab, keep WS + session running + → 'stop': send stop_generation, close tab, disconnect WS +``` + +### 8. Tab Persistence + +存储到 `localStorage`: +```ts +{ + openTabs: Array<{ sessionId: string; title: string }> + activeTabId: string | null +} +``` + +启动时: +1. 读取 localStorage +2. 检查 `restoreTabsOnStartup` 配置 +3. 校验每个 sessionId 是否仍存在 (API check) +4. 恢复有效 Tab, 连接活跃 Tab 的 WS + +## Backend Impact + +**无需后端改动**。后端已支持: +- 多 Session 并发 (`maxSessions` 配置) +- 独立 WebSocket 端点 (`/ws/${sessionId}`) +- Session CRUD REST API +- 独立的消息历史存储 + +前端并行打开多个 WS 连接即可。 + +## Files to Modify + +### New Files +- `desktop/src/stores/tabStore.ts` +- `desktop/src/components/layout/TabBar.tsx` +- `desktop/src/components/layout/TabCloseDialog.tsx` + +### Modified Files +- `desktop/src/stores/chatStore.ts` — 多 session 状态隔离 +- `desktop/src/api/websocket.ts` — 多连接管理 +- `desktop/src/components/layout/AppShell.tsx` — 集成 TabBar +- `desktop/src/components/layout/ContentRouter.tsx` — 从 tabStore 获取 activeTabId +- `desktop/src/components/layout/Sidebar.tsx` — 点击行为改为 openTab +- `desktop/src/pages/ActiveSession.tsx` — 从 chatStore.sessions[id] 读取 +- `desktop/src/pages/Settings.tsx` — 新增 Tab 相关设置项 +- `desktop/src/stores/uiStore.ts` — 可能需要调整 activeView 逻辑 +- `desktop/src/stores/cliTaskStore.ts` — 按 sessionId 隔离 task 状态 + +## Performance Considerations + +- 每个 WS 连接占用极少资源, 10 个以内无需担心 +- 消息缓存在内存中, 极端情况 (大量 Tab + 长对话) 可能占用较多内存 +- 非活跃 Tab 不渲染 DOM (React 条件渲染), 无 UI 性能开销 +- Tab 切换无需重新加载消息 (已缓存), 切换体验流畅 + +## Testing Strategy + +- Unit tests: tabStore CRUD, persistence, close flow logic +- Integration tests: Sidebar → Tab → WS 连接全链路 +- Edge cases: 关闭最后一个 Tab、恢复已删除的 Session、大量 Tab 滚动