From 51deb3491bd0e4abc72dbd0b594ac1855ed6f04d Mon Sep 17 00:00:00 2001 From: MatheusBBarni <29718530+MatheusBBarni@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:28:51 +0000 Subject: [PATCH] refactor: comprehensive project improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Security: Enable CSP, add prompt boundary markers - Testing: Add vitest with 83 tests, Biome linter - Architecture: Extract App.tsx into focused hooks (1423→821 lines), split useAppView.ts into 4 files, split chat.rs into 5 submodules - Rust: Replace string enums with SessionStatus/AutonomyMode/MessageRole, deduplicate approval gate, batch disk writes, reduce cloning - Performance: React.lazy code splitting, React.memo on screens, optimize terminal buffer - Accessibility: aria-labels on all interactive controls - Config: private:true, explicit tailwindcss, meta description - Docs: Update SPEC.md and AGENTS.md --- AGENTS.md | 9 +- biome.json | 50 + bun.lock | 204 ++++ docs/SPEC.md | 1 + index.html | 1 + package.json | 14 +- src-tauri/src/chat.rs | 1312 ---------------------- src-tauri/src/chat/commands.rs | 242 ++++ src-tauri/src/chat/execution.rs | 724 ++++++++++++ src-tauri/src/chat/helpers.rs | 168 +++ src-tauri/src/chat/mod.rs | 11 + src-tauri/src/chat/persistence.rs | 125 +++ src-tauri/src/chat/prompt.rs | 126 +++ src-tauri/src/generation.rs | 4 +- src-tauri/src/models.rs | 61 +- src-tauri/tauri.conf.json | 2 +- src/App.tsx | 900 +++------------ src/components/ControlColumn.tsx | 8 +- src/components/DocumentPane.tsx | 5 +- src/components/ExecutionPanel.tsx | 6 +- src/components/InspectorColumn.tsx | 8 +- src/components/MainWorkspace.tsx | 14 +- src/components/ProjectAiSettingsCard.tsx | 4 +- src/components/SettingsPrimitives.tsx | 2 +- src/components/SettingsView.tsx | 15 +- src/hooks/useAppLifecycle.ts | 4 +- src/hooks/useAppScreenProps.ts | 314 ++++++ src/hooks/useAppUiHandlers.ts | 205 ++++ src/hooks/useAppView.ts | 631 +---------- src/hooks/useChatHandlers.ts | 277 +++++ src/hooks/useDocumentHandlers.ts | 313 ++++++ src/hooks/useProjectHandlers.ts | 324 ++++++ src/hooks/useProjectSettingsHandlers.ts | 110 ++ src/lib/agentConfig.test.ts | 191 ++++ src/lib/appState.test.ts | 221 ++++ src/lib/appState.ts | 16 +- src/lib/projectConfig.test.ts | 211 ++++ src/lib/projectConfig.ts | 2 +- src/lib/runtime.ts | 6 +- src/screens/ChatScreen.tsx | 17 +- src/screens/ConfigurationScreen.tsx | 5 +- src/screens/PrdScreen.tsx | 6 +- src/screens/SettingsScreen.tsx | 9 +- src/store/useAgentStore.ts | 11 +- src/store/useSettingsStore.ts | 69 +- src/test/setup.ts | 1 + tsconfig.json | 2 +- vitest.config.ts | 11 + 48 files changed, 4164 insertions(+), 2808 deletions(-) create mode 100644 biome.json delete mode 100644 src-tauri/src/chat.rs create mode 100644 src-tauri/src/chat/commands.rs create mode 100644 src-tauri/src/chat/execution.rs create mode 100644 src-tauri/src/chat/helpers.rs create mode 100644 src-tauri/src/chat/mod.rs create mode 100644 src-tauri/src/chat/persistence.rs create mode 100644 src-tauri/src/chat/prompt.rs create mode 100644 src/hooks/useAppScreenProps.ts create mode 100644 src/hooks/useAppUiHandlers.ts create mode 100644 src/hooks/useChatHandlers.ts create mode 100644 src/hooks/useDocumentHandlers.ts create mode 100644 src/hooks/useProjectHandlers.ts create mode 100644 src/hooks/useProjectSettingsHandlers.ts create mode 100644 src/lib/agentConfig.test.ts create mode 100644 src/lib/appState.test.ts create mode 100644 src/lib/projectConfig.test.ts create mode 100644 src/test/setup.ts create mode 100644 vitest.config.ts diff --git a/AGENTS.md b/AGENTS.md index 03cc9bd..4e3659e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,6 +3,9 @@ ## Commands - `bun run dev` starts the Vite web shell on port 5173 for local UI work. - `bun run build` runs `tsc && vite build` for the frontend bundle. +- `bun run test` runs the Vitest test suite (unit and component tests). +- `bun run lint` runs the Biome linter/formatter check against `src/`. +- `bun run lint:fix` auto-fixes lint and format issues in `src/`. - `bun run tauri dev` starts the desktop shell against the local Vite server. - `bun run tauri build` packages the desktop app. - `cargo check --manifest-path .\src-tauri\Cargo.toml` validates the Rust command layer. @@ -19,7 +22,7 @@ - MUST keep `docs/PRD.md` and `docs/SPEC.md` aligned with shipped behavior when you change the review flow, model options, import flow, or autonomy modes. - MUST run `cargo check --manifest-path .\src-tauri\Cargo.toml` after changing Rust commands or shared payload types. - MUST run `bun run build` after changing routes, stores, document loading, or shared UI contracts. If Bun reports broken shims first, repair them with `bun install --force`. -- MUST extract new frontend behavior out of `src/App.tsx` when possible; it is already the main orchestration shell. +- MUST extract new frontend behavior out of `src/App.tsx` when possible; it is being refactored from a monolithic file into smaller components and route shells. - MUST use context7 mcp server for all documentation lookups. ## Ask First @@ -34,12 +37,12 @@ - NEVER commit secrets, auth tokens, or machine-local binary paths. ## Landmines -- `src/App.tsx` is 1,071 lines and `src/styles.css` is 1,047 lines. Prefer targeted extractions over widening either file. +- `src/App.tsx` is being refactored from a large monolithic file. Prefer targeted extractions into `src/components` and `src/lib` over widening it further. `src/styles.css` is similarly large; prefer Tailwind utilities over adding more custom CSS. - `src-tauri/src/lib.rs` mixes environment scanning, workspace walking, diffing, document parsing, and simulated agent execution. Small changes are safer than broad rewrites. - `git_get_diff()` returns a sample diff when the working tree is clean, and `FALLBACK_WORKSPACE` advertises files that may not exist yet. Keep demo behavior separate from real execution logic. - `scan_workspace_folder()` and `filterWorkspaceFiles()` intentionally respect `.gitignore`; preserve that behavior when changing workspace discovery. - `docs/SPEC.md` is partially aspirational today and references tooling that is not in `package.json` (`react-markdown`, `react-syntax-highlighter`, `tauri-plugin-store`). Update the docs when you normalize or implement those gaps. -- There is no committed frontend formatter, linter, or automated test suite yet. MUST ask before introducing one mid-task. +- Biome is configured as the project linter/formatter (`bun run lint`). Vitest is configured for testing (`bun run test`). A CI workflow validates typecheck, lint, and tests on push/PR. ## Patterns - Put reusable frontend behavior in `src/lib`, long-lived client state in `src/store`, and view composition in `src/components` or route shells. diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..d6496eb --- /dev/null +++ b/biome.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.11/schema.json", + "assist": { "actions": { "source": { "organizeImports": "on" } } }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "a11y": { + "noLabelWithoutControl": "warn" + }, + "complexity": { + "noExcessiveCognitiveComplexity": "warn" + }, + "correctness": { + "noUnusedImports": "warn", + "noUnusedVariables": "warn", + "noUnusedFunctionParameters": "warn", + "useExhaustiveDependencies": "warn" + }, + "style": { + "noNonNullAssertion": "off", + "useImportType": "off" + }, + "suspicious": { + "noExplicitAny": "warn", + "noArrayIndexKey": "warn" + } + } + }, + "formatter": { + "enabled": false + }, + "css": { + "linter": { + "enabled": false + }, + "parser": { + "cssModules": false + } + }, + "files": { + "includes": [ + "**", + "!**/dist/**", + "!**/node_modules/**", + "!**/src-tauri/target/**", + "!**/*.css" + ] + } +} diff --git a/bun.lock b/bun.lock index df4969f..f2dd43a 100644 --- a/bun.lock +++ b/bun.lock @@ -16,18 +16,28 @@ "zustand": "^5.0.8", }, "devDependencies": { + "@biomejs/biome": "^2.0.0", "@tailwindcss/vite": "^4.1.0", "@tauri-apps/cli": "^2.8.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", "@types/node": "^24.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^5.0.0", + "jsdom": "^26.1.0", + "tailwindcss": "^4.1.0", "typescript": "^5.9.0", "vite": "^7.0.0", + "vitest": "^3.2.1", }, }, }, "packages": { + "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], + + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@3.2.0", "", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw=="], + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], @@ -60,12 +70,42 @@ "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], + "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@biomejs/biome": ["@biomejs/biome@2.4.11", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.11", "@biomejs/cli-darwin-x64": "2.4.11", "@biomejs/cli-linux-arm64": "2.4.11", "@biomejs/cli-linux-arm64-musl": "2.4.11", "@biomejs/cli-linux-x64": "2.4.11", "@biomejs/cli-linux-x64-musl": "2.4.11", "@biomejs/cli-win32-arm64": "2.4.11", "@biomejs/cli-win32-x64": "2.4.11" }, "bin": { "biome": "bin/biome" } }, "sha512-nWxHX8tf3Opb/qRgZpBbsTOqOodkbrkJ7S+JxJAruxOReaDPPmPuLBAGQ8vigyUgo0QBB+oQltNEAvalLcjggA=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wOt+ed+L2dgZanWyL6i29qlXMc088N11optzpo10peayObBaAshbTcxKUchzEMp9QSY8rh5h6VfAFE3WTS1rqg=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-gZ6zR8XmZlExfi/Pz/PffmdpWOQ8Qhy7oBztgkR8/ylSRyLwfRPSadmiVCV8WQ8PoJ2MWUy2fgID9zmtgUUJmw=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-avdJaEElXrKceK0va9FkJ4P5ci3N01TGkc6ni3P8l3BElqbOz42Wg2IyX3gbh0ZLEd4HVKEIrmuVu/AMuSeFFA=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-+Sbo1OAmlegtdwqFE8iOxFIWLh1B3OEgsuZfBpyyN/kWuqZ8dx9ZEes6zVnDMo+zRHF2wLynRVhoQmV7ohxl2Q=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.11", "", { "os": "linux", "cpu": "x64" }, "sha512-TagWV0iomp5LnEnxWFg4nQO+e52Fow349vaX0Q/PIcX6Zhk4GGBgp3qqZ8PVkpC+cuehRctMf3+6+FgQ8jCEFQ=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.11", "", { "os": "linux", "cpu": "x64" }, "sha512-bexd2IklK7ZgPhrz6jXzpIL6dEAH9MlJU1xGTrypx+FICxrXUp4CqtwfiuoDKse+UlgAlWtzML3jrMqeEAHEhA=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-RJhaTnY8byzxDt4bDVb7AFPHkPcjOPK3xBip4ZRTrN3TEfyhjLRm3r3mqknqydgVTB74XG8l4jMLwEACEeihVg=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.11", "", { "os": "win32", "cpu": "x64" }, "sha512-A8D3JM/00C2KQgUV3oj8Ba15EHEYwebAGCy5Sf9GAjr5Y3+kJIYOiESoqRDeuRZueuMdCsbLZIUqmPhpYXJE9A=="], + + "@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="], + + "@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@3.1.0", "", { "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@3.0.5", "", { "peerDependencies": { "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], @@ -492,6 +532,14 @@ "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.10.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg=="], + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + + "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], + + "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], + + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], @@ -500,6 +548,10 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], @@ -510,12 +562,42 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="], + "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], + + "@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], + + "@vitest/runner": ["@vitest/runner@3.2.4", "", { "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ=="], + + "@vitest/snapshot": ["@vitest/snapshot@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ=="], + + "@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="], + + "@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.17", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA=="], "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + "caniuse-lite": ["caniuse-lite@1.0.30001787", "", {}, "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg=="], + "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], + + "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], + "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], @@ -524,22 +606,42 @@ "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], + + "cssstyle": ["cssstyle@4.6.0", "", { "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" } }, "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "data-urls": ["data-urls@5.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + "electron-to-chromium": ["electron-to-chromium@1.5.334", "", {}, "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog=="], "enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="], + "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -548,18 +650,32 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "iconoir-react": ["iconoir-react@7.11.0", "", { "peerDependencies": { "react": "18 || 19" } }, "sha512-uvTKtnHYwbbTsmQ6HCcliYd50WK0GbjP497RwdISxKzfS01x4cK1Mn/F2mT/t2roSaJQ0I+KnHxMcyvmNMXWsQ=="], + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + "input-otp": ["input-otp@1.4.2", "", { "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA=="], "intl-messageformat": ["intl-messageformat@10.7.18", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/fast-memoize": "2.2.7", "@formatjs/icu-messageformat-parser": "2.11.4", "tslib": "^2.8.0" } }, "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g=="], + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "jsdom": ["jsdom@26.1.0", "", { "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", "decimal.js": "^10.5.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.16", "parse5": "^7.2.1", "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^5.1.1", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.1.1", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], @@ -588,22 +704,40 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="], + "nwsapi": ["nwsapi@2.2.23", "", {}, "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ=="], + + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], "postcss": ["postcss@8.5.9", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="], + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], "react-aria": ["react-aria@3.47.0", "", { "dependencies": { "@internationalized/string": "^3.2.7", "@react-aria/breadcrumbs": "^3.5.32", "@react-aria/button": "^3.14.5", "@react-aria/calendar": "^3.9.5", "@react-aria/checkbox": "^3.16.5", "@react-aria/color": "^3.1.5", "@react-aria/combobox": "^3.15.0", "@react-aria/datepicker": "^3.16.1", "@react-aria/dialog": "^3.5.34", "@react-aria/disclosure": "^3.1.3", "@react-aria/dnd": "^3.11.6", "@react-aria/focus": "^3.21.5", "@react-aria/gridlist": "^3.14.4", "@react-aria/i18n": "^3.12.16", "@react-aria/interactions": "^3.27.1", "@react-aria/label": "^3.7.25", "@react-aria/landmark": "^3.0.10", "@react-aria/link": "^3.8.9", "@react-aria/listbox": "^3.15.3", "@react-aria/menu": "^3.21.0", "@react-aria/meter": "^3.4.30", "@react-aria/numberfield": "^3.12.5", "@react-aria/overlays": "^3.31.2", "@react-aria/progress": "^3.4.30", "@react-aria/radio": "^3.12.5", "@react-aria/searchfield": "^3.8.12", "@react-aria/select": "^3.17.3", "@react-aria/selection": "^3.27.2", "@react-aria/separator": "^3.4.16", "@react-aria/slider": "^3.8.5", "@react-aria/ssr": "^3.9.10", "@react-aria/switch": "^3.7.11", "@react-aria/table": "^3.17.11", "@react-aria/tabs": "^3.11.1", "@react-aria/tag": "^3.8.1", "@react-aria/textfield": "^3.18.5", "@react-aria/toast": "^3.0.11", "@react-aria/tooltip": "^3.9.2", "@react-aria/tree": "^3.1.7", "@react-aria/utils": "^3.33.1", "@react-aria/visually-hidden": "^3.8.31", "@react-types/shared": "^3.33.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-nvahimIqdByl/PXk/xPkG30LPRzcin+/Uk0uFfwbbKRRFC9aa22a6BRULZLqVHwa9GaNyKe6CDUxO1Dde4v0kA=="], @@ -612,6 +746,8 @@ "react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="], + "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], "react-router": ["react-router@7.14.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ=="], @@ -620,16 +756,36 @@ "react-stately": ["react-stately@3.45.0", "", { "dependencies": { "@react-stately/calendar": "^3.9.3", "@react-stately/checkbox": "^3.7.5", "@react-stately/collections": "^3.12.10", "@react-stately/color": "^3.9.5", "@react-stately/combobox": "^3.13.0", "@react-stately/data": "^3.15.2", "@react-stately/datepicker": "^3.16.1", "@react-stately/disclosure": "^3.0.11", "@react-stately/dnd": "^3.7.4", "@react-stately/form": "^3.2.4", "@react-stately/list": "^3.13.4", "@react-stately/menu": "^3.9.11", "@react-stately/numberfield": "^3.11.0", "@react-stately/overlays": "^3.6.23", "@react-stately/radio": "^3.11.5", "@react-stately/searchfield": "^3.5.19", "@react-stately/select": "^3.9.2", "@react-stately/selection": "^3.20.9", "@react-stately/slider": "^3.7.5", "@react-stately/table": "^3.15.4", "@react-stately/tabs": "^3.8.9", "@react-stately/toast": "^3.1.3", "@react-stately/toggle": "^3.9.5", "@react-stately/tooltip": "^3.5.11", "@react-stately/tree": "^3.9.6", "@react-types/shared": "^3.33.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-G3bYr0BIiookpt4H05VeZUuVS/FslQAj2TeT8vDfCiL314Y+LtPXIPe/a3eamCA0wljy7z1EDYKV50Qbz7pcJg=="], + "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], + "rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="], + "rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + + "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], + + "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="], + + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + "tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], "tailwind-variants": ["tailwind-variants@3.2.2", "", { "peerDependencies": { "tailwind-merge": ">=3.0.0", "tailwindcss": "*" }, "optionalPeers": ["tailwind-merge"] }, "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg=="], @@ -638,8 +794,26 @@ "tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], + + "tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], + + "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], + + "tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], + + "tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], + + "tough-cookie": ["tough-cookie@5.1.2", "", { "dependencies": { "tldts": "^6.1.32" } }, "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A=="], + + "tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], @@ -654,10 +828,34 @@ "vite": ["vite@7.3.2", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg=="], + "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], + + "vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="], + + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + + "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], + + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + + "whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], + + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "zustand": ["zustand@5.0.12", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g=="], + "@asamuzakjp/css-color/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], @@ -669,5 +867,11 @@ "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + + "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + + "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], } } diff --git a/docs/SPEC.md b/docs/SPEC.md index b70a571..131ea57 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -143,3 +143,4 @@ This prevents review from launching a second execution engine that could diverge * Opened workspace file tabs remain in-memory only; there is still no save-to-disk flow. * The desktop runtime is required for real project persistence, chat sessions, and CLI-backed turns. * The provider set remains limited to Codex CLI and Claude Code for this version. +* **Planned dependencies not yet installed:** `react-markdown`, `react-syntax-highlighter`, and `tauri-plugin-store` are referenced in design documents but are not currently in `package.json` or `Cargo.toml`. Features that depend on them (rich markdown rendering, syntax-highlighted code blocks, native key-value persistence) are aspirational and should not be assumed functional until the dependencies are added. diff --git a/index.html b/index.html index bd81a03..b87d7a4 100644 --- a/index.html +++ b/index.html @@ -3,6 +3,7 @@ + SpecForge diff --git a/package.json b/package.json index fc39bc8..a1a690a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "specforge", - "private": false, + "private": true, "version": "0.1.0", "type": "module", "packageManager": "bun@1.3.6", @@ -9,6 +9,10 @@ "tauri:dev": "tauri dev", "build": "tsc && vite build", "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "lint": "biome check src", + "lint:fix": "biome check --fix src", "preview": "vite preview", "tauri": "tauri" }, @@ -25,12 +29,18 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.1.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", "@tauri-apps/cli": "^2.8.0", "@types/node": "^24.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^5.0.0", "typescript": "^5.9.0", - "vite": "^7.0.0" + "jsdom": "^26.1.0", + "tailwindcss": "^4.1.0", + "vite": "^7.0.0", + "@biomejs/biome": "^2.0.0", + "vitest": "^3.2.1" } } \ No newline at end of file diff --git a/src-tauri/src/chat.rs b/src-tauri/src/chat.rs deleted file mode 100644 index 8983798..0000000 --- a/src-tauri/src/chat.rs +++ /dev/null @@ -1,1312 +0,0 @@ -use crate::{ - documents::parse_workspace_document, - environment::{current_timestamp, resolve_cli_binary}, - generation::{ - create_spec_generation_temp_dir, format_process_failure, map_claude_reasoning, - map_codex_reasoning, run_command_with_stdin, - }, - git::git_get_diff_for_root, - models::{ - ChatContextItem, ChatEventPayload, ChatMessage, ChatRuntimeState, ChatSessionIndexPayload, - ChatSessionSnapshot, ChatSessionSummary, ProjectSettings, - }, - paths::resolve_relative_path_under_root, - project::{ - build_default_project_settings, load_project_settings_from_workspace_root, - normalize_project_model, normalize_project_reasoning, - }, - state::{ChatExecutionRuntime, SharedState, WorkspaceContext}, -}; -use std::{ - collections::BTreeSet, - fs, - path::{Path, PathBuf}, - process::{Command, Stdio}, - sync::{ - atomic::{AtomicU64, Ordering}, - Arc, - }, - thread, - time::{SystemTime, UNIX_EPOCH}, -}; -use tauri::{AppHandle, Emitter, State}; - -const SESSION_DIRECTORY_RELATIVE_PATH: &str = ".specforge/sessions"; -const SESSION_INDEX_FILE_NAME: &str = "index.json"; -const CAVEMAN_PREAMBLE: &str = - "Default response style: caveman. Keep prose terse and direct while leaving code blocks, commands, and diffs fully normal."; - -static SESSION_COUNTER: AtomicU64 = AtomicU64::new(0); - -#[tauri::command] -pub(crate) fn create_chat_session( - state: State, - title: Option, -) -> Result { - let workspace = active_workspace_context(&state)?; - let settings = load_workspace_project_settings(&workspace.root)?; - let mut index = load_chat_session_index(&workspace.root)?; - let timestamp = current_timestamp(); - let session_id = create_chat_entity_id("session"); - let next_title = normalized_title(title.as_deref()) - .unwrap_or_else(|| format!("Topic {}", index.sessions.len() + 1)); - - let snapshot = ChatSessionSnapshot { - id: session_id.clone(), - title: next_title, - created_at: timestamp.clone(), - updated_at: timestamp, - status: String::from("idle"), - last_message_preview: String::new(), - selected_model: settings.selected_model.clone(), - selected_reasoning: settings.selected_reasoning.clone(), - autonomy_mode: String::from("milestone"), - context_items: build_default_context_items(&settings), - messages: Vec::new(), - runtime: ChatRuntimeState::default(), - }; - - write_chat_session_snapshot(&workspace.root, &snapshot)?; - upsert_chat_session_summary(&mut index, summarize_session(&snapshot)); - index.last_active_session_id = Some(session_id); - write_chat_session_index(&workspace.root, &index)?; - - Ok(snapshot) -} - -#[tauri::command] -pub(crate) fn load_chat_session( - state: State, - session_id: String, -) -> Result { - let workspace = active_workspace_context(&state)?; - let snapshot = read_chat_session_snapshot(&workspace.root, &session_id)?; - let mut index = load_chat_session_index(&workspace.root)?; - index.last_active_session_id = Some(session_id); - upsert_chat_session_summary(&mut index, summarize_session(&snapshot)); - write_chat_session_index(&workspace.root, &index)?; - Ok(snapshot) -} - -#[tauri::command] -pub(crate) fn save_chat_session( - state: State, - session_id: String, - selected_model: String, - selected_reasoning: String, - autonomy_mode: String, - context_items: Vec, -) -> Result { - let workspace = active_workspace_context(&state)?; - let mut snapshot = read_chat_session_snapshot(&workspace.root, &session_id)?; - snapshot.selected_model = - normalize_project_model(&selected_model, &snapshot.selected_model)?; - snapshot.selected_reasoning = - normalize_project_reasoning(&selected_reasoning, &snapshot.selected_reasoning)?; - snapshot.autonomy_mode = normalize_autonomy_mode(&autonomy_mode); - snapshot.context_items = normalize_context_items(context_items); - snapshot.updated_at = current_timestamp(); - write_chat_session_snapshot(&workspace.root, &snapshot)?; - - let mut index = load_chat_session_index(&workspace.root)?; - upsert_chat_session_summary(&mut index, summarize_session(&snapshot)); - write_chat_session_index(&workspace.root, &index)?; - - Ok(snapshot) -} - -#[tauri::command] -pub(crate) fn rename_chat_session( - state: State, - session_id: String, - title: String, -) -> Result { - let workspace = active_workspace_context(&state)?; - let mut snapshot = read_chat_session_snapshot(&workspace.root, &session_id)?; - snapshot.title = normalized_title(Some(&title)) - .ok_or_else(|| String::from("A non-empty session title is required."))?; - snapshot.updated_at = current_timestamp(); - write_chat_session_snapshot(&workspace.root, &snapshot)?; - - let summary = summarize_session(&snapshot); - let mut index = load_chat_session_index(&workspace.root)?; - upsert_chat_session_summary(&mut index, summary.clone()); - write_chat_session_index(&workspace.root, &index)?; - - Ok(summary) -} - -#[tauri::command] -pub(crate) fn delete_chat_session( - state: State, - session_id: String, -) -> Result { - let workspace = active_workspace_context(&state)?; - let session_path = session_snapshot_path(&workspace.root, &session_id); - - if session_path.exists() { - fs::remove_file(&session_path).map_err(|error| { - format!( - "Unable to delete chat session {}: {error}", - session_path.display() - ) - })?; - } - - let mut index = load_chat_session_index(&workspace.root)?; - index.sessions.retain(|entry| entry.id != session_id); - - if index - .last_active_session_id - .as_ref() - .is_some_and(|active_id| active_id == &session_id) - { - index.last_active_session_id = index.sessions.first().map(|entry| entry.id.clone()); - } - - write_chat_session_index(&workspace.root, &index)?; - Ok(index) -} - -#[tauri::command] -pub(crate) fn approve_chat_session( - state: State, - session_id: String, -) -> Result<(), String> { - let mut controls = state - .chat_runtime - .control - .lock() - .map_err(|_| String::from("Chat execution lock was poisoned."))?; - let control = controls.entry(session_id).or_default(); - control.awaiting_approval = false; - state.chat_runtime.signal.notify_all(); - Ok(()) -} - -#[tauri::command] -pub(crate) fn stop_chat_session( - state: State, - session_id: String, -) -> Result<(), String> { - let mut controls = state - .chat_runtime - .control - .lock() - .map_err(|_| String::from("Chat execution lock was poisoned."))?; - let control = controls.entry(session_id).or_default(); - control.stop_requested = true; - control.awaiting_approval = false; - state.chat_runtime.signal.notify_all(); - Ok(()) -} - -#[tauri::command] -pub(crate) fn send_chat_message( - app: AppHandle, - state: State, - session_id: String, - message: String, - claude_path: Option, - codex_path: Option, -) -> Result<(), String> { - let trimmed_message = message.trim().to_string(); - - if trimmed_message.is_empty() { - return Err(String::from("A message is required before sending.")); - } - - let workspace = active_workspace_context(&state)?; - let snapshot = read_chat_session_snapshot(&workspace.root, &session_id)?; - - if snapshot.runtime.is_busy || snapshot.runtime.awaiting_approval { - return Err(String::from( - "This topic is still waiting on the current turn. Approve or stop it before sending another message.", - )); - } - - let run_id = { - let mut controls = state - .chat_runtime - .control - .lock() - .map_err(|_| String::from("Chat execution lock was poisoned."))?; - let control = controls.entry(session_id.clone()).or_default(); - control.run_id = control.run_id.wrapping_add(1); - control.stop_requested = false; - control.awaiting_approval = false; - control.run_id - }; - - let runtime = state.chat_runtime.clone(); - thread::spawn(move || { - run_chat_turn( - app, - runtime, - workspace, - session_id, - run_id, - trimmed_message, - claude_path, - codex_path, - ); - }); - - Ok(()) -} - -pub(crate) fn load_chat_session_index( - workspace_root: &Path, -) -> Result { - let index_path = session_index_path(workspace_root); - - if !index_path.exists() { - return Ok(ChatSessionIndexPayload { - sessions: Vec::new(), - last_active_session_id: None, - }); - } - - let raw_value = fs::read_to_string(&index_path).map_err(|error| { - format!( - "Unable to read the chat session index {}: {error}", - index_path.display() - ) - })?; - - serde_json::from_str::(&raw_value).map_err(|error| { - format!( - "Unable to parse the chat session index {}: {error}", - index_path.display() - ) - }) -} - -fn run_chat_turn( - app: AppHandle, - runtime: Arc, - workspace: WorkspaceContext, - session_id: String, - run_id: u64, - user_message: String, - claude_path: Option, - codex_path: Option, -) { - let result = (|| -> Result<(), String> { - let mut snapshot = read_chat_session_snapshot(&workspace.root, &session_id)?; - snapshot.messages.push(ChatMessage { - id: create_chat_entity_id("msg"), - role: String::from("user"), - content: user_message.clone(), - created_at: current_timestamp(), - }); - snapshot.status = String::from("executing"); - snapshot.last_message_preview = build_message_preview(&user_message); - snapshot.updated_at = current_timestamp(); - snapshot.runtime.status = String::from("executing"); - snapshot.runtime.is_busy = true; - snapshot.runtime.awaiting_approval = false; - snapshot.runtime.last_error = None; - snapshot.runtime.pending_request = None; - snapshot.runtime.execution_summary = - Some(String::from("Preparing context and launching the selected CLI.")); - snapshot.runtime.pending_diff = None; - snapshot.runtime.current_milestone = Some(String::from("Queue Turn")); - write_chat_session_snapshot(&workspace.root, &snapshot)?; - refresh_index_summary(&workspace.root, &snapshot)?; - emit_session_event( - &app, - &session_id, - "messageStarted", - Some(snapshot.clone()), - None, - None, - Some(snapshot.runtime.clone()), - ); - - append_terminal_line( - &app, - &workspace.root, - &session_id, - &mut snapshot, - "Queued the new user turn and resolved the session context.", - )?; - - if matches!(stop_state(&runtime, &session_id, run_id), ChatStopState::StopRequested) { - halt_session( - &app, - &workspace.root, - &session_id, - &mut snapshot, - "Turn stopped before execution began.", - )?; - return Ok(()); - } - - if snapshot.autonomy_mode == "stepped" { - execute_chat_phase( - &app, - &workspace, - &session_id, - &runtime, - run_id, - &mut snapshot, - &user_message, - &claude_path, - &codex_path, - ChatExecutionPhase::Proposal, - )?; - snapshot.runtime.awaiting_approval = true; - snapshot.runtime.is_busy = true; - snapshot.runtime.status = String::from("awaiting_approval"); - snapshot.runtime.pending_request = - Some(String::from("Approve the proposal to rerun this turn with write access.")); - snapshot.runtime.execution_summary = Some(String::from( - "Stepped mode paused after the proposal phase. Approve to rerun the turn with write access.", - )); - snapshot.runtime.pending_diff = Some(git_get_diff_for_root(&workspace.root)?); - snapshot.updated_at = current_timestamp(); - snapshot.status = String::from("awaiting_approval"); - write_chat_session_snapshot(&workspace.root, &snapshot)?; - refresh_index_summary(&workspace.root, &snapshot)?; - emit_session_event( - &app, - &session_id, - "approvalRequired", - Some(snapshot.clone()), - None, - None, - Some(snapshot.runtime.clone()), - ); - - match wait_for_approval(&runtime, &session_id, run_id)? { - ApprovalOutcome::Approved => { - snapshot.runtime.awaiting_approval = false; - snapshot.runtime.status = String::from("executing"); - snapshot.runtime.pending_request = None; - snapshot.runtime.execution_summary = Some(String::from( - "Approval received. Replaying the turn with write access enabled.", - )); - snapshot.status = String::from("executing"); - snapshot.updated_at = current_timestamp(); - write_chat_session_snapshot(&workspace.root, &snapshot)?; - refresh_index_summary(&workspace.root, &snapshot)?; - } - ApprovalOutcome::StopRequested => { - halt_session( - &app, - &workspace.root, - &session_id, - &mut snapshot, - "Turn stopped during the stepped approval gate.", - )?; - return Ok(()); - } - ApprovalOutcome::Replaced => return Ok(()), - } - - execute_chat_phase( - &app, - &workspace, - &session_id, - &runtime, - run_id, - &mut snapshot, - &user_message, - &claude_path, - &codex_path, - ChatExecutionPhase::Write, - )?; - } else { - execute_chat_phase( - &app, - &workspace, - &session_id, - &runtime, - run_id, - &mut snapshot, - &user_message, - &claude_path, - &codex_path, - ChatExecutionPhase::Write, - )?; - } - - if snapshot.autonomy_mode == "milestone" { - snapshot.runtime.awaiting_approval = true; - snapshot.runtime.is_busy = true; - snapshot.runtime.status = String::from("awaiting_approval"); - snapshot.runtime.execution_summary = Some(String::from( - "Milestone mode paused after this turn. Review the current diff before the next prompt.", - )); - snapshot.runtime.pending_request = - Some(String::from("Approve the current diff to unlock the next turn.")); - snapshot.runtime.pending_diff = Some(git_get_diff_for_root(&workspace.root)?); - snapshot.updated_at = current_timestamp(); - snapshot.status = String::from("awaiting_approval"); - write_chat_session_snapshot(&workspace.root, &snapshot)?; - refresh_index_summary(&workspace.root, &snapshot)?; - emit_session_event( - &app, - &session_id, - "approvalRequired", - Some(snapshot.clone()), - None, - None, - Some(snapshot.runtime.clone()), - ); - - match wait_for_approval(&runtime, &session_id, run_id)? { - ApprovalOutcome::Approved => { - snapshot.runtime.awaiting_approval = false; - snapshot.runtime.pending_request = None; - snapshot.runtime.execution_summary = Some(String::from( - "Diff approved. The topic is ready for the next prompt.", - )); - } - ApprovalOutcome::StopRequested => { - halt_session( - &app, - &workspace.root, - &session_id, - &mut snapshot, - "Turn stopped during the milestone approval gate.", - )?; - return Ok(()); - } - ApprovalOutcome::Replaced => return Ok(()), - } - } - - snapshot.runtime.status = String::from("completed"); - snapshot.runtime.is_busy = false; - snapshot.runtime.awaiting_approval = false; - snapshot.runtime.pending_request = None; - snapshot.runtime.current_milestone = Some(String::from("Complete")); - snapshot.runtime.pending_diff = Some(git_get_diff_for_root(&workspace.root)?); - snapshot.runtime.execution_summary = Some(String::from( - "Turn completed. The transcript, terminal stream, and current diff are ready.", - )); - snapshot.status = String::from("completed"); - snapshot.updated_at = current_timestamp(); - write_chat_session_snapshot(&workspace.root, &snapshot)?; - refresh_index_summary(&workspace.root, &snapshot)?; - emit_session_event( - &app, - &session_id, - "completed", - Some(snapshot), - None, - None, - None, - ); - - Ok(()) - })(); - - if let Err(error) = result { - let _ = mark_session_error(&app, &workspace.root, &session_id, error); - } -} - -fn execute_chat_phase( - app: &AppHandle, - workspace: &WorkspaceContext, - session_id: &str, - runtime: &Arc, - run_id: u64, - snapshot: &mut ChatSessionSnapshot, - user_message: &str, - claude_path: &Option, - codex_path: &Option, - phase: ChatExecutionPhase, -) -> Result<(), String> { - if !matches!(stop_state(runtime, session_id, run_id), ChatStopState::Continue) { - halt_session( - app, - &workspace.root, - session_id, - snapshot, - "Turn stopped before the provider phase finished.", - )?; - return Ok(()); - } - - let phase_copy = phase.copy(); - snapshot.runtime.current_milestone = Some(String::from(phase_copy.milestone())); - snapshot.runtime.execution_summary = Some(String::from(phase_copy.summary())); - write_chat_session_snapshot(&workspace.root, snapshot)?; - refresh_index_summary(&workspace.root, snapshot)?; - append_terminal_line(app, &workspace.root, session_id, snapshot, phase_copy.line())?; - - let context_blocks = build_context_blocks(workspace, snapshot)?; - let prompt_payload = build_chat_prompt(snapshot, &context_blocks, user_message, phase_copy); - let assistant_content = run_chat_provider_request( - &workspace.root, - &snapshot.selected_model, - &snapshot.selected_reasoning, - phase_copy, - &prompt_payload, - claude_path.as_deref(), - codex_path.as_deref(), - )?; - - let assistant_message = ChatMessage { - id: create_chat_entity_id("msg"), - role: String::from("assistant"), - content: assistant_content.trim().to_string(), - created_at: current_timestamp(), - }; - snapshot.messages.push(assistant_message.clone()); - snapshot.last_message_preview = build_message_preview(&assistant_message.content); - snapshot.updated_at = current_timestamp(); - snapshot.runtime.pending_diff = Some(git_get_diff_for_root(&workspace.root)?); - snapshot.runtime.current_milestone = Some(String::from(phase_copy.completed_milestone())); - snapshot.runtime.execution_summary = Some(String::from(phase_copy.completed_summary())); - snapshot.status = String::from("executing"); - write_chat_session_snapshot(&workspace.root, snapshot)?; - refresh_index_summary(&workspace.root, snapshot)?; - emit_session_event( - app, - session_id, - "messageDelta", - None, - Some(assistant_message.content.clone()), - None, - None, - ); - emit_session_event( - app, - session_id, - "sessionUpdated", - Some(snapshot.clone()), - Some(assistant_message.content), - None, - Some(snapshot.runtime.clone()), - ); - - Ok(()) -} - -fn build_context_blocks( - workspace: &WorkspaceContext, - snapshot: &ChatSessionSnapshot, -) -> Result, String> { - let mut blocks = Vec::new(); - - for item in &snapshot.context_items { - let content = match item.kind.as_str() { - "workspace_summary" => build_workspace_summary(workspace), - _ => { - let Some(path) = item.path.as_deref() else { - continue; - }; - let resolved_path = resolve_relative_path_under_root(&workspace.root, path)?; - - if !resolved_path.exists() { - format!("Missing file at {path}.") - } else { - parse_workspace_document(&resolved_path)? - } - } - }; - - if content.trim().is_empty() { - continue; - } - - blocks.push((item.label.clone(), content)); - } - - Ok(blocks) -} - -fn build_chat_prompt( - snapshot: &ChatSessionSnapshot, - context_blocks: &[(String, String)], - user_message: &str, - phase: ChatExecutionPhase, -) -> String { - let mut prompt = String::new(); - prompt.push_str(CAVEMAN_PREAMBLE); - prompt.push_str("\n\n"); - prompt.push_str("You are SpecForge Chat, a desktop coding assistant operating on a project-scoped topic.\n"); - prompt.push_str("Keep responses direct. Preserve technical accuracy. Use the attached project context.\n"); - prompt.push_str("Current topic: "); - prompt.push_str(&snapshot.title); - prompt.push_str("\nAutonomy mode: "); - prompt.push_str(&snapshot.autonomy_mode); - prompt.push_str("\nExecution phase: "); - prompt.push_str(phase.label()); - prompt.push_str("\n"); - prompt.push_str(phase.instructions()); - - if !context_blocks.is_empty() { - prompt.push_str("\n\nAttached context:\n"); - - for (label, content) in context_blocks { - prompt.push_str("\n### "); - prompt.push_str(label); - prompt.push('\n'); - prompt.push_str(content.trim()); - prompt.push('\n'); - } - } - - if !snapshot.messages.is_empty() { - prompt.push_str("\nConversation so far:\n"); - - for message in &snapshot.messages { - prompt.push_str("\n"); - prompt.push_str(&message.role.to_uppercase()); - prompt.push_str(": "); - prompt.push_str(message.content.trim()); - prompt.push('\n'); - } - } else { - prompt.push_str("\nConversation so far:\n\nNo prior turns yet.\n"); - } - - prompt.push_str("\nCurrent user request:\n"); - prompt.push_str(user_message.trim()); - prompt.push('\n'); - prompt -} - -fn run_chat_provider_request( - workspace_root: &Path, - model: &str, - reasoning: &str, - phase: ChatExecutionPhase, - prompt_payload: &str, - claude_path: Option<&str>, - codex_path: Option<&str>, -) -> Result { - if model.starts_with("claude") { - run_claude_chat_request( - workspace_root, - &resolve_cli_binary("claude", claude_path)?, - model, - reasoning, - phase, - prompt_payload, - ) - } else { - run_codex_chat_request( - workspace_root, - &resolve_cli_binary("codex", codex_path)?, - model, - reasoning, - phase, - prompt_payload, - ) - } -} - -fn run_codex_chat_request( - workspace_root: &Path, - binary_path: &Path, - model: &str, - reasoning: &str, - phase: ChatExecutionPhase, - prompt_payload: &str, -) -> Result { - let temp_dir = create_spec_generation_temp_dir("codex-chat")?; - let output_path = temp_dir.join("assistant-message.md"); - let mut command = Command::new(binary_path); - command - .current_dir(workspace_root) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .arg("exec") - .arg("--color") - .arg("never") - .arg("--skip-git-repo-check") - .arg("--sandbox") - .arg(phase.codex_sandbox()) - .arg("--model") - .arg(model) - .arg("--config") - .arg(format!( - "model_reasoning_effort=\"{}\"", - map_codex_reasoning(reasoning) - )) - .arg("--output-last-message") - .arg(&output_path); - - let output = run_command_with_stdin(&mut command, "Codex CLI", prompt_payload)?; - - if !output.status.success() { - let _ = fs::remove_dir_all(&temp_dir); - return Err(format_process_failure("Codex CLI", &output)); - } - - let result = fs::read_to_string(&output_path).or_else(|_| { - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - - if stdout.is_empty() { - Err(std::io::Error::new( - std::io::ErrorKind::Other, - "The Codex CLI returned no assistant content.", - )) - } else { - Ok(stdout) - } - }); - let _ = fs::remove_dir_all(&temp_dir); - - result.map_err(|error| format!("Unable to read the Codex assistant output: {error}")) -} - -fn run_claude_chat_request( - workspace_root: &Path, - binary_path: &Path, - model: &str, - reasoning: &str, - phase: ChatExecutionPhase, - prompt_payload: &str, -) -> Result { - let mut command = Command::new(binary_path); - command - .current_dir(workspace_root) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .arg("--print") - .arg("Respond to the request provided on stdin.") - .arg("--model") - .arg(model) - .arg("--output-format") - .arg("text") - .arg("--permission-mode") - .arg(phase.claude_permission_mode()) - .arg("--max-turns") - .arg("8") - .arg("--effort") - .arg(map_claude_reasoning(reasoning)); - - let output = run_command_with_stdin(&mut command, "Claude CLI", prompt_payload)?; - - if !output.status.success() { - return Err(format_process_failure("Claude CLI", &output)); - } - - Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) -} - -fn refresh_index_summary(workspace_root: &Path, snapshot: &ChatSessionSnapshot) -> Result<(), String> { - let mut index = load_chat_session_index(workspace_root)?; - upsert_chat_session_summary(&mut index, summarize_session(snapshot)); - if index.last_active_session_id.is_none() { - index.last_active_session_id = Some(snapshot.id.clone()); - } - write_chat_session_index(workspace_root, &index) -} - -fn append_terminal_line( - app: &AppHandle, - workspace_root: &Path, - session_id: &str, - snapshot: &mut ChatSessionSnapshot, - line: &str, -) -> Result<(), String> { - snapshot.runtime.terminal_output.push(line.to_string()); - - if snapshot.runtime.terminal_output.len() > 240 { - let keep_from = snapshot.runtime.terminal_output.len() - 240; - snapshot.runtime.terminal_output.drain(0..keep_from); - } - - snapshot.updated_at = current_timestamp(); - write_chat_session_snapshot(workspace_root, snapshot)?; - refresh_index_summary(workspace_root, snapshot)?; - emit_session_event( - app, - session_id, - "terminalLine", - None, - None, - Some(line.to_string()), - Some(snapshot.runtime.clone()), - ); - Ok(()) -} - -fn halt_session( - app: &AppHandle, - workspace_root: &Path, - session_id: &str, - snapshot: &mut ChatSessionSnapshot, - message: &str, -) -> Result<(), String> { - snapshot.status = String::from("halted"); - snapshot.runtime.status = String::from("halted"); - snapshot.runtime.is_busy = false; - snapshot.runtime.awaiting_approval = false; - snapshot.runtime.pending_request = None; - snapshot.runtime.execution_summary = Some(message.to_string()); - snapshot.updated_at = current_timestamp(); - write_chat_session_snapshot(workspace_root, snapshot)?; - refresh_index_summary(workspace_root, snapshot)?; - emit_session_event( - app, - session_id, - "halted", - Some(snapshot.clone()), - None, - None, - Some(snapshot.runtime.clone()), - ); - Ok(()) -} - -fn mark_session_error( - app: &AppHandle, - workspace_root: &Path, - session_id: &str, - error: String, -) -> Result<(), String> { - let mut snapshot = read_chat_session_snapshot(workspace_root, session_id)?; - snapshot.status = String::from("error"); - snapshot.runtime.status = String::from("error"); - snapshot.runtime.is_busy = false; - snapshot.runtime.awaiting_approval = false; - snapshot.runtime.pending_request = None; - snapshot.runtime.last_error = Some(error.clone()); - snapshot.runtime.execution_summary = Some(error.clone()); - snapshot.updated_at = current_timestamp(); - write_chat_session_snapshot(workspace_root, &snapshot)?; - refresh_index_summary(workspace_root, &snapshot)?; - emit_session_event( - app, - session_id, - "error", - Some(snapshot), - Some(error), - None, - None, - ); - Ok(()) -} - -fn emit_session_event( - app: &AppHandle, - session_id: &str, - event_type: &str, - session: Option, - message_delta: Option, - terminal_line: Option, - runtime: Option, -) { - let summary = session.as_ref().map(summarize_session); - let message = session - .as_ref() - .and_then(|snapshot| snapshot.messages.last().cloned()); - let payload = ChatEventPayload { - session_id: session_id.to_string(), - event_type: event_type.to_string(), - message, - message_delta, - terminal_line, - session, - runtime, - summary, - }; - - let _ = app.emit("chat-session-event", payload); -} - -fn wait_for_approval( - runtime: &Arc, - session_id: &str, - run_id: u64, -) -> Result { - let mut controls = runtime - .control - .lock() - .map_err(|_| String::from("Chat execution lock was poisoned."))?; - let control = controls.entry(session_id.to_string()).or_default(); - control.awaiting_approval = true; - runtime.signal.notify_all(); - - loop { - let current = controls.entry(session_id.to_string()).or_default().clone(); - - if current.run_id != run_id { - return Ok(ApprovalOutcome::Replaced); - } - - if current.stop_requested { - return Ok(ApprovalOutcome::StopRequested); - } - - if !current.awaiting_approval { - return Ok(ApprovalOutcome::Approved); - } - - controls = runtime - .signal - .wait(controls) - .map_err(|_| String::from("Chat execution lock was poisoned."))?; - } -} - -fn stop_state(runtime: &Arc, session_id: &str, run_id: u64) -> ChatStopState { - runtime - .control - .lock() - .map(|controls| { - let Some(control) = controls.get(session_id) else { - return ChatStopState::Continue; - }; - - if control.run_id != run_id { - ChatStopState::Replaced - } else if control.stop_requested { - ChatStopState::StopRequested - } else { - ChatStopState::Continue - } - }) - .unwrap_or(ChatStopState::StopRequested) -} - -fn active_workspace_context(state: &State) -> Result { - state - .workspace - .lock() - .map_err(|_| String::from("Workspace lock was poisoned."))? - .clone() - .ok_or_else(|| String::from("No workspace folder is currently open.")) -} - -fn load_workspace_project_settings(workspace_root: &Path) -> Result { - let defaults = build_default_project_settings(workspace_root, None, None); - load_project_settings_from_workspace_root(workspace_root, defaults).map(|(settings, _)| settings) -} - -fn build_default_context_items(settings: &ProjectSettings) -> Vec { - let mut items = vec![ - build_context_item("prd", "PRD", Some(settings.prd_path.clone()), true), - build_context_item("spec", "SPEC", Some(settings.spec_path.clone()), true), - build_context_item("workspace_summary", "Workspace Tree Summary", None, true), - ]; - - for path in &settings.supporting_document_paths { - items.push(build_context_item( - "supporting_document", - &format!("Supporting: {path}"), - Some(path.clone()), - true, - )); - } - - normalize_context_items(items) -} - -fn build_context_item( - kind: &str, - label: &str, - path: Option, - is_default: bool, -) -> ChatContextItem { - ChatContextItem { - id: create_chat_entity_id("ctx"), - kind: kind.to_string(), - label: label.to_string(), - path, - is_default, - } -} - -fn normalize_context_items(items: Vec) -> Vec { - let mut seen = BTreeSet::::new(); - let mut normalized_items = Vec::new(); - - for item in items { - let dedupe_key = format!( - "{}::{}", - item.kind, - item.path.as_deref().unwrap_or(item.label.as_str()) - ); - - if !seen.insert(dedupe_key) { - continue; - } - - normalized_items.push(ChatContextItem { - id: if item.id.trim().is_empty() { - create_chat_entity_id("ctx") - } else { - item.id - }, - kind: item.kind.trim().to_string(), - label: item.label.trim().to_string(), - path: item.path.and_then(|value| { - let trimmed_value = value.trim().replace('\\', "/"); - (!trimmed_value.is_empty()).then_some(trimmed_value) - }), - is_default: item.is_default, - }); - } - - normalized_items -} - -fn summarize_session(snapshot: &ChatSessionSnapshot) -> ChatSessionSummary { - ChatSessionSummary { - id: snapshot.id.clone(), - title: snapshot.title.clone(), - created_at: snapshot.created_at.clone(), - updated_at: snapshot.updated_at.clone(), - status: snapshot.status.clone(), - last_message_preview: snapshot.last_message_preview.clone(), - selected_model: snapshot.selected_model.clone(), - selected_reasoning: snapshot.selected_reasoning.clone(), - autonomy_mode: snapshot.autonomy_mode.clone(), - } -} - -fn upsert_chat_session_summary( - index: &mut ChatSessionIndexPayload, - summary: ChatSessionSummary, -) { - if let Some(existing_summary) = index.sessions.iter_mut().find(|entry| entry.id == summary.id) { - *existing_summary = summary; - } else { - index.sessions.push(summary); - } - - index.sessions.sort_by(|left, right| right.updated_at.cmp(&left.updated_at)); -} - -fn write_chat_session_index( - workspace_root: &Path, - index: &ChatSessionIndexPayload, -) -> Result<(), String> { - ensure_session_directory(workspace_root)?; - let encoded = serde_json::to_string_pretty(index) - .map_err(|error| format!("Unable to encode the chat session index: {error}"))?; - fs::write(session_index_path(workspace_root), encoded.as_bytes()).map_err(|error| { - format!( - "Unable to write the chat session index {}: {error}", - session_index_path(workspace_root).display() - ) - }) -} - -fn read_chat_session_snapshot( - workspace_root: &Path, - session_id: &str, -) -> Result { - let session_path = session_snapshot_path(workspace_root, session_id); - let raw_value = fs::read_to_string(&session_path).map_err(|error| { - format!( - "Unable to read the chat session {}: {error}", - session_path.display() - ) - })?; - - serde_json::from_str::(&raw_value).map_err(|error| { - format!( - "Unable to parse the chat session {}: {error}", - session_path.display() - ) - }) -} - -fn write_chat_session_snapshot( - workspace_root: &Path, - snapshot: &ChatSessionSnapshot, -) -> Result<(), String> { - ensure_session_directory(workspace_root)?; - let encoded = serde_json::to_string_pretty(snapshot) - .map_err(|error| format!("Unable to encode the chat session {}: {error}", snapshot.id))?; - fs::write(session_snapshot_path(workspace_root, &snapshot.id), encoded.as_bytes()).map_err( - |error| { - format!( - "Unable to write the chat session {}: {error}", - session_snapshot_path(workspace_root, &snapshot.id).display() - ) - }, - ) -} - -fn ensure_session_directory(workspace_root: &Path) -> Result<(), String> { - let sessions_path = sessions_directory_path(workspace_root); - fs::create_dir_all(&sessions_path).map_err(|error| { - format!( - "Unable to create the chat session directory {}: {error}", - sessions_path.display() - ) - }) -} - -fn sessions_directory_path(workspace_root: &Path) -> PathBuf { - workspace_root.join(SESSION_DIRECTORY_RELATIVE_PATH) -} - -fn session_index_path(workspace_root: &Path) -> PathBuf { - sessions_directory_path(workspace_root).join(SESSION_INDEX_FILE_NAME) -} - -fn session_snapshot_path(workspace_root: &Path, session_id: &str) -> PathBuf { - sessions_directory_path(workspace_root).join(format!("{session_id}.json")) -} - -fn build_workspace_summary(workspace: &WorkspaceContext) -> String { - let mut paths = workspace.files.keys().cloned().collect::>(); - paths.sort(); - - if paths.is_empty() { - return String::from("No workspace files were discovered for this project."); - } - - let mut summary = String::from("Workspace files:\n"); - - for path in paths.iter().take(180) { - summary.push_str("- "); - summary.push_str(path); - summary.push('\n'); - } - - if paths.len() > 180 { - summary.push_str(&format!( - "- ... and {} more files not shown in this summary.\n", - paths.len() - 180 - )); - } - - summary -} - -fn normalized_title(title: Option<&str>) -> Option { - title - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(|value| value.replace('\n', " ")) -} - -fn normalize_autonomy_mode(value: &str) -> String { - match value.trim() { - "stepped" => String::from("stepped"), - "god_mode" => String::from("god_mode"), - _ => String::from("milestone"), - } -} - -fn build_message_preview(value: &str) -> String { - let collapsed = value.split_whitespace().collect::>().join(" "); - let mut preview = collapsed.chars().take(120).collect::(); - - if collapsed.chars().count() > 120 { - preview.push('…'); - } - - preview -} - -fn create_chat_entity_id(prefix: &str) -> String { - let millis = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_millis()) - .unwrap_or_default(); - let counter = SESSION_COUNTER.fetch_add(1, Ordering::Relaxed); - format!("{prefix}-{millis:x}-{counter:x}") -} - -#[derive(Clone, Copy)] -enum ChatExecutionPhase { - Proposal, - Write, -} - -impl ChatExecutionPhase { - fn copy(self) -> Self { - self - } - - fn label(self) -> &'static str { - match self { - Self::Proposal => "proposal", - Self::Write => "write", - } - } - - fn milestone(self) -> &'static str { - match self { - Self::Proposal => "Proposal Pass", - Self::Write => "Execution Pass", - } - } - - fn completed_milestone(self) -> &'static str { - match self { - Self::Proposal => "Proposal Complete", - Self::Write => "Execution Complete", - } - } - - fn summary(self) -> &'static str { - match self { - Self::Proposal => { - "Running a read-only pass to propose the patch or command plan before approval." - } - Self::Write => "Running the selected CLI against the project workspace.", - } - } - - fn completed_summary(self) -> &'static str { - match self { - Self::Proposal => "Proposal phase completed. Review the suggested plan before continuing.", - Self::Write => "Provider turn completed. Refresh the diff and transcript before continuing.", - } - } - - fn line(self) -> &'static str { - match self { - Self::Proposal => { - "Launching the proposal pass with read-only permissions and the attached project context." - } - Self::Write => "Launching the write pass with the configured autonomy permissions.", - } - } - - fn instructions(self) -> &'static str { - match self { - Self::Proposal => { - "Proposal-only pass. Do not mutate files or run write commands. Produce the clearest patch or command plan you would execute after approval." - } - Self::Write => { - "Write-enabled pass. You may edit files and run commands that fit the current autonomy mode. Summarize what changed and call out any blockers." - } - } - } - - fn codex_sandbox(self) -> &'static str { - match self { - Self::Proposal => "read-only", - Self::Write => "workspace-write", - } - } - - fn claude_permission_mode(self) -> &'static str { - match self { - Self::Proposal => "default", - Self::Write => "acceptEdits", - } - } -} - -enum ChatStopState { - Continue, - StopRequested, - Replaced, -} - -enum ApprovalOutcome { - Approved, - StopRequested, - Replaced, -} diff --git a/src-tauri/src/chat/commands.rs b/src-tauri/src/chat/commands.rs new file mode 100644 index 0000000..c06b656 --- /dev/null +++ b/src-tauri/src/chat/commands.rs @@ -0,0 +1,242 @@ +use crate::{ + environment::current_timestamp, + models::{ + AutonomyMode, ChatContextItem, ChatRuntimeState, ChatSessionIndexPayload, + ChatSessionSnapshot, ChatSessionSummary, SessionStatus, + }, + project::{normalize_project_model, normalize_project_reasoning}, + state::SharedState, +}; +use std::{fs, thread}; +use tauri::{AppHandle, State}; + +use super::{ + execution::run_chat_turn, + helpers::{ + active_workspace_context, build_default_context_items, build_message_preview, + create_chat_entity_id, load_workspace_project_settings, normalize_autonomy_mode, + normalize_context_items, normalized_title, summarize_session, + upsert_chat_session_summary, + }, + persistence::{ + load_chat_session_index, read_chat_session_snapshot, session_snapshot_path, + write_chat_session_index, write_chat_session_snapshot, + }, +}; + +#[tauri::command] +pub(crate) fn create_chat_session( + state: State, + title: Option, +) -> Result { + let workspace = active_workspace_context(&state)?; + let settings = load_workspace_project_settings(&workspace.root)?; + let mut index = load_chat_session_index(&workspace.root)?; + let timestamp = current_timestamp(); + let session_id = create_chat_entity_id("session"); + let next_title = normalized_title(title.as_deref()) + .unwrap_or_else(|| format!("Topic {}", index.sessions.len() + 1)); + + let snapshot = ChatSessionSnapshot { + id: session_id.clone(), + title: next_title, + created_at: timestamp.clone(), + updated_at: timestamp, + status: SessionStatus::Idle, + last_message_preview: String::new(), + selected_model: settings.selected_model.clone(), + selected_reasoning: settings.selected_reasoning.clone(), + autonomy_mode: AutonomyMode::Milestone, + context_items: build_default_context_items(&settings), + messages: Vec::new(), + runtime: ChatRuntimeState::default(), + }; + + write_chat_session_snapshot(&workspace.root, &snapshot)?; + upsert_chat_session_summary(&mut index, summarize_session(&snapshot)); + index.last_active_session_id = Some(session_id); + write_chat_session_index(&workspace.root, &index)?; + + Ok(snapshot) +} + +#[tauri::command] +pub(crate) fn load_chat_session( + state: State, + session_id: String, +) -> Result { + let workspace = active_workspace_context(&state)?; + let snapshot = read_chat_session_snapshot(&workspace.root, &session_id)?; + let mut index = load_chat_session_index(&workspace.root)?; + index.last_active_session_id = Some(session_id); + upsert_chat_session_summary(&mut index, summarize_session(&snapshot)); + write_chat_session_index(&workspace.root, &index)?; + Ok(snapshot) +} + +#[tauri::command] +pub(crate) fn save_chat_session( + state: State, + session_id: String, + selected_model: String, + selected_reasoning: String, + autonomy_mode: String, + context_items: Vec, +) -> Result { + let workspace = active_workspace_context(&state)?; + let mut snapshot = read_chat_session_snapshot(&workspace.root, &session_id)?; + snapshot.selected_model = + normalize_project_model(&selected_model, &snapshot.selected_model)?; + snapshot.selected_reasoning = + normalize_project_reasoning(&selected_reasoning, &snapshot.selected_reasoning)?; + snapshot.autonomy_mode = normalize_autonomy_mode(&autonomy_mode); + snapshot.context_items = normalize_context_items(context_items); + snapshot.updated_at = current_timestamp(); + write_chat_session_snapshot(&workspace.root, &snapshot)?; + + let mut index = load_chat_session_index(&workspace.root)?; + upsert_chat_session_summary(&mut index, summarize_session(&snapshot)); + write_chat_session_index(&workspace.root, &index)?; + + Ok(snapshot) +} + +#[tauri::command] +pub(crate) fn rename_chat_session( + state: State, + session_id: String, + title: String, +) -> Result { + let workspace = active_workspace_context(&state)?; + let mut snapshot = read_chat_session_snapshot(&workspace.root, &session_id)?; + snapshot.title = normalized_title(Some(&title)) + .ok_or_else(|| String::from("A non-empty session title is required."))?; + snapshot.updated_at = current_timestamp(); + write_chat_session_snapshot(&workspace.root, &snapshot)?; + + let summary = summarize_session(&snapshot); + let mut index = load_chat_session_index(&workspace.root)?; + upsert_chat_session_summary(&mut index, summary.clone()); + write_chat_session_index(&workspace.root, &index)?; + + Ok(summary) +} + +#[tauri::command] +pub(crate) fn delete_chat_session( + state: State, + session_id: String, +) -> Result { + let workspace = active_workspace_context(&state)?; + let session_path = session_snapshot_path(&workspace.root, &session_id); + + if session_path.exists() { + fs::remove_file(&session_path).map_err(|error| { + format!( + "Unable to delete chat session {}: {error}", + session_path.display() + ) + })?; + } + + let mut index = load_chat_session_index(&workspace.root)?; + index.sessions.retain(|entry| entry.id != session_id); + + if index + .last_active_session_id + .as_ref() + .is_some_and(|active_id| active_id == &session_id) + { + index.last_active_session_id = index.sessions.first().map(|entry| entry.id.clone()); + } + + write_chat_session_index(&workspace.root, &index)?; + Ok(index) +} + +#[tauri::command] +pub(crate) fn approve_chat_session( + state: State, + session_id: String, +) -> Result<(), String> { + let mut controls = state + .chat_runtime + .control + .lock() + .map_err(|_| String::from("Chat execution lock was poisoned."))?; + let control = controls.entry(session_id).or_default(); + control.awaiting_approval = false; + state.chat_runtime.signal.notify_all(); + Ok(()) +} + +#[tauri::command] +pub(crate) fn stop_chat_session( + state: State, + session_id: String, +) -> Result<(), String> { + let mut controls = state + .chat_runtime + .control + .lock() + .map_err(|_| String::from("Chat execution lock was poisoned."))?; + let control = controls.entry(session_id).or_default(); + control.stop_requested = true; + control.awaiting_approval = false; + state.chat_runtime.signal.notify_all(); + Ok(()) +} + +#[tauri::command] +pub(crate) fn send_chat_message( + app: AppHandle, + state: State, + session_id: String, + message: String, + claude_path: Option, + codex_path: Option, +) -> Result<(), String> { + let trimmed_message = message.trim().to_string(); + + if trimmed_message.is_empty() { + return Err(String::from("A message is required before sending.")); + } + + let workspace = active_workspace_context(&state)?; + let snapshot = read_chat_session_snapshot(&workspace.root, &session_id)?; + + if snapshot.runtime.is_busy || snapshot.runtime.awaiting_approval { + return Err(String::from( + "This topic is still waiting on the current turn. Approve or stop it before sending another message.", + )); + } + + let run_id = { + let mut controls = state + .chat_runtime + .control + .lock() + .map_err(|_| String::from("Chat execution lock was poisoned."))?; + let control = controls.entry(session_id.clone()).or_default(); + control.run_id = control.run_id.wrapping_add(1); + control.stop_requested = false; + control.awaiting_approval = false; + control.run_id + }; + + let runtime = state.chat_runtime.clone(); + thread::spawn(move || { + run_chat_turn( + app, + runtime, + workspace, + session_id, + run_id, + trimmed_message, + claude_path, + codex_path, + ); + }); + + Ok(()) +} diff --git a/src-tauri/src/chat/execution.rs b/src-tauri/src/chat/execution.rs new file mode 100644 index 0000000..0d3c1cf --- /dev/null +++ b/src-tauri/src/chat/execution.rs @@ -0,0 +1,724 @@ +use crate::{ + environment::{current_timestamp, resolve_cli_binary}, + generation::{ + create_spec_generation_temp_dir, format_process_failure, map_claude_reasoning, + map_codex_reasoning, run_command_with_stdin, + }, + git::git_get_diff_for_root, + models::{ + AutonomyMode, ChatEventPayload, ChatMessage, ChatRuntimeState, ChatSessionSnapshot, + MessageRole, SessionStatus, + }, + state::{ChatExecutionRuntime, WorkspaceContext}, +}; +use std::{ + fs, + path::Path, + process::{Command, Stdio}, + sync::Arc, +}; +use tauri::{AppHandle, Emitter}; + +use super::{ + helpers::{ + build_message_preview, create_chat_entity_id, summarize_session, + }, + persistence::{ + read_chat_session_snapshot, refresh_index_summary, write_chat_session_snapshot, + }, + prompt::{build_chat_prompt, build_context_blocks}, +}; + +pub(super) enum ApprovalGateResult { + Approved, + Stopped, + Replaced, +} + +enum ApprovalOutcome { + Approved, + StopRequested, + Replaced, +} + +enum ChatStopState { + Continue, + StopRequested, + Replaced, +} + +#[derive(Clone, Copy)] +pub(super) enum ChatExecutionPhase { + Proposal, + Write, +} + +impl ChatExecutionPhase { + pub(super) fn label(self) -> &'static str { + match self { + Self::Proposal => "proposal", + Self::Write => "write", + } + } + + pub(super) fn milestone(self) -> &'static str { + match self { + Self::Proposal => "Proposal Pass", + Self::Write => "Execution Pass", + } + } + + pub(super) fn completed_milestone(self) -> &'static str { + match self { + Self::Proposal => "Proposal Complete", + Self::Write => "Execution Complete", + } + } + + pub(super) fn summary(self) -> &'static str { + match self { + Self::Proposal => { + "Running a read-only pass to propose the patch or command plan before approval." + } + Self::Write => "Running the selected CLI against the project workspace.", + } + } + + pub(super) fn completed_summary(self) -> &'static str { + match self { + Self::Proposal => { + "Proposal phase completed. Review the suggested plan before continuing." + } + Self::Write => { + "Provider turn completed. Refresh the diff and transcript before continuing." + } + } + } + + pub(super) fn line(self) -> &'static str { + match self { + Self::Proposal => { + "Launching the proposal pass with read-only permissions and the attached project context." + } + Self::Write => "Launching the write pass with the configured autonomy permissions.", + } + } + + pub(super) fn instructions(self) -> &'static str { + match self { + Self::Proposal => { + "Proposal-only pass. Do not mutate files or run write commands. Produce the clearest patch or command plan you would execute after approval." + } + Self::Write => { + "Write-enabled pass. You may edit files and run commands that fit the current autonomy mode. Summarize what changed and call out any blockers." + } + } + } + + fn codex_sandbox(self) -> &'static str { + match self { + Self::Proposal => "read-only", + Self::Write => "workspace-write", + } + } + + fn claude_permission_mode(self) -> &'static str { + match self { + Self::Proposal => "default", + Self::Write => "acceptEdits", + } + } +} + +pub(super) fn run_approval_gate( + app: &AppHandle, + workspace: &WorkspaceContext, + session_id: &str, + runtime: &Arc, + run_id: u64, + snapshot: &mut ChatSessionSnapshot, + pending_request_message: &str, + execution_summary_message: &str, + halt_message: &str, + approved_summary: &str, + resume_status: bool, +) -> Result { + snapshot.runtime.awaiting_approval = true; + snapshot.runtime.is_busy = true; + snapshot.runtime.status = SessionStatus::AwaitingApproval; + snapshot.runtime.pending_request = Some(pending_request_message.to_string()); + snapshot.runtime.execution_summary = Some(execution_summary_message.to_string()); + snapshot.runtime.pending_diff = Some(git_get_diff_for_root(&workspace.root)?); + snapshot.updated_at = current_timestamp(); + snapshot.status = SessionStatus::AwaitingApproval; + write_chat_session_snapshot(&workspace.root, snapshot)?; + refresh_index_summary(&workspace.root, snapshot)?; + emit_session_event( + app, + session_id, + "approvalRequired", + Some(snapshot.clone()), + None, + None, + Some(snapshot.runtime.clone()), + ); + + match wait_for_approval(runtime, session_id, run_id)? { + ApprovalOutcome::Approved => { + snapshot.runtime.awaiting_approval = false; + snapshot.runtime.pending_request = None; + snapshot.runtime.execution_summary = Some(approved_summary.to_string()); + if resume_status { + snapshot.runtime.status = SessionStatus::Executing; + snapshot.status = SessionStatus::Executing; + } + snapshot.updated_at = current_timestamp(); + write_chat_session_snapshot(&workspace.root, snapshot)?; + refresh_index_summary(&workspace.root, snapshot)?; + Ok(ApprovalGateResult::Approved) + } + ApprovalOutcome::StopRequested => { + halt_session(app, &workspace.root, session_id, snapshot, halt_message)?; + Ok(ApprovalGateResult::Stopped) + } + ApprovalOutcome::Replaced => Ok(ApprovalGateResult::Replaced), + } +} + +pub(super) fn run_chat_turn( + app: AppHandle, + runtime: Arc, + workspace: WorkspaceContext, + session_id: String, + run_id: u64, + user_message: String, + claude_path: Option, + codex_path: Option, +) { + let result = (|| -> Result<(), String> { + let mut snapshot = read_chat_session_snapshot(&workspace.root, &session_id)?; + snapshot.messages.push(ChatMessage { + id: create_chat_entity_id("msg"), + role: MessageRole::User, + content: user_message.clone(), + created_at: current_timestamp(), + }); + snapshot.status = SessionStatus::Executing; + snapshot.last_message_preview = build_message_preview(&user_message); + snapshot.updated_at = current_timestamp(); + snapshot.runtime.status = SessionStatus::Executing; + snapshot.runtime.is_busy = true; + snapshot.runtime.awaiting_approval = false; + snapshot.runtime.last_error = None; + snapshot.runtime.pending_request = None; + snapshot.runtime.execution_summary = + Some(String::from("Preparing context and launching the selected CLI.")); + snapshot.runtime.pending_diff = None; + snapshot.runtime.current_milestone = Some(String::from("Queue Turn")); + write_chat_session_snapshot(&workspace.root, &snapshot)?; + refresh_index_summary(&workspace.root, &snapshot)?; + emit_session_event( + &app, + &session_id, + "messageStarted", + Some(snapshot.clone()), + None, + None, + Some(snapshot.runtime.clone()), + ); + + append_terminal_line( + &app, + &session_id, + &mut snapshot, + "Queued the new user turn and resolved the session context.", + ); + + if matches!( + stop_state(&runtime, &session_id, run_id), + ChatStopState::StopRequested + ) { + halt_session( + &app, + &workspace.root, + &session_id, + &mut snapshot, + "Turn stopped before execution began.", + )?; + return Ok(()); + } + + if snapshot.autonomy_mode == AutonomyMode::Stepped { + execute_chat_phase( + &app, + &workspace, + &session_id, + &runtime, + run_id, + &mut snapshot, + &user_message, + &claude_path, + &codex_path, + ChatExecutionPhase::Proposal, + )?; + + match run_approval_gate( + &app, + &workspace, + &session_id, + &runtime, + run_id, + &mut snapshot, + "Approve the proposal to rerun this turn with write access.", + "Stepped mode paused after the proposal phase. Approve to rerun the turn with write access.", + "Turn stopped during the stepped approval gate.", + "Approval received. Replaying the turn with write access enabled.", + true, + )? { + ApprovalGateResult::Approved => {} + ApprovalGateResult::Stopped | ApprovalGateResult::Replaced => return Ok(()), + } + + execute_chat_phase( + &app, + &workspace, + &session_id, + &runtime, + run_id, + &mut snapshot, + &user_message, + &claude_path, + &codex_path, + ChatExecutionPhase::Write, + )?; + } else { + execute_chat_phase( + &app, + &workspace, + &session_id, + &runtime, + run_id, + &mut snapshot, + &user_message, + &claude_path, + &codex_path, + ChatExecutionPhase::Write, + )?; + } + + if snapshot.autonomy_mode == AutonomyMode::Milestone { + match run_approval_gate( + &app, + &workspace, + &session_id, + &runtime, + run_id, + &mut snapshot, + "Approve the current diff to unlock the next turn.", + "Milestone mode paused after this turn. Review the current diff before the next prompt.", + "Turn stopped during the milestone approval gate.", + "Diff approved. The topic is ready for the next prompt.", + false, + )? { + ApprovalGateResult::Approved => {} + ApprovalGateResult::Stopped | ApprovalGateResult::Replaced => return Ok(()), + } + } + + snapshot.runtime.status = SessionStatus::Completed; + snapshot.runtime.is_busy = false; + snapshot.runtime.awaiting_approval = false; + snapshot.runtime.pending_request = None; + snapshot.runtime.current_milestone = Some(String::from("Complete")); + snapshot.runtime.pending_diff = Some(git_get_diff_for_root(&workspace.root)?); + snapshot.runtime.execution_summary = Some(String::from( + "Turn completed. The transcript, terminal stream, and current diff are ready.", + )); + snapshot.status = SessionStatus::Completed; + snapshot.updated_at = current_timestamp(); + write_chat_session_snapshot(&workspace.root, &snapshot)?; + refresh_index_summary(&workspace.root, &snapshot)?; + emit_session_event( + &app, + &session_id, + "completed", + Some(snapshot), + None, + None, + None, + ); + + Ok(()) + })(); + + if let Err(error) = result { + let _ = mark_session_error(&app, &workspace.root, &session_id, error); + } +} + +fn execute_chat_phase( + app: &AppHandle, + workspace: &WorkspaceContext, + session_id: &str, + runtime: &Arc, + run_id: u64, + snapshot: &mut ChatSessionSnapshot, + user_message: &str, + claude_path: &Option, + codex_path: &Option, + phase: ChatExecutionPhase, +) -> Result<(), String> { + if !matches!(stop_state(runtime, session_id, run_id), ChatStopState::Continue) { + halt_session( + app, + &workspace.root, + session_id, + snapshot, + "Turn stopped before the provider phase finished.", + )?; + return Ok(()); + } + + snapshot.runtime.current_milestone = Some(String::from(phase.milestone())); + snapshot.runtime.execution_summary = Some(String::from(phase.summary())); + write_chat_session_snapshot(&workspace.root, snapshot)?; + refresh_index_summary(&workspace.root, snapshot)?; + append_terminal_line(app, session_id, snapshot, phase.line()); + + let context_blocks = build_context_blocks(workspace, snapshot)?; + let prompt_payload = build_chat_prompt(snapshot, &context_blocks, user_message, phase); + let assistant_content = run_chat_provider_request( + &workspace.root, + &snapshot.selected_model, + &snapshot.selected_reasoning, + phase, + &prompt_payload, + claude_path.as_deref(), + codex_path.as_deref(), + )?; + + let assistant_message = ChatMessage { + id: create_chat_entity_id("msg"), + role: MessageRole::Assistant, + content: assistant_content.trim().to_string(), + created_at: current_timestamp(), + }; + snapshot.messages.push(assistant_message.clone()); + snapshot.last_message_preview = build_message_preview(&assistant_message.content); + snapshot.updated_at = current_timestamp(); + snapshot.runtime.pending_diff = Some(git_get_diff_for_root(&workspace.root)?); + snapshot.runtime.current_milestone = Some(String::from(phase.completed_milestone())); + snapshot.runtime.execution_summary = Some(String::from(phase.completed_summary())); + snapshot.status = SessionStatus::Executing; + write_chat_session_snapshot(&workspace.root, snapshot)?; + refresh_index_summary(&workspace.root, snapshot)?; + emit_session_event( + app, + session_id, + "messageDelta", + None, + Some(assistant_message.content.clone()), + None, + None, + ); + emit_session_event( + app, + session_id, + "sessionUpdated", + Some(snapshot.clone()), + Some(assistant_message.content), + None, + Some(snapshot.runtime.clone()), + ); + + Ok(()) +} + +fn run_chat_provider_request( + workspace_root: &Path, + model: &str, + reasoning: &str, + phase: ChatExecutionPhase, + prompt_payload: &str, + claude_path: Option<&str>, + codex_path: Option<&str>, +) -> Result { + if model.starts_with("claude") { + run_claude_chat_request( + workspace_root, + &resolve_cli_binary("claude", claude_path)?, + model, + reasoning, + phase, + prompt_payload, + ) + } else { + run_codex_chat_request( + workspace_root, + &resolve_cli_binary("codex", codex_path)?, + model, + reasoning, + phase, + prompt_payload, + ) + } +} + +fn run_codex_chat_request( + workspace_root: &Path, + binary_path: &Path, + model: &str, + reasoning: &str, + phase: ChatExecutionPhase, + prompt_payload: &str, +) -> Result { + let temp_dir = create_spec_generation_temp_dir("codex-chat")?; + let output_path = temp_dir.join("assistant-message.md"); + let mut command = Command::new(binary_path); + command + .current_dir(workspace_root) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .arg("exec") + .arg("--color") + .arg("never") + .arg("--skip-git-repo-check") + .arg("--sandbox") + .arg(phase.codex_sandbox()) + .arg("--model") + .arg(model) + .arg("--config") + .arg(format!( + "model_reasoning_effort=\"{}\"", + map_codex_reasoning(reasoning) + )) + .arg("--output-last-message") + .arg(&output_path); + + let output = run_command_with_stdin(&mut command, "Codex CLI", prompt_payload)?; + + if !output.status.success() { + let _ = fs::remove_dir_all(&temp_dir); + return Err(format_process_failure("Codex CLI", &output)); + } + + let result = fs::read_to_string(&output_path).or_else(|_| { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + if stdout.is_empty() { + Err(std::io::Error::new( + std::io::ErrorKind::Other, + "The Codex CLI returned no assistant content.", + )) + } else { + Ok(stdout) + } + }); + let _ = fs::remove_dir_all(&temp_dir); + + result.map_err(|error| format!("Unable to read the Codex assistant output: {error}")) +} + +fn run_claude_chat_request( + workspace_root: &Path, + binary_path: &Path, + model: &str, + reasoning: &str, + phase: ChatExecutionPhase, + prompt_payload: &str, +) -> Result { + let mut command = Command::new(binary_path); + command + .current_dir(workspace_root) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .arg("--print") + .arg("Respond to the request provided on stdin.") + .arg("--model") + .arg(model) + .arg("--output-format") + .arg("text") + .arg("--permission-mode") + .arg(phase.claude_permission_mode()) + .arg("--max-turns") + .arg("8") + .arg("--effort") + .arg(map_claude_reasoning(reasoning)); + + let output = run_command_with_stdin(&mut command, "Claude CLI", prompt_payload)?; + + if !output.status.success() { + return Err(format_process_failure("Claude CLI", &output)); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +fn append_terminal_line( + app: &AppHandle, + session_id: &str, + snapshot: &mut ChatSessionSnapshot, + line: &str, +) { + snapshot.runtime.terminal_output.push(line.to_string()); + + if snapshot.runtime.terminal_output.len() > 240 { + let keep_from = snapshot.runtime.terminal_output.len() - 240; + snapshot.runtime.terminal_output.drain(0..keep_from); + } + + emit_session_event( + app, + session_id, + "terminalLine", + None, + None, + Some(line.to_string()), + Some(snapshot.runtime.clone()), + ); +} + +fn halt_session( + app: &AppHandle, + workspace_root: &Path, + session_id: &str, + snapshot: &mut ChatSessionSnapshot, + message: &str, +) -> Result<(), String> { + snapshot.status = SessionStatus::Halted; + snapshot.runtime.status = SessionStatus::Halted; + snapshot.runtime.is_busy = false; + snapshot.runtime.awaiting_approval = false; + snapshot.runtime.pending_request = None; + snapshot.runtime.execution_summary = Some(message.to_string()); + snapshot.updated_at = current_timestamp(); + write_chat_session_snapshot(workspace_root, snapshot)?; + refresh_index_summary(workspace_root, snapshot)?; + emit_session_event( + app, + session_id, + "halted", + Some(snapshot.clone()), + None, + None, + Some(snapshot.runtime.clone()), + ); + Ok(()) +} + +fn mark_session_error( + app: &AppHandle, + workspace_root: &Path, + session_id: &str, + error: String, +) -> Result<(), String> { + let mut snapshot = read_chat_session_snapshot(workspace_root, session_id)?; + snapshot.status = SessionStatus::Error; + snapshot.runtime.status = SessionStatus::Error; + snapshot.runtime.is_busy = false; + snapshot.runtime.awaiting_approval = false; + snapshot.runtime.pending_request = None; + snapshot.runtime.last_error = Some(error.clone()); + snapshot.runtime.execution_summary = Some(error.clone()); + snapshot.updated_at = current_timestamp(); + write_chat_session_snapshot(workspace_root, &snapshot)?; + refresh_index_summary(workspace_root, &snapshot)?; + emit_session_event( + app, + session_id, + "error", + None, + Some(error), + None, + Some(snapshot.runtime), + ); + Ok(()) +} + +fn emit_session_event( + app: &AppHandle, + session_id: &str, + event_type: &str, + session: Option, + message_delta: Option, + terminal_line: Option, + runtime: Option, +) { + let summary = session.as_ref().map(summarize_session); + let message = session + .as_ref() + .and_then(|snapshot| snapshot.messages.last().cloned()); + let payload = ChatEventPayload { + session_id: session_id.to_string(), + event_type: event_type.to_string(), + message, + message_delta, + terminal_line, + session, + runtime, + summary, + }; + + let _ = app.emit("chat-session-event", payload); +} + +fn wait_for_approval( + runtime: &Arc, + session_id: &str, + run_id: u64, +) -> Result { + let mut controls = runtime + .control + .lock() + .map_err(|_| String::from("Chat execution lock was poisoned."))?; + let control = controls.entry(session_id.to_string()).or_default(); + control.awaiting_approval = true; + runtime.signal.notify_all(); + + loop { + let current = controls.entry(session_id.to_string()).or_default().clone(); + + if current.run_id != run_id { + return Ok(ApprovalOutcome::Replaced); + } + + if current.stop_requested { + return Ok(ApprovalOutcome::StopRequested); + } + + if !current.awaiting_approval { + return Ok(ApprovalOutcome::Approved); + } + + controls = runtime + .signal + .wait(controls) + .map_err(|_| String::from("Chat execution lock was poisoned."))?; + } +} + +fn stop_state( + runtime: &Arc, + session_id: &str, + run_id: u64, +) -> ChatStopState { + runtime + .control + .lock() + .map(|controls| { + let Some(control) = controls.get(session_id) else { + return ChatStopState::Continue; + }; + + if control.run_id != run_id { + ChatStopState::Replaced + } else if control.stop_requested { + ChatStopState::StopRequested + } else { + ChatStopState::Continue + } + }) + .unwrap_or(ChatStopState::StopRequested) +} diff --git a/src-tauri/src/chat/helpers.rs b/src-tauri/src/chat/helpers.rs new file mode 100644 index 0000000..c6e26a7 --- /dev/null +++ b/src-tauri/src/chat/helpers.rs @@ -0,0 +1,168 @@ +use crate::{ + models::{ + AutonomyMode, ChatContextItem, ChatSessionIndexPayload, ChatSessionSnapshot, + ChatSessionSummary, ProjectSettings, + }, + project::{build_default_project_settings, load_project_settings_from_workspace_root}, + state::{SharedState, WorkspaceContext}, +}; +use std::{ + collections::BTreeSet, + path::Path, + sync::atomic::{AtomicU64, Ordering}, + time::{SystemTime, UNIX_EPOCH}, +}; +use tauri::State; + +pub(crate) static SESSION_COUNTER: AtomicU64 = AtomicU64::new(0); + +pub(super) fn active_workspace_context( + state: &State, +) -> Result { + state + .workspace + .lock() + .map_err(|_| String::from("Workspace lock was poisoned."))? + .clone() + .ok_or_else(|| String::from("No workspace folder is currently open.")) +} + +pub(super) fn load_workspace_project_settings( + workspace_root: &Path, +) -> Result { + let defaults = build_default_project_settings(workspace_root, None, None); + load_project_settings_from_workspace_root(workspace_root, defaults) + .map(|(settings, _)| settings) +} + +pub(super) fn build_default_context_items(settings: &ProjectSettings) -> Vec { + let mut items = vec![ + build_context_item("prd", "PRD", Some(settings.prd_path.clone()), true), + build_context_item("spec", "SPEC", Some(settings.spec_path.clone()), true), + build_context_item("workspace_summary", "Workspace Tree Summary", None, true), + ]; + + for path in &settings.supporting_document_paths { + items.push(build_context_item( + "supporting_document", + &format!("Supporting: {path}"), + Some(path.clone()), + true, + )); + } + + normalize_context_items(items) +} + +pub(super) fn build_context_item( + kind: &str, + label: &str, + path: Option, + is_default: bool, +) -> ChatContextItem { + ChatContextItem { + id: create_chat_entity_id("ctx"), + kind: kind.to_string(), + label: label.to_string(), + path, + is_default, + } +} + +pub(super) fn normalize_context_items(items: Vec) -> Vec { + let mut seen = BTreeSet::::new(); + let mut normalized_items = Vec::new(); + + for item in items { + let dedupe_key = format!( + "{}::{}", + item.kind, + item.path.as_deref().unwrap_or(item.label.as_str()) + ); + + if !seen.insert(dedupe_key) { + continue; + } + + normalized_items.push(ChatContextItem { + id: if item.id.trim().is_empty() { + create_chat_entity_id("ctx") + } else { + item.id + }, + kind: item.kind.trim().to_string(), + label: item.label.trim().to_string(), + path: item.path.and_then(|value| { + let trimmed_value = value.trim().replace('\\', "/"); + (!trimmed_value.is_empty()).then_some(trimmed_value) + }), + is_default: item.is_default, + }); + } + + normalized_items +} + +pub(super) fn summarize_session(snapshot: &ChatSessionSnapshot) -> ChatSessionSummary { + ChatSessionSummary { + id: snapshot.id.clone(), + title: snapshot.title.clone(), + created_at: snapshot.created_at.clone(), + updated_at: snapshot.updated_at.clone(), + status: snapshot.status.clone(), + last_message_preview: snapshot.last_message_preview.clone(), + selected_model: snapshot.selected_model.clone(), + selected_reasoning: snapshot.selected_reasoning.clone(), + autonomy_mode: snapshot.autonomy_mode.clone(), + } +} + +pub(super) fn upsert_chat_session_summary( + index: &mut ChatSessionIndexPayload, + summary: ChatSessionSummary, +) { + if let Some(existing_summary) = index.sessions.iter_mut().find(|entry| entry.id == summary.id) { + *existing_summary = summary; + } else { + index.sessions.push(summary); + } + + index + .sessions + .sort_by(|left, right| right.updated_at.cmp(&left.updated_at)); +} + +pub(super) fn normalized_title(title: Option<&str>) -> Option { + title + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.replace('\n', " ")) +} + +pub(super) fn normalize_autonomy_mode(value: &str) -> AutonomyMode { + match value.trim() { + "stepped" => AutonomyMode::Stepped, + "god_mode" => AutonomyMode::GodMode, + _ => AutonomyMode::Milestone, + } +} + +pub(super) fn build_message_preview(value: &str) -> String { + let collapsed = value.split_whitespace().collect::>().join(" "); + let mut preview = collapsed.chars().take(120).collect::(); + + if collapsed.chars().count() > 120 { + preview.push('…'); + } + + preview +} + +pub(super) fn create_chat_entity_id(prefix: &str) -> String { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or_default(); + let counter = SESSION_COUNTER.fetch_add(1, Ordering::Relaxed); + format!("{prefix}-{millis:x}-{counter:x}") +} diff --git a/src-tauri/src/chat/mod.rs b/src-tauri/src/chat/mod.rs new file mode 100644 index 0000000..d07d3d5 --- /dev/null +++ b/src-tauri/src/chat/mod.rs @@ -0,0 +1,11 @@ +mod commands; +mod execution; +mod helpers; +mod persistence; +mod prompt; + +pub(crate) use commands::{ + approve_chat_session, create_chat_session, delete_chat_session, load_chat_session, + rename_chat_session, save_chat_session, send_chat_message, stop_chat_session, +}; +pub(crate) use persistence::load_chat_session_index; diff --git a/src-tauri/src/chat/persistence.rs b/src-tauri/src/chat/persistence.rs new file mode 100644 index 0000000..e7fc2f0 --- /dev/null +++ b/src-tauri/src/chat/persistence.rs @@ -0,0 +1,125 @@ +use crate::models::{ChatSessionIndexPayload, ChatSessionSnapshot}; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use super::helpers::{summarize_session, upsert_chat_session_summary}; + +pub(super) const SESSION_DIRECTORY_RELATIVE_PATH: &str = ".specforge/sessions"; +pub(super) const SESSION_INDEX_FILE_NAME: &str = "index.json"; + +pub(crate) fn load_chat_session_index( + workspace_root: &Path, +) -> Result { + let index_path = session_index_path(workspace_root); + + if !index_path.exists() { + return Ok(ChatSessionIndexPayload { + sessions: Vec::new(), + last_active_session_id: None, + }); + } + + let raw_value = fs::read_to_string(&index_path).map_err(|error| { + format!( + "Unable to read the chat session index {}: {error}", + index_path.display() + ) + })?; + + serde_json::from_str::(&raw_value).map_err(|error| { + format!( + "Unable to parse the chat session index {}: {error}", + index_path.display() + ) + }) +} + +pub(super) fn write_chat_session_index( + workspace_root: &Path, + index: &ChatSessionIndexPayload, +) -> Result<(), String> { + ensure_session_directory(workspace_root)?; + let encoded = serde_json::to_string_pretty(index) + .map_err(|error| format!("Unable to encode the chat session index: {error}"))?; + fs::write(session_index_path(workspace_root), encoded.as_bytes()).map_err(|error| { + format!( + "Unable to write the chat session index {}: {error}", + session_index_path(workspace_root).display() + ) + }) +} + +pub(super) fn read_chat_session_snapshot( + workspace_root: &Path, + session_id: &str, +) -> Result { + let session_path = session_snapshot_path(workspace_root, session_id); + let raw_value = fs::read_to_string(&session_path).map_err(|error| { + format!( + "Unable to read the chat session {}: {error}", + session_path.display() + ) + })?; + + serde_json::from_str::(&raw_value).map_err(|error| { + format!( + "Unable to parse the chat session {}: {error}", + session_path.display() + ) + }) +} + +pub(super) fn write_chat_session_snapshot( + workspace_root: &Path, + snapshot: &ChatSessionSnapshot, +) -> Result<(), String> { + ensure_session_directory(workspace_root)?; + let encoded = serde_json::to_string_pretty(snapshot) + .map_err(|error| format!("Unable to encode the chat session {}: {error}", snapshot.id))?; + fs::write( + session_snapshot_path(workspace_root, &snapshot.id), + encoded.as_bytes(), + ) + .map_err(|error| { + format!( + "Unable to write the chat session {}: {error}", + session_snapshot_path(workspace_root, &snapshot.id).display() + ) + }) +} + +pub(super) fn ensure_session_directory(workspace_root: &Path) -> Result<(), String> { + let sessions_path = sessions_directory_path(workspace_root); + fs::create_dir_all(&sessions_path).map_err(|error| { + format!( + "Unable to create the chat session directory {}: {error}", + sessions_path.display() + ) + }) +} + +pub(super) fn sessions_directory_path(workspace_root: &Path) -> PathBuf { + workspace_root.join(SESSION_DIRECTORY_RELATIVE_PATH) +} + +pub(super) fn session_index_path(workspace_root: &Path) -> PathBuf { + sessions_directory_path(workspace_root).join(SESSION_INDEX_FILE_NAME) +} + +pub(super) fn session_snapshot_path(workspace_root: &Path, session_id: &str) -> PathBuf { + sessions_directory_path(workspace_root).join(format!("{session_id}.json")) +} + +pub(super) fn refresh_index_summary( + workspace_root: &Path, + snapshot: &ChatSessionSnapshot, +) -> Result<(), String> { + let mut index = load_chat_session_index(workspace_root)?; + upsert_chat_session_summary(&mut index, summarize_session(snapshot)); + if index.last_active_session_id.is_none() { + index.last_active_session_id = Some(snapshot.id.clone()); + } + write_chat_session_index(workspace_root, &index) +} diff --git a/src-tauri/src/chat/prompt.rs b/src-tauri/src/chat/prompt.rs new file mode 100644 index 0000000..4bb6c84 --- /dev/null +++ b/src-tauri/src/chat/prompt.rs @@ -0,0 +1,126 @@ +use crate::{ + documents::parse_workspace_document, + models::ChatSessionSnapshot, + paths::resolve_relative_path_under_root, + state::WorkspaceContext, +}; + +use super::execution::ChatExecutionPhase; + +pub(super) const CAVEMAN_PREAMBLE: &str = + "Default response style: caveman. Keep prose terse and direct while leaving code blocks, commands, and diffs fully normal."; + +pub(super) fn build_context_blocks( + workspace: &WorkspaceContext, + snapshot: &ChatSessionSnapshot, +) -> Result, String> { + let mut blocks = Vec::new(); + + for item in &snapshot.context_items { + let content = match item.kind.as_str() { + "workspace_summary" => build_workspace_summary(workspace), + _ => { + let Some(path) = item.path.as_deref() else { + continue; + }; + let resolved_path = resolve_relative_path_under_root(&workspace.root, path)?; + + if !resolved_path.exists() { + format!("Missing file at {path}.") + } else { + parse_workspace_document(&resolved_path)? + } + } + }; + + if content.trim().is_empty() { + continue; + } + + blocks.push((item.label.clone(), content)); + } + + Ok(blocks) +} + +pub(super) fn build_chat_prompt( + snapshot: &ChatSessionSnapshot, + context_blocks: &[(String, String)], + user_message: &str, + phase: ChatExecutionPhase, +) -> String { + let mut prompt = String::new(); + prompt.push_str(CAVEMAN_PREAMBLE); + prompt.push_str("\n\n"); + prompt.push_str( + "You are SpecForge Chat, a desktop coding assistant operating on a project-scoped topic.\n", + ); + prompt.push_str( + "Keep responses direct. Preserve technical accuracy. Use the attached project context.\n", + ); + prompt.push_str("Current topic: "); + prompt.push_str(&snapshot.title); + prompt.push_str("\nAutonomy mode: "); + prompt.push_str(&snapshot.autonomy_mode.to_string()); + prompt.push_str("\nExecution phase: "); + prompt.push_str(phase.label()); + prompt.push('\n'); + prompt.push_str(phase.instructions()); + + if !context_blocks.is_empty() { + prompt.push_str("\n\nAttached context:\n"); + + for (label, content) in context_blocks { + prompt.push_str("\n### "); + prompt.push_str(label); + prompt.push('\n'); + prompt.push_str(content.trim()); + prompt.push('\n'); + } + } + + if !snapshot.messages.is_empty() { + prompt.push_str("\nConversation so far:\n"); + + for message in &snapshot.messages { + prompt.push_str("\n"); + prompt.push_str(message.role.display_label()); + prompt.push_str(": "); + prompt.push_str(message.content.trim()); + prompt.push('\n'); + } + } else { + prompt.push_str("\nConversation so far:\n\nNo prior turns yet.\n"); + } + + prompt.push_str("\n--- BEGIN USER REQUEST ---\n"); + prompt.push_str(user_message.trim()); + prompt.push_str("\n--- END USER REQUEST ---\n"); + prompt +} + +pub(super) fn build_workspace_summary(workspace: &WorkspaceContext) -> String { + let mut paths = workspace.files.keys().cloned().collect::>(); + paths.sort(); + + if paths.is_empty() { + return String::from("No workspace files were discovered for this project."); + } + + let mut summary = String::from("Workspace files:\n"); + + for path in paths.iter().take(180) { + summary.push_str("- "); + summary.push_str(path); + summary.push('\n'); + } + + if paths.len() > 180 { + summary.push_str(&format!( + "- ... and {} more files not shown in this summary.\n", + paths.len() - 180 + )); + } + + summary +} diff --git a/src-tauri/src/generation.rs b/src-tauri/src/generation.rs index 28ae78c..7eb254f 100644 --- a/src-tauri/src/generation.rs +++ b/src-tauri/src/generation.rs @@ -147,9 +147,9 @@ pub(crate) fn build_generation_prompt( ) -> String { let mut prompt = String::new(); prompt.push_str(prompt_template.trim()); - prompt.push_str("\n\n"); - prompt.push_str("Additional operator context:\n"); + prompt.push_str("\n\nAdditional operator context:\n--- BEGIN OPERATOR CONTEXT ---\n"); prompt.push_str(user_prompt.trim()); + prompt.push_str("\n--- END OPERATOR CONTEXT ---"); for (label, content) in attachments { let trimmed_content = content.trim(); diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index de20c43..4356a66 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -1,5 +1,52 @@ use serde::{Deserialize, Serialize}; +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum SessionStatus { + Idle, + Executing, + AwaitingApproval, + Completed, + Halted, + Error, +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) enum AutonomyMode { + #[serde(rename = "stepped")] + Stepped, + #[serde(rename = "milestone")] + Milestone, + #[serde(rename = "god_mode")] + GodMode, +} + +impl std::fmt::Display for AutonomyMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Stepped => write!(f, "stepped"), + Self::Milestone => write!(f, "milestone"), + Self::GodMode => write!(f, "god_mode"), + } + } +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum MessageRole { + User, + Assistant, +} + +impl MessageRole { + pub(crate) fn display_label(&self) -> &'static str { + match self { + Self::User => "USER", + Self::Assistant => "ASSISTANT", + } + } +} + #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct CliStatus { @@ -96,7 +143,7 @@ pub(crate) struct ChatContextItem { #[serde(rename_all = "camelCase")] pub(crate) struct ChatMessage { pub(crate) id: String, - pub(crate) role: String, + pub(crate) role: MessageRole, pub(crate) content: String, pub(crate) created_at: String, } @@ -104,7 +151,7 @@ pub(crate) struct ChatMessage { #[derive(Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct ChatRuntimeState { - pub(crate) status: String, + pub(crate) status: SessionStatus, pub(crate) terminal_output: Vec, pub(crate) current_milestone: Option, pub(crate) pending_diff: Option, @@ -118,7 +165,7 @@ pub(crate) struct ChatRuntimeState { impl Default for ChatRuntimeState { fn default() -> Self { Self { - status: String::from("idle"), + status: SessionStatus::Idle, terminal_output: Vec::new(), current_milestone: None, pending_diff: None, @@ -138,11 +185,11 @@ pub(crate) struct ChatSessionSummary { pub(crate) title: String, pub(crate) created_at: String, pub(crate) updated_at: String, - pub(crate) status: String, + pub(crate) status: SessionStatus, pub(crate) last_message_preview: String, pub(crate) selected_model: String, pub(crate) selected_reasoning: String, - pub(crate) autonomy_mode: String, + pub(crate) autonomy_mode: AutonomyMode, } #[derive(Clone, Serialize, Deserialize)] @@ -152,11 +199,11 @@ pub(crate) struct ChatSessionSnapshot { pub(crate) title: String, pub(crate) created_at: String, pub(crate) updated_at: String, - pub(crate) status: String, + pub(crate) status: SessionStatus, pub(crate) last_message_preview: String, pub(crate) selected_model: String, pub(crate) selected_reasoning: String, - pub(crate) autonomy_mode: String, + pub(crate) autonomy_mode: AutonomyMode, pub(crate) context_items: Vec, pub(crate) messages: Vec, pub(crate) runtime: ChatRuntimeState, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 144c8da..951b20e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -21,7 +21,7 @@ } ], "security": { - "csp": null + "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src ipc: http://ipc.localhost https://ipc.localhost" } }, "bundle": { diff --git a/src/App.tsx b/src/App.tsx index 34316b3..115a976 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,11 +1,10 @@ import { - startTransition, + lazy, + Suspense, useCallback, useEffect, - useMemo, useRef, - useState, - type ChangeEvent + useState } from "react"; import { Navigate, @@ -17,91 +16,72 @@ import { import { useShallow } from "zustand/react/shallow"; import { AppRail } from "./components/AppRail"; +import { + useAgentEventSubscription, + useDocumentTheme, + useInitialDiagnostics, + useProjectRestore, + useSystemThemePreference, + useWorkspaceSearchFocus, + useWorkspaceSearchRouteReset, + useWorkspaceSearchShortcuts +} from "./hooks/useAppLifecycle"; +import { + useAgentStoreSlice, + useProjectStoreSlice, + useSettingsStoreSlice +} from "./hooks/useAppStoreSlices"; +import { + useAppDerivedState, + useAppScreenProps, + useAppUiHandlers, + useProjectSettingsHandlers +} from "./hooks/useAppView"; +import { useChatHandlers } from "./hooks/useChatHandlers"; +import { useDocumentHandlers } from "./hooks/useDocumentHandlers"; +import { useProjectHandlers } from "./hooks/useProjectHandlers"; +import { + getModelLabel, + getReasoningLabel +} from "./lib/agentConfig"; import { buildFallbackSteps, clearFallbackTimer, + type DocumentTarget, + type FallbackStep, isOpenableWorkspacePath, runFallbackStep, stampLog, - type DocumentTarget, - type FallbackStep, type WorkspaceFileSource } from "./lib/appShell"; import { - getModelLabel, - getReasoningLabel -} from "./lib/agentConfig"; -import { - buildCurrentProjectSettings, - buildConfigPathDisplay, - buildWorkspaceNotice, - waitForNextPaint -} from "./lib/appState"; -import { - DEFAULT_PENDING_DIFF, - approveChatSession, approveAgentAction, createChatSession, - deleteChatSession, + DEFAULT_PENDING_DIFF, emergencyStop, - generatePrdDocument, - generateSpecDocument, getGitDiff, getWorkspaceSnapshot, isTauriRuntime, loadChatSession, - loadProjectContext, - pickDocument, - pickProjectFolder, readWorkspaceFile, - renameChatSession, runEnvironmentScan, - saveProjectSettings, - saveChatSession, - sendChatMessage, startAgentRun, - stopChatSession, subscribeToChatSessionEvents } from "./lib/runtime"; import { isOpenableTextFile, - parseWorkspaceDocument, - parseWorkspaceTextFile, - type ImportableFile + parseWorkspaceTextFile } from "./lib/workspaceImport"; -import { - useAgentEventSubscription, - useDocumentTheme, - useInitialDiagnostics, - useProjectRestore, - useSystemThemePreference, - useWorkspaceSearchFocus, - useWorkspaceSearchRouteReset, - useWorkspaceSearchShortcuts -} from "./hooks/useAppLifecycle"; -import { - useAgentStoreSlice, - useProjectStoreSlice, - useSettingsStoreSlice -} from "./hooks/useAppStoreSlices"; -import { - useAppDerivedState, - useAppScreenProps, - useAppUiHandlers, - useProjectSettingsHandlers -} from "./hooks/useAppView"; -import { ConfigurationScreen } from "./screens/ConfigurationScreen"; -import { ChatScreen } from "./screens/ChatScreen"; -import { PrdScreen } from "./screens/PrdScreen"; -import { SettingsScreen } from "./screens/SettingsScreen"; + +const ConfigurationScreen = lazy(() => import("./screens/ConfigurationScreen").then(m => ({ default: m.ConfigurationScreen }))); +const ChatScreen = lazy(() => import("./screens/ChatScreen").then(m => ({ default: m.ChatScreen }))); +const PrdScreen = lazy(() => import("./screens/PrdScreen").then(m => ({ default: m.PrdScreen }))); +const SettingsScreen = lazy(() => import("./screens/SettingsScreen").then(m => ({ default: m.SettingsScreen }))); + import { useAgentStore } from "./store/useAgentStore"; import { useChatStore } from "./store/useChatStore"; -import { useProjectStore } from "./store/useProjectStore"; import type { - ChatContextItem, - ChatSession, - EnvironmentStatus, - ProjectContext + EnvironmentStatus } from "./types"; function App() { @@ -178,8 +158,6 @@ function App() { const fallbackStepsRef = useRef([]); const fallbackIndexRef = useRef(0); const hasScannedEnvironmentRef = useRef(false); - const projectSaveTimerRef = useRef(null); - const pendingProjectReloadRef = useRef(false); const latestPathnameRef = useRef(location.pathname); useEffect(() => { @@ -242,223 +220,71 @@ function App() { [hasSelectedProject, settingsState] ); - const assignDocument = useCallback( - (target: DocumentTarget, content: string, path: string) => { - startTransition(() => { - if (target === "prd") { - projectState.setPrdContent(content, path); - projectState.setPrdPaneMode("preview"); - return; - } - - projectState.setSpecContent(content, path); - projectState.setSpecPaneMode("preview"); - }); - - if (target === "prd") { - setPrdGenerationPrompt(""); - setPrdGenerationError(""); - return; - } - - setSpecGenerationPrompt(""); - setSpecGenerationError(""); - }, - [projectState] - ); - - const applyProjectContext = useCallback( - (context: ProjectContext, options?: { navigateToChat?: boolean }) => { - const normalizedCurrentProjectPath = projectRootPath - .replace(/\\/g, "/") - .replace(/\/+$/, "") - .toLowerCase(); - const normalizedNextProjectPath = context.rootPath - .replace(/\\/g, "/") - .replace(/\/+$/, "") - .toLowerCase(); - const isSameProject = - normalizedCurrentProjectPath.length > 0 && - normalizedCurrentProjectPath === normalizedNextProjectPath; - const nextPrdSourcePath = - context.prdDocument?.sourcePath ?? context.settings.prdPath; - const nextSpecSourcePath = - context.specDocument?.sourcePath ?? context.settings.specPath; - const preserveEditingPrd = - isSameProject && - projectState.prdPaneMode === "edit" && - projectState.prdPath === nextPrdSourcePath; - const preserveEditingSpec = - isSameProject && - projectState.specPaneMode === "edit" && - projectState.specPath === nextSpecSourcePath; - const settingsPathDisplay = buildConfigPathDisplay( - context.settingsPath, - context.rootName - ); - const nextWorkspaceFiles = Object.fromEntries( - context.entries - .filter((entry) => entry.kind === "file") - .map((entry) => [ - entry.path, - { - kind: "desktop", - fileName: entry.name - } satisfies WorkspaceFileSource - ]) - ); - - if (!isSameProject) { - projectState.resetWorkspaceContext(); - } - setProjectRootName(context.rootName); - setProjectRootPath(context.rootPath); - setProjectConfigPath(context.settingsPath); - setHasSelectedProject(true); - setHasSavedProjectSettings(context.hasSavedSettings); - settingsState.setWorkspaceEntries(context.entries); - setWorkspaceFiles(nextWorkspaceFiles); - settingsState.setLastProjectPath(context.rootPath); - projectState.setProjectSettings(context.settings); - setPrdGenerationPrompt(""); - setPrdGenerationError(""); - setSpecGenerationPrompt(""); - setSpecGenerationError(""); - setChatSessions(context.chatSessions); - setActiveSessionId(context.lastActiveSessionId ?? context.chatSessions[0]?.id ?? null); - setCavemanStatus({ - ready: true, - message: "Caveman mode is built into every topic." - }); - setProjectStatusMessage( - context.hasSavedSettings - ? `Loaded project settings from ${context.rootName}/${settingsPathDisplay}.` - : `Selected ${context.rootName}. Save the setup to create ${context.rootName}/${settingsPathDisplay}.` - ); - setProjectErrorMessage(""); - setWorkspaceNotice(buildWorkspaceNotice(context)); - - startTransition(() => { - if (!preserveEditingPrd) { - projectState.setPrdContent( - context.prdDocument?.content ?? "", - nextPrdSourcePath - ); - projectState.setPrdPaneMode("preview"); - } - - if (!preserveEditingSpec) { - projectState.setSpecContent( - context.specDocument?.content ?? "", - nextSpecSourcePath - ); - projectState.setSpecPaneMode("preview"); - } - }); + // --- Document handlers --- + const { + handleOpenImportFile, + handleFileSelection, + handleGeneratePrd, + handleGenerateSpec + } = useDocumentHandlers({ + agentState, + derivedState, + desktopRuntime, + fileInputRef, + pendingImportTargetRef, + prdGenerationPrompt, + projectRootPath, + projectState, + setIsImporting, + setPrdGenerationError, + setPrdGenerationPrompt, + setSpecGenerationError, + setSpecGenerationPrompt, + settingsState, + specGenerationPrompt + }); - if (options?.navigateToChat && latestPathnameRef.current === "/") { - navigate("/chat"); - } - }, - [ - navigate, + // --- Project handlers --- + const { + applyProjectContext, + saveCurrentProjectSettings, + scheduleProjectSettingsSave, + handlePickProjectFolder, + projectSaveTimerRef + } = useProjectHandlers({ + applyProjectContextDeps: { + projectRootPath, projectState, + settingsState, + setProjectRootName, + setProjectRootPath, + setProjectConfigPath, + setHasSelectedProject, + setHasSavedProjectSettings, + setWorkspaceFiles, + setPrdGenerationPrompt, + setPrdGenerationError, + setSpecGenerationPrompt, + setSpecGenerationError, + setChatSessions, setActiveSessionId, setCavemanStatus, - setChatSessions, - settingsState - ] - ); - - const saveCurrentProjectSettings = useCallback( - async ({ - reloadProject = false, - navigateToChat = false - }: { - reloadProject?: boolean; - navigateToChat?: boolean; - } = {}) => { - if (!desktopRuntime) { - setProjectErrorMessage("Project configuration requires the desktop runtime."); - return; - } - - if (!projectRootPath.trim()) { - setProjectErrorMessage("Choose a project folder before saving."); - return; - } - - setProjectErrorMessage(""); - setProjectStatusMessage(""); - setIsProjectSaving(true); - - try { - const latestProjectState = useProjectStore.getState(); - const currentProjectSettings = buildCurrentProjectSettings({ - configuredPrdPath: latestProjectState.configuredPrdPath, - configuredSpecPath: latestProjectState.configuredSpecPath, - prdPromptTemplate: latestProjectState.prdPromptTemplate, - selectedModel: latestProjectState.selectedModel, - selectedReasoning: latestProjectState.selectedReasoning, - specPromptTemplate: latestProjectState.specPromptTemplate, - supportingDocumentPaths: latestProjectState.supportingDocumentPaths - }); - const savedSettings = await saveProjectSettings({ - folderPath: projectRootPath, - settings: currentProjectSettings - }); - - projectState.setProjectSettings(savedSettings); - setHasSavedProjectSettings(true); - setProjectStatusMessage( - projectRootName - ? `Saved project settings to ${projectRootName}/${derivedState.configPathDisplay}.` - : `Saved project settings to ${derivedState.configPathDisplay}.` - ); - - if (reloadProject || navigateToChat) { - const reloadedContext = await loadProjectContext(projectRootPath); - applyProjectContext(reloadedContext, { navigateToChat }); - } - } catch (error) { - setProjectErrorMessage( - error instanceof Error ? error.message : "Unable to save the current project settings." - ); - } finally { - setIsProjectSaving(false); - } - }, - [ - applyProjectContext, - derivedState.configPathDisplay, - desktopRuntime, - projectRootName, - projectRootPath, - projectState - ] - ); - - const scheduleProjectSettingsSave = useCallback( - (reloadProject = false) => { - if (!desktopRuntime || !hasSavedProjectSettings || !projectRootPath.trim()) { - return; - } - - pendingProjectReloadRef.current = pendingProjectReloadRef.current || reloadProject; - - if (projectSaveTimerRef.current !== null) { - window.clearTimeout(projectSaveTimerRef.current); - } - - projectSaveTimerRef.current = window.setTimeout(() => { - const shouldReload = pendingProjectReloadRef.current; - pendingProjectReloadRef.current = false; - projectSaveTimerRef.current = null; - void saveCurrentProjectSettings({ reloadProject: shouldReload }); - }, 700); + setProjectStatusMessage, + setProjectErrorMessage, + setWorkspaceNotice, + latestPathnameRef }, - [desktopRuntime, hasSavedProjectSettings, projectRootPath, saveCurrentProjectSettings] - ); + derivedState, + desktopRuntime, + hasSavedProjectSettings, + projectRootName, + projectRootPath, + projectState, + setIsProjectLoading, + setIsProjectSaving, + setProjectErrorMessage, + setProjectStatusMessage + }); const projectSettingsHandlers = useProjectSettingsHandlers({ saveCurrentProjectSettings, @@ -472,60 +298,33 @@ function App() { setSupportingDocumentPaths: projectState.setSupportingDocumentPaths }); - const handlePickProjectFolder = useCallback(async () => { - if (!desktopRuntime) { - setProjectErrorMessage("Project configuration requires the desktop runtime."); - return; - } - - setProjectErrorMessage(""); - setProjectStatusMessage(""); - setIsProjectLoading(true); - - try { - const nextProjectContext = await pickProjectFolder(); - - if (!nextProjectContext) { - return; - } - - applyProjectContext(nextProjectContext); - navigate("/"); - } catch (error) { - setProjectErrorMessage( - error instanceof Error ? error.message : "Unable to open the selected project folder." - ); - } finally { - setIsProjectLoading(false); - } - }, [applyProjectContext, desktopRuntime, navigate]); - - const handleFileSelection = useCallback( - async (event: ChangeEvent) => { - const file = event.target.files?.[0] as ImportableFile | undefined; - - if (!file) { - return; - } - - try { - const document = await parseWorkspaceDocument(file); - assignDocument(pendingImportTargetRef.current, document.content, document.sourcePath); - } catch (error) { - const message = - error instanceof Error ? error.message : "The selected file could not be imported."; - - if (pendingImportTargetRef.current === "prd") { - setPrdGenerationError(message); - } else { - setSpecGenerationError(message); - } - } finally { - event.target.value = ""; - } - }, - [assignDocument] - ); + // --- Chat handlers --- + const { + handleCreateChatSessionClick, + handleSelectChatSession, + handleRenameChatSession, + handleDeleteChatSession, + handleChatDraftChange, + handleSendChatMessage, + handleApproveChatSession, + handleStopChatSession, + handleSaveChatSessionConfig, + handleAttachChatFile, + handleRemoveChatContextItem + } = useChatHandlers({ + activeChatSession, + activeChatDraft, + activeSessionId, + settingsState, + upsertSession, + setActiveSessionId, + setChatDraft, + setChatContextItems, + setSessionConfig, + deleteChatSessionState, + setChatSessions, + setProjectErrorMessage + }); const handleWorkspaceFileOpen = useCallback( async (path: string) => { @@ -574,40 +373,6 @@ function App() { [projectState, workspaceFiles] ); - const handleOpenImportFile = useCallback( - async (target: DocumentTarget) => { - pendingImportTargetRef.current = target; - - if (desktopRuntime) { - setIsImporting(true); - - try { - const document = await pickDocument(); - - if (document) { - assignDocument(target, document.content, document.sourcePath); - } - } catch (error) { - const message = - error instanceof Error ? error.message : "The selected file could not be imported."; - - if (target === "prd") { - setPrdGenerationError(message); - } else { - setSpecGenerationError(message); - } - } finally { - setIsImporting(false); - } - - return; - } - - fileInputRef.current?.click(); - }, - [assignDocument, desktopRuntime] - ); - const handleApproveSpec = useCallback(() => { if (!projectState.specContent.trim()) { return; @@ -740,164 +505,13 @@ function App() { ); }, [agentState, desktopRuntime]); - const handleGeneratePrd = useCallback(async () => { - const trimmedPrompt = prdGenerationPrompt.trim(); - - if (!desktopRuntime) { - setPrdGenerationError("AI PRD generation requires the desktop runtime."); - return; - } - - if (!projectRootPath.trim()) { - setPrdGenerationError("Choose a project folder before generating a PRD."); - return; - } - - if (!derivedState.currentProjectSettings.prdPath.toLowerCase().endsWith(".md")) { - setPrdGenerationError("Configure the PRD path as a Markdown file before generating."); - return; - } - - if (!trimmedPrompt) { - setPrdGenerationError("Add the product context you want the AI to consider."); - return; - } - - setPrdGenerationError(""); - agentState.setStatus("generating_prd"); - agentState.appendTerminalOutput( - stampLog( - "prd", - `Generating a PRD draft with ${getModelLabel(projectState.selectedModel)} (${getReasoningLabel(projectState.selectedModel, projectState.selectedReasoning)} reasoning).` - ) - ); - - try { - await waitForNextPaint(); - - const generatedPrd = await generatePrdDocument({ - workspaceRoot: projectRootPath, - outputPath: derivedState.currentProjectSettings.prdPath, - promptTemplate: derivedState.currentProjectSettings.prdPrompt, - userPrompt: trimmedPrompt, - provider: derivedState.selectedModelProvider, - model: projectState.selectedModel, - reasoning: projectState.selectedReasoning, - claudePath: settingsState.claudePath, - codexPath: settingsState.codexPath - }); - - startTransition(() => { - projectState.setPrdContent(generatedPrd.content, generatedPrd.sourcePath); - projectState.setPrdPaneMode("preview"); - }); - setPrdGenerationPrompt(""); - agentState.setStatus("idle"); - agentState.appendTerminalOutput( - stampLog( - "prd", - `PRD draft generated, saved to ${generatedPrd.fileName}, and loaded into the review pane.` - ) - ); - } catch (error) { - const message = error instanceof Error ? error.message : "Unable to generate a PRD."; - setPrdGenerationError(message); - agentState.setStatus("error"); - agentState.appendTerminalOutput(stampLog("error", message)); - } - }, [ - agentState, - derivedState.currentProjectSettings, - derivedState.selectedModelProvider, - desktopRuntime, - prdGenerationPrompt, - projectRootPath, - projectState, - settingsState - ]); - - const handleGenerateSpec = useCallback(async () => { - const trimmedPrompt = specGenerationPrompt.trim(); - - if (!desktopRuntime) { - setSpecGenerationError("AI spec generation requires the desktop runtime."); - return; - } - - if (!projectRootPath.trim()) { - setSpecGenerationError("Choose a project folder before generating a spec."); - return; - } - - if (!projectState.prdContent.trim()) { - setSpecGenerationError("Load or generate a PRD before drafting a specification."); - return; - } - - if (!derivedState.currentProjectSettings.specPath.toLowerCase().endsWith(".md")) { - setSpecGenerationError("Configure the spec path as a Markdown file before generating."); - return; - } - - if (!trimmedPrompt) { - setSpecGenerationError("Add the technical guidance you want the AI to consider."); - return; - } - - setSpecGenerationError(""); - agentState.setStatus("generating_spec"); - agentState.appendTerminalOutput( - stampLog( - "spec", - `Generating a technical specification with ${getModelLabel(projectState.selectedModel)} (${getReasoningLabel(projectState.selectedModel, projectState.selectedReasoning)} reasoning).` - ) - ); - - try { - await waitForNextPaint(); - - const generatedSpec = await generateSpecDocument({ - workspaceRoot: projectRootPath, - outputPath: derivedState.currentProjectSettings.specPath, - prdContent: projectState.prdContent, - promptTemplate: derivedState.currentProjectSettings.specPrompt, - userPrompt: trimmedPrompt, - provider: derivedState.selectedModelProvider, - model: projectState.selectedModel, - reasoning: projectState.selectedReasoning, - claudePath: settingsState.claudePath, - codexPath: settingsState.codexPath - }); + const handleOpenChat = useCallback(() => { + navigate("/chat"); + }, [navigate]); - startTransition(() => { - projectState.setSpecContent(generatedSpec.content, generatedSpec.sourcePath); - projectState.setSpecPaneMode("preview"); - }); - setSpecGenerationPrompt(""); - agentState.setStatus("idle"); - agentState.appendTerminalOutput( - stampLog( - "spec", - `Specification draft generated, saved to ${generatedSpec.fileName}, and loaded into the review pane.` - ) - ); - } catch (error) { - const message = - error instanceof Error ? error.message : "Unable to generate a specification."; - setSpecGenerationError(message); - agentState.setStatus("error"); - agentState.appendTerminalOutput(stampLog("error", message)); - } - }, [ - agentState, - derivedState.currentProjectSettings, - derivedState.selectedModelProvider, - desktopRuntime, - projectRootPath, - projectState, - settingsState, - specGenerationPrompt - ]); + const handleOpenReview = useCallback(() => { + navigate("/review"); + }, [navigate]); const uiHandlers = useAppUiHandlers({ agentState, @@ -920,222 +534,6 @@ function App() { specGenerationError }); - const persistChatSession = useCallback( - async (payload: { - sessionId: string; - selectedModel: ChatSession["selectedModel"]; - selectedReasoning: ChatSession["selectedReasoning"]; - autonomyMode: ChatSession["autonomyMode"]; - contextItems: ChatContextItem[]; - }) => { - const nextSession = await saveChatSession(payload); - upsertSession(nextSession); - return nextSession; - }, - [upsertSession] - ); - - const handleCreateChatSessionClick = useCallback(async () => { - try { - const nextSession = await createChatSession(); - upsertSession(nextSession); - setActiveSessionId(nextSession.id); - } catch (error) { - setProjectErrorMessage( - error instanceof Error ? error.message : "Unable to create a new chat topic." - ); - } - }, [setActiveSessionId, upsertSession]); - - const handleSelectChatSession = useCallback( - (sessionId: string) => { - setActiveSessionId(sessionId); - }, - [setActiveSessionId] - ); - - const handleRenameChatSession = useCallback( - async (sessionId: string, title: string) => { - try { - await renameChatSession({ sessionId, title }); - const nextSession = await loadChatSession(sessionId); - upsertSession(nextSession); - } catch (error) { - setProjectErrorMessage( - error instanceof Error ? error.message : "Unable to rename the selected chat topic." - ); - } - }, - [upsertSession] - ); - - const handleDeleteChatSession = useCallback( - async (sessionId: string) => { - const confirmed = window.confirm("Delete this topic and its saved context?"); - - if (!confirmed) { - return; - } - - try { - const nextIndex = await deleteChatSession(sessionId); - deleteChatSessionState(sessionId, nextIndex.lastActiveSessionId); - setChatSessions(nextIndex.sessions); - - if (nextIndex.lastActiveSessionId) { - setActiveSessionId(nextIndex.lastActiveSessionId); - } - } catch (error) { - setProjectErrorMessage( - error instanceof Error ? error.message : "Unable to delete the selected chat topic." - ); - } - }, - [deleteChatSessionState, setActiveSessionId, setChatSessions] - ); - - const handleChatDraftChange = useCallback( - (value: string) => { - if (!activeSessionId) { - return; - } - - setChatDraft(activeSessionId, value); - }, - [activeSessionId, setChatDraft] - ); - - const handleSendChatMessage = useCallback(async () => { - if (!activeChatSession || !activeChatDraft.trim()) { - return; - } - - try { - await sendChatMessage({ - sessionId: activeChatSession.id, - message: activeChatDraft, - claudePath: settingsState.claudePath, - codexPath: settingsState.codexPath - }); - setChatDraft(activeChatSession.id, ""); - } catch (error) { - setProjectErrorMessage( - error instanceof Error ? error.message : "Unable to send the current chat message." - ); - } - }, [activeChatDraft, activeChatSession, setChatDraft, settingsState]); - - const handleApproveChatSession = useCallback(async () => { - if (!activeChatSession) { - return; - } - - try { - await approveChatSession(activeChatSession.id); - } catch (error) { - setProjectErrorMessage( - error instanceof Error ? error.message : "Unable to approve the active chat topic." - ); - } - }, [activeChatSession]); - - const handleStopChatSession = useCallback(async () => { - if (!activeChatSession) { - return; - } - - try { - await stopChatSession(activeChatSession.id); - } catch (error) { - setProjectErrorMessage( - error instanceof Error ? error.message : "Unable to stop the active chat topic." - ); - } - }, [activeChatSession]); - - const handleSaveChatSessionConfig = useCallback( - async (payload: { - sessionId: string; - selectedModel: ChatSession["selectedModel"]; - selectedReasoning: ChatSession["selectedReasoning"]; - autonomyMode: ChatSession["autonomyMode"]; - contextItems: ChatContextItem[]; - }) => { - setSessionConfig(payload); - setChatContextItems(payload.sessionId, payload.contextItems); - - try { - await persistChatSession(payload); - } catch (error) { - setProjectErrorMessage( - error instanceof Error ? error.message : "Unable to save the current chat topic." - ); - } - }, - [persistChatSession, setChatContextItems, setSessionConfig] - ); - - const handleAttachChatFile = useCallback( - (path: string) => { - if (!activeChatSession) { - return; - } - - if (activeChatSession.contextItems.some((item) => item.path === path)) { - return; - } - - const nextContextItems = [ - ...activeChatSession.contextItems, - { - id: `file-${Date.now().toString(36)}`, - kind: "file" as const, - label: path.split("/").pop() ?? path, - path, - isDefault: false - } - ]; - - void handleSaveChatSessionConfig({ - sessionId: activeChatSession.id, - selectedModel: activeChatSession.selectedModel, - selectedReasoning: activeChatSession.selectedReasoning, - autonomyMode: activeChatSession.autonomyMode, - contextItems: nextContextItems - }); - }, - [activeChatSession, handleSaveChatSessionConfig] - ); - - const handleRemoveChatContextItem = useCallback( - (itemId: string) => { - if (!activeChatSession) { - return; - } - - const nextContextItems = activeChatSession.contextItems.filter( - (item) => item.id !== itemId - ); - - void handleSaveChatSessionConfig({ - sessionId: activeChatSession.id, - selectedModel: activeChatSession.selectedModel, - selectedReasoning: activeChatSession.selectedReasoning, - autonomyMode: activeChatSession.autonomyMode, - contextItems: nextContextItems - }); - }, - [activeChatSession, handleSaveChatSessionConfig] - ); - - const handleOpenChat = useCallback(() => { - navigate("/chat"); - }, [navigate]); - - const handleOpenReview = useCallback(() => { - navigate("/review"); - }, [navigate]); - useSystemThemePreference(setSystemPrefersDark); useDocumentTheme(derivedState.resolvedTheme); useWorkspaceSearchShortcuts({ @@ -1401,19 +799,21 @@ function App() { type="file" /> - - } - path="/" - /> - - - - } - path="*" - /> - + + + } + path="/" + /> + + + + } + path="*" + /> + + ); diff --git a/src/components/ControlColumn.tsx b/src/components/ControlColumn.tsx index 3249f91..b2764ee 100644 --- a/src/components/ControlColumn.tsx +++ b/src/components/ControlColumn.tsx @@ -9,13 +9,13 @@ import { Spark } from "iconoir-react"; import { + type Key, memo, + type ReactNode, useCallback, useEffect, useMemo, - useState, - type Key, - type ReactNode + useState } from "react"; import { @@ -201,6 +201,7 @@ function ModelSelectField({ return ( {agentStatus === "awaiting_approval" ? ( diff --git a/src/components/InspectorColumn.tsx b/src/components/InspectorColumn.tsx index 7ec0005..ce13649 100644 --- a/src/components/InspectorColumn.tsx +++ b/src/components/InspectorColumn.tsx @@ -1,18 +1,18 @@ import { + Folder, NavArrowDown, NavArrowRight, - Folder, Page } from "iconoir-react"; import { + type ChangeEvent, memo, + type RefObject, useCallback, useEffect, useMemo, useRef, - useState, - type ChangeEvent, - type RefObject + useState } from "react"; import type { WorkspaceEntry } from "../types"; diff --git a/src/components/MainWorkspace.tsx b/src/components/MainWorkspace.tsx index 678b471..696ce9a 100644 --- a/src/components/MainWorkspace.tsx +++ b/src/components/MainWorkspace.tsx @@ -2,9 +2,15 @@ import { CheckCircle, FileNotFound } from "iconoir-react"; -import { memo, useEffect, useMemo, type ChangeEvent } from "react"; +import { type ChangeEvent, memo, useEffect, useMemo } from "react"; import { getWorkspaceDisplayPath } from "../lib/projectConfig"; +import type { + AgentStatus, + EditorTab, + PaneMode, + WorkspaceTab +} from "../types"; import { DocumentActionBar } from "./DocumentActionBar"; import { DocumentEmptyState } from "./DocumentEmptyState"; import { DocumentPane } from "./DocumentPane"; @@ -12,12 +18,6 @@ import { ExecutionPanel } from "./ExecutionPanel"; import { PrdEmptyState } from "./PrdEmptyState"; import { SpecEmptyState } from "./SpecEmptyState"; import { WorkspaceTabBar } from "./WorkspaceTabBar"; -import type { - AgentStatus, - EditorTab, - PaneMode, - WorkspaceTab -} from "../types"; interface MainWorkspaceProps { activeTab: WorkspaceTab; diff --git a/src/components/ProjectAiSettingsCard.tsx b/src/components/ProjectAiSettingsCard.tsx index 839cf45..ecdc8a3 100644 --- a/src/components/ProjectAiSettingsCard.tsx +++ b/src/components/ProjectAiSettingsCard.tsx @@ -6,16 +6,16 @@ import { Brain, Spark } from "iconoir-react"; import { memo } from "react"; import { getModelOptions, getReasoningOptions } from "../lib/agentConfig"; +import type { ModelId, ReasoningProfileId } from "../types"; import { + FIELD_LABEL_CLASS, ScopedPathReference, SETTINGS_PANEL_CLASS, SETTINGS_SURFACE_CLASS, SettingsSectionHeader, SettingsSelectField, - FIELD_LABEL_CLASS, TEXTAREA_CLASS } from "./SettingsPrimitives"; -import type { ModelId, ReasoningProfileId } from "../types"; interface ProjectAiSettingsCardProps { configPath: string; diff --git a/src/components/SettingsPrimitives.tsx b/src/components/SettingsPrimitives.tsx index ce1d8a6..118d13c 100644 --- a/src/components/SettingsPrimitives.tsx +++ b/src/components/SettingsPrimitives.tsx @@ -3,7 +3,7 @@ import { ListBox, Select } from "@heroui/react"; -import { useCallback, type Key, type ReactNode } from "react"; +import { type Key, type ReactNode, useCallback } from "react"; import type { SelectOption } from "../lib/agentConfig"; diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index 909f8f3..ad1b941 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -9,7 +9,13 @@ import { Terminal } from "iconoir-react"; import { memo } from "react"; - +import type { + EnvironmentStatus, + ModelId, + ReasoningProfileId, + SpecAnnotation, + ThemeMode +} from "../types"; import { CliHealthCard } from "./CliHealthCard"; import { ProjectAiSettingsCard } from "./ProjectAiSettingsCard"; import { ProjectDocumentsCard } from "./ProjectDocumentsCard"; @@ -21,13 +27,6 @@ import { SETTINGS_SURFACE_CLASS, SettingsSectionHeader } from "./SettingsPrimitives"; -import type { - EnvironmentStatus, - ModelId, - ReasoningProfileId, - SpecAnnotation, - ThemeMode -} from "../types"; interface SettingsViewProps { annotations: SpecAnnotation[]; diff --git a/src/hooks/useAppLifecycle.ts b/src/hooks/useAppLifecycle.ts index 9dd5044..ed40541 100644 --- a/src/hooks/useAppLifecycle.ts +++ b/src/hooks/useAppLifecycle.ts @@ -1,9 +1,9 @@ import { - useEffect, type Dispatch, type MutableRefObject, type RefObject, - type SetStateAction + type SetStateAction, + useEffect } from "react"; import { clearFallbackTimer } from "../lib/appShell"; diff --git a/src/hooks/useAppScreenProps.ts b/src/hooks/useAppScreenProps.ts new file mode 100644 index 0000000..c028747 --- /dev/null +++ b/src/hooks/useAppScreenProps.ts @@ -0,0 +1,314 @@ +import { + type ComponentProps, + type RefObject, + useMemo +} from "react"; + +import type { ConfigurationScreen } from "../screens/ConfigurationScreen"; +import type { PrdScreen } from "../screens/PrdScreen"; +import type { SettingsScreen } from "../screens/SettingsScreen"; +import type { AgentStoreSlice, ProjectStoreSlice, SettingsStoreSlice } from "./useAppStoreSlices"; +import type { AppUiHandlers } from "./useAppUiHandlers"; +import type { AppDerivedState } from "./useAppView"; +import type { ProjectSettingsHandlers } from "./useProjectSettingsHandlers"; + +interface UseAppScreenPropsOptions { + agentState: AgentStoreSlice; + commandSearch: string; + derivedState: AppDerivedState; + desktopRuntime: boolean; + folderInputRef: RefObject; + handleApproveSpec: () => void; + handleOpenChat: () => void; + handlePickProjectFolder: () => Promise; + hasSavedProjectSettings: boolean; + isImporting: boolean; + isProjectLoading: boolean; + isProjectSaving: boolean; + isSearchOpen: boolean; + reviewVisibleDiff: string; + prdGenerationError: string; + prdGenerationPrompt: string; + projectErrorMessage: string; + projectRootName: string; + projectRootPath: string; + projectSettingsHandlers: ProjectSettingsHandlers; + projectState: ProjectStoreSlice; + projectStatusMessage: string; + searchInputRef: RefObject; + settingsState: SettingsStoreSlice; + specGenerationError: string; + specGenerationPrompt: string; + uiHandlers: AppUiHandlers; + workspaceNotice: string; +} + +export function useAppScreenProps({ + agentState, + commandSearch, + derivedState, + desktopRuntime, + folderInputRef, + handleApproveSpec, + handleOpenChat, + handlePickProjectFolder, + hasSavedProjectSettings, + isImporting, + isProjectLoading, + isProjectSaving, + isSearchOpen, + reviewVisibleDiff, + prdGenerationError, + prdGenerationPrompt, + projectErrorMessage, + projectRootName, + projectRootPath, + projectSettingsHandlers, + projectState, + projectStatusMessage, + searchInputRef, + settingsState, + specGenerationError, + specGenerationPrompt, + uiHandlers, + workspaceNotice +}: UseAppScreenPropsOptions) { + const controlColumnProps = useMemo( + () => ({ + configuredModelProviders: derivedState.configuredModelProviders, + autonomyMode: projectState.autonomyMode, + mcpItems: derivedState.mcpItems, + onModeChange: projectState.setAutonomyMode, + onModelChange: projectSettingsHandlers.handleProjectModelChange, + onReasoningChange: projectSettingsHandlers.handleProjectReasoningChange, + selectedModel: projectState.selectedModel, + selectedReasoning: projectState.selectedReasoning + }), + [derivedState, projectSettingsHandlers, projectState] + ); + + const mainWorkspaceProps = useMemo( + () => ({ + activeTab: projectState.activeTab, + agentStatus: agentState.status, + canGeneratePrd: derivedState.canGeneratePrd, + canGenerateSpec: derivedState.canGenerateSpec, + configPath: derivedState.configPathDisplay, + executionSummary: agentState.executionSummary, + isGeneratingPrd: derivedState.isGeneratingPrd, + isGeneratingSpec: derivedState.isGeneratingSpec, + isSpecApproved: projectState.isSpecApproved, + executionControlsEnabled: false, + onActiveTabChange: projectState.setActiveTab, + onApproveExecutionGate: uiHandlers.handleApproveExecutionGateClick, + onApproveSpec: handleApproveSpec, + onEditorTabChange: projectState.updateEditorTabContent, + onEditorTabClose: projectState.closeEditorTab, + onEmergencyStop: uiHandlers.handleEmergencyStopClick, + onGeneratePrd: uiHandlers.handleGeneratePrdClick, + onGenerateSpec: uiHandlers.handleGenerateSpecClick, + onLoadPrd: uiHandlers.handleOpenPrdImportClick, + onLoadSpec: uiHandlers.handleOpenSpecImportClick, + onPrdContentChange: uiHandlers.handlePrdContentChange, + onPrdGenerationPromptChange: uiHandlers.handlePrdGenerationPromptChange, + onPrdPaneModeChange: projectState.setPrdPaneMode, + onSpecContentChange: uiHandlers.handleSpecContentChange, + onSpecGenerationPromptChange: uiHandlers.handleSpecGenerationPromptChange, + onSpecPaneModeChange: projectState.setSpecPaneMode, + onSpecSelect: uiHandlers.handleSpecSelect, + openEditorTabs: projectState.openEditorTabs, + prdContent: projectState.prdContent, + prdGenerationError, + prdGenerationHelperText: derivedState.prdGenerationHelperText, + prdGenerationPrompt, + prdPaneMode: projectState.prdPaneMode, + prdPath: projectState.prdPath, + prdPromptTemplate: projectState.prdPromptTemplate, + specContent: projectState.specContent, + specGenerationError, + specGenerationHelperText: derivedState.specGenerationHelperText, + specGenerationPrompt, + specPaneMode: projectState.specPaneMode, + specPath: projectState.specPath, + specPromptTemplate: projectState.specPromptTemplate, + terminalOutput: agentState.terminalOutput, + visibleDiff: reviewVisibleDiff, + workspaceRootName: projectRootName + }), + [ + agentState, + derivedState, + handleApproveSpec, + prdGenerationError, + prdGenerationPrompt, + projectRootName, + projectState, + reviewVisibleDiff, + specGenerationError, + specGenerationPrompt, + uiHandlers + ] + ); + + const inspectorColumnProps = useMemo( + () => ({ + emptyStateMessage: derivedState.deferredSearch.trim() + ? `No files match "${derivedState.deferredSearch.trim()}".` + : "Choose another project folder from setup if you want to switch workspaces.", + folderInputRef, + hasWorkspaceEntries: settingsState.workspaceEntries.length > 0, + onFileOpen: uiHandlers.handleWorkspaceFileOpenClick, + onFolderChange: uiHandlers.handleWorkspaceFolderSelection, + onOpenFolder: handlePickProjectFolder, + workspaceEntries: derivedState.filteredWorkspaceEntries, + workspaceNotice, + workspaceRootName: projectRootName, + workspaceRootPath: projectRootPath + }), + [ + derivedState, + folderInputRef, + handlePickProjectFolder, + projectRootName, + projectRootPath, + settingsState.workspaceEntries.length, + uiHandlers, + workspaceNotice + ] + ); + + const reviewScreenProps = useMemo>( + () => ({ + agentStatus: agentState.status, + commandSearch, + controlColumnProps, + inspectorColumnProps, + isSearchOpen, + isSpecApproved: projectState.isSpecApproved, + mainWorkspaceProps, + onCommandSearchChange: uiHandlers.handleCommandSearchChange, + onOpenChat: handleOpenChat, + onRefresh: uiHandlers.handleRefresh, + searchInputRef, + workspaceRootName: projectRootName + }), + [ + agentState.status, + commandSearch, + controlColumnProps, + handleOpenChat, + inspectorColumnProps, + isSearchOpen, + mainWorkspaceProps, + projectRootName, + projectState.isSpecApproved, + searchInputRef, + uiHandlers + ] + ); + + const settingsScreenProps = useMemo>( + () => ({ + agentStatus: agentState.status, + onRefresh: uiHandlers.handleRefresh, + settingsViewProps: { + annotations: projectState.annotations, + claudePath: settingsState.claudePath, + codexPath: settingsState.codexPath, + configPath: derivedState.configPathDisplay, + environment: settingsState.environment, + onClaudePathChange: settingsState.setClaudePath, + onCodexPathChange: settingsState.setCodexPath, + onModelChange: projectSettingsHandlers.handleProjectModelChange, + onPrdPathChange: projectSettingsHandlers.handleConfiguredPrdPathChange, + onPrdPromptChange: projectSettingsHandlers.handlePrdPromptTemplateChange, + onReasoningChange: projectSettingsHandlers.handleProjectReasoningChange, + onSpecPathChange: projectSettingsHandlers.handleConfiguredSpecPathChange, + onSpecPromptChange: projectSettingsHandlers.handleSpecPromptTemplateChange, + onSupportingDocumentsChange: projectSettingsHandlers.handleSupportingDocumentsChange, + onThemeChange: settingsState.setTheme, + prdPath: projectState.configuredPrdPath, + prdPrompt: projectState.prdPromptTemplate, + projectErrorMessage, + projectStatusMessage, + selectedModel: projectState.selectedModel, + selectedReasoning: projectState.selectedReasoning, + specPath: projectState.configuredSpecPath, + specPrompt: projectState.specPromptTemplate, + supportingDocumentsValue: derivedState.supportingDocumentsValue, + theme: settingsState.theme, + workspaceRootName: projectRootName + } + }), + [ + agentState.status, + derivedState, + projectErrorMessage, + projectRootName, + projectSettingsHandlers, + projectState, + projectStatusMessage, + settingsState, + uiHandlers + ] + ); + + const configurationScreenProps = useMemo>( + () => ({ + claudePath: settingsState.claudePath, + codexPath: settingsState.codexPath, + desktopRuntime, + environment: settingsState.environment, + errorMessage: projectErrorMessage, + hasSavedSettings: hasSavedProjectSettings, + isProjectLoading: isProjectLoading || isImporting, + isSaving: isProjectSaving, + onClaudePathChange: settingsState.setClaudePath, + onCodexPathChange: settingsState.setCodexPath, + onContinue: projectSettingsHandlers.handleSaveConfigurationAndContinue, + onModelChange: projectSettingsHandlers.handleProjectModelChange, + onPickFolder: handlePickProjectFolder, + onPrdPathChange: projectSettingsHandlers.handleConfiguredPrdPathChange, + onPrdPromptChange: projectSettingsHandlers.handlePrdPromptTemplateChange, + onReasoningChange: projectSettingsHandlers.handleProjectReasoningChange, + onRefresh: uiHandlers.handleRefresh, + onSpecPathChange: projectSettingsHandlers.handleConfiguredSpecPathChange, + onSpecPromptChange: projectSettingsHandlers.handleSpecPromptTemplateChange, + onSupportingDocumentsChange: projectSettingsHandlers.handleSupportingDocumentsChange, + prdPath: projectState.configuredPrdPath, + prdPrompt: projectState.prdPromptTemplate, + selectedModel: projectState.selectedModel, + selectedReasoning: projectState.selectedReasoning, + settingsPath: derivedState.configPathDisplay, + specPath: projectState.configuredSpecPath, + specPrompt: projectState.specPromptTemplate, + statusMessage: projectStatusMessage, + supportingDocumentsValue: derivedState.supportingDocumentsValue, + workspaceRootName: projectRootName, + workspaceRootPath: projectRootPath + }), + [ + derivedState, + desktopRuntime, + handlePickProjectFolder, + hasSavedProjectSettings, + isImporting, + isProjectLoading, + isProjectSaving, + projectErrorMessage, + projectRootName, + projectRootPath, + projectSettingsHandlers, + projectState, + projectStatusMessage, + settingsState, + uiHandlers + ] + ); + + return { + configurationScreenProps, + reviewScreenProps, + settingsScreenProps + }; +} diff --git a/src/hooks/useAppUiHandlers.ts b/src/hooks/useAppUiHandlers.ts new file mode 100644 index 0000000..47edfef --- /dev/null +++ b/src/hooks/useAppUiHandlers.ts @@ -0,0 +1,205 @@ +import { + type ChangeEvent, + type Dispatch, + type SetStateAction, + useCallback +} from "react"; + +import type { DocumentTarget } from "../lib/appShell"; +import type { EnvironmentStatus } from "../types"; +import type { AgentStoreSlice, ProjectStoreSlice } from "./useAppStoreSlices"; + +interface UseAppUiHandlersOptions { + agentState: AgentStoreSlice; + handleApproveExecutionGate: () => Promise; + handleEmergencyStop: () => Promise; + handleGeneratePrd: () => Promise; + handleGenerateSpec: () => Promise; + handleOpenImportFile: (target: DocumentTarget) => Promise; + handleStartBuild: () => Promise; + handleWorkspaceFileOpen: (path: string) => Promise; + prdGenerationError: string; + projectState: ProjectStoreSlice; + refreshDiagnostics: (previousEnvironment?: EnvironmentStatus) => Promise; + setCommandSearch: Dispatch>; + setIsSearchOpen: Dispatch>; + setPrdGenerationError: Dispatch>; + setPrdGenerationPrompt: Dispatch>; + setSpecGenerationError: Dispatch>; + setSpecGenerationPrompt: Dispatch>; + specGenerationError: string; +} + +export function useAppUiHandlers({ + agentState, + handleApproveExecutionGate, + handleEmergencyStop, + handleGeneratePrd, + handleGenerateSpec, + handleOpenImportFile, + handleStartBuild, + handleWorkspaceFileOpen, + prdGenerationError, + projectState, + refreshDiagnostics, + setCommandSearch, + setIsSearchOpen, + setPrdGenerationError, + setPrdGenerationPrompt, + setSpecGenerationError, + setSpecGenerationPrompt, + specGenerationError +}: UseAppUiHandlersOptions) { + const handlePrdContentChange = useCallback( + (value: string) => { + projectState.setPrdContent(value, projectState.prdPath); + }, + [projectState] + ); + + const handleSpecContentChange = useCallback( + (value: string) => { + if (value.trim()) { + setSpecGenerationError(""); + } + + projectState.setSpecContent(value, projectState.specPath); + }, + [projectState, setSpecGenerationError] + ); + + const handleSpecSelect = useCallback( + (event: ChangeEvent) => { + const { selectionStart, selectionEnd, value } = event.target; + + projectState.setSelectedSpecRange( + selectionStart === selectionEnd + ? null + : { + start: selectionStart, + end: selectionEnd, + text: value.slice(selectionStart, selectionEnd) + } + ); + }, + [projectState] + ); + + const handlePrdGenerationPromptChange = useCallback( + (value: string) => { + setPrdGenerationPrompt(value); + + if (prdGenerationError) { + setPrdGenerationError(""); + } + + if (agentState.status === "error") { + agentState.setStatus("idle"); + } + }, + [ + agentState, + prdGenerationError, + setPrdGenerationError, + setPrdGenerationPrompt + ] + ); + + const handleSpecGenerationPromptChange = useCallback( + (value: string) => { + setSpecGenerationPrompt(value); + + if (specGenerationError) { + setSpecGenerationError(""); + } + + if (agentState.status === "error") { + agentState.setStatus("idle"); + } + }, + [ + agentState, + setSpecGenerationError, + setSpecGenerationPrompt, + specGenerationError + ] + ); + + const handleCommandSearchChange = useCallback( + (event: ChangeEvent) => { + setCommandSearch(event.target.value); + }, + [setCommandSearch] + ); + + const closeWorkspaceSearch = useCallback(() => { + setIsSearchOpen(false); + setCommandSearch(""); + }, [setCommandSearch, setIsSearchOpen]); + + const handleRefresh = useCallback(() => { + void refreshDiagnostics(); + }, [refreshDiagnostics]); + + const handleOpenPrdImportClick = useCallback(() => { + void handleOpenImportFile("prd"); + }, [handleOpenImportFile]); + + const handleOpenSpecImportClick = useCallback(() => { + void handleOpenImportFile("spec"); + }, [handleOpenImportFile]); + + const handleStartBuildClick = useCallback(() => { + void handleStartBuild(); + }, [handleStartBuild]); + + const handleApproveExecutionGateClick = useCallback(() => { + void handleApproveExecutionGate(); + }, [handleApproveExecutionGate]); + + const handleEmergencyStopClick = useCallback(() => { + void handleEmergencyStop(); + }, [handleEmergencyStop]); + + const handleWorkspaceFolderSelection = useCallback( + (_event: ChangeEvent) => undefined, + [] + ); + + const handleWorkspaceFileOpenClick = useCallback( + (path: string) => { + void handleWorkspaceFileOpen(path); + }, + [handleWorkspaceFileOpen] + ); + + const handleGeneratePrdClick = useCallback(() => { + void handleGeneratePrd(); + }, [handleGeneratePrd]); + + const handleGenerateSpecClick = useCallback(() => { + void handleGenerateSpec(); + }, [handleGenerateSpec]); + + return { + handlePrdContentChange, + handleSpecContentChange, + handleSpecSelect, + handlePrdGenerationPromptChange, + handleSpecGenerationPromptChange, + handleCommandSearchChange, + closeWorkspaceSearch, + handleRefresh, + handleOpenPrdImportClick, + handleOpenSpecImportClick, + handleStartBuildClick, + handleApproveExecutionGateClick, + handleEmergencyStopClick, + handleWorkspaceFolderSelection, + handleWorkspaceFileOpenClick, + handleGeneratePrdClick, + handleGenerateSpecClick + }; +} + +export type AppUiHandlers = ReturnType; diff --git a/src/hooks/useAppView.ts b/src/hooks/useAppView.ts index 5941a12..3ed3182 100644 --- a/src/hooks/useAppView.ts +++ b/src/hooks/useAppView.ts @@ -1,25 +1,12 @@ import { - useCallback, useDeferredValue, - useMemo, - type ChangeEvent, - type ComponentProps, - type Dispatch, - type RefObject, - type SetStateAction + useMemo } from "react"; - +import { getModelProvider } from "../lib/agentConfig"; import { filterWorkspaceEntries, - resolveTheme, - type DocumentTarget + resolveTheme } from "../lib/appShell"; -import { getModelProvider } from "../lib/agentConfig"; -import { - formatSupportingDocumentPaths, - normalizeProjectRelativePath, - parseSupportingDocumentPaths -} from "../lib/projectConfig"; import { buildConfigPathDisplay, buildConfiguredModelProviders, @@ -28,16 +15,18 @@ import { getPrdGenerationHelperText, getSpecGenerationHelperText } from "../lib/appState"; -import { ConfigurationScreen } from "../screens/ConfigurationScreen"; -import { PrdScreen } from "../screens/PrdScreen"; -import { SettingsScreen } from "../screens/SettingsScreen"; -import type { EnvironmentStatus } from "../types"; +import { formatSupportingDocumentPaths } from "../lib/projectConfig"; import type { AgentStoreSlice, ProjectStoreSlice, SettingsStoreSlice } from "./useAppStoreSlices"; +export { useAppScreenProps } from "./useAppScreenProps"; +export { type AppUiHandlers, useAppUiHandlers } from "./useAppUiHandlers"; +// Re-export split hooks for convenience +export { type ProjectSettingsHandlers, useProjectSettingsHandlers } from "./useProjectSettingsHandlers"; + interface UseAppDerivedStateOptions { agentState: AgentStoreSlice; commandSearch: string; @@ -224,605 +213,3 @@ export function useAppDerivedState({ } export type AppDerivedState = ReturnType; - -interface SaveCurrentProjectSettingsOptions { - reloadProject?: boolean; - navigateToChat?: boolean; -} - -type SaveCurrentProjectSettings = ( - options?: SaveCurrentProjectSettingsOptions -) => Promise; - -interface UseProjectSettingsHandlersOptions { - saveCurrentProjectSettings: SaveCurrentProjectSettings; - scheduleProjectSettingsSave: (reloadProject?: boolean) => void; - setConfiguredPrdPath: ProjectStoreSlice["setConfiguredPrdPath"]; - setConfiguredSpecPath: ProjectStoreSlice["setConfiguredSpecPath"]; - setPrdPromptTemplate: ProjectStoreSlice["setPrdPromptTemplate"]; - setReasoningProfile: ProjectStoreSlice["setReasoningProfile"]; - setSelectedModel: ProjectStoreSlice["setSelectedModel"]; - setSpecPromptTemplate: ProjectStoreSlice["setSpecPromptTemplate"]; - setSupportingDocumentPaths: ProjectStoreSlice["setSupportingDocumentPaths"]; -} - -export function useProjectSettingsHandlers({ - saveCurrentProjectSettings, - scheduleProjectSettingsSave, - setConfiguredPrdPath, - setConfiguredSpecPath, - setPrdPromptTemplate, - setReasoningProfile, - setSelectedModel, - setSpecPromptTemplate, - setSupportingDocumentPaths -}: UseProjectSettingsHandlersOptions) { - const handleProjectModelChange = useCallback( - (model: Parameters[0]) => { - setSelectedModel(model); - scheduleProjectSettingsSave(false); - }, - [scheduleProjectSettingsSave, setSelectedModel] - ); - - const handleProjectReasoningChange = useCallback( - (reasoning: Parameters[0]) => { - setReasoningProfile(reasoning); - scheduleProjectSettingsSave(false); - }, - [scheduleProjectSettingsSave, setReasoningProfile] - ); - - const handlePrdPromptTemplateChange = useCallback( - (value: string) => { - setPrdPromptTemplate(value); - scheduleProjectSettingsSave(false); - }, - [scheduleProjectSettingsSave, setPrdPromptTemplate] - ); - - const handleSpecPromptTemplateChange = useCallback( - (value: string) => { - setSpecPromptTemplate(value); - scheduleProjectSettingsSave(false); - }, - [scheduleProjectSettingsSave, setSpecPromptTemplate] - ); - - const handleConfiguredPrdPathChange = useCallback( - (value: string) => { - setConfiguredPrdPath(normalizeProjectRelativePath(value)); - scheduleProjectSettingsSave(true); - }, - [scheduleProjectSettingsSave, setConfiguredPrdPath] - ); - - const handleConfiguredSpecPathChange = useCallback( - (value: string) => { - setConfiguredSpecPath(normalizeProjectRelativePath(value)); - scheduleProjectSettingsSave(true); - }, - [scheduleProjectSettingsSave, setConfiguredSpecPath] - ); - - const handleSupportingDocumentsChange = useCallback( - (value: string) => { - setSupportingDocumentPaths(parseSupportingDocumentPaths(value)); - scheduleProjectSettingsSave(false); - }, - [scheduleProjectSettingsSave, setSupportingDocumentPaths] - ); - - const handleSaveConfigurationAndContinue = useCallback(() => { - void saveCurrentProjectSettings({ reloadProject: true, navigateToChat: true }); - }, [saveCurrentProjectSettings]); - - return { - handleProjectModelChange, - handleProjectReasoningChange, - handlePrdPromptTemplateChange, - handleSpecPromptTemplateChange, - handleConfiguredPrdPathChange, - handleConfiguredSpecPathChange, - handleSupportingDocumentsChange, - handleSaveConfigurationAndContinue - }; -} - -export type ProjectSettingsHandlers = ReturnType; - -interface UseAppUiHandlersOptions { - agentState: AgentStoreSlice; - handleApproveExecutionGate: () => Promise; - handleEmergencyStop: () => Promise; - handleGeneratePrd: () => Promise; - handleGenerateSpec: () => Promise; - handleOpenImportFile: (target: DocumentTarget) => Promise; - handleStartBuild: () => Promise; - handleWorkspaceFileOpen: (path: string) => Promise; - prdGenerationError: string; - projectState: ProjectStoreSlice; - refreshDiagnostics: (previousEnvironment?: EnvironmentStatus) => Promise; - setCommandSearch: Dispatch>; - setIsSearchOpen: Dispatch>; - setPrdGenerationError: Dispatch>; - setPrdGenerationPrompt: Dispatch>; - setSpecGenerationError: Dispatch>; - setSpecGenerationPrompt: Dispatch>; - specGenerationError: string; -} - -export function useAppUiHandlers({ - agentState, - handleApproveExecutionGate, - handleEmergencyStop, - handleGeneratePrd, - handleGenerateSpec, - handleOpenImportFile, - handleStartBuild, - handleWorkspaceFileOpen, - prdGenerationError, - projectState, - refreshDiagnostics, - setCommandSearch, - setIsSearchOpen, - setPrdGenerationError, - setPrdGenerationPrompt, - setSpecGenerationError, - setSpecGenerationPrompt, - specGenerationError -}: UseAppUiHandlersOptions) { - const handlePrdContentChange = useCallback( - (value: string) => { - projectState.setPrdContent(value, projectState.prdPath); - }, - [projectState] - ); - - const handleSpecContentChange = useCallback( - (value: string) => { - if (value.trim()) { - setSpecGenerationError(""); - } - - projectState.setSpecContent(value, projectState.specPath); - }, - [projectState, setSpecGenerationError] - ); - - const handleSpecSelect = useCallback( - (event: ChangeEvent) => { - const { selectionStart, selectionEnd, value } = event.target; - - projectState.setSelectedSpecRange( - selectionStart === selectionEnd - ? null - : { - start: selectionStart, - end: selectionEnd, - text: value.slice(selectionStart, selectionEnd) - } - ); - }, - [projectState] - ); - - const handlePrdGenerationPromptChange = useCallback( - (value: string) => { - setPrdGenerationPrompt(value); - - if (prdGenerationError) { - setPrdGenerationError(""); - } - - if (agentState.status === "error") { - agentState.setStatus("idle"); - } - }, - [ - agentState, - prdGenerationError, - setPrdGenerationError, - setPrdGenerationPrompt - ] - ); - - const handleSpecGenerationPromptChange = useCallback( - (value: string) => { - setSpecGenerationPrompt(value); - - if (specGenerationError) { - setSpecGenerationError(""); - } - - if (agentState.status === "error") { - agentState.setStatus("idle"); - } - }, - [ - agentState, - setSpecGenerationError, - setSpecGenerationPrompt, - specGenerationError - ] - ); - - const handleCommandSearchChange = useCallback( - (event: ChangeEvent) => { - setCommandSearch(event.target.value); - }, - [setCommandSearch] - ); - - const closeWorkspaceSearch = useCallback(() => { - setIsSearchOpen(false); - setCommandSearch(""); - }, [setCommandSearch, setIsSearchOpen]); - - const handleRefresh = useCallback(() => { - void refreshDiagnostics(); - }, [refreshDiagnostics]); - - const handleOpenPrdImportClick = useCallback(() => { - void handleOpenImportFile("prd"); - }, [handleOpenImportFile]); - - const handleOpenSpecImportClick = useCallback(() => { - void handleOpenImportFile("spec"); - }, [handleOpenImportFile]); - - const handleStartBuildClick = useCallback(() => { - void handleStartBuild(); - }, [handleStartBuild]); - - const handleApproveExecutionGateClick = useCallback(() => { - void handleApproveExecutionGate(); - }, [handleApproveExecutionGate]); - - const handleEmergencyStopClick = useCallback(() => { - void handleEmergencyStop(); - }, [handleEmergencyStop]); - - const handleWorkspaceFolderSelection = useCallback( - (_event: ChangeEvent) => undefined, - [] - ); - - const handleWorkspaceFileOpenClick = useCallback( - (path: string) => { - void handleWorkspaceFileOpen(path); - }, - [handleWorkspaceFileOpen] - ); - - const handleGeneratePrdClick = useCallback(() => { - void handleGeneratePrd(); - }, [handleGeneratePrd]); - - const handleGenerateSpecClick = useCallback(() => { - void handleGenerateSpec(); - }, [handleGenerateSpec]); - - return { - handlePrdContentChange, - handleSpecContentChange, - handleSpecSelect, - handlePrdGenerationPromptChange, - handleSpecGenerationPromptChange, - handleCommandSearchChange, - closeWorkspaceSearch, - handleRefresh, - handleOpenPrdImportClick, - handleOpenSpecImportClick, - handleStartBuildClick, - handleApproveExecutionGateClick, - handleEmergencyStopClick, - handleWorkspaceFolderSelection, - handleWorkspaceFileOpenClick, - handleGeneratePrdClick, - handleGenerateSpecClick - }; -} - -export type AppUiHandlers = ReturnType; - -interface UseAppScreenPropsOptions { - agentState: AgentStoreSlice; - commandSearch: string; - derivedState: AppDerivedState; - desktopRuntime: boolean; - folderInputRef: RefObject; - handleApproveSpec: () => void; - handleOpenChat: () => void; - handlePickProjectFolder: () => Promise; - hasSavedProjectSettings: boolean; - isImporting: boolean; - isProjectLoading: boolean; - isProjectSaving: boolean; - isSearchOpen: boolean; - reviewVisibleDiff: string; - prdGenerationError: string; - prdGenerationPrompt: string; - projectErrorMessage: string; - projectRootName: string; - projectRootPath: string; - projectSettingsHandlers: ProjectSettingsHandlers; - projectState: ProjectStoreSlice; - projectStatusMessage: string; - searchInputRef: RefObject; - settingsState: SettingsStoreSlice; - specGenerationError: string; - specGenerationPrompt: string; - uiHandlers: AppUiHandlers; - workspaceNotice: string; -} - -export function useAppScreenProps({ - agentState, - commandSearch, - derivedState, - desktopRuntime, - folderInputRef, - handleApproveSpec, - handleOpenChat, - handlePickProjectFolder, - hasSavedProjectSettings, - isImporting, - isProjectLoading, - isProjectSaving, - isSearchOpen, - reviewVisibleDiff, - prdGenerationError, - prdGenerationPrompt, - projectErrorMessage, - projectRootName, - projectRootPath, - projectSettingsHandlers, - projectState, - projectStatusMessage, - searchInputRef, - settingsState, - specGenerationError, - specGenerationPrompt, - uiHandlers, - workspaceNotice -}: UseAppScreenPropsOptions) { - const controlColumnProps = useMemo( - () => ({ - configuredModelProviders: derivedState.configuredModelProviders, - autonomyMode: projectState.autonomyMode, - mcpItems: derivedState.mcpItems, - onModeChange: projectState.setAutonomyMode, - onModelChange: projectSettingsHandlers.handleProjectModelChange, - onReasoningChange: projectSettingsHandlers.handleProjectReasoningChange, - selectedModel: projectState.selectedModel, - selectedReasoning: projectState.selectedReasoning - }), - [derivedState, projectSettingsHandlers, projectState] - ); - - const mainWorkspaceProps = useMemo( - () => ({ - activeTab: projectState.activeTab, - agentStatus: agentState.status, - canGeneratePrd: derivedState.canGeneratePrd, - canGenerateSpec: derivedState.canGenerateSpec, - configPath: derivedState.configPathDisplay, - executionSummary: agentState.executionSummary, - isGeneratingPrd: derivedState.isGeneratingPrd, - isGeneratingSpec: derivedState.isGeneratingSpec, - isSpecApproved: projectState.isSpecApproved, - executionControlsEnabled: false, - onActiveTabChange: projectState.setActiveTab, - onApproveExecutionGate: uiHandlers.handleApproveExecutionGateClick, - onApproveSpec: handleApproveSpec, - onEditorTabChange: projectState.updateEditorTabContent, - onEditorTabClose: projectState.closeEditorTab, - onEmergencyStop: uiHandlers.handleEmergencyStopClick, - onGeneratePrd: uiHandlers.handleGeneratePrdClick, - onGenerateSpec: uiHandlers.handleGenerateSpecClick, - onLoadPrd: uiHandlers.handleOpenPrdImportClick, - onLoadSpec: uiHandlers.handleOpenSpecImportClick, - onPrdContentChange: uiHandlers.handlePrdContentChange, - onPrdGenerationPromptChange: uiHandlers.handlePrdGenerationPromptChange, - onPrdPaneModeChange: projectState.setPrdPaneMode, - onSpecContentChange: uiHandlers.handleSpecContentChange, - onSpecGenerationPromptChange: uiHandlers.handleSpecGenerationPromptChange, - onSpecPaneModeChange: projectState.setSpecPaneMode, - onSpecSelect: uiHandlers.handleSpecSelect, - openEditorTabs: projectState.openEditorTabs, - prdContent: projectState.prdContent, - prdGenerationError, - prdGenerationHelperText: derivedState.prdGenerationHelperText, - prdGenerationPrompt, - prdPaneMode: projectState.prdPaneMode, - prdPath: projectState.prdPath, - prdPromptTemplate: projectState.prdPromptTemplate, - specContent: projectState.specContent, - specGenerationError, - specGenerationHelperText: derivedState.specGenerationHelperText, - specGenerationPrompt, - specPaneMode: projectState.specPaneMode, - specPath: projectState.specPath, - specPromptTemplate: projectState.specPromptTemplate, - terminalOutput: agentState.terminalOutput, - visibleDiff: reviewVisibleDiff, - workspaceRootName: projectRootName - }), - [ - agentState, - derivedState, - handleApproveSpec, - prdGenerationError, - prdGenerationPrompt, - projectRootName, - projectState, - reviewVisibleDiff, - specGenerationError, - specGenerationPrompt, - uiHandlers - ] - ); - - const inspectorColumnProps = useMemo( - () => ({ - emptyStateMessage: derivedState.deferredSearch.trim() - ? `No files match "${derivedState.deferredSearch.trim()}".` - : "Choose another project folder from setup if you want to switch workspaces.", - folderInputRef, - hasWorkspaceEntries: settingsState.workspaceEntries.length > 0, - onFileOpen: uiHandlers.handleWorkspaceFileOpenClick, - onFolderChange: uiHandlers.handleWorkspaceFolderSelection, - onOpenFolder: handlePickProjectFolder, - workspaceEntries: derivedState.filteredWorkspaceEntries, - workspaceNotice, - workspaceRootName: projectRootName, - workspaceRootPath: projectRootPath - }), - [ - derivedState, - folderInputRef, - handlePickProjectFolder, - projectRootName, - projectRootPath, - settingsState.workspaceEntries.length, - uiHandlers, - workspaceNotice - ] - ); - - const reviewScreenProps = useMemo>( - () => ({ - agentStatus: agentState.status, - commandSearch, - controlColumnProps, - inspectorColumnProps, - isSearchOpen, - isSpecApproved: projectState.isSpecApproved, - mainWorkspaceProps, - onCommandSearchChange: uiHandlers.handleCommandSearchChange, - onOpenChat: handleOpenChat, - onRefresh: uiHandlers.handleRefresh, - searchInputRef, - workspaceRootName: projectRootName - }), - [ - agentState.status, - commandSearch, - controlColumnProps, - handleOpenChat, - inspectorColumnProps, - isSearchOpen, - mainWorkspaceProps, - projectRootName, - projectState.isSpecApproved, - searchInputRef, - uiHandlers - ] - ); - - const settingsScreenProps = useMemo>( - () => ({ - agentStatus: agentState.status, - onRefresh: uiHandlers.handleRefresh, - settingsViewProps: { - annotations: projectState.annotations, - claudePath: settingsState.claudePath, - codexPath: settingsState.codexPath, - configPath: derivedState.configPathDisplay, - environment: settingsState.environment, - onClaudePathChange: settingsState.setClaudePath, - onCodexPathChange: settingsState.setCodexPath, - onModelChange: projectSettingsHandlers.handleProjectModelChange, - onPrdPathChange: projectSettingsHandlers.handleConfiguredPrdPathChange, - onPrdPromptChange: projectSettingsHandlers.handlePrdPromptTemplateChange, - onReasoningChange: projectSettingsHandlers.handleProjectReasoningChange, - onSpecPathChange: projectSettingsHandlers.handleConfiguredSpecPathChange, - onSpecPromptChange: projectSettingsHandlers.handleSpecPromptTemplateChange, - onSupportingDocumentsChange: projectSettingsHandlers.handleSupportingDocumentsChange, - onThemeChange: settingsState.setTheme, - prdPath: projectState.configuredPrdPath, - prdPrompt: projectState.prdPromptTemplate, - projectErrorMessage, - projectStatusMessage, - selectedModel: projectState.selectedModel, - selectedReasoning: projectState.selectedReasoning, - specPath: projectState.configuredSpecPath, - specPrompt: projectState.specPromptTemplate, - supportingDocumentsValue: derivedState.supportingDocumentsValue, - theme: settingsState.theme, - workspaceRootName: projectRootName - } - }), - [ - agentState.status, - derivedState, - projectErrorMessage, - projectRootName, - projectSettingsHandlers, - projectState, - projectStatusMessage, - settingsState, - uiHandlers - ] - ); - - const configurationScreenProps = useMemo>( - () => ({ - claudePath: settingsState.claudePath, - codexPath: settingsState.codexPath, - desktopRuntime, - environment: settingsState.environment, - errorMessage: projectErrorMessage, - hasSavedSettings: hasSavedProjectSettings, - isProjectLoading: isProjectLoading || isImporting, - isSaving: isProjectSaving, - onClaudePathChange: settingsState.setClaudePath, - onCodexPathChange: settingsState.setCodexPath, - onContinue: projectSettingsHandlers.handleSaveConfigurationAndContinue, - onModelChange: projectSettingsHandlers.handleProjectModelChange, - onPickFolder: handlePickProjectFolder, - onPrdPathChange: projectSettingsHandlers.handleConfiguredPrdPathChange, - onPrdPromptChange: projectSettingsHandlers.handlePrdPromptTemplateChange, - onReasoningChange: projectSettingsHandlers.handleProjectReasoningChange, - onRefresh: uiHandlers.handleRefresh, - onSpecPathChange: projectSettingsHandlers.handleConfiguredSpecPathChange, - onSpecPromptChange: projectSettingsHandlers.handleSpecPromptTemplateChange, - onSupportingDocumentsChange: projectSettingsHandlers.handleSupportingDocumentsChange, - prdPath: projectState.configuredPrdPath, - prdPrompt: projectState.prdPromptTemplate, - selectedModel: projectState.selectedModel, - selectedReasoning: projectState.selectedReasoning, - settingsPath: derivedState.configPathDisplay, - specPath: projectState.configuredSpecPath, - specPrompt: projectState.specPromptTemplate, - statusMessage: projectStatusMessage, - supportingDocumentsValue: derivedState.supportingDocumentsValue, - workspaceRootName: projectRootName, - workspaceRootPath: projectRootPath - }), - [ - derivedState, - desktopRuntime, - handlePickProjectFolder, - hasSavedProjectSettings, - isImporting, - isProjectLoading, - isProjectSaving, - projectErrorMessage, - projectRootName, - projectRootPath, - projectSettingsHandlers, - projectState, - projectStatusMessage, - settingsState, - uiHandlers - ] - ); - - return { - configurationScreenProps, - reviewScreenProps, - settingsScreenProps - }; -} diff --git a/src/hooks/useChatHandlers.ts b/src/hooks/useChatHandlers.ts new file mode 100644 index 0000000..8cfa36f --- /dev/null +++ b/src/hooks/useChatHandlers.ts @@ -0,0 +1,277 @@ +import { useCallback } from "react"; + +import { + approveChatSession, + createChatSession, + deleteChatSession, + loadChatSession, + renameChatSession, + saveChatSession, + sendChatMessage, + stopChatSession +} from "../lib/runtime"; +import type { + ChatContextItem, + ChatSession, + ChatSessionSummary +} from "../types"; +import type { SettingsStoreSlice } from "./useAppStoreSlices"; + +interface UseChatHandlersOptions { + activeChatSession: ChatSession | null; + activeChatDraft: string; + activeSessionId: string | null; + settingsState: SettingsStoreSlice; + upsertSession: (session: ChatSession) => void; + setActiveSessionId: (id: string | null) => void; + setChatDraft: (sessionId: string, value: string) => void; + setChatContextItems: (sessionId: string, items: ChatContextItem[]) => void; + setSessionConfig: (payload: { + sessionId: string; + selectedModel: ChatSession["selectedModel"]; + selectedReasoning: ChatSession["selectedReasoning"]; + autonomyMode: ChatSession["autonomyMode"]; + contextItems: ChatContextItem[]; + }) => void; + deleteChatSessionState: (sessionId: string, nextActiveId: string | null) => void; + setChatSessions: (sessions: ChatSessionSummary[]) => void; + setProjectErrorMessage: (message: string) => void; +} + +export function useChatHandlers({ + activeChatSession, + activeChatDraft, + activeSessionId, + settingsState, + upsertSession, + setActiveSessionId, + setChatDraft, + setChatContextItems, + setSessionConfig, + deleteChatSessionState, + setChatSessions, + setProjectErrorMessage +}: UseChatHandlersOptions) { + const persistChatSession = useCallback( + async (payload: { + sessionId: string; + selectedModel: ChatSession["selectedModel"]; + selectedReasoning: ChatSession["selectedReasoning"]; + autonomyMode: ChatSession["autonomyMode"]; + contextItems: ChatContextItem[]; + }) => { + const nextSession = await saveChatSession(payload); + upsertSession(nextSession); + return nextSession; + }, + [upsertSession] + ); + + const handleCreateChatSessionClick = useCallback(async () => { + try { + const nextSession = await createChatSession(); + upsertSession(nextSession); + setActiveSessionId(nextSession.id); + } catch (error) { + setProjectErrorMessage( + error instanceof Error ? error.message : "Unable to create a new chat topic." + ); + } + }, [setActiveSessionId, setProjectErrorMessage, upsertSession]); + + const handleSelectChatSession = useCallback( + (sessionId: string) => { + setActiveSessionId(sessionId); + }, + [setActiveSessionId] + ); + + const handleRenameChatSession = useCallback( + async (sessionId: string, title: string) => { + try { + await renameChatSession({ sessionId, title }); + const nextSession = await loadChatSession(sessionId); + upsertSession(nextSession); + } catch (error) { + setProjectErrorMessage( + error instanceof Error ? error.message : "Unable to rename the selected chat topic." + ); + } + }, + [setProjectErrorMessage, upsertSession] + ); + + const handleDeleteChatSession = useCallback( + async (sessionId: string) => { + const confirmed = window.confirm("Delete this topic and its saved context?"); + + if (!confirmed) { + return; + } + + try { + const nextIndex = await deleteChatSession(sessionId); + deleteChatSessionState(sessionId, nextIndex.lastActiveSessionId); + setChatSessions(nextIndex.sessions); + + if (nextIndex.lastActiveSessionId) { + setActiveSessionId(nextIndex.lastActiveSessionId); + } + } catch (error) { + setProjectErrorMessage( + error instanceof Error ? error.message : "Unable to delete the selected chat topic." + ); + } + }, + [deleteChatSessionState, setActiveSessionId, setChatSessions, setProjectErrorMessage] + ); + + const handleChatDraftChange = useCallback( + (value: string) => { + if (!activeSessionId) { + return; + } + + setChatDraft(activeSessionId, value); + }, + [activeSessionId, setChatDraft] + ); + + const handleSendChatMessage = useCallback(async () => { + if (!activeChatSession || !activeChatDraft.trim()) { + return; + } + + try { + await sendChatMessage({ + sessionId: activeChatSession.id, + message: activeChatDraft, + claudePath: settingsState.claudePath, + codexPath: settingsState.codexPath + }); + setChatDraft(activeChatSession.id, ""); + } catch (error) { + setProjectErrorMessage( + error instanceof Error ? error.message : "Unable to send the current chat message." + ); + } + }, [activeChatDraft, activeChatSession, setChatDraft, setProjectErrorMessage, settingsState]); + + const handleApproveChatSession = useCallback(async () => { + if (!activeChatSession) { + return; + } + + try { + await approveChatSession(activeChatSession.id); + } catch (error) { + setProjectErrorMessage( + error instanceof Error ? error.message : "Unable to approve the active chat topic." + ); + } + }, [activeChatSession, setProjectErrorMessage]); + + const handleStopChatSession = useCallback(async () => { + if (!activeChatSession) { + return; + } + + try { + await stopChatSession(activeChatSession.id); + } catch (error) { + setProjectErrorMessage( + error instanceof Error ? error.message : "Unable to stop the active chat topic." + ); + } + }, [activeChatSession, setProjectErrorMessage]); + + const handleSaveChatSessionConfig = useCallback( + async (payload: { + sessionId: string; + selectedModel: ChatSession["selectedModel"]; + selectedReasoning: ChatSession["selectedReasoning"]; + autonomyMode: ChatSession["autonomyMode"]; + contextItems: ChatContextItem[]; + }) => { + setSessionConfig(payload); + setChatContextItems(payload.sessionId, payload.contextItems); + + try { + await persistChatSession(payload); + } catch (error) { + setProjectErrorMessage( + error instanceof Error ? error.message : "Unable to save the current chat topic." + ); + } + }, + [persistChatSession, setChatContextItems, setProjectErrorMessage, setSessionConfig] + ); + + const handleAttachChatFile = useCallback( + (path: string) => { + if (!activeChatSession) { + return; + } + + if (activeChatSession.contextItems.some((item) => item.path === path)) { + return; + } + + const nextContextItems = [ + ...activeChatSession.contextItems, + { + id: `file-${Date.now().toString(36)}`, + kind: "file" as const, + label: path.split("/").pop() ?? path, + path, + isDefault: false + } + ]; + + void handleSaveChatSessionConfig({ + sessionId: activeChatSession.id, + selectedModel: activeChatSession.selectedModel, + selectedReasoning: activeChatSession.selectedReasoning, + autonomyMode: activeChatSession.autonomyMode, + contextItems: nextContextItems + }); + }, + [activeChatSession, handleSaveChatSessionConfig] + ); + + const handleRemoveChatContextItem = useCallback( + (itemId: string) => { + if (!activeChatSession) { + return; + } + + const nextContextItems = activeChatSession.contextItems.filter( + (item) => item.id !== itemId + ); + + void handleSaveChatSessionConfig({ + sessionId: activeChatSession.id, + selectedModel: activeChatSession.selectedModel, + selectedReasoning: activeChatSession.selectedReasoning, + autonomyMode: activeChatSession.autonomyMode, + contextItems: nextContextItems + }); + }, + [activeChatSession, handleSaveChatSessionConfig] + ); + + return { + persistChatSession, + handleCreateChatSessionClick, + handleSelectChatSession, + handleRenameChatSession, + handleDeleteChatSession, + handleChatDraftChange, + handleSendChatMessage, + handleApproveChatSession, + handleStopChatSession, + handleSaveChatSessionConfig, + handleAttachChatFile, + handleRemoveChatContextItem + }; +} diff --git a/src/hooks/useDocumentHandlers.ts b/src/hooks/useDocumentHandlers.ts new file mode 100644 index 0000000..2994275 --- /dev/null +++ b/src/hooks/useDocumentHandlers.ts @@ -0,0 +1,313 @@ +import { + type ChangeEvent, + type RefObject, + startTransition, + useCallback +} from "react"; +import { getModelLabel, getReasoningLabel } from "../lib/agentConfig"; +import { type DocumentTarget, stampLog } from "../lib/appShell"; +import { waitForNextPaint } from "../lib/appState"; +import { + generatePrdDocument, + generateSpecDocument, + pickDocument +} from "../lib/runtime"; +import { + type ImportableFile, + parseWorkspaceDocument +} from "../lib/workspaceImport"; +import type { AgentStoreSlice, ProjectStoreSlice, SettingsStoreSlice } from "./useAppStoreSlices"; +import type { AppDerivedState } from "./useAppView"; + +interface UseDocumentHandlersOptions { + agentState: AgentStoreSlice; + derivedState: AppDerivedState; + desktopRuntime: boolean; + fileInputRef: RefObject; + pendingImportTargetRef: React.MutableRefObject; + prdGenerationPrompt: string; + projectRootPath: string; + projectState: ProjectStoreSlice; + setIsImporting: (value: boolean) => void; + setPrdGenerationError: (value: string) => void; + setPrdGenerationPrompt: (value: string) => void; + setSpecGenerationError: (value: string) => void; + setSpecGenerationPrompt: (value: string) => void; + settingsState: SettingsStoreSlice; + specGenerationPrompt: string; +} + +export function useDocumentHandlers({ + agentState, + derivedState, + desktopRuntime, + fileInputRef, + pendingImportTargetRef, + prdGenerationPrompt, + projectRootPath, + projectState, + setIsImporting, + setPrdGenerationError, + setPrdGenerationPrompt, + setSpecGenerationError, + setSpecGenerationPrompt, + settingsState, + specGenerationPrompt +}: UseDocumentHandlersOptions) { + const assignDocument = useCallback( + (target: DocumentTarget, content: string, path: string) => { + startTransition(() => { + if (target === "prd") { + projectState.setPrdContent(content, path); + projectState.setPrdPaneMode("preview"); + return; + } + + projectState.setSpecContent(content, path); + projectState.setSpecPaneMode("preview"); + }); + + if (target === "prd") { + setPrdGenerationPrompt(""); + setPrdGenerationError(""); + return; + } + + setSpecGenerationPrompt(""); + setSpecGenerationError(""); + }, + [projectState, setPrdGenerationError, setPrdGenerationPrompt, setSpecGenerationError, setSpecGenerationPrompt] + ); + + const handleOpenImportFile = useCallback( + async (target: DocumentTarget) => { + pendingImportTargetRef.current = target; + + if (desktopRuntime) { + setIsImporting(true); + + try { + const document = await pickDocument(); + + if (document) { + assignDocument(target, document.content, document.sourcePath); + } + } catch (error) { + const message = + error instanceof Error ? error.message : "The selected file could not be imported."; + + if (target === "prd") { + setPrdGenerationError(message); + } else { + setSpecGenerationError(message); + } + } finally { + setIsImporting(false); + } + + return; + } + + fileInputRef.current?.click(); + }, + [assignDocument, desktopRuntime, fileInputRef, pendingImportTargetRef, setIsImporting, setPrdGenerationError, setSpecGenerationError] + ); + + const handleFileSelection = useCallback( + async (event: ChangeEvent) => { + const file = event.target.files?.[0] as ImportableFile | undefined; + + if (!file) { + return; + } + + try { + const document = await parseWorkspaceDocument(file); + assignDocument(pendingImportTargetRef.current, document.content, document.sourcePath); + } catch (error) { + const message = + error instanceof Error ? error.message : "The selected file could not be imported."; + + if (pendingImportTargetRef.current === "prd") { + setPrdGenerationError(message); + } else { + setSpecGenerationError(message); + } + } finally { + event.target.value = ""; + } + }, + [assignDocument, pendingImportTargetRef, setPrdGenerationError, setSpecGenerationError] + ); + + const handleGeneratePrd = useCallback(async () => { + const trimmedPrompt = prdGenerationPrompt.trim(); + + if (!desktopRuntime) { + setPrdGenerationError("AI PRD generation requires the desktop runtime."); + return; + } + + if (!projectRootPath.trim()) { + setPrdGenerationError("Choose a project folder before generating a PRD."); + return; + } + + if (!derivedState.currentProjectSettings.prdPath.toLowerCase().endsWith(".md")) { + setPrdGenerationError("Configure the PRD path as a Markdown file before generating."); + return; + } + + if (!trimmedPrompt) { + setPrdGenerationError("Add the product context you want the AI to consider."); + return; + } + + setPrdGenerationError(""); + agentState.setStatus("generating_prd"); + agentState.appendTerminalOutput( + stampLog( + "prd", + `Generating a PRD draft with ${getModelLabel(projectState.selectedModel)} (${getReasoningLabel(projectState.selectedModel, projectState.selectedReasoning)} reasoning).` + ) + ); + + try { + await waitForNextPaint(); + + const generatedPrd = await generatePrdDocument({ + workspaceRoot: projectRootPath, + outputPath: derivedState.currentProjectSettings.prdPath, + promptTemplate: derivedState.currentProjectSettings.prdPrompt, + userPrompt: trimmedPrompt, + provider: derivedState.selectedModelProvider, + model: projectState.selectedModel, + reasoning: projectState.selectedReasoning, + claudePath: settingsState.claudePath, + codexPath: settingsState.codexPath + }); + + startTransition(() => { + projectState.setPrdContent(generatedPrd.content, generatedPrd.sourcePath); + projectState.setPrdPaneMode("preview"); + }); + setPrdGenerationPrompt(""); + agentState.setStatus("idle"); + agentState.appendTerminalOutput( + stampLog( + "prd", + `PRD draft generated, saved to ${generatedPrd.fileName}, and loaded into the review pane.` + ) + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Unable to generate a PRD."; + setPrdGenerationError(message); + agentState.setStatus("error"); + agentState.appendTerminalOutput(stampLog("error", message)); + } + }, [ + agentState, + derivedState.currentProjectSettings, + derivedState.selectedModelProvider, + desktopRuntime, + prdGenerationPrompt, + projectRootPath, + projectState, + setPrdGenerationError, + setPrdGenerationPrompt, + settingsState + ]); + + const handleGenerateSpec = useCallback(async () => { + const trimmedPrompt = specGenerationPrompt.trim(); + + if (!desktopRuntime) { + setSpecGenerationError("AI spec generation requires the desktop runtime."); + return; + } + + if (!projectRootPath.trim()) { + setSpecGenerationError("Choose a project folder before generating a spec."); + return; + } + + if (!projectState.prdContent.trim()) { + setSpecGenerationError("Load or generate a PRD before drafting a specification."); + return; + } + + if (!derivedState.currentProjectSettings.specPath.toLowerCase().endsWith(".md")) { + setSpecGenerationError("Configure the spec path as a Markdown file before generating."); + return; + } + + if (!trimmedPrompt) { + setSpecGenerationError("Add the technical guidance you want the AI to consider."); + return; + } + + setSpecGenerationError(""); + agentState.setStatus("generating_spec"); + agentState.appendTerminalOutput( + stampLog( + "spec", + `Generating a technical specification with ${getModelLabel(projectState.selectedModel)} (${getReasoningLabel(projectState.selectedModel, projectState.selectedReasoning)} reasoning).` + ) + ); + + try { + await waitForNextPaint(); + + const generatedSpec = await generateSpecDocument({ + workspaceRoot: projectRootPath, + outputPath: derivedState.currentProjectSettings.specPath, + prdContent: projectState.prdContent, + promptTemplate: derivedState.currentProjectSettings.specPrompt, + userPrompt: trimmedPrompt, + provider: derivedState.selectedModelProvider, + model: projectState.selectedModel, + reasoning: projectState.selectedReasoning, + claudePath: settingsState.claudePath, + codexPath: settingsState.codexPath + }); + + startTransition(() => { + projectState.setSpecContent(generatedSpec.content, generatedSpec.sourcePath); + projectState.setSpecPaneMode("preview"); + }); + setSpecGenerationPrompt(""); + agentState.setStatus("idle"); + agentState.appendTerminalOutput( + stampLog( + "spec", + `Specification draft generated, saved to ${generatedSpec.fileName}, and loaded into the review pane.` + ) + ); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unable to generate a specification."; + setSpecGenerationError(message); + agentState.setStatus("error"); + agentState.appendTerminalOutput(stampLog("error", message)); + } + }, [ + agentState, + derivedState.currentProjectSettings, + derivedState.selectedModelProvider, + desktopRuntime, + projectRootPath, + projectState, + setSpecGenerationError, + setSpecGenerationPrompt, + settingsState, + specGenerationPrompt + ]); + + return { + assignDocument, + handleOpenImportFile, + handleFileSelection, + handleGeneratePrd, + handleGenerateSpec + }; +} diff --git a/src/hooks/useProjectHandlers.ts b/src/hooks/useProjectHandlers.ts new file mode 100644 index 0000000..1faf1f4 --- /dev/null +++ b/src/hooks/useProjectHandlers.ts @@ -0,0 +1,324 @@ +import { + type MutableRefObject, + startTransition, + useCallback, + useRef +} from "react"; +import { useNavigate } from "react-router-dom"; +import type { WorkspaceFileSource } from "../lib/appShell"; +import { + buildConfigPathDisplay, + buildCurrentProjectSettings, + buildWorkspaceNotice +} from "../lib/appState"; +import { + loadProjectContext, + pickProjectFolder, + saveProjectSettings +} from "../lib/runtime"; +import { useProjectStore } from "../store/useProjectStore"; +import type { ChatSessionSummary, ProjectContext } from "../types"; +import type { ProjectStoreSlice, SettingsStoreSlice } from "./useAppStoreSlices"; +import type { AppDerivedState } from "./useAppView"; + +interface UseProjectHandlersOptions { + applyProjectContextDeps: { + projectRootPath: string; + projectState: ProjectStoreSlice; + settingsState: SettingsStoreSlice; + setProjectRootName: (value: string) => void; + setProjectRootPath: (value: string) => void; + setProjectConfigPath: (value: string) => void; + setHasSelectedProject: (value: boolean) => void; + setHasSavedProjectSettings: (value: boolean) => void; + setWorkspaceFiles: (files: Record) => void; + setPrdGenerationPrompt: (value: string) => void; + setPrdGenerationError: (value: string) => void; + setSpecGenerationPrompt: (value: string) => void; + setSpecGenerationError: (value: string) => void; + setChatSessions: (sessions: ChatSessionSummary[]) => void; + setActiveSessionId: (id: string | null) => void; + setCavemanStatus: (status: { ready: boolean; message: string }) => void; + setProjectStatusMessage: (message: string) => void; + setProjectErrorMessage: (message: string) => void; + setWorkspaceNotice: (notice: string) => void; + latestPathnameRef: MutableRefObject; + }; + derivedState: AppDerivedState; + desktopRuntime: boolean; + hasSavedProjectSettings: boolean; + projectRootName: string; + projectRootPath: string; + projectState: ProjectStoreSlice; + setIsProjectLoading: (value: boolean) => void; + setIsProjectSaving: (value: boolean) => void; + setProjectErrorMessage: (message: string) => void; + setProjectStatusMessage: (message: string) => void; +} + +export function useProjectHandlers({ + applyProjectContextDeps, + derivedState, + desktopRuntime, + hasSavedProjectSettings, + projectRootName, + projectRootPath, + projectState, + setIsProjectLoading, + setIsProjectSaving, + setProjectErrorMessage, + setProjectStatusMessage +}: UseProjectHandlersOptions) { + const navigate = useNavigate(); + const pendingProjectReloadRef = useRef(false); + const projectSaveTimerRef = useRef(null); + + const applyProjectContext = useCallback( + (context: ProjectContext, options?: { navigateToChat?: boolean }) => { + const { + projectRootPath: currentProjectRootPath, + projectState: ps, + settingsState: ss, + setProjectRootName, + setProjectRootPath, + setProjectConfigPath, + setHasSelectedProject, + setHasSavedProjectSettings: setHasSaved, + setWorkspaceFiles, + setPrdGenerationPrompt, + setPrdGenerationError, + setSpecGenerationPrompt, + setSpecGenerationError, + setChatSessions, + setActiveSessionId, + setCavemanStatus, + setProjectStatusMessage: setStatusMsg, + setProjectErrorMessage: setErrorMsg, + setWorkspaceNotice, + latestPathnameRef + } = applyProjectContextDeps; + + const normalizedCurrentProjectPath = currentProjectRootPath + .replace(/\\/g, "/") + .replace(/\/+$/, "") + .toLowerCase(); + const normalizedNextProjectPath = context.rootPath + .replace(/\\/g, "/") + .replace(/\/+$/, "") + .toLowerCase(); + const isSameProject = + normalizedCurrentProjectPath.length > 0 && + normalizedCurrentProjectPath === normalizedNextProjectPath; + const nextPrdSourcePath = + context.prdDocument?.sourcePath ?? context.settings.prdPath; + const nextSpecSourcePath = + context.specDocument?.sourcePath ?? context.settings.specPath; + const preserveEditingPrd = + isSameProject && + ps.prdPaneMode === "edit" && + ps.prdPath === nextPrdSourcePath; + const preserveEditingSpec = + isSameProject && + ps.specPaneMode === "edit" && + ps.specPath === nextSpecSourcePath; + const settingsPathDisplay = buildConfigPathDisplay( + context.settingsPath, + context.rootName + ); + const nextWorkspaceFiles = Object.fromEntries( + context.entries + .filter((entry) => entry.kind === "file") + .map((entry) => [ + entry.path, + { + kind: "desktop", + fileName: entry.name + } satisfies WorkspaceFileSource + ]) + ); + + if (!isSameProject) { + ps.resetWorkspaceContext(); + } + setProjectRootName(context.rootName); + setProjectRootPath(context.rootPath); + setProjectConfigPath(context.settingsPath); + setHasSelectedProject(true); + setHasSaved(context.hasSavedSettings); + ss.setWorkspaceEntries(context.entries); + setWorkspaceFiles(nextWorkspaceFiles); + ss.setLastProjectPath(context.rootPath); + ps.setProjectSettings(context.settings); + setPrdGenerationPrompt(""); + setPrdGenerationError(""); + setSpecGenerationPrompt(""); + setSpecGenerationError(""); + setChatSessions(context.chatSessions); + setActiveSessionId(context.lastActiveSessionId ?? context.chatSessions[0]?.id ?? null); + setCavemanStatus({ + ready: true, + message: "Caveman mode is built into every topic." + }); + setStatusMsg( + context.hasSavedSettings + ? `Loaded project settings from ${context.rootName}/${settingsPathDisplay}.` + : `Selected ${context.rootName}. Save the setup to create ${context.rootName}/${settingsPathDisplay}.` + ); + setErrorMsg(""); + setWorkspaceNotice(buildWorkspaceNotice(context)); + + startTransition(() => { + if (!preserveEditingPrd) { + ps.setPrdContent( + context.prdDocument?.content ?? "", + nextPrdSourcePath + ); + ps.setPrdPaneMode("preview"); + } + + if (!preserveEditingSpec) { + ps.setSpecContent( + context.specDocument?.content ?? "", + nextSpecSourcePath + ); + ps.setSpecPaneMode("preview"); + } + }); + + if (options?.navigateToChat && latestPathnameRef.current === "/") { + navigate("/chat"); + } + }, + [applyProjectContextDeps, navigate] + ); + + const saveCurrentProjectSettings = useCallback( + async ({ + reloadProject = false, + navigateToChat = false + }: { + reloadProject?: boolean; + navigateToChat?: boolean; + } = {}) => { + if (!desktopRuntime) { + setProjectErrorMessage("Project configuration requires the desktop runtime."); + return; + } + + if (!projectRootPath.trim()) { + setProjectErrorMessage("Choose a project folder before saving."); + return; + } + + setProjectErrorMessage(""); + setProjectStatusMessage(""); + setIsProjectSaving(true); + + try { + const latestProjectState = useProjectStore.getState(); + const currentProjectSettings = buildCurrentProjectSettings({ + configuredPrdPath: latestProjectState.configuredPrdPath, + configuredSpecPath: latestProjectState.configuredSpecPath, + prdPromptTemplate: latestProjectState.prdPromptTemplate, + selectedModel: latestProjectState.selectedModel, + selectedReasoning: latestProjectState.selectedReasoning, + specPromptTemplate: latestProjectState.specPromptTemplate, + supportingDocumentPaths: latestProjectState.supportingDocumentPaths + }); + const savedSettings = await saveProjectSettings({ + folderPath: projectRootPath, + settings: currentProjectSettings + }); + + projectState.setProjectSettings(savedSettings); + applyProjectContextDeps.setHasSavedProjectSettings(true); + setProjectStatusMessage( + projectRootName + ? `Saved project settings to ${projectRootName}/${derivedState.configPathDisplay}.` + : `Saved project settings to ${derivedState.configPathDisplay}.` + ); + + if (reloadProject || navigateToChat) { + const reloadedContext = await loadProjectContext(projectRootPath); + applyProjectContext(reloadedContext, { navigateToChat }); + } + } catch (error) { + setProjectErrorMessage( + error instanceof Error ? error.message : "Unable to save the current project settings." + ); + } finally { + setIsProjectSaving(false); + } + }, + [ + applyProjectContext, + applyProjectContextDeps, + derivedState.configPathDisplay, + desktopRuntime, + projectRootName, + projectRootPath, + projectState, + setIsProjectSaving, + setProjectErrorMessage, + setProjectStatusMessage + ] + ); + + const scheduleProjectSettingsSave = useCallback( + (reloadProject = false) => { + if (!desktopRuntime || !hasSavedProjectSettings || !projectRootPath.trim()) { + return; + } + + pendingProjectReloadRef.current = pendingProjectReloadRef.current || reloadProject; + + if (projectSaveTimerRef.current !== null) { + window.clearTimeout(projectSaveTimerRef.current); + } + + projectSaveTimerRef.current = window.setTimeout(() => { + const shouldReload = pendingProjectReloadRef.current; + pendingProjectReloadRef.current = false; + projectSaveTimerRef.current = null; + void saveCurrentProjectSettings({ reloadProject: shouldReload }); + }, 700); + }, + [desktopRuntime, hasSavedProjectSettings, projectRootPath, saveCurrentProjectSettings] + ); + + const handlePickProjectFolder = useCallback(async () => { + if (!desktopRuntime) { + setProjectErrorMessage("Project configuration requires the desktop runtime."); + return; + } + + setProjectErrorMessage(""); + setProjectStatusMessage(""); + setIsProjectLoading(true); + + try { + const nextProjectContext = await pickProjectFolder(); + + if (!nextProjectContext) { + return; + } + + applyProjectContext(nextProjectContext); + navigate("/"); + } catch (error) { + setProjectErrorMessage( + error instanceof Error ? error.message : "Unable to open the selected project folder." + ); + } finally { + setIsProjectLoading(false); + } + }, [applyProjectContext, desktopRuntime, navigate, setIsProjectLoading, setProjectErrorMessage, setProjectStatusMessage]); + + return { + applyProjectContext, + saveCurrentProjectSettings, + scheduleProjectSettingsSave, + handlePickProjectFolder, + projectSaveTimerRef + }; +} diff --git a/src/hooks/useProjectSettingsHandlers.ts b/src/hooks/useProjectSettingsHandlers.ts new file mode 100644 index 0000000..5650356 --- /dev/null +++ b/src/hooks/useProjectSettingsHandlers.ts @@ -0,0 +1,110 @@ +import { useCallback } from "react"; + +import { normalizeProjectRelativePath, parseSupportingDocumentPaths } from "../lib/projectConfig"; +import type { ProjectStoreSlice } from "./useAppStoreSlices"; + +interface SaveCurrentProjectSettingsOptions { + reloadProject?: boolean; + navigateToChat?: boolean; +} + +type SaveCurrentProjectSettings = ( + options?: SaveCurrentProjectSettingsOptions +) => Promise; + +interface UseProjectSettingsHandlersOptions { + saveCurrentProjectSettings: SaveCurrentProjectSettings; + scheduleProjectSettingsSave: (reloadProject?: boolean) => void; + setConfiguredPrdPath: ProjectStoreSlice["setConfiguredPrdPath"]; + setConfiguredSpecPath: ProjectStoreSlice["setConfiguredSpecPath"]; + setPrdPromptTemplate: ProjectStoreSlice["setPrdPromptTemplate"]; + setReasoningProfile: ProjectStoreSlice["setReasoningProfile"]; + setSelectedModel: ProjectStoreSlice["setSelectedModel"]; + setSpecPromptTemplate: ProjectStoreSlice["setSpecPromptTemplate"]; + setSupportingDocumentPaths: ProjectStoreSlice["setSupportingDocumentPaths"]; +} + +export function useProjectSettingsHandlers({ + saveCurrentProjectSettings, + scheduleProjectSettingsSave, + setConfiguredPrdPath, + setConfiguredSpecPath, + setPrdPromptTemplate, + setReasoningProfile, + setSelectedModel, + setSpecPromptTemplate, + setSupportingDocumentPaths +}: UseProjectSettingsHandlersOptions) { + const handleProjectModelChange = useCallback( + (model: Parameters[0]) => { + setSelectedModel(model); + scheduleProjectSettingsSave(false); + }, + [scheduleProjectSettingsSave, setSelectedModel] + ); + + const handleProjectReasoningChange = useCallback( + (reasoning: Parameters[0]) => { + setReasoningProfile(reasoning); + scheduleProjectSettingsSave(false); + }, + [scheduleProjectSettingsSave, setReasoningProfile] + ); + + const handlePrdPromptTemplateChange = useCallback( + (value: string) => { + setPrdPromptTemplate(value); + scheduleProjectSettingsSave(false); + }, + [scheduleProjectSettingsSave, setPrdPromptTemplate] + ); + + const handleSpecPromptTemplateChange = useCallback( + (value: string) => { + setSpecPromptTemplate(value); + scheduleProjectSettingsSave(false); + }, + [scheduleProjectSettingsSave, setSpecPromptTemplate] + ); + + const handleConfiguredPrdPathChange = useCallback( + (value: string) => { + setConfiguredPrdPath(normalizeProjectRelativePath(value)); + scheduleProjectSettingsSave(true); + }, + [scheduleProjectSettingsSave, setConfiguredPrdPath] + ); + + const handleConfiguredSpecPathChange = useCallback( + (value: string) => { + setConfiguredSpecPath(normalizeProjectRelativePath(value)); + scheduleProjectSettingsSave(true); + }, + [scheduleProjectSettingsSave, setConfiguredSpecPath] + ); + + const handleSupportingDocumentsChange = useCallback( + (value: string) => { + setSupportingDocumentPaths(parseSupportingDocumentPaths(value)); + scheduleProjectSettingsSave(false); + }, + [scheduleProjectSettingsSave, setSupportingDocumentPaths] + ); + + const handleSaveConfigurationAndContinue = useCallback(() => { + void saveCurrentProjectSettings({ reloadProject: true, navigateToChat: true }); + }, [saveCurrentProjectSettings]); + + return { + handleProjectModelChange, + handleProjectReasoningChange, + handlePrdPromptTemplateChange, + handleSpecPromptTemplateChange, + handleConfiguredPrdPathChange, + handleConfiguredSpecPathChange, + handleSupportingDocumentsChange, + handleSaveConfigurationAndContinue + }; +} + +export type ProjectSettingsHandlers = ReturnType; diff --git a/src/lib/agentConfig.test.ts b/src/lib/agentConfig.test.ts new file mode 100644 index 0000000..a5a177b --- /dev/null +++ b/src/lib/agentConfig.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_MODEL_ID, + DEFAULT_REASONING_PROFILE, + getModelLabel, + getModelOption, + getModelOptions, + getModelProvider, + getProviderLabel, + getReasoningHint, + getReasoningLabel, + getReasoningOptions, + normalizeReasoningProfile +} from "./agentConfig"; + +describe("getModelLabel", () => { + it("returns the label for a known codex model", () => { + expect(getModelLabel("gpt-5.4")).toBe("GPT-5.4"); + }); + + it("returns the label for a known claude model", () => { + expect(getModelLabel("claude-sonnet-4-20250514")).toBe("Claude Sonnet 4"); + }); + + it("returns the label for gpt-5.4-mini", () => { + expect(getModelLabel("gpt-5.4-mini")).toBe("GPT-5.4 Mini"); + }); + + it("falls back to the first model for an unknown model id", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const label = getModelLabel("nonexistent-model" as any); + expect(label).toBe("GPT-5.4"); + }); +}); + +describe("getModelProvider", () => { + it("returns 'codex' for a codex model", () => { + expect(getModelProvider("gpt-5.4")).toBe("codex"); + }); + + it("returns 'claude' for a claude model", () => { + expect(getModelProvider("claude-opus-4-20250514")).toBe("claude"); + }); + + it("falls back to the first model's provider for unknown id", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(getModelProvider("unknown" as any)).toBe("codex"); + }); +}); + +describe("getModelOption", () => { + it("returns the full model option for a valid id", () => { + const option = getModelOption("gpt-5.2"); + expect(option.id).toBe("gpt-5.2"); + expect(option.provider).toBe("codex"); + expect(option.label).toBe("GPT-5.2"); + }); + + it("returns the first model as fallback for unknown id", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const option = getModelOption("fake" as any); + expect(option.id).toBe("gpt-5.4"); + }); +}); + +describe("getModelOptions", () => { + it("returns all models when no provider is given", () => { + const options = getModelOptions(); + expect(options.length).toBeGreaterThan(0); + const ids = options.map((o) => o.value); + expect(ids).toContain("gpt-5.4"); + expect(ids).toContain("claude-opus-4-20250514"); + }); + + it("returns only codex models when filtered by codex", () => { + const options = getModelOptions("codex"); + expect(options.length).toBeGreaterThan(0); + for (const opt of options) { + expect(opt.hint).toMatch(/^Codex/); + } + }); + + it("returns only claude models when filtered by claude", () => { + const options = getModelOptions("claude"); + expect(options.length).toBeGreaterThan(0); + for (const opt of options) { + expect(opt.hint).toMatch(/^Claude/); + } + }); + + it("returns options with label and hint", () => { + const options = getModelOptions(); + for (const opt of options) { + expect(opt.label).toBeTruthy(); + expect(opt.hint).toBeTruthy(); + } + }); +}); + +describe("getReasoningLabel", () => { + it("returns 'Medium' for a standard medium profile", () => { + expect(getReasoningLabel("claude-opus-4-20250514", "medium")).toBe("Medium"); + }); + + it("returns 'Max (xhigh)' for codex model with max profile", () => { + expect(getReasoningLabel("gpt-5.4", "max")).toBe("Max (xhigh)"); + }); + + it("returns 'Max' for claude model with max profile", () => { + expect(getReasoningLabel("claude-opus-4-20250514", "max")).toBe("Max"); + }); + + it("returns 'Low' for low profile", () => { + expect(getReasoningLabel("gpt-5.4", "low")).toBe("Low"); + }); +}); + +describe("getReasoningHint", () => { + it("returns deepest Codex tier hint for codex max", () => { + const hint = getReasoningHint("gpt-5.4", "max"); + expect(hint).toContain("Codex"); + expect(hint).toContain("deepest"); + }); + + it("returns standard mode hint for claude model with only basic reasoning", () => { + const hint = getReasoningHint("claude-3-5-sonnet-20241022", "low"); + expect(hint).toContain("standard reasoning mode only"); + }); + + it("returns description-based hint for claude model with full reasoning", () => { + const hint = getReasoningHint("claude-opus-4-20250514", "medium"); + expect(hint).toContain("Claude"); + expect(hint).toContain("Balanced"); + }); +}); + +describe("getReasoningOptions", () => { + it("returns full range for a codex model", () => { + const options = getReasoningOptions("gpt-5.4"); + expect(options).toHaveLength(4); + expect(options.map((o) => o.value)).toEqual(["low", "medium", "high", "max"]); + }); + + it("returns basic range for a claude model without extended thinking", () => { + const options = getReasoningOptions("claude-3-5-sonnet-20241022"); + expect(options).toHaveLength(1); + expect(options[0].value).toBe("low"); + }); + + it("each option has a label and hint", () => { + const options = getReasoningOptions("gpt-5.4"); + for (const opt of options) { + expect(opt.label).toBeTruthy(); + expect(opt.hint).toBeTruthy(); + } + }); +}); + +describe("normalizeReasoningProfile", () => { + it("returns the profile when it is valid for the model", () => { + expect(normalizeReasoningProfile("gpt-5.4", "high")).toBe("high"); + }); + + it("falls back to the first profile when invalid for the model", () => { + expect(normalizeReasoningProfile("claude-3-5-sonnet-20241022", "max")).toBe("low"); + }); + + it("returns low for basic-reasoning claude model with medium", () => { + expect(normalizeReasoningProfile("claude-3-haiku-20240307", "medium")).toBe("low"); + }); +}); + +describe("getProviderLabel", () => { + it("returns 'Codex' for codex provider", () => { + expect(getProviderLabel("codex")).toBe("Codex"); + }); + + it("returns 'Claude' for claude provider", () => { + expect(getProviderLabel("claude")).toBe("Claude"); + }); +}); + +describe("DEFAULT exports", () => { + it("DEFAULT_MODEL_ID is gpt-5.4", () => { + expect(DEFAULT_MODEL_ID).toBe("gpt-5.4"); + }); + + it("DEFAULT_REASONING_PROFILE is medium", () => { + expect(DEFAULT_REASONING_PROFILE).toBe("medium"); + }); +}); diff --git a/src/lib/appState.test.ts b/src/lib/appState.test.ts new file mode 100644 index 0000000..0cc6fc7 --- /dev/null +++ b/src/lib/appState.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it } from "vitest"; +import type { EnvironmentStatus, ProjectContext } from "../types"; +import { + buildConfigPathDisplay, + buildConfiguredModelProviders, + buildCurrentProjectSettings, + buildMcpItems, + buildWorkspaceNotice +} from "./appState"; + +function makeEnvironment(overrides?: Partial>>): EnvironmentStatus { + return { + scannedAt: new Date().toISOString(), + claude: { + name: "Claude CLI", + status: "found", + path: "/usr/bin/claude", + detail: "Claude is available", + ...overrides?.claude + }, + codex: { + name: "Codex CLI", + status: "found", + path: "/usr/bin/codex", + detail: "Codex is available", + ...overrides?.codex + }, + git: { + name: "Git", + status: "found", + path: "/usr/bin/git", + detail: "Git is available", + ...overrides?.git + } + }; +} + +describe("buildCurrentProjectSettings", () => { + it("returns normalized settings with provided values", () => { + const result = buildCurrentProjectSettings({ + configuredPrdPath: "docs/PRD.md", + configuredSpecPath: "docs/SPEC.md", + prdPromptTemplate: "Generate PRD", + selectedModel: "gpt-5.4", + selectedReasoning: "medium", + specPromptTemplate: "Generate Spec", + supportingDocumentPaths: ["docs/api.md"] + }); + + expect(result.selectedModel).toBe("gpt-5.4"); + expect(result.selectedReasoning).toBe("medium"); + expect(result.prdPath).toBe("docs/PRD.md"); + expect(result.specPath).toBe("docs/SPEC.md"); + expect(result.supportingDocumentPaths).toEqual(["docs/api.md"]); + }); + + it("uses default PRD path when configuredPrdPath is empty", () => { + const result = buildCurrentProjectSettings({ + configuredPrdPath: "", + configuredSpecPath: "", + prdPromptTemplate: "prompt", + selectedModel: "gpt-5.4", + selectedReasoning: "medium", + specPromptTemplate: "prompt", + supportingDocumentPaths: [] + }); + + expect(result.prdPath).toBe("docs/PRD.md"); + expect(result.specPath).toBe("docs/SPEC.md"); + }); + + it("normalizes reasoning profile for the selected model", () => { + const result = buildCurrentProjectSettings({ + configuredPrdPath: "docs/PRD.md", + configuredSpecPath: "docs/SPEC.md", + prdPromptTemplate: "prompt", + selectedModel: "claude-3-5-sonnet-20241022", + selectedReasoning: "max", + specPromptTemplate: "prompt", + supportingDocumentPaths: [] + }); + + expect(result.selectedReasoning).toBe("low"); + }); +}); + +describe("buildConfigPathDisplay", () => { + it("returns workspace-relative display path when config path is set", () => { + const result = buildConfigPathDisplay("/Users/me/myapp/.specforge/settings.json", "myapp"); + expect(result).toBe(".specforge/settings.json"); + }); + + it("returns default settings relative path when config path is empty", () => { + const result = buildConfigPathDisplay("", "myapp"); + expect(result).toBe(".specforge/settings.json"); + }); + + it("returns default settings relative path for whitespace-only config path", () => { + const result = buildConfigPathDisplay(" ", "myapp"); + expect(result).toBe(".specforge/settings.json"); + }); + + it("handles config path without matching root name", () => { + const result = buildConfigPathDisplay("/some/other/path/settings.json", "myapp"); + expect(result).toBe("/some/other/path/settings.json"); + }); +}); + +describe("buildWorkspaceNotice", () => { + function makeContext(overrides?: Partial): ProjectContext { + return { + rootName: "my-project", + rootPath: "/Users/me/my-project", + settingsPath: "/Users/me/my-project/.specforge/settings.json", + hasSavedSettings: true, + settings: { + selectedModel: "gpt-5.4", + selectedReasoning: "medium", + prdPrompt: "prompt", + specPrompt: "prompt", + prdPath: "docs/PRD.md", + specPath: "docs/SPEC.md", + supportingDocumentPaths: [] + }, + entries: [], + ignoredFileCount: 0, + prdDocument: null, + specDocument: null, + chatSessions: [], + lastActiveSessionId: null, + ...overrides + }; + } + + it("shows 'no document exists' when no documents are loaded", () => { + const notice = buildWorkspaceNotice(makeContext()); + expect(notice).toContain("my-project is configured"); + expect(notice).toContain("No document exists yet"); + expect(notice).toContain("docs/PRD.md"); + expect(notice).toContain("docs/SPEC.md"); + }); + + it("lists PRD when only PRD is loaded", () => { + const notice = buildWorkspaceNotice( + makeContext({ + prdDocument: { content: "# PRD", sourcePath: "/path/PRD.md", fileName: "PRD.md" } + }) + ); + expect(notice).toContain("PRD: PRD.md"); + expect(notice).not.toContain("SPEC:"); + }); + + it("lists both PRD and SPEC when both are loaded", () => { + const notice = buildWorkspaceNotice( + makeContext({ + prdDocument: { content: "# PRD", sourcePath: "/path/PRD.md", fileName: "PRD.md" }, + specDocument: { content: "# SPEC", sourcePath: "/path/SPEC.md", fileName: "SPEC.md" } + }) + ); + expect(notice).toContain("PRD: PRD.md"); + expect(notice).toContain("SPEC: SPEC.md"); + expect(notice).toContain(" and "); + }); + + it("uses the project root name in the notice", () => { + const notice = buildWorkspaceNotice(makeContext({ rootName: "awesome-app" })); + expect(notice).toContain("awesome-app is configured"); + }); +}); + +describe("buildConfiguredModelProviders", () => { + it("returns both providers when both are found", () => { + const providers = buildConfiguredModelProviders(makeEnvironment()); + expect(providers).toContain("claude"); + expect(providers).toContain("codex"); + }); + + it("returns empty array when none are found", () => { + const providers = buildConfiguredModelProviders( + makeEnvironment({ claude: { status: "missing" }, codex: { status: "missing" } }) + ); + expect(providers).toEqual([]); + }); + + it("returns only claude when codex is missing", () => { + const providers = buildConfiguredModelProviders( + makeEnvironment({ codex: { status: "missing" } }) + ); + expect(providers).toEqual(["claude"]); + }); + + it("returns only codex when claude is missing", () => { + const providers = buildConfiguredModelProviders( + makeEnvironment({ claude: { status: "missing" } }) + ); + expect(providers).toEqual(["codex"]); + }); +}); + +describe("buildMcpItems", () => { + it("returns three items for codex, claude, and git", () => { + const items = buildMcpItems(makeEnvironment()); + expect(items).toHaveLength(3); + expect(items[0].name).toBe("Codex CLI"); + expect(items[1].name).toBe("Claude CLI"); + expect(items[2].name).toBe("Git"); + }); + + it("includes status and detail in each item", () => { + const items = buildMcpItems(makeEnvironment()); + for (const item of items) { + expect(item.status).toBeTruthy(); + expect(item.detail).toBeTruthy(); + } + }); + + it("reflects missing status", () => { + const items = buildMcpItems(makeEnvironment({ git: { status: "missing" } })); + expect(items[2].status).toBe("missing"); + }); +}); diff --git a/src/lib/appState.ts b/src/lib/appState.ts index 5ddcffe..e2207e1 100644 --- a/src/lib/appState.ts +++ b/src/lib/appState.ts @@ -1,11 +1,3 @@ -import { getModelLabel } from "./agentConfig"; -import { - DEFAULT_PROJECT_PRD_PATH, - DEFAULT_PROJECT_SPEC_PATH, - SPECFORGE_SETTINGS_RELATIVE_PATH, - getWorkspaceDisplayPath, - normalizeProjectSettings -} from "./projectConfig"; import type { EnvironmentStatus, ModelId, @@ -13,6 +5,14 @@ import type { ProjectContext, ReasoningProfileId } from "../types"; +import { getModelLabel } from "./agentConfig"; +import { + DEFAULT_PROJECT_PRD_PATH, + DEFAULT_PROJECT_SPEC_PATH, + getWorkspaceDisplayPath, + normalizeProjectSettings, + SPECFORGE_SETTINGS_RELATIVE_PATH +} from "./projectConfig"; interface BuildCurrentProjectSettingsOptions { configuredPrdPath: string; diff --git a/src/lib/projectConfig.test.ts b/src/lib/projectConfig.test.ts new file mode 100644 index 0000000..a2249ac --- /dev/null +++ b/src/lib/projectConfig.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from "vitest"; +import { + buildDefaultProjectSettings, + DEFAULT_PROJECT_PRD_PATH, + DEFAULT_PROJECT_SPEC_PATH, + formatSupportingDocumentPaths, + getWorkspaceDisplayPath, + normalizeProjectRelativePath, + normalizeProjectSettings, + normalizeSupportingDocumentPaths, + parseSupportingDocumentPaths, + SPECFORGE_DIRECTORY_NAME, + SPECFORGE_SETTINGS_FILE_NAME, + SPECFORGE_SETTINGS_RELATIVE_PATH +} from "./projectConfig"; + +describe("normalizeProjectRelativePath", () => { + it("trims whitespace and normalizes slashes", () => { + expect(normalizeProjectRelativePath(" docs\\PRD.md ")).toBe("docs/PRD.md"); + }); + + it("strips leading slashes", () => { + expect(normalizeProjectRelativePath("///docs/PRD.md")).toBe("docs/PRD.md"); + }); + + it("returns empty string for null or undefined", () => { + expect(normalizeProjectRelativePath(null)).toBe(""); + expect(normalizeProjectRelativePath(undefined)).toBe(""); + }); + + it("returns empty string for whitespace-only input", () => { + expect(normalizeProjectRelativePath(" ")).toBe(""); + }); + + it("handles a simple filename without directory", () => { + expect(normalizeProjectRelativePath("README.md")).toBe("README.md"); + }); +}); + +describe("normalizeSupportingDocumentPaths", () => { + it("normalizes each path in the array", () => { + const result = normalizeSupportingDocumentPaths([" docs\\a.md ", "/b.md"]); + expect(result).toEqual(["docs/a.md", "b.md"]); + }); + + it("removes empty entries", () => { + const result = normalizeSupportingDocumentPaths(["a.md", "", " ", "b.md"]); + expect(result).toEqual(["a.md", "b.md"]); + }); + + it("removes duplicates", () => { + const result = normalizeSupportingDocumentPaths(["a.md", "a.md", "b.md"]); + expect(result).toEqual(["a.md", "b.md"]); + }); + + it("returns empty array for null or undefined", () => { + expect(normalizeSupportingDocumentPaths(null)).toEqual([]); + expect(normalizeSupportingDocumentPaths(undefined)).toEqual([]); + }); + + it("returns empty array for empty array input", () => { + expect(normalizeSupportingDocumentPaths([])).toEqual([]); + }); +}); + +describe("formatSupportingDocumentPaths", () => { + it("joins paths with newlines", () => { + expect(formatSupportingDocumentPaths(["a.md", "b.md"])).toBe("a.md\nb.md"); + }); + + it("returns empty string for empty array", () => { + expect(formatSupportingDocumentPaths([])).toBe(""); + }); + + it("returns single path without newline for one-element array", () => { + expect(formatSupportingDocumentPaths(["only.md"])).toBe("only.md"); + }); +}); + +describe("parseSupportingDocumentPaths", () => { + it("splits on newlines and normalizes", () => { + const result = parseSupportingDocumentPaths("a.md\nb.md\nc.md"); + expect(result).toEqual(["a.md", "b.md", "c.md"]); + }); + + it("handles Windows-style line endings", () => { + const result = parseSupportingDocumentPaths("a.md\r\nb.md"); + expect(result).toEqual(["a.md", "b.md"]); + }); + + it("filters empty lines", () => { + const result = parseSupportingDocumentPaths("a.md\n\n\nb.md"); + expect(result).toEqual(["a.md", "b.md"]); + }); + + it("returns empty array for empty string", () => { + expect(parseSupportingDocumentPaths("")).toEqual([]); + }); + + it("deduplicates paths", () => { + const result = parseSupportingDocumentPaths("a.md\na.md\nb.md"); + expect(result).toEqual(["a.md", "b.md"]); + }); +}); + +describe("buildDefaultProjectSettings", () => { + it("returns expected default values", () => { + const settings = buildDefaultProjectSettings(); + expect(settings.selectedModel).toBe("gpt-5.4"); + expect(settings.selectedReasoning).toBe("medium"); + expect(settings.prdPath).toBe("docs/PRD.md"); + expect(settings.specPath).toBe("docs/SPEC.md"); + expect(settings.supportingDocumentPaths).toEqual([]); + }); + + it("returns prompts that are non-empty strings", () => { + const settings = buildDefaultProjectSettings(); + expect(settings.prdPrompt.length).toBeGreaterThan(0); + expect(settings.specPrompt.length).toBeGreaterThan(0); + }); +}); + +describe("normalizeProjectSettings", () => { + it("returns defaults when given null", () => { + const result = normalizeProjectSettings(null); + const defaults = buildDefaultProjectSettings(); + expect(result).toEqual(defaults); + }); + + it("returns defaults when given undefined", () => { + const result = normalizeProjectSettings(undefined); + const defaults = buildDefaultProjectSettings(); + expect(result).toEqual(defaults); + }); + + it("preserves valid overrides", () => { + const result = normalizeProjectSettings({ + selectedModel: "claude-opus-4-20250514", + selectedReasoning: "high", + prdPath: "custom/PRD.md", + specPath: "custom/SPEC.md" + }); + expect(result.selectedModel).toBe("claude-opus-4-20250514"); + expect(result.selectedReasoning).toBe("high"); + expect(result.prdPath).toBe("custom/PRD.md"); + expect(result.specPath).toBe("custom/SPEC.md"); + }); + + it("normalizes invalid model to default", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = normalizeProjectSettings({ selectedModel: "bad-model" as any }); + expect(result.selectedModel).toBe("gpt-5.4"); + }); + + it("normalizes reasoning profile that is invalid for the model", () => { + const result = normalizeProjectSettings({ + selectedModel: "claude-3-5-sonnet-20241022", + selectedReasoning: "max" + }); + expect(result.selectedReasoning).toBe("low"); + }); +}); + +describe("getWorkspaceDisplayPath", () => { + it("strips prefix up to workspace root name", () => { + const result = getWorkspaceDisplayPath("/Users/me/projects/myapp/src/file.ts", "myapp"); + expect(result).toBe("src/file.ts"); + }); + + it("returns normalized path when root name is not provided", () => { + const result = getWorkspaceDisplayPath("/Users/me/projects/myapp/src/file.ts"); + expect(result).toBe("/Users/me/projects/myapp/src/file.ts"); + }); + + it("returns normalized path when root name is not found", () => { + const result = getWorkspaceDisplayPath("/some/path/file.ts", "notfound"); + expect(result).toBe("/some/path/file.ts"); + }); + + it("handles Windows UNC-style paths", () => { + const result = getWorkspaceDisplayPath("//?/C:\\Users\\me\\myapp\\src\\file.ts", "myapp"); + expect(result).toBe("src/file.ts"); + }); + + it("is case-insensitive for root name matching", () => { + const result = getWorkspaceDisplayPath("/Users/me/MyApp/src/file.ts", "myapp"); + expect(result).toBe("src/file.ts"); + }); +}); + +describe("constants", () => { + it("SPECFORGE_DIRECTORY_NAME is .specforge", () => { + expect(SPECFORGE_DIRECTORY_NAME).toBe(".specforge"); + }); + + it("SPECFORGE_SETTINGS_FILE_NAME is settings.json", () => { + expect(SPECFORGE_SETTINGS_FILE_NAME).toBe("settings.json"); + }); + + it("SPECFORGE_SETTINGS_RELATIVE_PATH is correct", () => { + expect(SPECFORGE_SETTINGS_RELATIVE_PATH).toBe(".specforge/settings.json"); + }); + + it("DEFAULT_PROJECT_PRD_PATH is docs/PRD.md", () => { + expect(DEFAULT_PROJECT_PRD_PATH).toBe("docs/PRD.md"); + }); + + it("DEFAULT_PROJECT_SPEC_PATH is docs/SPEC.md", () => { + expect(DEFAULT_PROJECT_SPEC_PATH).toBe("docs/SPEC.md"); + }); +}); diff --git a/src/lib/projectConfig.ts b/src/lib/projectConfig.ts index 3f12a5e..d41ad3d 100644 --- a/src/lib/projectConfig.ts +++ b/src/lib/projectConfig.ts @@ -1,5 +1,5 @@ -import { DEFAULT_MODEL_ID, DEFAULT_REASONING_PROFILE, normalizeReasoningProfile } from "./agentConfig"; import type { ModelId, ProjectSettings, ReasoningProfileId } from "../types"; +import { DEFAULT_MODEL_ID, DEFAULT_REASONING_PROFILE, normalizeReasoningProfile } from "./agentConfig"; export const SPECFORGE_DIRECTORY_NAME = ".specforge"; export const SPECFORGE_SETTINGS_FILE_NAME = "settings.json"; diff --git a/src/lib/runtime.ts b/src/lib/runtime.ts index 4fea792..e9d155b 100644 --- a/src/lib/runtime.ts +++ b/src/lib/runtime.ts @@ -3,11 +3,11 @@ import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import type { AgentEventPayload, + AutonomyMode, ChatContextItem, ChatEventPayload, ChatSession, ChatSessionSummary, - AutonomyMode, EnvironmentStatus, ModelId, ModelProvider, @@ -15,8 +15,8 @@ import type { ProjectSettings, ReasoningProfileId, WorkspaceDocument, - WorkspaceScanResult, - WorkspaceEntry + WorkspaceEntry, + WorkspaceScanResult } from "../types"; export const DEFAULT_PENDING_DIFF = `diff --git a/src/App.tsx b/src/App.tsx diff --git a/src/screens/ChatScreen.tsx b/src/screens/ChatScreen.tsx index 9aef58b..adfc67c 100644 --- a/src/screens/ChatScreen.tsx +++ b/src/screens/ChatScreen.tsx @@ -27,7 +27,7 @@ import { WarningCircle, XmarkCircle } from "iconoir-react"; -import { useCallback, useMemo, useState, type Key } from "react"; +import { type Key, memo, useCallback, useMemo, useState } from "react"; import { DiffPreview } from "../components/DiffPreview"; import { @@ -119,7 +119,7 @@ const CONTEXT_CHIP_CLASS = const TERMINAL_CARD_CLASS = "min-h-0 flex-1 border border-[var(--border-soft)] bg-black/20 shadow-none"; -export function ChatScreen({ +export const ChatScreen = memo(function ChatScreen({ workspaceRootName, sessions, activeSession, @@ -201,13 +201,14 @@ export function ChatScreen({

Topics

{workspaceRootName}

- setTopicSearch(event.target.value)} placeholder="Search topics" @@ -313,7 +314,7 @@ export function ChatScreen({ Review - @@ -371,6 +372,7 @@ export function ChatScreen({