diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc deleted file mode 100644 index ddc29a013..000000000 --- a/.opencode/opencode.jsonc +++ /dev/null @@ -1,6 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json", - "plugin": [ - // "@neuralnomads/nomadworks@0.1.0-rc.10" - ] -} \ No newline at end of file diff --git a/.opencode/package-lock.json b/.opencode/package-lock.json deleted file mode 100644 index 408ff65b7..000000000 --- a/.opencode/package-lock.json +++ /dev/null @@ -1,376 +0,0 @@ -{ - "name": ".opencode", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "dependencies": { - "@opencode-ai/plugin": "1.14.24" - } - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", - "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", - "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", - "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", - "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", - "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", - "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@opencode-ai/plugin": { - "version": "1.14.24", - "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.14.24.tgz", - "integrity": "sha512-upzw2a9KfzIkIvvjYSPJiyV6o85D3HLmhVvAJIwV8mYWxbvi2wP2NA0hJaMp2+GZVuUl/ra8WV8kacD1CWcb4w==", - "license": "MIT", - "dependencies": { - "@opencode-ai/sdk": "1.14.24", - "effect": "4.0.0-beta.48", - "zod": "4.1.8" - }, - "peerDependencies": { - "@opentui/core": ">=0.1.99", - "@opentui/solid": ">=0.1.99" - }, - "peerDependenciesMeta": { - "@opentui/core": { - "optional": true - }, - "@opentui/solid": { - "optional": true - } - } - }, - "node_modules/@opencode-ai/sdk": { - "version": "1.14.24", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.24.tgz", - "integrity": "sha512-hZWc1jx+gtZBM6Mff9iOMlXM1at9BbAGg0uNrQk8DuXpd8K19fu942emojdInO2zy0jC5/wWggsi7GJu7HMp/w==", - "license": "MIT", - "dependencies": { - "cross-spawn": "7.0.6" - } - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/effect": { - "version": "4.0.0-beta.48", - "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.48.tgz", - "integrity": "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw==", - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "fast-check": "^4.6.0", - "find-my-way-ts": "^0.1.6", - "ini": "^6.0.0", - "kubernetes-types": "^1.30.0", - "msgpackr": "^1.11.9", - "multipasta": "^0.2.7", - "toml": "^4.1.1", - "uuid": "^13.0.0", - "yaml": "^2.8.3" - } - }, - "node_modules/fast-check": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz", - "integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT", - "dependencies": { - "pure-rand": "^8.0.0" - }, - "engines": { - "node": ">=12.17.0" - } - }, - "node_modules/find-my-way-ts": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", - "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", - "license": "MIT" - }, - "node_modules/ini": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", - "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/kubernetes-types": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", - "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", - "license": "Apache-2.0" - }, - "node_modules/msgpackr": { - "version": "1.11.10", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.10.tgz", - "integrity": "sha512-iCZNq+HszvF+fC3anCm4nBmWEnbeIAfpDs6IStAEKhQ2YSgkjzVG2FF9XJqwwQh5bH3N9OUTUt4QwVN6MLMLtA==", - "license": "MIT", - "optionalDependencies": { - "msgpackr-extract": "^3.0.2" - } - }, - "node_modules/msgpackr-extract": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", - "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-gyp-build-optional-packages": "5.2.2" - }, - "bin": { - "download-msgpackr-prebuilds": "bin/download-prebuilds.js" - }, - "optionalDependencies": { - "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" - } - }, - "node_modules/multipasta": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz", - "integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==", - "license": "MIT" - }, - "node_modules/node-gyp-build-optional-packages": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", - "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^2.0.1" - }, - "bin": { - "node-gyp-build-optional-packages": "bin.js", - "node-gyp-build-optional-packages-optional": "optional.js", - "node-gyp-build-optional-packages-test": "build-test.js" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/pure-rand": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", - "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/toml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz", - "integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, - "node_modules/zod": { - "version": "4.1.8", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/PROGRESS.md b/PROGRESS.md deleted file mode 100644 index cce670bff..000000000 --- a/PROGRESS.md +++ /dev/null @@ -1,149 +0,0 @@ -# CodeNomad - Development Progress - -## Completed Tasks - -### Task 001: Project Setup ✅ -- Set up Electron + SolidJS + Vite + TypeScript -- Configured TailwindCSS v3 (downgraded from v4 for electron-vite compatibility) -- Build pipeline with electron-vite -- Application window management -- Application menu with keyboard shortcuts - -### Task 002: Empty State UI & Folder Selection ✅ -- Empty state component with styled UI -- Native folder picker integration -- IPC handlers for folder selection -- UI state management with SolidJS signals -- Loading states with spinner -- Keyboard shortcuts (Cmd/Ctrl+N) - -### Task 003: Process Manager ✅ -- Process spawning: `opencode serve --port 0` -- Port detection from stdout (regex: `opencode server listening on http://...`) -- Process lifecycle management (spawn, kill, cleanup) -- IPC communication for instance management -- Instance state tracking (starting → ready → stopped/error) -- Auto-cleanup on app quit -- Error handling & timeout protection (10s) -- Graceful shutdown (SIGTERM → SIGKILL) - -### Task 004: SDK Integration ✅ -- Installed `@opencode-ai/sdk` package -- SDK manager for client lifecycle -- Session fetching from OpenCode server -- Agent fetching (`client.app.agents()`) -- Provider fetching (`client.config.providers()`) -- Session store with SolidJS signals -- Instance store updated with SDK client -- Loading states for async operations -- Error handling for network failures - -### Task 005: Session Picker Modal ✅ -- Modal dialog with Kobalte Dialog -- Lists ALL existing sessions (scrollable) -- Session metadata display (title, relative timestamp) -- Native HTML select dropdown for agents -- Auto-selects first agent by default -- Create new session with selected agent -- Cancel button stops instance and closes modal -- Resume session on click -- Empty state for no sessions -- Loading state for agents -- Keyboard navigation (Escape to cancel) - -## Current State - -**Working Features:** -- ✅ App launches with empty state -- ✅ Folder selection via native dialog -- ✅ OpenCode server spawning per folder -- ✅ Port extraction and process tracking -- ✅ SDK client connection to running servers -- ✅ Session list fetching and display -- ✅ Agent and provider data fetching -- ✅ Session picker modal on instance creation -- ✅ Resume existing sessions -- ✅ Create new sessions with agent selection - -**File Structure:** -``` -packages/opencode-client/ -├── electron/ -│ ├── main/ -│ │ ├── main.ts (window + IPC setup) -│ │ ├── menu.ts (app menu) -│ │ ├── ipc.ts (instance IPC handlers) -│ │ └── process-manager.ts (server spawning) -│ └── preload/ -│ └── index.ts (IPC bridge) -├── src/ -│ ├── components/ -│ │ ├── empty-state.tsx -│ │ └── session-picker.tsx -│ ├── lib/ -│ │ └── sdk-manager.ts -│ ├── stores/ -│ │ ├── ui.ts -│ │ ├── instances.ts -│ │ └── sessions.ts -│ ├── types/ -│ │ ├── electron.d.ts -│ │ ├── instance.ts -│ │ └── session.ts -│ └── App.tsx -├── tasks/ -│ ├── done/ (001-005) -│ └── todo/ (006+) -└── docs/ -``` - -## Next Steps - -### Task 006: Message Stream UI (NEXT) -- Message display component -- User/assistant message rendering -- Markdown support with syntax highlighting -- Tool use visualization -- Auto-scroll behavior - -### Task 007: Prompt Input -- Text input with multi-line support -- Send button -- File attachment support -- Keyboard shortcuts (Enter for new line; Cmd+Enter/Ctrl+Enter to send) - -### Task 008: Instance Tabs -- Tab bar for multiple instances -- Switch between instances -- Close instance tabs -- "+" button for new instance - -## Build & Test - -```bash -cd packages/opencode-client -bun run build -bunx electron . -``` - -**Known Issue:** -- Dev mode (`bun dev`) fails due to Bun workspace hoisting + electron-vite -- Workaround: Use production builds for testing - -## Dependencies - -- Electron 38 -- SolidJS 1.8 -- TailwindCSS 3.x -- @opencode-ai/sdk -- @kobalte/core (Dialog) -- Vite 5 -- TypeScript 5 - -## Stats - -- **Tasks completed:** 5/5 (Phase 1) -- **Files created:** 18+ -- **Lines of code:** ~1500+ -- **Build time:** ~7s -- **Bundle size:** 152KB (renderer) diff --git a/docs/scrs/SCR-2026-04-21-001-wake-lock-system-sleep-only.md b/docs/scrs/SCR-2026-04-21-001-wake-lock-system-sleep-only.md deleted file mode 100644 index 0bd0654d8..000000000 --- a/docs/scrs/SCR-2026-04-21-001-wake-lock-system-sleep-only.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -id: SCR-2026-04-21-001 -title: Wake lock should allow screen lock while preventing system sleep -status: draft ---- - -# Summary - -Refine wake-lock behavior so the product protects long-running active work from device/system sleep without intentionally keeping the display awake. The desired product experience is: users may lock the screen or let the display sleep, and in-platform work should continue whenever the platform can support that behavior. - -# Problem - -Current wake-lock behavior on desktop is oriented around display wake, which prevents normal screen lock or display sleep behavior on macOS and does not match the requested product outcome. The Product Owner wants wake lock to protect only against system/device sleep during active work, not against display sleep or screen lock. Scope includes Electron, Tauri, and web, with documented best-effort degradation where platform APIs cannot provide a system-sleep-only capability. - -# Requested Outcome - -- Allow the screen/display to sleep or lock normally while qualifying work is in progress. -- Prevent only system/device sleep during qualifying active work on platforms that support a system-sleep-only hold. -- Keep platform behavior aligned to a single product rule: never intentionally keep the display awake as a fallback for this feature. -- Apply the behavior across Electron, Tauri, and web using best-effort platform support with explicit limitation handling. - -# Product Scope - -## Active Work Definition - -For this change, **active work** means a user-initiated or product-initiated in-app operation that: - -- has started execution, -- is represented by the product as still in progress, -- is expected to continue without continuous foreground interaction, and -- would lose reliability or stop early if the device enters normal system sleep. - -Active work does **not** include: - -- the app merely being open or focused, -- idle viewing or reading states, -- paused, completed, failed, or cancelled work, -- states waiting indefinitely for new user input before further execution, or -- generic background presence without a currently running task. - -## Product Behavior Rule - -- When active work starts, the product may request a wake lock only if the platform can do so **without intentionally blocking screen lock or display sleep**. -- When active work ends, pauses, fails, is cancelled, or no longer needs protection, the product must release the wake lock promptly. -- The product intent is consistent across platforms, but implementation is **best-effort by platform capability**, not strict-identical by mechanism. - -## Fallback Policy - -- If a platform can provide **system-sleep-only** protection, the product should use it. -- If a platform can only provide a **display/screen wake** lock that keeps the screen awake, the product must **not** use that mode as a fallback for this feature. -- In unsupported or partially supported environments, the product should fall back to **no wake lock** rather than preserving the old display-wake behavior. -- Unsupported behavior must be treated as a documented platform limitation, not as a product failure. - -## Platform Expectations - -- **Electron:** In scope to use a system-sleep-only mode if available. -- **Tauri:** In scope to use a system-sleep-only mode if available through the chosen Tauri/native path. -- **Web:** Default expectation is unsupported or partially supported for this exact behavior unless a browser/runtime exposes a true system-sleep-only primitive. A screen wake lock that keeps the display awake is not an acceptable substitute. - -## Non-Goals - -- Keeping the display continuously awake during long-running work. -- Preserving current display-wake behavior on platforms where that is the only available wake-lock mode. -- Inventing platform-specific user settings to choose between display wake and system-sleep-only behavior as part of this SCR. - -# Acceptance Criteria - -- AC-1: The specification defines **active work** in user-observable product terms, including the states that do and do not qualify for wake-lock protection. -- AC-2: The specification defines a single cross-platform product rule: qualifying active work should protect against system sleep where possible, while screen lock and display sleep remain allowed. -- AC-3: The specification defines the fallback policy for unsupported platforms: if system-sleep-only protection is unavailable, the product must not substitute display/screen wake behavior and must instead degrade to no wake lock. -- AC-4: Platform expectations are documented for Electron, Tauri, and web, including the explicit expectation that web is best-effort and may remain unsupported for this exact behavior. -- AC-5: The specification defines wake-lock release expectations so protection ends promptly when qualifying active work is no longer running. -- AC-6: Any implementation derived from this SCR must document user-visible limitations for unsupported platforms in the appropriate product-facing documentation if final technical validation confirms those limitations. - -# Implementation Notes For Follow-On Technical Assessment - -- Electron and Tauri feasibility still requires technical validation of the exact API mode, lifecycle reliability, and background-execution behavior. -- Web feasibility still requires confirmation of browser/runtime support, permission constraints, visibility restrictions, and whether any supported runtime offers a true system-sleep-only primitive. -- If technical validation shows a desktop platform cannot provide system-sleep-only behavior safely, implementation should follow the fallback policy above rather than retaining display-wake behavior. diff --git a/packages/tauri-app/src-tauri/src/cli_manager.rs b/packages/tauri-app/src-tauri/src/cli_manager.rs index e44828f55..00675684b 100644 --- a/packages/tauri-app/src-tauri/src/cli_manager.rs +++ b/packages/tauri-app/src-tauri/src/cli_manager.rs @@ -1,3 +1,4 @@ +use crate::desktop_event_transport::DesktopEventStreamConfig; use crate::managed_node::resolve_bundled_node_binary; use dirs::home_dir; use parking_lot::Mutex; @@ -185,12 +186,13 @@ fn kill_process_tree_windows(pid: u32, force: bool) -> bool { } fn navigate_main(app: &AppHandle, url: &str) { if let Some(win) = app.webview_windows().get("main") { - let mut display = url.to_string(); + let final_url = augment_launch_url(url); + let mut display = final_url.clone(); if let Some(hash_index) = display.find('#') { display.replace_range(hash_index + 1.., "[REDACTED]"); } log_line(&format!("navigating main to {display}")); - if let Ok(parsed) = Url::parse(url) { + if let Ok(parsed) = Url::parse(&final_url) { let _ = win.navigate(parsed); } else { log_line("failed to parse URL for navigation"); @@ -200,6 +202,31 @@ fn navigate_main(app: &AppHandle, url: &str) { } } +fn augment_launch_url(base_url: &str) -> String { + let launch_query = std::env::var("CODENOMAD_UI_LAUNCH_QUERY") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + + let Some(launch_query) = launch_query else { + return base_url.to_string(); + }; + + if base_url.contains('?') { + return format!( + "{}&{}", + base_url, + launch_query.trim_start_matches(['?', '#']) + ); + } + + format!( + "{}?{}", + base_url, + launch_query.trim_start_matches(['?', '#']) + ) +} + fn extract_cookie_value(set_cookie: &str, name: &str) -> Option { let prefix = format!("{name}="); let cookie_kv = set_cookie.split(';').next()?.trim(); @@ -298,6 +325,15 @@ fn generate_auth_cookie_name() -> String { format!("{SESSION_COOKIE_NAME_PREFIX}_{pid}_{timestamp}") } +fn generate_transport_connection_id() -> String { + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let tid = std::thread::current().id(); + format!("tauri-{}-{:?}", ts, tid) +} + const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json"; #[derive(Debug, Deserialize)] @@ -456,6 +492,8 @@ pub struct CliProcessManager { job: Arc>>, ready: Arc, bootstrap_token: Arc>>, + session_cookie: Arc>>, + auth_cookie_name: Arc>>, } impl CliProcessManager { @@ -467,6 +505,8 @@ impl CliProcessManager { job: Arc::new(Mutex::new(None)), ready: Arc::new(AtomicBool::new(false)), bootstrap_token: Arc::new(Mutex::new(None)), + session_cookie: Arc::new(Mutex::new(None)), + auth_cookie_name: Arc::new(Mutex::new(None)), } } @@ -475,6 +515,8 @@ impl CliProcessManager { self.stop()?; self.ready.store(false, Ordering::SeqCst); *self.bootstrap_token.lock() = None; + *self.session_cookie.lock() = None; + *self.auth_cookie_name.lock() = None; { let mut status = self.status.lock(); status.state = CliState::Starting; @@ -491,6 +533,8 @@ impl CliProcessManager { let job_arc = self.job.clone(); let ready_flag = self.ready.clone(); let token_arc = self.bootstrap_token.clone(); + let session_cookie_arc = self.session_cookie.clone(); + let auth_cookie_name_arc = self.auth_cookie_name.clone(); thread::spawn(move || { if let Err(err) = Self::spawn_cli( app.clone(), @@ -500,6 +544,8 @@ impl CliProcessManager { job_arc, ready_flag, token_arc, + session_cookie_arc, + auth_cookie_name_arc, dev, ) { log_line(&format!("cli spawn failed: {err}")); @@ -594,6 +640,7 @@ impl CliProcessManager { status.port = None; status.url = None; status.error = None; + *self.session_cookie.lock() = None; Ok(()) } @@ -602,6 +649,26 @@ impl CliProcessManager { self.status.lock().clone() } + pub fn desktop_event_stream_config(&self) -> Option { + let base_url = self.status.lock().url.clone()?; + let events_url = format!("{}/api/events", base_url.trim_end_matches('/')); + let client_id = format!("tauri-{}", std::process::id()); + let cookie_name = self + .auth_cookie_name + .lock() + .clone() + .unwrap_or_else(|| SESSION_COOKIE_NAME_PREFIX.to_string()); + + Some(DesktopEventStreamConfig { + base_url, + events_url, + client_id, + connection_id: generate_transport_connection_id(), + cookie_name, + session_cookie: self.session_cookie.lock().clone(), + }) + } + fn spawn_cli( app: AppHandle, status: Arc>, @@ -609,6 +676,8 @@ impl CliProcessManager { #[cfg(windows)] job_holder: Arc>>, ready: Arc, bootstrap_token: Arc>>, + session_cookie: Arc>>, + auth_cookie_name_holder: Arc>>, dev: bool, ) -> anyhow::Result<()> { log_line("resolving CLI entry"); @@ -619,6 +688,7 @@ impl CliProcessManager { resolution.runner, resolution.entry, host )); let auth_cookie_name = Arc::new(generate_auth_cookie_name()); + *auth_cookie_name_holder.lock() = Some(auth_cookie_name.as_str().to_string()); let args = resolution.build_args(dev, &host, auth_cookie_name.as_str()); log_line(&format!("CLI args: {:?}", args)); if dev { @@ -723,6 +793,7 @@ impl CliProcessManager { let app_clone = app.clone(); let ready_clone = ready.clone(); let token_clone = bootstrap_token.clone(); + let session_cookie_clone = session_cookie.clone(); let auth_cookie_name_clone = auth_cookie_name.clone(); thread::spawn(move || { @@ -742,6 +813,7 @@ impl CliProcessManager { let status = status_clone.clone(); let ready = ready_clone.clone(); let token = token_clone.clone(); + let session_cookie = session_cookie_clone.clone(); let auth_cookie_name = auth_cookie_name_clone.clone(); thread::spawn(move || { Self::process_stream( @@ -751,6 +823,7 @@ impl CliProcessManager { &status, &ready, &token, + &session_cookie, auth_cookie_name.as_str(), ); }); @@ -761,6 +834,7 @@ impl CliProcessManager { let status = status_clone.clone(); let ready = ready_clone.clone(); let token = token_clone.clone(); + let session_cookie = session_cookie_clone.clone(); let auth_cookie_name = auth_cookie_name_clone.clone(); thread::spawn(move || { Self::process_stream( @@ -770,6 +844,7 @@ impl CliProcessManager { &status, &ready, &token, + &session_cookie, auth_cookie_name.as_str(), ); }); @@ -894,6 +969,7 @@ impl CliProcessManager { status: &Arc>, ready: &Arc, bootstrap_token: &Arc>>, + session_cookie: &Arc>>, auth_cookie_name: &str, ) { let mut buffer = String::new(); @@ -946,6 +1022,7 @@ impl CliProcessManager { status, ready, bootstrap_token, + session_cookie, auth_cookie_name, url, ); @@ -963,6 +1040,7 @@ impl CliProcessManager { status: &Arc>, ready: &Arc, bootstrap_token: &Arc>>, + session_cookie: &Arc>>, auth_cookie_name: &str, base_url: String, ) { @@ -995,6 +1073,7 @@ impl CliProcessManager { log_line(&format!("failed to set session cookie: {err}")); navigate_main(app, &format!("{base_url}/login")); } else { + *session_cookie.lock() = Some(session_id.clone()); navigate_main(app, &base_url); } } @@ -1215,31 +1294,37 @@ fn resolve_dev_entry(_app: &AppHandle) -> Option { } fn resolve_prod_entry(_app: &AppHandle) -> Option { + let base = workspace_root(); + let exe_dir = std::env::current_exe() + .ok() + .and_then(|exe| exe.parent().map(|dir| dir.to_path_buf())); + + first_existing(prod_entry_candidates(exe_dir, base)) +} + +fn prod_entry_candidates( + exe_dir: Option, + workspace: Option, +) -> Vec> { let mut candidates = Vec::new(); - if let Ok(exe) = std::env::current_exe() { - if let Some(dir) = exe.parent() { - candidates.push(Some(dir.join("resources/server/dist/bin.js"))); + if let Some(dir) = exe_dir { + candidates.push(Some(dir.join("resources/server/dist/bin.js"))); - let resources = dir.join("../Resources"); - candidates.push(Some(resources.join("server/dist/bin.js"))); - candidates.push(Some(resources.join("resources/server/dist/bin.js"))); + let resources = dir.join("../Resources"); + candidates.push(Some(resources.join("server/dist/bin.js"))); + candidates.push(Some(resources.join("resources/server/dist/bin.js"))); - let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")]; - for root in linux_resource_roots { - candidates.push(Some(root.join("server/dist/bin.js"))); - candidates.push(Some(root.join("resources/server/dist/bin.js"))); - } + let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")]; + for root in linux_resource_roots { + candidates.push(Some(root.join("server/dist/bin.js"))); + candidates.push(Some(root.join("resources/server/dist/bin.js"))); } } - let base = workspace_root(); - candidates.push( - base.as_ref() - .map(|p| p.join("packages/server/dist/bin.js")), - ); + candidates.push(workspace.map(|p| p.join("packages/server/dist/bin.js"))); - first_existing(candidates) + candidates } fn build_shell_command_string( @@ -1355,3 +1440,53 @@ fn normalize_path(path: PathBuf) -> String { rendered } } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex as StdMutex; + + static ENV_LOCK: StdMutex<()> = StdMutex::new(()); + + #[test] + fn prod_entry_candidates_prefer_exe_relative_before_workspace_fallback() { + let exe_dir = PathBuf::from("/opt/codenomad/bin"); + let workspace = PathBuf::from("/workspace/codenomad"); + + let candidates = prod_entry_candidates(Some(exe_dir.clone()), Some(workspace.clone())) + .into_iter() + .flatten() + .collect::>(); + + assert_eq!( + candidates.first(), + Some(&exe_dir.join("resources/server/dist/bin.js")) + ); + assert_eq!( + candidates.last(), + Some(&workspace.join("packages/server/dist/bin.js")) + ); + } + + #[test] + fn augment_launch_url_trims_leading_fragment_marker() { + let _guard = ENV_LOCK.lock().expect("env lock poisoned"); + std::env::set_var("CODENOMAD_UI_LAUNCH_QUERY", "#debug=true"); + + let augmented = augment_launch_url("http://127.0.0.1:3000"); + + std::env::remove_var("CODENOMAD_UI_LAUNCH_QUERY"); + assert_eq!(augmented, "http://127.0.0.1:3000?debug=true"); + } + + #[test] + fn augment_launch_url_trims_fragment_marker_when_query_exists() { + let _guard = ENV_LOCK.lock().expect("env lock poisoned"); + std::env::set_var("CODENOMAD_UI_LAUNCH_QUERY", "#debug=true"); + + let augmented = augment_launch_url("http://127.0.0.1:3000?existing=true"); + + std::env::remove_var("CODENOMAD_UI_LAUNCH_QUERY"); + assert_eq!(augmented, "http://127.0.0.1:3000?existing=true&debug=true"); + } +} diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport.rs new file mode 100644 index 000000000..372ca37c6 --- /dev/null +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport.rs @@ -0,0 +1,477 @@ +use parking_lot::Mutex; +use reqwest::blocking::{Client, Response}; +use reqwest::StatusCode; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::io::{BufRead, BufReader}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::mpsc::{self, RecvTimeoutError, SyncSender}; +use std::sync::Arc; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use tauri::{AppHandle, Emitter, Manager, Url}; + +mod assembler; +mod stream; +mod transport; + +use stream::*; +use transport::*; + +const EVENT_BATCH_NAME: &str = "desktop:event-batch"; +const EVENT_STATUS_NAME: &str = "desktop:event-stream-status"; +const FLUSH_INTERVAL_MS: u64 = 16; +const DELTA_STREAM_WINDOW_MS: u64 = 48; +const MAX_BATCH_EVENTS: usize = 256; +const DEFAULT_RECONNECT_INITIAL_DELAY_MS: u64 = 1_000; +const DEFAULT_RECONNECT_MAX_DELAY_MS: u64 = 10_000; +const DEFAULT_RECONNECT_MULTIPLIER: f64 = 2.0; +const STREAM_CONNECT_TIMEOUT_MS: u64 = 5_000; +const STREAM_TCP_KEEPALIVE_MS: u64 = 30_000; +const STREAM_STALL_TIMEOUT_MS: u64 = 30_000; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DesktopEventStreamConfig { + pub base_url: String, + pub events_url: String, + pub client_id: String, + pub connection_id: String, + pub cookie_name: String, + pub session_cookie: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct DesktopEventsStartRequest { + pub reconnect: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct DesktopEventReconnectPolicy { + pub initial_delay_ms: Option, + pub max_delay_ms: Option, + pub multiplier: Option, + pub max_attempts: Option, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DesktopEventsStartResult { + pub started: bool, + pub generation: Option, + pub reason: Option, +} + +#[derive(Clone, Debug, PartialEq)] +struct ResolvedDesktopEventReconnectPolicy { + initial_delay_ms: u64, + max_delay_ms: u64, + multiplier: f64, + max_attempts: Option, +} + +impl ResolvedDesktopEventReconnectPolicy { + fn resolve(policy: Option<&DesktopEventReconnectPolicy>) -> Self { + let initial_delay_ms = policy + .and_then(|value| value.initial_delay_ms) + .unwrap_or(DEFAULT_RECONNECT_INITIAL_DELAY_MS) + .max(1); + let max_delay_ms = policy + .and_then(|value| value.max_delay_ms) + .unwrap_or(DEFAULT_RECONNECT_MAX_DELAY_MS) + .max(initial_delay_ms); + let multiplier = policy + .and_then(|value| value.multiplier) + .filter(|value| value.is_finite() && *value >= 1.0) + .unwrap_or(DEFAULT_RECONNECT_MULTIPLIER); + let max_attempts = policy + .and_then(|value| value.max_attempts) + .filter(|value| *value > 0); + + Self { + initial_delay_ms, + max_delay_ms, + multiplier, + max_attempts, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +struct DesktopEventTransportConfig { + stream: DesktopEventStreamConfig, + reconnect: ResolvedDesktopEventReconnectPolicy, +} + +impl DesktopEventTransportConfig { + fn new(stream: DesktopEventStreamConfig, request: &DesktopEventsStartRequest) -> Self { + Self { + stream, + reconnect: ResolvedDesktopEventReconnectPolicy::resolve(request.reconnect.as_ref()), + } + } + + fn is_equivalent_start(&self, other: &Self) -> bool { + self.reconnect == other.reconnect + && self.stream.base_url == other.stream.base_url + && self.stream.events_url == other.stream.events_url + && self.stream.client_id == other.stream.client_id + && self.stream.cookie_name == other.stream.cookie_name + && self.stream.session_cookie == other.stream.session_cookie + } +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct WorkspaceEventBatchPayload { + generation: u64, + sequence: u64, + emitted_at: u128, + events: Vec, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct DesktopEventStreamStatusPayload { + generation: u64, + state: &'static str, + reconnect_attempt: u32, + terminal: bool, + reason: Option, + next_delay_ms: Option, + status_code: Option, + stats: DesktopEventTransportStats, +} + +#[derive(Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +struct DesktopEventTransportStats { + raw_events: u64, + emitted_events: u64, + emitted_batches: u64, + delta_coalesces: u64, + snapshot_coalesces: u64, + status_coalesces: u64, + superseded_deltas_dropped: u64, +} + +struct DesktopEventTransportState { + stop: Option>, + config: Option, +} + +pub struct DesktopEventTransportManager { + state: Arc>, + generation: Arc, +} + +enum ReaderMessage { + Activity, + Event(Value), + Ping(Value), + End(Option), +} + +enum PendingEntry { + Delta { + key: String, + scope: String, + event: Value, + started_at: Instant, + }, + Status { + key: String, + event: Value, + }, + Snapshot { + key: String, + event: Value, + }, + Event(Value), +} + +enum EventDeliveryPolicy { + CoalesceDelta(String), + CoalesceStatus(String), + CoalesceSnapshot(String), + Passthrough, +} + +enum OpenStreamErrorKind { + Unauthorized, + Http, + Transport, +} + +struct OpenStreamError { + kind: OpenStreamErrorKind, + message: String, + status_code: Option, +} + +#[derive(Default)] +struct PendingBatch { + events: Vec, +} + +impl DesktopEventTransportManager { + pub fn new() -> Self { + Self { + state: Arc::new(Mutex::new(DesktopEventTransportState { + stop: None, + config: None, + })), + generation: Arc::new(AtomicU64::new(0)), + } + } + + pub fn start( + &self, + app: AppHandle, + stream_config: Option, + request: Option, + ) -> DesktopEventsStartResult { + let Some(stream_config) = stream_config else { + return DesktopEventsStartResult { + started: false, + generation: None, + reason: Some("desktop event stream unavailable".to_string()), + }; + }; + + let request = request.unwrap_or_default(); + let transport_config = DesktopEventTransportConfig::new(stream_config, &request); + + let mut state = self.state.lock(); + if state + .config + .as_ref() + .is_some_and(|config| config.is_equivalent_start(&transport_config)) + { + if let Some(stop) = &state.stop { + if !stop.load(Ordering::SeqCst) { + return DesktopEventsStartResult { + started: true, + generation: Some(self.generation.load(Ordering::SeqCst)), + reason: None, + }; + } + } + } + + if let Some(stop) = state.stop.take() { + stop.store(true, Ordering::SeqCst); + } + + let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1; + let stop = Arc::new(AtomicBool::new(false)); + state.stop = Some(stop.clone()); + state.config = Some(transport_config.clone()); + let shared_generation = self.generation.clone(); + drop(state); + + thread::spawn(move || { + run_transport_loop(app, shared_generation, generation, stop, transport_config) + }); + + DesktopEventsStartResult { + started: true, + generation: Some(generation), + reason: None, + } + } + + pub fn stop(&self) { + let mut state = self.state.lock(); + if let Some(stop) = state.stop.take() { + stop.store(true, Ordering::SeqCst); + } + state.config = None; + self.generation.fetch_add(1, Ordering::SeqCst); + } +} + +fn classify_event(event: &Value) -> EventDeliveryPolicy { + if let Some(key) = delta_key(event) { + return EventDeliveryPolicy::CoalesceDelta(key); + } + + if let Some(key) = status_key(event) { + return EventDeliveryPolicy::CoalesceStatus(key); + } + + if let Some(key) = snapshot_key(event) { + return EventDeliveryPolicy::CoalesceSnapshot(key); + } + + EventDeliveryPolicy::Passthrough +} + +fn coalesced_payload_event<'a>(event: &'a Value) -> &'a Value { + if event.get("type").and_then(Value::as_str) == Some("instance.event") { + event.get("event").unwrap_or(event) + } else { + event + } +} + +fn coalesced_instance_id(event: &Value) -> &str { + event + .get("instanceId") + .and_then(Value::as_str) + .unwrap_or_default() +} + +fn snapshot_key(event: &Value) -> Option { + let instance_id = coalesced_instance_id(event); + let inner = coalesced_payload_event(event); + let inner_type = inner.get("type")?.as_str()?; + let props = inner.get("properties")?; + + match inner_type { + "message.part.updated" => { + let session_id = props + .get("part") + .and_then(|part| part.get("sessionID").or_else(|| part.get("sessionId"))) + .and_then(Value::as_str)?; + let message_id = props + .get("part") + .and_then(|part| part.get("messageID").or_else(|| part.get("messageId"))) + .and_then(Value::as_str)?; + let part_id = props + .get("part") + .and_then(|part| part.get("id")) + .and_then(Value::as_str)?; + + Some(format!( + "message.part.updated:{}:{}:{}:{}", + instance_id, session_id, message_id, part_id + )) + } + "message.updated" => { + let info = props.get("info")?; + let session_id = info + .get("sessionID") + .or_else(|| info.get("sessionId")) + .and_then(Value::as_str)?; + let message_id = info.get("id").and_then(Value::as_str)?; + + Some(format!( + "message.updated:{}:{}:{}", + instance_id, session_id, message_id + )) + } + "session.updated" | "session.status" => { + let session_id = props + .get("info") + .and_then(|info| info.get("id")) + .and_then(Value::as_str) + .or_else(|| { + props + .get("sessionID") + .or_else(|| props.get("sessionId")) + .and_then(Value::as_str) + })?; + + Some(format!("{}:{}:{}", inner_type, instance_id, session_id)) + } + _ => None, + } +} + +fn delta_scope(event: &Value) -> Option { + let instance_id = coalesced_instance_id(event); + let inner = coalesced_payload_event(event); + if inner.get("type")?.as_str()? != "message.part.delta" { + return None; + } + + let props = inner.get("properties")?; + let session_id = props + .get("sessionID") + .or_else(|| props.get("sessionId")) + .and_then(Value::as_str) + .unwrap_or_default(); + let message_id = props + .get("messageID") + .or_else(|| props.get("messageId")) + .and_then(Value::as_str)?; + let part_id = props + .get("partID") + .or_else(|| props.get("partId")) + .and_then(Value::as_str)?; + + Some(format!( + "message.part:{}:{}:{}:{}", + instance_id, session_id, message_id, part_id + )) +} + +fn delta_key(event: &Value) -> Option { + let scope = delta_scope(event)?; + let props = coalesced_payload_event(event).get("properties")?; + let field = props.get("field")?.as_str()?; + + Some(format!("{}:{}", scope, field)) +} + +fn snapshot_superseded_delta_scope(event: &Value) -> Option { + let instance_id = coalesced_instance_id(event); + let inner = coalesced_payload_event(event); + if inner.get("type")?.as_str()? != "message.part.updated" { + return None; + } + + let part = inner.get("properties")?.get("part")?; + let session_id = part + .get("sessionID") + .or_else(|| part.get("sessionId")) + .and_then(Value::as_str)?; + let message_id = part + .get("messageID") + .or_else(|| part.get("messageId")) + .and_then(Value::as_str)?; + let part_id = part.get("id")?.as_str()?; + + Some(format!( + "message.part:{}:{}:{}:{}", + instance_id, session_id, message_id, part_id + )) +} + +fn append_delta(target: &mut Value, event: &Value) { + let next_delta = coalesced_payload_event(event) + .get("properties") + .and_then(|value| value.get("delta")) + .and_then(Value::as_str) + .unwrap_or_default(); + + if let Some(existing_delta) = coalesced_payload_event_mut(target) + .and_then(|event| event.get_mut("properties")) + .and_then(Value::as_object_mut) + .and_then(|props| props.get_mut("delta")) + { + let combined = existing_delta.as_str().unwrap_or_default().to_string() + next_delta; + *existing_delta = Value::String(combined); + } +} + +fn coalesced_payload_event_mut(event: &mut Value) -> Option<&mut serde_json::Map> { + if event.get("type").and_then(Value::as_str) == Some("instance.event") { + event.get_mut("event").and_then(Value::as_object_mut) + } else { + event.as_object_mut() + } +} + +fn status_key(event: &Value) -> Option { + match event.get("type")?.as_str()? { + "instance.eventStatus" => Some(coalesced_instance_id(event).to_string()), + "session.status" => snapshot_key(event), + _ => None, + } +} + +#[cfg(test)] +mod tests; diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/assembler.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/assembler.rs new file mode 100644 index 000000000..f91bcb760 --- /dev/null +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport/assembler.rs @@ -0,0 +1,112 @@ +use super::*; + +impl PendingBatch { + pub(super) fn push(&mut self, event: Value, stats: &mut DesktopEventTransportStats) { + match classify_event(&event) { + EventDeliveryPolicy::CoalesceDelta(key) => { + let Some(scope) = delta_scope(&event) else { + self.events.push(PendingEntry::Event(event)); + return; + }; + + if let Some(PendingEntry::Delta { + key: existing_key, + event: existing_event, + .. + }) = self.events.last_mut() + { + if existing_key == &key { + append_delta(existing_event, &event); + stats.delta_coalesces = stats.delta_coalesces.saturating_add(1); + return; + } + } + + self.events.push(PendingEntry::Delta { + key, + scope, + event, + started_at: Instant::now(), + }); + } + EventDeliveryPolicy::CoalesceStatus(key) => { + if let Some(PendingEntry::Status { + key: existing_key, + event: existing_event, + }) = self.events.last_mut() + { + if existing_key == &key { + *existing_event = event; + stats.status_coalesces = stats.status_coalesces.saturating_add(1); + return; + } + } + + self.events.push(PendingEntry::Status { key, event }); + } + EventDeliveryPolicy::CoalesceSnapshot(key) => { + if let Some(part_scope) = snapshot_superseded_delta_scope(&event) { + let mut dropped = 0_u64; + while matches!( + self.events.last(), + Some(PendingEntry::Delta { scope, .. }) if scope == &part_scope + ) { + self.events.pop(); + dropped = dropped.saturating_add(1); + } + if dropped > 0 { + stats.superseded_deltas_dropped = + stats.superseded_deltas_dropped.saturating_add(dropped); + } + } + + if let Some(PendingEntry::Snapshot { + key: existing_key, + event: existing_event, + }) = self.events.last_mut() + { + if existing_key == &key { + *existing_event = event; + stats.snapshot_coalesces = stats.snapshot_coalesces.saturating_add(1); + return; + } + } + + self.events.push(PendingEntry::Snapshot { key, event }); + } + EventDeliveryPolicy::Passthrough => { + self.events.push(PendingEntry::Event(event)); + } + } + } + + pub(super) fn take_events(&mut self) -> Vec { + let pending = std::mem::take(&mut self.events); + pending + .into_iter() + .map(|entry| match entry { + PendingEntry::Delta { event, .. } => event, + PendingEntry::Status { event, .. } => event, + PendingEntry::Snapshot { event, .. } => event, + PendingEntry::Event(event) => event, + }) + .collect() + } + + pub(super) fn is_empty(&self) -> bool { + self.events.is_empty() + } + + pub(super) fn pending_len(&self) -> usize { + self.events.len() + } + + pub(super) fn should_hold_single_delta(&self, now: Instant) -> bool { + matches!( + self.events.as_slice(), + [PendingEntry::Delta { started_at, .. }] + if now.duration_since(*started_at) + < Duration::from_millis(DELTA_STREAM_WINDOW_MS) + ) + } +} diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs new file mode 100644 index 000000000..33737f9c8 --- /dev/null +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs @@ -0,0 +1,325 @@ +use super::*; +use reqwest::blocking::RequestBuilder; + +pub(super) fn build_stream_client() -> Result { + Client::builder() + .connect_timeout(Duration::from_millis(STREAM_CONNECT_TIMEOUT_MS)) + .tcp_keepalive(Duration::from_millis(STREAM_TCP_KEEPALIVE_MS)) + // Note: reqwest's blocking client doesn't expose a per-read timeout. + // The global `.timeout()` would kill the entire SSE stream, so we + // rely on: + // 1. tcp_keepalive to detect dead connections (OS will RST after + // several unacked probes, typically ~2 min). + // 2. Consumer-side stall detection (STREAM_STALL_TIMEOUT_MS). + // 3. Reader thread breaking on channel send error (consumer dropped). + .build() + .map_err(|error: reqwest::Error| OpenStreamError { + kind: OpenStreamErrorKind::Transport, + message: error.to_string(), + status_code: None, + }) +} + +pub(super) fn open_stream( + app: &AppHandle, + client: &Client, + config: &DesktopEventStreamConfig, +) -> Result { + let url = format!( + "{}?clientId={}&connectionId={}", + config.events_url, config.client_id, config.connection_id + ); + + let request = attach_session_cookie( + client.get(&url).header("Accept", "text/event-stream"), + app, + config, + ); + + let response = request.send().map_err(|error| OpenStreamError { + kind: OpenStreamErrorKind::Transport, + message: error.to_string(), + status_code: None, + })?; + + if response.status().is_success() { + return Ok(response); + } + + let status = response.status(); + let kind = if matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) { + OpenStreamErrorKind::Unauthorized + } else { + OpenStreamErrorKind::Http + }; + + Err(OpenStreamError { + kind, + message: format!("desktop event stream unavailable ({status})"), + status_code: Some(status.as_u16()), + }) +} + +fn resolve_session_cookie(app: &AppHandle, config: &DesktopEventStreamConfig) -> Option { + read_session_cookie_from_webview(app, &config.base_url, &config.cookie_name) + .or_else(|| config.session_cookie.clone()) + .filter(|value| !value.is_empty()) +} + +pub(super) fn attach_session_cookie( + request: RequestBuilder, + app: &AppHandle, + config: &DesktopEventStreamConfig, +) -> RequestBuilder { + attach_session_cookie_value( + request, + &config.cookie_name, + resolve_session_cookie(app, config).as_deref(), + ) +} + +fn attach_session_cookie_value( + request: RequestBuilder, + cookie_name: &str, + session_cookie: Option<&str>, +) -> RequestBuilder { + let Some(session_cookie) = session_cookie.filter(|value| !value.is_empty()) else { + return request; + }; + + request.header( + "Cookie", + format!( + "{}={}", + cookie_name, + encode_cookie_header_value(session_cookie) + ), + ) +} + +fn encode_cookie_header_value(value: &str) -> String { + let mut encoded = String::new(); + + for byte in value.bytes() { + if is_cookie_header_value_byte(byte) { + encoded.push(byte as char); + } else { + encoded.push_str(&format!("%{byte:02X}")); + } + } + + encoded +} + +fn is_cookie_header_value_byte(byte: u8) -> bool { + matches!( + byte, + b'!' | b'#'..=b'+' | b'-'..=b':' | b'<'..=b'[' | b']'..=b'~' + ) +} + +fn read_session_cookie_from_webview( + app: &AppHandle, + base_url: &str, + cookie_name: &str, +) -> Option { + let url = Url::parse(base_url).ok()?; + let host = url.host_str()?.to_ascii_lowercase(); + let path = url.path(); + let windows = app.webview_windows(); + let window = windows.get("main")?; + let cookies = window.cookies().ok()?; + cookies + .into_iter() + .filter(|cookie: &tauri::webview::cookie::Cookie<'static>| cookie.name() == cookie_name) + .filter(|cookie: &tauri::webview::cookie::Cookie<'static>| { + let Some(domain) = cookie.domain() else { + return true; + }; + + let normalized_domain = domain.trim_start_matches('.').to_ascii_lowercase(); + host == normalized_domain || host.ends_with(&format!(".{}", normalized_domain)) + }) + .filter(|cookie: &tauri::webview::cookie::Cookie<'static>| { + let Some(cookie_path) = cookie.path() else { + return true; + }; + + path.starts_with(cookie_path) + }) + .map(|cookie: tauri::webview::cookie::Cookie<'static>| cookie.value().to_string()) + .next() +} + +pub(super) fn read_sse( + response: Response, + tx: SyncSender, + stop: Arc, + generation_atomic: Arc, + generation: u64, +) { + let mut reader = BufReader::new(response); + let mut line = String::new(); + let mut event_name: Option = None; + let mut data_lines: Vec = Vec::new(); + + loop { + if stop.load(Ordering::SeqCst) || !generation_matches(&generation_atomic, generation) { + let _ = tx.send(ReaderMessage::End(Some("stopped".to_string()))); + return; + } + + line.clear(); + match reader.read_line(&mut line) { + Ok(0) => { + let _ = flush_sse_frame(&tx, &event_name, &data_lines); + let _ = tx.send(ReaderMessage::End(Some("stream closed".to_string()))); + return; + } + Ok(_) => { + if tx.send(ReaderMessage::Activity).is_err() { + return; // consumer dropped — stop reading + } + let trimmed = line.trim_end_matches(['\r', '\n']); + if handle_sse_line(trimmed, &mut event_name, &mut data_lines) { + if flush_sse_frame(&tx, &event_name, &data_lines).is_err() { + return; + } + event_name = None; + data_lines.clear(); + continue; + } + } + Err(error) => { + let _ = flush_sse_frame(&tx, &event_name, &data_lines); + let _ = tx.send(ReaderMessage::End(Some(error.to_string()))); + return; + } + } + } +} + +fn handle_sse_line( + trimmed: &str, + event_name: &mut Option, + data_lines: &mut Vec, +) -> bool { + if trimmed.is_empty() { + return true; + } + + if trimmed.starts_with(':') { + return false; + } + + if let Some(name) = trimmed.strip_prefix("event:") { + *event_name = Some(name.strip_prefix(' ').unwrap_or(name).to_string()); + return false; + } + + if let Some(data) = trimmed.strip_prefix("data:") { + data_lines.push(data.strip_prefix(' ').unwrap_or(data).to_string()); + } + + false +} + +fn flush_sse_frame( + tx: &SyncSender, + event_name: &Option, + lines: &[String], +) -> Result<(), ()> { + let Some(payload) = parse_sse_payload(lines) else { + return Ok(()); + }; + + if event_name.as_deref() == Some("codenomad.client.ping") { + tx.send(ReaderMessage::Ping(payload)).map_err(|_| ()) + } else { + tx.send(ReaderMessage::Event(payload)).map_err(|_| ()) + } +} + +fn parse_sse_payload(lines: &[String]) -> Option { + if lines.is_empty() { + return None; + } + + let payload = lines.join("\n").trim().to_string(); + if payload.is_empty() { + return None; + } + + serde_json::from_str::(&payload).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn named_ping_event_is_routed_to_ping_channel() { + let (tx, rx) = mpsc::sync_channel(1); + let mut event_name = None; + let mut data_lines = Vec::new(); + + assert!(!handle_sse_line( + "event: codenomad.client.ping", + &mut event_name, + &mut data_lines + )); + assert!(!handle_sse_line( + r#"data: {"ts":123}"#, + &mut event_name, + &mut data_lines + )); + assert!(handle_sse_line("", &mut event_name, &mut data_lines)); + + flush_sse_frame(&tx, &event_name, &data_lines).expect("ping frame should flush"); + + match rx.recv().expect("ping frame should be emitted") { + ReaderMessage::Ping(payload) => { + assert_eq!(payload.get("ts").and_then(Value::as_u64), Some(123)); + } + _ => panic!("expected ping frame"), + } + } + + #[test] + fn session_cookie_is_attached_to_requests() { + let request = attach_session_cookie_value( + Client::new().post("http://localhost/api/client-connections/pong"), + "codenomad_session", + Some("cookie-value"), + ) + .build() + .expect("request should build"); + + assert_eq!( + request + .headers() + .get("Cookie") + .and_then(|value| value.to_str().ok()), + Some("codenomad_session=cookie-value") + ); + } + + #[test] + fn session_cookie_value_is_encoded_before_header_attachment() { + let request = attach_session_cookie_value( + Client::new().post("http://localhost/api/client-connections/pong"), + "codenomad_session", + Some("safe;\r\nInjected=bad value"), + ) + .build() + .expect("request should build"); + + assert_eq!( + request + .headers() + .get("Cookie") + .and_then(|value| value.to_str().ok()), + Some("codenomad_session=safe%3B%0D%0AInjected=bad%20value") + ); + } +} diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/tests.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/tests.rs new file mode 100644 index 000000000..f2440a201 --- /dev/null +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport/tests.rs @@ -0,0 +1,374 @@ +use super::*; +use serde_json::json; + +fn fresh_stats() -> DesktopEventTransportStats { + DesktopEventTransportStats::default() +} + +fn stream_config(connection_id: &str) -> DesktopEventStreamConfig { + DesktopEventStreamConfig { + base_url: "http://127.0.0.1:4096".to_string(), + events_url: "http://127.0.0.1:4096/api/events".to_string(), + client_id: "tauri-test".to_string(), + connection_id: connection_id.to_string(), + cookie_name: "codenomad_session".to_string(), + session_cookie: Some("cookie-value".to_string()), + } +} + +fn delta_event(delta: &str) -> Value { + json!({ + "type": "instance.event", + "instanceId": "inst-1", + "event": { + "type": "message.part.delta", + "properties": { + "sessionID": "sess-1", + "messageID": "msg-1", + "partID": "part-1", + "field": "text", + "delta": delta, + } + } + }) +} + +fn delta_event_for(part_id: &str, delta: &str) -> Value { + json!({ + "type": "instance.event", + "instanceId": "inst-1", + "event": { + "type": "message.part.delta", + "properties": { + "sessionID": "sess-1", + "messageID": "msg-1", + "partID": part_id, + "field": "text", + "delta": delta, + } + } + }) +} + +fn direct_delta_event(delta: &str) -> Value { + json!({ + "type": "message.part.delta", + "properties": { + "sessionID": "sess-1", + "messageID": "msg-1", + "partID": "part-1", + "field": "text", + "delta": delta, + } + }) +} + +fn direct_message_part_updated_event(text: &str) -> Value { + json!({ + "type": "message.part.updated", + "properties": { + "part": { + "id": "part-1", + "type": "text", + "text": text, + "sessionID": "sess-1", + "messageID": "msg-1" + } + } + }) +} + +fn message_part_updated_event(text: &str) -> Value { + json!({ + "type": "instance.event", + "instanceId": "inst-1", + "event": { + "type": "message.part.updated", + "properties": { + "part": { + "id": "part-1", + "type": "text", + "text": text, + "sessionID": "sess-1", + "messageID": "msg-1" + } + } + } + }) +} + +#[test] +fn coalesces_message_part_delta_events() { + let mut pending = PendingBatch::default(); + let mut stats = fresh_stats(); + pending.push(delta_event("Hello"), &mut stats); + pending.push(delta_event(" world"), &mut stats); + + let events = pending.take_events(); + assert_eq!(events.len(), 1); + assert_eq!( + events[0]["event"]["properties"]["delta"].as_str(), + Some("Hello world") + ); +} + +#[test] +fn last_write_wins_for_status_events() { + let mut pending = PendingBatch::default(); + let mut stats = fresh_stats(); + pending.push( + json!({ + "type": "instance.eventStatus", + "instanceId": "inst-1", + "status": "connecting" + }), + &mut stats, + ); + pending.push( + json!({ + "type": "instance.eventStatus", + "instanceId": "inst-1", + "status": "connected" + }), + &mut stats, + ); + + let events = pending.take_events(); + assert_eq!(events.len(), 1); + assert_eq!(events[0]["status"].as_str(), Some("connected")); +} + +#[test] +fn last_write_wins_for_consecutive_snapshot_events() { + let mut pending = PendingBatch::default(); + let mut stats = fresh_stats(); + pending.push(message_part_updated_event("Hello"), &mut stats); + pending.push(message_part_updated_event("Hello world"), &mut stats); + + let events = pending.take_events(); + assert_eq!(events.len(), 1); + assert_eq!( + events[0]["event"]["properties"]["part"]["text"].as_str(), + Some("Hello world") + ); +} + +#[test] +fn interleaved_snapshot_keys_keep_order() { + let mut pending = PendingBatch::default(); + let mut stats = fresh_stats(); + pending.push(message_part_updated_event("A1"), &mut stats); + pending.push( + json!({ + "type": "instance.event", + "instanceId": "inst-1", + "event": { + "type": "message.part.updated", + "properties": { + "part": { + "id": "part-2", + "type": "text", + "text": "B1", + "sessionID": "sess-1", + "messageID": "msg-1" + } + } + } + }), + &mut stats, + ); + pending.push(message_part_updated_event("A2"), &mut stats); + + let events = pending.take_events(); + assert_eq!(events.len(), 3); + assert_eq!( + events[0]["event"]["properties"]["part"]["id"].as_str(), + Some("part-1") + ); + assert_eq!( + events[1]["event"]["properties"]["part"]["id"].as_str(), + Some("part-2") + ); + assert_eq!( + events[2]["event"]["properties"]["part"]["text"].as_str(), + Some("A2") + ); +} + +#[test] +fn snapshot_replaces_trailing_deltas_for_same_part() { + let mut pending = PendingBatch::default(); + let mut stats = fresh_stats(); + pending.push(delta_event("Hello"), &mut stats); + pending.push(message_part_updated_event("Hello world"), &mut stats); + + let events = pending.take_events(); + assert_eq!(events.len(), 1); + assert_eq!( + events[0]["event"]["type"].as_str(), + Some("message.part.updated") + ); + assert_eq!( + events[0]["event"]["properties"]["part"]["text"].as_str(), + Some("Hello world") + ); +} + +#[test] +fn structural_events_force_coalesced_flush_before_append() { + let mut pending = PendingBatch::default(); + let mut stats = fresh_stats(); + pending.push(delta_event("Hello"), &mut stats); + pending.push( + json!({ + "type": "instance.event", + "instanceId": "inst-1", + "event": { + "type": "message.updated", + "properties": { + "id": "msg-1" + } + } + }), + &mut stats, + ); + + let events = pending.take_events(); + assert_eq!(events.len(), 2); + assert_eq!( + events[0]["event"]["type"].as_str(), + Some("message.part.delta") + ); + assert_eq!(events[1]["event"]["type"].as_str(), Some("message.updated")); +} + +#[test] +fn interleaved_delta_keys_keep_order() { + let mut pending = PendingBatch::default(); + let mut stats = fresh_stats(); + pending.push(delta_event_for("part-1", "A1"), &mut stats); + pending.push(delta_event_for("part-2", "B1"), &mut stats); + pending.push(delta_event_for("part-1", "A2"), &mut stats); + + let events = pending.take_events(); + assert_eq!(events.len(), 3); + assert_eq!( + events[0]["event"]["properties"]["partID"].as_str(), + Some("part-1") + ); + assert_eq!( + events[0]["event"]["properties"]["delta"].as_str(), + Some("A1") + ); + assert_eq!( + events[1]["event"]["properties"]["partID"].as_str(), + Some("part-2") + ); + assert_eq!( + events[1]["event"]["properties"]["delta"].as_str(), + Some("B1") + ); + assert_eq!( + events[2]["event"]["properties"]["partID"].as_str(), + Some("part-1") + ); + assert_eq!( + events[2]["event"]["properties"]["delta"].as_str(), + Some("A2") + ); +} + +#[test] +fn reconnect_delay_grows_and_caps() { + let policy = ResolvedDesktopEventReconnectPolicy { + initial_delay_ms: 100, + max_delay_ms: 500, + multiplier: 2.0, + max_attempts: None, + }; + + assert_eq!(compute_reconnect_delay_ms(1, &policy), 100); + assert_eq!(compute_reconnect_delay_ms(2, &policy), 200); + assert_eq!(compute_reconnect_delay_ms(3, &policy), 400); + assert_eq!(compute_reconnect_delay_ms(4, &policy), 500); +} + +#[test] +fn holds_single_delta_within_stream_window() { + let pending = PendingBatch { + events: vec![PendingEntry::Delta { + key: "delta-key".to_string(), + scope: "delta-scope".to_string(), + event: delta_event("Hello"), + started_at: Instant::now(), + }], + }; + + assert!(pending.should_hold_single_delta(Instant::now())); +} + +#[test] +fn flushes_single_delta_after_stream_window() { + let started_at = Instant::now() - Duration::from_millis(DELTA_STREAM_WINDOW_MS + 1); + let pending = PendingBatch { + events: vec![PendingEntry::Delta { + key: "delta-key".to_string(), + scope: "delta-scope".to_string(), + event: delta_event("Hello"), + started_at, + }], + }; + + assert!(!pending.should_hold_single_delta(Instant::now())); +} + +#[test] +fn coalesces_direct_message_part_delta_events() { + let mut pending = PendingBatch::default(); + let mut stats = fresh_stats(); + pending.push(direct_delta_event("Hello"), &mut stats); + pending.push(direct_delta_event(" world"), &mut stats); + + let events = pending.take_events(); + assert_eq!(events.len(), 1); + assert_eq!( + events[0]["properties"]["delta"].as_str(), + Some("Hello world") + ); +} + +#[test] +fn direct_snapshot_replaces_trailing_direct_deltas_for_same_part() { + let mut pending = PendingBatch::default(); + let mut stats = fresh_stats(); + pending.push(direct_delta_event("Hello"), &mut stats); + pending.push(direct_message_part_updated_event("Hello world"), &mut stats); + + let events = pending.take_events(); + assert_eq!(events.len(), 1); + assert_eq!(events[0]["type"].as_str(), Some("message.part.updated")); + assert_eq!( + events[0]["properties"]["part"]["text"].as_str(), + Some("Hello world") + ); +} + +#[test] +fn equivalent_transport_start_ignores_fresh_connection_id() { + let request = DesktopEventsStartRequest::default(); + let first = DesktopEventTransportConfig::new(stream_config("conn-1"), &request); + let second = DesktopEventTransportConfig::new(stream_config("conn-2"), &request); + + assert!(first.is_equivalent_start(&second)); +} + +#[test] +fn equivalent_transport_start_detects_material_stream_changes() { + let request = DesktopEventsStartRequest::default(); + let first = DesktopEventTransportConfig::new(stream_config("conn-1"), &request); + let mut changed_stream = stream_config("conn-2"); + changed_stream.session_cookie = Some("other-cookie".to_string()); + let second = DesktopEventTransportConfig::new(changed_stream, &request); + + assert!(!first.is_equivalent_start(&second)); +} diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/transport.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/transport.rs new file mode 100644 index 000000000..5f0ed2314 --- /dev/null +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport/transport.rs @@ -0,0 +1,428 @@ +use super::*; + +fn send_connection_pong( + app: &AppHandle, + client: &Client, + config: &DesktopEventStreamConfig, + payload: &Value, +) { + let body = serde_json::json!({ + "clientId": config.client_id, + "connectionId": config.connection_id, + "pingTs": payload.get("ts").and_then(Value::as_u64), + }); + + let request = client + .post(format!( + "{}/api/client-connections/pong", + config.base_url.trim_end_matches('/') + )) + .json(&body); + + let _ = attach_session_cookie(request, app, config).send(); +} + +pub(super) fn run_transport_loop( + app: AppHandle, + generation_atomic: Arc, + generation: u64, + stop: Arc, + config: DesktopEventTransportConfig, +) { + let mut reconnect_attempt = 0_u32; + let mut stats = DesktopEventTransportStats::default(); + + let client = match build_stream_client() { + Ok(client) => client, + Err(error) => { + emit_status( + &app, + generation, + "error", + 0, + true, + Some(error.message), + None, + None, + &stats, + ); + return; + } + }; + + loop { + if stop.load(Ordering::SeqCst) || !generation_matches(&generation_atomic, generation) { + break; + } + + emit_status( + &app, + generation, + "connecting", + reconnect_attempt, + false, + None, + None, + None, + &stats, + ); + + match open_stream(&app, &client, &config.stream) { + Ok(response) => { + reconnect_attempt = 0; + emit_status( + &app, + generation, + "connected", + reconnect_attempt, + false, + None, + None, + None, + &stats, + ); + + let disconnect_reason = consume_stream( + &app, + &client, + &config.stream, + response, + &generation_atomic, + generation, + stop.clone(), + &mut stats, + ); + if stop.load(Ordering::SeqCst) + || !generation_matches(&generation_atomic, generation) + { + break; + } + + if !schedule_retry( + &app, + &generation_atomic, + generation, + stop.clone(), + &config.reconnect, + &mut reconnect_attempt, + "disconnected", + disconnect_reason, + None, + &stats, + ) { + break; + } + } + Err(error) => { + let state_name = match error.kind { + OpenStreamErrorKind::Unauthorized => "unauthorized", + OpenStreamErrorKind::Http | OpenStreamErrorKind::Transport => "error", + }; + + if !schedule_retry( + &app, + &generation_atomic, + generation, + stop.clone(), + &config.reconnect, + &mut reconnect_attempt, + state_name, + Some(error.message), + error.status_code, + &stats, + ) { + break; + } + } + } + } + + emit_status( + &app, + generation, + "stopped", + reconnect_attempt, + true, + None, + None, + None, + &stats, + ); +} + +fn schedule_retry( + app: &AppHandle, + generation_atomic: &Arc, + generation: u64, + stop: Arc, + policy: &ResolvedDesktopEventReconnectPolicy, + reconnect_attempt: &mut u32, + state_name: &'static str, + reason: Option, + status_code: Option, + stats: &DesktopEventTransportStats, +) -> bool { + *reconnect_attempt = reconnect_attempt.saturating_add(1); + let terminal = policy + .max_attempts + .map(|max_attempts| *reconnect_attempt >= max_attempts) + .unwrap_or(false); + let next_delay_ms = if terminal { + None + } else { + Some(compute_reconnect_delay_ms(*reconnect_attempt, policy)) + }; + + emit_status( + app, + generation, + state_name, + *reconnect_attempt, + terminal, + reason, + next_delay_ms, + status_code, + stats, + ); + + if terminal { + return false; + } + + if let Some(delay_ms) = next_delay_ms { + wait_with_cancellation(generation_atomic, generation, stop, delay_ms); + } + + true +} + +fn wait_with_cancellation( + generation_atomic: &Arc, + generation: u64, + stop: Arc, + delay_ms: u64, +) { + let mut remaining_ms = delay_ms; + while remaining_ms > 0 { + if stop.load(Ordering::SeqCst) || !generation_matches(generation_atomic, generation) { + return; + } + + let chunk_ms = remaining_ms.min(100); + thread::sleep(Duration::from_millis(chunk_ms)); + remaining_ms -= chunk_ms; + } +} + +fn consume_stream( + app: &AppHandle, + client: &Client, + stream_config: &DesktopEventStreamConfig, + response: Response, + generation_atomic: &Arc, + generation: u64, + stop: Arc, + stats: &mut DesktopEventTransportStats, +) -> Option { + let (tx, rx) = mpsc::sync_channel::(4096); + let reader_stop = stop.clone(); + let reader_generation_atomic = generation_atomic.clone(); + thread::spawn(move || { + read_sse( + response, + tx, + reader_stop, + reader_generation_atomic, + generation, + ) + }); + + let mut pending = PendingBatch::default(); + let mut sequence = 0_u64; + let mut last_reader_activity = Instant::now(); + + loop { + if stop.load(Ordering::SeqCst) || !generation_matches(generation_atomic, generation) { + return Some("stopped".to_string()); + } + + match rx.recv_timeout(Duration::from_millis(FLUSH_INTERVAL_MS)) { + Ok(ReaderMessage::Activity) => { + last_reader_activity = Instant::now(); + } + Ok(ReaderMessage::Ping(payload)) => { + last_reader_activity = Instant::now(); + send_connection_pong(app, client, stream_config, &payload); + } + Ok(ReaderMessage::Event(event)) => { + last_reader_activity = Instant::now(); + stats.raw_events = stats.raw_events.saturating_add(1); + + pending.push(event, stats); + if pending.pending_len() >= MAX_BATCH_EVENTS { + emit_pending_batch( + app, + generation, + &mut pending, + &mut sequence, + generation_atomic, + stats, + ); + } + } + Ok(ReaderMessage::End(reason)) => { + if !pending.is_empty() { + emit_pending_batch( + app, + generation, + &mut pending, + &mut sequence, + generation_atomic, + stats, + ); + } + return reason; + } + Err(RecvTimeoutError::Timeout) => { + if last_reader_activity.elapsed() >= Duration::from_millis(STREAM_STALL_TIMEOUT_MS) + { + if !pending.is_empty() { + sequence += 1; + emit_batch( + app, + generation, + &mut pending, + sequence, + generation_atomic, + stats, + ); + } + return Some("stream stalled".to_string()); + } + + if !pending.is_empty() { + if pending.should_hold_single_delta(Instant::now()) { + continue; + } + emit_pending_batch( + app, + generation, + &mut pending, + &mut sequence, + generation_atomic, + stats, + ); + } + } + Err(RecvTimeoutError::Disconnected) => { + if !pending.is_empty() { + emit_pending_batch( + app, + generation, + &mut pending, + &mut sequence, + generation_atomic, + stats, + ); + } + return Some("reader disconnected".to_string()); + } + } + } +} + +fn emit_pending_batch( + app: &AppHandle, + generation: u64, + pending: &mut PendingBatch, + sequence: &mut u64, + generation_atomic: &Arc, + stats: &mut DesktopEventTransportStats, +) { + if pending.is_empty() { + return; + } + + *sequence += 1; + emit_batch( + app, + generation, + pending, + *sequence, + generation_atomic, + stats, + ); +} + +fn emit_batch( + app: &AppHandle, + generation: u64, + pending: &mut PendingBatch, + sequence: u64, + generation_atomic: &Arc, + stats: &mut DesktopEventTransportStats, +) { + if !generation_matches(generation_atomic, generation) { + return; + } + + let events = pending.take_events(); + if events.is_empty() { + return; + } + + stats.emitted_batches = stats.emitted_batches.saturating_add(1); + stats.emitted_events = stats.emitted_events.saturating_add(events.len() as u64); + + let _ = app.emit( + EVENT_BATCH_NAME, + WorkspaceEventBatchPayload { + generation, + sequence, + emitted_at: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(), + events, + }, + ); +} + +fn emit_status( + app: &AppHandle, + generation: u64, + state_name: &'static str, + reconnect_attempt: u32, + terminal: bool, + reason: Option, + next_delay_ms: Option, + status_code: Option, + stats: &DesktopEventTransportStats, +) { + let _ = app.emit( + EVENT_STATUS_NAME, + DesktopEventStreamStatusPayload { + generation, + state: state_name, + reconnect_attempt, + terminal, + reason, + next_delay_ms, + status_code, + stats: stats.clone(), + }, + ); +} + +pub(super) fn generation_matches(generation_atomic: &Arc, generation: u64) -> bool { + generation_atomic.load(Ordering::SeqCst) == generation +} + +pub(super) fn compute_reconnect_delay_ms( + attempt: u32, + policy: &ResolvedDesktopEventReconnectPolicy, +) -> u64 { + let exponent = attempt.saturating_sub(1) as i32; + let scaled = (policy.initial_delay_ms as f64) * policy.multiplier.powi(exponent); + (scaled.round().max(policy.initial_delay_ms as f64) as u64).min(policy.max_delay_ms) +} diff --git a/packages/tauri-app/src-tauri/src/main.rs b/packages/tauri-app/src-tauri/src/main.rs index 43fccc43a..7ad5d7c2a 100644 --- a/packages/tauri-app/src-tauri/src/main.rs +++ b/packages/tauri-app/src-tauri/src/main.rs @@ -3,11 +3,13 @@ #[allow(dead_code)] mod cert_manager; mod cli_manager; +mod desktop_event_transport; #[cfg(target_os = "linux")] mod linux_tls; mod managed_node; use cli_manager::{CliProcessManager, CliStatus}; +use desktop_event_transport::{DesktopEventTransportManager, DesktopEventsStartRequest, DesktopEventsStartResult}; use keepawake::KeepAwake; use serde::Deserialize; use serde_json::json; @@ -49,6 +51,7 @@ const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client"; pub struct AppState { pub manager: CliProcessManager, + pub desktop_events: DesktopEventTransportManager, pub wake_lock: Mutex>, pub zoom_level: Mutex, pub remote_origins: Mutex>, @@ -133,6 +136,7 @@ fn cli_get_status(state: tauri::State) -> CliStatus { #[tauri::command] fn cli_restart(app: AppHandle, state: tauri::State) -> Result { let dev_mode = is_dev_mode(); + state.desktop_events.stop(); state.manager.stop().map_err(|e| e.to_string())?; state .manager @@ -141,6 +145,21 @@ fn cli_restart(app: AppHandle, state: tauri::State) -> Result, + request: Option, +) -> DesktopEventsStartResult { + let config = state.manager.desktop_event_stream_config(); + state.desktop_events.start(app, config, request) +} + +#[tauri::command] +fn desktop_events_stop(state: tauri::State) { + state.desktop_events.stop(); +} + #[tauri::command] fn wake_lock_start( state: tauri::State, @@ -563,6 +582,7 @@ fn main() { .plugin(navigation_guard) .manage(AppState { manager: CliProcessManager::new(), + desktop_events: DesktopEventTransportManager::new(), wake_lock: Mutex::new(None), zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL), remote_origins: Mutex::new(HashMap::new()), @@ -617,6 +637,8 @@ fn main() { .invoke_handler(tauri::generate_handler![ cli_get_status, cli_restart, + desktop_events_start, + desktop_events_stop, wake_lock_start, wake_lock_stop, needs_local_certificate_install, @@ -722,6 +744,7 @@ fn main() { let app = app_handle.clone(); std::thread::spawn(move || { if let Some(state) = app.try_state::() { + state.desktop_events.stop(); let _ = state.manager.stop(); } app.exit(0); @@ -773,6 +796,7 @@ fn main() { let app = app_handle.clone(); std::thread::spawn(move || { if let Some(state) = app.try_state::() { + state.desktop_events.stop(); let _ = state.manager.stop(); } app.exit(0); diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 7b8128ad8..259eff7c3 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -79,6 +79,8 @@ const App: Component = () => { const { preferences, recentFolders, + useTauriNativeEventTransport, + setUseTauriNativeEventTransport, serverSettings, recordWorkspaceLaunch, toggleShowThinkingBlocks, @@ -456,6 +458,8 @@ const App: Component = () => { const { commands: paletteCommands, executeCommand } = useCommands({ preferences, + useTauriNativeEventTransport, + setUseTauriNativeEventTransport, toggleAutoCleanupBlankSessions, toggleShowThinkingBlocks, toggleKeyboardShortcutHints, diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index ab8cbfb36..c0ad2ad6e 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -136,7 +136,6 @@ const InstanceShell2: Component = (props) => { activeSessions, activeSessionIdForInstance, activeSessionForInstance, - activeSessionDiffs, latestTodoState, tokenStats, backgroundProcessList, @@ -759,7 +758,6 @@ const InstanceShell2: Component = (props) => { instance={props.instance} activeSessionId={activeSessionIdForInstance} activeSession={activeSessionForInstance} - activeSessionDiffs={activeSessionDiffs} latestTodoState={latestTodoState} backgroundProcessList={backgroundProcessList} onOpenBackgroundOutput={openBackgroundOutput} @@ -828,7 +826,6 @@ const InstanceShell2: Component = (props) => { instance={props.instance} activeSessionId={activeSessionIdForInstance} activeSession={activeSessionForInstance} - activeSessionDiffs={activeSessionDiffs} latestTodoState={latestTodoState} backgroundProcessList={backgroundProcessList} onOpenBackgroundOutput={openBackgroundOutput} diff --git a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx index 874bc6da0..231c1834c 100644 --- a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx @@ -41,10 +41,7 @@ import { RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, - RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY, - RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY, RIGHT_PANEL_FILES_WORD_WRAP_KEY, - RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY, RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY, RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY, RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY, @@ -62,7 +59,6 @@ import { readStoredRightPanelTab, } from "../storage" -const LazyChangesTab = lazy(() => import("./tabs/ChangesTab")) const LazyGitChangesTab = lazy(() => import("./tabs/GitChangesTab")) const LazyFilesTab = lazy(() => import("./tabs/FilesTab")) const LazyStatusTab = lazy(() => import("./tabs/StatusTab")) @@ -79,7 +75,6 @@ interface RightPanelProps { activeSessionId: Accessor activeSession: Accessor - activeSessionDiffs: Accessor latestTodoState: Accessor backgroundProcessList: Accessor @@ -101,10 +96,9 @@ interface RightPanelProps { } const RightPanel: Component = (props) => { - const [rightPanelTab, setRightPanelTab] = createSignal(readStoredRightPanelTab("changes")) - const defaultStatusSectionIds = ["yolo-mode", "session-changes", "plan", "background-processes", "mcp", "lsp", "plugins"] + const [rightPanelTab, setRightPanelTab] = createSignal(readStoredRightPanelTab("git-changes")) + const defaultStatusSectionIds = ["yolo-mode", "plan", "background-processes", "mcp", "lsp", "plugins"] const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal(defaultStatusSectionIds) - const [selectedFile, setSelectedFile] = createSignal(null) const [browserPath, setBrowserPath] = createSignal(".") const [browserEntries, setBrowserEntries] = createSignal(null) @@ -131,17 +125,14 @@ const RightPanel: Component = (props) => { readStoredEnum(RIGHT_PANEL_FILES_WORD_WRAP_KEY, ["on", "off"] as const) ?? "off", ) - const [changesSplitWidth, setChangesSplitWidth] = createSignal(320) const [filesSplitWidth, setFilesSplitWidth] = createSignal(320) const [gitChangesSplitWidth, setGitChangesSplitWidth] = createSignal(320) - const [activeSplitResize, setActiveSplitResize] = createSignal<"changes" | "git-changes" | "files" | null>(null) + const [activeSplitResize, setActiveSplitResize] = createSignal<"git-changes" | "files" | null>(null) const [splitResizeStartX, setSplitResizeStartX] = createSignal(0) const [splitResizeStartWidth, setSplitResizeStartWidth] = createSignal(0) const [filesListOpen, setFilesListOpen] = createSignal(true) const [filesListTouched, setFilesListTouched] = createSignal(false) - const [changesListOpen, setChangesListOpen] = createSignal(true) - const [changesListTouched, setChangesListTouched] = createSignal(false) const [gitChangesListOpen, setGitChangesListOpen] = createSignal(true) const [gitChangesListTouched, setGitChangesListTouched] = createSignal(false) const [gitStagedOpen, setGitStagedOpen] = createSignal(true) @@ -149,11 +140,8 @@ const RightPanel: Component = (props) => { const listLayoutKey = createMemo(() => (props.isPhoneLayout() ? "phone" : "nonphone")) - const listOpenStorageKey = (tab: "changes" | "git-changes" | "files") => { + const listOpenStorageKey = (tab: "git-changes" | "files") => { const layout = listLayoutKey() - if (tab === "changes") { - return layout === "phone" ? RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY - } if (tab === "git-changes") { return layout === "phone" ? RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY @@ -174,7 +162,7 @@ const RightPanel: Component = (props) => { : RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY } - const persistListOpen = (tab: "changes" | "git-changes" | "files", value: boolean) => { + const persistListOpen = (tab: "git-changes" | "files", value: boolean) => { if (typeof window === "undefined") return window.localStorage.setItem(listOpenStorageKey(tab), value ? "true" : "false") } @@ -198,15 +186,6 @@ const RightPanel: Component = (props) => { setFilesListTouched(false) } - const changesPersisted = readStoredBool(listOpenStorageKey("changes")) - if (changesPersisted !== null) { - setChangesListOpen(changesPersisted) - setChangesListTouched(true) - } else { - setChangesListOpen(true) - setChangesListTouched(false) - } - const gitPersisted = readStoredBool(listOpenStorageKey("git-changes")) if (gitPersisted !== null) { setGitChangesListOpen(gitPersisted) @@ -271,19 +250,13 @@ const RightPanel: Component = (props) => { if (splitWidthsInitialized()) return if (!props.rightDrawerWidthInitialized()) return setSplitWidthsInitialized(true) - setChangesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY, 320))) setFilesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY, 320))) setGitChangesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY, 320))) }) - const persistSplitWidth = (mode: "changes" | "git-changes" | "files", width: number) => { + const persistSplitWidth = (mode: "git-changes" | "files", width: number) => { if (typeof window === "undefined") return - const key = - mode === "changes" - ? RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY - : mode === "git-changes" - ? RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY - : RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY + const key = mode === "git-changes" ? RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY : RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY window.localStorage.setItem(key, String(width)) } @@ -300,16 +273,14 @@ const RightPanel: Component = (props) => { const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl" const delta = (event.clientX - splitResizeStartX()) * (isRtl ? -1 : 1) const next = clampSplitWidth(splitResizeStartWidth() + delta) - if (mode === "changes") setChangesSplitWidth(next) - else if (mode === "git-changes") setGitChangesSplitWidth(next) + if (mode === "git-changes") setGitChangesSplitWidth(next) else setFilesSplitWidth(next) } function splitMouseUp() { const mode = activeSplitResize() if (mode) { - const width = - mode === "changes" ? changesSplitWidth() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth() + const width = mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth() persistSplitWidth(mode, width) } stopSplitResize() @@ -324,16 +295,14 @@ const RightPanel: Component = (props) => { const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl" const delta = (touch.clientX - splitResizeStartX()) * (isRtl ? -1 : 1) const next = clampSplitWidth(splitResizeStartWidth() + delta) - if (mode === "changes") setChangesSplitWidth(next) - else if (mode === "git-changes") setGitChangesSplitWidth(next) + if (mode === "git-changes") setGitChangesSplitWidth(next) else setFilesSplitWidth(next) } function splitTouchEnd() { const mode = activeSplitResize() if (mode) { - const width = - mode === "changes" ? changesSplitWidth() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth() + const width = mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth() persistSplitWidth(mode, width) } stopSplitResize() @@ -346,22 +315,20 @@ const RightPanel: Component = (props) => { onTouchEnd: splitTouchEnd, }) - const startSplitResize = (mode: "changes" | "git-changes" | "files", clientX: number) => { + const startSplitResize = (mode: "git-changes" | "files", clientX: number) => { if (typeof document === "undefined") return setActiveSplitResize(mode) setSplitResizeStartX(clientX) - setSplitResizeStartWidth( - mode === "changes" ? changesSplitWidth() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth(), - ) + setSplitResizeStartWidth(mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth()) splitPointerDrag.start() } - const handleSplitResizeMouseDown = (mode: "changes" | "git-changes" | "files") => (event: MouseEvent) => { + const handleSplitResizeMouseDown = (mode: "git-changes" | "files") => (event: MouseEvent) => { event.preventDefault() startSplitResize(mode, event.clientX) } - const handleSplitResizeTouchStart = (mode: "changes" | "git-changes" | "files") => (event: TouchEvent) => { + const handleSplitResizeTouchStart = (mode: "git-changes" | "files") => (event: TouchEvent) => { const touch = event.touches[0] if (!touch) return event.preventDefault() @@ -444,36 +411,6 @@ const RightPanel: Component = (props) => { setBrowserSelectedLoading(false) }) - const bestDiffFile = createMemo(() => { - const diffs = props.activeSessionDiffs() - if (!Array.isArray(diffs) || diffs.length === 0) return null - const best = diffs.reduce((currentBest, item) => { - const bestAdd = typeof (currentBest as any)?.additions === "number" ? (currentBest as any).additions : 0 - const bestDel = typeof (currentBest as any)?.deletions === "number" ? (currentBest as any).deletions : 0 - const bestScore = bestAdd + bestDel - - const add = typeof (item as any)?.additions === "number" ? (item as any).additions : 0 - const del = typeof (item as any)?.deletions === "number" ? (item as any).deletions : 0 - const score = add + del - - if (score > bestScore) return item - if (score < bestScore) return currentBest - return String(item.file || "").localeCompare(String((currentBest as any)?.file || "")) < 0 ? item : currentBest - }, diffs[0]) - return typeof (best as any)?.file === "string" ? (best as any).file : null - }) - - createEffect(() => { - const next = bestDiffFile() - if (!next) return - const diffs = props.activeSessionDiffs() - if (!Array.isArray(diffs) || diffs.length === 0) return - - const current = selectedFile() - if (current && diffs.some((d) => d.file === current)) return - setSelectedFile(next) - }) - const normalizeBrowserPath = (input: string) => { const raw = String(input || ".").trim() if (!raw || raw === "./") return "." @@ -644,22 +581,6 @@ const RightPanel: Component = (props) => { setBrowserSelectedDirty(false) }) - const handleSelectChangesFile = (file: string, closeList: boolean) => { - setSelectedFile(file) - if (closeList) { - setChangesListOpen(false) - } - } - - const toggleChangesList = () => { - setChangesListTouched(true) - setChangesListOpen((current) => { - const next = !current - persistListOpen("changes", next) - return next - }) - } - const toggleFilesList = () => { setFilesListTouched(true) setFilesListOpen((current) => { @@ -730,13 +651,6 @@ const RightPanel: Component = (props) => { const browserScopeKey = createMemo(() => `${props.instanceId}:${worktreeSlugForViewer()}`) const gitScopeKey = createMemo(() => `${props.instanceId}:git:${worktreeSlugForViewer()}`) - const openChangesTabFromStatus = (file?: string) => { - if (file) { - setSelectedFile(file) - } - setRightPanelTab("changes") - } - const handleAccordionChange = (values: string[]) => { setRightPanelExpandedItems(values) } @@ -774,15 +688,6 @@ const RightPanel: Component = (props) => {
-
- - }> - - - - }> = (props) => { instance={props.instance} activeSessionId={props.activeSessionId} activeSession={props.activeSession} - activeSessionDiffs={props.activeSessionDiffs} latestTodoState={props.latestTodoState} backgroundProcessList={props.backgroundProcessList} onOpenBackgroundOutput={props.onOpenBackgroundOutput} @@ -947,7 +826,6 @@ const RightPanel: Component = (props) => { onTerminateBackgroundProcess={props.onTerminateBackgroundProcess} expandedItems={rightPanelExpandedItems} onExpandedItemsChange={handleAccordionChange} - onOpenChangesTab={openChangesTabFromStatus} /> diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx deleted file mode 100644 index 6f6a5609b..000000000 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import { For, Show, Suspense, createMemo, lazy, type Accessor, type Component, type JSX } from "solid-js" - -import DiffToolbar from "../components/DiffToolbar" -import SplitFilePanel from "../components/SplitFilePanel" -import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types" - -const LazyMonacoDiffViewer = lazy(() => - import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })), -) - -interface ChangesTabProps { - t: (key: string, vars?: Record) => string - - instanceId: string - activeSessionId: Accessor - activeSessionDiffs: Accessor - - selectedFile: Accessor - onSelectFile: (file: string, closeList: boolean) => void - - diffViewMode: Accessor - diffContextMode: Accessor - diffWordWrapMode: Accessor - onViewModeChange: (mode: DiffViewMode) => void - onContextModeChange: (mode: DiffContextMode) => void - onWordWrapModeChange: (mode: DiffWordWrapMode) => void - - listOpen: Accessor - onToggleList: () => void - splitWidth: Accessor - onResizeMouseDown: (event: MouseEvent) => void - onResizeTouchStart: (event: TouchEvent) => void - isPhoneLayout: Accessor -} - -const ChangesTab: Component = (props) => { - const sessionId = createMemo(() => props.activeSessionId()) - const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info")) - const diffs = createMemo(() => (hasSession() ? props.activeSessionDiffs() : null)) - - const sorted = createMemo(() => { - const list = diffs() - if (!Array.isArray(list)) return [] - return [...list].sort((a, b) => String(a.file || "").localeCompare(String(b.file || ""))) - }) - - const totals = createMemo(() => { - return sorted().reduce( - (acc, item) => { - acc.additions += typeof item.additions === "number" ? item.additions : 0 - acc.deletions += typeof item.deletions === "number" ? item.deletions : 0 - return acc - }, - { additions: 0, deletions: 0 }, - ) - }) - - const mostChanged = createMemo(() => { - const items = sorted() - if (items.length === 0) return null - return items.reduce((best, item) => { - const bestAdd = typeof (best as any)?.additions === "number" ? (best as any).additions : 0 - const bestDel = typeof (best as any)?.deletions === "number" ? (best as any).deletions : 0 - const bestScore = bestAdd + bestDel - - const add = typeof (item as any)?.additions === "number" ? (item as any).additions : 0 - const del = typeof (item as any)?.deletions === "number" ? (item as any).deletions : 0 - const score = add + del - - if (score > bestScore) return item - if (score < bestScore) return best - return String(item.file || "").localeCompare(String((best as any)?.file || "")) < 0 ? item : best - }, items[0]) - }) - - const selectedFileData = createMemo(() => { - const currentSelected = props.selectedFile() - const items = sorted() - if (currentSelected) { - const match = items.find((f) => f.file === currentSelected) - if (match) return match - } - return mostChanged() - }) - - const scopeKey = createMemo(() => `${props.instanceId}:${hasSession() ? sessionId() : "no-session"}`) - - const emptyViewerMessage = createMemo(() => { - if (!hasSession()) return props.t("instanceShell.sessionChanges.noSessionSelected") - const currentDiffs = diffs() - if (currentDiffs === undefined) return props.t("instanceShell.sessionChanges.loading") - if (!Array.isArray(currentDiffs) || currentDiffs.length === 0) return props.t("instanceShell.sessionChanges.empty") - return props.t("instanceShell.filesShell.viewerEmpty") - }) - - const headerPath = createMemo(() => { - const file = selectedFileData() - return file?.file ? String(file.file) : props.t("instanceShell.rightPanel.tabs.changes") - }) - - const renderContent = (): JSX.Element => { - const sortedList = sorted() - const totalsValue = totals() - const selected = selectedFileData() - - const renderViewer = () => ( -
-
- 0 ? selected : null} - fallback={ -
- {emptyViewerMessage()} -
- } - > - {(file) => ( - - {props.t("instanceInfo.loading")} -
- } - > - - - )} - -
-
- ) - - const renderEmptyList = () => ( -
{emptyViewerMessage()}
- ) - - const renderListPanel = () => ( - 0} fallback={renderEmptyList()}> - - {(item) => ( -
{ - props.onSelectFile(item.file, props.isPhoneLayout()) - }} - > -
-
- {item.file} -
-
- +{item.additions} - -{item.deletions} -
-
-
- )} -
-
- ) - - const renderListOverlay = () => ( - 0} fallback={renderEmptyList()}> - - {(item) => ( -
{ - props.onSelectFile(item.file, true) - }} - title={item.file} - > -
-
- {item.file} -
-
- +{item.additions} - -{item.deletions} -
-
-
- )} -
-
- ) - - return ( - - - {headerPath()} - - -
- - +{totalsValue.additions} - - - -{totalsValue.deletions} - -
- -
- -
- - } - list={{ panel: renderListPanel, overlay: renderListOverlay }} - viewer={renderViewer()} - listOpen={props.listOpen()} - onToggleList={props.onToggleList} - splitWidth={props.splitWidth()} - onResizeMouseDown={props.onResizeMouseDown} - onResizeTouchStart={props.onResizeTouchStart} - isPhoneLayout={props.isPhoneLayout()} - overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.changes")} - /> - ) - } - - return <>{renderContent()} -} - -export default ChangesTab diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx index 7be3284c4..8dca954c1 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx @@ -24,7 +24,6 @@ interface StatusTabProps { activeSessionId: Accessor activeSession: Accessor - activeSessionDiffs: Accessor latestTodoState: Accessor @@ -36,7 +35,6 @@ interface StatusTabProps { expandedItems: Accessor onExpandedItemsChange: (values: string[]) => void - onOpenChangesTab: (file?: string) => void } const StatusTab: Component = (props) => { @@ -71,85 +69,6 @@ const StatusTab: Component = (props) => { ) } - const renderStatusSessionChanges = () => { - const sessionId = props.activeSessionId() - if (!sessionId || sessionId === "info") { - return ( -
- {props.t("instanceShell.sessionChanges.noSessionSelected")} -
- ) - } - - const diffs = props.activeSessionDiffs() - if (diffs === undefined) { - return ( -
- {props.t("instanceShell.sessionChanges.loading")} -
- ) - } - - if (!Array.isArray(diffs) || diffs.length === 0) { - return ( -
- {props.t("instanceShell.sessionChanges.empty")} -
- ) - } - - const sorted = [...diffs].sort((a, b) => String(a.file || "").localeCompare(String(b.file || ""))) - const totals = sorted.reduce( - (acc, item) => { - acc.additions += typeof item.additions === "number" ? item.additions : 0 - acc.deletions += typeof item.deletions === "number" ? item.deletions : 0 - return acc - }, - { additions: 0, deletions: 0 }, - ) - - return ( -
-
- {props.t("instanceShell.sessionChanges.filesChanged", { count: sorted.length })} - - {`+${totals.additions}`} - {`-${totals.deletions}`} - -
- -
-
- - {(item) => ( - - )} - -
-
-
- ) - } - const renderPlanSectionContent = () => { const sessionId = props.activeSessionId() if (!sessionId || sessionId === "info") { @@ -260,12 +179,6 @@ const StatusTab: Component = (props) => { tooltipKey: "instanceShell.rightPanel.sections.yoloMode.tooltip", render: renderYoloModeSection, }, - { - id: "session-changes", - labelKey: "instanceShell.rightPanel.sections.sessionChanges", - tooltipKey: "instanceShell.rightPanel.sections.sessionChanges.tooltip", - render: renderStatusSessionChanges, - }, { id: "plan", labelKey: "instanceShell.rightPanel.sections.plan", diff --git a/packages/ui/src/components/instance/shell/right-panel/types.ts b/packages/ui/src/components/instance/shell/right-panel/types.ts index a651383ad..aa407b6e0 100644 --- a/packages/ui/src/components/instance/shell/right-panel/types.ts +++ b/packages/ui/src/components/instance/shell/right-panel/types.ts @@ -1,4 +1,4 @@ -export type RightPanelTab = "changes" | "git-changes" | "files" | "status" +export type RightPanelTab = "git-changes" | "files" | "status" export type DiffViewMode = "split" | "unified" diff --git a/packages/ui/src/components/instance/shell/right-panel/useGitChanges.ts b/packages/ui/src/components/instance/shell/right-panel/useGitChanges.ts index dabdd1c92..de1899893 100644 --- a/packages/ui/src/components/instance/shell/right-panel/useGitChanges.ts +++ b/packages/ui/src/components/instance/shell/right-panel/useGitChanges.ts @@ -431,7 +431,7 @@ export function useGitChanges(options: UseGitChangesOptions) { if (event.type !== "instance.event") return if (event.instanceId !== options.instanceId) return const eventType = (event.event as { type?: unknown } | undefined)?.type - if (eventType !== "session.updated" && eventType !== "session.diff") return + if (eventType !== "session.updated") return void passiveRefreshGitStatus({ forceReloadSelectedDiff: true }) }) diff --git a/packages/ui/src/components/instance/shell/storage.ts b/packages/ui/src/components/instance/shell/storage.ts index 963f4838f..f50b5f4cb 100644 --- a/packages/ui/src/components/instance/shell/storage.ts +++ b/packages/ui/src/components/instance/shell/storage.ts @@ -12,11 +12,8 @@ export const LEFT_PIN_STORAGE_KEY = "opencode-session-left-drawer-pinned-v1" export const RIGHT_PIN_STORAGE_KEY = "opencode-session-right-drawer-pinned-v1" export const RIGHT_PANEL_TAB_STORAGE_KEY = "opencode-session-right-panel-tab-v2" export const LEGACY_RIGHT_PANEL_TAB_STORAGE_KEY = "opencode-session-right-panel-tab-v1" -export const RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY = "opencode-session-right-panel-changes-split-width-v1" export const RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY = "opencode-session-right-panel-files-split-width-v1" export const RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY = "opencode-session-right-panel-git-changes-split-width-v1" -export const RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-changes-list-open-nonphone-v1" -export const RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-changes-list-open-phone-v1" export const RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-files-list-open-nonphone-v1" export const RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-files-list-open-phone-v1" export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-git-changes-list-open-nonphone-v1" @@ -55,13 +52,13 @@ export function persistPinState(side: "left" | "right", value: boolean) { } export function readStoredRightPanelTab( - defaultValue: "changes" | "git-changes" | "files" | "status", -): "changes" | "git-changes" | "files" | "status" { + defaultValue: "git-changes" | "files" | "status", +): "git-changes" | "files" | "status" { if (typeof window === "undefined") return defaultValue const stored = window.localStorage.getItem(RIGHT_PANEL_TAB_STORAGE_KEY) if (stored === "status") return "status" - if (stored === "changes") return "changes" + if (stored === "changes") return "git-changes" if (stored === "git-changes") return "git-changes" if (stored === "files") return "files" @@ -69,7 +66,7 @@ export function readStoredRightPanelTab( const legacy = window.localStorage.getItem(LEGACY_RIGHT_PANEL_TAB_STORAGE_KEY) if (legacy === "status") return "status" if (legacy === "browser") return "files" - if (legacy === "files") return "changes" + if (legacy === "files") return "git-changes" return defaultValue } diff --git a/packages/ui/src/components/instance/shell/useInstanceSessionContext.ts b/packages/ui/src/components/instance/shell/useInstanceSessionContext.ts index 5eb98bf27..9b84ba869 100644 --- a/packages/ui/src/components/instance/shell/useInstanceSessionContext.ts +++ b/packages/ui/src/components/instance/shell/useInstanceSessionContext.ts @@ -27,7 +27,6 @@ type InstanceSessionContextState = { activeSessionIdForInstance: Accessor parentSessionIdForInstance: Accessor activeSessionForInstance: Accessor - activeSessionDiffs: Accessor // Usage / info summaries activeSessionUsage: Accessor @@ -77,11 +76,6 @@ export function useInstanceSessionContext(options: InstanceSessionContextOptions return activeSessions().get(sessionId) ?? null }) - const activeSessionDiffs = createMemo(() => { - const session = activeSessionForInstance() - return session?.diff - }) - const activeSessionUsage = createMemo(() => { const sessionId = activeSessionIdForInstance() if (!sessionId) return null @@ -161,7 +155,6 @@ export function useInstanceSessionContext(options: InstanceSessionContextOptions activeSessionIdForInstance, parentSessionIdForInstance, activeSessionForInstance, - activeSessionDiffs, activeSessionUsage, activeSessionInfoDetails, tokenStats, diff --git a/packages/ui/src/components/settings/appearance-settings-section.tsx b/packages/ui/src/components/settings/appearance-settings-section.tsx index 281366d67..cfb039817 100644 --- a/packages/ui/src/components/settings/appearance-settings-section.tsx +++ b/packages/ui/src/components/settings/appearance-settings-section.tsx @@ -17,6 +17,8 @@ export const AppearanceSettingsSection: Component = () => { const { themeMode, setThemeMode } = useTheme() const { preferences, + useTauriNativeEventTransport, + setUseTauriNativeEventTransport, updatePreferences, toggleShowThinkingBlocks, toggleKeyboardShortcutHints, @@ -36,6 +38,8 @@ export const AppearanceSettingsSection: Component = () => { const behaviorSettings = createMemo(() => getBehaviorSettings({ preferences, + useTauriNativeEventTransport, + setUseTauriNativeEventTransport, updatePreferences, toggleShowThinkingBlocks, toggleKeyboardShortcutHints, diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts index dfc512ea0..353360ea7 100644 --- a/packages/ui/src/lib/api-client.ts +++ b/packages/ui/src/lib/api-client.ts @@ -520,11 +520,15 @@ export const serverApi = { body: JSON.stringify({ ...identity, enabled }), }) }, - sendClientConnectionPong(payload: { clientId: string; connectionId: string; pingTs?: number }): Promise { - return request("/api/client-connections/pong", { + sendClientConnectionPong(payload: { clientId: string; connectionId: string; pingTs?: number }, signal?: AbortSignal): Promise { + const init: RequestInit = { method: "POST", body: JSON.stringify(payload), - }) + } + if (signal) { + init.signal = signal + } + return request("/api/client-connections/pong", init) }, fetchBackgroundProcessOutput( instanceId: string, diff --git a/packages/ui/src/lib/desktop-event-transport-preference.ts b/packages/ui/src/lib/desktop-event-transport-preference.ts new file mode 100644 index 000000000..1ee0ebcb6 --- /dev/null +++ b/packages/ui/src/lib/desktop-event-transport-preference.ts @@ -0,0 +1,25 @@ +export const TAURI_NATIVE_EVENT_TRANSPORT_STORAGE_KEY = "codenomad-use-tauri-native-event-transport" + +export function readUseTauriNativeEventTransportPreference(): boolean { + if (typeof window === "undefined") { + return true + } + + try { + return window.localStorage?.getItem(TAURI_NATIVE_EVENT_TRANSPORT_STORAGE_KEY) !== "0" + } catch { + return true + } +} + +export function writeUseTauriNativeEventTransportPreference(enabled: boolean): void { + if (typeof window === "undefined") { + return + } + + try { + window.localStorage?.setItem(TAURI_NATIVE_EVENT_TRANSPORT_STORAGE_KEY, enabled ? "1" : "0") + } catch { + // Ignore localStorage failures and keep the in-memory preference only. + } +} diff --git a/packages/ui/src/lib/event-transport-contract.ts b/packages/ui/src/lib/event-transport-contract.ts new file mode 100644 index 000000000..e4d91629c --- /dev/null +++ b/packages/ui/src/lib/event-transport-contract.ts @@ -0,0 +1,62 @@ +export interface DesktopEventTransportReconnectPolicy { + initialDelayMs: number + maxDelayMs: number + multiplier: number + maxAttempts?: number +} + +export interface DesktopEventTransportStartOptions { + reconnect?: Partial +} + +export type DesktopEventTransportState = + | "connecting" + | "connected" + | "disconnected" + | "unauthorized" + | "error" + | "stopped" + +export interface DesktopEventTransportStats { + rawEvents: number + emittedEvents: number + emittedBatches: number + deltaCoalesces: number + snapshotCoalesces: number + statusCoalesces: number + supersededDeltasDropped: number +} + +export interface DesktopEventTransportStatusPayload { + generation: number + state: DesktopEventTransportState + reconnectAttempt: number + terminal: boolean + reason?: string + nextDelayMs?: number + statusCode?: number + stats?: DesktopEventTransportStats +} + +export interface DesktopEventsStartResult { + started: boolean + generation?: number + reason?: string +} + +export const DEFAULT_DESKTOP_EVENT_RECONNECT_POLICY: DesktopEventTransportReconnectPolicy = { + initialDelayMs: 1000, + maxDelayMs: 10000, + multiplier: 2, +} + +export function resolveDesktopEventTransportStartOptions( + options?: DesktopEventTransportStartOptions, +): Required { + return { + reconnect: { + ...DEFAULT_DESKTOP_EVENT_RECONNECT_POLICY, + ...options?.reconnect, + }, + } +} diff --git a/packages/ui/src/lib/event-transport.ts b/packages/ui/src/lib/event-transport.ts new file mode 100644 index 000000000..24b69591d --- /dev/null +++ b/packages/ui/src/lib/event-transport.ts @@ -0,0 +1,76 @@ +import type { WorkspaceEventPayload } from "../../../server/src/api-types" +import { serverApi } from "./api-client" +import { + resolveDesktopEventTransportStartOptions, + type DesktopEventTransportStartOptions, +} from "./event-transport-contract" +import { readUseTauriNativeEventTransportPreference } from "./desktop-event-transport-preference" +import { getLogger } from "./logger" +import { runtimeEnv } from "./runtime-env" +import { connectTauriWorkspaceEvents } from "./native/desktop-events" + +const log = getLogger("sse") + +export interface WorkspaceEventTransportCallbacks { + onBatch: (events: WorkspaceEventPayload[]) => void + onError?: () => void + onOpen?: () => void + onPing?: (payload: { ts?: number }) => void +} + +export interface WorkspaceEventConnection { + disconnect: () => void +} + +async function connectBrowserWorkspaceEvents( + callbacks: WorkspaceEventTransportCallbacks, +): Promise { + const source = serverApi.connectEvents((event) => { + callbacks.onBatch([event]) + }, callbacks.onError, callbacks.onPing) + source.onopen = () => callbacks.onOpen?.() + return { + disconnect() { + source.close() + }, + } +} + +export async function connectWorkspaceEvents( + callbacks: WorkspaceEventTransportCallbacks, + options?: DesktopEventTransportStartOptions, +): Promise { + const nativeDesktopTransportEnabled = readUseTauriNativeEventTransportPreference() + + if ( + runtimeEnv.host === "tauri" && + runtimeEnv.windowContext === "local" && + nativeDesktopTransportEnabled + ) { + try { + const conn = await connectTauriWorkspaceEvents( + callbacks, + resolveDesktopEventTransportStartOptions(options), + ) + log.info("Event transport: rust-native (desktop_event_transport)") + return conn + } catch (error) { + log.warn("Failed to start native desktop event transport, falling back to browser EventSource", error) + } + } else if (runtimeEnv.host === "tauri" && runtimeEnv.windowContext === "remote") { + log.info("Event transport: browser-eventsource forced for remote Tauri window") + } else if (runtimeEnv.host === "tauri") { + log.info("Event transport: browser-eventsource forced by settings") + } + + log.info(`Event transport: browser-eventsource (host=${runtimeEnv.host})`) + return connectBrowserWorkspaceEvents(callbacks) +} + +export type { + DesktopEventsStartResult, + DesktopEventTransportReconnectPolicy, + DesktopEventTransportStartOptions, + DesktopEventTransportState, + DesktopEventTransportStatusPayload, +} from "./event-transport-contract" diff --git a/packages/ui/src/lib/hooks/use-commands.ts b/packages/ui/src/lib/hooks/use-commands.ts index 160cd87ff..734ceb4e7 100644 --- a/packages/ui/src/lib/hooks/use-commands.ts +++ b/packages/ui/src/lib/hooks/use-commands.ts @@ -29,6 +29,8 @@ function splitKeywords(key: string): string[] { export interface UseCommandsOptions { preferences: Accessor + useTauriNativeEventTransport: Accessor + setUseTauriNativeEventTransport: (next: boolean) => void toggleShowThinkingBlocks: () => void toggleKeyboardShortcutHints: () => void toggleShowMessageTimeline: () => void @@ -419,6 +421,8 @@ export function useCommands(options: UseCommandsOptions) { registerBehaviorCommands((command) => commandRegistry.register(command), { preferences: options.preferences, + useTauriNativeEventTransport: options.useTauriNativeEventTransport, + setUseTauriNativeEventTransport: options.setUseTauriNativeEventTransport, toggleShowThinkingBlocks: options.toggleShowThinkingBlocks, toggleKeyboardShortcutHints: options.toggleKeyboardShortcutHints, toggleShowMessageTimeline: options.toggleShowMessageTimeline, diff --git a/packages/ui/src/lib/i18n/messages/de/instance.ts b/packages/ui/src/lib/i18n/messages/de/instance.ts index b32ab4f64..02cd77ea8 100644 --- a/packages/ui/src/lib/i18n/messages/de/instance.ts +++ b/packages/ui/src/lib/i18n/messages/de/instance.ts @@ -89,7 +89,6 @@ export const instanceMessages = { "instanceShell.empty.description": "Senden Sie eine Nachricht, um eine neue Sitzung zu erstellen, oder wählen Sie eine bestehende Sitzung aus.", "instanceShell.rightPanel.title": "Status-Panel", - "instanceShell.rightPanel.tabs.changes": "Sitzungsänderungen", "instanceShell.rightPanel.tabs.gitChanges": "Git-Änderungen", "instanceShell.rightPanel.tabs.files": "Dateien", "instanceShell.rightPanel.tabs.status": "Status", @@ -109,8 +108,6 @@ export const instanceMessages = { "instanceShell.rightPanel.toast.saveError": "Datei konnte nicht gespeichert werden", "instanceShell.rightPanel.sections.yoloMode": "Yolo-Modus", "instanceShell.rightPanel.sections.yoloMode.tooltip": "Genehmigt Berechtigungsanfragen für die aktuelle Sitzung automatisch. Nur verwenden, wenn Sie den ausgeführten Tools vertrauen.", - "instanceShell.rightPanel.sections.sessionChanges": "Sitzungsänderungen", - "instanceShell.rightPanel.sections.sessionChanges.tooltip": "In der aktuellen Sitzung geänderte Dateien. Zeigt Hinzufügungen und Löschungen für jede Datei.", "instanceShell.rightPanel.sections.plan": "Plan", "instanceShell.rightPanel.sections.plan.tooltip": "Die Roadmap des Agenten für diese Sitzung. Verfolgt Aufgaben, Unteraufgaben und deren Abschlussstatus.", "instanceShell.rightPanel.sections.backgroundProcesses": "Hintergrund-Shells", @@ -122,12 +119,6 @@ export const instanceMessages = { "instanceShell.rightPanel.sections.plugins": "Plugins", "instanceShell.rightPanel.sections.plugins.tooltip": "Plugins, die die Benutzeroberfläche und das Serververhalten anpassen.", - "instanceShell.sessionChanges.noSessionSelected": "Wählen Sie eine Sitzung aus, um Änderungen zu sehen.", - "instanceShell.sessionChanges.loading": "Sitzungsänderungen werden abgerufen...", - "instanceShell.sessionChanges.empty": "Noch keine Sitzungsänderungen.", - "instanceShell.sessionChanges.filesChanged": "{count} Dateien geändert", - "instanceShell.sessionChanges.actions.show": "Änderungen anzeigen", - "instanceShell.gitChanges.noSessionSelected": "Wählen Sie eine Sitzung aus, um Git-Änderungen zu sehen.", "instanceShell.gitChanges.loading": "Git-Änderungen werden geladen...", "instanceShell.gitChanges.empty": "Noch keine Git-Änderungen.", diff --git a/packages/ui/src/lib/i18n/messages/en/instance.ts b/packages/ui/src/lib/i18n/messages/en/instance.ts index 153adaa43..efcff08e2 100644 --- a/packages/ui/src/lib/i18n/messages/en/instance.ts +++ b/packages/ui/src/lib/i18n/messages/en/instance.ts @@ -89,7 +89,6 @@ export const instanceMessages = { "instanceShell.empty.description": "Send a message below to create a new session, or select an existing session to continue.", "instanceShell.rightPanel.title": "Status Panel", - "instanceShell.rightPanel.tabs.changes": "Session Changes", "instanceShell.rightPanel.tabs.gitChanges": "Git Changes", "instanceShell.rightPanel.tabs.files": "Files", "instanceShell.rightPanel.tabs.status": "Status", @@ -109,8 +108,6 @@ export const instanceMessages = { "instanceShell.rightPanel.toast.saveError": "Failed to save file", "instanceShell.rightPanel.sections.yoloMode": "Yolo Mode", "instanceShell.rightPanel.sections.yoloMode.tooltip": "Automatically approves permission requests for the current session. Use it only when you trust the tools being run.", - "instanceShell.rightPanel.sections.sessionChanges": "Session Changes", - "instanceShell.rightPanel.sections.sessionChanges.tooltip": "Files modified in the current session. Shows additions and deletions for each file.", "instanceShell.rightPanel.sections.plan": "Plan", "instanceShell.rightPanel.sections.plan.tooltip": "The agent's roadmap for this session. Tracks tasks, subtasks, and their completion status.", "instanceShell.rightPanel.sections.backgroundProcesses": "Background Shells", @@ -122,12 +119,6 @@ export const instanceMessages = { "instanceShell.rightPanel.sections.plugins": "Plugins", "instanceShell.rightPanel.sections.plugins.tooltip": "Plugins that customize the UI and server behavior, adding features beyond MCP and LSP.", - "instanceShell.sessionChanges.noSessionSelected": "Select a session to view changes.", - "instanceShell.sessionChanges.loading": "Fetching session changes...", - "instanceShell.sessionChanges.empty": "No session changes yet.", - "instanceShell.sessionChanges.filesChanged": "{count} files changed", - "instanceShell.sessionChanges.actions.show": "Show changes", - "instanceShell.gitChanges.noSessionSelected": "Select a session to view git changes.", "instanceShell.gitChanges.loading": "Loading git changes...", "instanceShell.gitChanges.empty": "No git changes yet.", diff --git a/packages/ui/src/lib/i18n/messages/en/settings.ts b/packages/ui/src/lib/i18n/messages/en/settings.ts index 550463209..9a58dabc7 100644 --- a/packages/ui/src/lib/i18n/messages/en/settings.ts +++ b/packages/ui/src/lib/i18n/messages/en/settings.ts @@ -236,6 +236,8 @@ export const settingsMessages = { "settings.behavior.autoCleanup.subtitle": "Automatically clean up blank sessions when creating new ones.", "settings.behavior.keepUnseenSubagentIdle.title": "Keep subagent idle markers", "settings.behavior.keepUnseenSubagentIdle.subtitle": "Keep subagent idle markers visible until viewed instead of hiding them after 5 seconds.", + "settings.behavior.tauriNativeEventTransport.title": "Native Tauri event transport", + "settings.behavior.tauriNativeEventTransport.subtitle": "Use the Rust-native desktop event transport in Tauri. Disable this to fall back to the browser EventSource path.", "settings.behavior.promptVoiceInput.title": "Prompt voice input", "settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.", "settings.behavior.promptSubmit.title": "Enter to submit", diff --git a/packages/ui/src/lib/i18n/messages/es/instance.ts b/packages/ui/src/lib/i18n/messages/es/instance.ts index f01aa8de1..f98dc974b 100644 --- a/packages/ui/src/lib/i18n/messages/es/instance.ts +++ b/packages/ui/src/lib/i18n/messages/es/instance.ts @@ -89,7 +89,6 @@ export const instanceMessages = { "instanceShell.empty.description": "Envía un mensaje abajo para crear una nueva sesión, o selecciona una sesión existente para continuar.", "instanceShell.rightPanel.title": "Panel de estado", - "instanceShell.rightPanel.tabs.changes": "Cambios", "instanceShell.rightPanel.tabs.gitChanges": "Cambios de Git", "instanceShell.rightPanel.tabs.files": "Archivos", "instanceShell.rightPanel.tabs.status": "Estado", @@ -109,8 +108,6 @@ export const instanceMessages = { "instanceShell.rightPanel.toast.saveError": "Error al guardar el archivo", "instanceShell.rightPanel.sections.yoloMode": "Modo yolo", "instanceShell.rightPanel.sections.yoloMode.tooltip": "Aprueba automaticamente las solicitudes de permiso de la sesion actual. Usalo solo si confias en las herramientas que se estan ejecutando.", - "instanceShell.rightPanel.sections.sessionChanges": "Cambios de sesión", - "instanceShell.rightPanel.sections.sessionChanges.tooltip": "Archivos modificados en la sesión actual. Muestra las adiciones y eliminaciones de cada archivo.", "instanceShell.rightPanel.sections.plan": "Plan", "instanceShell.rightPanel.sections.plan.tooltip": "Hoja de ruta del agente para esta sesión. Realiza el seguimiento de tareas, subtareas y su estado de finalización.", "instanceShell.rightPanel.sections.backgroundProcesses": "Shells en segundo plano", @@ -122,12 +119,6 @@ export const instanceMessages = { "instanceShell.rightPanel.sections.plugins": "Plugins", "instanceShell.rightPanel.sections.plugins.tooltip": "Plugins que personalizan el comportamiento de la UI y del servidor, y añaden funciones más allá de MCP y LSP.", - "instanceShell.sessionChanges.noSessionSelected": "Selecciona una sesión para ver los cambios.", - "instanceShell.sessionChanges.loading": "Obteniendo cambios de la sesión...", - "instanceShell.sessionChanges.empty": "Aún no hay cambios.", - "instanceShell.sessionChanges.filesChanged": "{count} archivos cambiados", - "instanceShell.sessionChanges.actions.show": "Mostrar cambios", - "instanceShell.gitChanges.loading": "Cargando cambios de Git...", "instanceShell.gitChanges.empty": "Aún no hay cambios de Git.", "instanceShell.gitChanges.deleted": "Eliminado", diff --git a/packages/ui/src/lib/i18n/messages/es/settings.ts b/packages/ui/src/lib/i18n/messages/es/settings.ts index 2d662cc9e..c8f049ebd 100644 --- a/packages/ui/src/lib/i18n/messages/es/settings.ts +++ b/packages/ui/src/lib/i18n/messages/es/settings.ts @@ -235,6 +235,8 @@ export const settingsMessages = { "settings.behavior.autoCleanup.subtitle": "Limpia automaticamente las sesiones en blanco al crear nuevas.", "settings.behavior.keepUnseenSubagentIdle.title": "Mantener marcadores idle de subagentes", "settings.behavior.keepUnseenSubagentIdle.subtitle": "Mantiene visibles los marcadores idle de subagentes hasta verlos, en lugar de ocultarlos despues de 5 segundos.", + "settings.behavior.tauriNativeEventTransport.title": "Transporte de eventos nativo de Tauri", + "settings.behavior.tauriNativeEventTransport.subtitle": "Usa el transporte de eventos de escritorio nativo en Rust dentro de Tauri. Desactivalo para volver a la ruta browser EventSource.", "settings.behavior.promptVoiceInput.title": "Prompt voice input", "settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.", "settings.behavior.promptSubmit.title": "Enter para enviar", diff --git a/packages/ui/src/lib/i18n/messages/fr/instance.ts b/packages/ui/src/lib/i18n/messages/fr/instance.ts index 1f6efba1f..f726e1fa3 100644 --- a/packages/ui/src/lib/i18n/messages/fr/instance.ts +++ b/packages/ui/src/lib/i18n/messages/fr/instance.ts @@ -89,7 +89,6 @@ export const instanceMessages = { "instanceShell.empty.description": "Envoyez un message ci-dessous pour créer une nouvelle session, ou sélectionnez une session existante pour continuer.", "instanceShell.rightPanel.title": "Panneau d'état", - "instanceShell.rightPanel.tabs.changes": "Modifications", "instanceShell.rightPanel.tabs.gitChanges": "Changements Git", "instanceShell.rightPanel.tabs.files": "Fichiers", "instanceShell.rightPanel.tabs.status": "Statut", @@ -109,8 +108,6 @@ export const instanceMessages = { "instanceShell.rightPanel.toast.saveError": "Échec de l'enregistrement du fichier", "instanceShell.rightPanel.sections.yoloMode": "Mode yolo", "instanceShell.rightPanel.sections.yoloMode.tooltip": "Approuve automatiquement les demandes d'autorisation pour la session actuelle. A utiliser seulement si vous faites confiance aux outils executes.", - "instanceShell.rightPanel.sections.sessionChanges": "Changements de session", - "instanceShell.rightPanel.sections.sessionChanges.tooltip": "Fichiers modifiés dans la session actuelle. Affiche les ajouts et suppressions pour chaque fichier.", "instanceShell.rightPanel.sections.plan": "Plan", "instanceShell.rightPanel.sections.plan.tooltip": "Feuille de route de l'agent pour cette session. Suit les tâches et leur statut d'achèvement.", "instanceShell.rightPanel.sections.backgroundProcesses": "Shells en arrière-plan", @@ -122,12 +119,6 @@ export const instanceMessages = { "instanceShell.rightPanel.sections.plugins": "Plugins", "instanceShell.rightPanel.sections.plugins.tooltip": "Plugins qui personnalisent le comportement de l'UI et du serveur, ajoutant des fonctionnalités au-delà de MCP et LSP.", - "instanceShell.sessionChanges.noSessionSelected": "Sélectionnez une session pour voir les changements.", - "instanceShell.sessionChanges.loading": "Récupération des changements...", - "instanceShell.sessionChanges.empty": "Aucun changement pour l'instant.", - "instanceShell.sessionChanges.filesChanged": "{count} fichiers modifiés", - "instanceShell.sessionChanges.actions.show": "Afficher les changements", - "instanceShell.gitChanges.loading": "Chargement des changements Git...", "instanceShell.gitChanges.empty": "Aucun changement Git pour l'instant.", "instanceShell.gitChanges.deleted": "Supprimé", diff --git a/packages/ui/src/lib/i18n/messages/fr/settings.ts b/packages/ui/src/lib/i18n/messages/fr/settings.ts index 985f69b70..122c6a416 100644 --- a/packages/ui/src/lib/i18n/messages/fr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr/settings.ts @@ -235,6 +235,8 @@ export const settingsMessages = { "settings.behavior.autoCleanup.subtitle": "Nettoyer automatiquement les sessions vides lors de la creation de nouvelles.", "settings.behavior.keepUnseenSubagentIdle.title": "Garder les marqueurs inactifs des sous-agents", "settings.behavior.keepUnseenSubagentIdle.subtitle": "Garde les marqueurs inactifs des sous-agents visibles jusqu'a consultation au lieu de les masquer apres 5 secondes.", + "settings.behavior.tauriNativeEventTransport.title": "Transport d'evenements natif Tauri", + "settings.behavior.tauriNativeEventTransport.subtitle": "Utiliser le transport d'evenements desktop natif Rust dans Tauri. Desactivez-le pour revenir au chemin browser EventSource.", "settings.behavior.promptVoiceInput.title": "Prompt voice input", "settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.", "settings.behavior.promptSubmit.title": "Entrer pour envoyer", diff --git a/packages/ui/src/lib/i18n/messages/he/instance.ts b/packages/ui/src/lib/i18n/messages/he/instance.ts index c0e2141ef..0808ca991 100644 --- a/packages/ui/src/lib/i18n/messages/he/instance.ts +++ b/packages/ui/src/lib/i18n/messages/he/instance.ts @@ -89,7 +89,6 @@ export const instanceMessages = { "instanceShell.empty.description": "שלח הודעה למטה כדי ליצור סשן חדש, או בחר סשן קיים כדי להמשיך.", "instanceShell.rightPanel.title": "לוח סטטוס", - "instanceShell.rightPanel.tabs.changes": "שינויי סשן", "instanceShell.rightPanel.tabs.gitChanges": "שינויי Git", "instanceShell.rightPanel.tabs.files": "קבצים", "instanceShell.rightPanel.tabs.status": "סטטוס", @@ -109,8 +108,6 @@ export const instanceMessages = { "instanceShell.rightPanel.toast.saveError": "כשלון בשמירת הקובץ", "instanceShell.rightPanel.sections.yoloMode": "מצב Yolo", "instanceShell.rightPanel.sections.yoloMode.tooltip": "מאשר אוטומטית בקשות הרשאה עבור הסשן הנוכחי. השתמשו בזה רק אם אתם סומכים על הכלים שרצים.", - "instanceShell.rightPanel.sections.sessionChanges": "שינויי סשן", - "instanceShell.rightPanel.sections.sessionChanges.tooltip": "קבצים שהשתנו בסשן הנוכחי. מציג הוספות ומחיקות לכל קובץ.", "instanceShell.rightPanel.sections.plan": "תוכנית", "instanceShell.rightPanel.sections.plan.tooltip": "מפת הדרכים של הסוכן לסשן זה. עוקב אחר משימות, תת-משימות וסטטוס השלמתן.", "instanceShell.rightPanel.sections.backgroundProcesses": "מעטפות רקע", @@ -122,12 +119,6 @@ export const instanceMessages = { "instanceShell.rightPanel.sections.plugins": "תוספים", "instanceShell.rightPanel.sections.plugins.tooltip": "תוספים המתאימים אישית את הממשק ואת התנהגות השרת, ומוסיפים תכונות מעבר ל-MCP ו-LSP.", - "instanceShell.sessionChanges.noSessionSelected": "בחר סשן לצפייה בשינויים.", - "instanceShell.sessionChanges.loading": "מאחזר שינויי סשן...", - "instanceShell.sessionChanges.empty": "אין שינויי סשן עדיין.", - "instanceShell.sessionChanges.filesChanged": "{count} קבצים שונו", - "instanceShell.sessionChanges.actions.show": "הצג שינויים", - "instanceShell.filesShell.fileListTitle": "רשימת קבצים", "instanceShell.filesShell.mobileSelectorLabel": "בחר קובץ", "instanceShell.filesShell.mobileSelectorEmpty": "בחר קובץ", diff --git a/packages/ui/src/lib/i18n/messages/he/settings.ts b/packages/ui/src/lib/i18n/messages/he/settings.ts index 47b286d3b..ca2601592 100644 --- a/packages/ui/src/lib/i18n/messages/he/settings.ts +++ b/packages/ui/src/lib/i18n/messages/he/settings.ts @@ -235,6 +235,8 @@ export const settingsMessages = { "settings.behavior.autoCleanup.subtitle": "נקה אוטומטית סשנים ריקים בעת יצירת סשנים חדשים.", "settings.behavior.keepUnseenSubagentIdle.title": "השאר סמני idle של תתי-סוכנים", "settings.behavior.keepUnseenSubagentIdle.subtitle": "השאר סמני idle של תתי-סוכנים גלויים עד צפייה במקום להסתיר אותם אחרי 5 שניות.", + "settings.behavior.tauriNativeEventTransport.title": "תעבורת אירועים מקורית של Tauri", + "settings.behavior.tauriNativeEventTransport.subtitle": "השתמש בתעבורת האירועים השולחנית המקורית ב-Rust בתוך Tauri. כבה זאת כדי לחזור למסלול browser EventSource.", "settings.behavior.promptVoiceInput.title": "קלט קולי לפרומפט", "settings.behavior.promptVoiceInput.subtitle": "הצג את כפתור המיקרופון לקלט דיבור-לטקסט כאשר תכונת הקול מוגדרת.", "settings.behavior.promptSubmit.title": "Enter לשליחה", diff --git a/packages/ui/src/lib/i18n/messages/ja/instance.ts b/packages/ui/src/lib/i18n/messages/ja/instance.ts index ca6cd5511..6ff5833f1 100644 --- a/packages/ui/src/lib/i18n/messages/ja/instance.ts +++ b/packages/ui/src/lib/i18n/messages/ja/instance.ts @@ -89,7 +89,6 @@ export const instanceMessages = { "instanceShell.empty.description": "下にメッセージを送信して新しいセッションを作成するか、既存のセッションを選択して続行してください。", "instanceShell.rightPanel.title": "ステータスパネル", - "instanceShell.rightPanel.tabs.changes": "変更", "instanceShell.rightPanel.tabs.gitChanges": "Git 変更", "instanceShell.rightPanel.tabs.files": "ファイル", "instanceShell.rightPanel.tabs.status": "ステータス", @@ -109,8 +108,6 @@ export const instanceMessages = { "instanceShell.rightPanel.toast.saveError": "ファイルの保存に失敗しました", "instanceShell.rightPanel.sections.yoloMode": "Yoloモード", "instanceShell.rightPanel.sections.yoloMode.tooltip": "現在のセッションの権限リクエストを自動承認します。実行中のツールを信頼できる場合にのみ使用してください。", - "instanceShell.rightPanel.sections.sessionChanges": "セッション変更", - "instanceShell.rightPanel.sections.sessionChanges.tooltip": "現在のセッションで変更されたファイル。各ファイルの追加と削除を表示します。", "instanceShell.rightPanel.sections.plan": "計画", "instanceShell.rightPanel.sections.plan.tooltip": "このセッションにおけるエージェントのロードマップ。タスクやサブタスク、および完了状況を追跡します。", "instanceShell.rightPanel.sections.backgroundProcesses": "バックグラウンドシェル", @@ -122,12 +119,6 @@ export const instanceMessages = { "instanceShell.rightPanel.sections.plugins": "プラグイン", "instanceShell.rightPanel.sections.plugins.tooltip": "UI とサーバーの動作をカスタマイズし、MCP や LSP 以外の機能も追加できるプラグイン。", - "instanceShell.sessionChanges.noSessionSelected": "変更を表示するにはセッションを選択してください。", - "instanceShell.sessionChanges.loading": "変更を取得中...", - "instanceShell.sessionChanges.empty": "まだ変更はありません。", - "instanceShell.sessionChanges.filesChanged": "{count} 個のファイルが変更されました", - "instanceShell.sessionChanges.actions.show": "変更を表示", - "instanceShell.gitChanges.loading": "Git の変更を読み込み中...", "instanceShell.gitChanges.empty": "Git の変更はまだありません。", "instanceShell.gitChanges.deleted": "削除済み", diff --git a/packages/ui/src/lib/i18n/messages/ja/settings.ts b/packages/ui/src/lib/i18n/messages/ja/settings.ts index 3d6b4a17e..cd44eb4ee 100644 --- a/packages/ui/src/lib/i18n/messages/ja/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja/settings.ts @@ -235,6 +235,8 @@ export const settingsMessages = { "settings.behavior.autoCleanup.subtitle": "新しいセッション作成時に空のセッションを自動的にクリーンアップします。", "settings.behavior.keepUnseenSubagentIdle.title": "サブエージェントの idle マーカーを保持", "settings.behavior.keepUnseenSubagentIdle.subtitle": "サブエージェントの idle マーカーを 5 秒後に隠さず、表示するまで残します。", + "settings.behavior.tauriNativeEventTransport.title": "Tauri ネイティブイベント転送", + "settings.behavior.tauriNativeEventTransport.subtitle": "Tauri で Rust ネイティブのデスクトップイベント転送を使います。無効にすると browser EventSource 経路に戻ります。", "settings.behavior.promptVoiceInput.title": "Prompt voice input", "settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.", "settings.behavior.promptSubmit.title": "Enterで送信", diff --git a/packages/ui/src/lib/i18n/messages/ne/instance.ts b/packages/ui/src/lib/i18n/messages/ne/instance.ts index 4fa38473d..6d5f30cc9 100644 --- a/packages/ui/src/lib/i18n/messages/ne/instance.ts +++ b/packages/ui/src/lib/i18n/messages/ne/instance.ts @@ -89,7 +89,6 @@ export const instanceMessages = { "instanceShell.empty.description": "नयाँ सत्र सिर्जना गर्न तल सन्देश पठाउनुहोस्, वा जारी राख्न अवस्थित सत्र चयन गर्नुहोस्।", "instanceShell.rightPanel.title": "स्थिति प्यानल (Status Panel)", - "instanceShell.rightPanel.tabs.changes": "सत्र परिवर्तनहरू", "instanceShell.rightPanel.tabs.gitChanges": "Git परिवर्तनहरू", "instanceShell.rightPanel.tabs.files": "फाइलहरू", "instanceShell.rightPanel.tabs.status": "स्थिति", @@ -109,8 +108,6 @@ export const instanceMessages = { "instanceShell.rightPanel.toast.saveError": "फाइल बचत गर्न असफल भयो", "instanceShell.rightPanel.sections.yoloMode": "Yolo मोड", "instanceShell.rightPanel.sections.yoloMode.tooltip": "हालको सत्रको लागि अनुमति अनुरोधहरू स्वतः स्वीकृत गर्दछ। तपाईंले चलिरहेका उपकरणहरूलाई विश्वास गर्दा मात्र यसलाई प्रयोग गर्नुहोस्।", - "instanceShell.rightPanel.sections.sessionChanges": "सत्र परिवर्तनहरू", - "instanceShell.rightPanel.sections.sessionChanges.tooltip": "हालको सत्रमा परिमार्जन गरिएका फाइलहरू।", "instanceShell.rightPanel.sections.plan": "योजना", "instanceShell.rightPanel.sections.plan.tooltip": "यस सत्रको लागि एजेन्टको मार्गचित्र।", "instanceShell.rightPanel.sections.backgroundProcesses": "पृष्ठभूमि शेलहरू", @@ -122,12 +119,6 @@ export const instanceMessages = { "instanceShell.rightPanel.sections.plugins": "प्लगइनहरू", "instanceShell.rightPanel.sections.plugins.tooltip": "UI र सर्भर व्यवहार अनुकूलित गर्ने प्लगइनहरू।", - "instanceShell.sessionChanges.noSessionSelected": "परिवर्तनहरू हेर्न सत्र चयन गर्नुहोस्।", - "instanceShell.sessionChanges.loading": "सत्र परिवर्तनहरू प्राप्त गर्दै...", - "instanceShell.sessionChanges.empty": "अझै कुनै सत्र परिवर्तनहरू छैनन्।", - "instanceShell.sessionChanges.filesChanged": "{count} फाइलहरू परिवर्तन भए", - "instanceShell.sessionChanges.actions.show": "परिवर्तनहरू देखाउनुहोस्", - "instanceShell.gitChanges.noSessionSelected": "Git परिवर्तनहरू हेर्न सत्र चयन गर्नुहोस्।", "instanceShell.gitChanges.loading": "Git परिवर्तनहरू लोड गर्दै...", "instanceShell.gitChanges.empty": "अझै कुनै Git परिवर्तनहरू छैनन्।", diff --git a/packages/ui/src/lib/i18n/messages/ru/instance.ts b/packages/ui/src/lib/i18n/messages/ru/instance.ts index bce93442d..f76da0df7 100644 --- a/packages/ui/src/lib/i18n/messages/ru/instance.ts +++ b/packages/ui/src/lib/i18n/messages/ru/instance.ts @@ -89,7 +89,6 @@ export const instanceMessages = { "instanceShell.empty.description": "Отправьте сообщение ниже, чтобы создать новую сессию, или выберите существующую сессию, чтобы продолжить.", "instanceShell.rightPanel.title": "Панель состояния", - "instanceShell.rightPanel.tabs.changes": "Изменения", "instanceShell.rightPanel.tabs.gitChanges": "Изменения Git", "instanceShell.rightPanel.tabs.files": "Файлы", "instanceShell.rightPanel.tabs.status": "Статус", @@ -109,8 +108,6 @@ export const instanceMessages = { "instanceShell.rightPanel.toast.saveError": "Не удалось сохранить файл", "instanceShell.rightPanel.sections.yoloMode": "Режим Yolo", "instanceShell.rightPanel.sections.yoloMode.tooltip": "Автоматически одобряет запросы разрешений для текущей сессии. Включайте только если доверяете запускаемым инструментам.", - "instanceShell.rightPanel.sections.sessionChanges": "Изменения сессии", - "instanceShell.rightPanel.sections.sessionChanges.tooltip": "Файлы, измененные в текущей сессии. Показывает добавления и удаления для каждого файла.", "instanceShell.rightPanel.sections.plan": "План", "instanceShell.rightPanel.sections.plan.tooltip": "Дорожная карта агента для этой сессии. Отслеживает задачи и их статус выполнения.", "instanceShell.rightPanel.sections.backgroundProcesses": "Фоновые оболочки", @@ -122,12 +119,6 @@ export const instanceMessages = { "instanceShell.rightPanel.sections.plugins": "Плагины", "instanceShell.rightPanel.sections.plugins.tooltip": "Плагины, настраивающие поведение интерфейса и сервера, добавляющие функции поверх MCP и LSP.", - "instanceShell.sessionChanges.noSessionSelected": "Выберите сессию, чтобы просмотреть изменения.", - "instanceShell.sessionChanges.loading": "Загрузка изменений...", - "instanceShell.sessionChanges.empty": "Пока нет изменений.", - "instanceShell.sessionChanges.filesChanged": "Изменено файлов: {count}", - "instanceShell.sessionChanges.actions.show": "Показать изменения", - "instanceShell.gitChanges.loading": "Загрузка изменений Git...", "instanceShell.gitChanges.empty": "Изменений Git пока нет.", "instanceShell.gitChanges.deleted": "Удалено", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts index 70a378c9b..dc6dbd647 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts @@ -89,7 +89,6 @@ export const instanceMessages = { "instanceShell.empty.description": "在下方发送消息以创建新会话,或选择现有会话继续。", "instanceShell.rightPanel.title": "状态面板", - "instanceShell.rightPanel.tabs.changes": "更改", "instanceShell.rightPanel.tabs.gitChanges": "Git 更改", "instanceShell.rightPanel.tabs.files": "文件", "instanceShell.rightPanel.tabs.status": "状态", @@ -109,8 +108,6 @@ export const instanceMessages = { "instanceShell.rightPanel.toast.saveError": "保存文件失败", "instanceShell.rightPanel.sections.yoloMode": "Yolo 模式", "instanceShell.rightPanel.sections.yoloMode.tooltip": "自动批准当前会话的权限请求。仅在你信任正在运行的工具时启用。", - "instanceShell.rightPanel.sections.sessionChanges": "会话更改", - "instanceShell.rightPanel.sections.sessionChanges.tooltip": "当前会话中修改的文件。显示每个文件的添加和删除。", "instanceShell.rightPanel.sections.plan": "计划", "instanceShell.rightPanel.sections.plan.tooltip": "代理的路线图。跟踪任务、子任务及其完成状态。", "instanceShell.rightPanel.sections.backgroundProcesses": "后台 Shell", @@ -122,12 +119,6 @@ export const instanceMessages = { "instanceShell.rightPanel.sections.plugins": "插件", "instanceShell.rightPanel.sections.plugins.tooltip": "自定义 UI 和服务器行为的插件,添加超出 MCP 和 LSP 的功能。", - "instanceShell.sessionChanges.noSessionSelected": "选择会话以查看更改。", - "instanceShell.sessionChanges.loading": "正在获取会话更改...", - "instanceShell.sessionChanges.empty": "暂无会话更改。", - "instanceShell.sessionChanges.filesChanged": "已更改 {count} 个文件", - "instanceShell.sessionChanges.actions.show": "显示更改", - "instanceShell.gitChanges.loading": "正在加载 Git 更改...", "instanceShell.gitChanges.empty": "暂无 Git 更改。", "instanceShell.gitChanges.deleted": "已删除", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts index c1b827be8..91d2787e5 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts @@ -235,6 +235,8 @@ export const settingsMessages = { "settings.behavior.autoCleanup.subtitle": "创建新会话时自动清理空会话。", "settings.behavior.keepUnseenSubagentIdle.title": "保留子智能体 idle 标记", "settings.behavior.keepUnseenSubagentIdle.subtitle": "让子智能体 idle 标记保持可见直到查看,而不是 5 秒后隐藏。", + "settings.behavior.tauriNativeEventTransport.title": "Tauri 原生事件传输", + "settings.behavior.tauriNativeEventTransport.subtitle": "在 Tauri 中使用 Rust 原生桌面事件传输。禁用后将回退到浏览器 EventSource 路径。", "settings.behavior.promptVoiceInput.title": "Prompt voice input", "settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.", "settings.behavior.promptSubmit.title": "回车发送", diff --git a/packages/ui/src/lib/native/desktop-events.test.ts b/packages/ui/src/lib/native/desktop-events.test.ts new file mode 100644 index 000000000..c414b04b4 --- /dev/null +++ b/packages/ui/src/lib/native/desktop-events.test.ts @@ -0,0 +1,19 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import { createTerminalErrorNotifier } from "./desktop-events.ts" + +describe("createTerminalErrorNotifier", () => { + it("calls onError once for repeated terminal notifications", () => { + let errors = 0 + const notifyTerminalError = createTerminalErrorNotifier({ + onError: () => { + errors += 1 + }, + }) + + notifyTerminalError() + notifyTerminalError() + + assert.equal(errors, 1) + }) +}) diff --git a/packages/ui/src/lib/native/desktop-events.ts b/packages/ui/src/lib/native/desktop-events.ts new file mode 100644 index 000000000..01c9af6ea --- /dev/null +++ b/packages/ui/src/lib/native/desktop-events.ts @@ -0,0 +1,159 @@ +import { invoke } from "@tauri-apps/api/core" +import { listen } from "@tauri-apps/api/event" +import type { WorkspaceEventPayload } from "../../../../server/src/api-types" +import type { + DesktopEventsStartResult, + DesktopEventTransportStartOptions, + DesktopEventTransportStatusPayload, +} from "../event-transport-contract" +import type { WorkspaceEventConnection, WorkspaceEventTransportCallbacks } from "../event-transport" +import { getLogger } from "../logger" + +const log = getLogger("sse") + +interface WorkspaceEventBatchPayload { + generation: number + sequence: number + emittedAt: number + events: WorkspaceEventPayload[] +} + +export function createTerminalErrorNotifier(callbacks: Pick) { + let raised = false + return () => { + if (raised) return + raised = true + callbacks.onError?.() + } +} + +export async function connectTauriWorkspaceEvents( + callbacks: WorkspaceEventTransportCallbacks, + options: DesktopEventTransportStartOptions, +): Promise { + let closed = false + let opened = false + let expectedGeneration: number | null = null + const notifyTerminalError = createTerminalErrorNotifier(callbacks) + const pendingBatches: WorkspaceEventBatchPayload[] = [] + const pendingStatuses: DesktopEventTransportStatusPayload[] = [] + + const matchesGeneration = (generation: number) => expectedGeneration === generation + + const handleBatchPayload = (payload: WorkspaceEventBatchPayload) => { + if (!payload || !matchesGeneration(payload.generation)) return + + if (!opened) { + opened = true + callbacks.onOpen?.() + } + + const events = payload.events ?? [] + if (events.length === 0) { + return + } + + callbacks.onBatch(events) + } + + const handleStatusPayload = (payload: DesktopEventTransportStatusPayload) => { + if (!payload || !matchesGeneration(payload.generation)) return + + if (payload.state === "connected" && !opened) { + opened = true + callbacks.onOpen?.() + } + + if (payload.state === "unauthorized") { + log.warn("Native desktop event transport is waiting for authentication", { + reason: payload.reason, + reconnectAttempt: payload.reconnectAttempt, + nextDelayMs: payload.nextDelayMs, + stats: payload.stats, + }) + } else if (payload.state === "error") { + log.warn("Native desktop event transport reported an error", { + reason: payload.reason, + reconnectAttempt: payload.reconnectAttempt, + nextDelayMs: payload.nextDelayMs, + statusCode: payload.statusCode, + stats: payload.stats, + }) + } else if ((payload.state === "disconnected" || payload.state === "stopped") && payload.stats) { + log.info("Native desktop event transport stats", { + state: payload.state, + reconnectAttempt: payload.reconnectAttempt, + stats: payload.stats, + }) + } + + if (payload.state === "stopped") { + notifyTerminalError() + return + } + + if (payload.terminal) { + notifyTerminalError() + } + } + + const flushPending = () => { + if (expectedGeneration === null) return + for (const payload of pendingStatuses.splice(0, pendingStatuses.length)) { + handleStatusPayload(payload) + } + for (const payload of pendingBatches.splice(0, pendingBatches.length)) { + handleBatchPayload(payload) + } + } + + const unlistenBatch = await listen("desktop:event-batch", (event) => { + if (closed) return + const payload = event.payload + if (!payload) return + if (expectedGeneration === null) { + pendingBatches.push(payload) + return + } + handleBatchPayload(payload) + }) + + const unlistenStatus = await listen("desktop:event-stream-status", (event) => { + if (closed) return + const payload = event.payload + if (!payload) return + if (expectedGeneration === null) { + pendingStatuses.push(payload) + return + } + handleStatusPayload(payload) + }) + + try { + const result = await invoke("desktop_events_start", { request: options }) + if (!result?.started) { + throw new Error(result?.reason ?? "desktop event transport unavailable") + } + expectedGeneration = result.generation ?? null + flushPending() + } catch (error) { + unlistenBatch() + unlistenStatus() + throw error + } + + return { + disconnect() { + if (closed) { + return + } + + closed = true + unlistenBatch() + unlistenStatus() + void invoke("desktop_events_stop").catch((error) => { + log.warn("Failed to stop native desktop event transport", error) + }) + }, + } +} diff --git a/packages/ui/src/lib/retry-utils.ts b/packages/ui/src/lib/retry-utils.ts new file mode 100644 index 000000000..d644cbe98 --- /dev/null +++ b/packages/ui/src/lib/retry-utils.ts @@ -0,0 +1,64 @@ +interface RetryOptions { + maxAttempts?: number + initialDelayMs?: number + maxDelayMs?: number + backoffMultiplier?: number + timeoutMs?: number + shouldRetry?: (error: Error, attempt: number) => boolean +} + +export async function retryWithBackoff( + fn: (signal?: AbortSignal) => Promise, + options: RetryOptions = {}, +): Promise { + const { + maxAttempts = 3, + initialDelayMs = 100, + maxDelayMs = 5000, + backoffMultiplier = 2, + timeoutMs, + shouldRetry = () => true, + } = options + + let lastError: Error | null = null + let delayMs = initialDelayMs + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + if (timeoutMs) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const result = await fn(controller.signal) + clearTimeout(timer) + return result + } catch (error) { + clearTimeout(timer) + throw error + } + } + + return await fn() + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)) + lastError = err + + if (attempt < maxAttempts && shouldRetry(err, attempt)) { + await new Promise((resolve) => setTimeout(resolve, delayMs)) + delayMs = Math.min(delayMs * backoffMultiplier, maxDelayMs) + } else { + throw err + } + } + } + + throw lastError || new Error("Failed after retries") +} + +export function isRetryableError(error: Error): boolean { + if (error.name === "AbortError" || error.name === "TimeoutError") return true + if (error.message.includes("Failed to fetch")) return true + if (error.message.includes("NetworkError")) return true + if (error.message.includes("timeout")) return true + return false +} diff --git a/packages/ui/src/lib/server-events.ts b/packages/ui/src/lib/server-events.ts index 833e6c2aa..4145367dd 100644 --- a/packages/ui/src/lib/server-events.ts +++ b/packages/ui/src/lib/server-events.ts @@ -1,7 +1,10 @@ +import { batch as solidBatch } from "solid-js" import type { WorkspaceEventPayload, WorkspaceEventType } from "../../../server/src/api-types" import { serverApi } from "./api-client" import { getClientIdentity } from "./client-identity" +import { connectWorkspaceEvents, type WorkspaceEventConnection } from "./event-transport" import { getLogger } from "./logger" +import { retryWithBackoff, isRetryableError } from "./retry-utils" const RETRY_BASE_DELAY = 1000 const RETRY_MAX_DELAY = 10000 @@ -18,65 +21,125 @@ function logSse(message: string, context?: Record) { class ServerEvents { private handlers = new Map void>>() private openHandlers = new Set<() => void>() - private source: EventSource | null = null + private connection: WorkspaceEventConnection | null = null + private connectGeneration = 0 private retryDelay = RETRY_BASE_DELAY - private reconnectTimer: ReturnType | null = null + private retryTimer: ReturnType | null = null constructor() { - this.connect() + void this.connect() } - private connect() { - if (this.reconnectTimer !== null) { - clearTimeout(this.reconnectTimer) - this.reconnectTimer = null - } - if (this.source) { - this.source.close() + private async connect() { + const generation = ++this.connectGeneration + this.clearReconnectTimer() + + if (this.connection) { + this.connection.disconnect() + this.connection = null } + logSse("Connecting to backend events stream") - this.source = serverApi.connectEvents( - (event) => this.dispatch(event), - () => this.scheduleReconnect(), - (payload) => { - void serverApi - .sendClientConnectionPong({ - ...getClientIdentity(), - pingTs: payload.ts, - }) - .catch((error) => { - log.error("Failed to send client connection pong", error) + + try { + const connection = await connectWorkspaceEvents({ + onBatch: (events) => this.dispatchBatch(events), + onError: () => { + if (generation !== this.connectGeneration) { + return + } + this.scheduleReconnect() + }, + onOpen: () => { + if (generation !== this.connectGeneration) { + return + } + logSse("Events stream connected") + this.retryDelay = RETRY_BASE_DELAY + this.openHandlers.forEach((handler) => handler()) + }, + onPing: (payload) => { + const identity = getClientIdentity() + const pongPayload = { ...identity, pingTs: payload.ts } + + void retryWithBackoff( + (signal) => serverApi.sendClientConnectionPong(pongPayload, signal), + { + maxAttempts: 3, + initialDelayMs: 100, + maxDelayMs: 2000, + timeoutMs: 10000, + shouldRetry: (error) => isRetryableError(error), + }, + ).catch((error) => { + log.warn("Failed to send client connection pong after retries", error) }) - }, - ) - this.source.onopen = () => { - logSse("Events stream connected") - this.retryDelay = RETRY_BASE_DELAY - this.openHandlers.forEach((handler) => handler()) + }, + }) + + if (generation !== this.connectGeneration) { + connection.disconnect() + return + } + + this.connection = connection + } catch (error) { + if (generation !== this.connectGeneration) { + return + } + + logSse("Events stream failed to connect, scheduling reconnect", { + error: error instanceof Error ? error.message : String(error), + }) + this.scheduleReconnect() } } private scheduleReconnect() { - if (this.reconnectTimer !== null) { + if (this.retryTimer) { return } - const source = this.source - this.source = null + + if (this.connection) { + this.connection.disconnect() + this.connection = null + } + logSse("Events stream disconnected, scheduling reconnect", { delayMs: this.retryDelay }) - this.reconnectTimer = setTimeout(() => { - this.reconnectTimer = null + this.retryTimer = setTimeout(() => { + this.retryTimer = null this.retryDelay = Math.min(this.retryDelay * 2, RETRY_MAX_DELAY) - this.connect() + void this.connect() }, this.retryDelay) - source?.close() + } + + private clearReconnectTimer() { + if (!this.retryTimer) { + return + } + + clearTimeout(this.retryTimer) + this.retryTimer = null } private dispatch(event: WorkspaceEventPayload) { - logSse(`event ${event.type}`) this.handlers.get("*")?.forEach((handler) => handler(event)) this.handlers.get(event.type)?.forEach((handler) => handler(event)) } + private dispatchBatch(events: WorkspaceEventPayload[]) { + if (events.length === 0) { + return + } + + logSse("event batch", { size: events.length }) + solidBatch(() => { + for (const event of events) { + this.dispatch(event) + } + }) + } + on(type: WorkspaceEventType | "*", handler: (event: WorkspaceEventPayload) => void): () => void { if (!this.handlers.has(type)) { this.handlers.set(type, new Set()) @@ -90,6 +153,19 @@ class ServerEvents { this.openHandlers.add(handler) return () => this.openHandlers.delete(handler) } + + restart(reason = "manual restart"): void { + this.retryDelay = RETRY_BASE_DELAY + this.clearReconnectTimer() + + if (this.connection) { + this.connection.disconnect() + this.connection = null + } + + logSse("Restarting backend events stream", { reason }) + void this.connect() + } } export const serverEvents = new ServerEvents() diff --git a/packages/ui/src/lib/settings/behavior-registry.ts b/packages/ui/src/lib/settings/behavior-registry.ts index 25bd13e4b..b28229a12 100644 --- a/packages/ui/src/lib/settings/behavior-registry.ts +++ b/packages/ui/src/lib/settings/behavior-registry.ts @@ -6,7 +6,7 @@ import type { } from "../../stores/preferences" import type { Command } from "../commands" import { tGlobal } from "../i18n" -import { isWebHost } from "../runtime-env" +import { isTauriHost, isWebHost } from "../runtime-env" export type BehaviorSettingKind = "toggle" | "enum" @@ -35,6 +35,8 @@ export type BehaviorSetting = BehaviorToggleSetting | BehaviorEnumSetting export type BehaviorRegistryActions = { preferences: Accessor + useTauriNativeEventTransport: Accessor + setUseTauriNativeEventTransport: (next: boolean) => void updatePreferences?: (updates: Partial) => void toggleShowThinkingBlocks: () => void toggleKeyboardShortcutHints: () => void @@ -280,6 +282,20 @@ export function getBehaviorSettings(actions: BehaviorRegistryActions): BehaviorS } }, }, + ...(isTauriHost() + ? [ + { + kind: "toggle" as const, + id: "behavior.tauriNativeEventTransport", + titleKey: "settings.behavior.tauriNativeEventTransport.title", + subtitleKey: "settings.behavior.tauriNativeEventTransport.subtitle", + get: () => actions.useTauriNativeEventTransport(), + set: (next: boolean) => { + actions.setUseTauriNativeEventTransport(next) + }, + }, + ] + : []), { kind: "toggle", id: "behavior.promptVoiceInput", diff --git a/packages/ui/src/lib/sse-manager.ts b/packages/ui/src/lib/sse-manager.ts index 8fa3dfdc6..47b9ea802 100644 --- a/packages/ui/src/lib/sse-manager.ts +++ b/packages/ui/src/lib/sse-manager.ts @@ -10,7 +10,6 @@ import type { EventLspUpdated, EventSessionCompacted, - EventSessionDiff, EventSessionError, EventSessionIdle, EventSessionUpdated, @@ -77,7 +76,6 @@ type SSEEvent = | MessagePartDeltaEvent | EventSessionUpdated | EventSessionCompacted - | EventSessionDiff | EventSessionError | EventSessionIdle | EventSessionStatus @@ -168,9 +166,6 @@ class SSEManager { case "session.status": this.onSessionStatus?.(instanceId, event as EventSessionStatus) break - case "session.diff": - this.onSessionDiff?.(instanceId, event as EventSessionDiff) - break case "permission.asked": case "permission.updated": this.onPermissionUpdated?.(instanceId, event as any) @@ -234,7 +229,6 @@ class SSEManager { onTuiToast?: (instanceId: string, event: TuiToastEvent) => void onSessionIdle?: (instanceId: string, event: EventSessionIdle) => void onSessionStatus?: (instanceId: string, event: EventSessionStatus) => void - onSessionDiff?: (instanceId: string, event: EventSessionDiff) => void onPermissionUpdated?: (instanceId: string, event: EventPermissionV2Asked | LegacyPermissionAskedEvent) => void onPermissionReplied?: (instanceId: string, event: EventPermissionV2Replied | LegacyPermissionRepliedEvent) => void onQuestionAsked?: (instanceId: string, event: EventQuestionV2Asked | { type: "question.asked"; properties?: any }) => void diff --git a/packages/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx index eaa2548dc..3f1f88ee0 100644 --- a/packages/ui/src/stores/preferences.tsx +++ b/packages/ui/src/stores/preferences.tsx @@ -1,5 +1,9 @@ import { createContext, createMemo, createSignal, onMount, useContext } from "solid-js" import type { Accessor, ParentComponent } from "solid-js" +import { + readUseTauriNativeEventTransportPreference, + writeUseTauriNativeEventTransportPreference, +} from "../lib/desktop-event-transport-preference" import { storage, type OwnerBucket } from "../lib/storage" import type { RemoteServerProfile } from "../../../server/src/api-types" import { @@ -406,6 +410,9 @@ const [uiConfigBucket, setUiConfigBucket] = createSignal({}) const [serverConfigBucket, setServerConfigBucket] = createSignal({}) const [uiStateBucket, setUiStateBucket] = createSignal({}) const [isLoaded, setIsLoaded] = createSignal(false) +const [useTauriNativeEventTransport, setUseTauriNativeEventTransportSignal] = createSignal( + readUseTauriNativeEventTransportPreference(), +) const uiSettings = createMemo(() => normalizeUiSettings(uiConfigBucket().settings)) const themePreference = createMemo(() => uiConfigBucket().theme ?? "system") @@ -454,6 +461,23 @@ async function patchConfigOwner(owner: string, patch: unknown) { if (owner === "server") setServerConfigBucket(updated as any) } +function setUseTauriNativeEventTransport(enabled: boolean): void { + if (useTauriNativeEventTransport() === enabled) { + return + } + + setUseTauriNativeEventTransportSignal(enabled) + writeUseTauriNativeEventTransportPreference(enabled) + + void import("../lib/server-events") + .then(({ serverEvents }) => { + serverEvents.restart("desktop transport preference changed") + }) + .catch((error) => { + log.error("Failed to restart backend events stream after desktop transport preference change", error) + }) +} + async function patchStateOwner(owner: string, patch: unknown) { await ensureLoaded() const updated = await storage.patchStateOwner(owner, patch) @@ -781,6 +805,8 @@ void ensureLoaded().catch((error: unknown) => { interface ConfigContextValue { isLoaded: Accessor preferences: typeof preferences + useTauriNativeEventTransport: typeof useTauriNativeEventTransport + setUseTauriNativeEventTransport: typeof setUseTauriNativeEventTransport updatePreferences: typeof updatePreferences themePreference: typeof themePreference setThemePreference: typeof setThemePreference @@ -842,6 +868,8 @@ const ConfigContext = createContext() const configContextValue: ConfigContextValue = { isLoaded, preferences, + useTauriNativeEventTransport, + setUseTauriNativeEventTransport, updatePreferences, themePreference, setThemePreference, @@ -931,6 +959,8 @@ export function useConfig(): ConfigContextValue { export { preferences, + useTauriNativeEventTransport, + setUseTauriNativeEventTransport, uiState, serverSettings, recentFolders, diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index 0eb6e9b58..ac89ed658 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -7,7 +7,7 @@ import { type SessionStatus, } from "../types/session" import type { Message } from "../types/message" -import type { SnapshotFileDiff, SessionV2Info, V2SessionsResponse } from "@opencode-ai/sdk/v2/client" +import type { SessionV2Info, V2SessionsResponse } from "@opencode-ai/sdk/v2/client" import { instances } from "./instances" import { preferences, setAgentModelPreference } from "./preferences" @@ -61,8 +61,6 @@ import { hydrateSessionMetadataWithClient } from "./session-metadata" const log = getLogger("api") -const pendingSessionDiffFetches = new Map>() - function getErrorMessage(error: unknown): string { if (!error) return "Failed to load messages" @@ -89,46 +87,6 @@ async function getSessionWorkspacePayload(instanceId: string, sessionId: string) return workspace ? { workspace } : {} } -async function loadSessionDiff(instanceId: string, sessionId: string, force = false): Promise { - if (!instanceId || !sessionId) return - - const key = `${instanceId}:${sessionId}` - if (!force) { - const existing = sessions().get(instanceId)?.get(sessionId) - if (existing?.diff !== undefined) return - const pending = pendingSessionDiffFetches.get(key) - if (pending) return pending - } - - const promise = (async () => { - const instance = instances().get(instanceId) - if (!instance?.client) return - - const client = getRootClient(instanceId) - - try { - const diffs = await requestData( - client.session.diff({ sessionID: sessionId, ...(await getSessionWorkspacePayload(instanceId, sessionId)) }), - "session.diff", - ) - - if (!Array.isArray(diffs)) { - return - } - - withSession(instanceId, sessionId, (session) => { - session.diff = diffs - }) - } catch (error) { - log.warn("Failed to fetch session diff", { instanceId, sessionId, error }) - } - })() - - pendingSessionDiffFetches.set(key, promise) - void promise.finally(() => pendingSessionDiffFetches.delete(key)) - return promise -} - interface SessionForkResponse { id: string title?: string @@ -474,7 +432,6 @@ function toClientSessionV2(instanceId: string, apiSession: SessionV2Info, existi }, metadata: existingSession?.metadata, revert: existingSession?.revert, - diff: existingSession?.diff, pendingPermission: existingSession?.pendingPermission, pendingQuestion: existingSession?.pendingQuestion, } @@ -851,10 +808,9 @@ async function fetchProviders(instanceId: string): Promise { async function loadMessages( instanceId: string, sessionId: string, - options?: { force?: boolean; skipDiff?: boolean; skipChildren?: boolean }, + options?: { force?: boolean; skipChildren?: boolean }, ): Promise { const force = options?.force ?? false - const skipDiff = options?.skipDiff ?? false const skipChildren = options?.skipChildren ?? false if (force) { @@ -896,12 +852,6 @@ async function loadMessages( throw new Error("Session not found") } - if (!skipDiff) { - void loadSessionDiff(instanceId, sessionId).catch((error) => { - log.warn("Failed to load session diff", { instanceId, sessionId, error }) - }) - } - setLoading((prev) => { const next = { ...prev } const loadingSet = next.loadingMessages.get(instanceId) || new Set() @@ -1048,7 +998,7 @@ async function loadMessages( if (!skipChildren && session.parentId === null) { for (const child of getDescendantSessions(instanceId, sessionId)) { - void loadMessages(instanceId, child.id, { skipDiff: true, skipChildren: true }).catch((error) => + void loadMessages(instanceId, child.id, { skipChildren: true }).catch((error) => log.error("Failed to load child session messages", { instanceId, sessionId: child.id, diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index 8f87e936b..90430b1f5 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -8,7 +8,6 @@ import type { } from "../types/message" import type { EventSessionCompacted, - EventSessionDiff, EventSessionError, EventSessionIdle, EventSessionUpdated, @@ -563,31 +562,6 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo } } -function handleSessionDiff(instanceId: string, event: EventSessionDiff): void { - const sessionId = event.properties?.sessionID - if (!sessionId) return - - const diffs = event.properties?.diff - if (!Array.isArray(diffs)) return - - const existing = sessions().get(instanceId)?.get(sessionId) - if (existing) { - withSession(instanceId, sessionId, (session) => { - session.diff = diffs - }) - return - } - - // A diff event can arrive before we have hydrated the session list. - // Best-effort: fetch the session record so the diff has somewhere to live. - void (async () => { - await fetchSessionInfo(instanceId, sessionId, (event as any)?.directory) - withSession(instanceId, sessionId, (session) => { - session.diff = diffs - }) - })().catch((error) => log.warn("Failed to hydrate session for diff event", { instanceId, sessionId, error })) -} - function handleSessionIdle(instanceId: string, event: EventSessionIdle): void { const sessionId = event.properties?.sessionID if (!sessionId) return @@ -799,7 +773,6 @@ export { handleQuestionAsked, handleQuestionAnswered, handleSessionCompacted, - handleSessionDiff, handleSessionError, handleSessionIdle, handleSessionStatus, diff --git a/packages/ui/src/stores/sessions.ts b/packages/ui/src/stores/sessions.ts index 9dc353d2b..365fa8c39 100644 --- a/packages/ui/src/stores/sessions.ts +++ b/packages/ui/src/stores/sessions.ts @@ -80,7 +80,6 @@ import { handleQuestionAnswered, handleQuestionAsked, handleSessionCompacted, - handleSessionDiff, handleSessionError, handleSessionIdle, handleSessionStatus, @@ -95,7 +94,6 @@ sseManager.onMessageRemoved = handleMessageRemoved sseManager.onMessagePartRemoved = handleMessagePartRemoved sseManager.onSessionUpdate = handleSessionUpdate sseManager.onSessionCompacted = handleSessionCompacted -sseManager.onSessionDiff = handleSessionDiff sseManager.onSessionError = handleSessionError sseManager.onSessionIdle = handleSessionIdle sseManager.onSessionStatus = handleSessionStatus diff --git a/packages/ui/src/types/session.ts b/packages/ui/src/types/session.ts index 067ec63e9..e8e29ac38 100644 --- a/packages/ui/src/types/session.ts +++ b/packages/ui/src/types/session.ts @@ -3,8 +3,8 @@ import type { Agent as SDKAgent, Provider as SDKProvider, Model as SDKModel, -} from "@opencode-ai/sdk" -import type { SessionStatus as SDKSessionStatus, SnapshotFileDiff } from "@opencode-ai/sdk/v2/client" + SessionStatus as SDKSessionStatus, +} from "@opencode-ai/sdk/v2" // Export SDK types for external use export type { @@ -77,7 +77,6 @@ export interface Session retry?: SessionRetryState | null // Retry metadata for transient backoff states idleSince?: number | null // Timestamp set when work finished but the session has not been viewed yet metadata?: Record // Session metadata persisted by OpenCode - diff?: SnapshotFileDiff[] // Session-level file diffs (hydrated via session.diff) } // Adapter function to convert SDK Session to client Session diff --git a/tasks/README.md b/tasks/README.md deleted file mode 100644 index 285a1c86b..000000000 --- a/tasks/README.md +++ /dev/null @@ -1,177 +0,0 @@ -# Task Management - -This directory contains the task breakdown for building CodeNomad. - -## Structure - -- `todo/` - Tasks waiting to be worked on -- `done/` - Completed tasks (moved from todo/) - -## Task Naming Convention - -Tasks are numbered sequentially with a descriptive name: - -``` -001-project-setup.md -002-empty-state-ui.md -003-process-manager.md -... -``` - -## Task Format - -Each task file contains: - -1. **Goal** - What this task achieves -2. **Prerequisites** - What must be done first -3. **Acceptance Criteria** - Checklist of requirements -4. **Steps** - Detailed implementation guide -5. **Testing Checklist** - How to verify completion -6. **Dependencies** - What blocks/is blocked by this task -7. **Estimated Time** - Rough time estimate -8. **Notes** - Additional context - -## Workflow - -### Starting a Task - -1. Read the task file thoroughly -2. Ensure prerequisites are met -3. Check dependencies are complete -4. Create a feature branch: `feature/task-XXX-name` - -### Working on a Task - -1. Follow steps in order -2. Check off acceptance criteria as you complete them -3. Run tests frequently -4. Commit regularly with descriptive messages - -### Completing a Task - -1. Verify all acceptance criteria met -2. Run full testing checklist -3. Update task file with any notes/changes -4. Move task from `todo/` to `done/` -5. Create PR for review - -## Current Tasks - -### Phase 1: Foundation (Tasks 001-005) - -- [x] 001 - Project Setup -- [x] 002 - Empty State UI -- [x] 003 - Process Manager -- [x] 004 - SDK Integration -- [x] 005 - Session Picker Modal - -### Phase 2: Core Chat (Tasks 006-010) - -- [x] 006 - Instance & Session Tabs -- [x] 007 - Message Display -- [x] 008 - SSE Integration -- [x] 009 - Prompt Input (Basic) -- [x] 010 - Tool Call Rendering - -### Phase 3: Essential Features (Tasks 011-015) - -- [x] 011 - Agent/Model Selectors -- [x] 012 - Markdown Rendering -- [x] 013 - Logs Tab -- [ ] 014 - Error Handling -- [ ] 015 - Keyboard Shortcuts - -### Phase 4: Multi-Instance (Tasks 016-020) - -- [ ] 016 - Instance Tabs -- [ ] 017 - Instance Persistence -- [ ] 018 - Child Session Handling -- [ ] 019 - Instance Lifecycle -- [ ] 020 - Multiple SDK Clients - -### Phase 5: Advanced Input (Tasks 021-025) - -- [ ] 021 - Slash Commands -- [ ] 022 - File Attachments -- [ ] 023 - Drag & Drop -- [ ] 024 - Attachment Chips -- [ ] 025 - Input History - -### Phase 6: Polish (Tasks 026-030) - -- [ ] 026 - Message Actions -- [ ] 027 - Search in Session -- [ ] 028 - Session Management -- [ ] 029 - Settings UI -- [ ] 030 - Native Menus - -### Phase 7: System Integration (Tasks 031-035) - -- [ ] 031 - System Tray -- [ ] 032 - Notifications -- [ ] 033 - Auto-updater -- [ ] 034 - Crash Reporting -- [ ] 035 - Performance Profiling - -### Phase 8: Advanced (Tasks 036-040) - -- [ ] 036 - Virtual Scrolling -- [ ] 037 - Advanced Search -- [ ] 038 - Workspace Management -- [ ] 039 - Theme Customization -- [ ] 040 - Plugin System - -## Priority Levels - -Tasks are prioritized as follows: - -- **P0 (MVP)**: Must have for first release (Tasks 001-015) -- **P1 (Beta)**: Important for beta (Tasks 016-030) -- **P2 (v1.0)**: Should have for v1.0 (Tasks 031-035) -- **P3 (Future)**: Nice to have (Tasks 036-040) - -## Dependencies Graph - -``` -001 (Setup) - ├─ 002 (Empty State) - │ └─ 003 (Process Manager) - │ └─ 004 (SDK Integration) - │ └─ 005 (Session Picker) - │ ├─ 006 (Tabs) - │ │ └─ 007 (Messages) - │ │ └─ 008 (SSE) - │ │ └─ 009 (Input) - │ │ └─ 010 (Tool Calls) - │ │ └─ 011-015 (Essential Features) - │ │ └─ 016-020 (Multi-Instance) - │ │ └─ 021-025 (Advanced Input) - │ │ └─ 026-030 (Polish) - │ │ └─ 031-035 (System) - │ │ └─ 036-040 (Advanced) -``` - -## Tips - -- **Don't skip steps** - They're ordered for a reason -- **Test as you go** - Don't wait until the end -- **Keep tasks small** - Break down if >1 day of work -- **Document issues** - Note any blockers or problems -- **Ask questions** - If unclear, ask before proceeding - -## Tracking Progress - -Update this file as tasks complete: - -- Change `[ ]` to `[x]` in the task list -- Move completed task files to `done/` -- Update build roadmap doc - -## Getting Help - -If stuck on a task: - -1. Review prerequisites and dependencies -2. Check related documentation in `docs/` -3. Review similar patterns in existing code -4. Ask for clarification on unclear requirements diff --git a/tasks/current.md b/tasks/current.md deleted file mode 100644 index ce2b48fdf..000000000 --- a/tasks/current.md +++ /dev/null @@ -1,19 +0,0 @@ -# Current Tasks - -## Active Discussions - -- DISCUSSION-001 — Wake lock behavior change for macOS sleep vs screen lock — summarized, routed to task 056 - -## Active - -- 055-wake-lock-investigation.md — standard / investigation / logic — Assigned to tech_lead -- 056-wake-lock-behavior-change.md — complex / spec / logic — Assigned to business_analyst -- 057-implement-system-sleep-only-wake-lock.md — complex / implementation / logic — Assigned to workflow_runner - -## Todo - -- 023-symbol-attachments.md - -## Blocked - -- None. diff --git a/tasks/discussions/DISCUSSION-001-wake-lock-behavior-change-for-macos-sleep-vs-screen-lock.md b/tasks/discussions/DISCUSSION-001-wake-lock-behavior-change-for-macos-sleep-vs-screen-lock.md deleted file mode 100644 index dfc951c22..000000000 --- a/tasks/discussions/DISCUSSION-001-wake-lock-behavior-change-for-macos-sleep-vs-screen-lock.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -id: DISCUSSION-001 -title: "Wake lock behavior change for macOS sleep vs screen lock" -status: closed -summarized_by: business_analyst -source: runtime-transcript ---- - -# Discussion Summary - -## Topic -Change wake lock behavior so screen lock/display sleep is allowed while system sleep is still prevented during active work. - -## Purpose -Capture a workflow-ready summary of a requested product behavior change affecting desktop apps and web, including current behavior, desired behavior, scope, and unresolved platform feasibility. - -## Repository Truth Relevant To This Discussion -- Current desktop wake lock behavior is effectively configured as a display wake lock. -- Electron currently uses `prevent-display-sleep`. -- Tauri currently includes `display: true` in its wake-lock-related configuration. -- This current setup keeps the screen awake and blocks normal screen lock/display sleep on macOS. - -## Facts Established -- The reported problem is specific to current wake lock behavior preventing screen lock on macOS. -- The user wants wake lock to allow screen lock while still preventing the device from going to sleep. -- The requested scope was expanded beyond macOS-only behavior. -- The user explicitly requested coverage for all desktop apps and web. -- Browser/web platform limitations may affect how fully the requested behavior can be implemented. - -## Requirements Captured -- Wake lock must allow the display to sleep or lock normally. -- Wake lock must prevent only system sleep while work is active. -- On macOS, the screen should be able to turn off and lock while the machine remains awake enough to continue the task. -- The change should be researched and then applied, not just discussed. -- Scope should include all desktop apps and web, subject to technical feasibility. - -## Constraints -- The change affects multiple platforms and should not be treated as a macOS-only behavior change. -- Web support may be constrained by browser capabilities and wake lock API limitations. -- Platform-specific implementation details may differ between Electron, Tauri, and web. - -## Non-Goals -- Keeping the display continuously awake. -- Preserving the current display-wake behavior on macOS. -- Defining a macOS-only special case unless later justified. - -## Decisions Made -- Preferred product direction: allow display sleep/screen lock while preventing only system sleep during active work. -- Scope direction confirmed by the user: all desktop apps and web. -- The discussion should move into tracked workflow work with product and technical input before implementation. - -## Assumptions -- “Work is active” refers to periods when the application is performing a task that currently relies on wake lock protection. -- The intended outcome is continued task execution while the screen is locked or asleep, not continuous visual display. -- Some platforms may require best-effort behavior rather than identical implementation mechanics. - -## Open Questions -- What exact user-facing definition of “work is active” should trigger wake lock behavior across products? -- What behavior is achievable on web given browser/API support and permission constraints? -- If a platform cannot prevent only system sleep without also affecting display sleep, what fallback behavior is acceptable? -- Should platform-specific differences be exposed to users or documented in product behavior notes? - -## Risks Or Concerns -- Web may not support the requested behavior fully or consistently across browsers. -- A platform may not offer a clean “prevent system sleep only” mode, creating inconsistent behavior across products. -- Changing wake lock semantics could affect long-running task reliability if background execution assumptions are wrong. - -## Referenced Files Or Areas -- Electron wake lock implementation using `prevent-display-sleep` -- Tauri wake lock / `keepawake` configuration currently using `display: true` -- Cross-platform wake lock behavior for desktop apps -- Web wake lock behavior and browser capability research areas - -## Recommended Workflow Next Step -- assigned_to: product_manager -- why: Create a tracked task and SCR-ready handoff for cross-platform research and specification, then route to business analyst and technical architect for requirements and feasibility clarification before implementation. diff --git a/tasks/done/001-project-setup.md b/tasks/done/001-project-setup.md deleted file mode 100644 index fe55dc4ba..000000000 --- a/tasks/done/001-project-setup.md +++ /dev/null @@ -1,262 +0,0 @@ -# Task 001: Project Setup & Boilerplate - -## Goal - -Set up the basic Electron + SolidJS + Vite project structure with all necessary dependencies and configuration files. - -## Prerequisites - -- Node.js 18+ installed -- Bun package manager -- OpenCode CLI installed and accessible in PATH - -## Acceptance Criteria - -- [ ] Project structure matches documented layout -- [ ] All dependencies installed -- [ ] Dev server starts successfully -- [ ] Electron window launches -- [ ] Hot reload works for renderer -- [ ] TypeScript compilation works -- [ ] Basic "Hello World" renders - -## Steps - -### 1. Initialize Package - -- Create `package.json` with project metadata -- Set `name`: `@opencode-ai/client` -- Set `version`: `0.1.0` -- Set `type`: `module` -- Set `main`: `dist/main/main.js` - -### 2. Install Core Dependencies - -**Production:** - -- `electron` ^28.0.0 -- `solid-js` ^1.8.0 -- `@solidjs/router` ^0.13.0 -- `@opencode-ai/sdk` (from workspace) - -**Development:** - -- `electron-vite` ^2.0.0 -- `electron-builder` ^24.0.0 -- `vite` ^5.0.0 -- `vite-plugin-solid` ^2.10.0 -- `typescript` ^5.3.0 -- `tailwindcss` ^4.0.0 -- `@tailwindcss/vite` ^4.0.0 - -**UI Libraries:** - -- `@kobalte/core` ^0.13.0 -- `shiki` ^1.0.0 -- `marked` ^12.0.0 -- `lucide-solid` ^0.300.0 - -### 3. Create Directory Structure - -``` -packages/opencode-client/ -├── electron/ -│ ├── main/ -│ │ └── main.ts -│ ├── preload/ -│ │ └── index.ts -│ └── resources/ -│ └── icon.png -├── src/ -│ ├── components/ -│ ├── stores/ -│ ├── lib/ -│ ├── hooks/ -│ ├── types/ -│ ├── App.tsx -│ ├── main.tsx -│ └── index.css -├── docs/ -├── tasks/ -│ ├── todo/ -│ └── done/ -├── package.json -├── tsconfig.json -├── tsconfig.node.json -├── electron.vite.config.ts -├── tailwind.config.js -├── .gitignore -└── README.md -``` - -### 4. Configure TypeScript - -**tsconfig.json** (for renderer): - -- `target`: ES2020 -- `module`: ESNext -- `jsx`: preserve -- `jsxImportSource`: solid-js -- `moduleResolution`: bundler -- `strict`: true -- Path alias: `@/*` → `./src/*` - -**tsconfig.node.json** (for main & preload): - -- `target`: ES2020 -- `module`: ESNext -- `moduleResolution`: bundler -- Include: `electron/**/*.ts` - -### 5. Configure Electron Vite - -**electron.vite.config.ts:** - -- Main process config: External electron -- Preload config: External electron -- Renderer config: - - SolidJS plugin - - TailwindCSS plugin - - Path alias resolution - - Dev server port: 3000 - -### 6. Configure TailwindCSS - -**tailwind.config.js:** - -- Content: `['./src/**/*.{ts,tsx}']` -- Theme: Default (will customize later) -- Plugins: None initially - -**src/index.css:** - -```css -@import "tailwindcss"; -``` - -### 7. Create Main Process Entry - -**electron/main/main.ts:** - -- Import app, BrowserWindow from electron -- Set up window creation -- Window size: 1400x900 -- Min size: 800x600 -- Web preferences: - - preload: path to preload script - - contextIsolation: true - - nodeIntegration: false -- Load URL based on environment: - - Dev: http://localhost:3000 - - Prod: Load dist/index.html -- Handle app lifecycle: - - ready event - - window-all-closed (quit on non-macOS) - - activate (recreate window on macOS) - -### 8. Create Preload Script - -**electron/preload/index.ts:** - -- Import contextBridge, ipcRenderer -- Expose electronAPI object: - - Placeholder methods for future IPC -- Type definitions for window.electronAPI - -### 9. Create Renderer Entry - -**src/main.tsx:** - -- Import render from solid-js/web -- Import App component -- Render to #root element - -**src/App.tsx:** - -- Basic component with "Hello CodeNomad" -- Display environment info -- Basic styling with TailwindCSS - -**index.html:** - -- Root div with id="root" -- Link to src/main.tsx - -### 10. Add Scripts to package.json - -```json -{ - "scripts": { - "dev": "electron-vite dev", - "build": "electron-vite build", - "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.node.json", - "preview": "electron-vite preview", - "package:mac": "electron-builder --mac", - "package:win": "electron-builder --win", - "package:linux": "electron-builder --linux" - } -} -``` - -### 11. Configure Electron Builder - -**electron-builder.yml** or in package.json: - -- appId: ai.opencode.client -- Product name: CodeNomad -- Build resources: electron/resources -- Files to include: dist/, package.json -- Directories: - - output: release - - buildResources: electron/resources -- Platform-specific configs (basic) - -### 12. Add .gitignore - -``` -node_modules/ -dist/ -release/ -.DS_Store -*.log -.vite/ -.electron-vite/ -``` - -### 13. Create README - -- Project description -- Prerequisites -- Installation instructions -- Development commands -- Build commands -- Architecture overview link - -## Verification Steps - -1. Run `bun install` -2. Run `bun run dev` -3. Verify Electron window opens -4. Verify "Hello CodeNomad" displays -5. Make a change to App.tsx -6. Verify hot reload updates UI -7. Run `bun run typecheck` -8. Verify no TypeScript errors -9. Run `bun run build` -10. Verify dist/ folder created - -## Dependencies for Next Tasks - -- Task 002 (Empty State) depends on this -- Task 003 (Process Manager) depends on this - -## Estimated Time - -2-3 hours - -## Notes - -- Keep this minimal - just the skeleton -- Don't add any business logic yet -- Focus on getting build pipeline working -- Use official Electron + Vite + Solid templates as reference diff --git a/tasks/done/002-empty-state-ui.md b/tasks/done/002-empty-state-ui.md deleted file mode 100644 index 4ef9563c1..000000000 --- a/tasks/done/002-empty-state-ui.md +++ /dev/null @@ -1,280 +0,0 @@ -# Task 002: Empty State UI & Folder Selection - -## Goal - -Create the initial empty state interface that appears when no instances are running, with folder selection capability. - -## Prerequisites - -- Task 001 completed (project setup) -- Basic understanding of SolidJS components -- Electron IPC understanding - -## Acceptance Criteria - -- [ ] Empty state displays when no instances exist -- [ ] "Select Folder" button visible and styled -- [ ] Clicking button triggers Electron dialog -- [ ] Selected folder path displays temporarily -- [ ] UI matches design spec (centered, clean) -- [ ] Keyboard shortcut Cmd/Ctrl+N works -- [ ] Error handling for cancelled selection - -## Steps - -### 1. Create Empty State Component - -**src/components/empty-state.tsx:** - -**Structure:** - -- Centered container -- Large folder icon (from lucide-solid) -- Subheading: "Select a folder to start coding with AI" -- Primary button: "Select Folder" -- Helper text: "Keyboard shortcut: Cmd/Ctrl+N" - -**Styling:** - -- Use TailwindCSS utilities -- Center vertically and horizontally -- Max width: 500px -- Padding: 32px -- Icon size: 64px -- Text sizes: Heading 24px, body 16px, helper 14px -- Colors: Follow design spec (light/dark mode) - -**Props:** - -- `onSelectFolder: () => void` - Callback when button clicked - -### 2. Create UI Store - -**src/stores/ui.ts:** - -**State:** - -```typescript -interface UIStore { - hasInstances: boolean - selectedFolder: string | null - isSelectingFolder: boolean -} -``` - -**Signals:** - -- `hasInstances` - Reactive boolean -- `selectedFolder` - Reactive string or null -- `isSelectingFolder` - Reactive boolean (loading state) - -**Actions:** - -- `setHasInstances(value: boolean)` -- `setSelectedFolder(path: string | null)` -- `setIsSelectingFolder(value: boolean)` - -### 3. Implement IPC for Folder Selection - -**electron/main/main.ts additions:** - -**IPC Handler:** - -- Register handler for 'dialog:selectFolder' -- Use `dialog.showOpenDialog()` with: - - `properties: ['openDirectory']` - - Title: "Select Project Folder" - - Button label: "Select" -- Return selected folder path or null if cancelled -- Handle errors gracefully - -**electron/preload/index.ts additions:** - -**Expose method:** - -```typescript -electronAPI: { - selectFolder: () => Promise -} -``` - -**Type definitions:** - -```typescript -interface ElectronAPI { - selectFolder: () => Promise -} - -declare global { - interface Window { - electronAPI: ElectronAPI - } -} -``` - -### 4. Update App Component - -**src/App.tsx:** - -**Logic:** - -- Import UI store -- Import EmptyState component -- Check if `hasInstances` is false -- If false, render EmptyState -- If true, render placeholder for instance UI (future) - -**Folder selection handler:** - -```typescript -async function handleSelectFolder() { - setIsSelectingFolder(true) - try { - const folder = await window.electronAPI.selectFolder() - if (folder) { - setSelectedFolder(folder) - // TODO: Will trigger instance creation in Task 003 - console.log("Selected folder:", folder) - } - } catch (error) { - console.error("Folder selection failed:", error) - // TODO: Show error toast (Task 010) - } finally { - setIsSelectingFolder(false) - } -} -``` - -### 5. Add Keyboard Shortcut - -**electron/main/menu.ts (new file):** - -**Create application menu:** - -- File menu: - - New Instance (Cmd/Ctrl+N) - - Click: Send 'menu:newInstance' to renderer - - Separator - - Quit (Cmd/Ctrl+Q) - -**Platform-specific menu:** - -- macOS: Include app menu with About, Hide, etc. -- Windows/Linux: Standard File menu - -**Register menu in main.ts:** - -- Import Menu, buildFromTemplate -- Create menu structure -- Set as application menu - -**electron/preload/index.ts additions:** - -```typescript -electronAPI: { - onNewInstance: (callback: () => void) => void -} -``` - -**src/App.tsx additions:** - -- Listen for 'newInstance' event -- Trigger handleSelectFolder when received - -### 6. Add Loading State - -**Button states:** - -- Default: "Select Folder" -- Loading: "Selecting..." with spinner icon -- Disabled when isSelectingFolder is true - -**Spinner component:** - -- Use lucide-solid Loader2 icon -- Add spin animation class -- Size: 16px - -### 7. Add Validation - -**Folder validation (in handler):** - -- Check if folder exists -- Check if readable -- Check if it's actually a directory -- Show appropriate error if invalid - -**Error messages:** - -- "Folder does not exist" -- "Cannot access folder (permission denied)" -- "Please select a directory, not a file" - -### 8. Style Refinements - -**Responsive behavior:** - -- Works at minimum window size (800x600) -- Maintains centering -- Text remains readable - -**Accessibility:** - -- Button has proper ARIA labels -- Keyboard focus visible -- Screen reader friendly text - -**Theme support:** - -- Test in light mode -- Test in dark mode (use prefers-color-scheme) -- Icons and text have proper contrast - -### 9. Add Helpful Context - -**Additional helper text:** - -- "Examples: ~/projects/my-app" -- "You can have multiple instances of the same folder" - -**Icon improvements:** - -- Use animated folder icon (optional) -- Add subtle entrance animation (fade in) - -## Testing Checklist - -**Manual Tests:** - -1. Launch app → Empty state appears -2. Click "Select Folder" → Dialog opens -3. Select folder → Path logged to console -4. Cancel dialog → No error, back to empty state -5. Press Cmd/Ctrl+N → Dialog opens -6. Select non-directory → Error shown -7. Select restricted folder → Permission error shown -8. Resize window → Layout stays centered - -**Edge Cases:** - -- Very long folder paths (ellipsis) -- Special characters in folder name -- Folder on network drive -- Folder that gets deleted while selected - -## Dependencies - -- **Blocks:** Task 003 (needs folder path to create instance) -- **Blocked by:** Task 001 (needs project setup) - -## Estimated Time - -2-3 hours - -## Notes - -- Keep UI simple and clean -- Focus on UX - clear messaging -- Don't implement instance creation yet (that's Task 003) -- Log selected folder to console for verification -- Prepare for state management patterns used in later tasks diff --git a/tasks/done/003-process-manager.md b/tasks/done/003-process-manager.md deleted file mode 100644 index c2671df5d..000000000 --- a/tasks/done/003-process-manager.md +++ /dev/null @@ -1,430 +0,0 @@ -# Task 003: OpenCode Server Process Management - -## Goal - -Implement the ability to spawn, manage, and kill OpenCode server processes from the Electron main process. - -## Prerequisites - -- Task 001 completed (project setup) -- Task 002 completed (folder selection working) -- OpenCode CLI installed and in PATH -- Understanding of Node.js child_process API - -## Acceptance Criteria - -- [ ] Can spawn `opencode serve` for a folder -- [ ] Parses stdout to extract port number -- [ ] Returns port and PID to renderer -- [ ] Handles spawn errors gracefully -- [ ] Can kill process on command -- [ ] Captures and forwards stdout/stderr -- [ ] Timeout protection (10 seconds) -- [ ] Process cleanup on app quit - -## Steps - -### 1. Create Process Manager Module - -**electron/main/process-manager.ts:** - -**Exports:** - -```typescript -interface ProcessInfo { - pid: number - port: number -} - -interface ProcessManager { - spawn(folder: string): Promise - kill(pid: number): Promise - getStatus(pid: number): "running" | "stopped" | "unknown" - getAllProcesses(): Map -} - -interface ProcessMeta { - pid: number - port: number - folder: string - startTime: number - childProcess: ChildProcess -} -``` - -### 2. Implement Spawn Logic - -**spawn(folder: string):** - -**Pre-flight checks:** - -- Verify `opencode` binary exists in PATH - - Use `which opencode` or `where opencode` - - If not found, reject with helpful error -- Verify folder exists and is directory - - Use `fs.stat()` to check - - If invalid, reject with error -- Verify folder is readable - - Check permissions - - If denied, reject with error - -**Process spawning:** - -- Use `child_process.spawn()` -- Command: `opencode` -- Args: `['serve', '--port', '0']` - - Port 0 = random available port -- Options: - - `cwd`: The selected folder - - `stdio`: `['ignore', 'pipe', 'pipe']` - - stdin: ignore - - stdout: pipe (we'll read it) - - stderr: pipe (for errors) - - `env`: Inherit process.env - - `shell`: false (security) - -**Port extraction:** - -- Listen to stdout data events -- Buffer output line by line -- Regex match: `/Server listening on port (\d+)/` or similar -- Extract port number when found -- Store process metadata -- Resolve promise with { pid, port } - -**Timeout handling:** - -- Set 10 second timeout -- If port not found within timeout: - - Kill the process - - Reject promise with timeout error -- Clear timeout once port found - -**Error handling:** - -- Listen to process 'error' event - - Common: ENOENT (binary not found) - - Reject promise immediately -- Listen to process 'exit' event - - If exits before port found: - - Read stderr buffer - - Reject with exit code and stderr - -### 3. Implement Kill Logic - -**kill(pid: number):** - -**Find process:** - -- Look up pid in internal Map -- If not found, reject with "Process not found" - -**Graceful shutdown:** - -- Send SIGTERM signal first -- Wait 2 seconds -- If still running, send SIGKILL -- Remove from internal Map -- Resolve when process exits - -**Cleanup:** - -- Close stdio streams -- Remove all event listeners -- Free resources - -### 4. Implement Status Check - -**getStatus(pid: number):** - -**Check if running:** - -- On Unix: Use `process.kill(pid, 0)` - - Returns true if running - - Throws if not running -- On Windows: Use tasklist or similar -- Return 'running', 'stopped', or 'unknown' - -### 5. Add Process Tracking - -**Internal state:** - -```typescript -const processes = new Map() -``` - -**Track all spawned processes:** - -- Add on successful spawn -- Remove on kill or exit -- Use for cleanup on app quit - -### 6. Implement Auto-cleanup - -**On app quit:** - -- Listen to app 'before-quit' event -- Kill all tracked processes -- Wait for all to exit (with timeout) -- Prevent quit until cleanup done - -**On process crash:** - -- Listen to process 'exit' event -- If unexpected exit: - - Log error - - Notify renderer via IPC - - Remove from tracking - -### 7. Add Logging - -**Log output forwarding:** - -- Listen to stdout/stderr -- Parse into lines -- Send to renderer via IPC events - - Event: 'instance:log' - - Payload: { pid, level: 'info' | 'error', message } - -**Log important events:** - -- Process spawned -- Port discovered -- Process exited -- Errors occurred - -### 8. Add IPC Handlers - -**electron/main/ipc.ts (new file):** - -**Register handlers:** - -```typescript -ipcMain.handle("process:spawn", async (event, folder: string) => { - return await processManager.spawn(folder) -}) - -ipcMain.handle("process:kill", async (event, pid: number) => { - return await processManager.kill(pid) -}) - -ipcMain.handle("process:status", async (event, pid: number) => { - return processManager.getStatus(pid) -}) -``` - -**Send events:** - -```typescript -// When process exits unexpectedly -webContents.send("process:exited", { pid, code, signal }) - -// When log output received -webContents.send("process:log", { pid, level, message }) -``` - -### 9. Update Preload Script - -**electron/preload/index.ts additions:** - -**Expose methods:** - -```typescript -electronAPI: { - spawnServer: (folder: string) => Promise<{ pid: number, port: number }> - killServer: (pid: number) => Promise - getServerStatus: (pid: number) => Promise - - onServerExited: (callback: (data: any) => void) => void - onServerLog: (callback: (data: any) => void) => void -} -``` - -**Type definitions:** - -```typescript -interface ProcessInfo { - pid: number - port: number -} - -interface ElectronAPI { - // ... previous methods - spawnServer: (folder: string) => Promise - killServer: (pid: number) => Promise - getServerStatus: (pid: number) => Promise<"running" | "stopped" | "unknown"> - onServerExited: (callback: (data: { pid: number; code: number }) => void) => void - onServerLog: (callback: (data: { pid: number; level: string; message: string }) => void) => void -} -``` - -### 10. Create Instance Store - -**src/stores/instances.ts:** - -**State:** - -```typescript -interface Instance { - id: string // UUID - folder: string - port: number - pid: number - status: "starting" | "ready" | "error" | "stopped" - error?: string -} - -interface InstanceStore { - instances: Map - activeInstanceId: string | null -} -``` - -**Actions:** - -```typescript -async function createInstance(folder: string) { - const id = generateId() - - // Add with 'starting' status - instances.set(id, { - id, - folder, - port: 0, - pid: 0, - status: "starting", - }) - - try { - // Spawn server - const { pid, port } = await window.electronAPI.spawnServer(folder) - - // Update with port and pid - instances.set(id, { - ...instances.get(id)!, - port, - pid, - status: "ready", - }) - - return id - } catch (error) { - // Update with error - instances.set(id, { - ...instances.get(id)!, - status: "error", - error: error.message, - }) - throw error - } -} - -async function removeInstance(id: string) { - const instance = instances.get(id) - if (!instance) return - - // Kill server - if (instance.pid) { - await window.electronAPI.killServer(instance.pid) - } - - // Remove from store - instances.delete(id) - - // If was active, clear active - if (activeInstanceId === id) { - activeInstanceId = null - } -} -``` - -### 11. Wire Up Folder Selection - -**src/App.tsx updates:** - -**After folder selected:** - -```typescript -async function handleSelectFolder() { - const folder = await window.electronAPI.selectFolder() - if (!folder) return - - try { - const instanceId = await createInstance(folder) - setActiveInstance(instanceId) - - // Hide empty state, show instance UI - setHasInstances(true) - } catch (error) { - console.error("Failed to create instance:", error) - // TODO: Show error toast - } -} -``` - -**Listen for process exit:** - -```typescript -onMount(() => { - window.electronAPI.onServerExited(({ pid }) => { - // Find instance by PID - const instance = Array.from(instances.values()).find((i) => i.pid === pid) - - if (instance) { - // Update status - instances.set(instance.id, { - ...instance, - status: "stopped", - }) - - // TODO: Show notification (Task 010) - } - }) -}) -``` - -## Testing Checklist - -**Manual Tests:** - -1. Select folder → Server spawns -2. Console shows "Spawned PID: XXX, Port: YYYY" -3. Check `ps aux | grep opencode` → Process running -4. Quit app → Process killed -5. Select invalid folder → Error shown -6. Select without opencode installed → Helpful error -7. Spawn multiple instances → All tracked -8. Kill one instance → Others continue running - -**Error Cases:** - -- opencode not in PATH -- Permission denied on folder -- Port already in use (should not happen with port 0) -- Server crashes immediately -- Timeout (server takes >10s to start) - -**Edge Cases:** - -- Very long folder path -- Folder with spaces in name -- Folder on network drive (slow to spawn) -- Multiple instances same folder (different ports) - -## Dependencies - -- **Blocks:** Task 004 (needs running server to connect SDK) -- **Blocked by:** Task 001, Task 002 - -## Estimated Time - -4-5 hours - -## Notes - -- Security: Never use shell execution with user input -- Cross-platform: Test on macOS, Windows, Linux -- Error messages must be actionable -- Log everything for debugging -- Consider rate limiting (max 10 instances?) -- Memory: Track process memory usage (future enhancement) diff --git a/tasks/done/004-sdk-integration.md b/tasks/done/004-sdk-integration.md deleted file mode 100644 index 0ac579ff1..000000000 --- a/tasks/done/004-sdk-integration.md +++ /dev/null @@ -1,504 +0,0 @@ -# Task 004: SDK Client Integration & Session Management - -## Goal - -Integrate the OpenCode SDK to communicate with running servers, fetch session lists, and manage session lifecycle. - -## Prerequisites - -- Task 003 completed (server spawning works) -- OpenCode SDK package available -- Understanding of HTTP/REST APIs -- Understanding of SolidJS reactivity - -## Acceptance Criteria - -- [ ] SDK client created per instance -- [ ] Can fetch session list from server -- [ ] Can create new session -- [ ] Can get session details -- [ ] Can delete session -- [ ] Client lifecycle tied to instance lifecycle -- [ ] Error handling for network failures -- [ ] Proper TypeScript types throughout - -## Steps - -### 1. Create SDK Manager Module - -**src/lib/sdk-manager.ts:** - -**Purpose:** - -- Manage SDK client instances -- One client per server (per port) -- Create, retrieve, destroy clients - -**Interface:** - -```typescript -interface SDKManager { - createClient(port: number): OpenCodeClient - getClient(port: number): OpenCodeClient | null - destroyClient(port: number): void - destroyAll(): void -} -``` - -**Implementation details:** - -- Store clients in Map -- Create client with base URL: `http://localhost:${port}` -- Handle client creation errors -- Clean up on destroy - -### 2. Update Instance Store - -**src/stores/instances.ts additions:** - -**Add client to Instance:** - -```typescript -interface Instance { - // ... existing fields - client: OpenCodeClient | null -} -``` - -**Update createInstance:** - -- After server spawns successfully -- Create SDK client for that port -- Store in instance.client -- Handle client creation errors - -**Update removeInstance:** - -- Destroy SDK client before removing -- Call sdkManager.destroyClient(port) - -### 3. Create Session Store - -**src/stores/sessions.ts:** - -**State structure:** - -```typescript -interface Session { - id: string - instanceId: string - title: string - parentId: string | null - agent: string - model: { - providerId: string - modelId: string - } - time: { - created: number - updated: number - } -} - -interface SessionStore { - // Sessions grouped by instance - sessions: Map> - - // Active session per instance - activeSessionId: Map -} -``` - -**Core actions:** - -```typescript -// Fetch all sessions for an instance -async function fetchSessions(instanceId: string): Promise - -// Create new session -async function createSession(instanceId: string, agent: string): Promise - -// Delete session -async function deleteSession(instanceId: string, sessionId: string): Promise - -// Set active session -function setActiveSession(instanceId: string, sessionId: string): void - -// Get active session -function getActiveSession(instanceId: string): Session | null - -// Get all sessions for instance -function getSessions(instanceId: string): Session[] -``` - -### 4. Implement Session Fetching - -**fetchSessions implementation:** - -```typescript -async function fetchSessions(instanceId: string) { - const instance = instances.get(instanceId) - if (!instance || !instance.client) { - throw new Error("Instance not ready") - } - - try { - const response = await instance.client.session.list() - - // Convert API response to Session objects - const sessionMap = new Map() - - for (const apiSession of response.data) { - sessionMap.set(apiSession.id, { - id: apiSession.id, - instanceId, - title: apiSession.title || "Untitled", - parentId: apiSession.parentId || null, - agent: "", // Will be populated from messages - model: { providerId: "", modelId: "" }, - time: { - created: apiSession.time.created, - updated: apiSession.time.updated, - }, - }) - } - - sessions.set(instanceId, sessionMap) - } catch (error) { - console.error("Failed to fetch sessions:", error) - throw error - } -} -``` - -### 5. Implement Session Creation - -**createSession implementation:** - -```typescript -async function createSession(instanceId: string, agent: string): Promise { - const instance = instances.get(instanceId) - if (!instance || !instance.client) { - throw new Error("Instance not ready") - } - - try { - const response = await instance.client.session.create({ - // OpenCode API might need specific params - }) - - const session: Session = { - id: response.data.id, - instanceId, - title: "New Session", - parentId: null, - agent, - model: { providerId: "", modelId: "" }, - time: { - created: Date.now(), - updated: Date.now(), - }, - } - - // Add to store - const instanceSessions = sessions.get(instanceId) || new Map() - instanceSessions.set(session.id, session) - sessions.set(instanceId, instanceSessions) - - return session - } catch (error) { - console.error("Failed to create session:", error) - throw error - } -} -``` - -### 6. Implement Session Deletion - -**deleteSession implementation:** - -```typescript -async function deleteSession(instanceId: string, sessionId: string): Promise { - const instance = instances.get(instanceId) - if (!instance || !instance.client) { - throw new Error("Instance not ready") - } - - try { - await instance.client.session.delete({ path: { id: sessionId } }) - - // Remove from store - const instanceSessions = sessions.get(instanceId) - if (instanceSessions) { - instanceSessions.delete(sessionId) - } - - // Clear active if it was active - if (activeSessionId.get(instanceId) === sessionId) { - activeSessionId.delete(instanceId) - } - } catch (error) { - console.error("Failed to delete session:", error) - throw error - } -} -``` - -### 7. Implement Agent & Model Fetching - -**Fetch available agents:** - -```typescript -interface Agent { - name: string - description: string - mode: string -} - -async function fetchAgents(instanceId: string): Promise { - const instance = instances.get(instanceId) - if (!instance || !instance.client) { - throw new Error("Instance not ready") - } - - try { - const response = await instance.client.agent.list() - return response.data.filter((agent) => agent.mode !== "subagent") - } catch (error) { - console.error("Failed to fetch agents:", error) - return [] - } -} -``` - -**Fetch available models:** - -```typescript -interface Provider { - id: string - name: string - models: Model[] -} - -interface Model { - id: string - name: string - providerId: string -} - -async function fetchProviders(instanceId: string): Promise { - const instance = instances.get(instanceId) - if (!instance || !instance.client) { - throw new Error("Instance not ready") - } - - try { - const response = await instance.client.config.providers() - return response.data.providers.map((provider) => ({ - id: provider.id, - name: provider.name, - models: Object.entries(provider.models).map(([id, model]) => ({ - id, - name: model.name, - providerId: provider.id, - })), - })) - } catch (error) { - console.error("Failed to fetch providers:", error) - return [] - } -} -``` - -### 8. Add Error Handling - -**Network error handling:** - -```typescript -function handleSDKError(error: any): string { - if (error.code === "ECONNREFUSED") { - return "Cannot connect to server. Is it running?" - } - if (error.code === "ETIMEDOUT") { - return "Request timed out. Please try again." - } - if (error.response?.status === 404) { - return "Resource not found" - } - if (error.response?.status === 500) { - return "Server error. Check logs." - } - return error.message || "Unknown error occurred" -} -``` - -**Retry logic (for transient failures):** - -```typescript -async function withRetry(fn: () => Promise, maxRetries = 3, delay = 1000): Promise { - let lastError - - for (let i = 0; i < maxRetries; i++) { - try { - return await fn() - } catch (error) { - lastError = error - if (i < maxRetries - 1) { - await new Promise((resolve) => setTimeout(resolve, delay)) - } - } - } - - throw lastError -} -``` - -### 9. Add Loading States - -**Track loading states:** - -```typescript -interface LoadingState { - fetchingSessions: Map - creatingSession: Map - deletingSession: Map> -} - -const loading: LoadingState = { - fetchingSessions: new Map(), - creatingSession: new Map(), - deletingSession: new Map(), -} -``` - -**Use in actions:** - -```typescript -async function fetchSessions(instanceId: string) { - loading.fetchingSessions.set(instanceId, true) - try { - // ... fetch logic - } finally { - loading.fetchingSessions.set(instanceId, false) - } -} -``` - -### 10. Wire Up to Instance Creation - -**src/stores/instances.ts updates:** - -**After server ready:** - -```typescript -async function createInstance(folder: string) { - // ... spawn server ... - - // Create SDK client - const client = sdkManager.createClient(port) - - // Update instance - instances.set(id, { - ...instances.get(id)!, - port, - pid, - client, - status: "ready", - }) - - // Fetch initial data - try { - await fetchSessions(id) - await fetchAgents(id) - await fetchProviders(id) - } catch (error) { - console.error("Failed to fetch initial data:", error) - // Don't fail instance creation, just log - } - - return id -} -``` - -### 11. Add Type Safety - -**src/types/session.ts:** - -```typescript -export interface Session { - id: string - instanceId: string - title: string - parentId: string | null - agent: string - model: { - providerId: string - modelId: string - } - time: { - created: number - updated: number - } -} - -export interface Agent { - name: string - description: string - mode: string -} - -export interface Provider { - id: string - name: string - models: Model[] -} - -export interface Model { - id: string - name: string - providerId: string -} -``` - -## Testing Checklist - -**Manual Tests:** - -1. Create instance → Sessions fetched automatically -2. Console shows session list -3. Create new session → Appears in list -4. Delete session → Removed from list -5. Network fails → Error message shown -6. Server not running → Graceful error - -**Error Cases:** - -- Server not responding (ECONNREFUSED) -- Request timeout -- 404 on session endpoint -- 500 server error -- Invalid session ID - -**Edge Cases:** - -- No sessions exist (empty list) -- Many sessions (100+) -- Session with very long title -- Parent-child session relationships - -## Dependencies - -- **Blocks:** Task 005 (needs session data) -- **Blocked by:** Task 003 (needs running server) - -## Estimated Time - -3-4 hours - -## Notes - -- Keep SDK calls isolated in store actions -- All SDK calls should have error handling -- Consider caching to reduce API calls -- Log all API calls for debugging -- Handle slow connections gracefully diff --git a/tasks/done/005-session-picker-modal.md b/tasks/done/005-session-picker-modal.md deleted file mode 100644 index 943d28ce5..000000000 --- a/tasks/done/005-session-picker-modal.md +++ /dev/null @@ -1,333 +0,0 @@ -# Task 005: Session Picker Modal - -## Goal - -Create the session picker modal that appears when an instance starts, allowing users to resume an existing session or create a new one. - -## Prerequisites - -- Task 004 completed (SDK integration, session fetching) -- Understanding of modal/dialog patterns -- Kobalte UI primitives knowledge - -## Acceptance Criteria - -- [ ] Modal appears after instance becomes ready -- [ ] Displays list of existing sessions -- [ ] Shows session metadata (title, timestamp) -- [ ] Allows creating new session with agent selection -- [ ] Can close modal (cancels instance creation) -- [ ] Keyboard navigation works (up/down, enter) -- [ ] Properly styled and accessible -- [ ] Loading states during fetch - -## Steps - -### 1. Create Session Picker Component - -**src/components/session-picker.tsx:** - -**Props:** - -```typescript -interface SessionPickerProps { - instanceId: string - open: boolean - onClose: () => void - onSessionSelect: (sessionId: string) => void - onNewSession: (agent: string) => void -} -``` - -**Structure:** - -- Modal backdrop (semi-transparent overlay) -- Modal dialog (centered card) -- Header: "OpenCode • {folder}" -- Section 1: Resume session list -- Separator: "or" -- Section 2: Create new session -- Footer: Cancel button - -### 2. Use Kobalte Dialog - -**Implementation approach:** - -```typescript -import { Dialog } from '@kobalte/core' - - !open && props.onClose()}> - - - - {/* Modal content */} - - - -``` - -**Styling:** - -- Overlay: Dark background, 50% opacity -- Content: White card, max-width 500px, centered -- Padding: 24px -- Border radius: 8px -- Shadow: Large elevation - -### 3. Create Session List Section - -**Resume Section:** - -- Header: "Resume a session:" -- List of sessions (max 10 recent) -- Each item shows: - - Title (truncated at 50 chars) - - Relative timestamp ("2h ago") - - Hover state - - Active selection state - -**Session Item Component:** - -```typescript -interface SessionItemProps { - session: Session - selected: boolean - onClick: () => void -} -``` - -**Empty state:** - -- Show when no sessions exist -- Text: "No previous sessions" -- Muted styling - -**Scrollable:** - -- If >5 sessions, add scroll -- Max height: 300px - -### 4. Create New Session Section - -**Structure:** - -- Header: "Start new session:" -- Agent selector dropdown -- "Start" button - -**Agent Selector:** - -- Dropdown using Kobalte Select -- Shows agent name -- Grouped by category if applicable -- Default: "Build" agent - -**Start Button:** - -- Primary button style -- Click triggers onNewSession callback -- Disabled while creating - -### 5. Add Loading States - -**While fetching sessions:** - -- Show skeleton list (3-4 placeholder items) -- Shimmer animation - -**While fetching agents:** - -- Agent dropdown shows "Loading..." -- Disabled state - -**While creating session:** - -- Start button shows spinner -- Disabled state -- Text changes to "Creating..." - -### 6. Wire Up to Instance Store - -**Show modal after instance ready:** - -**src/stores/ui.ts additions:** - -```typescript -interface UIStore { - sessionPickerInstance: string | null -} - -function showSessionPicker(instanceId: string) { - sessionPickerInstance = instanceId -} - -function hideSessionPicker() { - sessionPickerInstance = null -} -``` - -**src/stores/instances.ts updates:** - -```typescript -async function createInstance(folder: string) { - // ... spawn and connect ... - - // Show session picker - showSessionPicker(id) - - return id -} -``` - -### 7. Handle Session Selection - -**Resume session:** - -```typescript -function handleSessionSelect(sessionId: string) { - setActiveSession(instanceId, sessionId) - hideSessionPicker() - - // Will trigger session display in Task 006 -} -``` - -**Create new session:** - -```typescript -async function handleNewSession(agent: string) { - try { - const session = await createSession(instanceId, agent) - setActiveSession(instanceId, session.id) - hideSessionPicker() - } catch (error) { - // Show error toast (Task 010) - console.error("Failed to create session:", error) - } -} -``` - -### 8. Handle Cancel - -**Close modal:** - -```typescript -function handleClose() { - // Remove instance since user cancelled - await removeInstance(instanceId) - hideSessionPicker() -} -``` - -**Confirmation if needed:** - -- If server already started, ask "Stop server?" -- Otherwise, just close - -### 9. Add Keyboard Navigation - -**Keyboard shortcuts:** - -- Up/Down: Navigate session list -- Enter: Select highlighted session -- Escape: Close modal (cancel) -- Tab: Cycle through sections - -**Implement focus management:** - -- Auto-focus first session on open -- Trap focus within modal -- Restore focus on close - -### 10. Add Accessibility - -**ARIA attributes:** - -- `role="dialog"` -- `aria-labelledby="dialog-title"` -- `aria-describedby="dialog-description"` -- `aria-modal="true"` - -**Screen reader support:** - -- Announce "X sessions available" -- Announce selection changes -- Clear focus indicators - -### 11. Style Refinements - -**Light/Dark mode:** - -- Test in both themes -- Ensure contrast meets WCAG AA -- Use CSS variables for colors - -**Responsive:** - -- Works at minimum window size -- Mobile-friendly (future web version) -- Scales text appropriately - -**Animations:** - -- Fade in backdrop (200ms) -- Scale in content (200ms) -- Smooth transitions on hover - -### 12. Update App Component - -**src/App.tsx:** - -**Render session picker:** - -```typescript - - {(instanceId) => ( - ui.hideSessionPicker()} - onSessionSelect={(id) => handleSessionSelect(instanceId(), id)} - onNewSession={(agent) => handleNewSession(instanceId(), agent)} - /> - )} - -``` - -## Testing Checklist - -**Manual Tests:** - -1. Create instance → Modal appears -2. Shows session list if sessions exist -3. Shows empty state if no sessions -4. Click session → Modal closes, session activates -5. Select agent, click Start → New session created -6. Press Escape → Modal closes, instance removed -7. Keyboard navigation works -8. Screen reader announces content - -**Edge Cases:** - -- No sessions + no agents (error state) -- Very long session titles (truncate) -- Many sessions (scroll works) -- Create session fails (error shown) -- Slow network (loading states) - -## Dependencies - -- **Blocks:** Task 006 (needs active session) -- **Blocked by:** Task 004 (needs session data) - -## Estimated Time - -3-4 hours - -## Notes - -- Keep modal simple and focused -- Clear call-to-action -- Don't overwhelm with options -- Loading states crucial for UX -- Consider adding search if >20 sessions (future) diff --git a/tasks/done/006-instance-session-tabs.md b/tasks/done/006-instance-session-tabs.md deleted file mode 100644 index 1188a754c..000000000 --- a/tasks/done/006-instance-session-tabs.md +++ /dev/null @@ -1,591 +0,0 @@ -# Task 006: Instance & Session Tabs - -## Goal - -Create the two-level tab navigation system: instance tabs (Level 1) and session tabs (Level 2) that allow users to switch between projects and conversations. - -## Prerequisites - -- Task 005 completed (Session picker modal, active session selection) -- Understanding of tab navigation patterns -- Familiarity with SolidJS For/Show components -- Knowledge of keyboard accessibility - -## Acceptance Criteria - -- [ ] Instance tabs render at top level -- [ ] Session tabs render below instance tabs for active instance -- [ ] Can switch between instance tabs -- [ ] Can switch between session tabs within an instance -- [ ] Active tab is visually highlighted -- [ ] Tab labels show appropriate text (folder name, session title) -- [ ] Close buttons work on tabs (with confirmation) -- [ ] "+" button creates new instance/session -- [ ] Keyboard navigation works (Cmd/Ctrl+1-9 for tabs) -- [ ] Tabs scroll horizontally when many exist -- [ ] Properly styled and accessible - -## Steps - -### 1. Create Instance Tabs Component - -**src/components/instance-tabs.tsx:** - -**Props:** - -```typescript -interface InstanceTabsProps { - instances: Map - activeInstanceId: string | null - onSelect: (instanceId: string) => void - onClose: (instanceId: string) => void - onNew: () => void -} -``` - -**Structure:** - -```tsx -
-
- - {([id, instance]) => ( - onSelect(id)} - onClose={() => onClose(id)} - /> - )} - - -
-
-``` - -**Styling:** - -- Horizontal layout -- Background: Secondary background color -- Border bottom: 1px solid border color -- Height: 40px -- Padding: 0 8px -- Overflow-x: auto (for many tabs) - -### 2. Create Instance Tab Item Component - -**src/components/instance-tab.tsx:** - -**Props:** - -```typescript -interface InstanceTabProps { - instance: Instance - active: boolean - onSelect: () => void - onClose: () => void -} -``` - -**Structure:** - -```tsx - - -``` - -**Styling:** - -- Display: inline-flex -- Align items center -- Gap: 8px -- Padding: 8px 12px -- Border radius: 6px 6px 0 0 -- Max width: 200px -- Truncate text with ellipsis -- Active: Background accent color -- Inactive: Transparent background -- Hover: Light background - -**Folder Name Formatting:** - -```typescript -function formatFolderName(path: string): string { - const name = path.split("/").pop() || path - return `~/${name}` -} -``` - -**Handle Duplicates:** - -- If multiple instances have same folder name, add counter -- Example: `~/project`, `~/project (2)`, `~/project (3)` - -### 3. Create Session Tabs Component - -**src/components/session-tabs.tsx:** - -**Props:** - -```typescript -interface SessionTabsProps { - instanceId: string - sessions: Map - activeSessionId: string | null - onSelect: (sessionId: string) => void - onClose: (sessionId: string) => void - onNew: () => void -} -``` - -**Structure:** - -```tsx -
-
- - {([id, session]) => ( - onSelect(id)} - onClose={() => onClose(id)} - /> - )} - - onSelect("logs")} /> - -
-
-``` - -**Styling:** - -- Similar to instance tabs but smaller -- Height: 36px -- Font size: 13px -- Less prominent than instance tabs - -### 4. Create Session Tab Item Component - -**src/components/session-tab.tsx:** - -**Props:** - -```typescript -interface SessionTabProps { - session?: Session - special?: "logs" - active: boolean - onSelect: () => void - onClose?: () => void -} -``` - -**Structure:** - -```tsx - - - -``` - -**Styling:** - -- Max width: 150px -- Truncate with ellipsis -- Active: Underline or bold text -- Logs tab: Slightly different color/icon - -### 5. Add Tab State Management - -**src/stores/ui.ts updates:** - -```typescript -interface UIState { - instanceTabOrder: string[] - sessionTabOrder: Map - - reorderInstanceTabs: (newOrder: string[]) => void - reorderSessionTabs: (instanceId: string, newOrder: string[]) => void -} - -const [instanceTabOrder, setInstanceTabOrder] = createSignal([]) -const [sessionTabOrder, setSessionTabOrder] = createSignal>(new Map()) - -function reorderInstanceTabs(newOrder: string[]) { - setInstanceTabOrder(newOrder) -} - -function reorderSessionTabs(instanceId: string, newOrder: string[]) { - setSessionTabOrder((prev) => { - const next = new Map(prev) - next.set(instanceId, newOrder) - return next - }) -} -``` - -### 6. Wire Up Tab Selection - -**src/stores/instances.ts updates:** - -```typescript -function setActiveInstance(id: string) { - activeInstanceId = id - - // Auto-select first session or show session picker - const instance = instances.get(id) - if (instance) { - const sessions = Array.from(instance.sessions.values()) - if (sessions.length > 0 && !instance.activeSessionId) { - instance.activeSessionId = sessions[0].id - } - } -} - -function setActiveSession(instanceId: string, sessionId: string) { - const instance = instances.get(instanceId) - if (instance) { - instance.activeSessionId = sessionId - } -} -``` - -### 7. Handle Tab Close Actions - -**Close Instance Tab:** - -```typescript -async function handleCloseInstance(instanceId: string) { - const confirmed = await showConfirmDialog({ - title: "Stop OpenCode instance?", - message: `This will stop the server for ${instance.folder}`, - confirmText: "Stop Instance", - destructive: true, - }) - - if (confirmed) { - await removeInstance(instanceId) - } -} -``` - -**Close Session Tab:** - -```typescript -async function handleCloseSession(instanceId: string, sessionId: string) { - const session = getInstance(instanceId)?.sessions.get(sessionId) - - if (session && session.messages.length > 0) { - const confirmed = await showConfirmDialog({ - title: "Delete session?", - message: `This will permanently delete "${session.title}"`, - confirmText: "Delete", - destructive: true, - }) - - if (!confirmed) return - } - - await deleteSession(instanceId, sessionId) - - // Switch to another session - const instance = getInstance(instanceId) - const remainingSessions = Array.from(instance.sessions.values()) - if (remainingSessions.length > 0) { - setActiveSession(instanceId, remainingSessions[0].id) - } else { - // Show session picker - showSessionPicker(instanceId) - } -} -``` - -### 8. Handle New Tab Buttons - -**New Instance:** - -```typescript -async function handleNewInstance() { - const folder = await window.electronAPI.selectFolder() - if (folder) { - await createInstance(folder) - } -} -``` - -**New Session:** - -```typescript -async function handleNewSession(instanceId: string) { - // For now, use default agent - // Later (Task 011) will show agent selector - const session = await createSession(instanceId, "build") - setActiveSession(instanceId, session.id) -} -``` - -### 9. Update App Layout - -**src/App.tsx:** - -```tsx -
- 0} fallback={}> - - - - {(instance) => ( - <> - setActiveSession(instance().id, id)} - onClose={(id) => handleCloseSession(instance().id, id)} - onNew={() => handleNewSession(instance().id)} - /> - -
- {/* Message stream and input will go here in Task 007 */} - - - - -
Session content will appear here (Task 007)
-
-
- - )} -
-
-
-``` - -### 10. Add Keyboard Shortcuts - -**Keyboard navigation:** - -```typescript -// src/lib/keyboard.ts - -export function setupTabKeyboardShortcuts() { - window.addEventListener("keydown", (e) => { - // Cmd/Ctrl + 1-9: Switch instance tabs - if ((e.metaKey || e.ctrlKey) && e.key >= "1" && e.key <= "9") { - e.preventDefault() - const index = parseInt(e.key) - 1 - const instances = Array.from(instanceStore.instances.keys()) - if (instances[index]) { - setActiveInstance(instances[index]) - } - } - - // Cmd/Ctrl + N: New instance - if ((e.metaKey || e.ctrlKey) && e.key === "n") { - e.preventDefault() - handleNewInstance() - } - - // Cmd/Ctrl + T: New session - if ((e.metaKey || e.ctrlKey) && e.key === "t") { - e.preventDefault() - if (activeInstanceId()) { - handleNewSession(activeInstanceId()!) - } - } - - // Cmd/Ctrl + W: Close current tab - if ((e.metaKey || e.ctrlKey) && e.key === "w") { - e.preventDefault() - const instanceId = activeInstanceId() - const instance = getInstance(instanceId) - if (instance?.activeSessionId && instance.activeSessionId !== "logs") { - handleCloseSession(instanceId!, instance.activeSessionId) - } - } - }) -} -``` - -**Call in main.tsx:** - -```typescript -import { setupTabKeyboardShortcuts } from "./lib/keyboard" - -onMount(() => { - setupTabKeyboardShortcuts() -}) -``` - -### 11. Add Accessibility - -**ARIA attributes:** - -```tsx -
- -
- -
- {/* Session tabs */} -
-``` - -**Focus management:** - -- Tab key cycles through tabs -- Arrow keys navigate within tab list -- Focus indicators visible -- Skip links for screen readers - -### 12. Style Refinements - -**Horizontal scroll:** - -```css -.tabs-container { - display: flex; - overflow-x: auto; - overflow-y: hidden; - scrollbar-width: thin; -} - -.tabs-container::-webkit-scrollbar { - height: 4px; -} - -.tabs-container::-webkit-scrollbar-thumb { - background: var(--border-color); - border-radius: 2px; -} -``` - -**Tab animations:** - -```css -.instance-tab, -.session-tab { - transition: background-color 150ms ease; -} - -.instance-tab:hover, -.session-tab:hover { - background-color: var(--hover-background); -} - -.instance-tab.active, -.session-tab.active { - background-color: var(--active-background); -} -``` - -**Close button styling:** - -```css -.tab-close { - opacity: 0; - transition: opacity 150ms ease; -} - -.instance-tab:hover .tab-close, -.session-tab:hover .tab-close { - opacity: 1; -} - -.tab-close:hover { - background-color: var(--danger-background); - color: var(--danger-color); -} -``` - -## Testing Checklist - -**Manual Tests:** - -1. Create instance → Instance tab appears -2. Click instance tab → Switches active instance -3. Session tabs appear below active instance -4. Click session tab → Switches active session -5. Click "+" on instance tabs → Opens folder picker -6. Click "+" on session tabs → Creates new session -7. Click close on instance tab → Shows confirmation, closes -8. Click close on session tab → Closes session -9. Cmd/Ctrl+1 switches to first instance -10. Cmd/Ctrl+N opens new instance -11. Cmd/Ctrl+T creates new session -12. Cmd/Ctrl+W closes active session -13. Tabs scroll when many exist -14. Logs tab always visible and non-closable -15. Tab labels truncate long names - -**Edge Cases:** - -- Only one instance (no scrolling needed) -- Many instances (>10, horizontal scroll) -- No sessions in instance (only Logs tab visible) -- Duplicate folder names (counter added) -- Very long folder/session names (ellipsis) -- Close last session (session picker appears) -- Switch instance while session is streaming - -## Dependencies - -- **Blocks:** Task 007 (needs tab structure to display messages) -- **Blocked by:** Task 005 (needs session selection to work) - -## Estimated Time - -4-5 hours - -## Notes - -- Keep tab design clean and minimal -- Don't over-engineer tab reordering (can add later) -- Focus on functionality over fancy animations -- Ensure keyboard accessibility from the start -- Tab state will persist in Task 017 -- Context menus for tabs can be added in Task 026 diff --git a/tasks/done/007-message-display.md b/tasks/done/007-message-display.md deleted file mode 100644 index 6c01510dd..000000000 --- a/tasks/done/007-message-display.md +++ /dev/null @@ -1,812 +0,0 @@ -# Task 007: Message Display - -## Goal - -Create the message display component that renders user and assistant messages in a scrollable stream, showing message content, tool calls, and streaming states. - -> Note: This legacy task predates `message-stream-v2` and the normalized message store; the new implementation lives under `packages/ui/src/components/message-stream-v2.tsx`. - -## Prerequisites - -- Task 006 completed (Tab navigation in place) -- Understanding of message part structure from OpenCode SDK -- Familiarity with markdown rendering -- Knowledge of SolidJS For/Show components - -## Acceptance Criteria - -- [ ] Messages render in chronological order -- [ ] User messages display with correct styling -- [ ] Assistant messages display with agent label -- [ ] Text content renders properly -- [ ] Tool calls display inline with collapse/expand -- [ ] Auto-scroll to bottom on new messages -- [ ] Manual scroll up disables auto-scroll -- [ ] "Scroll to bottom" button appears when scrolled up -- [ ] Empty state shows when no messages -- [ ] Loading state shows when fetching messages -- [ ] Timestamps display for each message -- [ ] Messages are accessible and keyboard-navigable - -## Steps - -### 1. Define Message Types - -**src/types/message.ts:** - -```typescript -export interface Message { - id: string - sessionId: string - type: "user" | "assistant" - parts: MessagePart[] - timestamp: number - status: "sending" | "sent" | "streaming" | "complete" | "error" -} - -export type MessagePart = TextPart | ToolCallPart | ToolResultPart | ErrorPart - -export interface TextPart { - type: "text" - text: string -} - -export interface ToolCallPart { - type: "tool_call" - id: string - tool: string - input: any - status: "pending" | "running" | "success" | "error" -} - -export interface ToolResultPart { - type: "tool_result" - toolCallId: string - output: any - error?: string -} - -export interface ErrorPart { - type: "error" - message: string -} -``` - -### 2. Create Message Stream Component - -**src/components/message-stream.tsx:** - -```typescript -import { For, Show, createSignal, onMount, onCleanup } from "solid-js" -import { Message } from "../types/message" -import MessageItem from "./message-item" - -interface MessageStreamProps { - sessionId: string - messages: Message[] - loading?: boolean -} - -export default function MessageStream(props: MessageStreamProps) { - let containerRef: HTMLDivElement | undefined - const [autoScroll, setAutoScroll] = createSignal(true) - const [showScrollButton, setShowScrollButton] = createSignal(false) - - function scrollToBottom() { - if (containerRef) { - containerRef.scrollTop = containerRef.scrollHeight - setAutoScroll(true) - setShowScrollButton(false) - } - } - - function handleScroll() { - if (!containerRef) return - - const { scrollTop, scrollHeight, clientHeight } = containerRef - const isAtBottom = scrollHeight - scrollTop - clientHeight < 50 - - setAutoScroll(isAtBottom) - setShowScrollButton(!isAtBottom) - } - - onMount(() => { - if (autoScroll()) { - scrollToBottom() - } - }) - - // Auto-scroll when new messages arrive - const messagesLength = () => props.messages.length - createEffect(() => { - messagesLength() // Track changes - if (autoScroll()) { - setTimeout(scrollToBottom, 0) - } - }) - - return ( -
-
- -
-
-

Start a conversation

-

Type a message below or try:

-
    -
  • /init-project
  • -
  • Ask about your codebase
  • -
  • Attach files with @
  • -
-
-
-
- - -
-
-

Loading messages...

-
- - - - {(message) => ( - - )} - -
- - - - -
- ) -} -``` - -### 3. Create Message Item Component - -**src/components/message-item.tsx:** - -```typescript -import { For, Show } from "solid-js" -import { Message } from "../types/message" -import MessagePart from "./message-part" - -interface MessageItemProps { - message: Message -} - -export default function MessageItem(props: MessageItemProps) { - const isUser = () => props.message.type === "user" - const timestamp = () => { - const date = new Date(props.message.timestamp) - return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) - } - - return ( -
-
- - {isUser() ? "You" : "Assistant"} - - {timestamp()} -
- -
- - {(part) => } - -
- - -
- ⚠ Message failed to send -
-
-
- ) -} -``` - -### 4. Create Message Part Component - -**src/components/message-part.tsx:** - -```typescript -import { Show, Match, Switch } from "solid-js" -import { MessagePart as MessagePartType } from "../types/message" -import ToolCall from "./tool-call" - -interface MessagePartProps { - part: MessagePartType -} - -export default function MessagePart(props: MessagePartProps) { - return ( - - -
- {(props.part as any).text} -
-
- - - - - - -
- ⚠ {(props.part as any).message} -
-
-
- ) -} -``` - -### 5. Create Tool Call Component - -**src/components/tool-call.tsx:** - -```typescript -import { createSignal, Show } from "solid-js" -import { ToolCallPart } from "../types/message" - -interface ToolCallProps { - toolCall: ToolCallPart -} - -export default function ToolCall(props: ToolCallProps) { - const [expanded, setExpanded] = createSignal(false) - - const statusIcon = () => { - switch (props.toolCall.status) { - case "pending": - return "⏳" - case "running": - return "⏳" - case "success": - return "✓" - case "error": - return "✗" - default: - return "" - } - } - - const statusClass = () => { - return `tool-call-status-${props.toolCall.status}` - } - - function toggleExpanded() { - setExpanded(!expanded()) - } - - function formatToolSummary() { - // Create a brief summary of the tool call - const { tool, input } = props.toolCall - - switch (tool) { - case "bash": - return `bash: ${input.command}` - case "edit": - return `edit ${input.filePath}` - case "read": - return `read ${input.filePath}` - case "write": - return `write ${input.filePath}` - default: - return `${tool}` - } - } - - return ( -
- - - -
-
-

Input:

-
{JSON.stringify(props.toolCall.input, null, 2)}
-
- - -
-

Output:

-
{formatToolOutput()}
-
-
-
-
-
- ) - - function formatToolOutput() { - // This will be enhanced in later tasks - // For now, just stringify - return "Output will be displayed here" - } -} -``` - -### 6. Add Message Store Integration - -**src/stores/sessions.ts updates:** - -```typescript -interface Session { - // ... existing fields - messages: Message[] -} - -async function loadMessages(instanceId: string, sessionId: string) { - const instance = getInstance(instanceId) - if (!instance) return - - try { - // Fetch messages from SDK - const response = await instance.client.session.getMessages(sessionId) - - // Update session with messages - const session = instance.sessions.get(sessionId) - if (session) { - session.messages = response.messages.map(transformMessage) - } - } catch (error) { - console.error("Failed to load messages:", error) - throw error - } -} - -function transformMessage(apiMessage: any): Message { - return { - id: apiMessage.id, - sessionId: apiMessage.sessionId, - type: apiMessage.type, - parts: apiMessage.parts || [], - timestamp: apiMessage.timestamp || Date.now(), - status: "complete", - } -} -``` - -### 7. Update App to Show Messages - -**src/App.tsx updates:** - -```tsx - - {() => { - const session = instance().sessions.get(instance().activeSessionId!) - - return ( - Session not found
}> - {(s) => } - - ) - }} - -``` - -### 8. Add Styling - -**src/components/message-stream.css:** - -```css -.message-stream-container { - position: relative; - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; -} - -.message-stream { - flex: 1; - overflow-y: auto; - padding: 16px; - display: flex; - flex-direction: column; - gap: 16px; -} - -.message-item { - display: flex; - flex-direction: column; - gap: 8px; - padding: 12px 16px; - border-radius: 8px; - max-width: 85%; -} - -.message-item.user { - align-self: flex-end; - background-color: var(--user-message-bg); -} - -.message-item.assistant { - align-self: flex-start; - background-color: var(--assistant-message-bg); -} - -.message-header { - display: flex; - justify-content: space-between; - align-items: center; - gap: 12px; -} - -.message-sender { - font-weight: 600; - font-size: 14px; -} - -.message-timestamp { - font-size: 12px; - color: var(--text-muted); -} - -.message-content { - display: flex; - flex-direction: column; - gap: 8px; -} - -.message-text { - font-size: 14px; - line-height: 1.5; - white-space: pre-wrap; - word-wrap: break-word; -} - -.tool-call { - margin: 8px 0; - border: 1px solid var(--border-color); - border-radius: 6px; - overflow: hidden; -} - -.tool-call-header { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 12px; - width: 100%; - background-color: var(--secondary-bg); - border: none; - cursor: pointer; - font-family: monospace; - font-size: 13px; -} - -.tool-call-header:hover { - background-color: var(--hover-bg); -} - -.tool-call-icon { - font-size: 10px; -} - -.tool-call-summary { - flex: 1; - text-align: left; -} - -.tool-call-status { - font-size: 14px; -} - -.tool-call-status-success { - border-left: 3px solid var(--success-color); -} - -.tool-call-status-error { - border-left: 3px solid var(--error-color); -} - -.tool-call-status-running { - border-left: 3px solid var(--warning-color); -} - -.tool-call-details { - padding: 12px; - background-color: var(--code-bg); - display: flex; - flex-direction: column; - gap: 12px; -} - -.tool-call-section h4 { - font-size: 12px; - font-weight: 600; - margin-bottom: 4px; - color: var(--text-muted); -} - -.tool-call-section pre { - margin: 0; - padding: 8px; - background-color: var(--background); - border-radius: 4px; - overflow-x: auto; -} - -.tool-call-section code { - font-family: monospace; - font-size: 12px; - line-height: 1.4; -} - -.scroll-to-bottom { - position: absolute; - bottom: 16px; - right: 16px; - width: 40px; - height: 40px; - border-radius: 50%; - background-color: var(--accent-color); - color: white; - border: none; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); - cursor: pointer; - font-size: 20px; - display: flex; - align-items: center; - justify-content: center; - transition: transform 150ms ease; -} - -.scroll-to-bottom:hover { - transform: scale(1.1); -} - -.empty-state { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - padding: 48px; -} - -.empty-state-content { - text-align: center; - max-width: 400px; -} - -.empty-state-content h3 { - font-size: 18px; - margin-bottom: 12px; -} - -.empty-state-content p { - font-size: 14px; - color: var(--text-muted); - margin-bottom: 16px; -} - -.empty-state-content ul { - list-style: none; - padding: 0; - display: flex; - flex-direction: column; - gap: 8px; -} - -.empty-state-content li { - font-size: 14px; - color: var(--text-muted); -} - -.empty-state-content code { - background-color: var(--code-bg); - padding: 2px 6px; - border-radius: 3px; - font-family: monospace; - font-size: 13px; -} - -.loading-state { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 16px; - padding: 48px; -} - -.spinner { - width: 32px; - height: 32px; - border: 3px solid var(--border-color); - border-top-color: var(--accent-color); - border-radius: 50%; - animation: spin 1s linear infinite; -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} -``` - -### 9. Add CSS Variables - -**src/index.css updates:** - -```css -:root { - /* Message colors */ - --user-message-bg: #e3f2fd; - --assistant-message-bg: #f5f5f5; - - /* Status colors */ - --success-color: #4caf50; - --error-color: #f44336; - --warning-color: #ff9800; - - /* Code colors */ - --code-bg: #f8f8f8; -} - -[data-theme="dark"] { - --user-message-bg: #1e3a5f; - --assistant-message-bg: #2a2a2a; - --code-bg: #1a1a1a; -} -``` - -### 10. Load Messages on Session Switch - -**src/hooks/use-session.ts:** - -```typescript -import { createEffect } from "solid-js" - -export function useSession(instanceId: string, sessionId: string) { - createEffect(() => { - // Load messages when session becomes active - if (sessionId && sessionId !== "logs") { - loadMessages(instanceId, sessionId).catch(console.error) - } - }) -} -``` - -**Use in App.tsx:** - -```tsx - - {(s) => { - useSession(instance().id, s().id) - - return - }} - -``` - -### 11. Add Accessibility - -**ARIA attributes:** - -```tsx -
- {/* Messages */} -
- -
- {/* Message content */} -
-``` - -**Keyboard navigation:** - -- Messages should be accessible via Tab key -- Tool calls can be expanded with Enter/Space -- Screen readers announce new messages - -### 12. Handle Long Messages - -**Text wrapping:** - -```css -.message-text { - overflow-wrap: break-word; - word-wrap: break-word; - hyphens: auto; -} -``` - -**Code blocks (for now, just basic):** - -```css -.message-text pre { - overflow-x: auto; - padding: 8px; - background-color: var(--code-bg); - border-radius: 4px; -} -``` - -## Testing Checklist - -**Manual Tests:** - -1. Empty session shows empty state -2. Messages load when switching sessions -3. User messages appear on right -4. Assistant messages appear on left -5. Timestamps display correctly -6. Tool calls appear inline -7. Tool calls expand/collapse on click -8. Auto-scroll works for new messages -9. Manual scroll up disables auto-scroll -10. Scroll to bottom button appears/works -11. Long messages wrap correctly -12. Multiple messages display properly -13. Messages are keyboard accessible - -**Edge Cases:** - -- Session with 1 message -- Session with 100+ messages -- Messages with very long text -- Messages with no parts -- Tool calls with large output -- Rapid message updates -- Switching sessions while loading - -## Dependencies - -- **Blocks:** Task 008 (SSE will update these messages in real-time) -- **Blocked by:** Task 006 (needs tab structure) - -## Estimated Time - -4-5 hours - -## Notes - -- Keep styling simple for now - markdown rendering comes in Task 012 -- Tool output formatting will be enhanced in Task 010 -- Focus on basic text display and structure -- Don't optimize for virtual scrolling yet (MVP principle) -- Message actions (copy, edit, etc.) come in Task 026 -- This is the foundation for real-time updates in Task 008 diff --git a/tasks/done/008-sse-integration.md b/tasks/done/008-sse-integration.md deleted file mode 100644 index ec49de66f..000000000 --- a/tasks/done/008-sse-integration.md +++ /dev/null @@ -1,445 +0,0 @@ -# Task 008: SSE Integration - Real-time Message Streaming - -> Note: References to `message-stream.tsx` here are legacy; the current UI uses `message-stream-v2.tsx` with the normalized message store. - -## Status: TODO - -## Objective - -Implement Server-Sent Events (SSE) integration to enable real-time message streaming from OpenCode servers. Each instance will maintain its own EventSource connection to receive live updates for sessions and messages. - -## Prerequisites - -- Task 006 (Instance/Session tabs) complete -- Task 007 (Message display) complete -- SDK client configured per instance -- Understanding of EventSource API - -## Context - -The OpenCode server emits events via SSE at the `/events` endpoint. These events include: - -- Message updates (streaming content) -- Session updates (new sessions, title changes) -- Tool execution status updates -- Server status changes - -We need to: - -1. Create an SSE manager to handle connections -2. Connect one EventSource per instance -3. Route events to the correct instance/session -4. Update reactive state to trigger UI updates -5. Implement reconnection logic for dropped connections - -## Implementation Steps - -### Step 1: Create SSE Manager Module - -Create `src/lib/sse-manager.ts`: - -```typescript -import { createSignal } from "solid-js" - -interface SSEConnection { - instanceId: string - eventSource: EventSource - reconnectAttempts: number - status: "connecting" | "connected" | "disconnected" | "error" -} - -interface MessageUpdateEvent { - type: "message_updated" - sessionId: string - messageId: string - parts: any[] - status: string -} - -interface SessionUpdateEvent { - type: "session_updated" - session: any -} - -class SSEManager { - private connections = new Map() - private maxReconnectAttempts = 5 - private baseReconnectDelay = 1000 - - connect(instanceId: string, port: number): void { - if (this.connections.has(instanceId)) { - this.disconnect(instanceId) - } - - const url = `http://localhost:${port}/events` - const eventSource = new EventSource(url) - - const connection: SSEConnection = { - instanceId, - eventSource, - reconnectAttempts: 0, - status: "connecting", - } - - this.connections.set(instanceId, connection) - - eventSource.onopen = () => { - connection.status = "connected" - connection.reconnectAttempts = 0 - console.log(`[SSE] Connected to instance ${instanceId}`) - } - - eventSource.onmessage = (event) => { - try { - const data = JSON.parse(event.data) - this.handleEvent(instanceId, data) - } catch (error) { - console.error("[SSE] Failed to parse event:", error) - } - } - - eventSource.onerror = () => { - connection.status = "error" - console.error(`[SSE] Connection error for instance ${instanceId}`) - this.handleReconnect(instanceId, port) - } - } - - disconnect(instanceId: string): void { - const connection = this.connections.get(instanceId) - if (connection) { - connection.eventSource.close() - this.connections.delete(instanceId) - console.log(`[SSE] Disconnected from instance ${instanceId}`) - } - } - - private handleEvent(instanceId: string, event: any): void { - switch (event.type) { - case "message_updated": - this.onMessageUpdate?.(instanceId, event as MessageUpdateEvent) - break - case "session_updated": - this.onSessionUpdate?.(instanceId, event as SessionUpdateEvent) - break - default: - console.warn("[SSE] Unknown event type:", event.type) - } - } - - private handleReconnect(instanceId: string, port: number): void { - const connection = this.connections.get(instanceId) - if (!connection) return - - if (connection.reconnectAttempts >= this.maxReconnectAttempts) { - console.error(`[SSE] Max reconnection attempts reached for ${instanceId}`) - connection.status = "disconnected" - return - } - - const delay = this.baseReconnectDelay * Math.pow(2, connection.reconnectAttempts) - connection.reconnectAttempts++ - - console.log(`[SSE] Reconnecting to ${instanceId} in ${delay}ms (attempt ${connection.reconnectAttempts})`) - - setTimeout(() => { - this.connect(instanceId, port) - }, delay) - } - - onMessageUpdate?: (instanceId: string, event: MessageUpdateEvent) => void - onSessionUpdate?: (instanceId: string, event: SessionUpdateEvent) => void - - getStatus(instanceId: string): SSEConnection["status"] | null { - return this.connections.get(instanceId)?.status ?? null - } -} - -export const sseManager = new SSEManager() -``` - -### Step 2: Integrate SSE Manager with Instance Store - -Update `src/stores/instances.ts` to use SSE manager: - -```typescript -import { sseManager } from "../lib/sse-manager" - -// In createInstance function, after SDK client is created: -async function createInstance(folder: string) { - // ... existing code to spawn server and create SDK client ... - - // Connect SSE - sseManager.connect(instance.id, port) - - // Set up event handlers - sseManager.onMessageUpdate = (instanceId, event) => { - handleMessageUpdate(instanceId, event) - } - - sseManager.onSessionUpdate = (instanceId, event) => { - handleSessionUpdate(instanceId, event) - } -} - -// In removeInstance function: -async function removeInstance(id: string) { - // Disconnect SSE before removing - sseManager.disconnect(id) - - // ... existing cleanup code ... -} -``` - -### Step 3: Handle Message Update Events - -Create message update handler in instance store: - -```typescript -function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent) { - const instance = instances.get(instanceId) - if (!instance) return - - const session = instance.sessions.get(event.sessionId) - if (!session) return - - // Find or create message - let message = session.messages.find((m) => m.id === event.messageId) - - if (!message) { - // New message - add it - message = { - id: event.messageId, - sessionId: event.sessionId, - type: "assistant", // Determine from event - parts: event.parts, - timestamp: Date.now(), - status: event.status, - } - session.messages.push(message) - } else { - // Update existing message - message.parts = event.parts - message.status = event.status - } - - // Trigger reactivity - update the map reference - instances.set(instanceId, { ...instance }) -} -``` - -### Step 4: Handle Session Update Events - -Create session update handler: - -```typescript -function handleSessionUpdate(instanceId: string, event: SessionUpdateEvent) { - const instance = instances.get(instanceId) - if (!instance) return - - const existingSession = instance.sessions.get(event.session.id) - - if (!existingSession) { - // New session - add it - const newSession = { - id: event.session.id, - instanceId, - title: event.session.title || "Untitled", - parentId: event.session.parentId, - agent: event.session.agent, - model: event.session.model, - messages: [], - status: "idle", - createdAt: Date.now(), - updatedAt: Date.now(), - } - - instance.sessions.set(event.session.id, newSession) - - // Auto-create tab for child sessions - if (event.session.parentId) { - console.log(`[SSE] New child session created: ${event.session.id}`) - // Optionally auto-switch to new session - // instance.activeSessionId = event.session.id - } - } else { - // Update existing session - existingSession.title = event.session.title || existingSession.title - existingSession.agent = event.session.agent || existingSession.agent - existingSession.model = event.session.model || existingSession.model - existingSession.updatedAt = Date.now() - } - - // Trigger reactivity - instances.set(instanceId, { ...instance }) -} -``` - -### Step 5: Add Connection Status Indicator - -Update `src/components/message-stream.tsx` to show connection status: - -```typescript -import { sseManager } from "../lib/sse-manager" - -function MessageStream(props) { - const connectionStatus = () => sseManager.getStatus(props.instanceId) - - return ( -
- {/* Connection status indicator */} -
- {connectionStatus() === "connected" && ( - -
- Connected - - )} - {connectionStatus() === "connecting" && ( - -
- Connecting... - - )} - {connectionStatus() === "error" && ( - -
- Disconnected - - )} -
- - {/* Existing message list */} - {/* ... */} -
- ) -} -``` - -### Step 6: Test SSE Connection - -Create a test utility to verify SSE is working: - -```typescript -// In browser console or test file: -async function testSSE() { - // Manually trigger a message - const response = await fetch("http://localhost:4096/session/SESSION_ID/message", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - prompt: "Hello, world!", - attachments: [], - }), - }) - - // Check console for SSE events - // Should see message_updated events arriving -} -``` - -### Step 7: Handle Edge Cases - -Add error handling for: - -```typescript -// Connection drops during message streaming -// - Reconnect logic should handle this automatically -// - Messages should resume from last known state - -// Multiple instances with different ports -// - Each instance has its own EventSource -// - Events routed correctly via instanceId - -// Instance removed while connected -// - EventSource closed before instance cleanup -// - No memory leaks - -// Page visibility changes (browser tab inactive) -// - EventSource may pause, reconnect on focus -// - Consider using Page Visibility API to manage connections -``` - -## Testing Checklist - -### Manual Testing - -- [ ] Open instance, verify SSE connection established -- [ ] Send message, verify streaming events arrive -- [ ] Check browser DevTools Network tab for SSE connection -- [ ] Verify connection status indicator shows "Connected" -- [ ] Kill server process, verify reconnection attempts -- [ ] Restart server, verify successful reconnection -- [ ] Open multiple instances, verify independent connections -- [ ] Switch between instances, verify events route correctly -- [ ] Close instance tab, verify EventSource closed cleanly - -### Testing Message Streaming - -- [ ] Send message, watch events in console -- [ ] Verify message parts update in real-time -- [ ] Check assistant response streams character by character -- [ ] Verify tool calls appear as they execute -- [ ] Confirm message status updates (streaming → complete) - -### Testing Child Sessions - -- [ ] Trigger action that creates child session -- [ ] Verify session_updated event received -- [ ] Confirm new session tab appears -- [ ] Check parentId correctly set - -### Testing Reconnection - -- [ ] Disconnect network, verify reconnection attempts -- [ ] Reconnect network, verify successful reconnection -- [ ] Verify exponential backoff delays -- [ ] Confirm max attempts limit works - -## Acceptance Criteria - -- [ ] SSE connection established when instance created -- [ ] Message updates arrive in real-time -- [ ] Session updates handled correctly -- [ ] Child sessions auto-create tabs -- [ ] Connection status visible in UI -- [ ] Reconnection logic works with exponential backoff -- [ ] Multiple instances have independent connections -- [ ] EventSource closed when instance removed -- [ ] No console errors during normal operation -- [ ] Events route to correct instance/session - -## Performance Considerations - -**Note: Per MVP principles, don't over-optimize** - -- Simple event handling - no batching needed -- Direct state updates trigger reactivity -- Reconnection uses exponential backoff -- Only optimize if lag occurs in testing - -## Future Enhancements (Post-MVP) - -- Event batching for high-frequency updates -- Delta updates instead of full message parts -- Offline queue for events missed during disconnect -- Page Visibility API integration -- Event compression for large payloads - -## References - -- [Technical Implementation - SSE Event Handling](../docs/technical-implementation.md#sse-event-handling) -- [Architecture - Communication Layer](../docs/architecture.md#communication-layer) -- [MDN - EventSource API](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) - -## Estimated Time - -3-4 hours - -## Notes - -- Keep reconnection logic simple for MVP -- Log all SSE events to console for debugging -- Test with long-running streaming responses -- Verify memory usage doesn't grow over time -- Consider adding SSE event debugging panel (optional) diff --git a/tasks/done/009-prompt-input-basic.md b/tasks/done/009-prompt-input-basic.md deleted file mode 100644 index 63c76182f..000000000 --- a/tasks/done/009-prompt-input-basic.md +++ /dev/null @@ -1,520 +0,0 @@ -# Task 009: Prompt Input Basic - Text Input with Send Functionality - -## Status: TODO - -## Objective - -Implement a basic prompt input component that allows users to type messages and send them to the OpenCode server. This enables testing of the SSE integration and completes the core chat interface loop. - -## Prerequisites - -- Task 007 (Message display) complete -- Task 008 (SSE integration) complete -- Active session available -- SDK client configured - -## Context - -The prompt input is the primary way users interact with OpenCode. For the MVP, we need: - -- Simple text input (multi-line textarea) -- Send button -- Basic keyboard shortcuts (Enter to send, Shift+Enter for new line) -- Loading state while assistant is responding -- Basic validation (empty message prevention) - -Advanced features (slash commands, file attachments, @ mentions) will come in Task 021-024. - -## Implementation Steps - -### Step 1: Create Prompt Input Component - -Create `src/components/prompt-input.tsx`: - -```typescript -import { createSignal, Show } from "solid-js" - -interface PromptInputProps { - instanceId: string - sessionId: string - onSend: (prompt: string) => Promise - disabled?: boolean -} - -export default function PromptInput(props: PromptInputProps) { - const [prompt, setPrompt] = createSignal("") - const [sending, setSending] = createSignal(false) - let textareaRef: HTMLTextAreaElement | undefined - - function handleKeyDown(e: KeyboardEvent) { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault() - handleSend() - } - } - - async function handleSend() { - const text = prompt().trim() - if (!text || sending() || props.disabled) return - - setSending(true) - try { - await props.onSend(text) - setPrompt("") - - // Auto-resize textarea back to initial size - if (textareaRef) { - textareaRef.style.height = "auto" - } - } catch (error) { - console.error("Failed to send message:", error) - alert("Failed to send message: " + (error instanceof Error ? error.message : String(error))) - } finally { - setSending(false) - textareaRef?.focus() - } - } - - function handleInput(e: Event) { - const target = e.target as HTMLTextAreaElement - setPrompt(target.value) - - // Auto-resize textarea - target.style.height = "auto" - target.style.height = Math.min(target.scrollHeight, 200) + "px" - } - - const canSend = () => prompt().trim().length > 0 && !sending() && !props.disabled - - return ( -
-
-