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 (