diff --git a/.gitignore b/.gitignore index 54d8ce1a..152fc8c9 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,7 @@ electron.vite.config.*.mjs # Claude binary (downloaded at build time) resources/bin/ + +# Personal entitlements (local dev only) +build/entitlements.mac.personal.plist +.agent-browser/ diff --git a/bun.lock b/bun.lock index 2a6e878c..b9a53ee7 100644 --- a/bun.lock +++ b/bun.lock @@ -5,15 +5,21 @@ "name": "21st-desktop", "dependencies": { "@ai-sdk/react": "^3.0.14", - "@anthropic-ai/claude-agent-sdk": "^0.2.3", + "@anthropic-ai/claude-agent-sdk": "^0.2.5", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@git-diff-view/react": "^0.0.35", "@git-diff-view/shiki": "^0.0.36", + "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-hover-card": "^1.1.14", + "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-progress": "^1.1.8", @@ -40,9 +46,11 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^3.6.0", + "devicons-react": "^1.5.0", "drizzle-orm": "^0.45.1", "electron-log": "^5.4.3", "electron-updater": "^6.7.3", + "gray-matter": "^4.0.3", "jotai": "^2.11.1", "lucide-react": "^0.468.0", "motion": "^11.15.0", @@ -55,12 +63,17 @@ "react-dom": "19.2.1", "react-hotkeys-hook": "^4.6.1", "react-icons": "^5.5.0", + "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.0", + "remark-breaks": "^4.0.0", + "remark-gfm": "^4.0.1", "shiki": "^1.24.4", + "simple-git": "^3.28.0", "sonner": "^1.7.1", "superjson": "^2.2.2", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", + "tone": "^15.1.22", "trpc-electron": "^0.1.2", "unique-names-generator": "^4.7.1", "xterm": "^5.3.0", @@ -102,7 +115,7 @@ "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.3", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-6h54MzD4R5S15esMiK7sPKwotwoYd3qxXdqzRWqSkYo96IwvtSoK5yb0jbWEdDKSW71jjEctFJZBkonGalmTAQ=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.12", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-lto5qlffODYa3He4jbSVdXtPCWVWUxEqWFj+8mWp4tSnY6tMsQBXjwalm7Bz8YgBsEbrCZrceYMcKSw0eL7H+A=="], "@apm-js-collab/code-transformer": ["@apm-js-collab/code-transformer@0.8.2", "", {}, "sha512-YRjJjNq5KFSjDUoqu5pFUWrrsvGOxl6c3bu+uMFc9HNNptZ2rNU/TI2nLw4jnhQNtka972Ee2m3uqbvDQtPeCA=="], @@ -152,6 +165,14 @@ "@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="], + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], + + "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], + + "@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="], + + "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], "@electron-toolkit/preload": ["@electron-toolkit/preload@3.0.2", "", { "peerDependencies": { "electron": ">=13.0.0" } }, "sha512-TWWPToXd8qPRfSXwzf5KVhpXMfONaUuRAZJHsKthKgZR/+LqX1dZVSSClQ8OTAEduvLGdecljCsoT2jSshfoUg=="], @@ -290,6 +311,10 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@kwsites/file-exists": ["@kwsites/file-exists@1.1.1", "", { "dependencies": { "debug": "^4.1.1" } }, "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw=="], + + "@kwsites/promise-deferred": ["@kwsites/promise-deferred@1.1.1", "", {}, "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw=="], + "@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@2.0.0", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg=="], "@malept/flatpak-bundler": ["@malept/flatpak-bundler@0.4.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.0", "lodash": "^4.17.15", "tmp-promise": "^3.0.2" } }, "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q=="], @@ -394,6 +419,8 @@ "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="], + "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="], "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], @@ -422,6 +449,10 @@ "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], + "@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg=="], + + "@radix-ui/react-icons": ["@radix-ui/react-icons@1.3.2", "", { "peerDependencies": { "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc" } }, "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g=="], + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], "@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="], @@ -600,6 +631,8 @@ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + "@types/fs-extra": ["@types/fs-extra@9.0.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA=="], "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], @@ -724,8 +757,12 @@ "at-least-node": ["at-least-node@1.0.0", "", {}, "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="], + "automation-events": ["automation-events@7.1.15", "", { "dependencies": { "@babel/runtime": "^7.28.6", "tslib": "^2.8.1" } }, "sha512-NsHJlve3twcgs8IyP4iEYph7Fzpnh6klN7G5LahwvypakBjFbsiGHJxrqTmeHKREdu/Tx6oZboqNI0tD4MnFlA=="], + "autoprefixer": ["autoprefixer@10.4.23", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001760", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA=="], + "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], @@ -902,6 +939,8 @@ "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + "devicons-react": ["devicons-react@1.5.0", "", { "peerDependencies": { "react": "*" } }, "sha512-8DYsWpgdbwI7ENDyC6Z9eqV72H01zUIAeRRo+UxBAq4clpPWcFhtqDrWSm0i5GUqbn/iNyiIfT1K69y0XLsscg=="], + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], @@ -980,7 +1019,11 @@ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], - "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], @@ -990,6 +1033,10 @@ "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], + "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], "extsprintf": ["extsprintf@1.4.1", "", {}, "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA=="], @@ -1078,6 +1125,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], @@ -1094,6 +1143,8 @@ "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "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" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], + "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "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" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], @@ -1104,6 +1155,8 @@ "hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], + "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], @@ -1136,6 +1189,8 @@ "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], @@ -1150,6 +1205,8 @@ "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + "is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], @@ -1164,6 +1221,8 @@ "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + "is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="], "is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="], @@ -1202,6 +1261,8 @@ "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], + "lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="], "lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="], @@ -1228,6 +1289,8 @@ "log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="], + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], "lowlight": ["lowlight@3.3.0", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "highlight.js": "~11.11.0" } }, "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ=="], @@ -1242,20 +1305,98 @@ "make-fetch-happen": ["make-fetch-happen@10.2.1", "", { "dependencies": { "agentkeepalive": "^4.2.1", "cacache": "^16.1.0", "http-cache-semantics": "^4.1.0", "http-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.0", "is-lambda": "^1.0.1", "lru-cache": "^7.7.1", "minipass": "^3.1.6", "minipass-collect": "^1.0.2", "minipass-fetch": "^2.0.3", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^0.6.3", "promise-retry": "^2.0.1", "socks-proxy-agent": "^7.0.0", "ssri": "^9.0.0" } }, "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w=="], + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], + + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "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" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="], + + "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], + + "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], + + "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="], + + "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="], + + "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], + + "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], + + "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "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" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], + + "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "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" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="], + + "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "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" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="], + + "mdast-util-newline-to-break": ["mdast-util-newline-to-break@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-find-and-replace": "^3.0.0" } }, "sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog=="], + + "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "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" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "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" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], + + "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + "micromark": ["micromark@4.0.2", "", { "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" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], + + "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "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" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], + + "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], + + "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], + + "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], + + "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], + + "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="], + + "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="], + + "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], + + "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], + + "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], + + "micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], + + "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "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" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], + + "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "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" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], + + "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], + + "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], + + "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], + + "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "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" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], + + "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], + + "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="], + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], @@ -1472,6 +1613,8 @@ "react-icons": ["react-icons@5.5.0", "", { "peerDependencies": { "react": "*" } }, "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw=="], + "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], @@ -1502,6 +1645,16 @@ "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], + "remark-breaks": ["remark-breaks@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-newline-to-break": "^2.0.0", "unified": "^11.0.0" } }, "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ=="], + + "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], + + "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], + + "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], + + "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="], @@ -1540,6 +1693,8 @@ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="], + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], @@ -1560,6 +1715,8 @@ "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], + "simple-git": ["simple-git@3.30.0", "", { "dependencies": { "@kwsites/file-exists": "^1.1.1", "@kwsites/promise-deferred": "^1.1.1", "debug": "^4.4.0" } }, "sha512-q6lxyDsCmEal/MEGhP1aVyQ3oxnagGlBDOVSIB4XUVLl1iZh0Pah6ebC9V4xBap/RfgP2WlI8EKs0WS0rMEJHg=="], + "simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="], "slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="], @@ -1580,10 +1737,12 @@ "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], - "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], + "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], "ssri": ["ssri@9.0.1", "", { "dependencies": { "minipass": "^3.1.1" } }, "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q=="], + "standardized-audio-context": ["standardized-audio-context@25.3.77", "", { "dependencies": { "@babel/runtime": "^7.25.6", "automation-events": "^7.0.9", "tslib": "^2.7.0" } }, "sha512-Ki9zNz6pKcC5Pi+QPjPyVsD9GwJIJWgryji0XL9cAJXMGyn+dPOf6Qik1AHei0+UNVcc4BOCa0hWLBzlwqsW/A=="], + "stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="], "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1598,8 +1757,14 @@ "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="], + "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], + + "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], + "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], "sumchecker": ["sumchecker@3.0.1", "", { "dependencies": { "debug": "^4.1.0" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="], @@ -1644,8 +1809,12 @@ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "tone": ["tone@15.1.22", "", { "dependencies": { "standardized-audio-context": "^25.3.70", "tslib": "^2.3.1" } }, "sha512-TCScAGD4sLsama5DjvTUXlLDXSqPealhL64nsdV1hhr6frPWve0DeSo63AKnSJwgfg55fhvxj0iPPRwPN5o0ag=="], + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + "trpc-electron": ["trpc-electron@0.1.2", "", { "peerDependencies": { "@trpc/client": ">=11.0.0", "@trpc/server": ">=11.0.0", "electron": ">19.0.0" } }, "sha512-sQpWBwQWzsgrERugjzUpPqY/+/n8NxkUq6YssQ5+5rALkvGCWq45T5Dreiwm2kh91dZMFlALTyMd8PhB0vgbIg=="], "truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "^1.0.1" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="], @@ -1662,6 +1831,8 @@ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], + "unique-filename": ["unique-filename@2.0.1", "", { "dependencies": { "unique-slug": "^3.0.0" } }, "sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A=="], "unique-names-generator": ["unique-names-generator@4.7.1", "", {}, "sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow=="], @@ -1726,6 +1897,8 @@ "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="], + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], @@ -1808,6 +1981,8 @@ "archiver-utils/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "automation-events/@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], + "c12/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "c12/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], @@ -1838,6 +2013,8 @@ "glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "gray-matter/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], + "hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], "iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="], @@ -1856,6 +2033,8 @@ "make-fetch-happen/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + "matcher/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "minipass-collect/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], @@ -1884,6 +2063,8 @@ "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "roarr/sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], + "socks-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], "ssri/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], @@ -1976,6 +2157,8 @@ "glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + "gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "lazystream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], diff --git a/docs/CLAUDE_INTEGRATION_ARCHITECTURE.md b/docs/CLAUDE_INTEGRATION_ARCHITECTURE.md new file mode 100644 index 00000000..176ee07a --- /dev/null +++ b/docs/CLAUDE_INTEGRATION_ARCHITECTURE.md @@ -0,0 +1,776 @@ +# Claude Integration Architecture + +## Overview + +21st Agents is an Electron desktop application that provides a local-first interface to Claude Code. This document explains how the application integrates with Claude, manages the Claude binary, and orchestrates communication between the UI and Claude's execution environment. + +## Why Download the Claude Binary? + +The application downloads and bundles the native Claude Code binary for several critical reasons: + +### 1. **Offline-First Architecture** +- Users can run Claude Code without requiring an internet connection to fetch the binary each time +- Binary is bundled with the application for immediate availability +- No dependency on external CDN availability during execution + +### 2. **Version Control & Consistency** +- Specific Claude Code version (default: 2.1.5) ensures consistent behavior across all users +- Prevents "works on my machine" issues from version mismatches +- Controlled upgrade path for new Claude Code releases + +### 3. **Security & Integrity** +- SHA256 checksum verification ensures binary hasn't been tampered with +- Downloaded from official Google Cloud Storage: `storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819` +- Eliminates risk of runtime binary substitution attacks + +### 4. **Cross-Platform Support** +- Pre-built binaries for all supported platforms: + - `darwin-arm64` (Apple Silicon) + - `darwin-x64` (Intel Mac) + - `linux-arm64` (ARM Linux) + - `linux-x64` (x86_64 Linux) + - `win32-x64` (Windows) +- Users don't need to build or install Claude Code separately + +### 5. **Isolation & Control** +- Application controls exact binary location and execution environment +- No conflicts with user's global Claude Code installation +- Full control over environment variables, working directory, and configuration + +## Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Renderer Process │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ React UI (active-chat.tsx) │ │ +│ │ - User types message │ │ +│ │ - Reads atoms (extended thinking, model selection) │ │ +│ │ - Calls IPCChatTransport.sendMessages() │ │ +│ └────────────────────────┬──────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ tRPC Client (trpc.ts) │ │ +│ │ - trpcClient.claude.chat.subscribe() │ │ +│ │ - Type-safe IPC communication │ │ +│ └────────────────────────┬──────────────────────────────────┘ │ +└────────────────────────────┼──────────────────────────────────────┘ + │ + │ Electron IPC (via tRPC) + │ +┌────────────────────────────▼──────────────────────────────────────┐ +│ Main Process │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ tRPC Router (routers/claude.ts) │ │ +│ │ - Receives subscription request │ │ +│ │ - Loads messages from SQLite DB │ │ +│ │ - Parses @mentions for agents/skills/tools │ │ +│ └────────────────────────┬──────────────────────────────────┘ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ Environment Setup (lib/claude/env.ts) │ │ +│ │ 1. getBundledClaudeBinaryPath() │ │ +│ │ - Resolves: resources/bin/{platform}-{arch}/claude │ │ +│ │ - Verifies binary exists and is executable │ │ +│ │ │ │ +│ │ 2. buildClaudeEnv() │ │ +│ │ - Loads shell environment via `zsh -ilc env` │ │ +│ │ - Merges with process.env │ │ +│ │ - Adds CLAUDE_CODE_OAUTH_TOKEN if authenticated │ │ +│ │ - Sets HOME, USER, SHELL, TERM │ │ +│ │ - Sets CLAUDE_CODE_ENTRYPOINT="sdk-ts" │ │ +│ │ - Removes potentially conflicting API keys │ │ +│ │ │ │ +│ │ 3. setupIsolatedSessionDir() │ │ +│ │ - Creates: {userData}/claude-sessions/{subChatId}/ │ │ +│ │ - Symlinks ~/.claude/agents/ and ~/.claude/skills/ │ │ +│ │ - Prevents cross-chat configuration contamination │ │ +│ └────────────────────────┬──────────────────────────────────┘ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ Claude SDK Query (@anthropic-ai/claude-agent-sdk) │ │ +│ │ const { query } = await import("claude-agent-sdk") │ │ +│ │ │ │ +│ │ query({ │ │ +│ │ cwd: projectPath, │ │ +│ │ systemPrompt: "claude_code", │ │ +│ │ permissionMode: "plan" | "bypassPermissions", │ │ +│ │ agents: [...mentionedAgents], │ │ +│ │ mcpServers: {...projectMcpConfig}, │ │ +│ │ pathToClaudeCodeExecutable: binaryPath, │ │ +│ │ settingSources: ["project", "user"], │ │ +│ │ canUseTool: (toolName) => approvalCallback(), │ │ +│ │ extendedThinking: { maxTokens: 10000 }, │ │ +│ │ resume: sessionId, // For multi-turn conversations │ │ +│ │ }) │ │ +│ └────────────────────────┬──────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────┐ │ +│ │ Claude Binary │ │ +│ │ (spawned process) │ │ +│ │ - Reads .claude.json│ │ +│ │ - Loads MCP servers │ │ +│ │ - Executes tools │ │ +│ │ - Streams responses │ │ +│ └──────────┬───────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ Stream Transformer │ │ +│ │ - Converts SDK format → UIMessageChunk format │ │ +│ │ - Handles text, tool calls, thinking, system messages │ │ +│ │ - Accumulates parts into complete assistant message │ │ +│ │ - Saves to DB with sessionId for resumption │ │ +│ └────────────────────────┬──────────────────────────────────┘ │ +│ │ │ +└───────────────────────────┼───────────────────────────────────────┘ + │ + │ tRPC Subscription Stream + │ +┌───────────────────────────▼───────────────────────────────────────┐ +│ Renderer Process │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ Stream Consumer │ │ +│ │ - Receives UIMessageChunk objects │ │ +│ │ - Updates chat UI in real-time │ │ +│ │ - Renders text, tool calls, thinking, diffs │ │ +│ │ - Shows notifications for errors │ │ +│ └───────────────────────────────────────────────────────────┘ │ +└───────────────────────────────────────────────────────────────────┘ +``` + +## Binary Management + +### Download Process + +**Script:** `scripts/download-claude-binary.mjs` + +```bash +# Download for current platform +bun run claude:download + +# Download for all platforms (for building releases) +bun run claude:download:all + +# Download specific version +bun run claude:download --version=2.1.5 +``` + +**Process:** +1. Detect platform and architecture +2. Fetch manifest from: `https://storage.googleapis.com/claude-code-dist-.../claude-code-releases/{version}/manifest.json` +3. Download binary for platform +4. Verify SHA256 checksum +5. Save to: `resources/bin/{platform}-{arch}/claude` (or `claude.exe` on Windows) +6. Make executable (chmod 0o755 on Unix) +7. Write version metadata to `resources/bin/VERSION` + +**Version Detection:** +- Default: 2.1.5 +- Fallback: Auto-detect latest from `https://claude.ai/install.sh` + +### Binary Storage Locations + +| Environment | Path | +|-------------|------| +| Development | `resources/bin/{platform}-{arch}/claude` | +| Production (macOS) | `{app.asar.unpacked}/resources/bin/claude` | +| Production (Windows) | `{app.asar.unpacked}/resources/bin/claude.exe` | + +### Binary Path Resolution + +**Function:** `getBundledClaudeBinaryPath()` in `src/main/lib/claude/env.ts` + +```typescript +// Logic: +if (is.dev) { + // Development: platform-specific subdirectory + return path.join(resources, 'bin', `${platform}-${arch}`, binaryName) +} else { + // Production: resources/bin/ (copied during build) + return path.join(process.resourcesPath, 'bin', binaryName) +} +``` + +The function includes extensive logging and verification: +- Platform and architecture detection +- File existence check +- Executable permission verification +- File size logging +- Debug output with `[getBundledClaudeBinaryPath]` prefix + +## Environment Configuration + +### buildClaudeEnv() Process + +**Location:** `src/main/lib/claude/env.ts:166-216` + +This is a sophisticated multi-step process that ensures Claude Code runs with the correct environment: + +#### Step 1: Shell Environment Loading + +```typescript +// Spawn interactive login shell to capture full environment +const { stdout } = await execAsync('zsh -ilc env', { + env: { + HOME: app.getPath('home'), + USER: process.env.USER, + LOGNAME: process.env.LOGNAME, + }, + timeout: 5000, +}) + +// Parse key=value pairs from shell output +const shellEnv = parseEnvOutput(stdout) +``` + +**Why?** Electron apps have a minimal PATH that doesn't include user-installed tools (brew, npm, etc.). Loading the shell environment ensures Claude has access to all user tools. + +#### Step 2: Environment Merging + +```typescript +const mergedEnv = { + ...shellEnv, // Shell environment (base) + ...process.env, // Current process env (overlays) + PATH: shellEnv.PATH // Restore shell PATH (critical!) +} +``` + +#### Step 3: Environment Stripping + +Remove potentially interfering variables: +```typescript +delete mergedEnv.ANTHROPIC_API_KEY // Prevent key confusion +delete mergedEnv.OPENAI_API_KEY +delete mergedEnv.CLAUDE_CODE_USE_BEDROCK +delete mergedEnv.CLAUDE_CODE_USE_VERTEX +``` + +#### Step 4: Required Variables + +```typescript +const finalEnv = { + ...mergedEnv, + HOME: app.getPath('home'), + USER: os.userInfo().username, + SHELL: process.env.SHELL || '/bin/zsh', + TERM: 'xterm-256color', + CLAUDE_CODE_ENTRYPOINT: 'sdk-ts', // Identifies this as SDK usage +} +``` + +#### Step 5: Authentication Token + +If user has authenticated with Claude Code OAuth: +```typescript +if (authToken) { + finalEnv.CLAUDE_CODE_OAUTH_TOKEN = authToken +} +``` + +**Token Storage:** +- Stored in SQLite DB: `{userData}/data/agents.db` +- Encrypted using Electron's `safeStorage` API (OS keychain) +- Retrieved via `authStore.getClaudeCodeOAuthToken()` + +### Fallback PATH Strategy + +If shell environment loading fails (timeout, error), uses hardcoded fallback: + +```typescript +const fallbackPath = [ + path.join(homeDir, '.local', 'bin'), + '/opt/homebrew/bin', // Apple Silicon Homebrew + '/usr/local/bin', // Intel Homebrew + '/usr/bin', + '/bin', + '/usr/sbin', + '/sbin', +].join(':') +``` + +## Session Isolation + +### Isolated Configuration Directories + +**Function:** `setupIsolatedSessionDir()` in `routers/claude.ts` + +Each sub-chat gets its own isolated configuration directory to prevent cross-contamination: + +``` +{userData}/claude-sessions/{subChatId}/ +├── agents/ → symlink to ~/.claude/agents/ +└── skills/ → symlink to ~/.claude/skills/ +``` + +**Why?** +- Prevents one chat's configuration from affecting another +- Allows safe concurrent Claude sessions +- Each session can have different tool approvals, settings, etc. + +**Implementation:** +```typescript +const sessionConfigDir = path.join( + app.getPath('userData'), + 'claude-sessions', + subChatId +) + +await fs.ensureDir(sessionConfigDir) + +// Symlink shared agents and skills +const agentsDir = path.join(os.homedir(), '.claude', 'agents') +const skillsDir = path.join(os.homedir(), '.claude', 'skills') + +await fs.ensureSymlink(agentsDir, path.join(sessionConfigDir, 'agents')) +await fs.ensureSymlink(skillsDir, path.join(sessionConfigDir, 'skills')) +``` + +## Message Flow & Streaming + +### End-to-End Message Flow + +``` +User Input → IPCChatTransport → tRPC Subscription → Claude Router + ↓ +Claude Router: + 1. Load existing messages from DB + 2. Save user message + 3. Parse @mentions + 4. Setup environment + 5. Call SDK query() + ↓ +Claude SDK: + 1. Spawn binary with environment + 2. Stream messages back + ↓ +Stream Transformer: + 1. Convert SDK format → UIMessageChunk + 2. Handle text, tools, thinking, system messages + 3. Accumulate complete assistant message + 4. Save to DB with sessionId + ↓ +tRPC Stream → Renderer → UI Update +``` + +### Message Structure + +**User Message:** +```typescript +{ + id: generateId(), + role: "user", + parts: [ + { type: "text", text: "User's message" }, + { type: "image", data: "base64...", mimeType: "image/png" } // if image + ] +} +``` + +**Assistant Message:** +```typescript +{ + id: generateId(), + role: "assistant", + parts: [ + { type: "text", text: "Assistant response" }, + { + type: "tool-Bash", + toolCallId: "call_123", + toolName: "Bash", + state: "call", + input: { command: "ls -la" } + }, + { + type: "tool-Bash", + toolCallId: "call_123", + toolName: "Bash", + state: "result", + result: "total 16\ndrwxr-xr-x..." + } + ], + metadata: { + sessionId: "sess_abc123", + inputTokens: 1234, + outputTokens: 567, + cost: 0.0045, + durationMs: 3500, + stopReason: "end_turn" + } +} +``` + +### Stream Transformer + +**Location:** `routers/claude.ts` transform function + +Converts SDK message format to UI message chunks: + +```typescript +async *transform(sdkMessage) { + switch (sdkMessage.type) { + case "text": + yield { type: "text-delta", text: sdkMessage.delta } + break + + case "tool-call": + yield { + type: "tool-call", + toolCallId: sdkMessage.toolCallId, + toolName: sdkMessage.toolName, + input: sdkMessage.input + } + break + + case "tool-result": + yield { + type: "tool-result", + toolCallId: sdkMessage.toolCallId, + result: sdkMessage.result + } + break + + case "extended_thinking": + // Transform thinking blocks into tool-like chunks + yield { + type: "thinking-delta", + text: sdkMessage.delta, + thinkingId: sdkMessage.thinkingId + } + break + } +} +``` + +## Authentication + +### Claude Code OAuth Flow + +**Router:** `src/main/lib/trpc/routers/claude-code.ts` + +#### Step 1: Start Auth +```typescript +trpcClient.claudeCode.startAuth.mutate() +``` +- Creates CodeSandbox environment +- Returns sandbox ID and status URL + +#### Step 2: Poll for OAuth URL +```typescript +trpcClient.claudeCode.pollStatus.mutate({ sandboxId }) +``` +- Polls sandbox until OAuth URL is ready +- Returns URL for user to visit in browser + +#### Step 3: User Completes OAuth +- User visits URL in browser +- Logs in to Anthropic account +- Authorizes 21st Agents +- Receives authorization code + +#### Step 4: Submit Code +```typescript +trpcClient.claudeCode.submitCode.mutate({ sandboxId, code }) +``` +- Sends code to sandbox +- Receives OAuth token +- Encrypts token with `safeStorage.encryptString()` +- Saves to SQLite DB + +#### Step 5: Token Usage +```typescript +const token = authStore.getClaudeCodeOAuthToken() +// Decrypt: safeStorage.decryptString(Buffer.from(encrypted, 'hex')) + +// Add to environment +env.CLAUDE_CODE_OAUTH_TOKEN = token + +// Pass to SDK +query({ ..., pathToClaudeCodeExecutable: binaryPath }) +``` + +### Token Security + +- **Storage:** SQLite DB at `{userData}/data/agents.db` +- **Encryption:** Electron's `safeStorage` API + - macOS: Keychain + - Windows: DPAPI + - Linux: libsecret or fallback to plain text (with warning) +- **Access:** Main process only (renderer never sees token) + +## SDK Integration + +### Dynamic Import + +**Location:** `routers/claude.ts:125` + +```typescript +const { query } = await import("@anthropic-ai/claude-agent-sdk") +``` + +**Why dynamic?** +- SDK is ESM-only module +- Electron main process uses CommonJS by default +- Dynamic import allows mixing module systems + +### Query Options + +```typescript +query({ + // Core options + cwd: projectPath, // Working directory + systemPrompt: "claude_code", // Preset system prompt + permissionMode: "bypassPermissions", // or "plan" for read-only + + // Session management + resume: sessionId, // Resume previous session + continue: true, // Continue after tool use + + // Configuration + pathToClaudeCodeExecutable: binaryPath, + settingSources: ["project", "user"], // Load .claude.json from both + + // Features + agents: [...registeredAgents], // @[agent:name] mentions + mcpServers: {...projectConfig}, // MCP server configuration + extendedThinking: { + maxTokens: 10000 // Thinking token budget + }, + + // Callbacks + canUseTool: async (toolName) => { + // Request user approval for destructive tools + if (destructiveTools.includes(toolName)) { + return await showApprovalDialog(toolName) + } + return true + }, + + // Messages + messages: [...previousMessages, userMessage], +}) +``` + +### MCP Server Configuration + +Reads from `~/.claude.json` in project directory: + +```json +{ + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/project"] + }, + "postgres": { + "command": "docker", + "args": ["exec", "-i", "postgres", "psql", "-U", "user", "-d", "db"] + } + } +} +``` + +These servers provide tools Claude can use (file operations, database queries, etc.). + +## Database Schema + +### sub_chats Table + +```sql +CREATE TABLE sub_chats ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + chat_id TEXT NOT NULL, + session_id TEXT, -- Claude session ID for resumption + mode TEXT NOT NULL, -- "plan" or "agent" + messages TEXT NOT NULL, -- JSON array of message objects + stream_id TEXT, -- Set during streaming, cleared on finish + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (chat_id) REFERENCES chats(id) +) +``` + +**Messages JSON Structure:** +```json +[ + { + "id": "msg_001", + "role": "user", + "parts": [ + { "type": "text", "text": "Hello" } + ] + }, + { + "id": "msg_002", + "role": "assistant", + "parts": [ + { "type": "text", "text": "Hi!" } + ], + "metadata": { + "sessionId": "sess_abc", + "inputTokens": 10, + "outputTokens": 5 + } + } +] +``` + +## Error Handling + +### Error Categories + +Defined in `routers/claude.ts`: + +```typescript +const errorCategories = { + AUTH_FAILED_SDK: "You are not logged in", + INVALID_API_KEY_SDK: "Invalid API key", + RATE_LIMIT_SDK: "Rate limit exceeded", + PROCESS_CRASH: "Claude process crashed", + EXECUTABLE_NOT_FOUND: "Claude binary not found", + NETWORK_ERROR: "Network connection error", +} +``` + +### Error Flow + +``` +Error occurs in Claude SDK + ↓ +Stream transformer catches error + ↓ +Emits error chunk with category + ↓ +Frontend displays toast notification + ↓ +If AUTH_FAILED_SDK: Shows login modal +``` + +### Logging + +**Debug Logging:** +```typescript +console.log('[SD]', 'Stream message:', message) +``` +- Prefix: `[SD]` = Stream Debug +- Logs all messages from Claude SDK + +**Raw Message Logging:** +```bash +export CLAUDE_RAW_LOG=1 +``` +- Logs raw SDK messages to JSONL files +- Location: `{userData}/logs/claude/` +- Rotation: 10MB per file +- Retention: 7 days +- File format: `claude-raw-{timestamp}.jsonl` + +## Build Configuration + +### electron-builder Configuration + +**File:** `electron-builder.yml` + +```yaml +asarUnpack: + - node_modules/better-sqlite3/**/* + - node_modules/node-pty/**/* + - node_modules/@anthropic-ai/claude-agent-sdk/**/* + +files: + - from: resources/bin/${platform}-${arch} + to: bin +``` + +**Why unpack SDK?** +- ASAR archives can break native modules +- SDK may use dynamic imports that need real filesystem +- Ensures SDK can spawn Claude binary properly + +### Binary Distribution + +**macOS:** +``` +Agents.app/ +└── Contents/ + └── Resources/ + ├── app.asar + └── app.asar.unpacked/ + └── resources/ + └── bin/ + └── claude (copied from resources/bin/darwin-arm64/) +``` + +**Windows:** +``` +Agents/ +├── resources/ +│ └── app.asar +└── app.asar.unpacked/ + └── resources/ + └── bin/ + └── claude.exe (copied from resources/bin/win32-x64/) +``` + +## Performance Considerations + +### Why Bundled Binary is Faster + +1. **No Download Wait:** Binary is immediately available +2. **No Version Check:** No need to contact remote server +3. **Predictable Location:** No PATH searching required +4. **Optimized Environment:** Pre-configured environment variables + +### Memory & Process Management + +- Claude binary runs as separate process (spawned by SDK) +- Process is terminated when session ends +- Multiple concurrent sessions supported (isolated config dirs) +- Each session has independent process + +## Security Considerations + +### Binary Verification + +```typescript +// SHA256 checksum verification during download +const actualHash = crypto + .createHash('sha256') + .update(binaryBuffer) + .digest('hex') + +if (actualHash !== expectedHash) { + throw new Error('Binary checksum mismatch') +} +``` + +### Token Security + +- OAuth tokens encrypted at rest +- Never exposed to renderer process +- Passed to Claude via environment variable (memory only) +- Removed from environment after SDK call + +### Isolated Sessions + +- Each chat has separate config directory +- No cross-chat data leakage +- Tool approvals scoped to session +- File system access controlled per session + +## Summary + +The 21st Agents application provides a sophisticated, secure, and performant integration with Claude Code by: + +1. **Bundling native binaries** for offline-first operation and version consistency +2. **Managing complex environments** by loading shell profiles and merging configurations +3. **Isolating sessions** to prevent cross-contamination and enable concurrent usage +4. **Securing authentication** with OS-level encryption and main-process-only access +5. **Streaming responses** efficiently via tRPC subscriptions and transform streams +6. **Supporting advanced features** like MCP servers, extended thinking, and session resumption +7. **Providing robust error handling** with categorized errors and comprehensive logging + +This architecture ensures users get a reliable, fast, and secure Claude Code experience integrated seamlessly into their desktop workflow. diff --git a/docs/OBSERVER_COMPARISON.md b/docs/OBSERVER_COMPARISON.md new file mode 100644 index 00000000..55e38a77 --- /dev/null +++ b/docs/OBSERVER_COMPARISON.md @@ -0,0 +1,955 @@ +# Observer vs 21st Agents Feature Comparison + +## Executive Summary + +**Observer** is a terminal-first AI workspace with comprehensive artifact tracking, while **21st Agents** is an Electron desktop app focused on git-isolated chat sessions. This document compares both architectures and highlights what 21st Agents can adopt from Observer. + +--- + +## 🎯 Core Architecture Comparison + +| Feature | Observer | 21st Agents | Winner | +|---------|----------|-------------|--------| +| **Framework** | Tauri (Rust) + Vanilla JS | Electron + React 19 | Tie | +| **Backend** | Python (asyncio) | Node.js (tRPC) | Tie | +| **Database** | SQLite (dual-layer: memory + disk) | SQLite (Drizzle ORM) | Tie | +| **State Management** | Vanilla JS (manual) | Jotai + Zustand + React Query | **21st** | +| **Communication** | JSON-RPC 2.0 | tRPC (type-safe) | **21st** | +| **Bundle Size** | Smaller (Rust) | Larger (Electron) | **Observer** | + +--- + +## 🔔 **NOTIFICATIONS** (Critical Gap for 21st Agents) + +### Observer's Approach ✅ + +**1. Real-Time Artifact Notifications** +- Backend sends `artifact_added` notification immediately when Claude uses a tool +- Frontend listens via JSON-RPC and updates UI in real-time +- No polling required + +```javascript +// Observer: Real-time notification pattern +window.rpc.onNotification('artifact_added', (data) => { + const { session_id, artifact } = data; + // Find session and add artifact + for (const date in sessions) { + const sessionList = sessions[date]; + const session = sessionList.find(s => s.session_id === session_id); + if (session) { + if (!session.artifacts) session.artifacts = []; + session.artifacts.push(artifact); + session.artifact_count = session.artifacts.length; + render(); // Update UI immediately + return; + } + } +}); +``` + +**2. Text-to-Speech (TTS) for Agent Events** +- Uses Web Speech API for voice notifications +- Priority fallback chain for consistent voice across platforms +- Tunable parameters for "computer" effect + +```javascript +// Observer: TTS implementation +const synth = window.speechSynthesis; +let voice = voices.find(v => v.name === 'Fred') || // macOS robot + voices.find(v => v.name === 'Google US English') || + voices.find(v => v.name === 'Samantha') || // Siri-like + voices[0]; + +function speak(text, interrupt = true) { + if (interrupt) synth.cancel(); + const utterance = new SpeechSynthesisUtterance(text); + utterance.voice = voice; + utterance.pitch = 1.0; + utterance.rate = 1.1; // 10% faster + utterance.volume = 0.8; + synth.speak(utterance); +} + +// Usage +speak("Task completed successfully"); +``` + +### 21st Agents' Current State ❌ + +**Notification Status: STUB ONLY** + +```typescript +// 21st Agents: src/renderer/features/agents/hooks/use-desktop-notifications.ts +export function useDesktopNotifications() { + return { + showNotification: (_title: string, _body: string) => { + // Desktop notification - TODO: implement real notifications + }, + notifyAgentComplete: (_chatName: string) => { + // Agent complete notification - TODO: implement real notifications + }, + requestPermission: () => Promise.resolve('granted' as NotificationPermission), + } +} +``` + +**Critical Issues:** +1. ❌ No Electron native notifications implemented +2. ❌ No TTS for agent completion +3. ❌ No real-time updates when Claude executes tools +4. ❌ User has no idea what's happening during long-running agent sessions + +### **RECOMMENDATION FOR 21ST AGENTS** 🎯 + +**Implement Electron Native Notifications + TTS** + +```typescript +// src/preload/index.ts - Add to desktopApi +desktopApi: { + // ... existing methods + notification: { + show: (title: string, body: string, options?: NotificationOptions) => + ipcRenderer.invoke('notification:show', { title, body, options }), + speak: (text: string, interrupt?: boolean) => + ipcRenderer.invoke('notification:speak', { text, interrupt }), + } +} + +// src/main/index.ts - IPC handlers +ipcMain.handle('notification:show', (_, { title, body, options }) => { + const notification = new Notification({ + title, + body, + icon: path.join(__dirname, '../../resources/icon.png'), + ...options + }); + notification.show(); +}); + +ipcMain.handle('notification:speak', (_, { text, interrupt }) => { + // Use say command on macOS, or Windows Speech API + if (process.platform === 'darwin') { + exec(`say "${text}"`); + } + // TODO: Windows/Linux TTS +}); + +// src/renderer/features/agents/hooks/use-desktop-notifications.ts +export function useDesktopNotifications() { + return { + showNotification: (title: string, body: string) => { + window.desktopApi.notification.show(title, body); + }, + notifyAgentComplete: (chatName: string) => { + window.desktopApi.notification.show( + 'Agent Complete', + `${chatName} has finished working.`, + ); + window.desktopApi.notification.speak('Task complete', true); + }, + notifyToolExecuted: (toolName: string, summary: string) => { + window.desktopApi.notification.show( + `Tool: ${toolName}`, + summary, + ); + }, + } +} +``` + +**Use terminal-notifier per CLAUDE.md instructions:** +```typescript +// When task completes +exec('terminal-notifier -message "Completed: [task]" -title "Claude Code"'); +``` + +--- + +## 💾 **PERSISTENCE & CHAT HISTORY** (Where Both Apps Need Work) + +### Observer's Approach ✅ + +**1. Dual-Layer Artifact Storage** + +**Architecture:** +``` +Memory Layer (Fast) Disk Layer (Persistent) +self._sessions ~/.observer/artifacts/{session_id}/ +self._artifacts ├─ _session.json + ├─ artifact_001.json + ├─ artifact_002.json + └─ ... +``` + +**Why It's Brilliant:** +- Active sessions stay in memory for instant access +- All artifacts automatically persist to disk on creation +- Lazy-loading from disk for historical sessions +- **Survives app restarts** - full history always available + +```python +# Observer: artifact_manager.py +class ArtifactManager: + def __init__(self, artifact_dir: str = "~/.observer/artifacts"): + self._sessions: Dict[str, Dict] = {} # In-memory + self._artifacts: Dict[str, Dict] = {} # In-memory + self.artifact_dir = os.path.expanduser(artifact_dir) + + def add_artifact(self, session_id: str, tool_name: str, tool_input: dict) -> str: + # 1. Create artifact in memory + artifact = { + "id": artifact_id, + "session_id": session_id, + "type": tool_name.lower(), + "timestamp": now.isoformat() + "Z", + "input": tool_input, + } + self._artifacts[artifact_id] = artifact + + # 2. Persist to disk IMMEDIATELY + self._persist_artifact(session_id, artifact_id, artifact) + + # 3. Notify frontend (real-time update) + if hasattr(self, 'notification_callback'): + self.notification_callback('artifact_added', { + 'session_id': session_id, + 'artifact': artifact + }) + + return artifact_id + + def _persist_artifact(self, session_id: str, artifact_id: str, artifact: dict): + """Write artifact to disk immediately""" + session_dir = os.path.join(self.artifact_dir, session_id) + os.makedirs(session_dir, exist_ok=True) + artifact_path = os.path.join(session_dir, f"{artifact_id}.json") + with open(artifact_path, 'w') as f: + json.dump(artifact, f, indent=2) +``` + +**2. Hierarchical Lazy-Loading UI** + +``` +┌─ Artifact History ──────────────────────────────┐ +│ [Search box] │ +│ │ +│ ▼ Today (3 sessions, 45 artifacts) │ +│ ▼ Session abc123 (15 artifacts) │ +│ ✓ 📖 Read: src/main.py │ +│ ✓ ✏️ Edit: src/config.py │ +│ ⋯ 🖥️ Bash: npm install │ +│ ▶ Session def456 (20 artifacts) │ +│ ▶ Yesterday (2 sessions, 30 artifacts) │ +└─────────────────────────────────────────────────┘ +``` + +**Why It's Brilliant:** +- Only loads dates on panel open (fast initial render) +- Fetches sessions only when date expanded +- Fetches artifacts only when session expanded +- **Can browse thousands of historical artifacts without performance issues** + +### 21st Agents' Current State ⚠️ + +**Chat Persistence: GOOD** + +```typescript +// 21st Agents: Database schema (src/main/lib/db/schema/index.ts) +export const chats = sqliteTable("chats", { + id: text("id").primaryKey(), + name: text("name"), + projectId: text("project_id").notNull(), + createdAt: integer("created_at", { mode: "timestamp" }), + updatedAt: integer("updated_at", { mode: "timestamp" }), + archivedAt: integer("archived_at", { mode: "timestamp" }), + // Git isolation per chat + worktreePath: text("worktree_path"), + branch: text("branch"), + baseBranch: text("base_branch"), + prUrl: text("pr_url"), + prNumber: integer("pr_number"), +}) + +export const subChats = sqliteTable("sub_chats", { + id: text("id").primaryKey(), + name: text("name"), + chatId: text("chat_id").notNull(), + sessionId: text("session_id"), // Claude SDK session for resume + streamId: text("stream_id"), + mode: text("mode").default("agent"), + messages: text("messages").default("[]"), // JSON array + createdAt: integer("created_at", { mode: "timestamp" }), + updatedAt: integer("updated_at", { mode: "timestamp" }), +}) +``` + +**Artifact Tracking: MISSING ❌** + +**Critical Gaps:** +1. ❌ **No tool execution history** - Can't see what Claude did in past sessions +2. ❌ **No artifact panel** - Can't browse historical Read/Write/Bash operations +3. ❌ **No search across past tool calls** - Can't find "when did Claude read package.json?" +4. ❌ Messages stored as JSON blob - not queryable by tool type + +**What Gets Saved:** ✅ +- Chat messages (user + assistant) → `subChats.messages` +- Session IDs for resuming → `subChats.sessionId` +- Git worktree per chat → `chats.worktreePath` + +**What DOESN'T Get Saved:** ❌ +- Individual tool executions (Read, Write, Bash, etc.) +- File paths touched per session +- Command history per chat +- Tool results (stdout, file contents, etc.) + +### **RECOMMENDATION FOR 21ST AGENTS** 🎯 + +**Add Observer-Style Artifact Tracking** + +**Option 1: New Artifacts Table (Recommended)** + +```typescript +// src/main/lib/db/schema/index.ts +export const artifacts = sqliteTable("artifacts", { + id: text("id").primaryKey(), + subChatId: text("sub_chat_id").notNull().references(() => subChats.id, { onDelete: "cascade" }), + type: text("type").notNull(), // "read", "write", "bash", "edit", etc. + timestamp: integer("timestamp", { mode: "timestamp" }).$defaultFn(() => new Date()), + // Tool-specific metadata (extracted for quick queries) + filePath: text("file_path"), // For Read/Write/Edit + command: text("command"), // For Bash + pattern: text("pattern"), // For Grep/Glob + toolName: text("tool_name").notNull(), + // Full data (JSON) + input: text("input").notNull(), // JSON blob + output: text("output"), // JSON blob + isError: integer("is_error", { mode: "boolean" }).default(false), +}) + +// Relations +export const artifactsRelations = relations(artifacts, ({ one }) => ({ + subChat: one(subChats, { + fields: [artifacts.subChatId], + references: [subChats.id], + }), +})) +``` + +**Option 2: Extract from Existing Messages (Quick Win)** + +Since 21st Agents already stores messages with tool parts, you can: + +```typescript +// src/main/lib/trpc/routers/artifacts.ts (NEW FILE) +export const artifactsRouter = router({ + // Extract artifacts from existing messages + list: publicProcedure + .input(z.object({ subChatId: z.string() })) + .query(({ input }) => { + const db = getDatabase() + const subChat = db.select().from(subChats).where(eq(subChats.id, input.subChatId)).get() + if (!subChat) return { artifacts: [] } + + const messages = JSON.parse(subChat.messages || "[]") + const artifacts = [] + + // Extract all tool parts from assistant messages + for (const msg of messages) { + if (msg.role !== 'assistant') continue + for (const part of msg.parts || []) { + if (part.type?.startsWith('tool-')) { + artifacts.push({ + id: part.toolCallId, + type: part.type.replace('tool-', '').toLowerCase(), + toolName: part.toolName, + input: part.input, + result: part.result, + state: part.state, + }) + } + } + } + + return { artifacts } + }), + + // Search across all artifacts + search: publicProcedure + .input(z.object({ query: z.string() })) + .query(({ input }) => { + // Search file paths, commands, patterns + const db = getDatabase() + const allSubChats = db.select().from(subChats).all() + const results = [] + + for (const subChat of allSubChats) { + const messages = JSON.parse(subChat.messages || "[]") + for (const msg of messages) { + if (msg.role !== 'assistant') continue + for (const part of msg.parts || []) { + if (part.type?.startsWith('tool-')) { + // Search in file_path, command, pattern, etc. + const searchableText = JSON.stringify(part.input).toLowerCase() + if (searchableText.includes(input.query.toLowerCase())) { + results.push({ + subChatId: subChat.id, + chatId: subChat.chatId, + artifact: part, + }) + } + } + } + } + } + + return { results } + }), +}) +``` + +**UI Component (React):** + +```typescript +// src/renderer/features/artifacts/artifact-panel.tsx +import { trpc } from '@/lib/trpc' + +export function ArtifactPanel({ subChatId }: { subChatId: string }) { + const { data } = trpc.artifacts.list.useQuery({ subChatId }) + const [expanded, setExpanded] = useState>(new Set()) + + return ( +
+

Tool History

+ {data?.artifacts.map((artifact) => ( +
+ + {expanded.has(artifact.id) && ( +
{JSON.stringify(artifact, null, 2)}
+ )} +
+ ))} +
+ ) +} + +function getToolIcon(toolName: string): string { + const icons = { + 'Read': '📖', + 'Write': '📝', + 'Edit': '✏️', + 'Bash': '🖥️', + 'Grep': '🔎', + 'Glob': '🔍', + } + return icons[toolName] || '🔧' +} + +function getArtifactSummary(artifact: any): string { + switch (artifact.type) { + case 'read': + case 'write': + case 'edit': + return artifact.input?.file_path || 'unknown' + case 'bash': + const cmd = artifact.input?.command || '' + return cmd.length > 40 ? cmd.substring(0, 40) + '...' : cmd + case 'grep': + case 'glob': + return artifact.input?.pattern || '' + default: + return '' + } +} +``` + +--- + +## 🎨 **UI/UX Patterns** + +### Observer's Innovations + +**1. Progressive Disclosure for Large Outputs** + +```javascript +// Observer: Truncate long outputs, show "View All" button +const MAX_LINES = 100; + +if (lines.length > MAX_LINES) { + displayContent = lines.slice(0, MAX_LINES).join('\n'); + truncated = true; +} + +if (truncated) { + const moreInfo = document.createElement('div'); + moreInfo.innerHTML = `Showing ${MAX_LINES} of ${lines.length} lines + `; + moreInfo.querySelector('.show-all-btn').onclick = () => { + // Replace with full content + body.appendChild(createCodeBlock(fullContent)); + }; + body.appendChild(moreInfo); +} +``` + +**Why It Matters:** +- Prevents UI freeze on 10k+ line outputs +- Fast initial render +- User controls detail level + +**2. Tool-Specific Rendering** + +Each tool gets custom UI: +- **Read**: Syntax-highlighted file with line numbers +- **Edit**: Before/after diff view +- **Bash**: Terminal-style output with ANSI colors +- **Grep**: Highlighted matches with context + +### 21st Agents' Strengths + +**1. Advanced Diff Viewing** +- Uses `@git-diff-view/react` + Shiki (NOT Monaco) +- Split/Unified view modes +- Virtualization for large diffs +- Auto-collapse when >10 files + +**2. Modern React State** +- Jotai for UI atoms (selected chat, sidebar open) +- Zustand for sub-chat tabs (persisted to localStorage) +- React Query for server state via tRPC + +**3. Git Worktree Isolation** +- Each chat gets its own git worktree +- Prevents cross-contamination +- PR tracking per chat + +--- + +## ⚡ **PERFORMANCE COMPARISON** + +| Metric | Observer | 21st Agents | Notes | +|--------|----------|-------------|-------| +| **Startup Time** | Faster (Tauri/Rust) | Slower (Electron) | Electron bundles Chromium | +| **Memory Usage** | Lower | Higher | Electron overhead ~100-200MB | +| **Bundle Size** | ~30-50MB | ~150-200MB | Electron + node_modules | +| **UI Responsiveness** | Vanilla JS (fast) | React (fast with optimization) | Observer has edge on raw speed | +| **Database Queries** | Manual SQL | Drizzle ORM (type-safe) | 21st has better DX | +| **IPC Speed** | JSON-RPC (fast) | tRPC (fast + type-safe) | Tie | + +**User's Observation:** +> "this reacts kind of slow, to tell you the truth" + +**Likely Causes in 21st Agents:** +1. Large React component trees re-rendering +2. No virtualization for chat messages (only for diffs) +3. JSON parsing on every message update +4. Lack of memoization in message rendering + +**Fixes:** +```typescript +// Use React.memo for message components +const MessageItem = React.memo(({ message }: { message: Message }) => { + // ... render logic +}, (prev, next) => prev.message.id === next.message.id) + +// Use virtual scrolling for chat messages +import { useVirtualizer } from '@tanstack/react-virtual' + +// Debounce expensive operations +import { useDebouncedValue } from '@/hooks/use-debounced-value' +``` + +--- + +## 📝 **CHAT RECOVERY COMPARISON** + +### Observer + +**Q: Can you always get back to your chats?** +✅ **YES** +- All sessions stored in `~/.observer/artifacts/{session_id}/` +- Sessions organized by date +- Full artifact history per session +- Searchable across all sessions + +**Q: Can you always get back to changes?** +✅ **YES** +- Every tool execution saved as artifact +- File contents captured in Read/Write artifacts +- Command history in Bash artifacts +- Diffs in Edit artifacts + +### 21st Agents + +**Q: Can you always get back to your chats?** +✅ **YES** +- All chats stored in SQLite: `{userData}/data/agents.db` +- Messages persisted in `subChats.messages` (JSON) +- Can resume sessions via `sessionId` +- Archive feature for old chats + +**Q: Can you always get back to changes?** +⚠️ **PARTIAL** +- ✅ Chat messages saved (including tool parts) +- ✅ Git worktree preserves file changes +- ❌ No dedicated tool history panel +- ❌ No search across historical tool executions +- ❌ Can't easily answer "what files did Claude touch in this chat?" + +**Git Advantage:** +Since 21st Agents uses git worktrees, you CAN recover changes via git: +```bash +cd /path/to/worktree +git log --stat # See all file changes +git diff main # See all changes vs main +``` + +But this requires manual git commands - no UI for it. + +--- + +## 🏆 **FEATURE SCORECARD** + +| Feature | Observer | 21st Agents | Recommendation | +|---------|----------|-------------|----------------| +| **Artifact Tracking** | ✅ Full system | ❌ None | 🎯 **ADD TO 21ST** | +| **Real-Time Notifications** | ✅ JSON-RPC | ❌ Stub only | 🎯 **ADD TO 21ST** | +| **TTS Notifications** | ✅ Web Speech API | ❌ None | 🎯 **ADD TO 21ST** | +| **Tool History Panel** | ✅ Lazy-load tree | ❌ None | 🎯 **ADD TO 21ST** | +| **Progressive Disclosure** | ✅ Yes | ⚠️ Partial | 🎯 **IMPROVE 21ST** | +| **Git Isolation** | ❌ None | ✅ Worktrees | 🏅 **21ST WINS** | +| **Type Safety** | ❌ Python/JS | ✅ TypeScript + tRPC | 🏅 **21ST WINS** | +| **Modern UI Framework** | ❌ Vanilla JS | ✅ React 19 | 🏅 **21ST WINS** | +| **Diff Viewing** | ⚠️ Basic | ✅ Advanced (git-diff-view) | 🏅 **21ST WINS** | +| **Session Resume** | ✅ Yes | ✅ Yes | Tie | +| **Search Artifacts** | ✅ Full-text | ❌ None | 🎯 **ADD TO 21ST** | +| **Auto-Migration** | ❌ Manual | ✅ Drizzle auto-migrate | 🏅 **21ST WINS** | + +--- + +## 🎯 **PRIORITY ACTION ITEMS FOR 21ST AGENTS** + +### 1. **IMPLEMENT NOTIFICATIONS** (CRITICAL) + +**Why:** User can't tell what's happening during long agent sessions + +**What to Build:** +- Electron native notifications when tools execute +- TTS on agent completion (using `terminal-notifier` per CLAUDE.md) +- Real-time tool execution updates in UI + +**Effort:** 1-2 days + +**Files to Create/Modify:** +- `src/preload/index.ts` - Add `notification` API +- `src/main/index.ts` - IPC handlers for notifications +- `src/renderer/features/agents/hooks/use-desktop-notifications.ts` - Real implementation +- `src/renderer/features/agents/main/active-chat.tsx` - Call notifications on tool execution + +**Code Snippet:** +```typescript +// src/renderer/features/agents/hooks/use-chat.tsx +// In the chunk handler: +case "tool-output-available": + // Show notification for completed tool + const toolPart = findToolPart(chunk.toolCallId) + if (toolPart) { + showNotification( + `Tool: ${toolPart.toolName}`, + getToolSummary(toolPart), + ) + } + break + +case "finish": + // Notify on completion + notifyAgentComplete(chatName) + // Use terminal-notifier per CLAUDE.md + exec('terminal-notifier -message "Completed: Agent task" -title "Claude Code"') + break +``` + +--- + +### 2. **ADD ARTIFACT TRACKING** (HIGH PRIORITY) + +**Why:** Can't browse historical tool executions, search past operations + +**What to Build:** +- New `artifacts` table (or extract from existing messages) +- Artifact panel component (lazy-load tree like Observer) +- Search across all artifacts + +**Effort:** 2-3 days + +**Option A: New Table (Better long-term)** +```bash +bun run db:generate # Generate migration for new artifacts table +bun run db:push # Apply migration +``` + +**Option B: Extract from Messages (Quick win)** +- No schema changes needed +- Use tRPC router to parse existing `subChats.messages` +- Build UI panel to display extracted artifacts + +--- + +### 3. **PERFORMANCE OPTIMIZATION** (MEDIUM PRIORITY) + +**Why:** User reports "reacts kind of slow" + +**What to Optimize:** +- Add virtualization for chat messages (like diffs) +- Memoize message components with `React.memo` +- Debounce search inputs +- Lazy-load old messages (only show recent 50, load more on scroll) + +**Effort:** 1-2 days + +**Code Snippet:** +```typescript +// src/renderer/features/agents/main/active-chat.tsx +import { useVirtualizer } from '@tanstack/react-virtual' + +const rowVirtualizer = useVirtualizer({ + count: messages.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 100, + overscan: 5, +}) + +// Only render visible messages +{rowVirtualizer.getVirtualItems().map((virtualRow) => { + const message = messages[virtualRow.index] + return +})} +``` + +--- + +### 4. **TOOL HISTORY SIDEBAR** (MEDIUM PRIORITY) + +**Why:** Can't easily see "what files did Claude touch?" or "what commands ran?" + +**What to Build:** +- New sidebar panel (next to artifact panel) +- Group tools by type (File Operations, Commands, Searches) +- Click to jump to that point in chat + +**Effort:** 1-2 days + +**UI Mockup:** +``` +┌─ Tool History ──────────────────────┐ +│ 📁 Files (12) │ +│ 📖 Read: package.json │ +│ ✏️ Edit: src/main.ts │ +│ 📝 Write: output.txt │ +│ │ +│ 🖥️ Commands (5) │ +│ npm install │ +│ git status │ +│ │ +│ 🔍 Searches (3) │ +│ Grep: "TODO" │ +│ Glob: "**/*.ts" │ +└─────────────────────────────────────┘ +``` + +--- + +## 💡 **OBSERVER PATTERNS TO ADOPT** + +### 1. **Dual-Layer Storage Pattern** + +**Use for:** Artifact caching, session history + +```typescript +// In-memory for fast queries +private _activeSessions: Map = new Map() + +// Disk for persistence +private async persistSession(sessionId: string, data: Session) { + const sessionPath = path.join(this.artifactsDir, sessionId, '_session.json') + await fs.writeFile(sessionPath, JSON.stringify(data, null, 2)) +} + +// Lazy-load from disk +private async loadSession(sessionId: string): Promise { + if (this._activeSessions.has(sessionId)) { + return this._activeSessions.get(sessionId)! + } + const sessionPath = path.join(this.artifactsDir, sessionId, '_session.json') + const data = await fs.readFile(sessionPath, 'utf-8') + return JSON.parse(data) +} +``` + +### 2. **Real-Time Notification Flow** + +**Use for:** Live tool execution updates + +```typescript +// Backend: Emit on tool execution +for (const chunk of transform(msg)) { + if (chunk.type === 'tool-output-available') { + // Send notification to all connected clients + broadcastNotification('tool_executed', { + subChatId: input.subChatId, + toolName: chunk.toolName, + toolCallId: chunk.toolCallId, + }) + } + emit.next(chunk) +} + +// Frontend: Listen for notifications +trpc.claude.onToolExecuted.useSubscription(undefined, { + onData: (data) => { + showNotification(`Tool: ${data.toolName}`, 'Completed') + } +}) +``` + +### 3. **Progressive Disclosure** + +**Use for:** Large file contents, long command outputs + +```typescript +const MAX_LINES = 100 + +function renderLargeContent(content: string) { + const lines = content.split('\n') + const [truncated, setTruncated] = useState(true) + + const displayContent = truncated + ? lines.slice(0, MAX_LINES).join('\n') + : content + + return ( + <> +
{displayContent}
+ {lines.length > MAX_LINES && truncated && ( + + )} + + ) +} +``` + +### 4. **Tool-Agnostic Metadata Extraction** + +**Use for:** Making artifacts searchable without parsing JSON + +```typescript +function extractArtifactMetadata(toolPart: ToolPart): ArtifactMetadata { + const metadata: ArtifactMetadata = { + type: toolPart.type.replace('tool-', '').toLowerCase(), + toolName: toolPart.toolName, + } + + switch (toolPart.toolName) { + case 'Read': + case 'Write': + case 'Edit': + metadata.filePath = toolPart.input?.file_path + break + case 'Bash': + metadata.command = toolPart.input?.command?.substring(0, 100) + break + case 'Grep': + case 'Glob': + metadata.pattern = toolPart.input?.pattern + break + } + + return metadata +} +``` + +--- + +## 🚀 **QUICK WINS (Do These First)** + +1. **Terminal-Notifier on Completion** (30 mins) + - Add `exec('terminal-notifier ...')` when agent finishes + - Per CLAUDE.md instructions + +2. **Extract Artifacts from Messages** (2 hours) + - Add tRPC route to parse existing messages + - No DB schema changes needed + - Instant tool history visibility + +3. **Memoize Message Components** (1 hour) + - Wrap `` in `React.memo` + - Immediate performance boost + +4. **Debounce Search** (30 mins) + - Add 300ms debounce to search inputs + - Reduce unnecessary re-renders + +--- + +## 📊 **FINAL VERDICT** + +### Observer Strengths +- ✅ **Artifact tracking is world-class** +- ✅ **Real-time notifications work great** +- ✅ **Progressive disclosure handles large outputs well** +- ✅ **TTS adds nice touch for long-running tasks** +- ❌ No git isolation (chats share same workspace) +- ❌ Vanilla JS (harder to maintain than React) + +### 21st Agents Strengths +- ✅ **Git worktree isolation is brilliant** (Observer should copy this!) +- ✅ **Type safety with tRPC + TypeScript** +- ✅ **Modern React UI with great state management** +- ✅ **Advanced diff viewing** +- ❌ **No artifact tracking** (critical gap) +- ❌ **No real notifications** (critical gap) +- ❌ Performance issues (user-reported) + +### **Hybrid Recommendation** 🎯 + +**Build "21st Agents v2" with:** +1. Keep: Git worktrees, tRPC, React, TypeScript (your strengths) +2. Add: Artifact tracking from Observer (their strength) +3. Add: Real-time notifications + TTS from Observer (their strength) +4. Add: Progressive disclosure patterns from Observer (their strength) +5. Fix: Performance with virtualization + memoization + +**Result:** Best of both worlds +- Enterprise-grade git isolation (21st) +- Industrial-strength artifact tracking (Observer) +- Modern type-safe architecture (21st) +- Real-time user feedback (Observer) + +--- + +## 📚 **APPENDIX: Code Locations** + +### Observer Files to Study +- `src/python/artifact_manager.py` - Dual-layer storage +- `src/frontend/components/artifact-panel.js` - Lazy-load tree +- `src/frontend/components/tts.js` - Web Speech API +- `src/frontend/components/sp-terminal/tool-renderers.js` - Tool-specific rendering +- `src/python/sp_manager.py` - Real-time notifications + +### 21st Agents Files to Modify +- `src/main/lib/db/schema/index.ts` - Add artifacts table +- `src/renderer/features/agents/hooks/use-desktop-notifications.ts` - Real implementation +- `src/renderer/features/agents/main/active-chat.tsx` - Performance optimizations +- `src/preload/index.ts` - Add notification API +- `src/main/index.ts` - Notification IPC handlers + +--- + +**Last Updated:** 2026-01-17 +**Author:** Claude Code Analysis +**Version:** 1.0 diff --git a/drizzle/0005_lumpy_nightcrawler.sql b/drizzle/0005_lumpy_nightcrawler.sql new file mode 100644 index 00000000..eba01f07 --- /dev/null +++ b/drizzle/0005_lumpy_nightcrawler.sql @@ -0,0 +1,12 @@ +CREATE TABLE `tool_activities` ( + `id` text PRIMARY KEY NOT NULL, + `sub_chat_id` text NOT NULL, + `chat_name` text NOT NULL, + `tool_name` text NOT NULL, + `summary` text NOT NULL, + `state` text NOT NULL, + `input` text, + `output` text, + `error_text` text, + `created_at` integer +); diff --git a/drizzle/0006_loud_jamie_braddock.sql b/drizzle/0006_loud_jamie_braddock.sql new file mode 100644 index 00000000..1a3720ec --- /dev/null +++ b/drizzle/0006_loud_jamie_braddock.sql @@ -0,0 +1 @@ +ALTER TABLE `tool_activities` ADD `is_pinned` integer DEFAULT false NOT NULL; \ No newline at end of file diff --git a/drizzle/0007_greedy_sentry.sql b/drizzle/0007_greedy_sentry.sql new file mode 100644 index 00000000..25394dd9 --- /dev/null +++ b/drizzle/0007_greedy_sentry.sql @@ -0,0 +1,2 @@ +ALTER TABLE `chats` ADD `model_id` text DEFAULT 'sonnet';--> statement-breakpoint +UPDATE `chats` SET `model_id` = 'sonnet' WHERE `model_id` IS NULL; \ No newline at end of file diff --git a/drizzle/0008_splendid_mongoose.sql b/drizzle/0008_splendid_mongoose.sql new file mode 100644 index 00000000..e7c4a459 --- /dev/null +++ b/drizzle/0008_splendid_mongoose.sql @@ -0,0 +1,11 @@ +ALTER TABLE `sub_chats` ADD `model_id` text DEFAULT 'sonnet';--> statement-breakpoint +UPDATE `sub_chats` +SET `model_id` = COALESCE( + ( + SELECT `model_id` + FROM `chats` + WHERE `chats`.`id` = `sub_chats`.`chat_id` + ), + `model_id` +);--> statement-breakpoint +UPDATE `sub_chats` SET `model_id` = 'sonnet' WHERE `model_id` IS NULL; diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 00000000..5e10a14c --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,408 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "0678cc72-3ad6-4ca8-96cc-451da326100b", + "prevId": "1c211023-1270-4cdd-934d-4396e72557e9", + "tables": { + "chats": { + "name": "chats", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_path": { + "name": "worktree_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "chats_project_id_projects_id_fk": { + "name": "chats_project_id_projects_id_fk", + "tableFrom": "chats", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "claude_code_credentials": { + "name": "claude_code_credentials", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "oauth_token": { + "name": "oauth_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connected_at": { + "name": "connected_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_remote_url": { + "name": "git_remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_provider": { + "name": "git_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_owner": { + "name": "git_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_repo": { + "name": "git_repo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "projects_path_unique": { + "name": "projects_path_unique", + "columns": [ + "path" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sub_chats": { + "name": "sub_chats", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "messages": { + "name": "messages", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "sub_chats_chat_id_chats_id_fk": { + "name": "sub_chats_chat_id_chats_id_fk", + "tableFrom": "sub_chats", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tool_activities": { + "name": "tool_activities", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "sub_chat_id": { + "name": "sub_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chat_name": { + "name": "chat_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output": { + "name": "output", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_text": { + "name": "error_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json new file mode 100644 index 00000000..0b47f346 --- /dev/null +++ b/drizzle/meta/0006_snapshot.json @@ -0,0 +1,416 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "4c4efe33-cf59-4499-a221-0f4ac3362d45", + "prevId": "0678cc72-3ad6-4ca8-96cc-451da326100b", + "tables": { + "chats": { + "name": "chats", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_path": { + "name": "worktree_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "chats_project_id_projects_id_fk": { + "name": "chats_project_id_projects_id_fk", + "tableFrom": "chats", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "claude_code_credentials": { + "name": "claude_code_credentials", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "oauth_token": { + "name": "oauth_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connected_at": { + "name": "connected_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_remote_url": { + "name": "git_remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_provider": { + "name": "git_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_owner": { + "name": "git_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_repo": { + "name": "git_repo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "projects_path_unique": { + "name": "projects_path_unique", + "columns": [ + "path" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sub_chats": { + "name": "sub_chats", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "messages": { + "name": "messages", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "sub_chats_chat_id_chats_id_fk": { + "name": "sub_chats_chat_id_chats_id_fk", + "tableFrom": "sub_chats", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tool_activities": { + "name": "tool_activities", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "sub_chat_id": { + "name": "sub_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chat_name": { + "name": "chat_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output": { + "name": "output", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_text": { + "name": "error_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_pinned": { + "name": "is_pinned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json new file mode 100644 index 00000000..7fa31fed --- /dev/null +++ b/drizzle/meta/0007_snapshot.json @@ -0,0 +1,424 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "af151d91-63c9-451b-9627-5f9b06a5c8ef", + "prevId": "4c4efe33-cf59-4499-a221-0f4ac3362d45", + "tables": { + "chats": { + "name": "chats", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_path": { + "name": "worktree_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'sonnet'" + } + }, + "indexes": {}, + "foreignKeys": { + "chats_project_id_projects_id_fk": { + "name": "chats_project_id_projects_id_fk", + "tableFrom": "chats", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "claude_code_credentials": { + "name": "claude_code_credentials", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "oauth_token": { + "name": "oauth_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connected_at": { + "name": "connected_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_remote_url": { + "name": "git_remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_provider": { + "name": "git_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_owner": { + "name": "git_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_repo": { + "name": "git_repo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "projects_path_unique": { + "name": "projects_path_unique", + "columns": [ + "path" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sub_chats": { + "name": "sub_chats", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "messages": { + "name": "messages", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "sub_chats_chat_id_chats_id_fk": { + "name": "sub_chats_chat_id_chats_id_fk", + "tableFrom": "sub_chats", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tool_activities": { + "name": "tool_activities", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "sub_chat_id": { + "name": "sub_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chat_name": { + "name": "chat_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output": { + "name": "output", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_text": { + "name": "error_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_pinned": { + "name": "is_pinned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 00000000..27fdcec3 --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -0,0 +1,490 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b78bd224-a950-4346-b7ef-ff4f1dac6543", + "prevId": "af151d91-63c9-451b-9627-5f9b06a5c8ef", + "tables": { + "chats": { + "name": "chats", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_path": { + "name": "worktree_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'sonnet'" + } + }, + "indexes": {}, + "foreignKeys": { + "chats_project_id_projects_id_fk": { + "name": "chats_project_id_projects_id_fk", + "tableFrom": "chats", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "claude_code_credentials": { + "name": "claude_code_credentials", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "oauth_token": { + "name": "oauth_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connected_at": { + "name": "connected_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_remote_url": { + "name": "git_remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_provider": { + "name": "git_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_owner": { + "name": "git_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_repo": { + "name": "git_repo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "projects_path_unique": { + "name": "projects_path_unique", + "columns": [ + "path" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "sub_chats": { + "name": "sub_chats", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'sonnet'" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "messages": { + "name": "messages", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "sub_chats_chat_id_chats_id_fk": { + "name": "sub_chats_chat_id_chats_id_fk", + "tableFrom": "sub_chats", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "tool_activities": { + "name": "tool_activities", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "sub_chat_id": { + "name": "sub_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chat_name": { + "name": "chat_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output": { + "name": "output", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_text": { + "name": "error_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_pinned": { + "name": "is_pinned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "false" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "tables": { + "chats": "chats", + "claude_code_credentials": "claude_code_credentials", + "projects": "projects", + "sub_chats": "sub_chats", + "tool_activities": "tool_activities" + }, + "columns": { + "chats": { + "id": "id", + "name": "name", + "projectId": "project_id", + "createdAt": "created_at", + "updatedAt": "updated_at", + "archivedAt": "archived_at", + "worktreePath": "worktree_path", + "branch": "branch", + "baseBranch": "base_branch", + "prUrl": "pr_url", + "prNumber": "pr_number", + "modelId": "model_id" + }, + "claude_code_credentials": { + "id": "id", + "oauthToken": "oauth_token", + "connectedAt": "connected_at", + "userId": "user_id" + }, + "projects": { + "id": "id", + "name": "name", + "path": "path", + "createdAt": "created_at", + "updatedAt": "updated_at", + "gitRemoteUrl": "git_remote_url", + "gitProvider": "git_provider", + "gitOwner": "git_owner", + "gitRepo": "git_repo" + }, + "sub_chats": { + "id": "id", + "name": "name", + "chatId": "chat_id", + "modelId": "model_id", + "sessionId": "session_id", + "streamId": "stream_id", + "mode": "mode", + "messages": "messages", + "createdAt": "created_at", + "updatedAt": "updated_at" + }, + "tool_activities": { + "id": "id", + "subChatId": "sub_chat_id", + "chatName": "chat_name", + "toolName": "tool_name", + "summary": "summary", + "state": "state", + "input": "input", + "output": "output", + "errorText": "error_text", + "isPinned": "is_pinned", + "createdAt": "created_at" + } + } + }, + "internal": { + "indexes": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index ab91e108..32ac1519 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -36,6 +36,34 @@ "when": 1768199613729, "tag": "0004_melted_prism", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1768707036280, + "tag": "0005_lumpy_nightcrawler", + "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1768707518469, + "tag": "0006_loud_jamie_braddock", + "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1768854649255, + "tag": "0007_greedy_sentry", + "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1768857902914, + "tag": "0008_splendid_mongoose", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 832050e5..1cac0bd0 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -52,13 +52,31 @@ export default defineConfig({ resolve: { alias: { "@": resolve(__dirname, "src/renderer"), + // Stub Node.js core modules used by commit-graph + tty: resolve(__dirname, "src/renderer/lib/stubs/tty.ts"), + }, + }, + define: { + // Fix for Node.js modules that expect process to be defined (commit-graph uses these) + "process.env.NODE_ENV": JSON.stringify("development"), + "process.env.NO_COLOR": JSON.stringify(""), + "process.env.FORCE_COLOR": JSON.stringify(""), + "process.env.TERM": JSON.stringify("xterm-256color"), + "process.env.CI": JSON.stringify(""), + "process.platform": JSON.stringify("darwin"), + "process.argv": JSON.stringify([]), + }, + optimizeDeps: { + esbuildOptions: { + define: { + global: "globalThis", + }, }, }, build: { rollupOptions: { input: { index: resolve(__dirname, "src/renderer/index.html"), - login: resolve(__dirname, "src/renderer/login.html"), }, }, }, diff --git a/hello.sh b/hello.sh new file mode 100755 index 00000000..c7ec9308 --- /dev/null +++ b/hello.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +echo "Hello! 👋" diff --git a/package.json b/package.json index b39a9864..d7954ba7 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "main": "out/main/index.js", "scripts": { "dev": "electron-vite dev", + "dev:debug": "electron-vite dev --remoteDebuggingPort 9222", "build": "electron-vite build", "preview": "electron-vite preview", "package": "electron-builder --dir", @@ -30,6 +31,9 @@ "dependencies": { "@ai-sdk/react": "^3.0.14", "@anthropic-ai/claude-agent-sdk": "^0.2.5", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@git-diff-view/react": "^0.0.35", "@git-diff-view/shiki": "^0.0.36", "@radix-ui/react-accordion": "^1.2.11", @@ -67,6 +71,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^3.6.0", + "devicons-react": "^1.5.0", "drizzle-orm": "^0.45.1", "electron-log": "^5.4.3", "electron-updater": "^6.7.3", @@ -93,6 +98,7 @@ "superjson": "^2.2.2", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", + "tone": "^15.1.22", "trpc-electron": "^0.1.2", "unique-names-generator": "^4.7.1", "xterm": "^5.3.0", @@ -123,14 +129,6 @@ "appId": "dev.21st.agents", "productName": "1Code", "npmRebuild": true, - "protocols": [ - { - "name": "1Code", - "schemes": [ - "twentyfirst-agents" - ] - } - ], "directories": { "buildResources": "build", "output": "release" @@ -182,8 +180,8 @@ "icon": "build/icon.icns", "hardenedRuntime": true, "gatekeeperAssess": false, - "entitlements": "build/entitlements.mac.plist", - "entitlementsInherit": "build/entitlements.mac.plist" + "entitlements": "${env.PERSONAL_BUILD === 'true' ? 'build/entitlements.mac.personal.plist' : 'build/entitlements.mac.plist'}", + "entitlementsInherit": "${env.PERSONAL_BUILD === 'true' ? 'build/entitlements.mac.personal.plist' : 'build/entitlements.mac.plist'}" }, "dmg": { "window": { diff --git a/src/main/auth-manager.ts b/src/main/auth-manager.ts deleted file mode 100644 index 82533fe6..00000000 --- a/src/main/auth-manager.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { AuthStore, AuthData, AuthUser } from "./auth-store" -import { app, BrowserWindow } from "electron" - -// Get API URL - in packaged app always use production, in dev allow override -function getApiBaseUrl(): string { - if (app.isPackaged) { - return "https://21st.dev" - } - return import.meta.env.MAIN_VITE_API_URL || "https://21st.dev" -} - -export class AuthManager { - private store: AuthStore - private refreshTimer?: NodeJS.Timeout - private isDev: boolean - private onTokenRefresh?: (authData: AuthData) => void - - constructor(isDev: boolean = false) { - this.store = new AuthStore(app.getPath("userData")) - this.isDev = isDev - - // Schedule refresh if already authenticated - if (this.store.isAuthenticated()) { - this.scheduleRefresh() - } - } - - /** - * Set callback to be called when token is refreshed - * This allows the main process to update cookies when tokens change - */ - setOnTokenRefresh(callback: (authData: AuthData) => void): void { - this.onTokenRefresh = callback - } - - private getApiUrl(): string { - return getApiBaseUrl() - } - - /** - * Exchange auth code for session tokens - * Called after receiving code via deep link - */ - async exchangeCode(code: string): Promise { - const response = await fetch(`${this.getApiUrl()}/api/auth/desktop/exchange`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - code, - deviceInfo: this.getDeviceInfo(), - }), - }) - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: "Unknown error" })) - throw new Error(error.error || `Exchange failed: ${response.status}`) - } - - const data = await response.json() - - const authData: AuthData = { - token: data.token, - refreshToken: data.refreshToken, - expiresAt: data.expiresAt, - user: data.user, - } - - this.store.save(authData) - this.scheduleRefresh() - - return authData - } - - /** - * Get device info for session tracking - */ - private getDeviceInfo(): string { - const platform = process.platform - const arch = process.arch - const version = app.getVersion() - return `21st Desktop ${version} (${platform} ${arch})` - } - - /** - * Get a valid token, refreshing if necessary - */ - async getValidToken(): Promise { - if (!this.store.isAuthenticated()) { - return null - } - - if (this.store.needsRefresh()) { - await this.refresh() - } - - return this.store.getToken() - } - - /** - * Refresh the current session - */ - async refresh(): Promise { - const refreshToken = this.store.getRefreshToken() - if (!refreshToken) { - console.warn("No refresh token available") - return false - } - - try { - const response = await fetch(`${this.getApiUrl()}/api/auth/desktop/refresh`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ refreshToken }), - }) - - if (!response.ok) { - console.error("Refresh failed:", response.status) - // If refresh fails, clear auth and require re-login - if (response.status === 401) { - this.logout() - } - return false - } - - const data = await response.json() - - const authData: AuthData = { - token: data.token, - refreshToken: data.refreshToken, - expiresAt: data.expiresAt, - user: data.user, - } - - this.store.save(authData) - this.scheduleRefresh() - - // Notify callback about token refresh (so cookie can be updated) - if (this.onTokenRefresh) { - this.onTokenRefresh(authData) - } - - return true - } catch (error) { - console.error("Refresh error:", error) - return false - } - } - - /** - * Schedule token refresh before expiration - */ - private scheduleRefresh(): void { - if (this.refreshTimer) { - clearTimeout(this.refreshTimer) - } - - const authData = this.store.load() - if (!authData) return - - const expiresAt = new Date(authData.expiresAt).getTime() - const now = Date.now() - - // Refresh 5 minutes before expiration - const refreshIn = Math.max(0, expiresAt - now - 5 * 60 * 1000) - - this.refreshTimer = setTimeout(() => { - this.refresh() - }, refreshIn) - - console.log(`Scheduled token refresh in ${Math.round(refreshIn / 1000 / 60)} minutes`) - } - - /** - * Check if user is authenticated - */ - isAuthenticated(): boolean { - return this.store.isAuthenticated() - } - - /** - * Get current user - */ - getUser(): AuthUser | null { - return this.store.getUser() - } - - /** - * Get current auth data - */ - getAuth(): AuthData | null { - return this.store.load() - } - - /** - * Logout and clear stored credentials - */ - logout(): void { - if (this.refreshTimer) { - clearTimeout(this.refreshTimer) - this.refreshTimer = undefined - } - this.store.clear() - } - - /** - * Start auth flow by opening browser - */ - startAuthFlow(mainWindow: BrowserWindow | null): void { - const { shell } = require("electron") - - let authUrl = `${this.getApiUrl()}/auth/desktop?auto=true` - - // In dev mode, use localhost callback (we run HTTP server on port 21321) - // Also pass the protocol so web knows which deep link to use as fallback - if (this.isDev) { - authUrl += `&callback=${encodeURIComponent("http://localhost:21321/auth/callback")}` - // Pass dev protocol so production web can use correct deep link if callback fails - authUrl += `&protocol=twentyfirst-agents-dev` - } - - shell.openExternal(authUrl) - } - - /** - * Update user profile on server and locally - */ - async updateUser(updates: { name?: string }): Promise { - const token = await this.getValidToken() - if (!token) { - throw new Error("Not authenticated") - } - - // Update on server using X-Desktop-Token header - const response = await fetch(`${this.getApiUrl()}/api/user/profile`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - "X-Desktop-Token": token, - }, - body: JSON.stringify({ - display_name: updates.name, - }), - }) - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: "Unknown error" })) - throw new Error(error.error || `Update failed: ${response.status}`) - } - - // Update locally - return this.store.updateUser({ name: updates.name ?? null }) - } -} diff --git a/src/main/auth-store.ts b/src/main/auth-store.ts deleted file mode 100644 index cd4a3881..00000000 --- a/src/main/auth-store.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync } from "fs" -import { join, dirname } from "path" -import { safeStorage } from "electron" - -export interface AuthUser { - id: string - email: string - name: string | null - imageUrl: string | null - username: string | null -} - -export interface AuthData { - token: string - refreshToken: string - expiresAt: string - user: AuthUser -} - -/** - * Storage for desktop authentication tokens - * Uses Electron's safeStorage API to encrypt sensitive data using OS keychain - * Falls back to plaintext only if encryption is unavailable (rare edge case) - */ -export class AuthStore { - private filePath: string - - constructor(userDataPath: string) { - this.filePath = join(userDataPath, "auth.dat") // .dat for encrypted data - } - - /** - * Check if encryption is available on this system - */ - private isEncryptionAvailable(): boolean { - return safeStorage.isEncryptionAvailable() - } - - /** - * Save authentication data (encrypted if possible) - */ - save(data: AuthData): void { - try { - const dir = dirname(this.filePath) - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) - } - - const jsonData = JSON.stringify(data) - - if (this.isEncryptionAvailable()) { - // Encrypt using OS keychain (macOS Keychain, Windows DPAPI, Linux Secret Service) - const encrypted = safeStorage.encryptString(jsonData) - writeFileSync(this.filePath, encrypted) - } else { - // Fallback: store with warning (should rarely happen) - console.warn("safeStorage not available - storing auth data without encryption") - writeFileSync(this.filePath + ".json", jsonData, "utf-8") - } - } catch (error) { - console.error("Failed to save auth data:", error) - throw error - } - } - - /** - * Load authentication data (decrypts if encrypted) - */ - load(): AuthData | null { - try { - // Try encrypted file first - if (existsSync(this.filePath) && this.isEncryptionAvailable()) { - const encrypted = readFileSync(this.filePath) - const decrypted = safeStorage.decryptString(encrypted) - return JSON.parse(decrypted) - } - - // Fallback: try unencrypted file (for migration or when encryption unavailable) - const fallbackPath = this.filePath + ".json" - if (existsSync(fallbackPath)) { - const content = readFileSync(fallbackPath, "utf-8") - const data = JSON.parse(content) - - // Migrate to encrypted storage if now available - if (this.isEncryptionAvailable()) { - this.save(data) - unlinkSync(fallbackPath) // Remove unencrypted file after migration - } - - return data - } - - // Legacy: check for old auth.json file and migrate - const legacyPath = join(dirname(this.filePath), "auth.json") - if (existsSync(legacyPath)) { - const content = readFileSync(legacyPath, "utf-8") - const data = JSON.parse(content) - - // Migrate to encrypted storage - this.save(data) - unlinkSync(legacyPath) // Remove legacy unencrypted file - console.log("Migrated auth data from plaintext to encrypted storage") - - return data - } - - return null - } catch { - console.error("Failed to load auth data") - return null - } - } - - /** - * Clear all stored authentication data (both encrypted and fallback files) - */ - clear(): void { - try { - // Remove encrypted file - if (existsSync(this.filePath)) { - unlinkSync(this.filePath) - } - // Remove fallback unencrypted file if exists - const fallbackPath = this.filePath + ".json" - if (existsSync(fallbackPath)) { - unlinkSync(fallbackPath) - } - // Remove legacy file if exists - const legacyPath = join(dirname(this.filePath), "auth.json") - if (existsSync(legacyPath)) { - unlinkSync(legacyPath) - } - } catch (error) { - console.error("Failed to clear auth data:", error) - } - } - - /** - * Check if user is authenticated - */ - isAuthenticated(): boolean { - const data = this.load() - if (!data) return false - - // Check if token is expired - const expiresAt = new Date(data.expiresAt).getTime() - return expiresAt > Date.now() - } - - /** - * Get current user if authenticated - */ - getUser(): AuthUser | null { - const data = this.load() - return data?.user ?? null - } - - /** - * Get current token if valid - */ - getToken(): string | null { - const data = this.load() - if (!data) return null - - const expiresAt = new Date(data.expiresAt).getTime() - if (expiresAt <= Date.now()) return null - - return data.token - } - - /** - * Get refresh token - */ - getRefreshToken(): string | null { - const data = this.load() - return data?.refreshToken ?? null - } - - /** - * Check if token needs refresh (expires in less than 5 minutes) - */ - needsRefresh(): boolean { - const data = this.load() - if (!data) return false - - const expiresAt = new Date(data.expiresAt).getTime() - const fiveMinutes = 5 * 60 * 1000 - return expiresAt - Date.now() < fiveMinutes - } - - /** - * Update user data (e.g., after profile update) - */ - updateUser(updates: Partial): AuthUser | null { - const data = this.load() - if (!data) return null - - data.user = { ...data.user, ...updates } - this.save(data) - return data.user - } -} diff --git a/src/main/index.ts b/src/main/index.ts index fa6b3fc9..62dac59d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,16 +1,13 @@ -import { app, BrowserWindow, session, Menu } from "electron" +import { app, BrowserWindow, Menu } from "electron" import { join } from "path" -import { createServer } from "http" import { readFileSync, existsSync, unlinkSync, readlinkSync } from "fs" -import * as Sentry from "@sentry/electron/main" +// NOTE: @sentry/electron CANNOT be statically imported - it accesses electron.app at require time +// which fails in dev mode. We use dynamic import in initSentry() instead. import { initDatabase, closeDatabase } from "./lib/db" -import { createMainWindow, getWindow, showLoginPage } from "./windows/main" -import { AuthManager } from "./auth-manager" +import { createMainWindow, getWindow } from "./windows/main" import { initAnalytics, - identify, trackAppOpened, - trackAuthCompleted, shutdown as shutdownAnalytics, } from "./lib/analytics" import { @@ -19,14 +16,11 @@ import { downloadUpdate, setupFocusUpdateCheck, } from "./lib/auto-updater" +import { initGhAuth } from "./lib/git/gh-auth-setup" // Dev mode detection const IS_DEV = !!process.env.ELECTRON_RENDERER_URL -// Deep link protocol (must match package.json build.protocols.schemes) -// Use different protocol in dev to avoid conflicts with production app -const PROTOCOL = IS_DEV ? "twentyfirst-agents-dev" : "twentyfirst-agents" - // Set dev mode userData path BEFORE requestSingleInstanceLock() // This ensures dev and prod have separate instance locks if (IS_DEV) { @@ -36,25 +30,30 @@ if (IS_DEV) { console.log("[Dev] Using separate userData path:", devUserData) } -// Initialize Sentry before app is ready (production only) -if (app.isPackaged && !IS_DEV) { - const sentryDsn = import.meta.env.MAIN_VITE_SENTRY_DSN - if (sentryDsn) { - try { - Sentry.init({ - dsn: sentryDsn, - }) - console.log("[App] Sentry initialized") - } catch (error) { - console.warn("[App] Failed to initialize Sentry:", error) +// Initialize Sentry (production only) - uses dynamic import to avoid dev mode crashes +// @sentry/electron accesses electron.app.getAppPath() at require time +async function initSentry(): Promise { + if (app.isPackaged && !IS_DEV) { + const sentryDsn = import.meta.env.MAIN_VITE_SENTRY_DSN + if (sentryDsn) { + try { + const Sentry = await import("@sentry/electron/main") + Sentry.init({ dsn: sentryDsn }) + console.log("[App] Sentry initialized") + } catch (error) { + console.warn("[App] Failed to initialize Sentry:", error) + } + } else { + console.log("[App] Skipping Sentry initialization (no DSN configured)") } } else { - console.log("[App] Skipping Sentry initialization (no DSN configured)") + console.log("[App] Skipping Sentry initialization (dev mode)") } -} else { - console.log("[App] Skipping Sentry initialization (dev mode)") } +// Initialize Sentry asynchronously (non-blocking) +initSentry().catch(console.error) + // URL configuration (exported for use in other modules) // In packaged app, ALWAYS use production URL to prevent localhost leaking into releases // In dev mode, allow override via MAIN_VITE_API_URL env variable @@ -65,263 +64,6 @@ export function getBaseUrl(): string { return import.meta.env.MAIN_VITE_API_URL || "https://21st.dev" } -export function getAppUrl(): string { - return process.env.ELECTRON_RENDERER_URL || "https://21st.dev/agents" -} - -// Auth manager singleton -let authManager: AuthManager - -export function getAuthManager(): AuthManager { - return authManager -} - -// Handle auth code from deep link (exported for IPC handlers) -export async function handleAuthCode(code: string): Promise { - console.log("[Auth] Handling auth code:", code.slice(0, 8) + "...") - - try { - const authData = await authManager.exchangeCode(code) - console.log("[Auth] Success for user:", authData.user.email) - - // Track successful authentication - trackAuthCompleted(authData.user.id, authData.user.email) - - // Set desktop token cookie using persist:main partition - const ses = session.fromPartition("persist:main") - try { - // First remove any existing cookie to avoid HttpOnly conflict - await ses.cookies.remove(getBaseUrl(), "x-desktop-token") - await ses.cookies.set({ - url: getBaseUrl(), - name: "x-desktop-token", - value: authData.token, - expirationDate: Math.floor( - new Date(authData.expiresAt).getTime() / 1000, - ), - httpOnly: false, - secure: getBaseUrl().startsWith("https"), - sameSite: "lax" as const, - }) - console.log("[Auth] Desktop token cookie set") - } catch (cookieError) { - // Cookie setting is optional - auth data is already saved to disk - console.warn("[Auth] Cookie set failed (non-critical):", cookieError) - } - - // Notify renderer - const win = getWindow() - win?.webContents.send("auth:success", authData.user) - - // Reload window to show app - if (process.env.ELECTRON_RENDERER_URL) { - win?.loadURL(process.env.ELECTRON_RENDERER_URL) - } else { - win?.loadFile(join(__dirname, "../renderer/index.html")) - } - win?.focus() - } catch (error) { - console.error("[Auth] Exchange failed:", error) - getWindow()?.webContents.send("auth:error", (error as Error).message) - } -} - -// Handle deep link -function handleDeepLink(url: string): void { - console.log("[DeepLink] Received:", url) - - try { - const parsed = new URL(url) - - // Handle auth callback: twentyfirstdev://auth?code=xxx - if (parsed.pathname === "/auth" || parsed.host === "auth") { - const code = parsed.searchParams.get("code") - if (code) { - handleAuthCode(code) - return - } - } - } catch (e) { - console.error("[DeepLink] Failed to parse:", e) - } -} - -// Register protocol BEFORE app is ready -console.log("[Protocol] ========== PROTOCOL REGISTRATION ==========") -console.log("[Protocol] Protocol:", PROTOCOL) -console.log("[Protocol] Is dev mode (process.defaultApp):", process.defaultApp) -console.log("[Protocol] process.execPath:", process.execPath) -console.log("[Protocol] process.argv:", process.argv) - -/** - * Register the app as the handler for our custom protocol. - * On macOS, this may not take effect immediately on first install - - * Launch Services caches protocol handlers and may need time to update. - */ -function registerProtocol(): boolean { - let success = false - - if (process.defaultApp) { - // Dev mode: need to pass execPath and script path - if (process.argv.length >= 2) { - success = app.setAsDefaultProtocolClient(PROTOCOL, process.execPath, [ - process.argv[1]!, - ]) - console.log( - `[Protocol] Dev mode registration:`, - success ? "success" : "failed", - ) - } else { - console.warn("[Protocol] Dev mode: insufficient argv for registration") - } - } else { - // Production mode - success = app.setAsDefaultProtocolClient(PROTOCOL) - console.log( - `[Protocol] Production registration:`, - success ? "success" : "failed", - ) - } - - return success -} - -// Store initial registration result (set in app.whenReady()) -let initialRegistration = false - -// Verify registration (this checks if OS recognizes us as the handler) -function verifyProtocolRegistration(): void { - const isDefault = process.defaultApp - ? app.isDefaultProtocolClient(PROTOCOL, process.execPath, [ - process.argv[1]!, - ]) - : app.isDefaultProtocolClient(PROTOCOL) - - console.log(`[Protocol] Verification - isDefaultProtocolClient: ${isDefault}`) - - if (!isDefault && initialRegistration) { - console.warn( - "[Protocol] Registration returned success but verification failed.", - ) - console.warn( - "[Protocol] This is common on first install - macOS Launch Services may need time to update.", - ) - console.warn("[Protocol] The protocol should work after app restart.") - } -} - -console.log("[Protocol] =============================================") - -// Note: app.on("open-url") will be registered in app.whenReady() - -// SVG favicon as data URI for auth callback pages (matches web app favicon) -const FAVICON_SVG = `` -const FAVICON_DATA_URI = `data:image/svg+xml,${encodeURIComponent(FAVICON_SVG)}` - -// Dev mode: Start local HTTP server for auth callback -// This catches http://localhost:21321/auth/callback?code=xxx -if (process.env.ELECTRON_RENDERER_URL) { - const server = createServer((req, res) => { - const url = new URL(req.url || "", "http://localhost:21321") - - // Serve favicon - if (url.pathname === "/favicon.ico" || url.pathname === "/favicon.svg") { - res.writeHead(200, { "Content-Type": "image/svg+xml" }) - res.end(FAVICON_SVG) - return - } - - if (url.pathname === "/auth/callback") { - const code = url.searchParams.get("code") - console.log( - "[Auth Server] Received callback with code:", - code?.slice(0, 8) + "...", - ) - - if (code) { - // Handle the auth code - handleAuthCode(code) - - // Send success response and close the browser tab - res.writeHead(200, { "Content-Type": "text/html" }) - res.end(` - - - - - 1Code - Authentication - - - -
- -

Authentication successful

-

You can close this tab

-
- - -`) - } else { - res.writeHead(400, { "Content-Type": "text/plain" }) - res.end("Missing code parameter") - } - } else { - res.writeHead(404, { "Content-Type": "text/plain" }) - res.end("Not found") - } - }) - - server.listen(21321, () => { - console.log("[Auth Server] Listening on http://localhost:21321") - }) -} - // Clean up stale lock files from crashed instances // Returns true if locks were cleaned, false otherwise function cleanupStaleLocks(): boolean { @@ -380,14 +122,8 @@ if (!gotTheLock) { } if (gotTheLock) { - // Handle second instance launch (also handles deep links on Windows/Linux) + // Handle second instance launch app.on("second-instance", (_event, commandLine) => { - // Check for deep link in command line args - const url = commandLine.find((arg) => arg.startsWith(`${PROTOCOL}://`)) - if (url) { - handleDeepLink(url) - } - const window = getWindow() if (window) { if (window.isMinimized()) window.restore() @@ -402,16 +138,6 @@ if (gotTheLock) { app.name = "Agents Dev" } - // Register protocol handler (must be after app is ready) - initialRegistration = registerProtocol() - - // Handle deep link on macOS (app already running) - app.on("open-url", (event, url) => { - console.log("[Protocol] open-url event received:", url) - event.preventDefault() - handleDeepLink(url) - }) - // Set app user model ID for Windows (different in dev to avoid taskbar conflicts) if (process.platform === "win32") { app.setAppUserModelId(IS_DEV ? "dev.21st.1code.dev" : "dev.21st.1code") @@ -419,10 +145,6 @@ if (gotTheLock) { console.log(`[App] Starting 1Code${IS_DEV ? " (DEV)" : ""}...`) - // Verify protocol registration after app is ready - // This helps diagnose first-install issues where the protocol isn't recognized yet - verifyProtocolRegistration() - // Get Claude Code version for About panel let claudeCodeVersion = "unknown" try { @@ -569,47 +291,12 @@ if (gotTheLock) { // Build initial menu buildMenu() - // Initialize auth manager - authManager = new AuthManager(!!process.env.ELECTRON_RENDERER_URL) - console.log("[App] Auth manager initialized") - - // Initialize analytics after auth manager so we can identify user + // Initialize analytics initAnalytics() - // If user already authenticated from previous session, identify them - if (authManager.isAuthenticated()) { - const user = authManager.getUser() - if (user) { - identify(user.id, { email: user.email }) - console.log("[Analytics] User identified from saved session:", user.id) - } - } - - // Track app opened (now with correct user ID if authenticated) + // Track app opened trackAppOpened() - // Set up callback to update cookie when token is refreshed - authManager.setOnTokenRefresh(async (authData) => { - console.log("[Auth] Token refreshed, updating cookie...") - const ses = session.fromPartition("persist:main") - try { - await ses.cookies.set({ - url: getBaseUrl(), - name: "x-desktop-token", - value: authData.token, - expirationDate: Math.floor( - new Date(authData.expiresAt).getTime() / 1000, - ), - httpOnly: false, - secure: getBaseUrl().startsWith("https"), - sameSite: "lax" as const, - }) - console.log("[Auth] Desktop token cookie updated after refresh") - } catch (err) { - console.error("[Auth] Failed to update cookie:", err) - } - }) - // Initialize database try { initDatabase() @@ -618,6 +305,11 @@ if (gotTheLock) { console.error("[App] Failed to initialize database:", error) } + // Configure git to use GitHub CLI for private repo auth + initGhAuth().catch((error) => { + console.warn("[App] GitHub CLI auth setup failed:", error) + }) + // Create main window createMainWindow() @@ -632,14 +324,6 @@ if (gotTheLock) { }, 5000) } - // Handle deep link from app launch (Windows/Linux) - const deepLinkUrl = process.argv.find((arg) => - arg.startsWith(`${PROTOCOL}://`), - ) - if (deepLinkUrl) { - handleDeepLink(deepLinkUrl) - } - // macOS: Re-create window when dock icon is clicked app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) { diff --git a/src/main/lib/analytics.ts b/src/main/lib/analytics.ts index 3cba8d93..ca435455 100644 --- a/src/main/lib/analytics.ts +++ b/src/main/lib/analytics.ts @@ -224,7 +224,7 @@ export function trackWorkspaceDeleted(workspaceId: string) { export function trackMessageSent(data: { workspaceId: string messageLength: number - mode: "plan" | "agent" + mode: "plan" | "agent" | "ask" }) { capture("message_sent", { workspace_id: data.workspaceId, diff --git a/src/main/lib/claude/cli-sync.ts b/src/main/lib/claude/cli-sync.ts new file mode 100644 index 00000000..4f7adca9 --- /dev/null +++ b/src/main/lib/claude/cli-sync.ts @@ -0,0 +1,251 @@ +import { promises as fs } from "fs" +import path from "path" + +/** + * CLI session message format (from .jsonl file) + */ +interface CliMessage { + type: "user" | "assistant" | "progress" | "file-history-snapshot" + message?: { + role: "user" | "assistant" + content: string | ContentBlock[] + } + uuid: string + timestamp: string + sessionId?: string + isMeta?: boolean +} + +/** + * Content block from CLI assistant messages + */ +interface ContentBlock { + type: "text" | "tool_use" | "tool_result" + text?: string + id?: string + name?: string + input?: unknown + content?: string | unknown[] + tool_use_id?: string + is_error?: boolean +} + +/** + * GUI message format (stored in database) + */ +interface GuiMessage { + id: string + role: "user" | "assistant" + parts: GuiPart[] + metadata?: { + sessionId?: string + inputTokens?: number + outputTokens?: number + totalTokens?: number + totalCostUsd?: number + durationMs?: number + resultSubtype?: string + finalTextId?: string + } +} + +/** + * GUI message part + */ +interface GuiPart { + type: string + text?: string + toolCallId?: string + toolName?: string + input?: unknown + state?: "call" | "result" + result?: unknown +} + +/** + * Parse a session JSONL file and return CLI messages + */ +export async function parseSessionFile( + filePath: string, +): Promise { + try { + const content = await fs.readFile(filePath, "utf-8") + const lines = content.trim().split("\n").filter(Boolean) + + const messages: CliMessage[] = [] + for (const line of lines) { + try { + const parsed = JSON.parse(line) + messages.push(parsed) + } catch (parseErr) { + console.error("[CLI-SYNC] Failed to parse line:", parseErr) + // Skip malformed lines + } + } + + return messages + } catch (err) { + console.error("[CLI-SYNC] Failed to read session file:", err) + return [] + } +} + +/** + * Convert CLI messages to GUI format and merge with existing messages + */ +export function convertCliToGuiMessages( + cliMessages: CliMessage[], + existingGuiMessages: GuiMessage[], +): GuiMessage[] { + // 1. Find existing message UUIDs to avoid duplicates + const existingUuids = new Set(existingGuiMessages.map((m) => m.id)) + + // 2. Filter to only user/assistant messages that are new + const newCliMessages = cliMessages.filter( + (m) => + !existingUuids.has(m.uuid) && + (m.type === "user" || m.type === "assistant") && + m.message && + !m.isMeta, // Skip meta messages (local command caveat, etc.) + ) + + // 3. Convert each new message + const convertedMessages = newCliMessages + .map((cliMsg) => { + if (!cliMsg.message) return null + + if (cliMsg.message.role === "user") { + return convertUserMessage(cliMsg) + } else { + return convertAssistantMessage(cliMsg) + } + }) + .filter((m): m is GuiMessage => m !== null) + + // 4. Merge with existing (preserving order) + return [...existingGuiMessages, ...convertedMessages] +} + +/** + * Convert CLI user message to GUI format + */ +function convertUserMessage(cliMsg: CliMessage): GuiMessage | null { + if (!cliMsg.message) return null + + const content = cliMsg.message.content + let text = "" + + if (typeof content === "string") { + text = content + } else if (Array.isArray(content)) { + // Extract text from content blocks + text = content + .filter((block) => block.type === "text") + .map((block) => block.text || "") + .join("\n") + } + + return { + id: cliMsg.uuid, + role: "user", + parts: [{ type: "text", text }], + } +} + +/** + * Convert CLI assistant message to GUI format + */ +function convertAssistantMessage(cliMsg: CliMessage): GuiMessage | null { + if (!cliMsg.message) return null + + const content = cliMsg.message.content + const parts: GuiPart[] = [] + + if (typeof content === "string") { + parts.push({ type: "text", text: content }) + } else if (Array.isArray(content)) { + // Track tool_use blocks to match with tool_result blocks + const toolUseMap = new Map() + + for (const block of content) { + if (block.type === "text") { + parts.push({ type: "text", text: block.text || "" }) + } else if (block.type === "tool_use") { + const toolPart: GuiPart = { + type: `tool-${block.name}`, + toolCallId: block.id, + toolName: block.name, + input: block.input, + state: "call", + } + parts.push(toolPart) + if (block.id) { + toolUseMap.set(block.id, toolPart) + } + } else if (block.type === "tool_result") { + // Find matching tool_use and update its state + const toolUseId = block.tool_use_id + if (toolUseId && toolUseMap.has(toolUseId)) { + const toolPart = toolUseMap.get(toolUseId)! + toolPart.state = "result" + + // Handle result content + if (typeof block.content === "string") { + toolPart.result = block.content + } else { + toolPart.result = block.content + } + + // Handle errors + if (block.is_error) { + toolPart.state = "result" + // Mark as error in result + toolPart.result = { + error: true, + content: block.content, + } + } + } + // Note: We don't add tool_result as a separate part, it updates the tool_use part + } + } + } + + return { + id: cliMsg.uuid, + role: "assistant", + parts, + metadata: { + sessionId: cliMsg.sessionId, + }, + } +} + +/** + * Find the session file path for a given subChatId and sessionId + */ +export async function findSessionFile( + configDir: string, + cwd: string, + sessionId: string, +): Promise { + // Encode CWD path (slashes become dashes) + const cwdEncoded = cwd.replace(/\//g, "-") + + const sessionFilePath = path.join( + configDir, + "projects", + cwdEncoded, + `${sessionId}.jsonl`, + ) + + try { + await fs.access(sessionFilePath) + return sessionFilePath + } catch { + console.error( + `[CLI-SYNC] Session file not found at: ${sessionFilePath}`, + ) + return null + } +} diff --git a/src/main/lib/claude/token.ts b/src/main/lib/claude/token.ts new file mode 100644 index 00000000..b5252bd7 --- /dev/null +++ b/src/main/lib/claude/token.ts @@ -0,0 +1,36 @@ +import { eq } from "drizzle-orm" +import { safeStorage } from "electron" +import { claudeCodeCredentials, getDatabase } from "../db" + +/** + * Decrypt token using Electron's safeStorage + */ +export function decryptToken(encrypted: string): string { + if (!safeStorage.isEncryptionAvailable()) { + return Buffer.from(encrypted, "base64").toString("utf-8") + } + const buffer = Buffer.from(encrypted, "base64") + return safeStorage.decryptString(buffer) +} + +/** + * Get Claude Code OAuth token from local SQLite + * Returns null if not connected + */ +export function getClaudeCodeToken(): string | null { + try { + const db = getDatabase() + const cred = db + .select() + .from(claudeCodeCredentials) + .where(eq(claudeCodeCredentials.id, "default")) + .get() + + if (!cred?.oauthToken) { + return null + } + return decryptToken(cred.oauthToken) + } catch { + return null + } +} diff --git a/src/main/lib/claude/transform.ts b/src/main/lib/claude/transform.ts index 8d98a2cc..a867aaad 100644 --- a/src/main/lib/claude/transform.ts +++ b/src/main/lib/claude/transform.ts @@ -73,7 +73,7 @@ export function createTransformer() { } return function* transform(msg: any): Generator { - // Debug: log all message types to understand what SDK sends + // Debug: log system messages if (msg.type === "system") { console.log("[transform] SYSTEM message:", msg.subtype, msg) } @@ -370,29 +370,84 @@ export function createTransformer() { } // Compact boundary - mark the compacting tool as complete + // Includes pre_tokens which is the actual context size before compacting if (msg.subtype === "compact_boundary" && lastCompactId) { + const preTokens = msg.compact_metadata?.pre_tokens + console.log("[transform] COMPACT_BOUNDARY pre_tokens:", preTokens) yield { type: "system-Compact", toolCallId: lastCompactId, state: "output-available", + preTokens, } lastCompactId = null // Clear for next compacting cycle } } + // ===== USER MESSAGE WITH SLASH COMMAND OUTPUT ===== + // Claude Code wraps /context (and other slash commands) output in user messages + // with tags - extract and display as assistant text + if (msg.type === "user" && msg.message?.content) { + const content = msg.message.content + const stdoutMatch = content.match(/([\s\S]*?)<\/local-command-stdout>/) + if (stdoutMatch) { + const commandOutput = stdoutMatch[1].trim() + console.log("[transform] SLASH COMMAND OUTPUT detected, length:", commandOutput.length) + // Emit as text so it shows in the UI + yield* endTextBlock() + yield* endToolInput() + textId = genId() + yield { type: "text-start", id: textId } + yield { type: "text-delta", id: textId, delta: commandOutput } + yield { type: "text-end", id: textId } + lastTextId = textId + textStarted = false + textId = null + } + } + // ===== RESULT (final) ===== if (msg.type === "result") { console.log("[transform] RESULT message, textStarted:", textStarted, "lastTextId:", lastTextId) + console.log("[transform] RESULT usage:", { + usage: msg.usage, + modelUsage: msg.modelUsage, + }) yield* endTextBlock() yield* endToolInput() - const inputTokens = msg.usage?.input_tokens - const outputTokens = msg.usage?.output_tokens + // Extract per-turn token usage from msg.usage (Anthropic API response) + // msg.usage has per-turn values; msg.modelUsage has cumulative session totals + const perTurnInputTokens = msg.usage?.input_tokens || 0 + const perTurnOutputTokens = msg.usage?.output_tokens || 0 + const cacheReadTokens = msg.usage?.cache_read_input_tokens || 0 + const cacheCreationTokens = msg.usage?.cache_creation_input_tokens || 0 + + // Total input tokens for this turn includes cache creation + cache read tokens. + // https://docs.anthropic.com/en/api/messages#usage + // This best matches "what the model saw" in the context window for this call. + const totalInputTokens = + perTurnInputTokens + cacheReadTokens + cacheCreationTokens + + // Get context window from modelUsage if available + let maxContextWindow = 0 + if (msg.modelUsage) { + for (const usage of Object.values(msg.modelUsage)) { + if (usage.contextWindow) { + maxContextWindow = Math.max(maxContextWindow, usage.contextWindow) + } + } + } + const metadata: MessageMetadata = { sessionId: msg.session_id, - inputTokens, - outputTokens, - totalTokens: inputTokens && outputTokens ? inputTokens + outputTokens : undefined, + // Store the total input tokens (incl. cache tokens) for context indicator + inputTokens: totalInputTokens, + outputTokens: perTurnOutputTokens, + cacheReadInputTokens: cacheReadTokens, + cacheCreationInputTokens: cacheCreationTokens, + contextWindow: maxContextWindow || undefined, + totalTokens: totalInputTokens + perTurnOutputTokens, totalCostUsd: msg.total_cost_usd, durationMs: startTime ? Date.now() - startTime : undefined, resultSubtype: msg.subtype || "success", diff --git a/src/main/lib/claude/types.ts b/src/main/lib/claude/types.ts index 8c0216f2..fbd8e288 100644 --- a/src/main/lib/claude/types.ts +++ b/src/main/lib/claude/types.ts @@ -43,6 +43,7 @@ export type UIMessageChunk = type: "system-Compact" toolCallId: string state: "input-streaming" | "output-available" + preTokens?: number // Context tokens before compacting (only on output-available) } // Session initialization (MCP servers, plugins, tools) | { @@ -70,6 +71,9 @@ export type MessageMetadata = { inputTokens?: number outputTokens?: number totalTokens?: number + cacheReadInputTokens?: number + cacheCreationInputTokens?: number + contextWindow?: number totalCostUsd?: number durationMs?: number resultSubtype?: string diff --git a/src/main/lib/db/schema/index.ts b/src/main/lib/db/schema/index.ts index 5643794e..aef74066 100644 --- a/src/main/lib/db/schema/index.ts +++ b/src/main/lib/db/schema/index.ts @@ -49,6 +49,8 @@ export const chats = sqliteTable("chats", { // PR tracking fields prUrl: text("pr_url"), prNumber: integer("pr_number"), + // Model preference (per-chat) + modelId: text("model_id").default("sonnet"), // "opus" | "sonnet" | "haiku" }) export const chatsRelations = relations(chats, ({ one, many }) => ({ @@ -68,9 +70,10 @@ export const subChats = sqliteTable("sub_chats", { chatId: text("chat_id") .notNull() .references(() => chats.id, { onDelete: "cascade" }), + modelId: text("model_id").default("sonnet"), // "opus" | "sonnet" | "haiku" sessionId: text("session_id"), // Claude SDK session ID for resume streamId: text("stream_id"), // Track in-progress streams - mode: text("mode").notNull().default("agent"), // "plan" | "agent" + mode: text("mode").notNull().default("agent"), // "plan" | "agent" | "ask" messages: text("messages").notNull().default("[]"), // JSON array createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn( () => new Date(), @@ -98,6 +101,26 @@ export const claudeCodeCredentials = sqliteTable("claude_code_credentials", { userId: text("user_id"), // Desktop auth user ID (for reference) }) +// ============ TOOL ACTIVITIES ============ +// Persisted tool execution history for activity feed +export const toolActivities = sqliteTable("tool_activities", { + id: text("id") + .primaryKey() + .$defaultFn(() => createId()), + subChatId: text("sub_chat_id").notNull(), // No FK - keep activities even if sub-chat deleted + chatName: text("chat_name").notNull(), + toolName: text("tool_name").notNull(), + summary: text("summary").notNull(), + state: text("state").notNull(), // "running" | "complete" | "error" + input: text("input"), // JSON string (truncated to 50KB) + output: text("output"), // JSON string (truncated to 50KB) + errorText: text("error_text"), + isPinned: integer("is_pinned", { mode: "boolean" }).notNull().default(false), + createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn( + () => new Date(), + ), +}) + // ============ TYPE EXPORTS ============ export type Project = typeof projects.$inferSelect export type NewProject = typeof projects.$inferInsert @@ -107,3 +130,5 @@ export type SubChat = typeof subChats.$inferSelect export type NewSubChat = typeof subChats.$inferInsert export type ClaudeCodeCredential = typeof claudeCodeCredentials.$inferSelect export type NewClaudeCodeCredential = typeof claudeCodeCredentials.$inferInsert +export type ToolActivity = typeof toolActivities.$inferSelect +export type NewToolActivity = typeof toolActivities.$inferInsert diff --git a/src/main/lib/git/ai-commit-sdk.ts b/src/main/lib/git/ai-commit-sdk.ts new file mode 100644 index 00000000..70d83d34 --- /dev/null +++ b/src/main/lib/git/ai-commit-sdk.ts @@ -0,0 +1,260 @@ +import type { ChangedFile } from "../../../shared/changes-types" +import { getBundledClaudeBinaryPath, buildClaudeEnv } from "../claude/env" +import { getClaudeCodeToken } from "../claude/token" + +const MAX_DIFF_LENGTH = 4000 + +/** + * Generate commit message using Claude Code SDK + * Uses OAuth token from local storage (same as chat system) + */ +export async function generateCommitMessageWithSDK(options: { + stagedFiles: ChangedFile[] + diffContent: string + worktreePath: string +}): Promise { + console.log("[AI_COMMIT_SDK] Generating commit message using Claude Code SDK") + + // Get OAuth token from encrypted storage + const claudeCodeToken = getClaudeCodeToken() + if (!claudeCodeToken) { + throw new Error("Claude Code not connected. Please connect in Settings.") + } + + // Build prompt with explicit instruction for commit message only + const userPrompt = buildCommitPrompt(options.stagedFiles, options.diffContent) + console.log("[AI_COMMIT_SDK] Built prompt, length:", userPrompt.length) + + // Build environment with OAuth token + const claudeEnv = buildClaudeEnv() + const finalEnv = { + ...claudeEnv, + CLAUDE_CODE_OAUTH_TOKEN: claudeCodeToken, + } + + // Dynamic import for ESM module + const { query } = await import("@anthropic-ai/claude-agent-sdk") + + // Get bundled Claude binary path + const claudeBinaryPath = getBundledClaudeBinaryPath() + + console.log("[AI_COMMIT_SDK] Binary path:", claudeBinaryPath) + console.log("[AI_COMMIT_SDK] Working directory:", options.worktreePath) + + // Capture stderr for debugging + const stderrLines: string[] = [] + + // Create abort controller with timeout + const abortController = new AbortController() + const timeoutId = setTimeout(() => { + console.error("[AI_COMMIT_SDK] Timeout after 30 seconds") + abortController.abort() + }, 30000) + + try { + // Invoke SDK with minimal options (text-only, no tools) + const queryOptions = { + prompt: userPrompt, + options: { + abortController, + cwd: options.worktreePath, + env: finalEnv, + pathToClaudeCodeExecutable: claudeBinaryPath, + permissionMode: "plan" as const, // Read-only mode, no tool execution + model: "haiku", // Haiku 4.5 - fast and cheap for commit messages + systemPrompt: { + type: "custom" as const, + text: "You generate git commit messages. Output ONLY the commit message with no preamble. Format: type(scope): description on first line, then blank line, then bullet points. Never explain what you're doing.", + }, + settingSources: ["user"] as const, // Don't read project config + includePartialMessages: true, // Get partial messages for streaming + stderr: (data: string) => { + stderrLines.push(data) + console.error("[AI_COMMIT_SDK stderr]", data) + }, + }, + } + + const stream = await query(queryOptions) + console.log("[AI_COMMIT_SDK] Stream created, waiting for messages...") + + // Collect response text + let commitMessage = "" + + try { + for await (const msg of stream) { + console.log("[AI_COMMIT_SDK] Received message type:", msg.type) + + // Handle errors + if (msg.type === "error") { + const errorMsg = (msg as any).error || "Unknown SDK error" + console.error("[AI_COMMIT_SDK] SDK error:", errorMsg) + const stderrOutput = stderrLines.join("\n") + throw new Error( + stderrOutput + ? `Failed to generate commit message: ${errorMsg}\n\nProcess output:\n${stderrOutput}` + : `Failed to generate commit message: ${errorMsg}`, + ) + } + + // Collect text from assistant messages (direct type) + if (msg.type === "assistant") { + const assistantMsg = msg as any + if (assistantMsg.message?.content) { + for (const block of assistantMsg.message.content) { + if (block.type === "text") { + console.log("[AI_COMMIT_SDK] Collecting text:", block.text) + commitMessage += block.text + } + } + } + } + + // Also handle wrapped message type + if (msg.type === "message" && (msg as any).message?.role === "assistant") { + for (const block of (msg as any).message.content) { + if (block.type === "text") { + console.log("[AI_COMMIT_SDK] Collecting text:", block.text) + commitMessage += block.text + } + } + } + } + } catch (streamError) { + // Catch streaming errors (like process exit) + const err = streamError as Error + const stderrOutput = stderrLines.join("\n") + + console.error("[AI_COMMIT_SDK] Streaming error:", err.message) + if (stderrOutput) { + console.error("[AI_COMMIT_SDK] stderr output:", stderrOutput) + } + + // Re-throw with stderr context + throw new Error( + stderrOutput + ? `Claude Code process error: ${err.message}\n\nProcess output:\n${stderrOutput}` + : `Claude Code process error: ${err.message}`, + ) + } + + // Extract conventional commit message (header + optional body) + // Format: type(scope): description followed by optional bullet points + const conventionalCommitRegex = + /\b(feat|fix|refactor|docs|style|test|chore|perf|ci|build|revert)(\([^)]+\))?:\s*[^\n]+/i + + const lines = commitMessage.split("\n") + let cleanedMessage = "" + let headerFound = false + let bodyLines: string[] = [] + + for (const line of lines) { + const trimmed = line.trim() + const lower = trimmed.toLowerCase() + + // Skip preamble lines before finding the header + if (!headerFound) { + if ( + lower.startsWith("here is") || + lower.startsWith("here's") || + lower.startsWith("based on") || + lower.startsWith("i'll help") || + lower.startsWith("i need") || + lower.startsWith("let me") || + lower.startsWith("i will") || + lower.startsWith("now i") || + lower.startsWith("this commit") || + trimmed.length === 0 + ) { + continue + } + + // Check if this line is a conventional commit header + if (conventionalCommitRegex.test(trimmed)) { + cleanedMessage = trimmed + headerFound = true + continue + } + } else { + // After header, collect body lines (bullet points or blank lines) + if (trimmed.startsWith("-") || trimmed.startsWith("*")) { + bodyLines.push(trimmed) + } else if (trimmed.length === 0 && bodyLines.length > 0) { + // Stop at second blank line after body starts + break + } else if (trimmed.length === 0) { + // First blank line after header - continue to body + continue + } else if ( + lower.startsWith("this commit") || + lower.startsWith("the implementation") || + lower.startsWith("this change") + ) { + // Stop at explanatory text after bullet points + break + } + } + } + + // Combine header and body + if (headerFound && bodyLines.length > 0) { + cleanedMessage = cleanedMessage + "\n\n" + bodyLines.join("\n") + } + + // Remove markdown formatting, quotes, and backticks + cleanedMessage = cleanedMessage + .replace(/\*\*/g, "") // Remove bold markdown + .replace(/^["'`]|["'`]$/g, "") // Remove quotes/backticks at start/end + .replace(/`([^`]+)`/g, "$1") // Remove inline code backticks + .trim() + + console.log("[AI_COMMIT_SDK] Extracted commit message:", cleanedMessage) + + if (!cleanedMessage) { + const stderrOutput = stderrLines.join("\n") + throw new Error( + stderrOutput + ? `Claude returned empty commit message\n\nProcess output:\n${stderrOutput}` + : "Claude returned empty commit message", + ) + } + + console.log("[AI_COMMIT_SDK] Generated commit message:", cleanedMessage) + return cleanedMessage + } finally { + clearTimeout(timeoutId) + } +} + +/** + * Build commit message prompt from staged files and diff + * (Reused from original implementation) + */ +function buildCommitPrompt(files: ChangedFile[], diff: string): string { + const fileList = files + .map((f) => `- ${f.path} (${f.status}, +${f.additions}, -${f.deletions})`) + .join("\n") + + const truncatedDiff = + diff.length > MAX_DIFF_LENGTH ? diff.slice(0, MAX_DIFF_LENGTH) + "\n... (truncated)" : diff + + return `FILES CHANGED: +${fileList} + +DIFF: +${truncatedDiff} + +Write a conventional commit message with: +1. First line: type(scope): short description (max 72 chars) +2. Blank line +3. Body: 2-4 bullet points explaining the key changes + +Format example: +feat(auth): add OAuth2 login flow + +- Implement token refresh mechanism +- Add secure storage for credentials +- Handle session expiration gracefully + +Output ONLY the commit message, no other text.` +} diff --git a/src/main/lib/git/cache/git-cache.ts b/src/main/lib/git/cache/git-cache.ts new file mode 100644 index 00000000..e8b08a68 --- /dev/null +++ b/src/main/lib/git/cache/git-cache.ts @@ -0,0 +1,357 @@ +import { createHash } from "crypto"; + +interface CacheEntry { + data: T; + hash: string; + timestamp: number; + accessCount: number; + sizeBytes: number; +} + +interface CacheConfig { + maxAge: number; // TTL in ms + maxEntries: number; + maxSizeBytes?: number; +} + +/** + * LRU Cache with TTL and optional size limits. + * Supports hash-based invalidation for efficient updates. + */ +class LRUCache { + private cache: Map> = new Map(); + private config: CacheConfig; + private currentSizeBytes = 0; + + constructor(config: CacheConfig) { + this.config = config; + } + + /** + * Get an entry from the cache if it exists and is not expired. + */ + get(key: string): T | null { + const entry = this.cache.get(key); + if (!entry) return null; + + // Check TTL + if (Date.now() - entry.timestamp > this.config.maxAge) { + this.delete(key); + return null; + } + + // Update access count for LRU + entry.accessCount++; + return entry.data; + } + + /** + * Get entry only if hash matches (for conditional updates). + */ + getIfHashMatches(key: string, hash: string): T | null { + const entry = this.cache.get(key); + if (!entry) return null; + + // Check TTL + if (Date.now() - entry.timestamp > this.config.maxAge) { + this.delete(key); + return null; + } + + // Check hash + if (entry.hash !== hash) { + return null; + } + + entry.accessCount++; + return entry.data; + } + + /** + * Set an entry in the cache. + */ + set(key: string, data: T, hash: string, sizeBytes = 0): void { + // Evict if necessary + this.evictIfNeeded(sizeBytes); + + // Delete existing entry first to update size tracking + if (this.cache.has(key)) { + this.delete(key); + } + + const entry: CacheEntry = { + data, + hash, + timestamp: Date.now(), + accessCount: 1, + sizeBytes, + }; + + this.cache.set(key, entry); + this.currentSizeBytes += sizeBytes; + } + + /** + * Delete an entry from the cache. + */ + delete(key: string): boolean { + const entry = this.cache.get(key); + if (entry) { + this.currentSizeBytes -= entry.sizeBytes; + return this.cache.delete(key); + } + return false; + } + + /** + * Invalidate all entries for a given worktree path. + */ + invalidateByPrefix(prefix: string): number { + let count = 0; + const keys = Array.from(this.cache.keys()); + for (const key of keys) { + if (key.startsWith(prefix)) { + this.delete(key); + count++; + } + } + return count; + } + + /** + * Clear all entries. + */ + clear(): void { + this.cache.clear(); + this.currentSizeBytes = 0; + } + + /** + * Get cache statistics. + */ + getStats(): { + entries: number; + sizeBytes: number; + maxEntries: number; + maxSizeBytes: number | undefined; + } { + return { + entries: this.cache.size, + sizeBytes: this.currentSizeBytes, + maxEntries: this.config.maxEntries, + maxSizeBytes: this.config.maxSizeBytes, + }; + } + + private evictIfNeeded(incomingSizeBytes: number): void { + // Evict by entry count + while (this.cache.size >= this.config.maxEntries) { + this.evictLRU(); + } + + // Evict by size if configured + if (this.config.maxSizeBytes) { + while ( + this.currentSizeBytes + incomingSizeBytes > + this.config.maxSizeBytes && + this.cache.size > 0 + ) { + this.evictLRU(); + } + } + } + + private evictLRU(): void { + let lruKey: string | null = null; + let lruAccessCount = Number.POSITIVE_INFINITY; + let lruTimestamp = Number.POSITIVE_INFINITY; + + const entries = Array.from(this.cache.entries()); + for (const [key, entry] of entries) { + // Prioritize by access count, then by timestamp + if ( + entry.accessCount < lruAccessCount || + (entry.accessCount === lruAccessCount && + entry.timestamp < lruTimestamp) + ) { + lruKey = key; + lruAccessCount = entry.accessCount; + lruTimestamp = entry.timestamp; + } + } + + if (lruKey) { + this.delete(lruKey); + } + } +} + +/** + * Compute content hash for cache invalidation. + */ +export function computeContentHash(content: string): string { + return createHash("sha256").update(content).digest("hex").slice(0, 16); +} + +/** + * Estimate byte size of a JavaScript value. + */ +export function estimateSizeBytes(value: unknown): number { + if (typeof value === "string") { + return value.length * 2; // UTF-16 + } + if (typeof value === "number") { + return 8; + } + if (typeof value === "boolean") { + return 4; + } + if (value === null || value === undefined) { + return 0; + } + if (Array.isArray(value)) { + return value.reduce( + (sum, item) => sum + estimateSizeBytes(item), + 64, + ); + } + if (typeof value === "object") { + return Object.entries(value).reduce( + (sum, [key, val]) => + sum + key.length * 2 + estimateSizeBytes(val), + 64, + ); + } + return 0; +} + +// Cache configuration +const CACHE_CONFIGS = { + // Git status - short lived, frequently invalidated + status: { + maxAge: 5000, // 5 seconds + maxEntries: 20, + }, + // Parsed diff - longer lived, hash-based invalidation + parsedDiff: { + maxAge: 60000, // 1 minute + maxEntries: 100, + maxSizeBytes: 50 * 1024 * 1024, // 50MB + }, + // File contents - content-addressed, longer TTL + fileContents: { + maxAge: 300000, // 5 minutes + maxEntries: 500, + maxSizeBytes: 100 * 1024 * 1024, // 100MB + }, +} as const; + +/** + * GitCache provides caching for git operations. + * Uses different caching strategies for different data types. + */ +class GitCache { + private statusCache: LRUCache; + private parsedDiffCache: LRUCache; + private fileContentsCache: LRUCache; + + constructor() { + this.statusCache = new LRUCache(CACHE_CONFIGS.status); + this.parsedDiffCache = new LRUCache(CACHE_CONFIGS.parsedDiff); + this.fileContentsCache = new LRUCache(CACHE_CONFIGS.fileContents); + } + + // Status cache methods + getStatus(worktreePath: string): T | null { + return this.statusCache.get(worktreePath) as T | null; + } + + setStatus(worktreePath: string, status: T): void { + const hash = computeContentHash(JSON.stringify(status)); + this.statusCache.set(worktreePath, status, hash); + } + + invalidateStatus(worktreePath: string): void { + this.statusCache.delete(worktreePath); + } + + // Parsed diff cache methods + getParsedDiff(worktreePath: string, diffHash: string): T | null { + const key = `${worktreePath}:${diffHash}`; + return this.parsedDiffCache.getIfHashMatches(key, diffHash) as T | null; + } + + setParsedDiff(worktreePath: string, diffHash: string, parsed: T): void { + const key = `${worktreePath}:${diffHash}`; + const sizeBytes = estimateSizeBytes(parsed); + this.parsedDiffCache.set(key, parsed, diffHash, sizeBytes); + } + + invalidateParsedDiff(worktreePath: string): number { + return this.parsedDiffCache.invalidateByPrefix(worktreePath); + } + + // File contents cache methods + getFileContent(worktreePath: string, filePath: string): string | null { + const key = `${worktreePath}:${filePath}`; + return this.fileContentsCache.get(key); + } + + getFileContentIfHashMatches( + worktreePath: string, + filePath: string, + contentHash: string, + ): string | null { + const key = `${worktreePath}:${filePath}`; + return this.fileContentsCache.getIfHashMatches(key, contentHash); + } + + setFileContent( + worktreePath: string, + filePath: string, + content: string, + ): void { + const key = `${worktreePath}:${filePath}`; + const hash = computeContentHash(content); + this.fileContentsCache.set(key, content, hash, content.length * 2); + } + + invalidateFileContent(worktreePath: string, filePath: string): void { + const key = `${worktreePath}:${filePath}`; + this.fileContentsCache.delete(key); + } + + invalidateAllFileContents(worktreePath: string): number { + return this.fileContentsCache.invalidateByPrefix(worktreePath); + } + + // Invalidate all caches for a worktree + invalidateWorktree(worktreePath: string): void { + this.statusCache.delete(worktreePath); + this.parsedDiffCache.invalidateByPrefix(worktreePath); + this.fileContentsCache.invalidateByPrefix(worktreePath); + } + + // Get statistics for monitoring + getStats(): { + status: ReturnType["getStats"]>; + parsedDiff: ReturnType["getStats"]>; + fileContents: ReturnType["getStats"]>; + } { + return { + status: this.statusCache.getStats(), + parsedDiff: this.parsedDiffCache.getStats(), + fileContents: this.fileContentsCache.getStats(), + }; + } + + // Clear all caches + clearAll(): void { + this.statusCache.clear(); + this.parsedDiffCache.clear(); + this.fileContentsCache.clear(); + } +} + +// Singleton instance +export const gitCache = new GitCache(); diff --git a/src/main/lib/git/cache/index.ts b/src/main/lib/git/cache/index.ts new file mode 100644 index 00000000..c5bad2c6 --- /dev/null +++ b/src/main/lib/git/cache/index.ts @@ -0,0 +1,5 @@ +export { + gitCache, + computeContentHash, + estimateSizeBytes, +} from "./git-cache"; diff --git a/src/main/lib/git/gh-auth-setup.ts b/src/main/lib/git/gh-auth-setup.ts new file mode 100644 index 00000000..bffe4423 --- /dev/null +++ b/src/main/lib/git/gh-auth-setup.ts @@ -0,0 +1,32 @@ +import { execWithShellEnv } from "./shell-env" + +let setupAttempted = false + +/** + * Configures git to use GitHub CLI for authentication. + * Runs `gh auth setup-git` which sets up the git credential helper. + * Safe to call multiple times - will skip if already attempted this session. + */ +export async function initGhAuth(): Promise { + if (setupAttempted) return + setupAttempted = true + + try { + // First check if gh is authenticated + await execWithShellEnv("gh", ["auth", "status"], { timeout: 5000 }) + + // Configure git to use gh for authentication + await execWithShellEnv("gh", ["auth", "setup-git"], { timeout: 5000 }) + console.log("[GhAuth] Git configured to use GitHub CLI for authentication") + } catch (error) { + // Non-fatal - gh CLI may not be installed or authenticated + const message = error instanceof Error ? error.message : String(error) + if (message.includes("ENOENT")) { + console.log("[GhAuth] gh CLI not installed, skipping auth setup") + } else if (message.includes("not logged in")) { + console.log("[GhAuth] gh CLI not authenticated, skipping auth setup") + } else { + console.warn("[GhAuth] Failed to setup git auth:", message) + } + } +} diff --git a/src/main/lib/git/git-operations.ts b/src/main/lib/git/git-operations.ts index 463bd725..d1c27142 100644 --- a/src/main/lib/git/git-operations.ts +++ b/src/main/lib/git/git-operations.ts @@ -1,10 +1,12 @@ import { shell } from "electron"; import simpleGit from "simple-git"; import { z } from "zod"; +import { TRPCError } from "@trpc/server"; import { publicProcedure, router } from "../trpc"; import { isUpstreamMissingError } from "./git-utils"; import { assertRegisteredWorktree } from "./security"; import { fetchGitHubPRStatus } from "./github"; +import { generateCommitMessageWithSDK } from "./ai-commit-sdk"; export { isUpstreamMissingError }; @@ -41,6 +43,40 @@ export const createGitOperationsRouter = () => { }, ), + generateCommitMessage: publicProcedure + .input(z.object({ worktreePath: z.string() })) + .mutation(async ({ input }) => { + assertRegisteredWorktree(input.worktreePath); + + const git = simpleGit(input.worktreePath); + const diffResult = await git.diff(["--cached"]); + + if (!diffResult.trim()) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "No staged changes", + }); + } + + // Get staged file list from status + const statusResult = await git.status(); + const stagedFiles = statusResult.staged.map((path) => ({ + path, + status: "modified" as const, + additions: 0, + deletions: 0, + })); + + // Use SDK-based generation (handles OAuth token internally) + const message = await generateCommitMessageWithSDK({ + stagedFiles, + diffContent: diffResult, + worktreePath: input.worktreePath, + }); + + return { message }; + }), + push: publicProcedure .input( z.object({ diff --git a/src/main/lib/git/status.ts b/src/main/lib/git/status.ts index d15aea59..63aa9a95 100644 --- a/src/main/lib/git/status.ts +++ b/src/main/lib/git/status.ts @@ -97,9 +97,150 @@ export const createStatusRouter = () => { return files; }), + + /** + * Get commit log for commit-graph visualization. + * Returns commits in the format expected by the commit-graph npm package. + */ + getCommitLog: publicProcedure + .input( + z.object({ + worktreePath: z.string(), + limit: z.number().optional().default(50), + offset: z.number().optional().default(0), + }), + ) + .query(async ({ input }) => { + assertRegisteredWorktree(input.worktreePath); + + const git = simpleGit(input.worktreePath); + + // Get commits with parent info in format: hash|parents|subject|author|email|date + const logOutput = await git.raw([ + "log", + "--all", + `--skip=${input.offset}`, + `-n${input.limit}`, + "--format=%H|%P|%s|%an|%ae|%aI", + ]); + + const commits = parseCommitLogForGraph(logOutput); + + // Get branch heads (local and remote) + const branchOutput = await git.raw([ + "for-each-ref", + "--format=%(refname:short)|%(objectname)", + "refs/heads", + "refs/remotes/origin", + ]); + + const branches = parseBranchHeads(branchOutput); + + // Get set of commits that exist on remote (for sync status) + const remoteShas = await getRemoteCommitShas(git); + + // Check if there are more commits + const totalCountOutput = await git.raw(["rev-list", "--all", "--count"]); + const totalCount = Number.parseInt(totalCountOutput.trim(), 10); + const hasMore = input.offset + input.limit < totalCount; + + return { + commits, + branches, + remoteShas, + hasMore, + totalCount, + }; + }), }); }; +/** Commit type for commit-graph package */ +interface GraphCommit { + sha: string; + commit: { + author: { + name: string; + email: string; + date: string; + }; + message: string; + }; + parents: Array<{ sha: string }>; +} + +/** Branch type for commit-graph package */ +interface GraphBranch { + name: string; + commit: { sha: string }; +} + +function parseCommitLogForGraph(output: string): GraphCommit[] { + if (!output.trim()) return []; + + return output + .trim() + .split("\n") + .filter((line) => line.trim()) + .map((line) => { + const [hash, parents, message, author, email, date] = line.split("|"); + return { + sha: hash, + commit: { + author: { + name: author, + email: email, + date: date, + }, + message: message, + }, + parents: parents + ? parents.split(" ").map((p) => ({ sha: p })) + : [], + }; + }); +} + +function parseBranchHeads(output: string): GraphBranch[] { + if (!output.trim()) return []; + + return output + .trim() + .split("\n") + .filter((line) => line.trim()) + .map((line) => { + const [name, sha] = line.split("|"); + // Clean up remote branch names (origin/main -> main) + const cleanName = name.startsWith("origin/") + ? name.replace("origin/", "") + " (remote)" + : name; + return { + name: cleanName, + commit: { sha }, + }; + }); +} + +async function getRemoteCommitShas( + git: ReturnType, +): Promise { + try { + // Get all commits that exist on the remote tracking branch + const output = await git.raw([ + "rev-list", + "--remotes", + "-n", + "100", // Limit to avoid performance issues + ]); + return output + .trim() + .split("\n") + .filter((sha) => sha.trim()); + } catch { + return []; + } +} + interface BranchComparison { commits: GitChangesStatus["commits"]; againstBase: ChangedFile[]; diff --git a/src/main/lib/git/watcher/git-watcher.ts b/src/main/lib/git/watcher/git-watcher.ts new file mode 100644 index 00000000..141868db --- /dev/null +++ b/src/main/lib/git/watcher/git-watcher.ts @@ -0,0 +1,312 @@ +import { EventEmitter } from "events"; + +// Chokidar is ESM-only, so we need to dynamically import it +type FSWatcher = Awaited>["FSWatcher"] extends new () => infer T ? T : never; + +// Simple debounce implementation to avoid lodash-es dependency in main process +function debounce unknown>( + func: T, + wait: number, +): (...args: Parameters) => void { + let timeoutId: NodeJS.Timeout | null = null; + return (...args: Parameters) => { + if (timeoutId) clearTimeout(timeoutId); + timeoutId = setTimeout(() => func(...args), wait); + }; +} + +export type FileChangeType = "add" | "change" | "unlink"; + +export interface FileChange { + path: string; + type: FileChangeType; +} + +export interface GitWatchEvent { + type: "batch"; + changes: FileChange[]; + timestamp: number; + worktreePath: string; +} + +interface GitWatcherConfig { + worktreePath: string; + debounceMs?: number; + ignorePatterns?: string[]; +} + +const DEFAULT_IGNORE_PATTERNS = [ + // Node/JS + "**/node_modules/**", + "**/dist/**", + "**/build/**", + "**/out/**", + "**/.next/**", + "**/.turbo/**", + "**/.cache/**", + "**/coverage/**", + "**/*.log", + "**/.DS_Store", + "**/package-lock.json", + "**/pnpm-lock.yaml", + "**/yarn.lock", + "**/.env*", + "**/*.map", + "**/*.d.ts", + // Rust + "**/target/**", + // Python + "**/__pycache__/**", + "**/.pytest_cache/**", + "**/*.pyc", + // Go/PHP + "**/vendor/**", + // Java/Kotlin + "**/.gradle/**", + "**/*.class", + // IDE + "**/.idea/**", + "**/.vscode/**", + // iOS + "**/Pods/**", + // C/C++ + "**/*.o", + "**/*.obj", + // Temporary files + "**/*.swp", + "**/*.swo", + "**/*~", +]; + +/** + * GitWatcher monitors a worktree directory for file changes using chokidar. + * Changes are batched and debounced to avoid overwhelming the renderer with events. + */ +export class GitWatcher extends EventEmitter { + private watcher: FSWatcher | null = null; + private worktreePath: string; + private pendingChanges: Map = new Map(); + private isDisposed = false; + private debounceMs: number; + private initPromise: Promise; + + constructor(config: GitWatcherConfig) { + super(); + this.worktreePath = config.worktreePath; + this.debounceMs = config.debounceMs ?? 100; + this.initPromise = this.initWatcher(config); + } + + private async initWatcher(config: GitWatcherConfig): Promise { + // Dynamic import for ESM-only chokidar + const chokidar = await import("chokidar"); + const path = await import("path"); + + // Strategy: Watch ONLY .git/index and .git/HEAD + // - .git/index changes on ANY git operation (commit, stage, unstage, checkout, merge, etc.) + // - .git/HEAD changes on branch switches + // This uses only 2 file descriptors instead of thousands, avoiding EMFILE errors + const gitIndexPath = path.join(config.worktreePath, ".git", "index"); + const gitHeadPath = path.join(config.worktreePath, ".git", "HEAD"); + + const watchPaths = [gitIndexPath, gitHeadPath]; + + this.watcher = chokidar.watch(watchPaths, { + persistent: true, + ignoreInitial: true, + awaitWriteFinish: { + stabilityThreshold: 50, + pollInterval: 25, + }, + // Native events (no polling) for efficiency + usePolling: false, + // Don't follow symlinks + followSymlinks: false, + }); + + // Debounced flush function + const flushChanges = debounce(() => { + if (this.isDisposed || this.pendingChanges.size === 0) return; + + const changes: FileChange[] = Array.from( + this.pendingChanges.entries(), + ).map(([path, type]) => ({ + path, + type, + })); + + this.pendingChanges.clear(); + + const event: GitWatchEvent = { + type: "batch", + changes, + timestamp: Date.now(), + worktreePath: this.worktreePath, + }; + + this.emit("change", event); + }, this.debounceMs); + + this.watcher + .on("add", (path: string) => { + this.pendingChanges.set(path, "add"); + flushChanges(); + }) + .on("change", (path: string) => { + this.pendingChanges.set(path, "change"); + flushChanges(); + }) + .on("unlink", (path: string) => { + this.pendingChanges.set(path, "unlink"); + flushChanges(); + }) + .on("error", (error: Error) => { + console.error("[GitWatcher] Error:", error); + this.emit("error", error); + }); + + console.log(`[GitWatcher] Watching: ${config.worktreePath}`); + } + + /** + * Wait for the watcher to be initialized. + */ + async waitForReady(): Promise { + await this.initPromise; + } + + getWorktreePath(): string { + return this.worktreePath; + } + + async dispose(): Promise { + if (this.isDisposed) return; + this.isDisposed = true; + + // Wait for init to complete before disposing + await this.initPromise.catch(() => {}); + + await this.watcher?.close(); + this.pendingChanges.clear(); + this.removeAllListeners(); + console.log(`[GitWatcher] Disposed: ${this.worktreePath}`); + } +} + +/** + * Registry for managing multiple GitWatcher instances (one per worktree). + * Ensures only one watcher exists per worktree path. + */ +class GitWatcherRegistry { + private watchers: Map = new Map(); + private listeners: Map void>> = + new Map(); + + /** + * Get or create a watcher for the given worktree path. + * If a watcher already exists, returns the existing one. + */ + async getOrCreate(worktreePath: string): Promise { + let watcher = this.watchers.get(worktreePath); + if (!watcher) { + watcher = new GitWatcher({ + worktreePath, + debounceMs: 100, + }); + this.watchers.set(worktreePath, watcher); + + // Wire up event forwarding + watcher.on("change", (event: GitWatchEvent) => { + const listeners = this.listeners.get(worktreePath); + if (listeners) { + const callbacks = Array.from(listeners); + for (const callback of callbacks) { + try { + callback(event); + } catch (error) { + console.error( + "[GitWatcherRegistry] Listener error:", + error, + ); + } + } + } + }); + + // Wait for the watcher to be ready + await watcher.waitForReady(); + } + return watcher; + } + + /** + * Subscribe to file change events for a worktree. + * Returns an unsubscribe function. + * + * NOTE: This is async to ensure the watcher is ready before returning. + * This prevents race conditions where events could be missed if the + * callback is added before the watcher finishes initializing. + */ + async subscribe( + worktreePath: string, + callback: (event: GitWatchEvent) => void, + ): Promise<() => void> { + // Wait for watcher to be ready before adding listener + await this.getOrCreate(worktreePath); + + // Add listener + let listeners = this.listeners.get(worktreePath); + if (!listeners) { + listeners = new Set(); + this.listeners.set(worktreePath, listeners); + } + listeners.add(callback); + + // Return unsubscribe function + return () => { + listeners?.delete(callback); + // Keep watcher alive for potential reuse (only 2 file descriptors) + }; + } + + /** + * Check if a watcher exists for the given worktree. + */ + has(worktreePath: string): boolean { + return this.watchers.has(worktreePath); + } + + /** + * Dispose a specific watcher. + */ + async dispose(worktreePath: string): Promise { + const watcher = this.watchers.get(worktreePath); + if (watcher) { + await watcher.dispose(); + this.watchers.delete(worktreePath); + this.listeners.delete(worktreePath); + } + } + + /** + * Dispose all watchers. Call this when the app is shutting down. + */ + async disposeAll(): Promise { + const disposals = Array.from(this.watchers.values()).map((watcher) => + watcher.dispose(), + ); + await Promise.all(disposals); + this.watchers.clear(); + this.listeners.clear(); + } + + /** + * Get the number of active watchers. + */ + getWatcherCount(): number { + return this.watchers.size; + } +} + +// Singleton instance +export const gitWatcherRegistry = new GitWatcherRegistry(); diff --git a/src/main/lib/git/watcher/index.ts b/src/main/lib/git/watcher/index.ts new file mode 100644 index 00000000..a70f4bc9 --- /dev/null +++ b/src/main/lib/git/watcher/index.ts @@ -0,0 +1,12 @@ +export { + GitWatcher, + gitWatcherRegistry, + type FileChange, + type FileChangeType, + type GitWatchEvent, +} from "./git-watcher"; + +export { + registerGitWatcherIPC, + cleanupGitWatchers, +} from "./ipc-bridge"; diff --git a/src/main/lib/git/watcher/ipc-bridge.ts b/src/main/lib/git/watcher/ipc-bridge.ts new file mode 100644 index 00000000..22f13b21 --- /dev/null +++ b/src/main/lib/git/watcher/ipc-bridge.ts @@ -0,0 +1,92 @@ +import { ipcMain, BrowserWindow } from "electron"; +import { gitWatcherRegistry, type GitWatchEvent } from "./git-watcher"; +import { gitCache } from "../cache"; + +/** + * IPC Bridge for GitWatcher. + * Handles subscription/unsubscription from renderer and forwards file change events. + */ + +// Track active subscriptions per worktree +const activeSubscriptions: Map void> = new Map(); + +/** + * Register IPC handlers for git watcher. + * Call this once during app initialization. + */ +export function registerGitWatcherIPC( + getWindow: () => BrowserWindow | null, +): void { + // Handle subscription requests from renderer + ipcMain.handle( + "git:subscribe-watcher", + async (_event, worktreePath: string) => { + if (!worktreePath) return; + + // Already subscribed? + if (activeSubscriptions.has(worktreePath)) { + return; + } + + // Subscribe to file changes (await to ensure watcher is ready) + const unsubscribe = await gitWatcherRegistry.subscribe( + worktreePath, + (event: GitWatchEvent) => { + const win = getWindow(); + if (!win || win.isDestroyed()) return; + + // We're watching .git/index and .git/HEAD, so any event means a git operation occurred. + // Invalidate status and parsedDiff caches - these are always affected by git operations. + // File content cache is content-addressed and will update on next request if hash changed. + gitCache.invalidateStatus(worktreePath); + gitCache.invalidateParsedDiff(worktreePath); + + // Send event to renderer + win.webContents.send("git:status-changed", { + worktreePath: event.worktreePath, + changes: event.changes, + }); + }, + ); + + activeSubscriptions.set(worktreePath, unsubscribe); + console.log( + `[GitWatcher] Subscribed to: ${worktreePath}`, + ); + }, + ); + + // Handle unsubscription requests from renderer + ipcMain.handle( + "git:unsubscribe-watcher", + async (_event, worktreePath: string) => { + if (!worktreePath) return; + + const unsubscribe = activeSubscriptions.get(worktreePath); + if (unsubscribe) { + unsubscribe(); + activeSubscriptions.delete(worktreePath); + console.log( + `[GitWatcher] Unsubscribed from: ${worktreePath}`, + ); + } + }, + ); +} + +/** + * Cleanup all watchers. + * Call this when the app is shutting down. + */ +export async function cleanupGitWatchers(): Promise { + // Unsubscribe all + const unsubscribers = Array.from(activeSubscriptions.values()); + for (const unsubscribe of unsubscribers) { + unsubscribe(); + } + activeSubscriptions.clear(); + + // Dispose all watchers + await gitWatcherRegistry.disposeAll(); + console.log("[GitWatcher] All watchers cleaned up"); +} diff --git a/src/main/lib/trpc/routers/activities.ts b/src/main/lib/trpc/routers/activities.ts new file mode 100644 index 00000000..ff772cff --- /dev/null +++ b/src/main/lib/trpc/routers/activities.ts @@ -0,0 +1,132 @@ +import { z } from "zod" +import { router, publicProcedure } from "../index" +import { getDatabase, toolActivities } from "../../db" +import { desc, eq, lte } from "drizzle-orm" + +const MAX_JSON_SIZE = 50000 // 50KB limit for input/output + +/** + * Truncate JSON string to max size with indicator + */ +function truncateJson(value: unknown): string | null { + if (value === undefined || value === null) return null + let json = typeof value === "string" ? value : JSON.stringify(value) + if (json.length > MAX_JSON_SIZE) { + json = json.substring(0, MAX_JSON_SIZE) + "\n... (truncated)" + } + return json +} + +export const activitiesRouter = router({ + /** + * Get recent activities (limit to 100 most recent) + */ + getRecent: publicProcedure + .input(z.object({ limit: z.number().optional().default(100) })) + .query(({ input }) => { + const db = getDatabase() + return db + .select() + .from(toolActivities) + .orderBy(desc(toolActivities.createdAt)) + .limit(input.limit) + .all() + }), + + /** + * Add new activity (when tool starts) + */ + create: publicProcedure + .input( + z.object({ + subChatId: z.string(), + chatName: z.string(), + toolName: z.string(), + summary: z.string(), + state: z.enum(["running", "complete", "error"]), + input: z.unknown().optional(), + }), + ) + .mutation(({ input }) => { + const db = getDatabase() + const activity = db + .insert(toolActivities) + .values({ + subChatId: input.subChatId, + chatName: input.chatName, + toolName: input.toolName, + summary: input.summary, + state: input.state, + input: truncateJson(input.input), + }) + .returning() + .get() + return activity + }), + + /** + * Update activity state (when tool completes) + */ + update: publicProcedure + .input( + z.object({ + id: z.string(), + state: z.enum(["complete", "error"]), + output: z.unknown().optional(), + errorText: z.string().optional(), + }), + ) + .mutation(({ input }) => { + const db = getDatabase() + const updated = db + .update(toolActivities) + .set({ + state: input.state, + output: truncateJson(input.output), + errorText: input.errorText, + }) + .where(eq(toolActivities.id, input.id)) + .returning() + .get() + return updated + }), + + /** + * Clear all activities + */ + clear: publicProcedure.mutation(() => { + const db = getDatabase() + db.delete(toolActivities).run() + return { success: true } + }), + + /** + * Cleanup old activities (older than 30 days) + */ + cleanup: publicProcedure.mutation(() => { + const db = getDatabase() + const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) + const result = db + .delete(toolActivities) + .where(lte(toolActivities.createdAt, thirtyDaysAgo)) + .returning() + .all() + return { deleted: result.length } + }), + + /** + * Toggle pin status for an activity + */ + togglePin: publicProcedure + .input(z.object({ id: z.string(), isPinned: z.boolean() })) + .mutation(({ input }) => { + const db = getDatabase() + const updated = db + .update(toolActivities) + .set({ isPinned: input.isPinned }) + .where(eq(toolActivities.id, input.id)) + .returning() + .get() + return updated + }), +}) diff --git a/src/main/lib/trpc/routers/artifacts.ts b/src/main/lib/trpc/routers/artifacts.ts new file mode 100644 index 00000000..a9316770 --- /dev/null +++ b/src/main/lib/trpc/routers/artifacts.ts @@ -0,0 +1,164 @@ +import { z } from "zod" +import { eq } from "drizzle-orm" +import { router, publicProcedure } from "../index" +import { getDatabase, subChats } from "../../db" + +/** + * Artifact type representing a tool execution + */ +interface Artifact { + id: string + type: string + toolName: string + input: Record + result?: unknown + state?: string + // Extracted key fields for display + filePath?: string + command?: string + pattern?: string +} + +/** + * Artifacts router - query tool execution history from chat messages + * Extracts tool parts from existing messages (no DB schema changes needed) + */ +export const artifactsRouter = router({ + /** + * Get tool history for a sub-chat + */ + list: publicProcedure + .input( + z.object({ + subChatId: z.string(), + toolName: z.string().optional(), + }), + ) + .query(({ input }) => { + const db = getDatabase() + const subChat = db + .select() + .from(subChats) + .where(eq(subChats.id, input.subChatId)) + .get() + + if (!subChat) return { artifacts: [] } + + const messages = JSON.parse(subChat.messages || "[]") + const artifacts: Artifact[] = [] + + for (const msg of messages) { + if (msg.role !== "assistant") continue + for (const part of msg.parts || []) { + if (!part.type?.startsWith("tool-")) continue + if (input.toolName && part.toolName !== input.toolName) continue + + artifacts.push({ + id: part.toolCallId || `${msg.id}-${artifacts.length}`, + type: part.type.replace("tool-", ""), + toolName: part.toolName || "unknown", + input: part.input || {}, + result: part.result, + state: part.state, + // Extract key fields for display + filePath: part.input?.file_path, + command: part.input?.command?.substring(0, 100), + pattern: part.input?.pattern, + }) + } + } + + return { artifacts } + }), + + /** + * Search across all sub-chats for matching artifacts + */ + search: publicProcedure + .input( + z.object({ + query: z.string(), + chatId: z.string().optional(), + }), + ) + .query(({ input }) => { + const db = getDatabase() + const allSubChats = input.chatId + ? db + .select() + .from(subChats) + .where(eq(subChats.chatId, input.chatId)) + .all() + : db.select().from(subChats).all() + + const results: Array<{ + subChatId: string + chatId: string + artifact: { + id: string + toolName: string + filePath?: string + command?: string + } + }> = [] + + const queryLower = input.query.toLowerCase() + + for (const subChat of allSubChats) { + const messages = JSON.parse(subChat.messages || "[]") + for (const msg of messages) { + if (msg.role !== "assistant") continue + for (const part of msg.parts || []) { + if (!part.type?.startsWith("tool-")) continue + + const searchable = JSON.stringify(part.input).toLowerCase() + if (searchable.includes(queryLower)) { + results.push({ + subChatId: subChat.id, + chatId: subChat.chatId, + artifact: { + id: part.toolCallId || `${msg.id}-${results.length}`, + toolName: part.toolName || "unknown", + filePath: part.input?.file_path, + command: part.input?.command?.substring(0, 50), + }, + }) + } + } + } + } + + // Limit results to prevent performance issues + return { results: results.slice(0, 50) } + }), + + /** + * Get summary of tool usage for a sub-chat + */ + summary: publicProcedure + .input(z.object({ subChatId: z.string() })) + .query(({ input }) => { + const db = getDatabase() + const subChat = db + .select() + .from(subChats) + .where(eq(subChats.id, input.subChatId)) + .get() + + if (!subChat) return { summary: {} } + + const messages = JSON.parse(subChat.messages || "[]") + const summary: Record = {} + + for (const msg of messages) { + if (msg.role !== "assistant") continue + for (const part of msg.parts || []) { + if (!part.type?.startsWith("tool-")) continue + const toolName = part.toolName || "unknown" + summary[toolName] = (summary[toolName] || 0) + 1 + } + } + + return { summary } + }), +}) diff --git a/src/main/lib/trpc/routers/chats.ts b/src/main/lib/trpc/routers/chats.ts index fdf999be..2ef5651a 100644 --- a/src/main/lib/trpc/routers/chats.ts +++ b/src/main/lib/trpc/routers/chats.ts @@ -10,7 +10,6 @@ import { } from "../../git" import { execWithShellEnv } from "../../git/shell-env" import simpleGit from "simple-git" -import { getAuthManager, getBaseUrl } from "../../../index" import { trackWorkspaceCreated, trackWorkspaceArchived, @@ -119,7 +118,8 @@ export const chatsRouter = router({ .optional(), baseBranch: z.string().optional(), // Branch to base the worktree off useWorktree: z.boolean().default(true), // If false, work directly in project dir - mode: z.enum(["plan", "agent"]).default("agent"), + mode: z.enum(["plan", "agent", "ask"]).default("agent"), + modelId: z.enum(["opus", "sonnet", "haiku"]).optional(), // Default model for first sub-chat }), ) .mutation(async ({ input }) => { @@ -138,7 +138,11 @@ export const chatsRouter = router({ // Create chat (fast path) const chat = db .insert(chats) - .values({ name: input.name, projectId: input.projectId }) + .values({ + name: input.name, + projectId: input.projectId, + modelId: input.modelId, // Per-chat model preference + }) .returning() .get() console.log("[chats.create] created chat:", chat) @@ -170,6 +174,7 @@ export const chatsRouter = router({ .values({ chatId: chat.id, mode: input.mode, + modelId: input.modelId, messages: initialMessages, }) .returning() @@ -264,6 +269,21 @@ export const chatsRouter = router({ .get() }), + /** + * Update chat model preference + */ + updateModel: publicProcedure + .input(z.object({ id: z.string(), modelId: z.enum(["opus", "sonnet", "haiku"]) })) + .mutation(({ input }) => { + const db = getDatabase() + return db + .update(chats) + .set({ modelId: input.modelId, updatedAt: new Date() }) + .where(eq(chats.id, input.id)) + .returning() + .get() + }), + /** * Archive a chat */ @@ -389,7 +409,8 @@ export const chatsRouter = router({ z.object({ chatId: z.string(), name: z.string().optional(), - mode: z.enum(["plan", "agent"]).default("agent"), + mode: z.enum(["plan", "agent", "ask"]).default("agent"), + modelId: z.enum(["opus", "sonnet", "haiku"]).optional(), }), ) .mutation(({ input }) => { @@ -400,6 +421,7 @@ export const chatsRouter = router({ chatId: input.chatId, name: input.name, mode: input.mode, + modelId: input.modelId, messages: "[]", }) .returning() @@ -440,7 +462,7 @@ export const chatsRouter = router({ * Update sub-chat mode */ updateSubChatMode: publicProcedure - .input(z.object({ id: z.string(), mode: z.enum(["plan", "agent"]) })) + .input(z.object({ id: z.string(), mode: z.enum(["plan", "agent", "ask"]) })) .mutation(({ input }) => { const db = getDatabase() return db @@ -451,6 +473,30 @@ export const chatsRouter = router({ .get() }), + /** + * Update sub-chat model preference + */ + updateSubChatModel: publicProcedure + .input(z.object({ id: z.string(), modelId: z.enum(["opus", "sonnet", "haiku"]) })) + .mutation(({ input }) => { + const db = getDatabase() + const updatedSubChat = db + .update(subChats) + .set({ modelId: input.modelId, updatedAt: new Date() }) + .where(eq(subChats.id, input.id)) + .returning() + .get() + + if (updatedSubChat) { + db.update(chats) + .set({ updatedAt: new Date() }) + .where(eq(chats.id, updatedSubChat.chatId)) + .run() + } + + return updatedSubChat + }), + /** * Rename a sub-chat */ @@ -517,47 +563,8 @@ export const chatsRouter = router({ .input(z.object({ userMessage: z.string() })) .mutation(async ({ input }) => { try { - const authManager = getAuthManager() - const token = await authManager.getValidToken() - // Always use production API for name generation - const apiUrl = "https://21st.dev" - - console.log( - "[generateSubChatName] Calling API with token:", - token ? "present" : "missing", - ) - console.log( - "[generateSubChatName] URL:", - `${apiUrl}/api/agents/sub-chat/generate-name`, - ) - - const response = await fetch( - `${apiUrl}/api/agents/sub-chat/generate-name`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - ...(token && { "X-Desktop-Token": token }), - }, - body: JSON.stringify({ userMessage: input.userMessage }), - }, - ) - - console.log("[generateSubChatName] Response status:", response.status) - - if (!response.ok) { - const errorText = await response.text() - console.error( - "[generateSubChatName] API error:", - response.status, - errorText, - ) - return { name: getFallbackName(input.userMessage) } - } - - const data = await response.json() - console.log("[generateSubChatName] Generated name:", data.name) - return { name: data.name || getFallbackName(input.userMessage) } + // Local-only mode: avoid external API calls + return { name: getFallbackName(input.userMessage) } } catch (error) { console.error("[generateSubChatName] Error:", error) return { name: getFallbackName(input.userMessage) } diff --git a/src/main/lib/trpc/routers/claude-code.ts b/src/main/lib/trpc/routers/claude-code.ts index 3846fd7c..b39c9d03 100644 --- a/src/main/lib/trpc/routers/claude-code.ts +++ b/src/main/lib/trpc/routers/claude-code.ts @@ -1,17 +1,95 @@ import { z } from "zod" import { shell, safeStorage } from "electron" +import { randomBytes, createHash, randomUUID } from "crypto" import { router, publicProcedure } from "../index" -import { getAuthManager } from "../../../index" -import { getApiUrl } from "../../config" import { getDatabase, claudeCodeCredentials } from "../../db" import { eq } from "drizzle-orm" -/** - * Get desktop auth token for server API calls - */ -async function getDesktopToken(): Promise { - const authManager = getAuthManager() - return authManager.getValidToken() +const CLAUDE_CODE_OAUTH_CLIENT_ID = + process.env.CLAUDE_CODE_OAUTH_CLIENT_ID || + "9d1c250a-e61b-44d9-88ed-5944d1962f5e" + +const CLAUDE_CODE_AUTHORIZE_URL = + process.env.CLAUDE_CODE_AUTHORIZE_URL || "https://platform.claude.com/oauth/authorize" + +const CLAUDE_CODE_TOKEN_URL = + process.env.CLAUDE_CODE_TOKEN_URL || "https://platform.claude.com/v1/oauth/token" + +const CLAUDE_CODE_MANUAL_REDIRECT_URL = + process.env.CLAUDE_CODE_MANUAL_REDIRECT_URL || + "https://platform.claude.com/oauth/code/callback" + +const CLAUDE_CODE_SCOPES = [ + "org:create_api_key", + "user:profile", + "user:inference", + "user:sessions:claude_code", +] + +type OAuthSession = { + sessionId: string + createdAtMs: number + oauthUrl: string + state: string + codeVerifier: string +} + +const sessions = new Map() + +function base64UrlEncode(buffer: Buffer): string { + return buffer + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, "") +} + +function createCodeVerifier(): string { + // RFC 7636: 43-128 chars, unreserved URL chars + return base64UrlEncode(randomBytes(64)) +} + +function createCodeChallenge(verifier: string): string { + const hash = createHash("sha256").update(verifier).digest() + return base64UrlEncode(hash) +} + +function buildAuthorizeUrl(state: string, codeChallenge: string): string { + const url = new URL(CLAUDE_CODE_AUTHORIZE_URL) + // Claude Code CLI includes this flag in the authorize URL + url.searchParams.set("code", "true") + url.searchParams.set("client_id", CLAUDE_CODE_OAUTH_CLIENT_ID) + url.searchParams.set("response_type", "code") + url.searchParams.set("redirect_uri", CLAUDE_CODE_MANUAL_REDIRECT_URL) + url.searchParams.set("scope", CLAUDE_CODE_SCOPES.join(" ")) + url.searchParams.set("state", state) + url.searchParams.set("code_challenge", codeChallenge) + url.searchParams.set("code_challenge_method", "S256") + return url.toString() +} + +function parsePastedAuthCode(raw: string): { code: string; state: string | null } { + const trimmed = raw.trim() + + // Accept full callback URL (common copy/paste) + try { + if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { + const url = new URL(trimmed) + const code = url.searchParams.get("code")?.trim() + const state = url.searchParams.get("state")?.trim() || null + if (code) return { code, state } + } + } catch { + // fall through + } + + const hashIndex = trimmed.indexOf("#") + if (hashIndex === -1) { + return { code: trimmed, state: null } + } + const code = trimmed.slice(0, hashIndex).trim() + const state = trimmed.slice(hashIndex + 1).trim() || null + return { code, state } } /** @@ -38,7 +116,7 @@ function decryptToken(encrypted: string): string { /** * Claude Code OAuth router for desktop - * Uses server only for sandbox creation, stores token locally + * Uses PKCE OAuth directly (no 21st.dev login required), stores token locally */ export const claudeCodeRouter = router({ /** @@ -59,34 +137,33 @@ export const claudeCodeRouter = router({ }), /** - * Start OAuth flow - calls server to create sandbox + * Start OAuth flow - creates local PKCE session */ startAuth: publicProcedure.mutation(async () => { - const token = await getDesktopToken() - if (!token) { - throw new Error("Not authenticated with 21st.dev") - } - - // Server creates sandbox (has CodeSandbox SDK) - const response = await fetch(`${getApiUrl()}/api/auth/claude-code/start`, { - method: "POST", - headers: { "x-desktop-token": token }, + const sessionId = randomUUID() + const codeVerifier = createCodeVerifier() + const codeChallenge = createCodeChallenge(codeVerifier) + const state = base64UrlEncode(randomBytes(24)) + const oauthUrl = buildAuthorizeUrl(state, codeChallenge) + + sessions.set(sessionId, { + sessionId, + createdAtMs: Date.now(), + oauthUrl, + state, + codeVerifier, }) - if (!response.ok) { - const error = await response.json().catch(() => ({ error: "Unknown error" })) - throw new Error(error.error || `Start auth failed: ${response.status}`) - } - - return (await response.json()) as { - sandboxId: string - sandboxUrl: string - sessionId: string + // Preserve existing renderer contract (sandboxUrl is unused in local flow) + return { + sandboxId: "local", + sandboxUrl: "local", + sessionId, } }), /** - * Poll for OAuth URL - calls sandbox directly + * Poll for OAuth URL - returns local URL */ pollStatus: publicProcedure .input( @@ -96,29 +173,23 @@ export const claudeCodeRouter = router({ }) ) .query(async ({ input }) => { - try { - const response = await fetch( - `${input.sandboxUrl}/api/auth/${input.sessionId}/status` - ) - - if (!response.ok) { - return { state: "error" as const, oauthUrl: null, error: "Failed to poll status" } - } - - const data = await response.json() + const session = sessions.get(input.sessionId) + if (!session) { return { - state: data.state as string, - oauthUrl: data.oauthUrl ?? null, - error: data.error ?? null, + state: "error" as const, + oauthUrl: null, + error: "Auth session not found. Please restart authentication.", } - } catch (error) { - console.error("[ClaudeCode] Poll status error:", error) - return { state: "error" as const, oauthUrl: null, error: "Connection failed" } + } + return { + state: "waiting_code" as const, + oauthUrl: session.oauthUrl, + error: null, } }), /** - * Submit OAuth code - calls sandbox directly, stores token locally + * Submit OAuth code - exchanges directly, stores token locally */ submitCode: publicProcedure .input( @@ -129,46 +200,52 @@ export const claudeCodeRouter = router({ }) ) .mutation(async ({ input }) => { - // Submit code to sandbox - const codeRes = await fetch( - `${input.sandboxUrl}/api/auth/${input.sessionId}/code`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ code: input.code }), - } - ) - - if (!codeRes.ok) { - throw new Error(`Code submission failed: ${codeRes.statusText}`) + const session = sessions.get(input.sessionId) + if (!session) { + throw new Error("Auth session not found. Please restart authentication.") } - // Poll for OAuth token (max 10 seconds) - let oauthToken: string | null = null - - for (let i = 0; i < 10; i++) { - await new Promise((r) => setTimeout(r, 1000)) + const { code, state } = parsePastedAuthCode(input.code) - const statusRes = await fetch( - `${input.sandboxUrl}/api/auth/${input.sessionId}/status` - ) + if (!code) { + throw new Error("Invalid authorization code") + } - if (!statusRes.ok) continue + if (state && state !== session.state) { + throw new Error("Authorization code does not match this session. Please restart authentication.") + } - const status = await statusRes.json() + // Match Claude Code CLI's token exchange payload (JSON + includes state) + const body = JSON.stringify({ + grant_type: "authorization_code", + code, + redirect_uri: CLAUDE_CODE_MANUAL_REDIRECT_URL, + client_id: CLAUDE_CODE_OAUTH_CLIENT_ID, + code_verifier: session.codeVerifier, + state: session.state, + }) - if (status.state === "success" && status.oauthToken) { - oauthToken = status.oauthToken - break - } + const response = await fetch(CLAUDE_CODE_TOKEN_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body, + }) - if (status.state === "error") { - throw new Error(status.error || "Authentication failed") - } + if (!response.ok) { + const errorText = await response.text().catch(() => "") + throw new Error( + `Token exchange failed: ${response.status}${errorText ? ` - ${errorText}` : ""}`, + ) } + const data = (await response.json()) as { access_token?: string } + const oauthToken = data.access_token + if (!oauthToken) { - throw new Error("Timeout waiting for OAuth token") + throw new Error("Token exchange failed: missing access_token") } // Validate token format @@ -176,10 +253,6 @@ export const claudeCodeRouter = router({ throw new Error("Invalid OAuth token format") } - // Get user ID for reference - const authManager = getAuthManager() - const user = authManager.getUser() - // Encrypt and store locally const encryptedToken = encryptToken(oauthToken) const db = getDatabase() @@ -194,11 +267,12 @@ export const claudeCodeRouter = router({ id: "default", oauthToken: encryptedToken, connectedAt: new Date(), - userId: user?.id ?? null, + userId: null, }) .run() console.log("[ClaudeCode] Token stored locally") + sessions.delete(input.sessionId) return { success: true } }), diff --git a/src/main/lib/trpc/routers/claude.ts b/src/main/lib/trpc/routers/claude.ts index 59fe6475..c5526f9d 100644 --- a/src/main/lib/trpc/routers/claude.ts +++ b/src/main/lib/trpc/routers/claude.ts @@ -1,6 +1,6 @@ import { observable } from "@trpc/server/observable" import { eq } from "drizzle-orm" -import { app, safeStorage, BrowserWindow } from "electron" +import { app, BrowserWindow } from "electron" import path from "path" import * as os from "os" import * as fs from "fs/promises" @@ -13,9 +13,10 @@ import { logRawClaudeMessage, type UIMessageChunk, } from "../../claude" -import { chats, claudeCodeCredentials, getDatabase, subChats } from "../../db" +import { chats, getDatabase, subChats } from "../../db" import { publicProcedure, router } from "../index" import { buildAgentsOption } from "./agent-utils" +import { getClaudeCodeToken } from "../../claude/token" /** * Parse @[agent:name], @[skill:name], and @[tool:name] mentions from prompt text @@ -84,41 +85,6 @@ function parseMentions(prompt: string): { return { cleanedPrompt, agentMentions, skillMentions, fileMentions, folderMentions, toolMentions } } -/** - * Decrypt token using Electron's safeStorage - */ -function decryptToken(encrypted: string): string { - if (!safeStorage.isEncryptionAvailable()) { - return Buffer.from(encrypted, "base64").toString("utf-8") - } - const buffer = Buffer.from(encrypted, "base64") - return safeStorage.decryptString(buffer) -} - -/** - * Get Claude Code OAuth token from local SQLite - * Returns null if not connected - */ -function getClaudeCodeToken(): string | null { - try { - const db = getDatabase() - const cred = db - .select() - .from(claudeCodeCredentials) - .where(eq(claudeCodeCredentials.id, "default")) - .get() - - if (!cred?.oauthToken) { - console.log("[claude] No Claude Code credentials found") - return null - } - - return decryptToken(cred.oauthToken) - } catch (error) { - console.error("[claude] Error getting Claude Code token:", error) - return null - } -} // Dynamic import for ESM module const getClaudeQuery = async () => { @@ -169,7 +135,7 @@ export const claudeRouter = router({ prompt: z.string(), cwd: z.string(), projectPath: z.string().optional(), // Original project path for MCP config lookup - mode: z.enum(["plan", "agent"]).default("agent"), + mode: z.enum(["plan", "agent", "ask"]).default("agent"), sessionId: z.string().optional(), model: z.string().optional(), maxThinkingTokens: z.number().optional(), // Enable extended thinking @@ -401,6 +367,7 @@ export const claudeRouter = router({ // MCP servers to pass to SDK (read from ~/.claude.json) let mcpServersForSdk: Record | undefined + const RESERVED_MCP_SERVER_NAMES = new Set(["claude-in-chrome"]) // Ensure isolated config dir exists and symlink skills/agents from ~/.claude/ // This is needed because SDK looks for skills at $CLAUDE_CONFIG_DIR/skills/ @@ -456,8 +423,19 @@ export const claudeRouter = router({ console.log(`[claude] MCP config lookup: lookupPath=${lookupPath}, found=${!!projectConfig?.mcpServers}`) if (projectConfig?.mcpServers) { console.log(`[claude] MCP servers found: ${Object.keys(projectConfig.mcpServers).join(", ")}`) - // Store MCP servers to pass to SDK - mcpServersForSdk = projectConfig.mcpServers + // Store MCP servers to pass to SDK (filter reserved names that crash Claude Code) + mcpServersForSdk = Object.fromEntries( + Object.entries(projectConfig.mcpServers).filter( + ([name]) => !RESERVED_MCP_SERVER_NAMES.has(name), + ), + ) + for (const name of Object.keys(projectConfig.mcpServers)) { + if (RESERVED_MCP_SERVER_NAMES.has(name)) { + console.warn( + `[claude] Skipping reserved MCP server "${name}" from ~/.claude.json (it causes Claude Code to crash)`, + ) + } + } } else { // Log available project paths in config for debugging const projectPaths = Object.keys(originalConfig.projects || {}).filter(k => originalConfig.projects[k]?.mcpServers) @@ -504,8 +482,10 @@ export const claudeRouter = router({ permissionMode: input.mode === "plan" ? ("plan" as const) - : ("bypassPermissions" as const), - ...(input.mode !== "plan" && { + : input.mode === "ask" + ? ("dontAsk" as const) + : ("bypassPermissions" as const), + ...(input.mode === "agent" && { allowDangerouslySkipPermissions: true, }), includePartialMessages: true, @@ -516,6 +496,20 @@ export const claudeRouter = router({ toolInput: Record, options: { toolUseID: string }, ) => { + // Ask mode: deny ALL tools (defense-in-depth alongside permissionMode: "dontAsk") + if (input.mode === "ask") { + const denyMessage = "Tools are not available in Ask mode" + // Emit error to frontend so user sees feedback + safeEmit({ + type: "tool-output-error", + toolCallId: options.toolUseID, + errorText: denyMessage, + } as UIMessageChunk) + return { + behavior: "deny", + message: denyMessage, + } + } if (toolName === "AskUserQuestion") { const { toolUseID } = options // Emit to UI (safely in case observer is closed) @@ -817,11 +811,17 @@ export const claudeRouter = router({ ) if (existingCompact) { existingCompact.state = chunk.state + if (typeof chunk.preTokens === "number") { + existingCompact.preTokens = chunk.preTokens + } } else { parts.push({ type: "system-Compact", toolCallId: chunk.toolCallId, state: chunk.state, + ...(typeof chunk.preTokens === "number" && { + preTokens: chunk.preTokens, + }), }) } break @@ -1139,4 +1139,92 @@ export const claudeRouter = router({ pendingToolApprovals.delete(input.toolUseId) return { ok: true } }), + + /** + * Sync CLI session messages back to GUI database + * Called when a Claude Code terminal exits + */ + syncCliSession: publicProcedure + .input( + z.object({ + subChatId: z.string(), + sessionId: z.string(), + cwd: z.string(), + }), + ) + .mutation(async ({ input }) => { + const db = getDatabase() + + // 1. Get config directory for this subChat + const configDir = path.join( + app.getPath("userData"), + "claude-sessions", + input.subChatId, + ) + + // 2. Find the session file + const { findSessionFile, parseSessionFile, convertCliToGuiMessages } = + await import("../../claude/cli-sync") + + const sessionFilePath = await findSessionFile( + configDir, + input.cwd, + input.sessionId, + ) + + if (!sessionFilePath) { + console.warn( + `[CLI-SYNC] Session file not found for subChat ${input.subChatId}`, + ) + return { synced: false, messageCount: 0, reason: "file_not_found" } + } + + // 3. Parse the JSONL file + const cliMessages = await parseSessionFile(sessionFilePath) + if (cliMessages.length === 0) { + console.warn(`[CLI-SYNC] No messages in session file`) + return { synced: false, messageCount: 0, reason: "no_messages" } + } + + // 4. Get existing messages from database + const existing = db + .select() + .from(subChats) + .where(eq(subChats.id, input.subChatId)) + .get() + + if (!existing) { + console.error(`[CLI-SYNC] SubChat not found: ${input.subChatId}`) + return { synced: false, messageCount: 0, reason: "subchat_not_found" } + } + + const existingMessages = JSON.parse(existing.messages || "[]") + + // 5. Convert CLI messages to GUI format and merge + const convertedMessages = convertCliToGuiMessages( + cliMessages, + existingMessages, + ) + + const newMessageCount = convertedMessages.length - existingMessages.length + + // 6. Update database + db.update(subChats) + .set({ + messages: JSON.stringify(convertedMessages), + updatedAt: new Date(), + }) + .where(eq(subChats.id, input.subChatId)) + .run() + + console.log( + `[CLI-SYNC] Synced ${newMessageCount} new messages for subChat ${input.subChatId}`, + ) + + return { + synced: true, + messageCount: convertedMessages.length, + newMessages: newMessageCount, + } + }), }) diff --git a/src/main/lib/trpc/routers/debug.ts b/src/main/lib/trpc/routers/debug.ts index ec16de27..9d47acf4 100644 --- a/src/main/lib/trpc/routers/debug.ts +++ b/src/main/lib/trpc/routers/debug.ts @@ -1,38 +1,19 @@ import { router, publicProcedure } from "../index" import { getDatabase, projects, chats, subChats } from "../../db" import { app, shell } from "electron" -import { getAuthManager } from "../../../index" - -// Protocol constant (must match main/index.ts) const IS_DEV = !!process.env.ELECTRON_RENDERER_URL -const PROTOCOL = IS_DEV ? "twentyfirst-agents-dev" : "twentyfirst-agents" export const debugRouter = router({ /** * Get system information for debug display */ getSystemInfo: publicProcedure.query(() => { - // Check protocol registration - let protocolRegistered = false - try { - protocolRegistered = process.defaultApp - ? app.isDefaultProtocolClient( - PROTOCOL, - process.execPath, - [process.argv[1]!], - ) - : app.isDefaultProtocolClient(PROTOCOL) - } catch { - protocolRegistered = false - } - return { version: app.getVersion(), platform: process.platform, arch: process.arch, isDev: IS_DEV, userDataPath: app.getPath("userData"), - protocolRegistered, } }), @@ -77,16 +58,6 @@ export const debugRouter = router({ return { success: true } }), - /** - * Logout (clear auth only) - */ - logout: publicProcedure.mutation(() => { - const authManager = getAuthManager() - authManager.logout() - console.log("[Debug] User logged out") - return { success: true } - }), - /** * Open userData folder in system file manager */ diff --git a/src/main/lib/trpc/routers/index.ts b/src/main/lib/trpc/routers/index.ts index 58ca6279..0d152262 100644 --- a/src/main/lib/trpc/routers/index.ts +++ b/src/main/lib/trpc/routers/index.ts @@ -9,6 +9,8 @@ import { filesRouter } from "./files" import { debugRouter } from "./debug" import { skillsRouter } from "./skills" import { agentsRouter } from "./agents" +import { artifactsRouter } from "./artifacts" +import { activitiesRouter } from "./activities" import { createGitRouter } from "../../git" import { BrowserWindow } from "electron" @@ -28,6 +30,8 @@ export function createAppRouter(getWindow: () => BrowserWindow | null) { debug: debugRouter, skills: skillsRouter, agents: agentsRouter, + artifacts: artifactsRouter, + activities: activitiesRouter, // Git operations - named "changes" to match Superset API changes: createGitRouter(), }) diff --git a/src/main/lib/trpc/routers/terminal.ts b/src/main/lib/trpc/routers/terminal.ts index e116d106..99e8fbea 100644 --- a/src/main/lib/trpc/routers/terminal.ts +++ b/src/main/lib/trpc/routers/terminal.ts @@ -6,6 +6,7 @@ import { observable } from "@trpc/server/observable" import { terminalManager } from "../../terminal/manager" import type { TerminalEvent } from "../../terminal/types" import { TRPCError } from "@trpc/server" +import { app } from "electron" export const terminalRouter = router({ /** @@ -197,4 +198,79 @@ export const terminalRouter = router({ } }) }), + + /** + * Create a terminal session that runs Claude Code CLI with resume + */ + createClaudeCodeSession: publicProcedure + .input( + z.object({ + paneId: z.string(), + subChatId: z.string(), + sessionId: z.string(), + cwd: z.string(), + workspaceId: z.string(), + }), + ) + .mutation(async ({ input }) => { + const fs = await import("fs/promises") + const os = await import("os") + + // Get the isolated config directory for this subChat + const configDir = path.join( + app.getPath("userData"), + "claude-sessions", + input.subChatId, + ) + + // Ensure config directory exists + await fs.mkdir(configDir, { recursive: true }) + + // Symlink config.json from ~/.claude/ so CLI auth works + const homeClaudeDir = path.join(os.homedir(), ".claude") + const configSource = path.join(homeClaudeDir, "config.json") + const configTarget = path.join(configDir, "config.json") + + try { + const configSourceExists = await fs + .stat(configSource) + .then(() => true) + .catch(() => false) + const configTargetExists = await fs + .lstat(configTarget) + .then(() => true) + .catch(() => false) + + if (configSourceExists && !configTargetExists) { + await fs.symlink(configSource, configTarget, "file") + console.log(`[TERMINAL] Symlinked config: ${configTarget} -> ${configSource}`) + } + } catch (symlinkErr) { + console.warn("[TERMINAL] Failed to symlink config.json:", symlinkErr) + } + + // Create command with environment variable set inline + // This sets CLAUDE_CONFIG_DIR and runs claude --resume in one command + const command = `CLAUDE_CONFIG_DIR="${configDir}" claude --resume ${input.sessionId}` + + // Create the terminal session with the command as initialCommands + const result = await terminalManager.createOrAttach({ + paneId: input.paneId, + workspaceId: input.workspaceId, + cwd: input.cwd, + cols: 80, + rows: 24, + initialCommands: [command], + }) + + console.log( + `[TERMINAL] Created Claude Code session: paneId=${input.paneId}, sessionId=${input.sessionId}`, + ) + + return { + paneId: input.paneId, + sessionId: input.sessionId, + isNew: result.isNew, + } + }), }) diff --git a/src/main/windows/main.ts b/src/main/windows/main.ts index d5938678..bdf22e64 100644 --- a/src/main/windows/main.ts +++ b/src/main/windows/main.ts @@ -5,12 +5,12 @@ import { ipcMain, app, clipboard, - session, } from "electron" import { join } from "path" +import { exec } from "child_process" import { createIPCHandler } from "trpc-electron/main" import { createAppRouter } from "../lib/trpc/routers" -import { getAuthManager, handleAuthCode, getBaseUrl } from "../index" +import { getBaseUrl } from "../index" // Register IPC handlers for window operations (only once) let ipcHandlersRegistered = false @@ -35,6 +35,29 @@ function registerIpcHandlers(getWindow: () => BrowserWindow | null): void { }, ) + // Terminal-notifier for macOS (per CLAUDE.md instructions) + ipcMain.handle( + "app:terminal-notify", + (_event, { message, title }: { message: string; title: string }) => { + if (process.platform === "darwin") { + // Escape quotes to prevent command injection + const safeMessage = message.replace(/"/g, '\\"') + const safeTitle = title.replace(/"/g, '\\"') + exec( + `terminal-notifier -message "${safeMessage}" -title "${safeTitle}"`, + (err) => { + if (err) { + console.warn( + "[Notification] terminal-notifier failed:", + err.message, + ) + } + }, + ) + } + }, + ) + // API base URL for fetch requests ipcMain.handle("app:get-api-base-url", () => getBaseUrl()) @@ -127,92 +150,11 @@ function registerIpcHandlers(getWindow: () => BrowserWindow | null): void { clipboard.writeText(text), ) ipcMain.handle("clipboard:read", () => clipboard.readText()) - - // Auth IPC handlers - const validateSender = (event: Electron.IpcMainInvokeEvent): boolean => { - const senderUrl = event.sender.getURL() - try { - const parsed = new URL(senderUrl) - if (parsed.protocol === "file:") return true - const hostname = parsed.hostname.toLowerCase() - const trusted = ["21st.dev", "localhost", "127.0.0.1"] - return trusted.some((h) => hostname === h || hostname.endsWith(`.${h}`)) - } catch { - return false - } - } - - ipcMain.handle("auth:get-user", (event) => { - if (!validateSender(event)) return null - return getAuthManager().getUser() - }) - - ipcMain.handle("auth:is-authenticated", (event) => { - if (!validateSender(event)) return false - return getAuthManager().isAuthenticated() - }) - - ipcMain.handle("auth:logout", async (event) => { - if (!validateSender(event)) return - getAuthManager().logout() - // Clear cookie from persist:main partition - const ses = session.fromPartition("persist:main") - try { - await ses.cookies.remove(getBaseUrl(), "x-desktop-token") - console.log("[Auth] Cookie cleared on logout") - } catch (err) { - console.error("[Auth] Failed to clear cookie:", err) - } - showLoginPage() - }) - - ipcMain.handle("auth:start-flow", (event) => { - if (!validateSender(event)) return - getAuthManager().startAuthFlow(getWindow()) - }) - - ipcMain.handle("auth:submit-code", async (event, code: string) => { - if (!validateSender(event)) return - if (!code || typeof code !== "string") { - getWindow()?.webContents.send("auth:error", "Invalid authorization code") - return - } - await handleAuthCode(code) - }) - - ipcMain.handle("auth:update-user", async (event, updates: { name?: string }) => { - if (!validateSender(event)) return null - try { - return await getAuthManager().updateUser(updates) - } catch (error) { - console.error("[Auth] Failed to update user:", error) - throw error - } - }) } // Current window reference let currentWindow: BrowserWindow | null = null -/** - * Show login page - */ -export function showLoginPage(): void { - if (!currentWindow) return - console.log("[Main] Showing login page") - - // In dev mode, login.html is in src/renderer, not out/renderer - if (process.env.ELECTRON_RENDERER_URL) { - // Dev mode: load from source directory - const loginPath = join(app.getAppPath(), "src/renderer/login.html") - console.log("[Main] Loading login from:", loginPath) - currentWindow.loadFile(loginPath) - } else { - // Production: load from built output - currentWindow.loadFile(join(__dirname, "../renderer/login.html")) - } -} - // Singleton IPC handler (prevents duplicate handlers on macOS window recreation) let ipcHandler: ReturnType | null = null @@ -320,33 +262,15 @@ export function createMainWindow(): BrowserWindow { // Load the renderer - check auth first const devServerUrl = process.env.ELECTRON_RENDERER_URL - const authManager = getAuthManager() - console.log("[Main] ========== AUTH CHECK ==========") - console.log("[Main] AuthManager exists:", !!authManager) - const isAuth = authManager.isAuthenticated() - console.log("[Main] isAuthenticated():", isAuth) - const user = authManager.getUser() - console.log("[Main] getUser():", user ? user.email : "null") - console.log("[Main] ================================") - - if (isAuth) { - console.log("[Main] ✓ User authenticated, loading app") - if (devServerUrl) { - window.loadURL(devServerUrl) + if (devServerUrl) { + window.loadURL(devServerUrl) + // Gate DevTools with env var to allow clean CDP automation (single target) + if (process.env.OPEN_DEVTOOLS !== "0") { window.webContents.openDevTools() - } else { - window.loadFile(join(__dirname, "../renderer/index.html")) } } else { - console.log("[Main] ✗ Not authenticated, showing login page") - // In dev mode, login.html is in src/renderer - if (devServerUrl) { - const loginPath = join(app.getAppPath(), "src/renderer/login.html") - window.loadFile(loginPath) - } else { - window.loadFile(join(__dirname, "../renderer/login.html")) - } + window.loadFile(join(__dirname, "../renderer/index.html")) } // Ensure traffic lights are visible after page load (covers reload/Cmd+R case) diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 51a06b4c..e1efd4d8 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -10,14 +10,6 @@ export interface UpdateProgress { total: number } -export interface DesktopUser { - id: string - email: string - name: string | null - imageUrl: string | null - username: string | null -} - export interface DesktopApi { // Platform info platform: NodeJS.Platform @@ -69,16 +61,6 @@ export interface DesktopApi { clipboardWrite: (text: string) => Promise clipboardRead: () => Promise - // Auth - getUser: () => Promise - isAuthenticated: () => Promise - logout: () => Promise - startAuthFlow: () => Promise - submitAuthCode: (code: string) => Promise - updateUser: (updates: { name?: string }) => Promise - onAuthSuccess: (callback: (user: any) => void) => () => void - onAuthError: (callback: (error: string) => void) => () => void - // Shortcuts onShortcutNewAgent: (callback: () => void) => () => void } diff --git a/src/preload/index.ts b/src/preload/index.ts index ec8ee8d3..2818cca2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -103,6 +103,8 @@ contextBridge.exposeInMainWorld("desktopApi", { setBadge: (count: number | null) => ipcRenderer.invoke("app:set-badge", count), showNotification: (options: { title: string; body: string }) => ipcRenderer.invoke("app:show-notification", options), + terminalNotify: (message: string, title: string) => + ipcRenderer.invoke("app:terminal-notify", { message, title }), openExternal: (url: string) => ipcRenderer.invoke("shell:open-external", url), // API base URL (for fetch requests to server) @@ -112,26 +114,6 @@ contextBridge.exposeInMainWorld("desktopApi", { clipboardWrite: (text: string) => ipcRenderer.invoke("clipboard:write", text), clipboardRead: () => ipcRenderer.invoke("clipboard:read"), - // Auth methods - getUser: () => ipcRenderer.invoke("auth:get-user"), - isAuthenticated: () => ipcRenderer.invoke("auth:is-authenticated"), - logout: () => ipcRenderer.invoke("auth:logout"), - startAuthFlow: () => ipcRenderer.invoke("auth:start-flow"), - submitAuthCode: (code: string) => ipcRenderer.invoke("auth:submit-code", code), - updateUser: (updates: { name?: string }) => ipcRenderer.invoke("auth:update-user", updates), - - // Auth events - onAuthSuccess: (callback: (user: any) => void) => { - const handler = (_event: unknown, user: any) => callback(user) - ipcRenderer.on("auth:success", handler) - return () => ipcRenderer.removeListener("auth:success", handler) - }, - onAuthError: (callback: (error: string) => void) => { - const handler = (_event: unknown, error: string) => callback(error) - ipcRenderer.on("auth:error", handler) - return () => ipcRenderer.removeListener("auth:error", handler) - }, - // Shortcut events (from main process menu accelerators) onShortcutNewAgent: (callback: () => void) => { const handler = () => callback() @@ -193,6 +175,7 @@ export interface DesktopApi { setAnalyticsOptOut: (optedOut: boolean) => Promise setBadge: (count: number | null) => Promise showNotification: (options: { title: string; body: string }) => Promise + terminalNotify: (message: string, title: string) => Promise openExternal: (url: string) => Promise getApiBaseUrl: () => Promise clipboardWrite: (text: string) => Promise diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index ec334019..5b9711e0 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from "react" +import { useEffect, useMemo, useRef } from "react" import { Provider as JotaiProvider, useAtomValue } from "jotai" import { ThemeProvider, useTheme } from "next-themes" import { Toaster } from "sonner" @@ -15,6 +15,8 @@ import { VSCodeThemeProvider } from "./lib/themes/theme-provider" import { anthropicOnboardingCompletedAtom } from "./lib/atoms" import { selectedProjectAtom } from "./features/agents/atoms" import { trpc } from "./lib/trpc" +import { soundManager } from "./lib/sound-manager" +import { useNaggingSound } from "./features/agents/hooks/use-nagging-sound" /** * Custom Toaster that adapts to theme @@ -40,6 +42,9 @@ function AppContent() { ) const selectedProject = useAtomValue(selectedProjectAtom) + // Play nagging sound when user questions are pending + useNaggingSound() + // Fetch projects to validate selectedProject exists const { data: projects, isLoading: isLoadingProjects } = trpc.projects.list.useQuery() @@ -71,6 +76,33 @@ function AppContent() { } export function App() { + const soundInitializedRef = useRef(false) + + // Initialize sound on first user interaction (required by Web Audio API) + useEffect(() => { + const initSound = async () => { + if (soundInitializedRef.current) return + soundInitializedRef.current = true + await soundManager.init() + } + + // Listen for first user interaction + const handleInteraction = () => { + initSound() + // Remove listeners after initialization + document.removeEventListener("click", handleInteraction) + document.removeEventListener("keydown", handleInteraction) + } + + document.addEventListener("click", handleInteraction) + document.addEventListener("keydown", handleInteraction) + + return () => { + document.removeEventListener("click", handleInteraction) + document.removeEventListener("keydown", handleInteraction) + } + }, []) + // Initialize analytics on mount useEffect(() => { initAnalytics() diff --git a/src/renderer/components/chat-markdown-renderer.tsx b/src/renderer/components/chat-markdown-renderer.tsx index 83c1e299..acf61321 100644 --- a/src/renderer/components/chat-markdown-renderer.tsx +++ b/src/renderer/components/chat-markdown-renderer.tsx @@ -54,6 +54,21 @@ const codeBlockTextSize = { lg: "text-sm", // 14px - matches p text } +// Module-level cache for Shiki highlighted code (persists across renders) +// Key: themeId:language:codeHash, Value: highlighted HTML +const highlightCache = new Map() + +// Simple hash function for cache key (fast, not cryptographic) +function hashCode(str: string): string { + let hash = 0 + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i) + hash = ((hash << 5) - hash) + char + hash = hash & hash // Convert to 32-bit integer + } + return hash.toString(36) +} + // Code block with copy button using Shiki function CodeBlock({ language, @@ -81,6 +96,14 @@ function CodeBlock({ useEffect(() => { if (!shouldHighlight) return + // Check cache first (perf optimization - avoids re-highlighting same code) + const cacheKey = `${themeId}:${language}:${hashCode(children)}` + const cached = highlightCache.get(cacheKey) + if (cached) { + setHighlightedHtml(cached) + return + } + let cancelled = false const highlight = async () => { @@ -88,6 +111,8 @@ function CodeBlock({ const html = await highlightCode(children, language, themeId) if (!cancelled) { setHighlightedHtml(html) + // Cache the result + highlightCache.set(cacheKey, html) } } catch (error) { console.error("Failed to highlight code:", error) diff --git a/src/renderer/components/dialogs/agents-settings-dialog.tsx b/src/renderer/components/dialogs/agents-settings-dialog.tsx index 3e8226ce..c0cf487e 100644 --- a/src/renderer/components/dialogs/agents-settings-dialog.tsx +++ b/src/renderer/components/dialogs/agents-settings-dialog.tsx @@ -12,6 +12,7 @@ import { } from "../../icons" import { SkillIconFilled, CustomAgentIconFilled, OriginalMCPIcon } from "../ui/icons" import { AgentsAppearanceTab } from "./settings-tabs/agents-appearance-tab" +import { AgentsClaudeCodeTab } from "./settings-tabs/agents-claude-code-tab" import { AgentsProfileTab } from "./settings-tabs/agents-profile-tab" import { AgentsPreferencesTab } from "./settings-tabs/agents-preferences-tab" import { AgentsDebugTab } from "./settings-tabs/agents-debug-tab" @@ -47,9 +48,15 @@ interface AgentsSettingsDialogProps { const ALL_TABS = [ { id: "profile" as SettingsTab, - label: "Account", + label: "Profile", icon: ProfileIconFilled, - description: "Manage your account settings", + description: "Manage your profile settings", + }, + { + id: "claudeCode" as SettingsTab, + label: "Claude Code", + icon: OriginalMCPIcon, + description: "Connect Claude Code OAuth", }, { id: "appearance" as SettingsTab, @@ -202,6 +209,8 @@ export function AgentsSettingsDialog({ switch (activeTab) { case "profile": return + case "claudeCode": + return case "appearance": return case "preferences": diff --git a/src/renderer/components/dialogs/settings-tabs/agents-claude-code-tab.tsx b/src/renderer/components/dialogs/settings-tabs/agents-claude-code-tab.tsx new file mode 100644 index 00000000..b4628039 --- /dev/null +++ b/src/renderer/components/dialogs/settings-tabs/agents-claude-code-tab.tsx @@ -0,0 +1,2 @@ +export { AgentsClaudeCodeTab } from "../../../features/agents/components/settings-tabs/agents-claude-code-tab" + diff --git a/src/renderer/components/dialogs/settings-tabs/agents-debug-tab.tsx b/src/renderer/components/dialogs/settings-tabs/agents-debug-tab.tsx index a56479d8..b0259d25 100644 --- a/src/renderer/components/dialogs/settings-tabs/agents-debug-tab.tsx +++ b/src/renderer/components/dialogs/settings-tabs/agents-debug-tab.tsx @@ -51,14 +51,6 @@ export function AgentsDebugTab() { onError: (error) => toast.error(error.message), }) - const logoutMutation = trpc.debug.logout.useMutation({ - onSuccess: () => { - toast.success("Logged out. Reloading...") - setTimeout(() => window.location.reload(), 500) - }, - onError: (error) => toast.error(error.message), - }) - const openFolderMutation = trpc.debug.openUserDataFolder.useMutation({ onError: (error) => toast.error(error.message), }) @@ -118,12 +110,6 @@ export function AgentsDebugTab() { value={systemInfo?.isDev ? "Yes" : "No"} isLoading={isLoading} /> -
userData
@@ -275,7 +261,7 @@ export function AgentsDebugTab() {

Data Management

-
+
- +
+
+
+
+ {/* Keyboard Shortcuts Section */}
diff --git a/src/renderer/components/dialogs/settings-tabs/agents-profile-tab.tsx b/src/renderer/components/dialogs/settings-tabs/agents-profile-tab.tsx index e280dad4..0f34224a 100644 --- a/src/renderer/components/dialogs/settings-tabs/agents-profile-tab.tsx +++ b/src/renderer/components/dialogs/settings-tabs/agents-profile-tab.tsx @@ -1,9 +1,11 @@ import { useState, useEffect } from "react" +import { useAtom } from "jotai" import { Button } from "../../ui/button" import { Input } from "../../ui/input" import { Label } from "../../ui/label" import { IconSpinner } from "../../../icons" import { toast } from "sonner" +import { localProfileNameAtom } from "../../../lib/atoms" // Hook to detect narrow screen function useIsNarrowScreen(): boolean { @@ -22,46 +24,21 @@ function useIsNarrowScreen(): boolean { return isNarrow } -interface DesktopUser { - id: string - email: string - name: string | null - imageUrl: string | null - username: string | null -} - export function AgentsProfileTab() { - const [user, setUser] = useState(null) - const [fullName, setFullName] = useState("") + const [storedName, setStoredName] = useAtom(localProfileNameAtom) + const [fullName, setFullName] = useState(storedName) const [isSaving, setIsSaving] = useState(false) - const [isLoading, setIsLoading] = useState(true) const isNarrowScreen = useIsNarrowScreen() - // Fetch real user data from desktop API useEffect(() => { - async function fetchUser() { - if (window.desktopApi?.getUser) { - const userData = await window.desktopApi.getUser() - setUser(userData) - setFullName(userData?.name || "") - } - setIsLoading(false) - } - fetchUser() - }, []) + setFullName(storedName) + }, [storedName]) const handleSave = async () => { setIsSaving(true) try { - if (window.desktopApi?.updateUser) { - const updatedUser = await window.desktopApi.updateUser({ name: fullName }) - if (updatedUser) { - setUser(updatedUser) - toast.success("Profile updated successfully") - } - } else { - throw new Error("Desktop API not available") - } + setStoredName(fullName.trim()) + toast.success("Profile saved") } catch (error) { console.error("Error updating profile:", error) toast.error( @@ -72,14 +49,6 @@ export function AgentsProfileTab() { } } - if (isLoading) { - return ( -
- -
- ) - } - return (
{/* Profile Settings Card */} @@ -109,23 +78,6 @@ export function AgentsProfileTab() { />
- - {/* Email Field (read-only) */} -
-
- -

- Your account email -

-
-
- -
-
{/* Save Button Footer */} diff --git a/src/renderer/components/dialogs/settings-tabs/index.ts b/src/renderer/components/dialogs/settings-tabs/index.ts index ec082a65..1fea5b5e 100644 --- a/src/renderer/components/dialogs/settings-tabs/index.ts +++ b/src/renderer/components/dialogs/settings-tabs/index.ts @@ -1,4 +1,5 @@ export { AgentsAppearanceTab } from "./agents-appearance-tab" +export { AgentsClaudeCodeTab } from "./agents-claude-code-tab" export { AgentsProfileTab } from "./agents-profile-tab" export { AgentsDebugTab } from "./agents-debug-tab" export { AgentsSkillsTab } from "./agents-skills-tab" diff --git a/src/renderer/components/ui/canvas-icons.tsx b/src/renderer/components/ui/canvas-icons.tsx index defc2657..07474497 100644 --- a/src/renderer/components/ui/canvas-icons.tsx +++ b/src/renderer/components/ui/canvas-icons.tsx @@ -4160,6 +4160,62 @@ export function PlanIconSmall({ className }: { className?: string }) { ) } +// Ask mode icon - message bubble with question mark (24x24) +export function AskIcon(props: IconProps) { + return ( + + + + + ) +} + +// Small 12x12 Ask mode icon for badges +export function AskIconSmall({ className }: { className?: string }) { + return ( + + + + + ) +} + // Small 12x12 version of the new Prototype icon (matches ExploreIcon in mode-toggle-button) export function PrototypeIconSmall({ className }: { className?: string }) { return ( diff --git a/src/renderer/components/ui/icons.tsx b/src/renderer/components/ui/icons.tsx index 6dbfa15a..84c394b2 100644 --- a/src/renderer/components/ui/icons.tsx +++ b/src/renderer/components/ui/icons.tsx @@ -4247,6 +4247,62 @@ export function PlanIconSmall({ className }: { className?: string }) { ) } +// Ask mode icon - message bubble with question mark (24x24) +export function AskIcon(props: IconProps) { + return ( + + + + + ) +} + +// Small 12x12 Ask mode icon for badges +export function AskIconSmall({ className }: { className?: string }) { + return ( + + + + + ) +} + // Small 12x12 version of the new Prototype icon (matches ExploreIcon in mode-toggle-button) export function PrototypeIconSmall({ className }: { className?: string }) { return ( diff --git a/src/renderer/components/ui/resizable-sidebar.tsx b/src/renderer/components/ui/resizable-sidebar.tsx index 0afacd69..e95efd41 100644 --- a/src/renderer/components/ui/resizable-sidebar.tsx +++ b/src/renderer/components/ui/resizable-sidebar.tsx @@ -2,7 +2,7 @@ import { useAtom, type WritableAtom } from "jotai" import { AnimatePresence, motion } from "motion/react" -import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" import { createPortal, flushSync } from "react-dom" import { Kbd } from "./kbd" @@ -34,7 +34,7 @@ const DEFAULT_MAX_WIDTH = 9999 // Effectively no limit - CSS constraints handle const DEFAULT_ANIMATION_DURATION = 0 // Disabled for performance const EXTENDED_HOVER_AREA_WIDTH = 8 -export function ResizableSidebar({ +export const ResizableSidebar = React.memo(function ResizableSidebar({ isOpen, onClose, widthAtom, @@ -552,4 +552,4 @@ export function ResizableSidebar({ ) -} +}) diff --git a/src/renderer/components/ui/sheet.tsx b/src/renderer/components/ui/sheet.tsx new file mode 100644 index 00000000..d312765d --- /dev/null +++ b/src/renderer/components/ui/sheet.tsx @@ -0,0 +1,145 @@ +"use client" + +import * as React from "react" +import * as SheetPrimitive from "@radix-ui/react-dialog" +import { cva, type VariantProps } from "class-variance-authority" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Sheet = SheetPrimitive.Root + +const SheetTrigger = SheetPrimitive.Trigger + +const SheetClose = SheetPrimitive.Close + +const SheetPortal = SheetPrimitive.Portal + +const SheetOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName + +const sheetVariants = cva( + "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500", + { + variants: { + side: { + top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", + bottom: + "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", + left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", + right: + "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", + }, + }, + defaultVariants: { + side: "right", + }, + } +) + +interface SheetContentProps + extends React.ComponentPropsWithoutRef, + VariantProps { + disableOverlayClickClose?: boolean // Custom prop + hideClose?: boolean // Custom prop +} + +const SheetContent = React.forwardRef< + React.ElementRef, + SheetContentProps +>(({ side = "right", className, children, disableOverlayClickClose, hideClose, ...props }, ref) => ( + + e.stopPropagation() : undefined} /> + + {children} + {!hideClose && ( + + + Close + + )} + + +)) +SheetContent.displayName = SheetPrimitive.Content.displayName + +const SheetHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +SheetHeader.displayName = "SheetHeader" + +const SheetFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +SheetFooter.displayName = "SheetFooter" + +const SheetTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetTitle.displayName = SheetPrimitive.Title.displayName + +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetDescription.displayName = SheetPrimitive.Description.displayName + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/src/renderer/features/activity/activity-feed.tsx b/src/renderer/features/activity/activity-feed.tsx new file mode 100644 index 00000000..78593f8b --- /dev/null +++ b/src/renderer/features/activity/activity-feed.tsx @@ -0,0 +1,494 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { useAtomValue, useSetAtom } from "jotai" +import { + activityFeedEnabledAtom, + toolActivityAtom, + clearToolActivityAtom, + setToolActivitiesAtom, + selectedActivityIdAtom, + toggleActivityPinAtom, + type ToolActivity, +} from "../../lib/atoms" +import { LoadingDot } from "../../icons" +import { Button } from "../../components/ui/button" +import { cn } from "../../lib/utils" +import { trpcClient } from "../../lib/trpc" +import { ActivityViewerModal } from "./activity-viewer-modal" +import { useAgentSubChatStore } from "../agents/stores/sub-chat-store" +import { detectCommand } from "../../lib/bash-command-utils" +import { CommandIcon } from "../../lib/command-icons" +import { + FileText, + FilePen, + Terminal, + Search, + Globe, + Bot, + ListTodo, + HelpCircle, + FileCode, + Wrench, + Pin, + FolderSearch, + ChevronRight, + ClipboardList, + LogOut, +} from "lucide-react" + +// Exploration tools that can be grouped +const EXPLORE_TOOLS = new Set(["Read", "Grep", "Glob"]) + +// Minimum count to form a group +const MIN_GROUP_SIZE = 3 + +// Type for grouped activities in the feed +type ActivityGroup = { + type: "group" + id: string // Use first activity's id + activities: ToolActivity[] + summary: string + chatName: string + createdAt: Date + hasError: boolean + hasRunning: boolean +} + +type FeedItem = ToolActivity | ActivityGroup + +function isActivityGroup(item: FeedItem): item is ActivityGroup { + return "type" in item && item.type === "group" +} + +// Tool icon components for display +function getToolIcon(toolName: string, activity?: ToolActivity) { + const iconClass = "w-3.5 h-3.5" + + // Special handling for Bash - detect command type + if (toolName === "Bash" && activity?.input) { + try { + const input = JSON.parse(activity.input) + const detected = detectCommand(input.command || "") + return + } catch { + // Fall through to default + } + } + + switch (toolName) { + case "Read": + return + case "Write": + return + case "Edit": + return + case "Bash": + return + case "Glob": + return + case "Grep": + return + case "WebFetch": + case "WebSearch": + return + case "Task": + return + case "TodoWrite": + return + case "AskUserQuestion": + return + case "NotebookEdit": + return + case "PlanWrite": + return + case "ExitPlanMode": + return + default: + return + } +} + +/** + * Format relative time (e.g., "2s ago", "1m ago") + */ +function formatRelativeTime(date: Date): string { + const seconds = Math.floor((Date.now() - date.getTime()) / 1000) + + if (seconds < 5) return "now" + if (seconds < 60) return `${seconds}s ago` + + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m ago` + + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + + return `${Math.floor(hours / 24)}d ago` +} + +/** + * Group consecutive exploration tools (Read, Grep, Glob) + */ +function groupActivities(sortedActivities: ToolActivity[]): FeedItem[] { + const result: FeedItem[] = [] + let currentGroup: ToolActivity[] = [] + + const flushGroup = () => { + if (currentGroup.length >= MIN_GROUP_SIZE) { + // Create a group + const hasError = currentGroup.some((a) => a.state === "error") + const hasRunning = currentGroup.some((a) => a.state === "running") + result.push({ + type: "group", + id: currentGroup[0].id, + activities: currentGroup, + summary: `Explored ${currentGroup.length} files`, + chatName: currentGroup[0].chatName, + createdAt: currentGroup[0].createdAt, + hasError, + hasRunning, + }) + } else { + // Not enough to group, add individually + for (const activity of currentGroup) { + result.push(activity) + } + } + currentGroup = [] + } + + for (const activity of sortedActivities) { + // Pinned items never get grouped + if (activity.isPinned) { + flushGroup() + result.push(activity) + continue + } + + if (EXPLORE_TOOLS.has(activity.toolName)) { + // Check if this belongs to the same session (within 5 minutes and same chat) + const lastInGroup = currentGroup[currentGroup.length - 1] + const timeDiff = lastInGroup + ? Math.abs(activity.createdAt.getTime() - lastInGroup.createdAt.getTime()) + : 0 + const sameChat = lastInGroup ? activity.chatName === lastInGroup.chatName : true + + if (currentGroup.length === 0 || (timeDiff < 5 * 60 * 1000 && sameChat)) { + currentGroup.push(activity) + } else { + // Different session, flush and start new group + flushGroup() + currentGroup.push(activity) + } + } else { + // Non-explore tool, flush any current group and add this one + flushGroup() + result.push(activity) + } + } + + // Flush remaining + flushGroup() + + return result +} + +/** + * Single activity item in the feed + */ +function ActivityItem({ + activity, + onClick, + onTogglePin, +}: { + activity: ToolActivity + onClick: () => void + onTogglePin: (e: React.MouseEvent) => void +}) { + return ( +
+
+
{getToolIcon(activity.toolName, activity)}
+ + {activity.toolName} + + + {activity.state === "running" && ( + + )} + {activity.state === "error" && ( + Error + )} + {activity.state === "complete" && ( + + )} +
+
+ {activity.summary} +
+
+ {activity.chatName} + + {formatRelativeTime(activity.createdAt)} +
+
+ ) +} + +/** + * Grouped activity item in the feed + */ +function GroupedActivityItem({ + group, + onClick, +}: { + group: ActivityGroup + onClick: () => void +}) { + return ( +
+
+
+ +
+ + Exploring + + + {group.activities.length} + + {group.hasRunning && ( + + )} + {group.hasError && !group.hasRunning && ( + Error + )} + {!group.hasError && !group.hasRunning && ( + + )} + +
+
+ {group.summary} +
+
+ {group.chatName} + + {formatRelativeTime(group.createdAt)} +
+
+ ) +} + +/** + * Activity Feed Panel + * Shows real-time tool execution history + */ +export function ActivityFeed({ className }: { className?: string }) { + const allActivities = useAtomValue(toolActivityAtom) + const enabled = useAtomValue(activityFeedEnabledAtom) + const clearActivities = useSetAtom(clearToolActivityAtom) + const setActivities = useSetAtom(setToolActivitiesAtom) + const setSelectedActivityId = useSetAtom(selectedActivityIdAtom) + const selectedActivityId = useAtomValue(selectedActivityIdAtom) + const togglePin = useSetAtom(toggleActivityPinAtom) + + // State for grouped selection (when clicking a group) + const [selectedGroupActivities, setSelectedGroupActivities] = useState() + + // Get current chat's sub-chat IDs to filter activities + const allSubChats = useAgentSubChatStore((s) => s.allSubChats) + const currentSubChatIds = useMemo( + () => new Set(allSubChats.map((sc) => sc.id)), + [allSubChats], + ) + + // Filter activities to only show those from the current chat's sub-chats + const activities = useMemo( + () => allActivities.filter((a) => currentSubChatIds.has(a.subChatId)), + [allActivities, currentSubChatIds], + ) + + // Load activities from database on mount + useEffect(() => { + const loadActivities = async () => { + try { + const dbActivities = await trpcClient.activities.getRecent.query({ + limit: 100, + }) + // Map DB activities to ToolActivity interface + const mapped: ToolActivity[] = dbActivities.map((a) => ({ + id: a.id, + subChatId: a.subChatId, + chatName: a.chatName, + toolName: a.toolName, + summary: a.summary, + state: a.state as ToolActivity["state"], + input: a.input, + output: a.output, + errorText: a.errorText, + isPinned: a.isPinned, + createdAt: a.createdAt ?? new Date(), + })) + setActivities(mapped) + } catch (err) { + console.error("[ACTIVITY_FEED] Failed to load activities:", err) + } + } + loadActivities() + }, [setActivities]) + + // Handle toggle pin + const handleTogglePin = async (activity: ToolActivity, e: React.MouseEvent) => { + e.stopPropagation() // Prevent opening modal + const newPinnedState = !activity.isPinned + + // Optimistic update + togglePin({ id: activity.id, isPinned: newPinnedState }) + + // Persist to DB + try { + await trpcClient.activities.togglePin.mutate({ + id: activity.id, + isPinned: newPinnedState, + }) + } catch (err) { + console.error("[ACTIVITY_FEED] Failed to toggle pin:", err) + // Revert optimistic update on error + togglePin({ id: activity.id, isPinned: activity.isPinned }) + } + } + + // Handle clear - also clears from DB + const handleClear = async () => { + try { + await trpcClient.activities.clear.mutate() + clearActivities() + } catch (err) { + console.error("[ACTIVITY_FEED] Failed to clear activities:", err) + } + } + + // Get selected activity for modal + const selectedActivity = selectedActivityId + ? activities.find((a) => a.id === selectedActivityId) + : null + + if (!enabled) return null + + // Sort: pinned items first, then by createdAt desc + const sortedActivities = [...activities].sort((a, b) => { + if (a.isPinned && !b.isPinned) return -1 + if (!a.isPinned && b.isPinned) return 1 + return b.createdAt.getTime() - a.createdAt.getTime() + }) + + // Group consecutive exploration tools + const feedItems = groupActivities(sortedActivities) + + // Count running activities + const runningCount = activities.filter((a) => a.state === "running").length + + return ( + <> +
+ {/* Header */} +
+
+

Activity

+ {runningCount > 0 && ( + + {runningCount} + + )} +
+ {activities.length > 0 && ( + + )} +
+ + {/* Activity list */} +
+ {feedItems.length > 0 ? ( + feedItems.map((item) => { + if (isActivityGroup(item)) { + return ( + { + setSelectedActivityId(item.activities[0].id) + setSelectedGroupActivities(item.activities) + }} + /> + ) + } + return ( + { + setSelectedActivityId(item.id) + setSelectedGroupActivities(undefined) + }} + onTogglePin={(e) => handleTogglePin(item, e)} + /> + ) + }) + ) : ( +
+ No recent activity +
+ )} +
+
+ + {/* Viewer Modal */} + { + if (!open) { + setSelectedActivityId(null) + setSelectedGroupActivities(undefined) + } + }} + /> + + ) +} diff --git a/src/renderer/features/activity/activity-viewer-modal.tsx b/src/renderer/features/activity/activity-viewer-modal.tsx new file mode 100644 index 00000000..6ba073b5 --- /dev/null +++ b/src/renderer/features/activity/activity-viewer-modal.tsx @@ -0,0 +1,205 @@ +"use client" + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "../../components/ui/dialog" +import { type ToolActivity } from "../../lib/atoms" +import { cn } from "../../lib/utils" +import { + FileText, + FilePen, + Terminal, + Search, + Globe, + Bot, + ListTodo, + HelpCircle, + FileCode, + Wrench, + FolderSearch, + ClipboardList, + LogOut, +} from "lucide-react" +import { + BashModalContent, + EditModalContent, + WebFetchModalContent, + WebSearchModalContent, + ExploreModalContent, + DefaultModalContent, + PlanModalContent, + ExitPlanModalContent, +} from "./tool-modal-content" + +// Tool icon components for display +function getToolIcon(toolName: string) { + const iconClass = "w-5 h-5" + switch (toolName) { + case "Read": + return + case "Write": + return + case "Edit": + return + case "Bash": + return + case "Glob": + return + case "Grep": + return + case "WebFetch": + case "WebSearch": + return + case "Task": + return + case "TodoWrite": + return + case "AskUserQuestion": + return + case "NotebookEdit": + return + case "PlanWrite": + return + case "ExitPlanMode": + return + default: + return + } +} + +/** + * Get modal size class based on tool type + * - Large for diff views (Edit, Write) + * - Medium for terminal/web content (Bash, WebFetch) + * - Smaller for simple tools + */ +function getModalSizeClass(toolName: string): string { + switch (toolName) { + case "Edit": + case "Write": + return "max-w-4xl" + case "Bash": + case "WebFetch": + case "WebSearch": + return "max-w-2xl" + case "Read": + case "Grep": + case "Glob": + return "max-w-2xl" + case "PlanWrite": + case "ExitPlanMode": + return "max-w-2xl" + default: + return "max-w-lg" + } +} + +/** + * Format timestamp for display + */ +function formatTimestamp(date: Date): string { + return date.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) +} + +/** + * Render the appropriate content component based on tool type + */ +function getModalContent(activity: ToolActivity, activities?: ToolActivity[]) { + switch (activity.toolName) { + case "Bash": + return + case "Edit": + case "Write": + return + case "WebFetch": + return + case "WebSearch": + return + case "Read": + case "Grep": + case "Glob": + return + case "PlanWrite": + return + case "ExitPlanMode": + return + default: + return + } +} + +interface ActivityViewerModalProps { + activity: ToolActivity | null + // For grouped activities + activities?: ToolActivity[] + open: boolean + onOpenChange: (open: boolean) => void +} + +export function ActivityViewerModal({ + activity, + activities, + open, + onOpenChange, +}: ActivityViewerModalProps) { + if (!activity) return null + + // Determine if this is a grouped view + const isGrouped = activities && activities.length > 1 + const modalSizeClass = isGrouped ? "max-w-2xl" : getModalSizeClass(activity.toolName) + + // For grouped view, use a combined title + const title = isGrouped + ? `Explored ${activities.length} files` + : activity.toolName + + return ( + + + +
+
+ {isGrouped ? : getToolIcon(activity.toolName)} +
+
+ + {title} + {!isGrouped && ( + + {activity.state} + + )} + +
+ {activity.chatName} • {formatTimestamp(activity.createdAt)} +
+
+
+
+ +
+ {getModalContent(activity, activities)} +
+
+
+ ) +} diff --git a/src/renderer/features/activity/index.ts b/src/renderer/features/activity/index.ts new file mode 100644 index 00000000..7141a766 --- /dev/null +++ b/src/renderer/features/activity/index.ts @@ -0,0 +1,2 @@ +export { ActivityFeed } from "./activity-feed" +export { ActivityViewerModal } from "./activity-viewer-modal" diff --git a/src/renderer/features/activity/tool-modal-content/bash-modal-content.tsx b/src/renderer/features/activity/tool-modal-content/bash-modal-content.tsx new file mode 100644 index 00000000..0dfa5bc9 --- /dev/null +++ b/src/renderer/features/activity/tool-modal-content/bash-modal-content.tsx @@ -0,0 +1,172 @@ +"use client" + +import { useState, useMemo } from "react" +import { Check, X, Copy } from "lucide-react" +import { Button } from "../../../components/ui/button" +import { type ToolActivity } from "../../../lib/atoms" +import { cn } from "../../../lib/utils" +import { detectCommand } from "../../../lib/bash-command-utils" +import { CommandIcon } from "../../../lib/command-icons" + +interface BashModalContentProps { + activity: ToolActivity +} + +function parseActivityData(activity: ToolActivity) { + let input: { command?: string; description?: string } = {} + let output: { stdout?: string; stderr?: string; exitCode?: number; exit_code?: number; output?: string } = {} + + try { + if (activity.input) { + input = JSON.parse(activity.input) + } + } catch { + // Keep empty + } + + try { + if (activity.output) { + output = JSON.parse(activity.output) + } + } catch { + // Keep empty + } + + return { input, output } +} + +export function BashModalContent({ activity }: BashModalContentProps) { + const [copied, setCopied] = useState(false) + + const { input, output } = useMemo(() => parseActivityData(activity), [activity]) + + const command = input.command || "" + const description = input.description || "" + const stdout = output.stdout || output.output || "" + const stderr = output.stderr || "" + const exitCode = output.exitCode ?? output.exit_code + + const isSuccess = exitCode === 0 + const isError = exitCode !== undefined && exitCode !== 0 + const isRunning = activity.state === "running" + + // Detect command type for header display + const detected = useMemo(() => detectCommand(command), [command]) + + const handleCopyCommand = async () => { + await navigator.clipboard.writeText(command) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( +
+ {/* Description if provided */} + {description && ( +
+ {description} +
+ )} + + {/* Status badge */} +
+ {isRunning ? ( + + Running + + ) : isSuccess ? ( + + + Success + + ) : isError ? ( + + + Failed (exit code {exitCode}) + + ) : null} +
+ + {/* Terminal-style command display */} +
+ {/* Terminal header */} +
+
+
+
+
+
+
+ {/* Show detected command type */} + {detected.type !== "unknown" && ( +
+ + + {detected.type} + +
+ )} +
+ +
+ + {/* Command and output */} +
+ {/* Command */} +
+ $ + + {command} + +
+ + {/* Stdout */} + {stdout && ( +
+ {stdout} +
+ )} + + {/* Stderr */} + {stderr && ( +
+ {stderr} +
+ )} + + {/* Running indicator */} + {isRunning && !stdout && !stderr && ( +
+ Command is still running... +
+ )} +
+
+ + {/* Error text if present */} + {activity.errorText && ( +
+ {activity.errorText} +
+ )} +
+ ) +} diff --git a/src/renderer/features/activity/tool-modal-content/default-modal-content.tsx b/src/renderer/features/activity/tool-modal-content/default-modal-content.tsx new file mode 100644 index 00000000..3b7cc635 --- /dev/null +++ b/src/renderer/features/activity/tool-modal-content/default-modal-content.tsx @@ -0,0 +1,141 @@ +"use client" + +import { useState } from "react" +import { Copy, Check, ChevronDown, ChevronRight } from "lucide-react" +import { Button } from "../../../components/ui/button" +import { type ToolActivity } from "../../../lib/atoms" +import { cn } from "../../../lib/utils" + +interface DefaultModalContentProps { + activity: ToolActivity +} + +interface CollapsibleJsonProps { + title: string + json: string | null + defaultExpanded?: boolean + maxHeight?: string +} + +function CollapsibleJson({ title, json, defaultExpanded = true, maxHeight = "250px" }: CollapsibleJsonProps) { + const [isExpanded, setIsExpanded] = useState(defaultExpanded) + const [copied, setCopied] = useState(false) + + if (!json) { + return null + } + + // Try to parse and pretty-print + let formatted = json + try { + const parsed = JSON.parse(json) + formatted = JSON.stringify(parsed, null, 2) + } catch { + // Keep original if not valid JSON + } + + const isTruncated = formatted.includes("... (truncated)") + + const handleCopy = async () => { + await navigator.clipboard.writeText(formatted) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( +
+ {/* Header */} +
setIsExpanded(!isExpanded)} + className="flex items-center justify-between px-3 py-2 bg-muted/30 cursor-pointer hover:bg-muted/50 transition-colors" + > +
+ {isExpanded ? ( + + ) : ( + + )} + {title} +
+ {isExpanded && ( + + )} +
+ + {/* Content */} + {isExpanded && ( +
+
+            {formatted}
+          
+ {isTruncated && ( +
+ Output was truncated (exceeds 50KB limit) +
+ )} +
+ )} +
+ ) +} + +export function DefaultModalContent({ activity }: DefaultModalContentProps) { + return ( +
+ {/* Summary */} +
+

Summary

+
+ {activity.summary} +
+
+ + {/* Input */} + + + {/* Output or Error */} + {activity.state === "error" && activity.errorText ? ( +
+

Error

+
+ {activity.errorText} +
+
+ ) : activity.output ? ( + + ) : activity.state === "running" ? ( +
+

Output

+
+ Tool is still running... +
+
+ ) : null} +
+ ) +} diff --git a/src/renderer/features/activity/tool-modal-content/edit-modal-content.tsx b/src/renderer/features/activity/tool-modal-content/edit-modal-content.tsx new file mode 100644 index 00000000..505b39e1 --- /dev/null +++ b/src/renderer/features/activity/tool-modal-content/edit-modal-content.tsx @@ -0,0 +1,283 @@ +"use client" + +import { useMemo, useState, useEffect } from "react" +import { type ToolActivity } from "../../../lib/atoms" +import { cn } from "../../../lib/utils" +import { useCodeTheme } from "../../../lib/hooks/use-code-theme" +import { highlightCode } from "../../../lib/themes/shiki-theme-loader" +import { getFileIconByExtension } from "../../agents/mentions/agents-file-mention" + +interface EditModalContentProps { + activity: ToolActivity +} + +type DiffLine = { type: "added" | "removed" | "context"; content: string } + +function getLanguageFromFilename(filename: string): string { + const ext = filename.split(".").pop()?.toLowerCase() || "" + const langMap: Record = { + ts: "typescript", + tsx: "tsx", + js: "javascript", + jsx: "jsx", + py: "python", + go: "go", + rs: "rust", + html: "html", + css: "css", + json: "json", + md: "markdown", + sh: "bash", + bash: "bash", + } + return langMap[ext] || "plaintext" +} + +function parseActivityData(activity: ToolActivity) { + let input: { file_path?: string; old_string?: string; new_string?: string; content?: string } = {} + let output: { structuredPatch?: Array<{ lines: string[] }>; content?: string } = {} + + try { + if (activity.input) { + input = JSON.parse(activity.input) + } + } catch { + // Keep empty + } + + try { + if (activity.output) { + output = JSON.parse(activity.output) + } + } catch { + // Keep empty + } + + return { input, output } +} + +function getDiffLines(patches: Array<{ lines: string[] }> | undefined): DiffLine[] { + const result: DiffLine[] = [] + if (!patches) return result + + for (const patch of patches) { + if (!patch.lines) continue + for (const line of patch.lines) { + if (line.startsWith("+")) { + result.push({ type: "added", content: line.slice(1) }) + } else if (line.startsWith("-")) { + result.push({ type: "removed", content: line.slice(1) }) + } else if (line.startsWith(" ")) { + result.push({ type: "context", content: line.slice(1) }) + } + } + } + + return result +} + +function calculateDiffStats(patches: Array<{ lines?: string[] }> | undefined): { added: number; removed: number } | null { + if (!patches || patches.length === 0) return null + + let added = 0 + let removed = 0 + + for (const patch of patches) { + if (!patch.lines) continue + for (const line of patch.lines) { + if (line.startsWith("+")) added++ + else if (line.startsWith("-")) removed++ + } + } + + return { added, removed } +} + +function useBatchHighlight( + lines: DiffLine[], + language: string, + themeId: string, +): Map { + const [highlightedMap, setHighlightedMap] = useState>(() => new Map()) + + const linesKey = useMemo(() => lines.map((l) => l.content).join("\n"), [lines]) + + useEffect(() => { + if (lines.length === 0) { + setHighlightedMap(new Map()) + return + } + + let cancelled = false + + const highlightAll = async () => { + try { + const results = new Map() + for (let i = 0; i < lines.length; i++) { + const content = lines[i].content || " " + const highlighted = await highlightCode(content, language, themeId) + results.set(i, highlighted) + } + if (!cancelled) { + setHighlightedMap(results) + } + } catch (error) { + console.error("[EDIT_MODAL] Failed to highlight code:", error) + if (!cancelled) { + setHighlightedMap(new Map()) + } + } + } + + const timer = setTimeout(highlightAll, 50) + return () => { + cancelled = true + clearTimeout(timer) + } + }, [linesKey, language, themeId, lines.length]) + + return highlightedMap +} + +export function EditModalContent({ activity }: EditModalContentProps) { + const codeTheme = useCodeTheme() + const { input, output } = useMemo(() => parseActivityData(activity), [activity]) + const isWriteMode = activity.toolName === "Write" + + const filePath = input.file_path || "" + const filename = filePath ? filePath.split("/").pop() || "file" : "" + const language = filename ? getLanguageFromFilename(filename) : "plaintext" + + // Get clean display path + const displayPath = useMemo(() => { + if (!filePath) return "" + const prefixes = ["/project/sandbox/repo/", "/project/sandbox/", "/project/"] + for (const prefix of prefixes) { + if (filePath.startsWith(prefix)) { + return filePath.slice(prefix.length) + } + } + if (filePath.startsWith("/")) { + const parts = filePath.split("/") + const rootIndicators = ["apps", "packages", "src", "lib", "components"] + const rootIndex = parts.findIndex((p: string) => rootIndicators.includes(p)) + if (rootIndex > 0) { + return parts.slice(rootIndex).join("/") + } + } + return filePath + }, [filePath]) + + // Build diff lines + const diffLines = useMemo(() => { + if (isWriteMode) { + const content = input.content || output.content || "" + if (!content) return [] + return content.split("\n").map((line: string) => ({ + type: "added" as const, + content: line, + })) + } + if (output.structuredPatch) { + return getDiffLines(output.structuredPatch) + } + // Fallback to new_string preview + if (input.new_string) { + return input.new_string.split("\n").map((line: string) => ({ + type: "added" as const, + content: line, + })) + } + return [] + }, [isWriteMode, input, output]) + + // Calculate stats + const diffStats = useMemo(() => { + if (isWriteMode) { + const content = input.content || output.content || "" + const added = content ? content.split("\n").length : 0 + return { added, removed: 0 } + } + return calculateDiffStats(output.structuredPatch) + }, [isWriteMode, input, output]) + + // Highlight all lines + const highlightedMap = useBatchHighlight(diffLines, language, codeTheme) + + const FileIcon = filename ? getFileIconByExtension(filename, true) : null + + return ( +
+ {/* File header */} +
+
+ {FileIcon && } +
+
{filename}
+
{displayPath}
+
+
+ + {/* Diff stats */} + {diffStats && ( +
+ +{diffStats.added} + {diffStats.removed > 0 && ( + -{diffStats.removed} + )} +
+ )} +
+ + {/* Diff content */} + {diffLines.length > 0 ? ( +
+
+ {diffLines.map((line, idx) => ( +
+ {highlightedMap.get(idx) ? ( + + ) : ( + + {line.content || " "} + + )} +
+ ))} +
+
+ ) : ( +
+ No diff data available +
+ )} + + {/* Error text if present */} + {activity.errorText && ( +
+ {activity.errorText} +
+ )} +
+ ) +} diff --git a/src/renderer/features/activity/tool-modal-content/explore-modal-content.tsx b/src/renderer/features/activity/tool-modal-content/explore-modal-content.tsx new file mode 100644 index 00000000..cddaae1a --- /dev/null +++ b/src/renderer/features/activity/tool-modal-content/explore-modal-content.tsx @@ -0,0 +1,263 @@ +"use client" + +import { useMemo } from "react" +import { FileText, Search, FolderSearch, Check, X, ChevronRight } from "lucide-react" +import { type ToolActivity } from "../../../lib/atoms" +import { cn } from "../../../lib/utils" +import { getFileIconByExtension } from "../../agents/mentions/agents-file-mention" + +interface ExploreModalContentProps { + activity: ToolActivity + // For grouped activities + activities?: ToolActivity[] +} + +function parseActivityData(activity: ToolActivity) { + let input: { file_path?: string; pattern?: string; path?: string; glob?: string } = {} + let output: { content?: string; files?: string[]; matches?: any[]; truncated?: boolean } = {} + + try { + if (activity.input) { + input = JSON.parse(activity.input) + } + } catch { + // Keep empty + } + + try { + if (activity.output) { + output = JSON.parse(activity.output) + } + } catch { + // Keep empty + } + + return { input, output } +} + +function getToolIcon(toolName: string) { + switch (toolName) { + case "Read": + return FileText + case "Grep": + return Search + case "Glob": + return FolderSearch + default: + return FileText + } +} + +function getDisplayPath(filePath: string): string { + if (!filePath) return "" + const prefixes = ["/project/sandbox/repo/", "/project/sandbox/", "/project/"] + for (const prefix of prefixes) { + if (filePath.startsWith(prefix)) { + return filePath.slice(prefix.length) + } + } + if (filePath.startsWith("/")) { + const parts = filePath.split("/") + const rootIndicators = ["apps", "packages", "src", "lib", "components"] + const rootIndex = parts.findIndex((p: string) => rootIndicators.includes(p)) + if (rootIndex > 0) { + return parts.slice(rootIndex).join("/") + } + } + return filePath +} + +// Single activity view (Read, Grep, or Glob) +function SingleExploreContent({ activity }: { activity: ToolActivity }) { + const { input, output } = useMemo(() => parseActivityData(activity), [activity]) + const isRunning = activity.state === "running" + const isError = activity.state === "error" + + const Icon = getToolIcon(activity.toolName) + const filePath = input.file_path || input.path || "" + const pattern = input.pattern || input.glob || "" + const displayPath = getDisplayPath(filePath) + const filename = filePath ? filePath.split("/").pop() || "" : "" + + // Get file icon for Read + const FileIcon = activity.toolName === "Read" && filename + ? getFileIconByExtension(filename, true) + : null + + return ( +
+ {/* Header */} +
+
+ +
+
+
+ {FileIcon && } +
+ {activity.toolName === "Read" && filename} + {activity.toolName === "Grep" && `Pattern: ${pattern}`} + {activity.toolName === "Glob" && `Pattern: ${pattern}`} +
+
+ {displayPath && ( +
+ {displayPath} +
+ )} +
+
+ {isRunning ? ( + + Running + + ) : isError ? ( + + + Error + + ) : ( + + + Done + + )} +
+
+ + {/* Content based on tool type */} + {activity.toolName === "Read" && output.content && ( +
+

File content

+
+
+              {output.content}
+            
+
+ {output.truncated && ( +
+ Content was truncated +
+ )} +
+ )} + + {activity.toolName === "Glob" && output.files && output.files.length > 0 && ( +
+

+ Found {output.files.length} {output.files.length === 1 ? "file" : "files"} +

+
+
+ {output.files.map((file: string, idx: number) => { + const name = file.split("/").pop() || file + const FileIcon = getFileIconByExtension(name, true) + return ( +
+ {FileIcon && } + {getDisplayPath(file)} +
+ ) + })} +
+
+
+ )} + + {activity.toolName === "Grep" && output.matches && output.matches.length > 0 && ( +
+

+ Found {output.matches.length} {output.matches.length === 1 ? "match" : "matches"} +

+
+
+ {output.matches.map((match: any, idx: number) => ( +
+
+ {getDisplayPath(match.file || match.path || "")} + {match.line && `:${match.line}`} +
+ {match.content && ( +
+ {match.content} +
+ )} +
+ ))} +
+
+
+ )} + + {/* Error text if present */} + {activity.errorText && ( +
+ {activity.errorText} +
+ )} +
+ ) +} + +// Grouped activities view +function GroupedExploreContent({ activities }: { activities: ToolActivity[] }) { + return ( +
+
+ {activities.length} exploration operations +
+ +
+
+ {activities.map((activity) => { + const { input } = parseActivityData(activity) + const Icon = getToolIcon(activity.toolName) + const filePath = input.file_path || input.path || "" + const pattern = input.pattern || input.glob || "" + const displayPath = getDisplayPath(filePath) + const isError = activity.state === "error" + + return ( +
+ +
+
+ {activity.toolName === "Read" && displayPath} + {activity.toolName === "Grep" && pattern} + {activity.toolName === "Glob" && pattern} +
+ {activity.toolName !== "Read" && displayPath && ( +
+ in {displayPath} +
+ )} +
+ {isError ? ( + + ) : ( + + )} +
+ ) + })} +
+
+
+ ) +} + +export function ExploreModalContent({ activity, activities }: ExploreModalContentProps) { + // If we have multiple activities (grouped), show grouped view + if (activities && activities.length > 1) { + return + } + + // Single activity view + return +} diff --git a/src/renderer/features/activity/tool-modal-content/index.ts b/src/renderer/features/activity/tool-modal-content/index.ts new file mode 100644 index 00000000..cde33ae6 --- /dev/null +++ b/src/renderer/features/activity/tool-modal-content/index.ts @@ -0,0 +1,6 @@ +export { BashModalContent } from "./bash-modal-content" +export { EditModalContent } from "./edit-modal-content" +export { WebFetchModalContent, WebSearchModalContent } from "./web-modal-content" +export { ExploreModalContent } from "./explore-modal-content" +export { DefaultModalContent } from "./default-modal-content" +export { PlanModalContent, ExitPlanModalContent } from "./plan-modal-content" diff --git a/src/renderer/features/activity/tool-modal-content/plan-modal-content.tsx b/src/renderer/features/activity/tool-modal-content/plan-modal-content.tsx new file mode 100644 index 00000000..48d8b82e --- /dev/null +++ b/src/renderer/features/activity/tool-modal-content/plan-modal-content.tsx @@ -0,0 +1,301 @@ +"use client" + +import { useMemo } from "react" +import { Circle, SkipForward, FileCode2, Check, ClipboardList } from "lucide-react" +import { type ToolActivity } from "../../../lib/atoms" +import { cn } from "../../../lib/utils" +import { ChatMarkdownRenderer } from "../../../components/chat-markdown-renderer" + +interface PlanStep { + id: string + title: string + description?: string + files?: readonly string[] | string[] + estimatedComplexity?: "low" | "medium" | "high" + status: "pending" | "in_progress" | "completed" | "skipped" +} + +interface Plan { + id: string + title: string + summary?: string + steps: readonly PlanStep[] | PlanStep[] + status: "draft" | "awaiting_approval" | "approved" | "in_progress" | "completed" +} + +interface PlanModalContentProps { + activity: ToolActivity +} + +function parseActivityData(activity: ToolActivity) { + let input: { action?: string; plan?: Plan } = {} + let output: { success?: boolean; message?: string } = {} + + try { + if (activity.input) { + input = JSON.parse(activity.input) + } + } catch { + // Keep empty + } + + try { + if (activity.output) { + output = JSON.parse(activity.output) + } + } catch { + // Keep empty + } + + return { input, output } +} + +function StepStatusIcon({ status }: { status: PlanStep["status"] }) { + switch (status) { + case "completed": + return ( +
+ +
+ ) + case "in_progress": + return ( +
+ +
+ ) + case "skipped": + return ( +
+ +
+ ) + default: + return ( +
+ ) + } +} + +function ComplexityBadge({ complexity }: { complexity?: "low" | "medium" | "high" }) { + if (!complexity) return null + + const colors = { + low: "bg-green-500/10 text-green-600 dark:text-green-400", + medium: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400", + high: "bg-red-500/10 text-red-600 dark:text-red-400", + } + + return ( + + {complexity} + + ) +} + +function PlanStatusBadge({ status }: { status: Plan["status"] }) { + const statusConfig = { + draft: { label: "Draft", className: "bg-muted text-muted-foreground" }, + awaiting_approval: { label: "Awaiting Approval", className: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400" }, + approved: { label: "Approved", className: "bg-blue-500/10 text-blue-600 dark:text-blue-400" }, + in_progress: { label: "In Progress", className: "bg-blue-500/10 text-blue-600 dark:text-blue-400" }, + completed: { label: "Completed", className: "bg-green-500/10 text-green-600 dark:text-green-400" }, + } + + const config = statusConfig[status] || statusConfig.draft + + return ( + + {config.label} + + ) +} + +export function PlanModalContent({ activity }: PlanModalContentProps) { + const { input, output } = useMemo(() => parseActivityData(activity), [activity]) + + const plan = input.plan + const action = input.action || "create" + + if (!plan) { + return ( +
+ No plan data available +
+ ) + } + + const steps = plan.steps || [] + const completedCount = steps.filter(s => s.status === "completed").length + const totalSteps = steps.length + const progressPercent = totalSteps > 0 ? (completedCount / totalSteps) * 100 : 0 + + return ( +
+ {/* Header */} +
+
+ +
+
+
+

{plan.title}

+ +
+ {plan.summary && ( +

+ {plan.summary} +

+ )} +
+
+ + {/* Progress bar */} + {totalSteps > 0 && ( +
+
+ + {completedCount} of {totalSteps} steps completed + + + {Math.round(progressPercent)}% + +
+
+
+
+
+ )} + + {/* Steps list */} + {steps.length > 0 && ( +
+
+ {steps.map((step, idx) => ( +
+
+ +
+
+ + {step.title} + + +
+ + {step.description && ( +

+ {step.description} +

+ )} + + {/* Files */} + {step.files && step.files.length > 0 && ( +
+ {step.files.map((file, fileIdx) => ( + + + {typeof file === 'string' ? file.split("/").pop() : file} + + ))} +
+ )} +
+
+
+ ))} +
+
+ )} + + {/* Status footer */} + {plan.status === "awaiting_approval" && ( +
+ This plan is awaiting your approval to proceed. +
+ )} + + {/* Error if present */} + {activity.errorText && ( +
+ {activity.errorText} +
+ )} +
+ ) +} + +/** + * Modal content for ExitPlanMode - shows the final plan markdown + */ +export function ExitPlanModalContent({ activity }: PlanModalContentProps) { + const { output } = useMemo(() => { + let output: { plan?: string } = {} + try { + if (activity.output) { + output = JSON.parse(activity.output) + } + } catch { + // Keep empty + } + return { output } + }, [activity]) + + const planText = typeof output.plan === "string" ? output.plan : "" + + if (!planText) { + return ( +
+ No plan content available +
+ ) + } + + return ( +
+ {/* Header */} +
+ + Plan ready for approval +
+ + {/* Plan markdown content */} +
+
+ +
+
+ + {/* Error if present */} + {activity.errorText && ( +
+ {activity.errorText} +
+ )} +
+ ) +} diff --git a/src/renderer/features/activity/tool-modal-content/web-modal-content.tsx b/src/renderer/features/activity/tool-modal-content/web-modal-content.tsx new file mode 100644 index 00000000..89a91726 --- /dev/null +++ b/src/renderer/features/activity/tool-modal-content/web-modal-content.tsx @@ -0,0 +1,228 @@ +"use client" + +import { useMemo } from "react" +import { Globe, ExternalLink, Search } from "lucide-react" +import { type ToolActivity } from "../../../lib/atoms" +import { cn } from "../../../lib/utils" + +interface WebModalContentProps { + activity: ToolActivity +} + +interface SearchResult { + title: string + url: string +} + +function parseActivityData(activity: ToolActivity) { + let input: { url?: string; query?: string; prompt?: string } = {} + let output: { result?: string; bytes?: number; code?: number; results?: any[] } = {} + + try { + if (activity.input) { + input = JSON.parse(activity.input) + } + } catch { + // Keep empty + } + + try { + if (activity.output) { + output = JSON.parse(activity.output) + } + } catch { + // Keep empty + } + + return { input, output } +} + +function formatBytes(bytes: number) { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +function parseSearchResults(rawResults: any[]): SearchResult[] { + const allResults: SearchResult[] = [] + + for (const result of rawResults) { + if (result.content && Array.isArray(result.content)) { + for (const item of result.content) { + if (item.title && item.url) { + allResults.push({ title: item.title, url: item.url }) + } + } + } else if (result.title && result.url) { + allResults.push({ title: result.title, url: result.url }) + } + } + + return allResults +} + +export function WebFetchModalContent({ activity }: WebModalContentProps) { + const { input, output } = useMemo(() => parseActivityData(activity), [activity]) + + const url = input.url || "" + const prompt = input.prompt || "" + const result = output.result || "" + const bytes = output.bytes || 0 + const statusCode = output.code + const isSuccess = statusCode === 200 + const isRunning = activity.state === "running" + + // Extract hostname for display + let hostname = "" + try { + hostname = new URL(url).hostname.replace("www.", "") + } catch { + hostname = url.slice(0, 50) + } + + return ( +
+ {/* URL header */} +
+
+ +
+
+
{hostname}
+ + {url} + +
+
+ {isRunning ? ( + + Fetching... + + ) : isSuccess ? ( + {formatBytes(bytes)} + ) : ( + + {statusCode ? `Error ${statusCode}` : "Failed"} + + )} +
+
+ + {/* Prompt if provided */} + {prompt && ( +
+

Extraction prompt

+
+ {prompt} +
+
+ )} + + {/* Content */} + {result ? ( +
+

Content

+
+
+              {result}
+            
+
+
+ ) : isRunning ? ( +
+ Fetching content... +
+ ) : null} + + {/* Error text if present */} + {activity.errorText && ( +
+ {activity.errorText} +
+ )} +
+ ) +} + +export function WebSearchModalContent({ activity }: WebModalContentProps) { + const { input, output } = useMemo(() => parseActivityData(activity), [activity]) + + const query = input.query || "" + const isRunning = activity.state === "running" + + const results = useMemo(() => { + if (!output.results) return [] + return parseSearchResults(output.results) + }, [output.results]) + + return ( +
+ {/* Query header */} +
+
+ +
+
+
Search query
+
{query}
+
+
+ {isRunning ? ( + + Searching... + + ) : ( + `${results.length} results` + )} +
+
+ + {/* Results list */} + {results.length > 0 ? ( +
+
+ {results.map((result, idx) => ( + + +
+
+ {result.title} +
+
+ {result.url} +
+
+
+ ))} +
+
+ ) : isRunning ? ( +
+ Searching... +
+ ) : ( +
+ No results found +
+ )} + + {/* Error text if present */} + {activity.errorText && ( +
+ {activity.errorText} +
+ )} +
+ ) +} diff --git a/src/renderer/features/agents/atoms/index.ts b/src/renderer/features/agents/atoms/index.ts index 18464f25..f62f35ef 100644 --- a/src/renderer/features/agents/atoms/index.ts +++ b/src/renderer/features/agents/atoms/index.ts @@ -178,6 +178,35 @@ export const lastSelectedModelIdAtom = atomWithStorage( { getOnInit: true }, ) +// Global default model for new chats (configured in Settings > Preferences) +export const defaultModelIdAtom = atomWithStorage( + "preferences:default-model-id", + "sonnet", + undefined, + { getOnInit: true }, +) + +// Chat mode type +export type ChatMode = "agent" | "plan" | "ask" + +// Migrate from old boolean atom if exists +if (typeof window !== "undefined") { + const oldValue = localStorage.getItem("agents:isPlanMode") + if (oldValue !== null) { + const mode: ChatMode = oldValue === "true" ? "plan" : "agent" + localStorage.setItem("agents:chatMode", JSON.stringify(mode)) + localStorage.removeItem("agents:isPlanMode") + } +} + +export const chatModeAtom = atomWithStorage( + "agents:chatMode", + "agent", + undefined, + { getOnInit: true }, +) + +// @deprecated Use chatModeAtom instead - kept for backward compatibility during migration export const isPlanModeAtom = atomWithStorage( "agents:isPlanMode", false, @@ -322,10 +351,10 @@ export const archiveSearchQueryAtom = atom("") // Repository filter for archive (null = all repositories) export const archiveRepositoryFilterAtom = atom(null) -// Track last used mode (plan/agent) per chat -// Map -export const lastChatModesAtom = atom>( - new Map(), +// Track last used mode per chat +// Map +export const lastChatModesAtom = atom>( + new Map(), ) // Mobile view mode - chat (default, shows NewChatForm), chats list, preview, diff, or terminal @@ -468,6 +497,15 @@ export const lastSelectedBranchesAtom = atomWithStorage>( // Set - subChats currently being compacted export const compactingSubChatsAtom = atom>(new Set()) +export type LastCompactInfo = { + preTokens: number + at: number +} + +// Last known compact info per sub-chat (not persisted) +// Useful to explain why context usage may look stale until the next request. +export const lastCompactInfoAtom = atom>({}) + // Track IDs of chats/subchats created in this browser session (NOT persisted - resets on reload) // Used to determine whether to show placeholder + typewriter effect export const justCreatedIdsAtom = atom>(new Set()) @@ -489,6 +527,14 @@ export type PendingUserQuestions = { } export const pendingUserQuestionsAtom = atom(null) +// Track when a chat has finished and is waiting for user input +// Set to subChatId when chat finishes, cleared when user sends a message +export const chatWaitingForUserAtom = atom(null) + +// Track if nagging sound is muted (user clicked the bell to silence it) +// Resets when a new nag starts (new chat finishes or new question appears) +export const nagMutedAtom = atom(false) + // Track sub-chats with pending plan approval (plan ready but not yet implemented) // Set export const pendingPlanApprovalsAtom = atom>(new Set()) @@ -504,3 +550,18 @@ export type UndoItem = | { type: "subchat"; subChatId: string; chatId: string; timeoutId: ReturnType } export const undoStackAtom = atom([]) + +// Git panel state +export const gitPanelOpenAtom = atomWithStorage( + "agents:gitPanelOpen", + false, + undefined, + { getOnInit: true }, +) + +export const gitPanelHeightAtom = atomWithStorage( + "agents:gitPanelHeight", + 250, + undefined, + { getOnInit: true }, +) diff --git a/src/renderer/features/agents/commands/agents-slash-command.tsx b/src/renderer/features/agents/commands/agents-slash-command.tsx index 25ceb6c5..b3e088c7 100644 --- a/src/renderer/features/agents/commands/agents-slash-command.tsx +++ b/src/renderer/features/agents/commands/agents-slash-command.tsx @@ -16,6 +16,7 @@ import { IconChatBubble, PlanIcon, AgentIcon, + AskIcon, } from "../../../components/ui/icons" import { MessageSquareCode, @@ -38,6 +39,8 @@ function getCommandIcon(commandName: string) { return PlanIcon case "agent": return AgentIcon + case "ask": + return AskIcon case "review": return Eye case "pr-comments": @@ -51,6 +54,8 @@ function getCommandIcon(commandName: string) { } } +type ChatMode = "agent" | "plan" | "ask" + interface AgentsSlashCommandProps { isOpen: boolean onClose: () => void @@ -59,7 +64,7 @@ interface AgentsSlashCommandProps { position: { top: number; left: number } teamId?: string repository?: string - isPlanMode?: boolean + chatMode?: ChatMode disabledCommands?: string[] } @@ -72,7 +77,7 @@ export const AgentsSlashCommand = memo(function AgentsSlashCommand({ position, teamId, repository, - isPlanMode, + chatMode, disabledCommands, }: AgentsSlashCommandProps) { const dropdownRef = useRef(null) @@ -151,11 +156,12 @@ export const AgentsSlashCommand = memo(function AgentsSlashCommand({ const options: SlashCommandOption[] = useMemo(() => { let builtinFiltered = filterBuiltinCommands(debouncedSearchText) - // Hide /plan when already in Plan mode, hide /agent when already in Agent mode - if (isPlanMode !== undefined) { + // Hide current mode's command (e.g., hide /plan when already in Plan mode) + if (chatMode !== undefined) { builtinFiltered = builtinFiltered.filter((cmd) => { - if (isPlanMode && cmd.name === "plan") return false - if (!isPlanMode && cmd.name === "agent") return false + if (chatMode === "plan" && cmd.name === "plan") return false + if (chatMode === "agent" && cmd.name === "agent") return false + if (chatMode === "ask" && cmd.name === "ask") return false return true }) } @@ -180,7 +186,7 @@ export const AgentsSlashCommand = memo(function AgentsSlashCommand({ // Return builtin first, then repository commands return [...builtinFiltered, ...repoFiltered] - }, [debouncedSearchText, repoCommands, isPlanMode, disabledCommands]) + }, [debouncedSearchText, repoCommands, chatMode, disabledCommands]) // Track previous values for smarter selection reset const prevIsOpenRef = useRef(isOpen) diff --git a/src/renderer/features/agents/commands/builtin-commands.ts b/src/renderer/features/agents/commands/builtin-commands.ts index f3968c14..067415bd 100644 --- a/src/renderer/features/agents/commands/builtin-commands.ts +++ b/src/renderer/features/agents/commands/builtin-commands.ts @@ -50,6 +50,13 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandOption[] = [ description: "Switch to Agent mode (applies changes directly)", category: "builtin", }, + { + id: "builtin:ask", + name: "ask", + command: "/ask", + description: "Switch to Ask mode (answer questions without code changes)", + category: "builtin", + }, { id: "builtin:compact", name: "compact", @@ -57,6 +64,13 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandOption[] = [ description: "Compact conversation context to reduce token usage", category: "builtin", }, + { + id: "builtin:context", + name: "context", + command: "/context", + description: "Show context window usage breakdown", + category: "builtin", + }, // Prompt-based commands { id: "builtin:review", diff --git a/src/renderer/features/agents/commands/types.ts b/src/renderer/features/agents/commands/types.ts index 4d37902c..e3cb65cd 100644 --- a/src/renderer/features/agents/commands/types.ts +++ b/src/renderer/features/agents/commands/types.ts @@ -34,6 +34,7 @@ export type BuiltinCommandAction = | { type: "plan" } | { type: "agent" } | { type: "compact" } + | { type: "context" } // Prompt-based commands (send to agent) | { type: "review" } | { type: "pr-comments" } diff --git a/src/renderer/features/agents/components/agent-chat-card.tsx b/src/renderer/features/agents/components/agent-chat-card.tsx index d95eeec3..91c05385 100644 --- a/src/renderer/features/agents/components/agent-chat-card.tsx +++ b/src/renderer/features/agents/components/agent-chat-card.tsx @@ -6,6 +6,7 @@ import { IconSpinner, PlanIcon, AgentIcon, + AskIcon, } from "../../../components/ui/canvas-icons" import { useAtomValue } from "jotai" import { agentsUnseenChangesAtom, lastChatModesAtom } from "../atoms" @@ -28,6 +29,8 @@ interface AgentChatCardProps { repoName?: string | null } +type ChatMode = "agent" | "plan" | "ask" + // Chat icon with status badge function ChatIconWithBadge({ isLoading, @@ -39,7 +42,7 @@ function ChatIconWithBadge({ }: { isLoading: boolean hasUnseenChanges: boolean - lastMode: "plan" | "agent" + lastMode: ChatMode isSelected?: boolean gitOwner?: string | null gitProvider?: string | null @@ -87,6 +90,13 @@ function ChatIconWithBadge({ isSelected ? "text-primary-foreground" : "text-muted-foreground", )} /> + ) : lastMode === "ask" ? ( + ) : ( >(new Set()) + // Track timers for cleanup + const timersRef = useRef>(new Map()) + + useEffect(() => { + // Function to process queue for a specific sub-chat + const processQueue = async (subChatId: string) => { + // Check if already processing this sub-chat + if (processingRef.current.has(subChatId)) { + return + } + + // Check streaming status + const status = useStreamingStatusStore.getState().getStatus(subChatId) + if (status !== "ready") { + return + } + + // Get queue for this sub-chat + const queue = useMessageQueueStore.getState().queues[subChatId] || [] + if (queue.length === 0) { + return + } + + // Get the Chat object from agentChatStore + const chat = agentChatStore.get(subChatId) + if (!chat) { + return + } + + // Mark as processing + processingRef.current.add(subChatId) + + // Pop the first item from queue (atomic operation) + const item = useMessageQueueStore.getState().popItem(subChatId, queue[0].id) + if (!item) { + processingRef.current.delete(subChatId) + return + } + + try { + // Build message parts from queued item + const parts: any[] = [ + ...(item.images || []).map((img) => ({ + type: "data-image" as const, + data: { + url: img.url, + mediaType: img.mediaType, + filename: img.filename, + base64Data: img.base64Data, + }, + })), + ...(item.files || []).map((f) => ({ + type: "data-file" as const, + data: { + url: f.url, + mediaType: f.mediaType, + filename: f.filename, + size: f.size, + }, + })), + ] + + if (item.message) { + parts.push({ type: "text", text: item.message }) + } + + // Get mode from sub-chat store for analytics + const subChatMeta = useAgentSubChatStore + .getState() + .allSubChats.find((sc) => sc.id === subChatId) + const mode = subChatMeta?.mode || "agent" + + // Track message sent + trackMessageSent({ + workspaceId: subChatId, + messageLength: item.message.length, + mode, + }) + + // Update timestamps + useAgentSubChatStore.getState().updateSubChatTimestamp(subChatId) + + // Set loading state for sidebar indicator + const parentChatId = agentChatStore.getParentChatId(subChatId) + if (parentChatId) { + setLoading( + (fn) => appStore.set(loadingSubChatsAtom, fn(appStore.get(loadingSubChatsAtom))), + subChatId, + parentChatId + ) + } + + // Send message using Chat's sendMessage method + await chat.sendMessage({ role: "user", parts }) + + } catch (error) { + console.error(`[QueueProcessor] Error processing queue:`, error) + + // Requeue the item at the front so it can be retried + useMessageQueueStore.getState().prependItem(subChatId, item) + + // Set error status (will be cleared on next successful send or manual retry) + useStreamingStatusStore.getState().setStatus(subChatId, "error") + + // Clear loading state since send failed + clearLoading( + (fn) => appStore.set(loadingSubChatsAtom, fn(appStore.get(loadingSubChatsAtom))), + subChatId + ) + + // Notify user + toast.error("Failed to send queued message. It will be retried.") + } finally { + processingRef.current.delete(subChatId) + } + } + + // Schedule processing for a sub-chat with delay + const scheduleProcessing = (subChatId: string) => { + // Clear any existing timer for this sub-chat + const existingTimer = timersRef.current.get(subChatId) + if (existingTimer) { + clearTimeout(existingTimer) + } + + // Schedule new processing + const timer = setTimeout(() => { + timersRef.current.delete(subChatId) + processQueue(subChatId) + }, QUEUE_PROCESS_DELAY) + + timersRef.current.set(subChatId, timer) + } + + // Check all queues and schedule processing for ready sub-chats + const checkAllQueues = () => { + const queues = useMessageQueueStore.getState().queues + + for (const subChatId of Object.keys(queues)) { + const queue = queues[subChatId] + if (!queue || queue.length === 0) continue + + const status = useStreamingStatusStore.getState().getStatus(subChatId) + + // Process when ready, or retry on error status + if ((status === "ready" || status === "error") && !processingRef.current.has(subChatId)) { + // If error status, clear it before retrying + if (status === "error") { + useStreamingStatusStore.getState().setStatus(subChatId, "ready") + } + scheduleProcessing(subChatId) + } + } + } + + // Subscribe to queue changes with selector (requires subscribeWithSelector middleware) + const unsubscribeQueue = useMessageQueueStore.subscribe( + (state) => state.queues, + () => checkAllQueues() + ) + + // Subscribe to streaming status changes with selector + const unsubscribeStatus = useStreamingStatusStore.subscribe( + (state) => state.statuses, + () => checkAllQueues() + ) + + // Initial check + checkAllQueues() + + // Cleanup + return () => { + unsubscribeQueue() + unsubscribeStatus() + + // Clear all timers + for (const timer of timersRef.current.values()) { + clearTimeout(timer) + } + timersRef.current.clear() + } + }, []) + + // This component doesn't render anything + return null +} diff --git a/src/renderer/features/agents/components/settings-tabs/agents-profile-tab.tsx b/src/renderer/features/agents/components/settings-tabs/agents-profile-tab.tsx index d646b90c..b0b4480b 100644 --- a/src/renderer/features/agents/components/settings-tabs/agents-profile-tab.tsx +++ b/src/renderer/features/agents/components/settings-tabs/agents-profile-tab.tsx @@ -1,6 +1,7 @@ "use client" import { useState, useEffect, useRef } from "react" +import { useAtom, useAtomValue, atom } from "jotai" import { Button } from "../../../../components/ui/button" import { Input } from "../../../../components/ui/input" import { Label } from "../../../../components/ui/label" @@ -9,6 +10,7 @@ import { toast } from "sonner" import { Upload, Edit } from "lucide-react" import { motion } from "motion/react" import { cn } from "../../../../lib/utils" +import { localProfileNameAtom } from "../../../../lib/atoms" // Desktop user interface interface DesktopUser { @@ -21,21 +23,21 @@ interface DesktopUser { // Custom hook for desktop user profile const useDesktopUserProfile = () => { - const [user, setUser] = useState(null) - const [isLoading, setIsLoading] = useState(true) - - useEffect(() => { - async function fetchUser() { - if (window.desktopApi?.getUser) { - const userData = await window.desktopApi.getUser() - setUser(userData) - } - setIsLoading(false) - } - fetchUser() - }, []) + const [storedName, setStoredName] = useAtom(localProfileNameAtom) + + const user: DesktopUser = { + id: "local", + email: "Local", + name: storedName?.trim() || null, + imageUrl: null, + username: null, + } - return { user, setUser, isLoading } + return { + user, + setUser: (next: DesktopUser | null) => setStoredName(next?.name ?? ""), + isLoading: false, + } } // Stub for image upload (not implemented in desktop yet) @@ -85,9 +87,6 @@ const api = { }, }, } -import { useAtomValue } from "jotai" -// Desktop: mock team atom -import { atom } from "jotai" const selectedTeamIdAtom = atom(null) type AuthFlowState = @@ -137,15 +136,14 @@ export function AgentsProfileTab() { setIsSaving(true) try { - if (window.desktopApi?.updateUser) { - const updatedUser = await window.desktopApi.updateUser({ name: fullName }) - if (updatedUser) { - setUser(updatedUser) - toast.success("Profile updated successfully") - } - } else { - throw new Error("Desktop API not available") - } + setUser({ + id: "local", + email: "Local", + name: fullName.trim() || null, + imageUrl: null, + username: null, + }) + toast.success("Profile saved") } catch (error) { console.error("Error updating profile:", error) toast.error( diff --git a/src/renderer/features/agents/context/text-selection-context.tsx b/src/renderer/features/agents/context/text-selection-context.tsx new file mode 100644 index 00000000..962a7e24 --- /dev/null +++ b/src/renderer/features/agents/context/text-selection-context.tsx @@ -0,0 +1,152 @@ +"use client" + +import { + createContext, + useContext, + useState, + useEffect, + useCallback, + type ReactNode, +} from "react" + +export interface TextSelectionState { + selectedText: string | null + selectedMessageId: string | null + selectionRect: DOMRect | null +} + +interface TextSelectionContextValue extends TextSelectionState { + clearSelection: () => void +} + +const TextSelectionContext = createContext( + null +) + +export function useTextSelection(): TextSelectionContextValue { + const ctx = useContext(TextSelectionContext) + if (!ctx) { + throw new Error( + "useTextSelection must be used within TextSelectionProvider" + ) + } + return ctx +} + +interface TextSelectionProviderProps { + children: ReactNode +} + +export function TextSelectionProvider({ + children, +}: TextSelectionProviderProps) { + const [state, setState] = useState({ + selectedText: null, + selectedMessageId: null, + selectionRect: null, + }) + + const clearSelection = useCallback(() => { + window.getSelection()?.removeAllRanges() + setState({ + selectedText: null, + selectedMessageId: null, + selectionRect: null, + }) + }, []) + + useEffect(() => { + let rafId: number | null = null + + const handleSelectionChange = () => { + // Cancel any pending frame to debounce rapid selection changes + if (rafId !== null) { + cancelAnimationFrame(rafId) + } + + rafId = requestAnimationFrame(() => { + rafId = null + + const selection = window.getSelection() + + // No selection or collapsed (just cursor) + if (!selection || selection.isCollapsed) { + setState({ + selectedText: null, + selectedMessageId: null, + selectionRect: null, + }) + return + } + + const text = selection.toString().trim() + if (!text) { + setState({ + selectedText: null, + selectedMessageId: null, + selectionRect: null, + }) + return + } + + // Get the selection range + const range = selection.getRangeAt(0) + const container = range.commonAncestorContainer + + // Find the closest assistant message element + const element = + container.nodeType === Node.TEXT_NODE + ? container.parentElement + : (container as Element) + + const messageElement = element?.closest?.( + "[data-assistant-message-id]" + ) as HTMLElement | null + + // Selection is not within an assistant message + if (!messageElement) { + setState({ + selectedText: null, + selectedMessageId: null, + selectionRect: null, + }) + return + } + + const messageId = messageElement.getAttribute("data-assistant-message-id") + if (!messageId) { + setState({ + selectedText: null, + selectedMessageId: null, + selectionRect: null, + }) + return + } + + // Get the bounding rect of the selection + const rect = range.getBoundingClientRect() + + setState({ + selectedText: text, + selectedMessageId: messageId, + selectionRect: rect, + }) + }) + } + + document.addEventListener("selectionchange", handleSelectionChange) + + return () => { + document.removeEventListener("selectionchange", handleSelectionChange) + if (rafId !== null) { + cancelAnimationFrame(rafId) + } + } + }, []) + + return ( + + {children} + + ) +} diff --git a/src/renderer/features/agents/hooks/use-nagging-sound.ts b/src/renderer/features/agents/hooks/use-nagging-sound.ts new file mode 100644 index 00000000..369f5997 --- /dev/null +++ b/src/renderer/features/agents/hooks/use-nagging-sound.ts @@ -0,0 +1,80 @@ +import { useEffect, useRef } from "react" +import { useAtomValue, useSetAtom } from "jotai" +import { chatWaitingForUserAtom, nagMutedAtom, pendingUserQuestionsAtom } from "../atoms" +import { soundManager } from "@/lib/sound-manager" + +const NAGGING_INTERVAL_MS = 30000 // 30 seconds +const MAX_NAG_DURATION_MS = 10 * 60 * 1000 // 10 minutes + +/** + * Hook that plays a nagging sound when Claude needs user attention: + * 1. Pending user questions (AskUserQuestion tool) + * 2. Chat finished and waiting for user input + * + * Plays immediately when state changes, then every 30 seconds until: + * - User responds + * - User mutes via bell icon + * - 10 minutes have passed (auto-stops) + */ +export function useNaggingSound(): void { + const pendingQuestions = useAtomValue(pendingUserQuestionsAtom) + const chatWaiting = useAtomValue(chatWaitingForUserAtom) + const isMuted = useAtomValue(nagMutedAtom) + const setMuted = useSetAtom(nagMutedAtom) + const intervalRef = useRef | null>(null) + const timeoutRef = useRef | null>(null) + const prevShouldNagRef = useRef(false) + + // Nag if either: pending questions exist OR chat is waiting for user + const shouldNag = !!pendingQuestions || !!chatWaiting + + // Reset mute when a NEW nag starts (transition from false to true) + useEffect(() => { + if (shouldNag && !prevShouldNagRef.current) { + setMuted(false) + } + prevShouldNagRef.current = shouldNag + }, [shouldNag, setMuted]) + + useEffect(() => { + // Clear any existing timers first + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + timeoutRef.current = null + } + + if (shouldNag && !isMuted) { + // Play immediately when needing attention + soundManager.play("notification") + + // Start interval for nagging every 30 seconds + intervalRef.current = setInterval(() => { + soundManager.play("notification") + }, NAGGING_INTERVAL_MS) + + // Auto-stop after 10 minutes + timeoutRef.current = setTimeout(() => { + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + }, MAX_NAG_DURATION_MS) + } + + // Cleanup on unmount or when no longer needs attention + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + timeoutRef.current = null + } + } + }, [shouldNag, isMuted]) +} diff --git a/src/renderer/features/agents/hooks/use-text-context-selection.ts b/src/renderer/features/agents/hooks/use-text-context-selection.ts new file mode 100644 index 00000000..b3f41b4e --- /dev/null +++ b/src/renderer/features/agents/hooks/use-text-context-selection.ts @@ -0,0 +1,75 @@ +import { useState, useCallback, useRef } from "react" +import { + type SelectedTextContext, + createTextPreview, +} from "../lib/queue-utils" + +export interface UseTextContextSelectionReturn { + textContexts: SelectedTextContext[] + addTextContext: (text: string, sourceMessageId: string) => void + removeTextContext: (id: string) => void + clearTextContexts: () => void + // Ref for accessing current value in callbacks without re-renders + textContextsRef: React.RefObject + // Direct state setter for restoring from draft + setTextContextsFromDraft: (contexts: SelectedTextContext[]) => void +} + +export function useTextContextSelection(): UseTextContextSelectionReturn { + const [textContexts, setTextContexts] = useState([]) + const textContextsRef = useRef([]) + + // Keep ref in sync with state + textContextsRef.current = textContexts + + const addTextContext = useCallback( + (text: string, sourceMessageId: string) => { + const trimmedText = text.trim() + if (!trimmedText) return + + // Prevent duplicates - check if same text from same message already exists + const isDuplicate = textContextsRef.current.some( + (ctx) => + ctx.text === trimmedText && ctx.sourceMessageId === sourceMessageId + ) + if (isDuplicate) return + + const newContext: SelectedTextContext = { + id: `tc_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, + text: trimmedText, + sourceMessageId, + preview: createTextPreview(trimmedText), + createdAt: new Date(), + } + + setTextContexts((prev) => [...prev, newContext]) + }, + [] + ) + + const removeTextContext = useCallback((id: string) => { + setTextContexts((prev) => prev.filter((ctx) => ctx.id !== id)) + }, []) + + const clearTextContexts = useCallback(() => { + setTextContexts([]) + }, []) + + // Direct state setter for restoring from draft + const setTextContextsFromDraft = useCallback( + (contexts: SelectedTextContext[]) => { + setTextContexts(contexts) + textContextsRef.current = contexts + }, + [] + ) + + return { + textContexts, + addTextContext, + removeTextContext, + clearTextContexts, + textContextsRef, + setTextContextsFromDraft, + } +} diff --git a/src/renderer/features/agents/hooks/use-tool-notifications.ts b/src/renderer/features/agents/hooks/use-tool-notifications.ts new file mode 100644 index 00000000..378e863c --- /dev/null +++ b/src/renderer/features/agents/hooks/use-tool-notifications.ts @@ -0,0 +1,330 @@ +"use client" + +import { useCallback, useEffect, useRef } from "react" +import { useAtomValue, useSetAtom } from "jotai" +import { toast } from "sonner" +import { + notificationModeAtom, + toastNotificationsEnabledAtom, + addToolActivityAtom, + updateToolActivityAtom, + type ToolActivity, +} from "../../../lib/atoms" +import { trpcClient } from "../../../lib/trpc" +import { detectCommand } from "../../../lib/bash-command-utils" + +// Tool icons for toast notifications +const TOOL_ICONS: Record = { + Read: "📖", + Write: "📝", + Edit: "✏️", + Bash: "🖥️", + Glob: "🔍", + Grep: "🔎", + WebFetch: "🌐", + Task: "🤖", + TodoWrite: "📋", + WebSearch: "🔎", + AskUserQuestion: "❓", + NotebookEdit: "📓", + PlanWrite: "📋", + ExitPlanMode: "✅", +} + +/** + * Extract a human-readable summary from tool input + */ +function getToolSummary( + toolName: string, + input: Record, +): string { + switch (toolName) { + case "Read": + case "Write": + case "Edit": { + const filePath = input?.file_path as string + return filePath?.split("/").pop() || "file" + } + case "Bash": { + // First check if Claude provided a description + const description = input?.description as string + if (description) { + return description.length > 50 + ? description.substring(0, 50) + "..." + : description + } + // Fall back to smart command detection + const cmd = (input?.command as string) || "" + const detected = detectCommand(cmd) + return detected.summary + } + case "Glob": + case "Grep": { + return (input?.pattern as string) || "pattern" + } + case "WebFetch": { + try { + const url = input?.url as string + return url ? new URL(url).hostname : "url" + } catch { + return "url" + } + } + case "WebSearch": { + return (input?.query as string)?.substring(0, 40) || "search" + } + case "Task": { + return (input?.description as string)?.substring(0, 40) || "task" + } + case "TodoWrite": { + const todos = input?.todos as unknown[] + return todos ? `${todos.length} items` : "todos" + } + case "PlanWrite": { + const plan = input?.plan as { title?: string } + return plan?.title || "Plan" + } + case "ExitPlanMode": { + return "Plan ready for approval" + } + default: + return toolName + } +} + +/** + * Get icon for a tool + */ +function getToolIcon(toolName: string): string { + return TOOL_ICONS[toolName] || "🔧" +} + +// Track window focus state +let isWindowFocused = + typeof document !== "undefined" ? document.hasFocus() : true + +// Setup focus tracking (runs once) +if (typeof window !== "undefined") { + window.addEventListener("focus", () => { + isWindowFocused = true + }) + window.addEventListener("blur", () => { + isWindowFocused = false + }) +} + +// Custom event types for tool notifications +declare global { + interface WindowEventMap { + "tool-start": CustomEvent<{ + toolCallId: string + toolName: string + input: Record + subChatId: string + chatName: string + }> + "tool-complete": CustomEvent<{ + toolCallId: string + isError: boolean + output?: unknown + errorText?: string + }> + } +} + +/** + * Hook for tool execution notifications + * - Shows toast notifications when tools start (if enabled) + * - Adds activities to the activity feed + * - Persists activities to database + * - Respects notification mode settings + */ +export function useToolNotifications(subChatId: string, chatName: string) { + const notificationMode = useAtomValue(notificationModeAtom) + const toastsEnabled = useAtomValue(toastNotificationsEnabledAtom) + const addActivity = useSetAtom(addToolActivityAtom) + const updateActivity = useSetAtom(updateToolActivityAtom) + + // Track tool call IDs to activity IDs mapping (local temp ID -> DB ID) + const toolCallToActivityId = useRef>(new Map()) + // Track local temp IDs for DB updates + const localToDbId = useRef>(new Map()) + + /** + * Check if we should show notifications based on current mode + */ + const shouldNotify = useCallback((): boolean => { + if (notificationMode === "always") return true + if (notificationMode === "never") return false + return !isWindowFocused // "unfocused" mode + }, [notificationMode]) + + /** + * Notify when a tool starts executing + */ + const notifyToolStart = useCallback( + (toolCallId: string, toolName: string, input: Record) => { + const summary = getToolSummary(toolName, input) + const inputJson = JSON.stringify(input) + + // Optimistic UI update - add to atom immediately + const newActivity = addActivity({ + subChatId, + chatName, + toolName, + summary, + state: "running", + input: inputJson, + }) + + // Track mapping for later updates (toolCallId -> local activity ID) + if (newActivity) { + toolCallToActivityId.current.set(toolCallId, newActivity.id) + } + + // Persist to database in background (fire-and-forget) + trpcClient.activities.create + .mutate({ + subChatId, + chatName, + toolName, + summary, + state: "running", + input, + }) + .then((dbActivity) => { + // Map local ID to DB ID for later updates + if (newActivity && dbActivity) { + localToDbId.current.set(newActivity.id, dbActivity.id) + } + }) + .catch((err) => { + console.error("[TOOL_NOTIF] Failed to persist activity:", err) + }) + + // Show toast if enabled and should notify + if (toastsEnabled && shouldNotify()) { + toast(`${getToolIcon(toolName)} ${toolName}`, { + description: summary, + duration: 3000, + }) + } + }, + [subChatId, chatName, toastsEnabled, shouldNotify, addActivity], + ) + + /** + * Notify when a tool completes + */ + const notifyToolComplete = useCallback( + ( + toolCallId: string, + isError: boolean, + output?: unknown, + errorText?: string, + ) => { + const localActivityId = toolCallToActivityId.current.get(toolCallId) + if (!localActivityId) return + + const state: ToolActivity["state"] = isError ? "error" : "complete" + const outputJson = output ? JSON.stringify(output) : null + + // Optimistic UI update + updateActivity({ + id: localActivityId, + state, + output: outputJson, + errorText: errorText ?? null, + }) + + // Get DB ID and persist update + const dbId = localToDbId.current.get(localActivityId) + if (dbId) { + trpcClient.activities.update + .mutate({ + id: dbId, + state, + output, + errorText, + }) + .catch((err) => { + console.error("[TOOL_NOTIF] Failed to update activity:", err) + }) + localToDbId.current.delete(localActivityId) + } + + toolCallToActivityId.current.delete(toolCallId) + }, + [updateActivity], + ) + + // Listen for global tool events + useEffect(() => { + const handleToolStart = (e: WindowEventMap["tool-start"]) => { + // Only handle events for this sub-chat + if (e.detail.subChatId === subChatId) { + notifyToolStart(e.detail.toolCallId, e.detail.toolName, e.detail.input) + } + } + + const handleToolComplete = (e: WindowEventMap["tool-complete"]) => { + notifyToolComplete( + e.detail.toolCallId, + e.detail.isError, + e.detail.output, + e.detail.errorText, + ) + } + + window.addEventListener("tool-start", handleToolStart) + window.addEventListener("tool-complete", handleToolComplete) + + return () => { + window.removeEventListener("tool-start", handleToolStart) + window.removeEventListener("tool-complete", handleToolComplete) + } + }, [subChatId, notifyToolStart, notifyToolComplete]) + + return { + notifyToolStart, + notifyToolComplete, + shouldNotify, + } +} + +/** + * Dispatch a tool start event (called from ipc-chat-transport) + */ +export function dispatchToolStart( + toolCallId: string, + toolName: string, + input: Record, + subChatId: string, + chatName: string, +) { + if (typeof window === "undefined") return + + window.dispatchEvent( + new CustomEvent("tool-start", { + detail: { toolCallId, toolName, input, subChatId, chatName }, + }), + ) +} + +/** + * Dispatch a tool complete event (called from ipc-chat-transport) + */ +export function dispatchToolComplete( + toolCallId: string, + isError: boolean, + output?: unknown, + errorText?: string, +) { + if (typeof window === "undefined") return + + window.dispatchEvent( + new CustomEvent("tool-complete", { + detail: { toolCallId, isError, output, errorText }, + }), + ) +} diff --git a/src/renderer/features/agents/lib/agents-actions.ts b/src/renderer/features/agents/lib/agents-actions.ts index 2a4cc18e..fddd5320 100644 --- a/src/renderer/features/agents/lib/agents-actions.ts +++ b/src/renderer/features/agents/lib/agents-actions.ts @@ -19,6 +19,7 @@ export interface AgentActionContext { // UI states setSidebarOpen?: (open: boolean | ((prev: boolean) => boolean)) => void + setRightPanelOpen?: (open: boolean | ((prev: boolean) => boolean)) => void setSettingsDialogOpen?: (open: boolean) => void setSettingsActiveTab?: (tab: SettingsTab) => void setShortcutsDialogOpen?: (open: boolean) => void @@ -105,6 +106,18 @@ const toggleSidebarAction: AgentActionDefinition = { }, } +const toggleRightPanelAction: AgentActionDefinition = { + id: "toggle-right-panel", + label: "Toggle right panel", + description: "Show/hide right panel", + category: "view", + hotkey: ["cmd+shift+p", "ctrl+shift+p"], + handler: async (context) => { + context.setRightPanelOpen?.((prev) => !prev) + return { success: true } + }, +} + // ============================================================================ // ACTION REGISTRY // ============================================================================ @@ -114,6 +127,7 @@ export const AGENT_ACTIONS: Record = { "create-new-agent": createNewAgentAction, "open-settings": openSettingsAction, "toggle-sidebar": toggleSidebarAction, + "toggle-right-panel": toggleRightPanelAction, } export function getAgentAction(id: string): AgentActionDefinition | undefined { diff --git a/src/renderer/features/agents/lib/agents-hotkeys-manager.ts b/src/renderer/features/agents/lib/agents-hotkeys-manager.ts index 68bebc82..e2d862ee 100644 --- a/src/renderer/features/agents/lib/agents-hotkeys-manager.ts +++ b/src/renderer/features/agents/lib/agents-hotkeys-manager.ts @@ -61,6 +61,7 @@ function matchesHotkey(e: KeyboardEvent, hotkey: string): boolean { export interface AgentsHotkeysManagerConfig { setSelectedChatId?: (id: string | null) => void setSidebarOpen?: (open: boolean | ((prev: boolean) => boolean)) => void + setRightPanelOpen?: (open: boolean | ((prev: boolean) => boolean)) => void setSettingsDialogOpen?: (open: boolean) => void setSettingsActiveTab?: (tab: SettingsTab) => void setShortcutsDialogOpen?: (open: boolean) => void @@ -89,6 +90,7 @@ export function useAgentsHotkeys( (): AgentActionContext => ({ setSelectedChatId: config.setSelectedChatId, setSidebarOpen: config.setSidebarOpen, + setRightPanelOpen: config.setRightPanelOpen, setSettingsDialogOpen: config.setSettingsDialogOpen, setSettingsActiveTab: config.setSettingsActiveTab, setShortcutsDialogOpen: config.setShortcutsDialogOpen, @@ -209,6 +211,7 @@ export function useAgentsHotkeys( action.hotkey !== undefined && action.id !== "create-new-agent" && action.id !== "toggle-sidebar" && + action.id !== "toggle-right-panel" && action.id !== "open-shortcuts" && action.id !== "open-settings", ), diff --git a/src/renderer/features/agents/lib/ipc-chat-transport.ts b/src/renderer/features/agents/lib/ipc-chat-transport.ts index 94df2211..a7e44bac 100644 --- a/src/renderer/features/agents/lib/ipc-chat-transport.ts +++ b/src/renderer/features/agents/lib/ipc-chat-transport.ts @@ -10,13 +10,20 @@ import { appStore } from "../../../lib/jotai-store" import { trpcClient } from "../../../lib/trpc" import { askUserQuestionResultsAtom, + chatWaitingForUserAtom, compactingSubChatsAtom, + lastCompactInfoAtom, lastSelectedModelIdAtom, MODEL_ID_MAP, pendingAuthRetryMessageAtom, pendingUserQuestionsAtom, } from "../atoms" import { useAgentSubChatStore } from "../stores/sub-chat-store" +import { + dispatchToolStart, + dispatchToolComplete, +} from "../hooks/use-tool-notifications" +import { soundManager } from "../../../lib/sound-manager" // Error categories and their user-friendly messages const ERROR_TOAST_CONFIG: Record< @@ -92,7 +99,7 @@ type IPCChatTransportConfig = { subChatId: string cwd: string projectPath?: string // Original project path for MCP config lookup (when using worktrees) - mode: "plan" | "agent" + mode: "plan" | "agent" | "ask" model?: string } @@ -110,6 +117,9 @@ export class IPCChatTransport implements ChatTransport { messages: UIMessage[] abortSignal?: AbortSignal }): Promise> { + // Clear waiting-for-user state when user sends a new message + appStore.set(chatWaitingForUserAtom, null) + // Extract prompt and images from last user message const lastUser = [...options.messages] .reverse() @@ -127,10 +137,20 @@ export class IPCChatTransport implements ChatTransport { const thinkingEnabled = appStore.get(extendedThinkingEnabledAtom) const maxThinkingTokens = thinkingEnabled ? 128_000 : undefined - // Read model selection dynamically (so model changes apply to existing chats) - const selectedModelId = appStore.get(lastSelectedModelIdAtom) - const modelString = MODEL_ID_MAP[selectedModelId] - + // Use per-sub-chat model from store, fall back to config (seed), then global atom + // This lets model switches apply to the next send without recreating the Chat instance. + const subChatModelId = + useAgentSubChatStore + .getState() + .allSubChats.find((subChat) => subChat.id === this.config.subChatId) + ?.modelId + const modelId = + subChatModelId || this.config.model || appStore.get(lastSelectedModelIdAtom) + const modelString = MODEL_ID_MAP[modelId] + + // Read mode dynamically from sub-chat store (like model above). + // This lets mode switches (Agent/Plan/Ask) apply to the next send without recreating the Chat instance. + // Users can change modes mid-conversation via the dropdown or /agent, /plan, /ask commands. const currentMode = useAgentSubChatStore .getState() @@ -141,6 +161,8 @@ export class IPCChatTransport implements ChatTransport { const subId = this.config.subChatId.slice(-8) let chunkCount = 0 let lastChunkType = "" + // Track if thinking sound was played this message (play once per message) + let thinkingSoundPlayed = false console.log(`[SD] R:START sub=${subId} cwd=${this.config.cwd} projectPath=${this.config.projectPath || "(not set)"}`) return new ReadableStream({ @@ -200,6 +222,21 @@ export class IPCChatTransport implements ChatTransport { newCompacting.delete(this.config.subChatId) } appStore.set(compactingSubChatsAtom, newCompacting) + + if ( + chunk.state === "output-available" && + typeof chunk.preTokens === "number" && + chunk.preTokens > 0 + ) { + const current = appStore.get(lastCompactInfoAtom) + appStore.set(lastCompactInfoAtom, { + ...current, + [this.config.subChatId]: { + preTokens: chunk.preTokens, + at: Date.now(), + }, + }) + } } // Handle session init - store MCP servers, plugins, tools info @@ -220,6 +257,73 @@ export class IPCChatTransport implements ChatTransport { }) } + // Dispatch tool events for notification system + if (chunk.type === "tool-input-available") { + // Get chat name from store for display + const subChat = useAgentSubChatStore + .getState() + .allSubChats.find((sc) => sc.id === this.config.subChatId) + const chatName = subChat?.name || "Agent" + + dispatchToolStart( + chunk.toolCallId, + chunk.toolName, + chunk.input || {}, + this.config.subChatId, + chatName, + ) + } + + if (chunk.type === "tool-output-available") { + dispatchToolComplete(chunk.toolCallId, false, chunk.output) + } else if (chunk.type === "tool-output-error") { + dispatchToolComplete( + chunk.toolCallId, + true, + undefined, + chunk.errorText, + ) + // Show toast for Ask mode tool denial so user gets clear feedback + if (chunk.errorText?.includes("Ask mode")) { + toast.error("Tool blocked", { + description: chunk.errorText, + }) + } + } + + // === SOUND TRIGGERS === + + // Thinking sound - play once when reasoning starts + if ( + (chunk.type === "reasoning" || + chunk.type === "reasoning-delta") && + !thinkingSoundPlayed + ) { + soundManager.play("thinking") + thinkingSoundPlayed = true + } + + // Tool execution sound - debounced, per-tool sounds + if (chunk.type === "tool-input-available") { + soundManager.playTool(chunk.toolName, 2000) // 2s debounce + } + + // Bash result sounds + if (chunk.type === "tool-output-available") { + // Check if this was a Bash tool by looking at the output structure + const output = chunk.output as { exitCode?: number } | undefined + if (typeof output?.exitCode === "number") { + soundManager.playResult(output.exitCode === 0) + } + } + + // Stream complete sound + set waiting for user state + if (chunk.type === "finish") { + soundManager.play("stop") + // Mark this chat as waiting for user input (triggers nagging sound) + appStore.set(chatWaitingForUserAtom, this.config.subChatId) + } + // Clear pending questions ONLY when agent has moved on // Don't clear on tool-input-* chunks (still building the question input) // Clear when we get tool-output-* (answer received) or text-delta (agent moved on) @@ -240,6 +344,7 @@ export class IPCChatTransport implements ChatTransport { // Handle authentication errors - show Claude login modal if (chunk.type === "auth-error") { + soundManager.play("error") // Store the failed message for retry after successful auth // readyToRetry=false prevents immediate retry - modal sets it to true on OAuth success appStore.set(pendingAuthRetryMessageAtom, { @@ -259,6 +364,7 @@ export class IPCChatTransport implements ChatTransport { // Handle errors - show toast to user FIRST before anything else if (chunk.type === "error") { + soundManager.play("error") // Track error in Sentry const category = chunk.debugInfo?.category || "UNKNOWN" Sentry.captureException( diff --git a/src/renderer/features/agents/lib/queue-utils.ts b/src/renderer/features/agents/lib/queue-utils.ts new file mode 100644 index 00000000..37997612 --- /dev/null +++ b/src/renderer/features/agents/lib/queue-utils.ts @@ -0,0 +1,130 @@ +/** + * Queue utilities for managing message queue in agents chat + * Adapted from canvas chat queue implementation + */ + +import type { UploadedImage, UploadedFile } from "../hooks/use-agents-file-upload" + +export interface QueuedImage { + id: string + url: string + mediaType: string + filename?: string + base64Data?: string +} + +export interface QueuedFile { + id: string + url: string + filename: string + mediaType?: string + size?: number +} + +// Text context selected from assistant messages +export interface SelectedTextContext { + id: string + text: string + sourceMessageId: string + preview: string // Truncated for display (~50 chars) + createdAt: Date +} + +export interface QueuedTextContext { + id: string + text: string + sourceMessageId: string +} + +export type AgentQueueItem = { + id: string + message: string // Serialized value with @[id] tokens for mentions + images?: QueuedImage[] + files?: QueuedFile[] + textContexts?: QueuedTextContext[] + timestamp: Date + status: "pending" | "processing" +} + +export function generateQueueId(): string { + return `queue_${Date.now()}_${Math.random().toString(36).substring(2, 11)}` +} + +export function createQueueItem( + id: string, + message: string, + images?: QueuedImage[], + files?: QueuedFile[], + textContexts?: QueuedTextContext[] +): AgentQueueItem { + return { + id, + message, + images: images && images.length > 0 ? images : undefined, + files: files && files.length > 0 ? files : undefined, + textContexts: textContexts && textContexts.length > 0 ? textContexts : undefined, + timestamp: new Date(), + status: "pending", + } +} + +export function getNextQueueItem( + queue: AgentQueueItem[] +): AgentQueueItem | null { + return queue.find((item) => item.status === "pending") || null +} + +export function removeQueueItem( + queue: AgentQueueItem[], + itemId: string +): AgentQueueItem[] { + return queue.filter((item) => item.id !== itemId) +} + +export function updateQueueItemStatus( + queue: AgentQueueItem[], + itemId: string, + status: AgentQueueItem["status"] +): AgentQueueItem[] { + return queue.map((item) => + item.id === itemId ? { ...item, status } : item + ) +} + +// Helper to convert UploadedImage to QueuedImage +export function toQueuedImage(img: UploadedImage): QueuedImage { + return { + id: img.id, + url: img.url, + mediaType: img.mediaType || "image/png", + filename: img.filename, + base64Data: img.base64Data, + } +} + +// Helper to convert UploadedFile to QueuedFile +export function toQueuedFile(file: UploadedFile): QueuedFile { + return { + id: file.id, + url: file.url, + filename: file.filename, + mediaType: file.type, + size: file.size, + } +} + +// Helper to convert SelectedTextContext to QueuedTextContext +export function toQueuedTextContext(ctx: SelectedTextContext): QueuedTextContext { + return { + id: ctx.id, + text: ctx.text, + sourceMessageId: ctx.sourceMessageId, + } +} + +// Helper to create a truncated preview from text +export function createTextPreview(text: string, maxLength: number = 50): string { + const trimmed = text.trim().replace(/\s+/g, " ") + if (trimmed.length <= maxLength) return trimmed + return trimmed.slice(0, maxLength) + "..." +} diff --git a/src/renderer/features/agents/main/active-chat.tsx b/src/renderer/features/agents/main/active-chat.tsx index 4d1b4346..3f94c0d0 100644 --- a/src/renderer/features/agents/main/active-chat.tsx +++ b/src/renderer/features/agents/main/active-chat.tsx @@ -16,6 +16,7 @@ import { } from "../../../components/ui/dropdown-menu" import { AgentIcon, + AskIcon, AttachIcon, CheckIcon, ClaudeCodeIcon, @@ -51,7 +52,10 @@ import { Chat, useChat } from "@ai-sdk/react" import { DiffModeEnum } from "@git-diff-view/react" import { atom, useAtom, useAtomValue, useSetAtom } from "jotai" import { + Bell, + BellOff, ChevronDown, + Code, Columns2, Eye, GitCommitHorizontal, @@ -64,6 +68,7 @@ import { import { motion } from "motion/react" import { createContext, + memo, useCallback, useContext, useEffect, @@ -76,15 +81,16 @@ import { createPortal } from "react-dom" import { toast } from "sonner" import { trackMessageSent } from "../../../lib/analytics" import { apiFetch } from "../../../lib/api-fetch" -import { soundNotificationsEnabledAtom } from "../../../lib/atoms" +import { soundNotificationsEnabledAtom, notificationModeAtom } from "../../../lib/atoms" import { appStore } from "../../../lib/jotai-store" import { api } from "../../../lib/mock-api" import { trpc, trpcClient } from "../../../lib/trpc" import { getQueryClient } from "../../../contexts/TRPCProvider" import { cn } from "../../../lib/utils" import { getShortcutKey, isDesktopApp } from "../../../lib/utils/platform" -import { terminalSidebarOpenAtom } from "../../terminal/atoms" +import { terminalSidebarOpenAtom, openClaudeCodeAtom } from "../../terminal/atoms" import { TerminalSidebar } from "../../terminal/terminal-sidebar" +import { rightPanelOpenAtom } from "../../right-panel/atoms" import { agentsDiffSidebarWidthAtom, agentsPreviewSidebarOpenAtom, @@ -93,13 +99,17 @@ import { agentsSubChatsSidebarModeAtom, agentsSubChatUnseenChangesAtom, agentsUnseenChangesAtom, + chatWaitingForUserAtom, clearLoading, compactingSubChatsAtom, + lastCompactInfoAtom, diffSidebarOpenAtomFamily, - isPlanModeAtom, + chatModeAtom, + type ChatMode, justCreatedIdsAtom, lastSelectedModelIdAtom, loadingSubChatsAtom, + nagMutedAtom, pendingAuthRetryMessageAtom, pendingPrMessageAtom, pendingReviewMessageAtom, @@ -124,6 +134,7 @@ import { PreviewSetupHoverCard } from "../components/preview-setup-hover-card" import { useAgentsFileUpload } from "../hooks/use-agents-file-upload" import { useChangedFilesTracking } from "../hooks/use-changed-files-tracking" import { useDesktopNotifications } from "../hooks/use-desktop-notifications" +import { useToolNotifications } from "../hooks/use-tool-notifications" import { useFocusInputOnEnter } from "../hooks/use-focus-input-on-enter" import { useHaptic } from "../hooks/use-haptic" import { useToggleFocusOnCmdEsc } from "../hooks/use-toggle-focus-on-cmd-esc" @@ -153,6 +164,7 @@ import { AgentExitPlanModeTool } from "../ui/agent-exit-plan-mode-tool" import { AgentExploringGroup } from "../ui/agent-exploring-group" import { AgentFileItem } from "../ui/agent-file-item" import { AgentImageItem } from "../ui/agent-image-item" +import { AgentTextContextItem } from "../ui/agent-text-context-item" import { AgentMessageUsage, type AgentMessageMetadata, @@ -174,6 +186,7 @@ import { MobileChatHeader } from "../ui/mobile-chat-header" import { PrStatusBar } from "../ui/pr-status-bar" import { SubChatSelector } from "../ui/sub-chat-selector" import { SubChatStatusCard } from "../ui/sub-chat-status-card" +import { MessagePartRenderer } from "../ui/message-part-renderer" import { autoRenameAgentChat } from "../utils/auto-rename" import { handlePasteEvent } from "../utils/paste-text" import { generateCommitToPrMessage, generatePrMessage, generateReviewMessage } from "../utils/pr-message" @@ -182,7 +195,27 @@ import { clearSubChatDraft, getSubChatDraft, } from "../lib/drafts" -const clearSubChatSelectionAtom = atom(null, () => {}) + +// Search feature +import { + ChatSearchBar, + SearchHighlightProvider, + toggleSearchAtom, + chatSearchCurrentMatchAtom, +} from "../search" + +// Text selection context +import { TextSelectionProvider } from "../context/text-selection-context" +import { TextSelectionPopover } from "../ui/text-selection-popover" +import { useTextContextSelection } from "../hooks/use-text-context-selection" +import type { SelectedTextContext } from "../lib/queue-utils" + +// Message queue +import { useMessageQueueStore, EMPTY_QUEUE } from "../stores/message-queue-store" + +// Streaming status store (for queue processing) +import { useStreamingStatusStore } from "../stores/streaming-status-store" +const clearSubChatSelectionAtom = atom(null, () => { }) const isSubChatMultiSelectModeAtom = atom(false) const selectedSubChatIdsAtom = atom(new Set()) // import { selectedTeamIdAtom } from "@/lib/atoms/team" @@ -230,6 +263,13 @@ function groupExploringTools(parts: any[], nestedToolIds: Set): any[] { return result } +// Motion animation constants - extracted to module level to prevent object recreation +const FADE_IN_ANIMATION = { + initial: { opacity: 0 }, + animate: { opacity: 1 }, + transition: { duration: 0.1, ease: "easeOut" }, +} as const + // Get the ID of the first sub-chat by creation date function getFirstSubChatId( subChats: @@ -701,6 +741,42 @@ function PlayButton({ ) } +// Bell button to show nag status and allow muting +function NagBellButton({ isMobile = false }: { isMobile?: boolean }) { + const pendingQuestions = useAtomValue(pendingUserQuestionsAtom) + const chatWaiting = useAtomValue(chatWaitingForUserAtom) + const [isMuted, setMuted] = useAtom(nagMutedAtom) + + const isNagging = !!pendingQuestions || !!chatWaiting + + // Don't render if not nagging + if (!isNagging) return null + + const handleClick = () => { + setMuted(!isMuted) + } + + return ( + + ) +} + // Message group wrapper - measures user message height for sticky todo positioning interface MessageGroupProps { children: React.ReactNode @@ -741,6 +817,162 @@ function MessageGroup({ children }: MessageGroupProps) { ) } +// Mode icons lookup +const MODE_ICONS: Record = { + agent: AgentIcon, + plan: PlanIcon, + ask: AskIcon, +} + +// Mode labels +const MODE_LABELS: Record = { + agent: "Agent", + plan: "Plan", + ask: "Ask", +} + +// Mode tooltips +const MODE_TOOLTIPS: Record = { + agent: "Apply changes directly without a plan", + plan: "Create a plan before making changes", + ask: "Answer questions without code changes", +} + +// Memoized Mode Dropdown component - extracted to prevent re-renders on hover +// When tooltip state changes, only this component re-renders, not the entire message list +const ModeDropdown = memo(function ModeDropdown({ + mode, + setMode, +}: { + mode: ChatMode + setMode: (value: ChatMode) => void +}) { + const [modeDropdownOpen, setModeDropdownOpen] = useState(false) + const [modeTooltip, setModeTooltip] = useState<{ + visible: boolean + position: { top: number; left: number } + mode: ChatMode + } | null>(null) + const tooltipTimeoutRef = useRef | null>(null) + const hasShownTooltipRef = useRef(false) + + const ModeIcon = MODE_ICONS[mode] + + const handleModeSelect = (newMode: ChatMode) => { + if (tooltipTimeoutRef.current) { + clearTimeout(tooltipTimeoutRef.current) + tooltipTimeoutRef.current = null + } + setModeTooltip(null) + setMode(newMode) + setModeDropdownOpen(false) + } + + const handleMouseEnter = (e: React.MouseEvent, targetMode: ChatMode) => { + if (tooltipTimeoutRef.current) { + clearTimeout(tooltipTimeoutRef.current) + tooltipTimeoutRef.current = null + } + const rect = e.currentTarget.getBoundingClientRect() + const showTooltip = () => { + setModeTooltip({ + visible: true, + position: { + top: rect.top, + left: rect.right + 8, + }, + mode: targetMode, + }) + hasShownTooltipRef.current = true + tooltipTimeoutRef.current = null + } + if (hasShownTooltipRef.current) { + showTooltip() + } else { + tooltipTimeoutRef.current = setTimeout(showTooltip, 1000) + } + } + + const handleMouseLeave = () => { + if (tooltipTimeoutRef.current) { + clearTimeout(tooltipTimeoutRef.current) + tooltipTimeoutRef.current = null + } + setModeTooltip(null) + } + + return ( + { + setModeDropdownOpen(open) + if (!open) { + if (tooltipTimeoutRef.current) { + clearTimeout(tooltipTimeoutRef.current) + tooltipTimeoutRef.current = null + } + setModeTooltip(null) + hasShownTooltipRef.current = false + } + }} + > + + + + e.preventDefault()} + > + {(["agent", "plan", "ask"] as const).map((modeOption) => { + const Icon = MODE_ICONS[modeOption] + return ( + handleModeSelect(modeOption)} + className="justify-between gap-2" + onMouseEnter={(e) => handleMouseEnter(e, modeOption)} + onMouseLeave={handleMouseLeave} + > +
+ + {MODE_LABELS[modeOption]} +
+ {mode === modeOption && ( + + )} +
+ ) + })} +
+ {modeTooltip?.visible && + createPortal( +
+
+ {MODE_TOOLTIPS[modeTooltip.mode]} +
+
, + document.body, + )} +
+ ) +}) + // Collapsible steps component for intermediate content before final response interface CollapsibleStepsProps { stepsCount: number @@ -818,6 +1050,11 @@ function ChatViewInner({ projectPath, isArchived = false, onRestoreWorkspace, + dbSessionId, + subChatModelId, + onModelChange, + textContexts, + removeTextContext, }: { chat: Chat subChatId: string @@ -837,6 +1074,11 @@ function ChatViewInner({ projectPath?: string isArchived?: boolean onRestoreWorkspace?: () => void + dbSessionId?: string + subChatModelId?: string | null + onModelChange?: (modelId: "opus" | "sonnet" | "haiku") => void + textContexts: SelectedTextContext[] + removeTextContext: (id: string) => void }) { // UNCONTROLLED: just track if editor has content for send button const [hasContent, setHasContent] = useState(false) @@ -963,8 +1205,15 @@ function ChatViewInner({ [subChatId, subChatName, renameSubChatMutation], ) - // Plan mode state (read from global atom) - const [isPlanMode, setIsPlanMode] = useAtom(isPlanModeAtom) + // Chat mode state (read from global atom) + const [chatMode, setChatMode] = useAtom(chatModeAtom) + + // Terminal sidebar control for "Open in Claude Code" button + const setTerminalSidebarOpen = useSetAtom(terminalSidebarOpenAtom) + const setOpenClaudeCode = useSetAtom(openClaudeCodeAtom) + + // Chat search toggle (CMD+F) + const toggleSearch = useSetAtom(toggleSearchAtom) // Mutation for updating sub-chat mode in database const updateSubChatModeMutation = api.agents.updateSubChatMode.useMutation({ @@ -984,15 +1233,14 @@ function ChatViewInner({ const subChat = useAgentSubChatStore .getState() .allSubChats.find((sc) => sc.id === variables.subChatId) - if (subChat) { - // Revert to previous mode - const revertedMode = variables.mode === "plan" ? "agent" : "plan" + if (subChat && subChat.mode) { + // Revert to previous mode from store useAgentSubChatStore .getState() - .updateSubChatMode(variables.subChatId, revertedMode) - // Update ref BEFORE setIsPlanMode to prevent useEffect from triggering - lastIsPlanModeRef.current = revertedMode === "plan" - setIsPlanMode(revertedMode === "plan") + .updateSubChatMode(variables.subChatId, subChat.mode) + // Update ref BEFORE setChatMode to prevent useEffect from triggering + lastChatModeRef.current = subChat.mode + setChatMode(subChat.mode) } console.error("Failed to update sub-chat mode:", error.message) }, @@ -1009,48 +1257,60 @@ function ChatViewInner({ .allSubChats.find((sc) => sc.id === subChatId) if (subChat?.mode) { - setIsPlanMode(subChat.mode === "plan") + setChatMode(subChat.mode) } lastInitializedRef.current = subChatId } - // Dependencies: Only subChatId - setIsPlanMode is stable, useAgentSubChatStore is external - }, [subChatId, setIsPlanMode]) + // Dependencies: Only subChatId - setChatMode is stable, useAgentSubChatStore is external + }, [subChatId, setChatMode]) // Track last mode to detect actual user changes (not store updates) - const lastIsPlanModeRef = useRef(isPlanMode) + const lastChatModeRef = useRef(chatMode) - // Update mode for current sub-chat when USER changes isPlanMode + // Update mode for current sub-chat when USER changes chatMode useEffect(() => { - // Skip if isPlanMode didn't actually change - if (lastIsPlanModeRef.current === isPlanMode) { + // Skip if chatMode didn't actually change + if (lastChatModeRef.current === chatMode) { return } - const newMode = isPlanMode ? "plan" : "agent" - - lastIsPlanModeRef.current = isPlanMode + lastChatModeRef.current = chatMode if (subChatId) { // Update local store immediately (optimistic update) - useAgentSubChatStore.getState().updateSubChatMode(subChatId, newMode) + useAgentSubChatStore.getState().updateSubChatMode(subChatId, chatMode) // Save to database with error handling to maintain consistency if (!subChatId.startsWith("temp-")) { - updateSubChatModeMutation.mutate({ subChatId, mode: newMode }) + updateSubChatModeMutation.mutate({ subChatId, mode: chatMode }) } } // Dependencies: updateSubChatModeMutation.mutate is stable, useAgentSubChatStore is external - }, [isPlanMode, subChatId, updateSubChatModeMutation.mutate]) + }, [chatMode, subChatId, updateSubChatModeMutation.mutate]) - // Model selection state + // Model selection state - uses per-sub-chat model from DB, falls back to global default const [lastSelectedModelId, setLastSelectedModelId] = useAtom( lastSelectedModelIdAtom, ) const [selectedAgent, setSelectedAgent] = useState(() => agents[0]) - const [selectedModel, setSelectedModel] = useState( - () => - claudeModels.find((m) => m.id === lastSelectedModelId) || claudeModels[1], - ) + // Initialize from sub-chat's saved model ONLY - don't fallback to global here + // Sync effect below will handle updating if subChatModelId arrives later + const [selectedModel, setSelectedModel] = useState(() => { + if (!subChatModelId) return claudeModels[1] // Temporary fallback until sync effect runs + const model = claudeModels.find((m) => m.id === subChatModelId) + return model || claudeModels[1] + }) + + // Sync selectedModel UI state when subChatModelId changes + // Per-sub-chat model takes priority, fall back to global if no per-sub-chat model set + // This also happens on first render if subChatModelId arrives after component mounts + useEffect(() => { + const modelId = subChatModelId || lastSelectedModelId + const model = claudeModels.find((m) => m.id === modelId) + if (model && model.id !== selectedModel?.id) { + setSelectedModel(model) + } + }, [subChatModelId, lastSelectedModelId, selectedModel?.id]) const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false) const [shouldOpenClaudeSubmenu, setShouldOpenClaudeSubmenu] = useState(false) @@ -1099,18 +1359,40 @@ function ChatViewInner({ return () => window.removeEventListener("keydown", handleKeyDown, true) }, []) - // Mode tooltip state (floating tooltip like canvas) - const [modeTooltip, setModeTooltip] = useState<{ - visible: boolean - position: { top: number; left: number } - mode: "agent" | "plan" - } | null>(null) + // Keyboard shortcut: Cmd+F to toggle chat search + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "f") { + e.preventDefault() + e.stopPropagation() + toggleSearch() + } + } + + window.addEventListener("keydown", handleKeyDown, true) + return () => window.removeEventListener("keydown", handleKeyDown, true) + }, [toggleSearch]) + + // Scroll to current search match when it changes + const currentSearchMatch = useAtomValue(chatSearchCurrentMatchAtom) + useEffect(() => { + if (!currentSearchMatch) return + const container = chatContainerRef.current + if (!container) return + + // Find the message element by data attribute (user or assistant) + const msgEl = container.querySelector( + `[data-user-message-id="${currentSearchMatch.messageId}"], [data-assistant-message-id="${currentSearchMatch.messageId}"]` + ) + if (msgEl) { + msgEl.scrollIntoView({ behavior: "smooth", block: "center" }) + } + }, [currentSearchMatch]) + + // Plan approval state const [planApprovalPending, setPlanApprovalPending] = useState< Record >({}) - const tooltipTimeoutRef = useRef | null>(null) - const hasShownTooltipRef = useRef(false) - const [modeDropdownOpen, setModeDropdownOpen] = useState(false) // Track chat changes for rename trigger reset const chatRef = useRef | null>(null) @@ -1205,12 +1487,25 @@ function ChatViewInner({ }, [subChatId, parentChatId]) // Use subChatId as stable key to prevent HMR-induced duplicate resume requests - // resume: !!streamId to reconnect to active streams (background streaming support) + // Only attempt resume when the chat is idle; otherwise it can clobber the active request + // (and break stop/cancel) if `streamId` updates during an in-flight stream. + const lastResumeStreamIdRef = useRef(null) + const shouldResumeStream = + !!streamId && + chat.status === "ready" && + streamId !== lastResumeStreamIdRef.current + + useEffect(() => { + if (shouldResumeStream) { + lastResumeStreamIdRef.current = streamId || null + } + }, [shouldResumeStream, streamId]) + const { messages, sendMessage, status, stop, regenerate } = useChat({ id: subChatId, chat, - resume: !!streamId, - // experimental_throttle: 200, + resume: shouldResumeStream, + experimental_throttle: 200, }) // Stream debug: log status changes and scroll to plan/response start when streaming finishes @@ -1258,9 +1553,39 @@ function ChatViewInner({ const isStreaming = status === "streaming" || status === "submitted" + // Sync streaming status to global store (for queue processing) + const setStreamingStatus = useStreamingStatusStore((s) => s.setStatus) + useEffect(() => { + setStreamingStatus(subChatId, status) + }, [subChatId, status, setStreamingStatus]) + + const handleStopStream = useCallback(async () => { + if (!isStreaming) return + agentChatStore.setManuallyAborted(subChatId, true) + + // Cancel server-side stream too (covers edge cases where the local abort controller is stale) + try { + trpcClient.claude.cancel.mutate({ subChatId }) + } catch { + // Ignore + } + + await stop() + }, [isStreaming, stop, subChatId]) + + // Extract sessionId from last assistant message for CLI handoff + // Fallback to database sessionId for older chats that don't have it in message metadata + const sessionId = useMemo(() => { + const lastAssistantMessage = [...messages].reverse().find((m) => m.role === "assistant") + const sid = (lastAssistantMessage as any)?.metadata?.sessionId as string | undefined + return sid || dbSessionId + }, [messages, dbSessionId]) + // Track compacting status from SDK const compactingSubChats = useAtomValue(compactingSubChatsAtom) const isCompacting = compactingSubChats.has(subChatId) + const lastCompactInfoBySubChat = useAtomValue(lastCompactInfoAtom) + const lastCompactInfo = lastCompactInfoBySubChat[subChatId] // Handler to trigger manual context compaction const handleCompact = useCallback(() => { @@ -1271,6 +1596,21 @@ function ChatViewInner({ }) }, [isStreaming, sendMessage]) + // Handler to open chat in Claude Code CLI + const handleOpenInClaudeCode = useCallback(() => { + console.log('[CLAUDE-CODE] Button clicked', { sessionId, subChatId, parentChatId }) + if (!sessionId) { + console.warn('[CLAUDE-CODE] No sessionId available') + return + } + // Signal to terminal sidebar to create Claude Code session + setOpenClaudeCode({ subChatId, sessionId, chatId: parentChatId }) + console.log('[CLAUDE-CODE] Set openClaudeCode atom signal') + // Open the terminal sidebar + setTerminalSidebarOpen(true) + console.log('[CLAUDE-CODE] Opened terminal sidebar') + }, [sessionId, subChatId, parentChatId, setOpenClaudeCode, setTerminalSidebarOpen]) + // Keep refs updated for scroll save cleanup to use useEffect(() => { currentStatusRef.current = status @@ -1553,14 +1893,14 @@ function ChatViewInner({ useAgentSubChatStore.getState().updateSubChatMode(subChatId, "agent") // Update React state (for UI) - setIsPlanMode(false) + setChatMode("agent") // Send "Implement plan" message (now in agent mode) sendMessage({ role: "user", parts: [{ type: "text", text: "Implement plan" }], }) - }, [subChatId, setIsPlanMode, sendMessage]) + }, [subChatId, setChatMode, sendMessage]) // Detect PR URLs in assistant messages and store them const detectedPrUrlRef = useRef(null) @@ -1669,20 +2009,13 @@ function ChatViewInner({ if (shouldStop) { e.preventDefault() - // Mark as manually aborted to prevent completion sound - agentChatStore.setManuallyAborted(subChatId, true) - await stop() - // Call DELETE endpoint to cancel server-side stream - await fetch(`/api/agents/chat?id=${encodeURIComponent(subChatId)}`, { - method: "DELETE", - credentials: "include", - }) + await handleStopStream() } } window.addEventListener("keydown", handleKeyDown) return () => window.removeEventListener("keydown", handleKeyDown) - }, [isStreaming, stop, subChatId]) + }, [handleStopStream, isStreaming]) // Keyboard shortcut: Enter to focus input when not already focused useFocusInputOnEnter(editorRef) @@ -1990,7 +2323,7 @@ function ChatViewInner({ trackMessageSent({ workspaceId: subChatId, messageLength: text.length, - mode: isPlanMode ? "plan" : "agent", + mode: chatMode, }) // Trigger auto-rename on first message in a new sub-chat @@ -2150,19 +2483,29 @@ function ChatViewInner({ } break case "plan": - if (!isPlanMode) { - setIsPlanMode(true) + if (chatMode !== "plan") { + setChatMode("plan") } break case "agent": - if (isPlanMode) { - setIsPlanMode(false) + if (chatMode !== "agent") { + setChatMode("agent") + } + break + case "ask": + if (chatMode !== "ask") { + setChatMode("ask") } break case "compact": // Trigger context compaction handleCompact() break + case "context": + // Send /context to Claude Code to get real context breakdown + editorRef.current?.setValue("/context") + setTimeout(() => handleSend(), 0) + break // Prompt-based commands - auto-send to agent case "review": case "pr-comments": @@ -2187,7 +2530,7 @@ function ChatViewInner({ setTimeout(() => handleSend(), 0) } }, - [isPlanMode, setIsPlanMode, handleSend, onCreateNewSubChat, handleCompact], + [chatMode, setChatMode, handleSend, onCreateNewSubChat, handleCompact], ) // Paste handler for images and plain text @@ -2353,8 +2696,83 @@ function ChatViewInner({ return groups }, [messages]) + // Pre-compute nested tools data for all assistant messages (perf optimization) + // This avoids recomputing inside the map callback on every render + const messageNestedData = useMemo(() => { + const result = new Map< + string, + { + nestedToolsMap: Map + nestedToolIds: Set + taskPartIds: Set + orphanTaskGroups: Map + orphanToolCallIds: Set + orphanFirstToolCallIds: Set + } + >() + + for (const group of messageGroups) { + for (const assistantMsg of group.assistantMsgs) { + const nestedToolsMap = new Map() + const nestedToolIds = new Set() + const taskPartIds = new Set( + (assistantMsg.parts || []) + .filter((p: any) => p.type === "tool-Task" && p.toolCallId) + .map((p: any) => p.toolCallId), + ) + const orphanTaskGroups = new Map< + string, + { parts: any[]; firstToolCallId: string } + >() + const orphanToolCallIds = new Set() + const orphanFirstToolCallIds = new Set() + + for (const part of assistantMsg.parts || []) { + if (part.toolCallId?.includes(":")) { + const parentId = part.toolCallId.split(":")[0] + if (taskPartIds.has(parentId)) { + if (!nestedToolsMap.has(parentId)) { + nestedToolsMap.set(parentId, []) + } + nestedToolsMap.get(parentId)!.push(part) + nestedToolIds.add(part.toolCallId) + } else { + let grp = orphanTaskGroups.get(parentId) + if (!grp) { + grp = { + parts: [], + firstToolCallId: part.toolCallId, + } + orphanTaskGroups.set(parentId, grp) + orphanFirstToolCallIds.add(part.toolCallId) + } + grp.parts.push(part) + orphanToolCallIds.add(part.toolCallId) + } + } + } + + result.set(assistantMsg.id, { + nestedToolsMap, + nestedToolIds, + taskPartIds, + orphanTaskGroups, + orphanToolCallIds, + orphanFirstToolCallIds, + }) + } + } + + return result + }, [messageGroups]) + return ( <> + {/* Chat Search Bar (CMD+F) */} + {/* Chat title - flex above scroll area (desktop only) */} {!isMobile && (
- {/* Attachments - NOT sticky, scroll normally */} - {imageParts.length > 0 && ( - - - - )} - {/* User message text - sticky WITHIN this group */} - {/* z-10 ensures user message stays above scrolling content (tool calls, buttons) */} -
div]:!mb-4 pointer-events-auto", - "sticky z-10", - isMobile - ? CHAT_LAYOUT.stickyTopMobile - : isSubChatsSidebarOpen - ? CHAT_LAYOUT.stickyTopSidebarOpen - : CHAT_LAYOUT.stickyTopSidebarClosed, - )} - > - 0 && ( + + + + )} + {/* User message text - sticky WITHIN this group */} + {/* z-10 ensures user message stays above scrolling content (tool calls, buttons) */} +
div]:!mb-4 pointer-events-auto", + "sticky z-10", + isMobile + ? CHAT_LAYOUT.stickyTopMobile + : isSubChatsSidebarOpen + ? CHAT_LAYOUT.stickyTopSidebarOpen + : CHAT_LAYOUT.stickyTopSidebarClosed, + )} + > + + {/* Cloning indicator - shown while sandbox is being set up */} + {shouldShowCloning && ( +
+ - {/* Cloning indicator - shown while sandbox is being set up */} - {shouldShowCloning && ( -
- -
- )} - {/* Setup error with retry */} - {shouldShowSetupError && ( -
-
- - Failed to set up sandbox - {sandboxSetupError ? `: ${sandboxSetupError}` : ""} - - {onRetrySetup && ( - - )} -
-
- )}
+ )} + {/* Setup error with retry */} + {shouldShowSetupError && ( +
+
+ + Failed to set up sandbox + {sandboxSetupError ? `: ${sandboxSetupError}` : ""} + + {onRetrySetup && ( + + )} +
+
+ )} +
- {/* Assistant messages in this group */} - {group.assistantMsgs.map((assistantMsg) => { - const isLastMessage = - assistantMsg.id === messages[messages.length - 1]?.id + {/* Assistant messages in this group */} + {group.assistantMsgs.map((assistantMsg) => { + const isLastMessage = + assistantMsg.id === messages[messages.length - 1]?.id // Assistant message - flat layout, no bubble (like Canvas) const contentParts = @@ -2504,47 +2920,22 @@ function ChatViewInner({ (p: any) => p.type === "text" && p.text?.trim(), ) - // Build map of nested tools per parent Task - const nestedToolsMap = new Map() - const nestedToolIds = new Set() - const taskPartIds = new Set( - (assistantMsg.parts || []) - .filter( - (p: any) => p.type === "tool-Task" && p.toolCallId, - ) - .map((p: any) => p.toolCallId), - ) - const orphanTaskGroups = new Map< - string, - { parts: any[]; firstToolCallId: string } - >() - const orphanToolCallIds = new Set() - const orphanFirstToolCallIds = new Set() - - for (const part of assistantMsg.parts || []) { - if (part.toolCallId?.includes(":")) { - const parentId = part.toolCallId.split(":")[0] - if (taskPartIds.has(parentId)) { - if (!nestedToolsMap.has(parentId)) { - nestedToolsMap.set(parentId, []) - } - nestedToolsMap.get(parentId)!.push(part) - nestedToolIds.add(part.toolCallId) - } else { - let group = orphanTaskGroups.get(parentId) - if (!group) { - group = { - parts: [], - firstToolCallId: part.toolCallId, - } - orphanTaskGroups.set(parentId, group) - orphanFirstToolCallIds.add(part.toolCallId) - } - group.parts.push(part) - orphanToolCallIds.add(part.toolCallId) - } - } + // Get pre-computed nested tools data (computed in useMemo above) + const nestedData = messageNestedData.get(assistantMsg.id) || { + nestedToolsMap: new Map(), + nestedToolIds: new Set(), + taskPartIds: new Set(), + orphanTaskGroups: new Map(), + orphanToolCallIds: new Set(), + orphanFirstToolCallIds: new Set(), } + const { + nestedToolsMap, + nestedToolIds, + orphanTaskGroups, + orphanToolCallIds, + orphanFirstToolCallIds, + } = nestedData // Get metadata for usage display const msgMetadata = @@ -2618,284 +3009,12 @@ function ChatViewInner({ return true }).length - // Helper function to render a single part - const renderPart = ( - part: any, - idx: number, - isFinal = false, - ) => { - // Skip step-start parts - if (part.type === "step-start") { - return null - } - - // Skip TaskOutput - internal tool with meta info not useful for UI - if (part.type === "tool-TaskOutput") { - return null - } - - if ( - part.toolCallId && - orphanToolCallIds.has(part.toolCallId) - ) { - if (!orphanFirstToolCallIds.has(part.toolCallId)) { - return null - } - const parentId = part.toolCallId.split(":")[0] - const group = orphanTaskGroups.get(parentId) - if (group) { - return ( - - ) - } - } - - // Skip nested tools - they're rendered within their parent Task - if ( - part.toolCallId && - nestedToolIds.has(part.toolCallId) - ) { - return null - } - - // Exploring group - grouped Read/Grep/Glob tools - // NOTE: isGroupStreaming is calculated in the map() call below - // because we need to know if this is the last element - if (part.type === "exploring-group") { - return null // Handled separately in map with isLast info - } - - // Text parts - with px-2 like Canvas - if (part.type === "text") { - if (!part.text?.trim()) return null - // Check if this is the final text by comparing index (parts don't have IDs) - const isFinalText = isFinal && idx === finalTextIndex - - return ( -
0 && - "pt-3 border-t border-border/50", - )} - > - {/* Only show Summary label if there are steps to collapse */} - {isFinalText && visibleStepsCount > 0 && ( -
- Response -
- )} - -
- ) - } - - // Special handling for tool-Task - render with nested tools - if (part.type === "tool-Task") { - const nestedTools = - nestedToolsMap.get(part.toolCallId) || [] - return ( - - ) - } - - // Special handling for tool-Bash - render with full command and output - if (part.type === "tool-Bash") { - return ( - - ) - } - - // Special handling for tool-Thinking - Extended Thinking - if (part.type === "tool-Thinking") { - return ( - - ) - } - - // Special handling for tool-Edit - render with file icon and diff stats - if (part.type === "tool-Edit") { - return ( - - ) - } - - // Special handling for tool-Write - render with file preview (reuses AgentEditTool) - if (part.type === "tool-Write") { - return ( - - ) - } - - // Special handling for tool-WebSearch - collapsible results list - if (part.type === "tool-WebSearch") { - return ( - - ) - } - - // Special handling for tool-WebFetch - expandable content preview - if (part.type === "tool-WebFetch") { - return ( - - ) - } - - // Special handling for tool-PlanWrite - plan with steps - if (part.type === "tool-PlanWrite") { - return ( - - ) - } - - // Special handling for tool-ExitPlanMode - show simple indicator inline - // Full plan card is rendered at end of message - if (part.type === "tool-ExitPlanMode") { - const { isPending, isError } = getToolStatus( - part, - status, - ) - return ( - - ) - } - - // Special handling for tool-TodoWrite - todo list with progress - // All todos render inline - sticky behavior is handled by IntersectionObserver - if (part.type === "tool-TodoWrite") { - return ( - - ) - } - - // Special handling for tool-AskUserQuestion - if (part.type === "tool-AskUserQuestion") { - const { isPending, isError } = getToolStatus( - part, - status, - ) - return ( - - ) - } - - // Tool parts - check registry - if (part.type in AgentToolRegistry) { - const meta = AgentToolRegistry[part.type] - const { isPending, isError } = getToolStatus( - part, - status, - ) - return ( - - ) - } - - // Fallback for unknown tool types - if (part.type?.startsWith("tool-")) { - return ( -
- {part.type.replace("tool-", "")} -
- ) - } - - return null - } - return (
{/* Collapsible steps section - show when we have final text OR a plan */} @@ -2921,7 +3040,25 @@ function ChatViewInner({ /> ) } - return renderPart(part, idx, false) + return ( + + ) }) })()} @@ -2948,10 +3085,24 @@ function ChatViewInner({ /> ) } - return renderPart( - part, - hasFinalText ? finalTextIndex + idx : idx, - hasFinalText, + return ( + ) }) })()} @@ -3005,6 +3156,7 @@ function ChatViewInner({ playbackRate={ttsPlaybackRate} onPlaybackRateChange={handlePlaybackRateChange} /> +
{/* Token usage info - right side */} - -
- )} + sandboxSetupStatus === "ready" && ( +
+ +
+ )} ) })} @@ -3066,19 +3218,7 @@ function ChatViewInner({ isCompacting={isCompacting} changedFiles={changedFilesForSubChat} worktreePath={projectPath} - onStop={async () => { - // Mark as manually aborted to prevent completion sound - agentChatStore.setManuallyAborted(subChatId, true) - await stop() - // Call DELETE endpoint to cancel server-side stream - await fetch( - `/api/agents/chat?id=${encodeURIComponent(subChatId)}`, - { - method: "DELETE", - credentials: "include", - }, - ) - }} + onStop={handleStopStream} />
@@ -3089,8 +3229,8 @@ function ChatViewInner({ className={cn( "px-2 pb-2 shadow-sm shadow-background relative z-10", (isStreaming || changedFilesForSubChat.length > 0) && - !(pendingQuestions?.subChatId === subChatId) && - "-mt-3 pt-3", + !(pendingQuestions?.subChatId === subChatId) && + "-mt-3 pt-3", )} >
@@ -3113,7 +3253,7 @@ function ChatViewInner({ maxHeight={200} onSubmit={handleSend} contextItems={ - images.length > 0 || files.length > 0 ? ( + images.length > 0 || files.length > 0 || textContexts.length > 0 ? (
{(() => { // Build allImages array for gallery navigation @@ -3149,6 +3289,14 @@ function ChatViewInner({ onRemove={() => removeFile(f.id)} /> ))} + {textContexts.map((ctx) => ( + removeTextContext(ctx.id)} + /> + ))}
) : null } @@ -3177,7 +3325,7 @@ function ChatViewInner({ onCloseSlashTrigger={handleCloseSlashTrigger} onContentChange={handleContentChange} onSubmit={handleSend} - onShiftTab={() => setIsPlanMode((prev) => !prev)} + onShiftTab={() => setChatMode((prev) => (prev === "plan" ? "agent" : "plan"))} placeholder="Plan, @ for context, / for commands" className={cn( "bg-transparent max-h-[200px] overflow-y-auto p-1", @@ -3190,173 +3338,8 @@ function ChatViewInner({
- {/* Mode toggle (Agent/Plan) */} - { - setModeDropdownOpen(open) - if (!open) { - if (tooltipTimeoutRef.current) { - clearTimeout(tooltipTimeoutRef.current) - tooltipTimeoutRef.current = null - } - setModeTooltip(null) - hasShownTooltipRef.current = false - } - }} - > - - - - e.preventDefault()} - > - { - // Clear tooltip before closing dropdown (onMouseLeave won't fire) - if (tooltipTimeoutRef.current) { - clearTimeout(tooltipTimeoutRef.current) - tooltipTimeoutRef.current = null - } - setModeTooltip(null) - setIsPlanMode(false) - setModeDropdownOpen(false) - }} - className="justify-between gap-2" - onMouseEnter={(e) => { - if (tooltipTimeoutRef.current) { - clearTimeout(tooltipTimeoutRef.current) - tooltipTimeoutRef.current = null - } - const rect = e.currentTarget.getBoundingClientRect() - const showTooltip = () => { - setModeTooltip({ - visible: true, - position: { - top: rect.top, - left: rect.right + 8, - }, - mode: "agent", - }) - hasShownTooltipRef.current = true - tooltipTimeoutRef.current = null - } - if (hasShownTooltipRef.current) { - showTooltip() - } else { - tooltipTimeoutRef.current = setTimeout( - showTooltip, - 1000, - ) - } - }} - onMouseLeave={() => { - if (tooltipTimeoutRef.current) { - clearTimeout(tooltipTimeoutRef.current) - tooltipTimeoutRef.current = null - } - setModeTooltip(null) - }} - > -
- - Agent -
- {!isPlanMode && ( - - )} -
- { - // Clear tooltip before closing dropdown (onMouseLeave won't fire) - if (tooltipTimeoutRef.current) { - clearTimeout(tooltipTimeoutRef.current) - tooltipTimeoutRef.current = null - } - setModeTooltip(null) - setIsPlanMode(true) - setModeDropdownOpen(false) - }} - className="justify-between gap-2" - onMouseEnter={(e) => { - if (tooltipTimeoutRef.current) { - clearTimeout(tooltipTimeoutRef.current) - tooltipTimeoutRef.current = null - } - const rect = e.currentTarget.getBoundingClientRect() - const showTooltip = () => { - setModeTooltip({ - visible: true, - position: { - top: rect.top, - left: rect.right + 8, - }, - mode: "plan", - }) - hasShownTooltipRef.current = true - tooltipTimeoutRef.current = null - } - if (hasShownTooltipRef.current) { - showTooltip() - } else { - tooltipTimeoutRef.current = setTimeout( - showTooltip, - 1000, - ) - } - }} - onMouseLeave={() => { - if (tooltipTimeoutRef.current) { - clearTimeout(tooltipTimeoutRef.current) - tooltipTimeoutRef.current = null - } - setModeTooltip(null) - }} - > -
- - Plan -
- {isPlanMode && ( - - )} -
-
- {modeTooltip?.visible && - createPortal( -
-
- - {modeTooltip.mode === "agent" - ? "Apply changes directly without a plan" - : "Create a plan before making changes"} - -
-
, - document.body, - )} -
+ {/* Mode toggle (Agent/Plan) - extracted to prevent hover re-renders */} + {/* Model selector */} { setSelectedModel(model) - setLastSelectedModelId(model.id) + // Update per-chat model in database (NOT the global atom) + // The global atom is only a fallback for chats without saved model + onModelChange?.(model.id as "opus" | "sonnet" | "haiku") }} className="gap-2 justify-between" > @@ -3402,6 +3387,23 @@ function ChatViewInner({ })} + + {/* Open in Claude Code button - only show when sessionId exists */} + {sessionId && ( + + + + + + Continue in Claude Code CLI + + + )}
@@ -3425,6 +3427,7 @@ function ChatViewInner({ onCompact={handleCompact} isCompacting={isCompacting} disabled={isStreaming} + lastCompactPreTokens={lastCompactInfo?.preTokens} /> {/* Attachment button */} @@ -3445,10 +3448,10 @@ function ChatViewInner({
{/* Show "Implement plan" button when plan is ready and input is empty */} {hasUnapprovedPlan && - !hasContent && - images.length === 0 && - files.length === 0 && - !isStreaming ? ( + !hasContent && + images.length === 0 && + files.length === 0 && + !isStreaming ? (
@@ -3531,7 +3525,7 @@ function ChatViewInner({ position={slashPosition} teamId={teamId} repository={repository} - isPlanMode={isPlanMode} + chatMode={chatMode} />
@@ -3564,7 +3558,7 @@ export function ChatView({ }) { const [selectedTeamId] = useAtom(selectedTeamIdAtom) const [selectedModelId] = useAtom(lastSelectedModelIdAtom) - const [isPlanMode] = useAtom(isPlanModeAtom) + const [chatMode] = useAtom(chatModeAtom) const setLoadingSubChats = useSetAtom(loadingSubChatsAtom) const unseenChanges = useAtomValue(agentsUnseenChangesAtom) const setUnseenChanges = useSetAtom(agentsUnseenChangesAtom) @@ -3574,6 +3568,15 @@ export function ChatView({ const setUndoStack = useSetAtom(undoStackAtom) const { notifyAgentComplete } = useDesktopNotifications() + // Text selection context hook + const { textContexts, addTextContext, removeTextContext, clearTextContexts } = useTextContextSelection() + + // Get active sub-chat ID for tracking purposes + const activeSubChatId = useAgentSubChatStore((state) => state.activeSubChatId) + + // Message queue for this sub-chat + const queue = useMessageQueueStore((s) => s.queues[activeSubChatId ?? ""] ?? EMPTY_QUEUE) + // Check if any chat has unseen changes const hasAnyUnseenChanges = unseenChanges.size > 0 const [, forceUpdate] = useState({}) @@ -3607,7 +3610,8 @@ export function ChatView({ Record >({}) const [diffMode, setDiffMode] = useAtom(diffViewModeAtom) - const subChatsSidebarMode = useAtomValue(agentsSubChatsSidebarModeAtom) + const [subChatsSidebarMode, setSubChatsSidebarMode] = useAtom(agentsSubChatsSidebarModeAtom) + const [isRightPanelOpen, setIsRightPanelOpen] = useAtom(rightPanelOpenAtom) // Track diff sidebar width for responsive header const storedDiffSidebarWidth = useAtomValue(agentsDiffSidebarWidthAtom) @@ -3681,8 +3685,28 @@ export function ChatView({ }) }, [chatId, setUnseenChanges]) - // Get sub-chat state from store - const activeSubChatId = useAgentSubChatStore((state) => state.activeSubChatId) + // Reset local state when chatId changes (since we removed key prop) + useEffect(() => { + // Reset diff state + setDiffStats({ + fileCount: 0, + additions: 0, + deletions: 0, + isLoading: true, + hasChanges: false, + }) + setDiffContent(null) + setParsedFileDiffs(null) + setPrefetchedFileContents({}) + + // Reset UI states owned by ChatView + // Note: ChatViewInner states (dropdowns etc) are reset because ChatViewInner has key={activeSubChatId} + setIsCreatingPr(false) + setIsReviewing(false) + setIsCommittingToPr(false) + + // Note: Diff stats fetching is handled by the existing useEffect on [worktreePath, sandboxId, chatId] + }, [chatId]) // Clear sub-chat "unseen changes" indicator when sub-chat becomes active useEffect(() => { @@ -3716,14 +3740,24 @@ export function ChatView({ { chatId }, { enabled: !!chatId }, ) + + + // Tool notifications (toast + activity feed) - listens for tool events via window events + useToolNotifications( + activeSubChatId || "", + agentChat?.name || "Agent", + ) + const agentSubChats = (agentChat?.subChats ?? []) as Array<{ id: string name?: string | null mode?: "plan" | "agent" | null + modelId?: "opus" | "sonnet" | "haiku" | null created_at?: Date | string | null updated_at?: Date | string | null messages?: any stream_id?: string | null + sessionId?: string | null }> // Get PR status when PR exists (for checking if it's open/merged/closed) @@ -3780,6 +3814,31 @@ export function ChatView({ restoreWorkspaceMutation.mutate({ id: chatId }) }, [chatId, restoreWorkspaceMutation]) + // Update sub-chat model mutation + const updateSubChatModelMutation = trpc.chats.updateSubChatModel.useMutation({ + onSuccess: () => { + // Invalidate to refresh chat data with new model + utils.agents.getAgentChat.invalidate({ chatId }) + }, + onError: (error) => { + toast.error("Failed to update model", { description: error.message }) + // Refetch to restore correct state in UI + utils.agents.getAgentChat.invalidate({ chatId }) + }, + }) + + const handleModelChange = useCallback( + (modelId: "opus" | "sonnet" | "haiku") => { + if (!activeSubChatId) return + + // Optimistically update local store so the next send uses the new model + useAgentSubChatStore.getState().updateSubChatModel(activeSubChatId, modelId) + + updateSubChatModelMutation.mutate({ id: activeSubChatId, modelId }) + }, + [activeSubChatId, updateSubChatModelMutation], + ) + // Check if this workspace is archived const isArchived = !!agentChat?.archivedAt @@ -4143,6 +4202,9 @@ export function ChatView({ return { id: sc.id, name: sc.name || "New Chat", + modelId: + (sc.modelId as "opus" | "sonnet" | "haiku" | undefined) || + existingLocal?.modelId, // Prefer DB timestamp, fall back to local timestamp, then current time created_at: createdAt ?? existingLocal?.created_at ?? new Date().toISOString(), @@ -4163,10 +4225,14 @@ export function ChatView({ const currentOpenIds = freshState.openSubChatIds currentOpenIds.forEach((id) => { if (!dbSubChatIds.has(id)) { + const existingLocal = existingSubChatsMap.get(id) allSubChats.push({ id, name: "New Chat", created_at: new Date().toISOString(), + modelId: + existingLocal?.modelId || + ((agentChat as any)?.modelId as "opus" | "sonnet" | "haiku" | undefined), }) } }) @@ -4207,24 +4273,30 @@ export function ChatView({ const subChat = agentSubChats.find((sc) => sc.id === subChatId) const messages = (subChat?.messages as any[]) || [] - // Get mode from store metadata (falls back to current isPlanMode) + // Get mode from store metadata (falls back to current chatMode) const subChatMeta = useAgentSubChatStore .getState() .allSubChats.find((sc) => sc.id === subChatId) - const subChatMode = subChatMeta?.mode || (isPlanMode ? "plan" : "agent") + const subChatMode = subChatMeta?.mode || chatMode // Desktop: use IPCChatTransport for local Claude Code execution // Note: Extended thinking setting is read dynamically inside the transport // projectPath: original project path for MCP config lookup (worktreePath is the cwd) const projectPath = (agentChat as any)?.project?.path as string | undefined + // Get per-sub-chat model (fall back to chat-level model if needed) + const subChatModel = + subChatMeta?.modelId || + ((subChat as any)?.modelId as "opus" | "sonnet" | "haiku" | undefined) || + ((agentChat as any)?.modelId as "opus" | "sonnet" | "haiku" | undefined) const transport = worktreePath ? new IPCChatTransport({ - chatId, - subChatId, - cwd: worktreePath, - projectPath, - mode: subChatMode, - }) + chatId, + subChatId, + cwd: worktreePath, + projectPath, + mode: subChatMode, + model: subChatModel, // Pass per-sub-chat model to transport + }) : null // Web transport not supported in desktop app if (!transport) { @@ -4271,20 +4343,28 @@ export function ChatView({ }) // Play completion sound only if NOT manually aborted and sound is enabled + // Respect notification mode setting if (!wasManuallyAborted) { const isSoundEnabled = appStore.get(soundNotificationsEnabledAtom) - if (isSoundEnabled) { + const notifMode = appStore.get(notificationModeAtom) + const shouldNotify = + notifMode === "always" || + (notifMode === "unfocused" && !document.hasFocus()) + + if (isSoundEnabled && shouldNotify) { try { const audio = new Audio("./sound.mp3") audio.volume = 1.0 - audio.play().catch(() => {}) + audio.play().catch(() => { }) } catch { // Ignore audio errors } } - // Show native notification (desktop app, when window not focused) - notifyAgentComplete(agentChat?.name || "Agent") + // Show native notification (desktop app) based on notification mode + if (shouldNotify) { + notifyAgentComplete(agentChat?.name || "Agent") + } } } @@ -4308,7 +4388,7 @@ export function ChatView({ chatWorkingDir, worktreePath, chatId, - isPlanMode, + chatMode, setSubChatUnseenChanges, selectedChatId, setUnseenChanges, @@ -4319,13 +4399,17 @@ export function ChatView({ // Handle creating a new sub-chat const handleCreateNewSubChat = useCallback(async () => { const store = useAgentSubChatStore.getState() - const subChatMode = isPlanMode ? "plan" : "agent" + const subChatMode = chatMode + const activeModelId = + store.allSubChats.find((sc) => sc.id === store.activeSubChatId)?.modelId || + ((agentChat as any)?.modelId as "opus" | "sonnet" | "haiku" | undefined) // Create sub-chat in DB first to get the real ID const newSubChat = await trpcClient.chats.createSubChat.mutate({ chatId, name: "New Chat", mode: subChatMode, + modelId: activeModelId, }) const newId = newSubChat.id @@ -4338,6 +4422,9 @@ export function ChatView({ name: "New Chat", created_at: new Date().toISOString(), mode: subChatMode, + modelId: + (newSubChat as any)?.modelId || + activeModelId, }) // Add to open tabs and set as active @@ -4350,12 +4437,16 @@ export function ChatView({ // Note: Extended thinking setting is read dynamically inside the transport // projectPath: original project path for MCP config lookup (worktreePath is the cwd) const projectPath = (agentChat as any)?.project?.path as string | undefined + const newSubChatModel = + (newSubChat as any)?.modelId || + activeModelId const transport = new IPCChatTransport({ chatId, subChatId: newId, cwd: worktreePath, projectPath, mode: subChatMode, + model: newSubChatModel, // Pass per-sub-chat model to transport }) const newChat = new Chat({ @@ -4396,20 +4487,28 @@ export function ChatView({ }) // Play completion sound only if NOT manually aborted and sound is enabled + // Respect notification mode setting if (!wasManuallyAborted) { const isSoundEnabled = appStore.get(soundNotificationsEnabledAtom) - if (isSoundEnabled) { + const notifMode = appStore.get(notificationModeAtom) + const shouldNotify = + notifMode === "always" || + (notifMode === "unfocused" && !document.hasFocus()) + + if (isSoundEnabled && shouldNotify) { try { const audio = new Audio("./sound.mp3") audio.volume = 1.0 - audio.play().catch(() => {}) + audio.play().catch(() => { }) } catch { // Ignore audio errors } } - // Show native notification (desktop app, when window not focused) - notifyAgentComplete(agentChat?.name || "Agent") + // Show native notification (desktop app) based on notification mode + if (shouldNotify) { + notifyAgentComplete(agentChat?.name || "Agent") + } } } @@ -4427,7 +4526,7 @@ export function ChatView({ }, [ worktreePath, chatId, - isPlanMode, + chatMode, setSubChatUnseenChanges, selectedChatId, setUnseenChanges, @@ -4843,15 +4942,19 @@ export function ChatView({ // No early return - let the UI render with loading state handled by activeChat check below return ( -
- {/* Main content */} -
- {/* Chat Panel */} -
- {/* SubChatSelector header - absolute when sidebar open (desktop only), regular div otherwise */} + + +
+ {/* Main content */} +
+ {/* Chat Panel */} +
+ {/* Text Selection Popover for adding AI response text to context */} + + {/* SubChatSelector header - absolute when sidebar open (desktop only), regular div otherwise */} {!shouldHideChatHeader && (
setIsRightPanelOpen(!isRightPanelOpen)} /> sc.id === activeSubChatId)?.stream_id ?? + agentChatStore.getStreamId(activeSubChatId) + } isMobile={isMobileFullscreen} isSubChatsSidebarOpen={subChatsSidebarMode === "sidebar"} sandboxId={sandboxId || undefined} projectPath={worktreePath || undefined} isArchived={isArchived} onRestoreWorkspace={handleRestoreWorkspace} + dbSessionId={agentSubChats.find(sc => sc.id === activeSubChatId)?.sessionId || undefined} + subChatModelId={ + (agentSubChats.find(sc => sc.id === activeSubChatId)?.modelId as string | null | undefined) + } + onModelChange={handleModelChange} + textContexts={textContexts} + removeTextContext={removeTextContext} /> ) : ( <> @@ -5065,7 +5180,7 @@ export function ChatView({
{}} + onClick={() => { }} />
@@ -5487,7 +5602,9 @@ export function ChatView({ workspaceId={chatId} /> )} -
-
+
+
+ + ) } diff --git a/src/renderer/features/agents/main/new-chat-form.tsx b/src/renderer/features/agents/main/new-chat-form.tsx index c4911c7c..89f3f50f 100644 --- a/src/renderer/features/agents/main/new-chat-form.tsx +++ b/src/renderer/features/agents/main/new-chat-form.tsx @@ -14,6 +14,7 @@ import { } from "../../../components/ui/dropdown-menu" import { AgentIcon, + AskIcon, AttachIcon, BranchIcon, CheckIcon, @@ -31,11 +32,13 @@ import { import { cn } from "../../../lib/utils" import { agentsDebugModeAtom, - isPlanModeAtom, + chatModeAtom, + type ChatMode, justCreatedIdsAtom, lastSelectedAgentIdAtom, lastSelectedBranchesAtom, lastSelectedModelIdAtom, + defaultModelIdAtom, lastSelectedRepoAtom, lastSelectedWorkModeAtom, selectedAgentChatIdAtom, @@ -165,7 +168,8 @@ export function NewChatForm({ const [lastSelectedModelId, setLastSelectedModelId] = useAtom( lastSelectedModelIdAtom, ) - const [isPlanMode, setIsPlanMode] = useAtom(isPlanModeAtom) + const defaultModelId = useAtomValue(defaultModelIdAtom) + const [chatMode, setChatMode] = useAtom(chatModeAtom) const [workMode, setWorkMode] = useAtom(lastSelectedWorkModeAtom) const debugMode = useAtomValue(agentsDebugModeAtom) const setSettingsDialogOpen = useSetAtom(agentsSettingsDialogOpenAtom) @@ -184,7 +188,7 @@ export function NewChatForm({ ) const [selectedModel, setSelectedModel] = useState( () => - claudeModels.find((m) => m.id === lastSelectedModelId) || claudeModels[1], + claudeModels.find((m) => m.id === defaultModelId) || claudeModels[1], ) const [repoPopoverOpen, setRepoPopoverOpen] = useState(false) const [branchPopoverOpen, setBranchPopoverOpen] = useState(false) @@ -241,7 +245,7 @@ export function NewChatForm({ const [modeTooltip, setModeTooltip] = useState<{ visible: boolean position: { top: number; left: number } - mode: "agent" | "plan" + mode: ChatMode } | null>(null) const tooltipTimeoutRef = useRef | null>(null) const hasShownTooltipRef = useRef(false) @@ -658,7 +662,8 @@ export function NewChatForm({ baseBranch: workMode === "worktree" ? selectedBranch || undefined : undefined, useWorktree: workMode === "worktree", - mode: isPlanMode ? "plan" : "agent", + mode: chatMode, + modelId: selectedModel?.id as "opus" | "sonnet" | "haiku", }) // Editor and images are cleared in onSuccess callback }, [ @@ -668,7 +673,7 @@ export function NewChatForm({ selectedBranch, workMode, images, - isPlanMode, + chatMode, ]) const handleMentionSelect = useCallback((mention: FileMentionOption) => { @@ -807,13 +812,18 @@ export function NewChatForm({ editorRef.current?.clear() break case "plan": - if (!isPlanMode) { - setIsPlanMode(true) + if (chatMode !== "plan") { + setChatMode("plan") } break case "agent": - if (isPlanMode) { - setIsPlanMode(false) + if (chatMode !== "agent") { + setChatMode("agent") + } + break + case "ask": + if (chatMode !== "ask") { + setChatMode("ask") } break // Prompt-based commands - auto-send to agent @@ -840,7 +850,7 @@ export function NewChatForm({ setTimeout(() => handleSend(), 0) } }, - [isPlanMode, setIsPlanMode, handleSend], + [chatMode, setChatMode, handleSend], ) // Paste handler for images and plain text @@ -1035,12 +1045,14 @@ export function NewChatForm({ }} > - {isPlanMode ? ( + {chatMode === "plan" ? ( + ) : chatMode === "ask" ? ( + ) : ( )} - {isPlanMode ? "Plan" : "Agent"} + {chatMode === "plan" ? "Plan" : chatMode === "ask" ? "Ask" : "Agent"} Agent
- {!isPlanMode && ( + {chatMode === "agent" && ( )} @@ -1113,7 +1125,7 @@ export function NewChatForm({ tooltipTimeoutRef.current = null } setModeTooltip(null) - setIsPlanMode(true) + setChatMode("plan") setModeDropdownOpen(false) }} className="justify-between gap-2" @@ -1156,7 +1168,62 @@ export function NewChatForm({ Plan
- {isPlanMode && ( + {chatMode === "plan" && ( + + )} + + { + // Clear tooltip before closing dropdown (onMouseLeave won't fire) + if (tooltipTimeoutRef.current) { + clearTimeout(tooltipTimeoutRef.current) + tooltipTimeoutRef.current = null + } + setModeTooltip(null) + setChatMode("ask") + setModeDropdownOpen(false) + }} + className="justify-between gap-2" + onMouseEnter={(e) => { + if (tooltipTimeoutRef.current) { + clearTimeout(tooltipTimeoutRef.current) + tooltipTimeoutRef.current = null + } + const rect = e.currentTarget.getBoundingClientRect() + const showTooltip = () => { + setModeTooltip({ + visible: true, + position: { + top: rect.top, + left: rect.right + 8, + }, + mode: "ask", + }) + hasShownTooltipRef.current = true + tooltipTimeoutRef.current = null + } + if (hasShownTooltipRef.current) { + showTooltip() + } else { + tooltipTimeoutRef.current = setTimeout( + showTooltip, + 1000, + ) + } + }} + onMouseLeave={() => { + if (tooltipTimeoutRef.current) { + clearTimeout(tooltipTimeoutRef.current) + tooltipTimeoutRef.current = null + } + setModeTooltip(null) + }} + > +
+ + Ask +
+ {chatMode === "ask" && ( )}
@@ -1178,7 +1245,9 @@ export function NewChatForm({ {modeTooltip.mode === "agent" ? "Apply changes directly without a plan" - : "Create a plan before making changes"} + : modeTooltip.mode === "plan" + ? "Create a plan before making changes" + : "Answer questions without code changes"}
, @@ -1266,7 +1335,7 @@ export function NewChatForm({ !hasContent || !selectedProject || isUploading, )} onClick={handleSend} - isPlanMode={isPlanMode} + mode={chatMode} />
@@ -1466,7 +1535,7 @@ export function NewChatForm({ position={slashPosition} teamId={selectedTeamId || undefined} repository={resolvedRepo?.full_name} - isPlanMode={isPlanMode} + chatMode={chatMode} disabledCommands={["clear"]} />
diff --git a/src/renderer/features/agents/search/chat-search-atoms.ts b/src/renderer/features/agents/search/chat-search-atoms.ts new file mode 100644 index 00000000..1232eb6b --- /dev/null +++ b/src/renderer/features/agents/search/chat-search-atoms.ts @@ -0,0 +1,170 @@ +import { atom } from "jotai" +import { atomFamily } from "jotai/utils" + +// ============================================================================ +// TYPES +// ============================================================================ + +export interface SearchMatch { + id: string // unique match id: `${messageId}:${partIndex}:${offset}` + messageId: string + partIndex: number + partType: string // "text" | "tool-Bash:stdout" | "tool-Read:content" | etc. + offset: number // character offset within the text + length: number // length of matched text +} + +export interface HighlightRange { + offset: number + length: number + isCurrent: boolean + indexInPart: number // 0-based index of this match within the part (for DOM highlighting) +} + +// ============================================================================ +// SEARCH STATE ATOMS +// ============================================================================ + +// Search panel open state +export const chatSearchOpenAtom = atom(false) + +// Raw input value (updates immediately for responsive UI) +export const chatSearchInputAtom = atom("") + +// Debounced search query (for actual searching) +export const chatSearchQueryAtom = atom("") + +// All matches found +export const chatSearchMatchesAtom = atom([]) + +// Current match index (0-based) +export const chatSearchCurrentIndexAtom = atom(0) + +// ============================================================================ +// DERIVED ATOMS +// ============================================================================ + +// Current match for scroll-to +export const chatSearchCurrentMatchAtom = atom((get) => { + const matches = get(chatSearchMatchesAtom) + const index = get(chatSearchCurrentIndexAtom) + return matches[index] ?? null +}) + +// Match count info for display +export const chatSearchCountInfoAtom = atom((get) => { + const matches = get(chatSearchMatchesAtom) + const index = get(chatSearchCurrentIndexAtom) + return { + current: matches.length > 0 ? index + 1 : 0, + total: matches.length, + } +}) + +// ============================================================================ +// HIGHLIGHT RANGES PER MESSAGE/PART +// ============================================================================ + +// Cache for highlight ranges by message and part +// Key format: `${messageId}:${partIndex}:${partType}` +const highlightRangesCache = new Map() + +// Atom family for getting highlight ranges for a specific message part +export const highlightRangesAtomFamily = atomFamily( + (key: string) => + atom((get) => { + const matches = get(chatSearchMatchesAtom) + const currentMatch = get(chatSearchCurrentMatchAtom) + + // Parse key + const [messageId, partIndexStr, partType] = key.split(":") + const partIndex = parseInt(partIndexStr, 10) + + // Filter matches for this message/part + const relevantMatches = matches.filter( + (m) => + m.messageId === messageId && + m.partIndex === partIndex && + m.partType === partType + ) + + if (relevantMatches.length === 0) { + return [] + } + + // Convert to highlight ranges + const ranges: HighlightRange[] = relevantMatches.map((m, idx) => ({ + offset: m.offset, + length: m.length, + isCurrent: currentMatch?.id === m.id, + indexInPart: idx, + })) + + // Check cache for stable reference + const cacheKey = key + const cached = highlightRangesCache.get(cacheKey) + if ( + cached && + cached.length === ranges.length && + cached.every( + (r, i) => + r.offset === ranges[i].offset && + r.length === ranges[i].length && + r.isCurrent === ranges[i].isCurrent + ) + ) { + return cached + } + + highlightRangesCache.set(cacheKey, ranges) + return ranges + }), + (a, b) => a === b +) + +// ============================================================================ +// ACTIONS +// ============================================================================ + +// Navigate to next match +export const goToNextMatchAtom = atom(null, (get, set) => { + const matches = get(chatSearchMatchesAtom) + const currentIndex = get(chatSearchCurrentIndexAtom) + if (matches.length === 0) return + const newIndex = (currentIndex + 1) % matches.length + set(chatSearchCurrentIndexAtom, newIndex) +}) + +// Navigate to previous match +export const goToPrevMatchAtom = atom(null, (get, set) => { + const matches = get(chatSearchMatchesAtom) + const currentIndex = get(chatSearchCurrentIndexAtom) + if (matches.length === 0) return + const newIndex = currentIndex === 0 ? matches.length - 1 : currentIndex - 1 + set(chatSearchCurrentIndexAtom, newIndex) +}) + +// Close search and clear state +export const closeSearchAtom = atom(null, (_get, set) => { + set(chatSearchOpenAtom, false) + set(chatSearchInputAtom, "") + set(chatSearchQueryAtom, "") + set(chatSearchMatchesAtom, []) + set(chatSearchCurrentIndexAtom, 0) + highlightRangesCache.clear() +}) + +// Open search +export const openSearchAtom = atom(null, (_get, set) => { + set(chatSearchOpenAtom, true) +}) + +// Toggle search +export const toggleSearchAtom = atom(null, (get, set) => { + const isOpen = get(chatSearchOpenAtom) + if (isOpen) { + set(closeSearchAtom) + } else { + set(openSearchAtom) + } +}) diff --git a/src/renderer/features/agents/search/chat-search-bar.tsx b/src/renderer/features/agents/search/chat-search-bar.tsx new file mode 100644 index 00000000..2605c339 --- /dev/null +++ b/src/renderer/features/agents/search/chat-search-bar.tsx @@ -0,0 +1,205 @@ +import { useAtom, useAtomValue, useSetAtom } from "jotai" +import { ChevronDown, ChevronUp, X } from "lucide-react" +import * as React from "react" +import { useCallback, useEffect, useRef, useState } from "react" + +import { cn } from "../../../lib/utils" +import { + chatSearchCountInfoAtom, + chatSearchInputAtom, + chatSearchMatchesAtom, + chatSearchOpenAtom, + chatSearchQueryAtom, + chatSearchCurrentIndexAtom, + closeSearchAtom, + goToNextMatchAtom, + goToPrevMatchAtom, +} from "./chat-search-atoms" +import { + extractSearchableText, + findMatches, +} from "./chat-search-utils" +import type { Message } from "../stores/message-store" + +interface ChatSearchBarProps { + messages: Message[] + className?: string + topOffset?: string // e.g., "52px" when sub-chat selector is open +} + +export function ChatSearchBar({ messages, className, topOffset }: ChatSearchBarProps) { + const isOpen = useAtomValue(chatSearchOpenAtom) + const [inputValue, setInputValue] = useAtom(chatSearchInputAtom) + const setSearchQuery = useSetAtom(chatSearchQueryAtom) + const setMatches = useSetAtom(chatSearchMatchesAtom) + const setCurrentIndex = useSetAtom(chatSearchCurrentIndexAtom) + const countInfo = useAtomValue(chatSearchCountInfoAtom) + const closeSearch = useSetAtom(closeSearchAtom) + const goToNext = useSetAtom(goToNextMatchAtom) + const goToPrev = useSetAtom(goToPrevMatchAtom) + + const inputRef = useRef(null) + const debounceTimeoutRef = useRef | null>(null) + + // Track if search has completed (to avoid showing "No results" while typing) + const [searchCompleted, setSearchCompleted] = useState(false) + + // Focus input when search opens + useEffect(() => { + if (isOpen && inputRef.current) { + inputRef.current.focus() + inputRef.current.select() + } + }, [isOpen]) + + // Debounced search + useEffect(() => { + // Mark search as not completed when input changes + setSearchCompleted(false) + + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current) + } + + debounceTimeoutRef.current = setTimeout(() => { + setSearchQuery(inputValue) + + if (!inputValue.trim()) { + setMatches([]) + setCurrentIndex(0) + setSearchCompleted(true) + return + } + + // Extract and search + const extracted = extractSearchableText(messages) + const matches = findMatches(extracted, inputValue) + + setMatches(matches) + setCurrentIndex(0) + setSearchCompleted(true) + }, 200) + + return () => { + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current) + } + } + }, [inputValue, messages, setSearchQuery, setMatches, setCurrentIndex]) + + // Keyboard navigation + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault() + closeSearch() + } else if (e.key === "Enter") { + e.preventDefault() + if (e.shiftKey) { + goToPrev() + } else { + goToNext() + } + } else if (e.key === "ArrowDown" || (e.key === "g" && !e.shiftKey && (e.metaKey || e.ctrlKey))) { + e.preventDefault() + goToNext() + } else if (e.key === "ArrowUp" || (e.key === "g" && e.shiftKey && (e.metaKey || e.ctrlKey))) { + e.preventDefault() + goToPrev() + } + }, + [closeSearch, goToNext, goToPrev] + ) + + // Focus input when clicking on container (but not on buttons) + const handleContainerClick = useCallback((e: React.MouseEvent) => { + // Only focus if clicking directly on container or non-interactive elements + const target = e.target as HTMLElement + if (!target.closest("button")) { + inputRef.current?.focus() + } + }, []) + + if (!isOpen) return null + + return ( +
+ {/* Search input - grows to fill space, shrinks on narrow screens */} + setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search..." + className={cn( + "flex-1 min-w-[80px] h-7 px-2 text-sm bg-transparent", + "border-none outline-none", + "placeholder:text-muted-foreground/60" + )} + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + spellCheck={false} + /> + + {/* Results area - fixed width: shows counter+arrows OR "No results" */} +
+ {countInfo.total > 0 ? ( + <> + + {`${countInfo.current} of ${countInfo.total}`} + + + + + ) : ( + inputValue.trim() && searchCompleted && ( + No results + ) + )} +
+ + {/* Close button - fixed width */} + +
+ ) +} diff --git a/src/renderer/features/agents/search/chat-search-utils.ts b/src/renderer/features/agents/search/chat-search-utils.ts new file mode 100644 index 00000000..e1457d54 --- /dev/null +++ b/src/renderer/features/agents/search/chat-search-utils.ts @@ -0,0 +1,353 @@ +import type { Message, MessagePart } from "../stores/message-store" +import type { SearchMatch } from "./chat-search-atoms" + +// ============================================================================ +// TEXT EXTRACTION +// ============================================================================ + +interface ExtractedText { + messageId: string + partIndex: number + partType: string + text: string +} + +/** + * Extract all searchable text from a message part + * NOTE: Currently only searches text parts for simplicity. + * Tool content search was removed to avoid complexity with highlighting. + */ +function extractTextFromPart( + messageId: string, + partIndex: number, + part: MessagePart +): ExtractedText[] { + const results: ExtractedText[] = [] + + // Only search text parts - tool content search is disabled for now + if (part.type === "text" && part.text && typeof part.text === "string" && part.text.trim()) { + results.push({ messageId, partIndex, partType: "text", text: part.text }) + } + + return results +} + +// Keep old implementation commented for reference if we want to re-enable tool search later +/* +function extractTextFromPartFull( + messageId: string, + partIndex: number, + part: MessagePart +): ExtractedText[] { + const results: ExtractedText[] = [] + + const addText = (partType: string, text: string | undefined | null) => { + if (text && typeof text === "string" && text.trim()) { + results.push({ messageId, partIndex, partType, text }) + } + } + + switch (part.type) { + case "text": + addText("text", part.text) + break + + case "tool-Bash": + addText("tool-Bash:command", part.input?.command) + addText("tool-Bash:stdout", part.output?.stdout) + addText("tool-Bash:stderr", part.output?.stderr) + break + + case "tool-Read": + addText("tool-Read:path", part.input?.file_path) + addText("tool-Read:content", part.output?.content) + break + + case "tool-Write": + addText("tool-Write:path", part.input?.file_path) + addText("tool-Write:content", part.input?.content) + break + + case "tool-Edit": + addText("tool-Edit:path", part.input?.file_path) + if (part.output?.structuredPatch && Array.isArray(part.output.structuredPatch)) { + const lines: string[] = [] + for (const patch of part.output.structuredPatch) { + if (patch.lines && Array.isArray(patch.lines)) { + for (const line of patch.lines) { + if (typeof line === 'string' && line.length > 0) { + lines.push(line.slice(1)) + } + } + } + } + if (lines.length > 0) { + addText("tool-Edit:content", lines.join("\n")) + } + } else if (part.input?.new_string) { + addText("tool-Edit:content", part.input.new_string) + } + break + + case "tool-Glob": + addText("tool-Glob:pattern", part.input?.pattern) + addText("tool-Glob:path", part.input?.path) + if (Array.isArray(part.output)) { + addText("tool-Glob:results", part.output.join("\n")) + } + break + + case "tool-Grep": + addText("tool-Grep:pattern", part.input?.pattern) + addText("tool-Grep:path", part.input?.path) + if (part.output?.content) { + addText("tool-Grep:content", part.output.content) + } + break + + case "tool-WebSearch": + addText("tool-WebSearch:query", part.input?.query) + if (Array.isArray(part.output?.results)) { + const resultsText = part.output.results + .map((r: { title?: string; snippet?: string; url?: string }) => + `${r.title || ""} ${r.snippet || ""} ${r.url || ""}` + ) + .join("\n") + addText("tool-WebSearch:results", resultsText) + } + break + + case "tool-WebFetch": + addText("tool-WebFetch:url", part.input?.url) + addText("tool-WebFetch:prompt", part.input?.prompt) + addText("tool-WebFetch:content", part.output?.markdown || part.output?.content) + break + + case "tool-Task": + addText("tool-Task:prompt", part.input?.prompt) + addText("tool-Task:result", part.output?.result || part.result) + break + + case "tool-TodoWrite": + if (Array.isArray(part.input?.todos)) { + const todosText = part.input.todos + .map((t: { content?: string }) => t.content || "") + .join("\n") + addText("tool-TodoWrite:todos", todosText) + } + break + + case "tool-AskUserQuestion": + if (Array.isArray(part.input?.questions)) { + const questionsText = part.input.questions + .map((q: { question?: string }) => q.question || "") + .join("\n") + addText("tool-AskUserQuestion:questions", questionsText) + } + break + + case "tool-Thinking": + addText("tool-Thinking:content", part.thinking || part.text) + break + + default: + // For unknown tool types, try to extract any text-like content + if (part.text) { + addText(part.type, part.text) + } + if (part.input && typeof part.input === "object") { + // Try to stringify input for search + try { + const inputStr = JSON.stringify(part.input) + if (inputStr.length < 10000) { + // Limit for performance + addText(`${part.type}:input`, inputStr) + } + } catch { + // Ignore stringify errors + } + } + if (part.output && typeof part.output === "string") { + addText(`${part.type}:output`, part.output) + } + break + } + + return results +} +*/ + +/** + * Extract all searchable text from messages + * Currently only extracts from text parts (tool content search disabled) + */ +export function extractSearchableText(messages: Message[]): ExtractedText[] { + const results: ExtractedText[] = [] + + for (const message of messages) { + if (!message.parts) continue + + // For user messages, consolidate all text parts into one entry with partIndex 0 + // This matches how user messages are rendered (single bubble with all text joined) + if (message.role === "user") { + const textParts = message.parts.filter( + (p): p is MessagePart & { type: "text"; text: string } => + p.type === "text" && typeof p.text === "string" && p.text.trim().length > 0 + ) + if (textParts.length > 0) { + const combinedText = textParts.map((p) => p.text).join("\n") + results.push({ + messageId: message.id, + partIndex: 0, // Always 0 for user messages + partType: "text", + text: combinedText, + }) + } + continue + } + + // For assistant messages, extract from all parts (text and tools) + for (let partIndex = 0; partIndex < message.parts.length; partIndex++) { + const part = message.parts[partIndex] + const extracted = extractTextFromPart(message.id, partIndex, part) + results.push(...extracted) + } + } + + return results +} + +// ============================================================================ +// SEARCH ALGORITHM +// ============================================================================ + +/** + * Find all matches for a query in extracted texts + */ +export function findMatches( + extractedTexts: ExtractedText[], + query: string +): SearchMatch[] { + if (!query.trim()) return [] + + const matches: SearchMatch[] = [] + const lowerQuery = query.toLowerCase() + + for (const extracted of extractedTexts) { + const lowerText = extracted.text.toLowerCase() + let searchStart = 0 + + while (true) { + const index = lowerText.indexOf(lowerQuery, searchStart) + if (index === -1) break + + const matchId = `${extracted.messageId}:${extracted.partIndex}:${extracted.partType}:${index}` + + matches.push({ + id: matchId, + messageId: extracted.messageId, + partIndex: extracted.partIndex, + partType: extracted.partType, + offset: index, + length: query.length, + }) + + searchStart = index + 1 + } + } + + return matches +} + +// ============================================================================ +// HIGHLIGHT UTILITIES +// ============================================================================ + +export interface TextSegment { + text: string + isHighlight: boolean + isCurrent: boolean +} + +/** + * Split text into segments based on highlight ranges + */ +export function splitTextByHighlights( + text: string, + highlights: Array<{ offset: number; length: number; isCurrent: boolean }> +): TextSegment[] { + if (highlights.length === 0) { + return [{ text, isHighlight: false, isCurrent: false }] + } + + // Sort highlights by offset + const sorted = [...highlights].sort((a, b) => a.offset - b.offset) + + const result: TextSegment[] = [] + let cursor = 0 + + for (const h of sorted) { + // Skip invalid highlights + if (h.offset < cursor || h.offset >= text.length) continue + + // Text before highlight + if (h.offset > cursor) { + result.push({ + text: text.slice(cursor, h.offset), + isHighlight: false, + isCurrent: false, + }) + } + + // Highlighted text + const endOffset = Math.min(h.offset + h.length, text.length) + result.push({ + text: text.slice(h.offset, endOffset), + isHighlight: true, + isCurrent: h.isCurrent, + }) + + cursor = endOffset + } + + // Remaining text after last highlight + if (cursor < text.length) { + result.push({ + text: text.slice(cursor), + isHighlight: false, + isCurrent: false, + }) + } + + return result +} + +// ============================================================================ +// DEBOUNCE UTILITY +// ============================================================================ + +export function debounce void>( + fn: T, + delay: number +): T & { cancel: () => void } { + let timeoutId: ReturnType | null = null + + const debounced = ((...args: Parameters) => { + if (timeoutId) { + clearTimeout(timeoutId) + } + timeoutId = setTimeout(() => { + fn(...args) + timeoutId = null + }, delay) + }) as T & { cancel: () => void } + + debounced.cancel = () => { + if (timeoutId) { + clearTimeout(timeoutId) + timeoutId = null + } + } + + return debounced +} diff --git a/src/renderer/features/agents/search/index.ts b/src/renderer/features/agents/search/index.ts new file mode 100644 index 00000000..65c23256 --- /dev/null +++ b/src/renderer/features/agents/search/index.ts @@ -0,0 +1,39 @@ +// Atoms +export { + chatSearchOpenAtom, + chatSearchInputAtom, + chatSearchQueryAtom, + chatSearchMatchesAtom, + chatSearchCurrentIndexAtom, + chatSearchCurrentMatchAtom, + chatSearchCountInfoAtom, + highlightRangesAtomFamily, + goToNextMatchAtom, + goToPrevMatchAtom, + closeSearchAtom, + openSearchAtom, + toggleSearchAtom, + type SearchMatch, + type HighlightRange, +} from "./chat-search-atoms" + +// Utils +export { + extractSearchableText, + findMatches, + splitTextByHighlights, + debounce, + type TextSegment, +} from "./chat-search-utils" + +// Components +export { ChatSearchBar } from "./chat-search-bar" + +// Context +export { + SearchHighlightProvider, + useSearchHighlightContext, + useSearchHighlight, + useIsSearchActive, + useSearchQuery, +} from "./search-highlight-context" diff --git a/src/renderer/features/agents/search/search-highlight-context.tsx b/src/renderer/features/agents/search/search-highlight-context.tsx new file mode 100644 index 00000000..dd746c22 --- /dev/null +++ b/src/renderer/features/agents/search/search-highlight-context.tsx @@ -0,0 +1,172 @@ +import { useAtomValue } from "jotai" +import * as React from "react" +import { createContext, useContext, useMemo, useCallback } from "react" + +import { + chatSearchOpenAtom, + chatSearchQueryAtom, + chatSearchMatchesAtom, + chatSearchCurrentMatchAtom, + type SearchMatch, + type HighlightRange, +} from "./chat-search-atoms" + +// ============================================================================ +// CONTEXT TYPES +// ============================================================================ + +interface SearchHighlightContextValue { + query: string + isSearchActive: boolean + getHighlightRanges: ( + messageId: string, + partIndex: number, + partType: string + ) => HighlightRange[] +} + +const SearchHighlightContext = createContext( + null +) + +// ============================================================================ +// PROVIDER +// ============================================================================ + +interface SearchHighlightProviderProps { + children: React.ReactNode +} + +// Empty context value when search is closed - stable reference to avoid re-renders +const EMPTY_HIGHLIGHT_RANGES: HighlightRange[] = [] +const emptyGetHighlightRanges = () => EMPTY_HIGHLIGHT_RANGES +const CLOSED_SEARCH_VALUE: SearchHighlightContextValue = { + query: "", + isSearchActive: false, + getHighlightRanges: emptyGetHighlightRanges, +} + +export function SearchHighlightProvider({ + children, +}: SearchHighlightProviderProps) { + // Only subscribe to isOpen first - this is the gate + const isOpen = useAtomValue(chatSearchOpenAtom) + + // When search is closed, render with static empty context + // This prevents any subscriptions to query/matches/currentMatch + if (!isOpen) { + return ( + + {children} + + ) + } + + // Search is open - render the active provider + return ( + + {children} + + ) +} + +// Separate component for when search is active +// This isolates the subscriptions to query/matches/currentMatch +function SearchHighlightProviderActive({ + children, +}: SearchHighlightProviderProps) { + const query = useAtomValue(chatSearchQueryAtom) + const matches = useAtomValue(chatSearchMatchesAtom) + const currentMatch = useAtomValue(chatSearchCurrentMatchAtom) + + // Build lookup map for efficient highlight retrieval + const matchesByKey = useMemo(() => { + const map = new Map() + for (const match of matches) { + const key = `${match.messageId}:${match.partIndex}:${match.partType}` + const existing = map.get(key) || [] + existing.push(match) + map.set(key, existing) + } + return map + }, [matches]) + + const getHighlightRanges = useCallback( + (messageId: string, partIndex: number, partType: string): HighlightRange[] => { + const key = `${messageId}:${partIndex}:${partType}` + const relevantMatches = matchesByKey.get(key) + + if (!relevantMatches || relevantMatches.length === 0) { + return EMPTY_HIGHLIGHT_RANGES + } + + return relevantMatches.map((m, idx) => ({ + offset: m.offset, + length: m.length, + isCurrent: currentMatch?.id === m.id, + indexInPart: idx, + })) + }, + [matchesByKey, currentMatch] + ) + + const value = useMemo( + () => ({ + query, + isSearchActive: query.trim().length > 0, + getHighlightRanges, + }), + [query, getHighlightRanges] + ) + + return ( + + {children} + + ) +} + +// ============================================================================ +// HOOKS +// ============================================================================ + +/** + * Hook to access search highlight context + */ +export function useSearchHighlightContext() { + return useContext(SearchHighlightContext) +} + +/** + * Hook to get highlight ranges for a specific message part + * Returns empty array if search is not active or no matches + */ +export function useSearchHighlight( + messageId: string, + partIndex: number, + partType: string +): HighlightRange[] { + const context = useContext(SearchHighlightContext) + + if (!context || !context.isSearchActive) { + return [] + } + + return context.getHighlightRanges(messageId, partIndex, partType) +} + +/** + * Hook to check if search is currently active + */ +export function useIsSearchActive(): boolean { + const context = useContext(SearchHighlightContext) + return context?.isSearchActive ?? false +} + +/** + * Hook to get the current search query + */ +export function useSearchQuery(): string { + const context = useContext(SearchHighlightContext) + return context?.query ?? "" +} diff --git a/src/renderer/features/agents/stores/message-queue-store.ts b/src/renderer/features/agents/stores/message-queue-store.ts new file mode 100644 index 00000000..137274da --- /dev/null +++ b/src/renderer/features/agents/stores/message-queue-store.ts @@ -0,0 +1,95 @@ +import { create } from "zustand" +import { subscribeWithSelector } from "zustand/middleware" +import type { AgentQueueItem } from "../lib/queue-utils" +import { removeQueueItem } from "../lib/queue-utils" + +// Empty array constant to avoid creating new arrays on each call +// Exported for use in selectors to maintain stable reference +export const EMPTY_QUEUE: AgentQueueItem[] = [] + +interface MessageQueueState { + // Map: subChatId -> queue items + queues: Record + + // Actions + addToQueue: (subChatId: string, item: AgentQueueItem) => void + removeFromQueue: (subChatId: string, itemId: string) => void + getQueue: (subChatId: string) => AgentQueueItem[] + getNextItem: (subChatId: string) => AgentQueueItem | null + clearQueue: (subChatId: string) => void + // Returns and removes the item from queue (atomic operation) + popItem: (subChatId: string, itemId: string) => AgentQueueItem | null + // Add item to front of queue (for error recovery) + prependItem: (subChatId: string, item: AgentQueueItem) => void +} + +export const useMessageQueueStore = create()( + subscribeWithSelector((set, get) => ({ + queues: {}, + + addToQueue: (subChatId, item) => { + set((state) => ({ + queues: { + ...state.queues, + [subChatId]: [...(state.queues[subChatId] || []), item], + }, + })) + }, + + removeFromQueue: (subChatId, itemId) => { + set((state) => { + const currentQueue = state.queues[subChatId] || [] + return { + queues: { + ...state.queues, + [subChatId]: removeQueueItem(currentQueue, itemId), + }, + } + }) + }, + + getQueue: (subChatId) => { + return get().queues[subChatId] ?? EMPTY_QUEUE + }, + + getNextItem: (subChatId) => { + const queue = get().queues[subChatId] || [] + return queue.find((item) => item.status === "pending") || null + }, + + clearQueue: (subChatId) => { + set((state) => ({ + queues: { + ...state.queues, + [subChatId]: [], + }, + })) + }, + + // Atomic pop: find and remove in single set() call to prevent race conditions + popItem: (subChatId, itemId) => { + let foundItem: AgentQueueItem | null = null + set((state) => { + const currentQueue = state.queues[subChatId] || [] + foundItem = currentQueue.find((i) => i.id === itemId) || null + if (!foundItem) return state + return { + queues: { + ...state.queues, + [subChatId]: currentQueue.filter((i) => i.id !== itemId), + }, + } + }) + return foundItem + }, + + // Add item to front of queue (used for error recovery - requeue failed items) + prependItem: (subChatId, item) => { + set((state) => ({ + queues: { + ...state.queues, + [subChatId]: [item, ...(state.queues[subChatId] || [])], + }, + })) + }, +}))) diff --git a/src/renderer/features/agents/stores/message-store.ts b/src/renderer/features/agents/stores/message-store.ts new file mode 100644 index 00000000..9f8b5900 --- /dev/null +++ b/src/renderer/features/agents/stores/message-store.ts @@ -0,0 +1,723 @@ +"use client" + +import { atom } from "jotai" +import { atomFamily } from "jotai/utils" + +// Types +export interface MessagePart { + type: string + text?: string + toolCallId?: string + state?: string + input?: any + output?: any + result?: any + [key: string]: any +} + +export interface Message { + id: string + role: "user" | "assistant" | "system" + parts?: MessagePart[] + metadata?: any + createdAt?: Date +} + +// ============================================================================ +// MESSAGE STORE - OPTIMIZED ARCHITECTURE +// ============================================================================ +// Key insight: Jotai atomFamily creates INDEPENDENT atoms for each key. +// When we use atomFamily with primitive atoms (not derived), each message +// has its own atom that can be updated without affecting other messages. +// +// Architecture: +// - messageAtomFamily: atomFamily - INDEPENDENT atoms per message +// - messageIdsAtom: string[] - ordered list of message IDs for rendering +// - messageRolesAtom: Map - cached roles for grouping (avoids reading all messages) +// - lastMessageIdAtom: derived atom for the last message ID +// - streamingMessageIdAtom: ID of currently streaming message (or null) +// +// During streaming: +// - Only the streaming message's atom is updated +// - Other message atoms remain unchanged → no re-renders +// ============================================================================ + +// Per-message atom family - each message has its own INDEPENDENT atom +// This is the key optimization: updating one message doesn't affect others +export const messageAtomFamily = atomFamily((_messageId: string) => + atom(null) +) + +// Track active message IDs per subChat for cleanup +const activeMessageIdsByChat = new Map>() + +// Ordered list of message IDs (for rendering order) +export const messageIdsAtom = atom([]) + +// Message roles cache - updated only when messages are added/removed +// This avoids reading all message atoms just to check roles +const messageRolesAtom = atom>(new Map()) + +// Currently streaming message ID (null if not streaming) +export const streamingMessageIdAtom = atom(null) + +// Chat status atom +export const chatStatusAtom = atom("ready") + +// Current subChatId - used to isolate caches per chat +export const currentSubChatIdAtom = atom("default") + +// Last message ID - derived (uses stable messageIdsAtom) +export const lastMessageIdAtom = atom((get) => { + const ids = get(messageIdsAtom) + return ids.length > 0 ? ids[ids.length - 1] : null +}) + +// ============================================================================ +// SELECTORS +// ============================================================================ + +// Check if a specific message is the last one +export const isLastMessageAtomFamily = atomFamily((messageId: string) => + atom((get) => get(lastMessageIdAtom) === messageId) +) + +// Check if a specific message is currently streaming +export const isMessageStreamingAtomFamily = atomFamily((messageId: string) => + atom((get) => { + const streamingId = get(streamingMessageIdAtom) + const lastId = get(lastMessageIdAtom) + // A message is streaming if it's the last message and there's active streaming + return messageId === lastId && streamingId === messageId + }) +) + +// ============================================================================ +// TEXT PART ATOMS - For IsolatedTextPart optimization +// ============================================================================ +// Problem: When IsolatedTextPart subscribes to messageAtomFamily, ALL text parts +// of that message re-render when ANY part changes (even tool parts). +// +// Solution: Create a derived atom that extracts ONLY the specific text part. +// This way, a text part only re-renders when ITS text changes, not when +// other parts of the same message change. + +// Cache for text part content to return stable references +const textPartCache = new Map() + +export const textPartAtomFamily = atomFamily((key: string) => { + // Key format: "messageId:partIndex" + const [messageId, partIndexStr] = key.split(":") + const partIndex = parseInt(partIndexStr!, 10) + + return atom((get) => { + const message = get(messageAtomFamily(messageId!)) + const parts = message?.parts || [] + const part = parts[partIndex] + const text = part?.type === "text" ? (part.text || "") : "" + + // Return cached value if text hasn't changed (stable reference) + const cached = textPartCache.get(key) + if (cached === text) { + return cached + } + + textPartCache.set(key, text) + return text + }) +}) + +// ============================================================================ +// MESSAGE PARTS STRUCTURE - For AssistantMessageItem optimization +// ============================================================================ +// Problem: AssistantMessageItem subscribes to the whole message object. +// When ANY part changes (including text content), the whole component re-renders, +// causing all IsolatedTextPart children to re-render. +// +// Solution: Create an atom that returns only the STRUCTURE of parts (types, states, +// toolCallIds) without text content. This way AssistantMessageItem only re-renders +// when the structure changes (new part added, tool state changed), not when text +// content streams in. + +interface PartStructure { + type: string + toolCallId?: string + state?: string + // For tools we need input to determine rendering + inputJson?: string + // For tool results + hasOutput?: boolean + hasResult?: boolean + hasError?: boolean + // For text parts - whether text is non-empty (without including actual text) + hasText?: boolean +} + +interface MessageStructure { + id: string + role: "user" | "assistant" | "system" + partsStructure: PartStructure[] + metadata?: any +} + +// Cache for message structure +const messageStructureCache = new Map() + +export const messageStructureAtomFamily = atomFamily((messageId: string) => + atom((get) => { + const message = get(messageAtomFamily(messageId)) + if (!message) return null + + // Build structure without text content + const partsStructure: PartStructure[] = (message.parts || []).map((part: any) => { + const structure: PartStructure = { + type: part.type, + } + if (part.toolCallId) structure.toolCallId = part.toolCallId + if (part.state) structure.state = part.state + // For tools, include input as JSON for comparison + if (part.input) structure.inputJson = JSON.stringify(part.input) + if (part.output !== undefined) structure.hasOutput = true + if (part.result !== undefined) structure.hasResult = true + if (part.error !== undefined || part.errorText !== undefined) structure.hasError = true + // For text parts, track whether text is non-empty (without including actual text) + if (part.type === "text") structure.hasText = !!part.text?.trim() + return structure + }) + + const newStructure: MessageStructure = { + id: message.id, + role: message.role, + partsStructure, + metadata: message.metadata, + } + + // Check if structure changed + const cached = messageStructureCache.get(messageId) + if (cached) { + // Compare structures + if ( + cached.id === newStructure.id && + cached.role === newStructure.role && + cached.partsStructure.length === newStructure.partsStructure.length && + cached.partsStructure.every((p, i) => { + const n = newStructure.partsStructure[i] + return ( + p.type === n?.type && + p.toolCallId === n?.toolCallId && + p.state === n?.state && + p.inputJson === n?.inputJson && + p.hasOutput === n?.hasOutput && + p.hasResult === n?.hasResult && + p.hasError === n?.hasError && + p.hasText === n?.hasText + ) + }) && + // Shallow compare metadata (for usage tracking) + cached.metadata === message.metadata + ) { + return cached + } + } + + messageStructureCache.set(messageId, newStructure) + return newStructure + }) +) + +// ============================================================================ +// USER MESSAGE IDS - For IsolatedMessagesSection +// ============================================================================ +// Uses a cache to return stable reference when IDs haven't changed +// Cache is per-subChatId to avoid collisions between different chats + +const userMessageIdsCacheByChat = new Map() +export const userMessageIdsAtom = atom((get) => { + const ids = get(messageIdsAtom) + const roles = get(messageRolesAtom) + const subChatId = get(currentSubChatIdAtom) + const newUserIds = ids.filter((id) => roles.get(id) === "user") + + // Return cached array if content is the same + const cached = userMessageIdsCacheByChat.get(subChatId) + if ( + cached && + newUserIds.length === cached.length && + newUserIds.every((id, i) => id === cached[i]) + ) { + return cached + } + + userMessageIdsCacheByChat.set(subChatId, newUserIds) + return newUserIds +}) + +// ============================================================================ +// MESSAGE GROUPS - For rendering structure +// ============================================================================ + +type MessageGroupType = { userMsgId: string; assistantMsgIds: string[] } +const messageGroupsCacheByChat = new Map() + +export const messageGroupsAtom = atom((get) => { + const ids = get(messageIdsAtom) + const roles = get(messageRolesAtom) + const subChatId = get(currentSubChatIdAtom) + + const groups: MessageGroupType[] = [] + let currentGroup: MessageGroupType | null = null + + for (const id of ids) { + const role = roles.get(id) + if (!role) continue + + if (role === "user") { + if (currentGroup) { + groups.push(currentGroup) + } + currentGroup = { userMsgId: id, assistantMsgIds: [] } + } else if (currentGroup && role === "assistant") { + currentGroup.assistantMsgIds.push(id) + } + } + + if (currentGroup) { + groups.push(currentGroup) + } + + // Check if groups structurally match cached + const cachedMessageGroups = messageGroupsCacheByChat.get(subChatId) ?? [] + if (groups.length === cachedMessageGroups.length) { + let allMatch = true + for (let i = 0; i < groups.length; i++) { + const newGroup = groups[i] + const cachedGroup = cachedMessageGroups[i] + if ( + newGroup.userMsgId !== cachedGroup?.userMsgId || + newGroup.assistantMsgIds.length !== cachedGroup?.assistantMsgIds.length || + !newGroup.assistantMsgIds.every((id, j) => id === cachedGroup?.assistantMsgIds[j]) + ) { + allMatch = false + break + } + } + if (allMatch) { + return cachedMessageGroups + } + } + + messageGroupsCacheByChat.set(subChatId, groups) + return groups +}) + +// ============================================================================ +// ASSISTANT IDS FOR USER MESSAGE - For IsolatedMessageGroup +// ============================================================================ + +// Key format: "subChatId:userMsgId" to isolate per chat +const assistantIdsCacheByChat = new Map() +export const assistantIdsForUserMsgAtomFamily = atomFamily((userMsgId: string) => + atom((get) => { + const groups = get(messageGroupsAtom) + const subChatId = get(currentSubChatIdAtom) + const group = groups.find((g) => g.userMsgId === userMsgId) + const newIds = group?.assistantMsgIds ?? [] + + // Return cached array if content is the same + const cacheKey = `${subChatId}:${userMsgId}` + const cached = assistantIdsCacheByChat.get(cacheKey) + if ( + cached && + cached.length === newIds.length && + cached.every((id, i) => id === newIds[i]) + ) { + return cached + } + + assistantIdsCacheByChat.set(cacheKey, newIds) + return newIds + }) +) + +// Is this user message the last one? +export const isLastUserMessageAtomFamily = atomFamily((userMsgId: string) => + atom((get) => { + const userIds = get(userMessageIdsAtom) + return userIds[userIds.length - 1] === userMsgId + }) +) + +// ============================================================================ +// STREAMING STATUS +// ============================================================================ + +export const isStreamingAtom = atom((get) => { + const status = get(chatStatusAtom) + return status === "streaming" || status === "submitted" +}) + +// Has any messages +export const hasMessagesAtom = atom((get) => { + const ids = get(messageIdsAtom) + return ids.length > 0 +}) + +// ============================================================================ +// LAST ASSISTANT MESSAGE - For plan detection +// ============================================================================ + +// Cache for last assistant message to avoid re-reading on every check +// Keyed by subChatId to isolate per chat +const lastAssistantCacheByChat = new Map() + +export const lastAssistantMessageAtom = atom((get) => { + const ids = get(messageIdsAtom) + const roles = get(messageRolesAtom) + const subChatId = get(currentSubChatIdAtom) + + // Find the last assistant ID + let lastAssistantId: string | null = null + for (let i = ids.length - 1; i >= 0; i--) { + if (roles.get(ids[i]!) === "assistant") { + lastAssistantId = ids[i]! + break + } + } + + const cached = lastAssistantCacheByChat.get(subChatId) + + if (!lastAssistantId) { + lastAssistantCacheByChat.set(subChatId, { id: null, msg: null }) + return null + } + + // If same ID, return cached message + if (lastAssistantId === cached?.id && cached.msg) { + // But we need to get fresh message in case it changed during streaming + const freshMsg = get(messageAtomFamily(lastAssistantId)) + if (freshMsg === cached.msg) { + return cached.msg + } + lastAssistantCacheByChat.set(subChatId, { id: lastAssistantId, msg: freshMsg }) + return freshMsg + } + + // Different ID, get fresh message + const msg = get(messageAtomFamily(lastAssistantId)) + lastAssistantCacheByChat.set(subChatId, { id: lastAssistantId, msg }) + return msg +}) + +// Has unapproved plan (for approve button) +export const hasUnapprovedPlanAtom = atom((get) => { + const lastAssistant = get(lastAssistantMessageAtom) + if (!lastAssistant) return false + + const parts = lastAssistant.parts || [] + for (const part of parts) { + if (part.type === "tool-invocation" && part.toolName === "ExitPlanMode") { + if (!part.result) return true + } + } + return false +}) + +// ============================================================================ +// TOKEN DATA - For input area +// ============================================================================ + +// Cache for token data to avoid full recalculation +// Keyed by subChatId to isolate per chat +type TokenData = { + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheWriteTokens: number + reasoningTokens: number + totalTokens: number + messageCount: number + // Track last message's output tokens to detect when streaming completes + lastMsgOutputTokens: number +} +const tokenDataCacheByChat = new Map() + +export const messageTokenDataAtom = atom((get) => { + const ids = get(messageIdsAtom) + const subChatId = get(currentSubChatIdAtom) + + // Get the last message to check if its tokens changed + const lastId = ids[ids.length - 1] + const lastMsg = lastId ? get(messageAtomFamily(lastId)) : null + const lastMsgOutputTokens = (lastMsg?.metadata as any)?.usage?.outputTokens || 0 + + const cached = tokenDataCacheByChat.get(subChatId) + + // Cache is valid if: + // 1. Message count is the same AND + // 2. Last message's output tokens haven't changed (detects streaming completion) + if ( + cached && + ids.length === cached.messageCount && + lastMsgOutputTokens === cached.lastMsgOutputTokens + ) { + return cached + } + + // Recalculate token data + let inputTokens = 0 + let outputTokens = 0 + let cacheReadTokens = 0 + let cacheWriteTokens = 0 + let reasoningTokens = 0 + + for (const id of ids) { + const msg = get(messageAtomFamily(id)) + const metadata = msg?.metadata as any + if (metadata?.usage) { + inputTokens += metadata.usage.inputTokens || 0 + outputTokens += metadata.usage.outputTokens || 0 + cacheReadTokens += metadata.usage.cacheReadInputTokens || 0 + cacheWriteTokens += metadata.usage.cacheCreationInputTokens || 0 + reasoningTokens += metadata.usage.reasoningTokens || 0 + } + } + + const newTokenData: TokenData = { + inputTokens, + outputTokens, + cacheReadTokens, + cacheWriteTokens, + reasoningTokens, + totalTokens: inputTokens + outputTokens, + messageCount: ids.length, + lastMsgOutputTokens, + } + + tokenDataCacheByChat.set(subChatId, newTokenData) + return newTokenData +}) + +// ============================================================================ +// SYNC WITH STATUS - Main sync function +// ============================================================================ +// This is called from useChat to sync messages to the store. +// Key optimization: Only updates atoms for messages that actually changed. +// ============================================================================ + +// Track previous message state to detect changes +// Key format: "subChatId:msgId" to isolate per chat +// +// NOTE: This is a simplified change detection optimized for streaming performance. +// It only checks the LAST part (partsLength + lastPartText + lastPartState). +// During streaming, only the last part changes, so this is sufficient and fast. +// +// Compare with messages-list.tsx which uses a more thorough check (all parts' +// textLengths[] and partStates[]) for useSyncExternalStore. That approach is +// more comprehensive but slightly slower. Both are correct for their use cases: +// - This (message-store): Jotai atom updates during high-frequency streaming +// - messages-list.tsx: External store subscription for React render triggering +const previousMessageState = new Map() + +function hasMessageChanged(subChatId: string, msgId: string, msg: Message): boolean { + const cacheKey = `${subChatId}:${msgId}` + const prev = previousMessageState.get(cacheKey) + const parts = msg.parts || [] + const lastPart = parts[parts.length - 1] + + const current = { + partsLength: parts.length, + lastPartText: lastPart?.text, + lastPartState: lastPart?.state, + lastPartInputJson: lastPart?.input ? JSON.stringify(lastPart.input) : undefined, + } + + if (!prev) { + previousMessageState.set(cacheKey, current) + return true + } + + const changed = + prev.partsLength !== current.partsLength || + prev.lastPartText !== current.lastPartText || + prev.lastPartState !== current.lastPartState || + prev.lastPartInputJson !== current.lastPartInputJson + + if (changed) { + previousMessageState.set(cacheKey, current) + } + + return changed +} + +export const syncMessagesWithStatusAtom = atom( + null, + (get, set, payload: { messages: Message[]; status: string; subChatId?: string }) => { + const { messages, status, subChatId } = payload + + // Update current subChatId if provided + if (subChatId) { + set(currentSubChatIdAtom, subChatId) + } + const currentSubChatId = subChatId ?? get(currentSubChatIdAtom) + + // Update status + set(chatStatusAtom, status) + + const currentIds = get(messageIdsAtom) + const currentRoles = get(messageRolesAtom) + + // Build new IDs list and roles map + const newIds = messages.map((m) => m.id) + const newRoles = new Map() + + for (const msg of messages) { + newRoles.set(msg.id, msg.role) + } + + // Check if IDs changed (new message added or removed) + const idsChanged = + newIds.length !== currentIds.length || + newIds.some((id, i) => id !== currentIds[i]) + + if (idsChanged) { + set(messageIdsAtom, newIds) + } + + // Check if roles changed + let rolesChanged = newRoles.size !== currentRoles.size + if (!rolesChanged) { + for (const [id, role] of newRoles) { + if (currentRoles.get(id) !== role) { + rolesChanged = true + break + } + } + } + + if (rolesChanged) { + set(messageRolesAtom, newRoles) + } + + // Update individual message atoms ONLY if they changed + // This is the key optimization - only changed messages trigger re-renders + // CRITICAL: AI SDK mutates objects in-place, so we MUST create a new reference + // for Jotai to detect the change (it uses Object.is() for comparison) + // We need to deep clone the message because: + // 1. msg object itself is mutated in-place + // 2. msg.parts array is mutated in-place + // 3. Individual part objects inside parts are mutated in-place + for (const msg of messages) { + if (hasMessageChanged(currentSubChatId, msg.id, msg)) { + // Deep clone message with new parts array and new part objects + const clonedMsg = { + ...msg, + parts: msg.parts?.map((part: any) => ({ ...part, input: part.input ? { ...part.input } : undefined })), + } + set(messageAtomFamily(msg.id), clonedMsg) + } + } + + // Cleanup removed message atoms to prevent memory leaks + const newIdsSet = new Set(newIds) + const previousIds = activeMessageIdsByChat.get(currentSubChatId) ?? new Set() + + for (const oldId of previousIds) { + if (!newIdsSet.has(oldId)) { + // Message was removed - cleanup its atom and caches + messageAtomFamily.remove(oldId) + previousMessageState.delete(`${currentSubChatId}:${oldId}`) + assistantIdsCacheByChat.delete(`${currentSubChatId}:${oldId}`) + } + } + + // Update active IDs tracking + activeMessageIdsByChat.set(currentSubChatId, newIdsSet) + + // Update streaming message ID + if (status === "streaming" || status === "submitted") { + const lastId = newIds[newIds.length - 1] ?? null + set(streamingMessageIdAtom, lastId) + } else { + set(streamingMessageIdAtom, null) + } + } +) + +// Legacy sync atom (not used, but kept for compatibility) +export const syncMessagesAtom = atom( + null, + (get, set, messages: Message[]) => { + set(syncMessagesWithStatusAtom, { messages, status: get(chatStatusAtom) }) + } +) + +// ============================================================================ +// CLEANUP - For clearing store when switching chats +// ============================================================================ + +// Clear all caches for a specific subChat (call when unmounting/switching) +export function clearSubChatCaches(subChatId: string) { + // Clear message atoms + const activeIds = activeMessageIdsByChat.get(subChatId) + if (activeIds) { + for (const id of activeIds) { + messageAtomFamily.remove(id) + previousMessageState.delete(`${subChatId}:${id}`) + assistantIdsCacheByChat.delete(`${subChatId}:${id}`) + } + activeMessageIdsByChat.delete(subChatId) + } + + // Clear other caches + userMessageIdsCacheByChat.delete(subChatId) + messageGroupsCacheByChat.delete(subChatId) + lastAssistantCacheByChat.delete(subChatId) + tokenDataCacheByChat.delete(subChatId) +} + +// Clear all caches (call on app reset/logout) +export function clearAllCaches() { + for (const subChatId of activeMessageIdsByChat.keys()) { + clearSubChatCaches(subChatId) + } +} + +// ============================================================================ +// TTS PLAYBACK RATE - For PlayButton +// ============================================================================ +// Stored in localStorage and accessible via Jotai atom. +// This allows PlayButton to manage its own state without passing callbacks +// through props (which would break memoization). + +export const PLAYBACK_SPEEDS = [1, 2, 3] as const +export type PlaybackSpeed = (typeof PLAYBACK_SPEEDS)[number] + +// Atom with localStorage persistence +export const ttsPlaybackRateAtom = atom( + // Initial value from localStorage + (() => { + if (typeof window !== "undefined") { + const saved = localStorage.getItem("tts-playback-rate") + if (saved && PLAYBACK_SPEEDS.includes(Number(saved) as PlaybackSpeed)) { + return Number(saved) as PlaybackSpeed + } + } + return 1 + })() +) + +// Write atom that also persists to localStorage +export const setTtsPlaybackRateAtom = atom( + null, + (_get, set, rate: PlaybackSpeed) => { + set(ttsPlaybackRateAtom, rate) + if (typeof window !== "undefined") { + localStorage.setItem("tts-playback-rate", String(rate)) + } + } +) diff --git a/src/renderer/features/agents/stores/streaming-status-store.ts b/src/renderer/features/agents/stores/streaming-status-store.ts new file mode 100644 index 00000000..4f9c9cfc --- /dev/null +++ b/src/renderer/features/agents/stores/streaming-status-store.ts @@ -0,0 +1,57 @@ +import { create } from "zustand" +import { subscribeWithSelector } from "zustand/middleware" + +export type StreamingStatus = "ready" | "streaming" | "submitted" | "error" + +interface StreamingStatusState { + // Map: subChatId -> streaming status + statuses: Record + + // Actions + setStatus: (subChatId: string, status: StreamingStatus) => void + getStatus: (subChatId: string) => StreamingStatus + isStreaming: (subChatId: string) => boolean + clearStatus: (subChatId: string) => void + + // Get all sub-chats that are ready (not streaming) + getReadySubChats: () => string[] +} + +export const useStreamingStatusStore = create()( + subscribeWithSelector((set, get) => ({ + statuses: {}, + + setStatus: (subChatId, status) => { + set((state) => ({ + statuses: { + ...state.statuses, + [subChatId]: status, + }, + })) + }, + + getStatus: (subChatId) => { + return get().statuses[subChatId] ?? "ready" + }, + + isStreaming: (subChatId) => { + const status = get().statuses[subChatId] ?? "ready" + return status === "streaming" || status === "submitted" + }, + + clearStatus: (subChatId) => { + set((state) => { + const newStatuses = { ...state.statuses } + delete newStatuses[subChatId] + return { statuses: newStatuses } + }) + }, + + getReadySubChats: () => { + const { statuses } = get() + return Object.entries(statuses) + .filter(([_, status]) => status === "ready") + .map(([subChatId]) => subChatId) + }, + })) +) diff --git a/src/renderer/features/agents/stores/sub-chat-store.ts b/src/renderer/features/agents/stores/sub-chat-store.ts index 89cc9e94..7be76b8c 100644 --- a/src/renderer/features/agents/stores/sub-chat-store.ts +++ b/src/renderer/features/agents/stores/sub-chat-store.ts @@ -5,7 +5,8 @@ export interface SubChatMeta { name: string created_at?: string updated_at?: string - mode?: "plan" | "agent" + mode?: "plan" | "agent" | "ask" + modelId?: "opus" | "sonnet" | "haiku" } interface AgentSubChatStore { @@ -28,8 +29,10 @@ interface AgentSubChatStore { setAllSubChats: (subChats: SubChatMeta[]) => void addToAllSubChats: (subChat: SubChatMeta) => void updateSubChatName: (subChatId: string, name: string) => void - updateSubChatMode: (subChatId: string, mode: "plan" | "agent") => void + updateSubChatMode: (subChatId: string, mode: "plan" | "agent" | "ask") => void + updateSubChatModel: (subChatId: string, modelId: "opus" | "sonnet" | "haiku") => void updateSubChatTimestamp: (subChatId: string) => void + reorderOpenSubChats: (oldIndex: number, newIndex: number) => void reset: () => void } @@ -175,6 +178,17 @@ export const useAgentSubChatStore = create((set, get) => ({ }) }, + updateSubChatModel: (subChatId, modelId) => { + const { allSubChats } = get() + set({ + allSubChats: allSubChats.map((sc) => + sc.id === subChatId + ? { ...sc, modelId } + : sc, + ), + }) + }, + updateSubChatTimestamp: (subChatId: string) => { const { allSubChats } = get() const newTimestamp = new Date().toISOString() @@ -188,6 +202,20 @@ export const useAgentSubChatStore = create((set, get) => ({ }) }, + reorderOpenSubChats: (oldIndex: number, newIndex: number) => { + const { openSubChatIds, chatId } = get() + if (oldIndex === newIndex) return + if (oldIndex < 0 || oldIndex >= openSubChatIds.length) return + if (newIndex < 0 || newIndex >= openSubChatIds.length) return + + const newIds = [...openSubChatIds] + const [removed] = newIds.splice(oldIndex, 1) + newIds.splice(newIndex, 0, removed) + + set({ openSubChatIds: newIds }) + if (chatId) saveToLS(chatId, "open", newIds) + }, + reset: () => { set({ chatId: null, diff --git a/src/renderer/features/agents/ui/agent-context-indicator.tsx b/src/renderer/features/agents/ui/agent-context-indicator.tsx index 901576fd..e7f29ea0 100644 --- a/src/renderer/features/agents/ui/agent-context-indicator.tsx +++ b/src/renderer/features/agents/ui/agent-context-indicator.tsx @@ -9,22 +9,17 @@ import { import { cn } from "../../../lib/utils" import type { AgentMessageMetadata } from "./agent-message-usage" -// Claude model context windows -const CONTEXT_WINDOWS = { - opus: 200_000, - sonnet: 200_000, - haiku: 200_000, -} as const - -type ModelId = keyof typeof CONTEXT_WINDOWS +// Default Claude model context windows (can be overridden by SDK) +const DEFAULT_CONTEXT_WINDOW = 200_000 interface AgentContextIndicatorProps { messages: Array<{ metadata?: AgentMessageMetadata }> - modelId?: ModelId + contextWindow?: number className?: string onCompact?: () => void isCompacting?: boolean disabled?: boolean + lastCompactPreTokens?: number } function formatTokens(tokens: number): string { @@ -88,18 +83,23 @@ function CircularProgress({ export const AgentContextIndicator = memo(function AgentContextIndicator({ messages, - modelId = "sonnet", + contextWindow: contextWindowProp, className, onCompact, isCompacting, disabled, + lastCompactPreTokens, }: AgentContextIndicatorProps) { - // Calculate session totals from all message metadata - const sessionTotals = useMemo(() => { + // Calculate context usage from most recent API call + // For new messages: Use modelUsage data which includes full context with caching + // For old messages: Estimate context from accumulated conversation tokens + const contextUsage = useMemo(() => { + let currentContextTokens = 0 let totalInputTokens = 0 let totalOutputTokens = 0 let totalCostUsd = 0 + // Sum all tokens for cost/usage tracking for (const msg of messages) { if (msg.metadata) { totalInputTokens += msg.metadata.inputTokens || 0 @@ -108,23 +108,83 @@ export const AgentContextIndicator = memo(function AgentContextIndicator({ } } - const totalTokens = totalInputTokens + totalOutputTokens + // Find the most recent message with metadata (represents current conversation state) + let foundMessageIndex = -1 + let foundMeta: AgentMessageMetadata | undefined + for (let i = messages.length - 1; i >= 0; i--) { + const meta = messages[i].metadata + if ( + meta && + (typeof meta.inputTokens === "number" || + typeof meta.totalTokens === "number") + ) { + foundMessageIndex = i + foundMeta = meta + break + } + } + + let metadataContextWindow: number | undefined + + if (foundMeta) { + // The inputTokens from modelUsage represents the actual context sent to the API + // Cache tokens are for billing tracking, not context size + if (typeof foundMeta.inputTokens === "number") { + currentContextTokens = foundMeta.inputTokens + } else if ( + typeof foundMeta.totalTokens === "number" && + typeof foundMeta.outputTokens === "number" + ) { + currentContextTokens = Math.max( + 0, + foundMeta.totalTokens - foundMeta.outputTokens, + ) + } else if (typeof foundMeta.totalTokens === "number") { + // Fallback: not strictly "context", but better than showing 0. + currentContextTokens = Math.max(0, foundMeta.totalTokens) + } + metadataContextWindow = foundMeta.contextWindow + } + + if (import.meta.env.DEV) { + // Debug logging for context tracking (dev only) + console.log("[CONTEXT_INDICATOR] Context calculation:", { + messagesCount: messages.length, + foundMessageIndex, + foundMetadata: foundMeta + ? { + input: foundMeta.inputTokens, + cacheRead: foundMeta.cacheReadInputTokens, + cacheCreation: foundMeta.cacheCreationInputTokens, + contextWindow: foundMeta.contextWindow, + } + : "not found", + currentContextTokens, + metadataContextWindow, + }) + } return { - inputTokens: totalInputTokens, - outputTokens: totalOutputTokens, - totalTokens, + currentContextTokens, + metadataContextWindow, + totalInputTokens, + totalOutputTokens, totalCostUsd, } }, [messages]) - const contextWindow = CONTEXT_WINDOWS[modelId] + // Use context window from: prop > metadata > default + const contextWindow = + contextWindowProp || + contextUsage.metadataContextWindow || + DEFAULT_CONTEXT_WINDOW + const safeContextWindow = contextWindow > 0 ? contextWindow : DEFAULT_CONTEXT_WINDOW const percentUsed = Math.min( 100, - (sessionTotals.totalTokens / contextWindow) * 100, + (contextUsage.currentContextTokens / safeContextWindow) * 100, ) - const isEmpty = sessionTotals.totalTokens === 0 + const isEmpty = contextUsage.currentContextTokens === 0 const isClickable = onCompact && !disabled && !isCompacting @@ -134,7 +194,7 @@ export const AgentContextIndicator = memo(function AgentContextIndicator({
{isEmpty ? ( - Context: 0 / {formatTokens(contextWindow)} + Context: 0 / {formatTokens(safeContextWindow)} ) : ( <> @@ -163,12 +223,22 @@ export const AgentContextIndicator = memo(function AgentContextIndicator({ · - {formatTokens(sessionTotals.totalTokens)} /{" "} - {formatTokens(contextWindow)} context + {formatTokens(contextUsage.currentContextTokens)} /{" "} + {formatTokens(safeContextWindow)} context )}

+ {typeof lastCompactPreTokens === "number" && lastCompactPreTokens > 0 && ( +

+ Last compact (pre): {formatTokens(lastCompactPreTokens)} tokens +

+ )} + {isClickable && ( +

+ Click to compact context +

+ )} ) diff --git a/src/renderer/features/agents/ui/agent-message-usage.tsx b/src/renderer/features/agents/ui/agent-message-usage.tsx index 394cd476..444c28a0 100644 --- a/src/renderer/features/agents/ui/agent-message-usage.tsx +++ b/src/renderer/features/agents/ui/agent-message-usage.tsx @@ -14,6 +14,9 @@ export interface AgentMessageMetadata { inputTokens?: number outputTokens?: number totalTokens?: number + cacheReadInputTokens?: number + cacheCreationInputTokens?: number + contextWindow?: number finalTextId?: string durationMs?: number resultSubtype?: string diff --git a/src/renderer/features/agents/ui/agent-queue-indicator.tsx b/src/renderer/features/agents/ui/agent-queue-indicator.tsx new file mode 100644 index 00000000..c9b52945 --- /dev/null +++ b/src/renderer/features/agents/ui/agent-queue-indicator.tsx @@ -0,0 +1,184 @@ +"use client" + +import { memo, useState, useCallback, useEffect } from "react" +import { ChevronDown, ArrowUp, X } from "lucide-react" +import { motion, AnimatePresence } from "motion/react" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "../../../components/ui/tooltip" +import { cn } from "../../../lib/utils" +import type { AgentQueueItem } from "../lib/queue-utils" + +const QUEUE_EXPANDED_KEY = "agent-queue-expanded" + +// Queue item row component +const QueueItemRow = memo(function QueueItemRow({ + item, + onRemove, + onSendNow, +}: { + item: AgentQueueItem + onRemove?: (itemId: string) => void + onSendNow?: (itemId: string) => void +}) { + const handleRemove = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + onRemove?.(item.id) + }, + [item.id, onRemove] + ) + + const handleSendNow = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + onSendNow?.(item.id) + }, + [item.id, onSendNow] + ) + + // Get display text - truncate message and show attachment count + const hasAttachments = + (item.images && item.images.length > 0) || + (item.files && item.files.length > 0) + const attachmentCount = + (item.images?.length || 0) + (item.files?.length || 0) + + return ( +
+ {item.message} + {hasAttachments && ( + + +{attachmentCount} {attachmentCount === 1 ? "file" : "files"} + + )} +
+ {onSendNow && ( + + + + + Send now + + )} + {onRemove && ( + + + + + Remove + + )} +
+
+ ) +}) + +interface AgentQueueIndicatorProps { + queue: AgentQueueItem[] + onRemoveItem?: (itemId: string) => void + onSendNow?: (itemId: string) => void + isStreaming?: boolean + /** Whether there's a status card below this one - affects border radius */ + hasStatusCardBelow?: boolean +} + +export const AgentQueueIndicator = memo(function AgentQueueIndicator({ + queue, + onRemoveItem, + onSendNow, + isStreaming = false, + hasStatusCardBelow = false, +}: AgentQueueIndicatorProps) { + // Load expanded state from localStorage + const [isExpanded, setIsExpanded] = useState(() => { + if (typeof window === "undefined") return true + const saved = localStorage.getItem(QUEUE_EXPANDED_KEY) + return saved !== null ? saved === "true" : true // Default to expanded + }) + + // Save expanded state to localStorage + useEffect(() => { + localStorage.setItem(QUEUE_EXPANDED_KEY, String(isExpanded)) + }, [isExpanded]) + + if (queue.length === 0) { + return null + } + + return ( +
+ {/* Header - at top */} +
setIsExpanded(!isExpanded)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + setIsExpanded(!isExpanded) + } + }} + aria-expanded={isExpanded} + aria-label={`${isExpanded ? "Collapse" : "Expand"} queue`} + className="flex items-center justify-between pr-1 pl-3 h-8 cursor-pointer hover:bg-muted/50 transition-colors duration-150 focus:outline-none rounded-sm" + > +
+ + + {queue.length} in queue + +
+ +
+ + {/* Expanded content - queue items */} + + {isExpanded && ( + +
+ {queue.map((item) => ( + + ))} +
+
+ )} +
+
+ ) +}) diff --git a/src/renderer/features/agents/ui/agent-text-context-item.tsx b/src/renderer/features/agents/ui/agent-text-context-item.tsx new file mode 100644 index 00000000..51e35fd0 --- /dev/null +++ b/src/renderer/features/agents/ui/agent-text-context-item.tsx @@ -0,0 +1,73 @@ +"use client" + +import { useState } from "react" +import { X, Quote } from "lucide-react" +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "../../../components/ui/hover-card" + +interface AgentTextContextItemProps { + text: string + preview: string + onRemove?: () => void +} + +export function AgentTextContextItem({ + text, + preview, + onRemove, +}: AgentTextContextItemProps) { + const [isHovered, setIsHovered] = useState(false) + + return ( + + +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + + + + {preview} + + + {onRemove && ( + + )} +
+
+ +
+
+ + Selected text +
+

{text}

+
+
+
+ ) +} diff --git a/src/renderer/features/agents/ui/agent-tool-registry.tsx b/src/renderer/features/agents/ui/agent-tool-registry.tsx index 60f4ba79..c39725e1 100644 --- a/src/renderer/features/agents/ui/agent-tool-registry.tsx +++ b/src/renderer/features/agents/ui/agent-tool-registry.tsx @@ -452,6 +452,11 @@ export const AgentToolRegistry: Record = { part.state !== "output-available" && part.state !== "output-error" return isPending ? "Compacting..." : "Compacted" }, + subtitle: (part) => { + const preTokens = (part as any)?.preTokens + if (typeof preTokens !== "number" || preTokens <= 0) return "" + return `Before: ${preTokens.toLocaleString()} tokens` + }, variant: "simple", }, diff --git a/src/renderer/features/agents/ui/agents-content.tsx b/src/renderer/features/agents/ui/agents-content.tsx index 132035de..6cbb8efd 100644 --- a/src/renderer/features/agents/ui/agents-content.tsx +++ b/src/renderer/features/agents/ui/agents-content.tsx @@ -1,14 +1,13 @@ "use client" -import { useEffect, useMemo, useRef, useState } from "react" +import { memo, useDeferredValue, useEffect, useMemo, useRef, useState } from "react" import { useAtom, useAtomValue, useSetAtom } from "jotai" // import { useSearchParams, useRouter } from "next/navigation" // Desktop doesn't use next/navigation // Desktop: mock Next.js navigation hooks const useSearchParams = () => ({ get: () => null }) -const useRouter = () => ({ push: () => {}, replace: () => {} }) +const useRouter = () => ({ push: () => { }, replace: () => { } }) // Desktop: mock Clerk hooks const useUser = () => ({ user: null }) -const useClerk = () => ({ signOut: () => {} }) import { selectedAgentChatIdAtom, previousAgentChatIdAtom, @@ -46,7 +45,7 @@ import { motion, AnimatePresence } from "motion/react" import { ResizableSidebar } from "../../../components/ui/resizable-sidebar" // import { useClerk, useUser } from "@clerk/nextjs" // import { useCombinedAuth } from "@/lib/hooks/use-combined-auth" -const useCombinedAuth = () => ({ userId: null }) // Desktop mock +const useCombinedAuth = () => ({ userId: "local" }) // Desktop mock import { Button } from "../../../components/ui/button" import { AlignJustify } from "lucide-react" import { AgentsQuickSwitchDialog } from "../components/agents-quick-switch-dialog" @@ -55,9 +54,11 @@ import { isDesktopApp } from "../../../lib/utils/platform" // Desktop mock const useIsAdmin = () => false -// Main Component -export function AgentsContent() { +// Main Component - memoized to prevent unnecessary re-renders +export const AgentsContent = memo(function AgentsContent() { const [selectedChatId, setSelectedChatId] = useAtom(selectedAgentChatIdAtom) + // Defer the chat ID for heavy rendering (ChatView) to keep UI responsive + const deferredChatId = useDeferredValue(selectedChatId) const [selectedTeamId] = useAtom(selectedTeamIdAtom) const [sidebarOpen, setSidebarOpen] = useAtom(agentsSidebarOpenAtom) const [previewSidebarOpen, setPreviewSidebarOpen] = useAtom( @@ -83,7 +84,6 @@ export function AgentsContent() { const [isHydrated, setIsHydrated] = useState(false) const { userId } = useCombinedAuth() const { user } = useUser() - const { signOut } = useClerk() const isAdmin = useIsAdmin() // Quick-switch dialog state - Agents (Opt+Ctrl+Tab) @@ -257,9 +257,9 @@ export function AgentsContent() { // IMPORTANT: Only recalculate when dialog is closed to prevent flickering const sortedChats = agentChats ? [...agentChats].sort( - (a, b) => - new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(), - ) + (a, b) => + new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(), + ) : [] let recentChats: typeof sortedChats = [] @@ -666,7 +666,7 @@ export function AgentsContent() { if (subChatQuickSwitchOpenRef.current) { const selectedSubChat = frozenSubChatsRef.current?.[ - subChatQuickSwitchSelectedIndexRef.current + subChatQuickSwitchSelectedIndexRef.current ] if (selectedSubChat) { @@ -694,14 +694,7 @@ export function AgentsContent() { // Note: Cmd+E archive hotkey is handled in AgentsSidebar to share undo stack const handleSignOut = async () => { - // Check if running in Electron desktop app - if (typeof window !== "undefined" && window.desktopApi) { - // Use desktop logout which clears the token and shows login page - await window.desktopApi.logout() - } else { - // Web: use Clerk sign out - await signOut({ redirectUrl: window.location.pathname }) - } + // No-op in desktop local mode (no app-level login) } // Check if sub-chats data is loaded (use separate selectors to avoid object creation) @@ -743,10 +736,10 @@ export function AgentsContent() { // Check if chat has sandbox with port for preview const chatMeta = chatData?.meta as | { - sandboxConfig?: { port?: number } - isQuickSetup?: boolean - repository?: string - } + sandboxConfig?: { port?: number } + isQuickSetup?: boolean + repository?: string + } | undefined const isQuickSetup = chatMeta?.isQuickSetup === true const canShowPreview = !!( @@ -776,7 +769,7 @@ export function AgentsContent() { userId={userId} clerkUser={user} onSignOut={handleSignOut} - onToggleSidebar={() => {}} + onToggleSidebar={() => { }} isMobileFullscreen={true} onChatSelect={() => setMobileViewMode("chat")} /> @@ -819,10 +812,10 @@ export function AgentsContent() { > {selectedChatId ? ( {}} + onToggleSidebar={() => { }} selectedTeamName={selectedTeam?.name} selectedTeamImageUrl={selectedTeam?.image_url} isMobileFullscreen={true} @@ -841,9 +834,9 @@ export function AgentsContent() { onOpenTerminal={ canShowTerminal ? () => { - setTerminalSidebarOpen(true) - setMobileViewMode("terminal") - } + setTerminalSidebarOpen(true) + setMobileViewMode("terminal") + } : undefined } /> @@ -903,8 +896,8 @@ export function AgentsContent() { {selectedChatId ? (
setSidebarOpen((prev) => !prev)} selectedTeamName={selectedTeam?.name} @@ -954,4 +947,4 @@ export function AgentsContent() { )} ) -} +}) diff --git a/src/renderer/features/agents/ui/agents-header-controls.tsx b/src/renderer/features/agents/ui/agents-header-controls.tsx index 26c8a00f..eedec7f4 100644 --- a/src/renderer/features/agents/ui/agents-header-controls.tsx +++ b/src/renderer/features/agents/ui/agents-header-controls.tsx @@ -1,7 +1,7 @@ "use client" import { Button } from "../../../components/ui/button" -import { AlignJustify } from "lucide-react" +import { AlignJustify, PanelRightClose, PanelRightOpen } from "lucide-react" import { Tooltip, TooltipContent, @@ -15,6 +15,8 @@ interface AgentsHeaderControlsProps { onToggleSidebar: () => void hasUnseenChanges?: boolean isSubChatsSidebarOpen?: boolean + isRightPanelOpen?: boolean + onToggleRightPanel?: () => void } export function AgentsHeaderControls({ @@ -22,33 +24,64 @@ export function AgentsHeaderControls({ onToggleSidebar, hasUnseenChanges = false, isSubChatsSidebarOpen = false, + isRightPanelOpen = false, + onToggleRightPanel, }: AgentsHeaderControlsProps) { - // Only show open button when both sidebars are closed - if (isSidebarOpen || isSubChatsSidebarOpen) return null + // Only show open button for left sidebar when both sidebars are closed + // But always show right panel toggle if onToggleRightPanel is provided + // if (isSidebarOpen || isSubChatsSidebarOpen) return null + return ( - - - + {(!isSidebarOpen && !isSubChatsSidebarOpen) && ( + + + + )} Open sidebar ⌘\ + + {/* Right Panel Toggle */} + {onToggleRightPanel && ( + + + + + + {isRightPanelOpen ? "Close activity panel" : "Open activity panel"} + ⌘⇧P + + + )} ) } diff --git a/src/renderer/features/agents/ui/message-part-renderer.tsx b/src/renderer/features/agents/ui/message-part-renderer.tsx new file mode 100644 index 00000000..73206a84 --- /dev/null +++ b/src/renderer/features/agents/ui/message-part-renderer.tsx @@ -0,0 +1,254 @@ +import { memo } from "react" +import { cn } from "../../../lib/utils" +import { ChatMarkdownRenderer } from "../../../components/chat-markdown-renderer" +import { AgentAskUserQuestionTool } from "./agent-ask-user-question-tool" +import { AgentBashTool } from "./agent-bash-tool" +import { AgentEditTool } from "./agent-edit-tool" +import { AgentExitPlanModeTool } from "./agent-exit-plan-mode-tool" +import { AgentExploringGroup } from "./agent-exploring-group" +import { AgentPlanTool } from "./agent-plan-tool" +import { AgentTaskTool } from "./agent-task-tool" +import { AgentThinkingTool } from "./agent-thinking-tool" +import { AgentTodoTool } from "./agent-todo-tool" +import { AgentToolCall } from "./agent-tool-call" +import { AgentToolRegistry, getToolStatus } from "./agent-tool-registry" +import { AgentWebFetchTool } from "./agent-web-fetch-tool" +import { AgentWebSearchCollapsible } from "./agent-web-search-collapsible" + +// ============================================================================ +// Types +// ============================================================================ + +export interface MessagePartRendererProps { + part: any + idx: number + isFinal?: boolean + // Nested tools data + nestedToolsMap: Map + nestedToolIds: Set + orphanToolCallIds: Set + orphanFirstToolCallIds: Set + orphanTaskGroups: Map + // Message context + status: string + finalTextIndex: number + visibleStepsCount: number + isStreaming: boolean + isLastMessage: boolean + subChatId: string +} + +// ============================================================================ +// Component +// ============================================================================ + +/** + * Memoized component for rendering individual message parts. + * Extracted from active-chat.tsx to prevent re-creation on every render. + */ +export const MessagePartRenderer = memo(function MessagePartRenderer({ + part, + idx, + isFinal = false, + nestedToolsMap, + nestedToolIds, + orphanToolCallIds, + orphanFirstToolCallIds, + orphanTaskGroups, + status, + finalTextIndex, + visibleStepsCount, + isStreaming, + isLastMessage, + subChatId, +}: MessagePartRendererProps) { + // Skip step-start parts + if (part.type === "step-start") { + return null + } + + // Skip TaskOutput - internal tool with meta info not useful for UI + if (part.type === "tool-TaskOutput") { + return null + } + + // Handle orphan tool calls (nested tools without parent Task) + if (part.toolCallId && orphanToolCallIds.has(part.toolCallId)) { + if (!orphanFirstToolCallIds.has(part.toolCallId)) { + return null + } + const parentId = part.toolCallId.split(":")[0] + const group = orphanTaskGroups.get(parentId) + if (group) { + return ( + + ) + } + } + + // Skip nested tools - they're rendered within their parent Task + if (part.toolCallId && nestedToolIds.has(part.toolCallId)) { + return null + } + + // Exploring group - handled separately in the parent with isLast info + if (part.type === "exploring-group") { + return null + } + + // Text parts - with px-2 like Canvas + if (part.type === "text") { + if (!part.text?.trim()) return null + const isFinalText = isFinal && idx === finalTextIndex + + return ( +
0 && "pt-3 border-t border-border/50", + )} + > + {/* Only show Summary label if there are steps to collapse */} + {isFinalText && visibleStepsCount > 0 && ( +
+ Response +
+ )} + +
+ ) + } + + // Special handling for tool-Task - render with nested tools + if (part.type === "tool-Task") { + const nestedTools = nestedToolsMap.get(part.toolCallId) || [] + return ( + + ) + } + + // Special handling for tool-Bash - render with full command and output + if (part.type === "tool-Bash") { + return + } + + // Special handling for tool-Thinking - Extended Thinking + if (part.type === "tool-Thinking") { + return + } + + // Special handling for tool-Edit - render with file icon and diff stats + if (part.type === "tool-Edit") { + return + } + + // Special handling for tool-Write - render with file preview (reuses AgentEditTool) + if (part.type === "tool-Write") { + return + } + + // Special handling for tool-WebSearch - collapsible results list + if (part.type === "tool-WebSearch") { + return + } + + // Special handling for tool-WebFetch - expandable content preview + if (part.type === "tool-WebFetch") { + return + } + + // Special handling for tool-PlanWrite - plan with steps + if (part.type === "tool-PlanWrite") { + return + } + + // Special handling for tool-ExitPlanMode - show simple indicator inline + // Full plan card is rendered at end of message + if (part.type === "tool-ExitPlanMode") { + const { isPending, isError } = getToolStatus(part, status) + return ( + + ) + } + + // Special handling for tool-TodoWrite - todo list with progress + if (part.type === "tool-TodoWrite") { + return ( + + ) + } + + // Special handling for tool-AskUserQuestion + if (part.type === "tool-AskUserQuestion") { + const { isPending, isError } = getToolStatus(part, status) + return ( + + ) + } + + // Tool parts - check registry + if (part.type in AgentToolRegistry) { + const meta = AgentToolRegistry[part.type] + const { isPending, isError } = getToolStatus(part, status) + return ( + + ) + } + + // Fallback for unknown tool types + if (part.type?.startsWith("tool-")) { + return ( +
+ {part.type.replace("tool-", "")} +
+ ) + } + + return null +}) diff --git a/src/renderer/features/agents/ui/sub-chat-selector.tsx b/src/renderer/features/agents/ui/sub-chat-selector.tsx index 5a421bb5..5f4ece45 100644 --- a/src/renderer/features/agents/ui/sub-chat-selector.tsx +++ b/src/renderer/features/agents/ui/sub-chat-selector.tsx @@ -1,6 +1,6 @@ "use client" -import { useCallback, useMemo, useEffect, useRef, useState } from "react" +import { memo, useCallback, useMemo, useEffect, useRef, useState } from "react" import { useAtom, useAtomValue, useSetAtom } from "jotai" import { loadingSubChatsAtom, @@ -9,7 +9,7 @@ import { pendingUserQuestionsAtom, } from "../atoms" import { trpc } from "../../../lib/trpc" -import { X, Plus, AlignJustify, Play } from "lucide-react" +import { X, Plus, AlignJustify, Play, GripVertical } from "lucide-react" import { IconSpinner, PlanIcon, @@ -45,6 +45,25 @@ import { toast } from "sonner" import { SearchCombobox } from "../../../components/ui/search-combobox" import { SubChatContextMenu } from "./sub-chat-context-menu" import { formatTimeAgo } from "../utils/format-time-ago" +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragEndEvent, + DragStartEvent, + DragOverlay, +} from "@dnd-kit/core" +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + horizontalListSortingStrategy, + useSortable, +} from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" interface DiffStats { fileCount: number @@ -54,6 +73,223 @@ interface DiffStats { hasChanges: boolean } +interface SortableTabProps { + subChat: SubChatMeta + isActive: boolean + isLoading: boolean + hasUnseen: boolean + isPinned: boolean + hasPendingQuestion: boolean + hasPendingPlan: boolean + isEditing: boolean + editName: string + editLoading: boolean + isTruncated: boolean + canClose: boolean + onSwitch: (id: string) => void + onRename: (subChat: SubChatMeta) => void + onEditSave: (subChat: SubChatMeta) => void + onEditCancel: (subChat: SubChatMeta) => void + onEditNameChange: (name: string) => void + onClose: (id: string) => void + tabRef: (el: HTMLButtonElement | null) => void + textRef: (el: HTMLSpanElement | null) => void + isDragging?: boolean +} + +function SortableTab({ + subChat, + isActive, + isLoading, + hasUnseen, + isPinned, + hasPendingQuestion, + hasPendingPlan, + isEditing, + editName, + editLoading, + isTruncated, + canClose, + onSwitch, + onRename, + onEditSave, + onEditCancel, + onEditNameChange, + onClose, + tabRef, + textRef, + isDragging = false, +}: SortableTabProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging: isSortableDragging, + } = useSortable({ id: subChat.id }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isSortableDragging ? 0.5 : 1, + } + + const mode = subChat.mode || "agent" + + return ( + + ) +} + +// Overlay component for drag preview (simpler version without refs) +function TabDragOverlay({ subChat, isActive }: { subChat: SubChatMeta; isActive: boolean }) { + const mode = subChat.mode || "agent" + + return ( +
+
+ {mode === "plan" ? ( + + ) : ( + + )} +
+ {subChat.name || "New Chat"} +
+ ) +} + interface SubChatSelectorProps { onCreateNew: () => void isMobile?: boolean @@ -66,7 +302,8 @@ interface SubChatSelectorProps { diffStats?: DiffStats } -export function SubChatSelector({ +// Memoized to prevent re-renders when parent updates +export const SubChatSelector = memo(function SubChatSelector({ onCreateNew, isMobile = false, onBackToChats, @@ -78,7 +315,7 @@ export function SubChatSelector({ diffStats, }: SubChatSelectorProps) { // Use shallow comparison to prevent re-renders when arrays have same content - const { activeSubChatId, openSubChatIds, pinnedSubChatIds, allSubChats, parentChatId, togglePinSubChat } = useAgentSubChatStore( + const { activeSubChatId, openSubChatIds, pinnedSubChatIds, allSubChats, parentChatId, togglePinSubChat, reorderOpenSubChats } = useAgentSubChatStore( useShallow((state) => ({ activeSubChatId: state.activeSubChatId, openSubChatIds: state.openSubChatIds, @@ -86,6 +323,7 @@ export function SubChatSelector({ allSubChats: state.allSubChats, parentChatId: state.chatId, togglePinSubChat: state.togglePinSubChat, + reorderOpenSubChats: state.reorderOpenSubChats, })) ) const [loadingSubChats] = useAtom(loadingSubChatsAtom) @@ -118,6 +356,7 @@ export function SubChatSelector({ const [truncatedTabs, setTruncatedTabs] = useState>(new Set()) const [showLeftGradient, setShowLeftGradient] = useState(false) const [showRightGradient, setShowRightGradient] = useState(false) + const [activeDragId, setActiveDragId] = useState(null) // Map open IDs to metadata and sort: pinned first, then preserve user's tab order const openSubChats = useMemo(() => { @@ -147,6 +386,40 @@ export function SubChatSelector({ return [...pinnedChats, ...unpinnedChats] }, [openSubChatIds, allSubChats, pinnedSubChatIds]) + // dnd-kit sensors with distance threshold to allow clicks + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, // 8px drag distance before activation + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ) + + const handleDragStart = useCallback((event: DragStartEvent) => { + setActiveDragId(event.active.id as string) + }, []) + + const handleDragEnd = useCallback((event: DragEndEvent) => { + const { active, over } = event + setActiveDragId(null) + + if (over && active.id !== over.id) { + const oldIndex = openSubChatIds.indexOf(active.id as string) + const newIndex = openSubChatIds.indexOf(over.id as string) + if (oldIndex !== -1 && newIndex !== -1) { + reorderOpenSubChats(oldIndex, newIndex) + } + } + }, [openSubChatIds, reorderOpenSubChats]) + + const activeDragSubChat = useMemo(() => { + if (!activeDragId) return null + return openSubChats.find((sc) => sc.id === activeDragId) || null + }, [activeDragId, openSubChats]) + const onSwitch = useCallback( (subChatId: string) => { const store = useAgentSubChatStore.getState() @@ -491,193 +764,102 @@ export function SubChatSelector({ )} {/* Scrollable tabs container - with padding-right for plus button */} -
- {hasNoChats - ? null - : openSubChats.map((subChat, index) => { - const isActive = activeSubChatId === subChat.id - const isLoading = loadingSubChats.has(subChat.id) - const hasUnseen = subChatUnseenChanges.has(subChat.id) - const hasTabsToRight = index < openSubChats.length - 1 - const isPinned = pinnedSubChatIds.includes(subChat.id) - // Get mode from sub-chat itself (defaults to "agent") - const mode = subChat.mode || "agent" - // Check if this chat is waiting for user answer - const hasPendingQuestion = pendingQuestions?.subChatId === subChat.id - // Check if this chat has a pending plan approval - const hasPendingPlan = pendingPlanApprovals.has(subChat.id) - - return ( - - - - - 2} - /> - - ) - })} -
+ /> + + 2} + /> + + ) + })} +
+ + + {activeDragSubChat ? ( + + ) : null} + + {/* Plus button - absolute positioned on right with gradient cover */} {(isMobile || (!isMobile && subChatsSidebarMode === "tabs")) && ( @@ -881,4 +1063,4 @@ export function SubChatSelector({
) -} +}) diff --git a/src/renderer/features/agents/ui/text-selection-popover.tsx b/src/renderer/features/agents/ui/text-selection-popover.tsx new file mode 100644 index 00000000..ed40c0a8 --- /dev/null +++ b/src/renderer/features/agents/ui/text-selection-popover.tsx @@ -0,0 +1,104 @@ +"use client" + +import { useEffect, useCallback, useState, useRef } from "react" +import { createPortal } from "react-dom" +import { useTextSelection } from "../context/text-selection-context" + +interface TextSelectionPopoverProps { + onAddToContext: (text: string, messageId: string) => void +} + +export function TextSelectionPopover({ + onAddToContext, +}: TextSelectionPopoverProps) { + const { selectedText, selectedMessageId, selectionRect, clearSelection } = + useTextSelection() + const [isVisible, setIsVisible] = useState(false) + const [isMouseDown, setIsMouseDown] = useState(false) + const popoverRef = useRef(null) + + const handleAddToContext = useCallback(() => { + if (selectedText && selectedMessageId) { + onAddToContext(selectedText, selectedMessageId) + clearSelection() + setIsVisible(false) + } + }, [selectedText, selectedMessageId, onAddToContext, clearSelection]) + + // Track mouse down/up to know when selection is complete + useEffect(() => { + const handleMouseDown = (e: MouseEvent) => { + // Ignore clicks on the popover itself + if (popoverRef.current?.contains(e.target as Node)) { + return + } + setIsMouseDown(true) + setIsVisible(false) // Hide while selecting + } + + const handleMouseUp = (e: MouseEvent) => { + // Ignore clicks on the popover itself + if (popoverRef.current?.contains(e.target as Node)) { + return + } + setIsMouseDown(false) + } + + document.addEventListener("mousedown", handleMouseDown) + document.addEventListener("mouseup", handleMouseUp) + + return () => { + document.removeEventListener("mousedown", handleMouseDown) + document.removeEventListener("mouseup", handleMouseUp) + } + }, []) + + // Show popover only when mouse is up and we have a valid selection + useEffect(() => { + if (!isMouseDown && selectedText && selectedMessageId && selectionRect) { + setIsVisible(true) + } else if (!selectedText || !selectedMessageId || !selectionRect) { + setIsVisible(false) + } + }, [isMouseDown, selectedText, selectedMessageId, selectionRect]) + + // Don't render if not visible + if (!isVisible || !selectedText || !selectedMessageId || !selectionRect) { + return null + } + + // Calculate position - below the selection, centered + // But clamp to viewport bounds + const viewportWidth = window.innerWidth + const popoverWidth = 120 // approximate width + let left = selectionRect.left + selectionRect.width / 2 + + // Clamp left position to prevent overflow + left = Math.max(popoverWidth / 2 + 8, Math.min(left, viewportWidth - popoverWidth / 2 - 8)) + + const style: React.CSSProperties = { + position: "fixed", + top: selectionRect.bottom + 6, + left, + transform: "translateX(-50%)", + zIndex: 100000, + } + + const popoverContent = ( +
+ +
+ ) + + return createPortal(popoverContent, document.body) +} diff --git a/src/renderer/features/git/commit-graph-panel.tsx b/src/renderer/features/git/commit-graph-panel.tsx new file mode 100644 index 00000000..04b139f7 --- /dev/null +++ b/src/renderer/features/git/commit-graph-panel.tsx @@ -0,0 +1,251 @@ +"use client" + +import { useState, useMemo } from "react" +import { trpc } from "../../lib/trpc" +import { RefreshCw, History, ChevronDown, ChevronRight, Cloud, CloudOff } from "lucide-react" +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "../../components/ui/collapsible" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "../../components/ui/tooltip" +import { + formatCommits, + setBranchAndCommitColor, + defaultStyle, + getCommitDotPosition, +} from "../../lib/commit-graph" +import { computePosition } from "../../lib/commit-graph/compute-position" +import { Branches, Curves, CommitDot } from "../../lib/commit-graph/svg-components" +import type { CommitNode, Branch } from "../../lib/commit-graph/types" + +interface CommitGraphPanelProps { + worktreePath: string | null +} + +const graphStyle = { + ...defaultStyle, + commitSpacing: 28, + branchSpacing: 12, // Tighter spacing for narrow column + nodeRadius: 3, +} + +const GRAPH_WIDTH = 40 // Fixed narrow width for graph column + +export function CommitGraphPanel({ worktreePath }: CommitGraphPanelProps) { + const [isOpen, setIsOpen] = useState(true) + + const { data, isLoading, isFetching } = trpc.changes.getCommitLog.useQuery( + { worktreePath: worktreePath || "", limit: 50, offset: 0 }, + { + enabled: !!worktreePath, + staleTime: 30000, + } + ) + + // Process commits for graph rendering + const graphData = useMemo(() => { + if (!data?.commits || data.commits.length === 0) return null + + const commitNodes = formatCommits(data.commits) + const { columns, commitsMap } = computePosition(commitNodes) + setBranchAndCommitColor(columns, graphStyle.branchColors, commitsMap) + + const commitsArray = Array.from(commitsMap.values()) + const height = commitsMap.size + ? Math.max(...commitsArray.map((c) => c.y)) * graphStyle.commitSpacing + graphStyle.nodeRadius * 8 + 20 + : 0 + + return { + columns, + commitsMap, + commitsArray, + height, + branches: data.branches, + remoteShas: new Set(data.remoteShas), + } + }, [data]) + + if (!worktreePath) { + return null + } + + const commitCount = data?.totalCount ?? 0 + + return ( + + +
+ {isOpen ? ( + + ) : ( + + )} + + History + {commitCount > 0 && ( + + ({commitCount}) + + )} +
+ {(isLoading || isFetching) && ( + + )} +
+ +
+ {isLoading ? ( +
+ + Loading history... +
+ ) : graphData && graphData.commitsArray.length > 0 ? ( +
+ {/* SVG Graph - narrow fixed width, overlaid behind text */} + + + + {graphData.commitsArray.map((commit) => ( + + ))} + + + {/* Commit Rows (our UI) - overlaid on top of graph */} +
+ {graphData.commitsArray.map((commit) => ( + + ))} +
+
+ ) : ( +
+ No commit history +
+ )} +
+
+
+ ) +} + +// Our custom commit row component +function CommitRow({ + commit, + branches, + isLocal, + graphStyle, +}: { + commit: CommitNode + branches: Branch[] + isLocal: boolean + graphStyle: typeof defaultStyle +}) { + const { y } = getCommitDotPosition( + graphStyle.branchSpacing, + graphStyle.commitSpacing, + graphStyle.nodeRadius, + commit + ) + + // Find branch labels for this commit + const commitBranches = branches.filter((b) => b.commit.sha === commit.hash) + const shortHash = commit.hash.slice(0, 7) + + return ( +
+ {/* Commit message */} + + + + {commit.message} + + + +

{commit.message}

+

+ {commit.committer} · {shortHash} +

+
+
+ + {/* Branch labels - smaller, inline badges with truncation */} + {commitBranches.map((branch) => ( + + + + {branch.name} + + + +

{branch.name}

+
+
+ ))} + + {/* Spacer to push hash and icon to the right */} +
+ + {/* Short hash - right aligned */} + {shortHash} + + {/* Sync status */} + {isLocal ? ( + + + + + Not pushed to remote + + ) : ( + + )} +
+ ) +} diff --git a/src/renderer/features/git/git-panel.tsx b/src/renderer/features/git/git-panel.tsx new file mode 100644 index 00000000..db80e3d0 --- /dev/null +++ b/src/renderer/features/git/git-panel.tsx @@ -0,0 +1,697 @@ +"use client" + +import { useState, useMemo, useCallback } from "react" +import { trpc } from "../../lib/trpc" +import { + GitBranch, + GitCommit, + Plus, + Minus, + RefreshCw, + Check, + ChevronDown, + ChevronRight, + Undo2, + Upload, + Download, + Sparkles, + Loader2, + ArrowDownUp, +} from "lucide-react" +import { cn } from "../../lib/utils" +import { Button } from "../../components/ui/button" +import { Textarea } from "../../components/ui/textarea" +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "../../components/ui/collapsible" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "../../components/ui/tooltip" +import { toast } from "sonner" +import type { ChangedFile, FileStatus } from "../../../shared/changes-types" +import { CommitGraphPanel } from "./commit-graph-panel" + +interface GitPanelProps { + worktreePath: string | null + defaultBranch?: string + onFileSelect?: (filePath: string, category: "staged" | "unstaged") => void +} + +const statusColors: Record = { + added: "text-green-500", + modified: "text-blue-500", + deleted: "text-red-500", + renamed: "text-purple-500", + copied: "text-purple-500", + untracked: "text-muted-foreground", +} + +const statusLabels: Record = { + added: "A", + modified: "M", + deleted: "D", + renamed: "R", + copied: "C", + untracked: "U", +} + +function FileItem({ + file, + category, + onStage, + onUnstage, + onDiscard, + onSelect, + isStaging, +}: { + file: ChangedFile + category: "staged" | "unstaged" | "untracked" + onStage?: () => void + onUnstage?: () => void + onDiscard?: () => void + onSelect?: () => void + isStaging?: boolean +}) { + const [isHovered, setIsHovered] = useState(false) + const fileName = file.path.split("/").pop() || file.path + const dirPath = file.path.includes("/") + ? file.path.substring(0, file.path.lastIndexOf("/")) + : "" + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onClick={onSelect} + > + + {statusLabels[file.status]} + + + {fileName} + {dirPath && ( + {dirPath} + )} + + {file.additions > 0 && ( + +{file.additions} + )} + {file.deletions > 0 && ( + -{file.deletions} + )} + {isHovered && !isStaging && ( +
+ {category === "staged" && onUnstage && ( + + + + + Unstage + + )} + {(category === "unstaged" || category === "untracked") && onStage && ( + + + + + Stage + + )} + {category === "unstaged" && onDiscard && ( + + + + + Discard Changes + + )} +
+ )} +
+ ) +} + +export function GitPanel({ + worktreePath, + defaultBranch = "main", + onFileSelect, +}: GitPanelProps) { + const [commitMessage, setCommitMessage] = useState("") + const [stagedOpen, setStagedOpen] = useState(true) + const [changesOpen, setChangesOpen] = useState(true) + const [outgoingOpen, setOutgoingOpen] = useState(true) + const [isStaging, setIsStaging] = useState(false) + + const utils = trpc.useUtils() + + const { data: status, isLoading, refetch } = trpc.changes.getStatus.useQuery( + { worktreePath: worktreePath || "", defaultBranch }, + { enabled: !!worktreePath, refetchInterval: 5000 } + ) + + const stageMutation = trpc.changes.stageFile.useMutation({ + onSuccess: () => utils.changes.getStatus.invalidate(), + onError: (err) => toast.error(`Failed to stage: ${err.message}`), + }) + + const unstageMutation = trpc.changes.unstageFile.useMutation({ + onSuccess: () => utils.changes.getStatus.invalidate(), + onError: (err) => toast.error(`Failed to unstage: ${err.message}`), + }) + + const stageAllMutation = trpc.changes.stageAll.useMutation({ + onSuccess: () => utils.changes.getStatus.invalidate(), + onError: (err) => toast.error(`Failed to stage all: ${err.message}`), + }) + + const unstageAllMutation = trpc.changes.unstageAll.useMutation({ + onSuccess: () => utils.changes.getStatus.invalidate(), + onError: (err) => toast.error(`Failed to unstage all: ${err.message}`), + }) + + const discardMutation = trpc.changes.discardChanges.useMutation({ + onSuccess: () => utils.changes.getStatus.invalidate(), + onError: (err) => toast.error(`Failed to discard: ${err.message}`), + }) + + const commitMutation = trpc.changes.commit.useMutation({ + onSuccess: () => { + utils.changes.getStatus.invalidate() + setCommitMessage("") + toast.success("Changes committed!") + }, + onError: (err) => toast.error(`Commit failed: ${err.message}`), + }) + + const pushMutation = trpc.changes.push.useMutation({ + onSuccess: () => { + utils.changes.getStatus.invalidate() + toast.success("Pushed to remote!") + }, + onError: (err) => toast.error(`Push failed: ${err.message}`), + }) + + const pullMutation = trpc.changes.pull.useMutation({ + onSuccess: () => { + utils.changes.getStatus.invalidate() + toast.success("Pulled from remote!") + }, + onError: (err) => toast.error(`Pull failed: ${err.message}`), + }) + + const syncMutation = trpc.changes.sync.useMutation({ + onSuccess: () => { + utils.changes.getStatus.invalidate() + toast.success("Synced with remote!") + }, + onError: (err) => toast.error(`Sync failed: ${err.message}`), + }) + + const generateMutation = trpc.changes.generateCommitMessage.useMutation({ + onSuccess: (data) => { + setCommitMessage(data.message) + toast.success("Generated commit message") + }, + onError: (err) => toast.error(err.message), + }) + + const handleStage = useCallback( + async (filePath: string) => { + if (!worktreePath) return + setIsStaging(true) + try { + await stageMutation.mutateAsync({ worktreePath, filePath }) + } finally { + setIsStaging(false) + } + }, + [worktreePath, stageMutation] + ) + + const handleUnstage = useCallback( + async (filePath: string) => { + if (!worktreePath) return + setIsStaging(true) + try { + await unstageMutation.mutateAsync({ worktreePath, filePath }) + } finally { + setIsStaging(false) + } + }, + [worktreePath, unstageMutation] + ) + + const handleDiscard = useCallback( + async (filePath: string) => { + if (!worktreePath) return + if (!confirm(`Discard changes to ${filePath}?`)) return + await discardMutation.mutateAsync({ worktreePath, filePath }) + }, + [worktreePath, discardMutation] + ) + + const handleCommit = useCallback(async () => { + if (!worktreePath || !commitMessage.trim()) return + await commitMutation.mutateAsync({ + worktreePath, + message: commitMessage.trim(), + }) + }, [worktreePath, commitMessage, commitMutation]) + + const handleStageAll = useCallback(async () => { + if (!worktreePath) return + await stageAllMutation.mutateAsync({ worktreePath }) + }, [worktreePath, stageAllMutation]) + + const handleUnstageAll = useCallback(async () => { + if (!worktreePath) return + await unstageAllMutation.mutateAsync({ worktreePath }) + }, [worktreePath, unstageAllMutation]) + + const unstagedFiles = useMemo(() => { + if (!status) return [] + return [...(status.unstaged ?? []), ...(status.untracked ?? [])] + }, [status]) + + const stagedCount = status?.staged?.length ?? 0 + const unstagedCount = unstagedFiles.length + const hasChanges = stagedCount > 0 || unstagedCount > 0 + + if (!worktreePath) { + return ( +
+ +

No project selected

+
+ ) + } + + if (isLoading) { + return ( +
+ + Loading... +
+ ) + } + + return ( +
+ {/* Header */} +
+
+ + {status?.branch || "—"} +
+
+ + + + + + Pull{status?.pullCount ? ` (${status.pullCount})` : ""} + + + + + + + + Push{status?.pushCount ? ` (${status.pushCount})` : ""} + + + + + + + Sync (Pull & Push) + + + + + + Refresh + +
+
+ + {/* Commit input */} +
+
+