diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1f9566d5..d2d8d3f7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -78,6 +78,10 @@ jobs: (process.env.R2_PUBLIC_BASE_URL ? `${process.env.R2_PUBLIC_BASE_URL.replace(/\/$/, "")}/latest.json` : ""); + if (!pubkey || !endpoint) { + console.error("TAURI_UPDATER_PUBKEY and an updater endpoint are required"); + process.exit(1); + } writeFileSync( process.env.RUNNER_TEMP + "/tauri.release.conf.json", JSON.stringify( @@ -107,6 +111,18 @@ jobs: TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: npx tauri build --bundles app,dmg --config "$RUNNER_TEMP/tauri.release.conf.json" + - name: Download Linux packages + uses: actions/download-artifact@v4 + with: + name: linux-packages + path: target/release/bundle/linux + + - name: Download Windows packages + uses: actions/download-artifact@v4 + with: + name: windows-packages + path: target/release/bundle/nsis + - name: Upload to R2 env: AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} @@ -122,14 +138,30 @@ jobs: TAR=$(find "$BUNDLE/macos" -maxdepth 1 -type f -name '*.app.tar.gz' -print -quit) SIG="${TAR}.sig" DMG=$(find "$BUNDLE/dmg" -maxdepth 1 -type f -name '*.dmg' -print -quit) + shopt -s nullglob + WINDOWS_INSTALLERS=("$BUNDLE"/nsis/*.exe) + WINDOWS_SIGNATURES=("$BUNDLE"/nsis/*.exe.sig) + if (( ${#WINDOWS_INSTALLERS[@]} != 1 || ${#WINDOWS_SIGNATURES[@]} != 1 )); then + echo "Expected exactly one Windows installer and updater signature" >&2 + exit 1 + fi + WINDOWS_INSTALLER="${WINDOWS_INSTALLERS[0]}" + WINDOWS_SIGNATURE="${WINDOWS_SIGNATURES[0]}" BASE="${R2_PUBLIC_BASE_URL%/}" ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" RELEASE_PREFIX="releases/${VERSION}" TAR_KEY="${RELEASE_PREFIX}/MonoCode.app.tar.gz" SIG_KEY="${TAR_KEY}.sig" DMG_KEY="${RELEASE_PREFIX}/MonoCode_${VERSION}_aarch64.dmg" + WINDOWS_INSTALLER_KEY="${RELEASE_PREFIX}/MonoCode_${VERSION}_x64-setup.exe" + WINDOWS_SIGNATURE_KEY="${WINDOWS_INSTALLER_KEY}.sig" - VERSION="$VERSION" SIG="$SIG" TAR_URL="$BASE/$TAR_KEY" node <<'NODE' + VERSION="$VERSION" \ + SIG="$SIG" \ + TAR_URL="$BASE/$TAR_KEY" \ + WINDOWS_SIGNATURE="$WINDOWS_SIGNATURE" \ + WINDOWS_INSTALLER_URL="$BASE/$WINDOWS_INSTALLER_KEY" \ + node <<'NODE' const fs = require("fs"); fs.writeFileSync( "latest.json", @@ -143,6 +175,12 @@ jobs: signature: fs.readFileSync(process.env.SIG, "utf8").trim(), url: process.env.TAR_URL, }, + "windows-x86_64": { + signature: fs + .readFileSync(process.env.WINDOWS_SIGNATURE, "utf8") + .trim(), + url: process.env.WINDOWS_INSTALLER_URL, + }, }, }, null, @@ -157,23 +195,13 @@ jobs: aws s3 cp "$DMG" "s3://${R2_BUCKET_NAME}/${DMG_KEY}" --cache-control "$IMMUTABLE_CACHE" --endpoint-url "$ENDPOINT" aws s3 cp "$TAR" "s3://${R2_BUCKET_NAME}/${TAR_KEY}" --cache-control "$IMMUTABLE_CACHE" --endpoint-url "$ENDPOINT" aws s3 cp "$SIG" "s3://${R2_BUCKET_NAME}/${SIG_KEY}" --cache-control "$IMMUTABLE_CACHE" --endpoint-url "$ENDPOINT" + aws s3 cp "$WINDOWS_INSTALLER" "s3://${R2_BUCKET_NAME}/${WINDOWS_INSTALLER_KEY}" --cache-control "$IMMUTABLE_CACHE" --endpoint-url "$ENDPOINT" + aws s3 cp "$WINDOWS_SIGNATURE" "s3://${R2_BUCKET_NAME}/${WINDOWS_SIGNATURE_KEY}" --cache-control "$IMMUTABLE_CACHE" --endpoint-url "$ENDPOINT" # Keep the website's stable DMG link, but never let the CDN retain an # older release under this mutable name. aws s3 cp "$DMG" "s3://${R2_BUCKET_NAME}/MonoCode.dmg" --cache-control "$NO_CACHE" --endpoint-url "$ENDPOINT" aws s3 cp latest.json "s3://${R2_BUCKET_NAME}/latest.json" --content-type "application/json" --cache-control "$NO_CACHE" --endpoint-url "$ENDPOINT" - - name: Download Linux packages - uses: actions/download-artifact@v4 - with: - name: linux-packages - path: target/release/bundle/linux - - - name: Download Windows packages - uses: actions/download-artifact@v4 - with: - name: windows-packages - path: target/release/bundle/nsis - - name: GitHub Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -181,21 +209,23 @@ jobs: set -euo pipefail VERSION="${GITHUB_REF_NAME#v}" BUNDLE="target/release/bundle" - TAR=$(find "$BUNDLE/macos" -maxdepth 1 -type f -name '*.app.tar.gz' -print -quit) + TAR="$BUNDLE/macos/MonoCode.app.tar.gz" SIG="${TAR}.sig" - DMG=$(find "$BUNDLE/dmg" -maxdepth 1 -type f -name '*.dmg' -print -quit) - shopt -s nullglob - DEB=("$BUNDLE"/linux/*.deb) - APPIMAGE=("$BUNDLE"/linux/*.AppImage) - NSIS=("$BUNDLE"/nsis/*.exe) - if (( ${#DEB[@]} != 1 || ${#APPIMAGE[@]} != 1 || ${#NSIS[@]} != 1 )); then - echo "Expected exactly one .deb, one AppImage, and one NSIS installer" >&2 - exit 1 - fi + DMG="$BUNDLE/dmg/MonoCode_${VERSION}_aarch64.dmg" + DEB="$BUNDLE/linux/MonoCode_${VERSION}_amd64.deb" + APPIMAGE="$BUNDLE/linux/MonoCode_${VERSION}_amd64.AppImage" + NSIS="$BUNDLE/nsis/MonoCode_${VERSION}_x64-setup.exe" + NSIS_SIG="${NSIS}.sig" + for artifact in "$DMG" "$TAR" "$SIG" "$DEB" "$APPIMAGE" "$NSIS" "$NSIS_SIG"; do + if [[ ! -f "$artifact" ]]; then + echo "Missing release artifact: $artifact" >&2 + exit 1 + fi + done gh release create "$GITHUB_REF_NAME" \ --title "MonoCode $VERSION" \ --notes "See CHANGELOG.md for details." \ - "$DMG" "$TAR" "$SIG" "${DEB[0]}" "${APPIMAGE[0]}" "${NSIS[0]}" + "$DMG" "$TAR" "$SIG" "$DEB" "$APPIMAGE" "$NSIS" "$NSIS_SIG" linux: name: Linux packages @@ -253,12 +283,57 @@ jobs: - run: npm ci + - name: Write updater release config + env: + TAURI_UPDATER_PUBKEY: ${{ secrets.TAURI_UPDATER_PUBKEY }} + TAURI_UPDATER_ENDPOINT: ${{ secrets.TAURI_UPDATER_ENDPOINT }} + R2_PUBLIC_BASE_URL: ${{ secrets.R2_PUBLIC_BASE_URL }} + run: | + $endpoint = $env:TAURI_UPDATER_ENDPOINT + if ([string]::IsNullOrWhiteSpace($endpoint)) { + if ([string]::IsNullOrWhiteSpace($env:R2_PUBLIC_BASE_URL)) { + throw "TAURI_UPDATER_ENDPOINT or R2_PUBLIC_BASE_URL is required" + } + $endpoint = "$($env:R2_PUBLIC_BASE_URL.TrimEnd('/'))/latest.json" + } + if ([string]::IsNullOrWhiteSpace($env:TAURI_UPDATER_PUBKEY)) { + throw "TAURI_UPDATER_PUBKEY is required" + } + $config = @{ + bundle = @{ + createUpdaterArtifacts = $true + } + plugins = @{ + updater = @{ + pubkey = $env:TAURI_UPDATER_PUBKEY + endpoints = @($endpoint) + } + } + } + $config | ConvertTo-Json -Depth 5 | Set-Content -Encoding utf8 "$env:RUNNER_TEMP\tauri.release.conf.json" + - name: Build Windows packages - run: npm run build:windows + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + $env:RUSTUP_TOOLCHAIN = "stable-x86_64-pc-windows-msvc" + npx tauri build --bundles nsis --config "$env:RUNNER_TEMP\tauri.release.conf.json" + + - name: Stage Windows packages + run: | + $installers = @(Get-ChildItem "target/release/bundle/nsis" -Filter "*.exe") + $signatures = @(Get-ChildItem "target/release/bundle/nsis" -Filter "*.exe.sig") + if ($installers.Count -ne 1 -or $signatures.Count -ne 1) { + throw "Expected exactly one NSIS installer and one updater signature" + } + New-Item -ItemType Directory -Force "release-artifacts" | Out-Null + $artifacts = @($installers[0].FullName, $signatures[0].FullName) + Copy-Item -Path $artifacts -Destination "release-artifacts/" - name: Upload Windows packages uses: actions/upload-artifact@v4 with: name: windows-packages - path: target/release/bundle/nsis/*.exe + path: release-artifacts/ if-no-files-found: error diff --git a/CHANGELOG.md b/CHANGELOG.md index 965572ea..40582e8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,70 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.41] - 2026-09-09 + +### Added + +- OMP models support fast mode, including live RPC and configuration updates with a clear fallback when a model does not support it. + +### Changed + +- The file explorer avoids unnecessary rerenders and preserves unchanged file-icon DOM for smoother updates. +- The Changes panel header consistently shows its label instead of replacing it with diff counts. + +### Fixed + +- The sidebar update control stays hidden when no update is available and prevents duplicate installs from concurrent clicks. In #132 by @fobsouza. + +## [0.1.40] - 2026-09-08 + +### Added + +- Working-tree reviews now separate staged changes against `HEAD` from unstaged changes against the index, including partially staged files. +- The Changes panel can organize files into a collapsible directory tree, remembers the selected list or tree view, and can open every change in one review. + +### Fixed + +- Code block syntax highlighting follows MonoCode's appearance preference instead of the system color scheme. In #117 by @kartava. +- Split conversation panes share one continuous chat background instead of repeating the image in every pane. +- Project search safely treats include filters beginning with `-` as path patterns instead of Git options. In #125 by @Karajelly. +- Release publishing validates and uploads the expected versioned artifacts for every supported platform. + +## [0.1.39] - 2026-09-08 + +### Added + +- Windows releases now check for, download, and install signed updates through the same in-app update flow as macOS. + +### Changed + +- The prompt outline remains visible on slightly narrower windows. +- Removed the scrolled transcript's top-edge fade and blur effect. + +## [0.1.38] - 2026-09-08 + +### Added + +- The sidebar project picker is now searchable and keyboard navigable, shows each project's parent path, and includes actions for opening a new project or starting a new tab. +- Right-click a title-bar tab to close that tab, the other tabs, or every tab to its left or right. Bulk closing still protects unsaved files and running terminals. +- Shift-click conversations in the sidebar to select several at once, then pin, unpin, archive, unarchive, move into or out of folders, or delete them together. +- Settings → Appearance → Chat background adds an on-device image behind empty sessions or every conversation, with adjustable visibility. Each project can override the global image from its project-rail menu. +- Long transcripts have a vertical prompt outline for jumping between turns. Hover or keyboard-focus a marker to preview its prompt and reply. In #90 by @kartava. +- Drag image files into a note to copy them into MonoCode's local note storage and insert them into the note at the cursor. +- Archive the focused conversation with Shift+Command/Ctrl+A. The shortcut stays out of editors, terminals, diffs, and open overlays. In #89 by @kualta. + +### Changed + +- Scrolled transcripts fade and blur smoothly beneath the title bar, and popover backdrops now use theme-aware tints. +- Light mode uses an opaque native window for legibility, preserves the dark-mode glass settings, and gives the composer theme-specific shadows and send-button states. + +### Fixed + +- Enabling Sounds now plays the switch cue immediately. In #111 by @kartava. +- Sidebar multi-selection clears reliably when its menu closes or the pointer moves outside the selected conversation cards. +- Chat background changes appear across open session panes immediately, and the empty-session arcade stays hidden when a background is visible. +- Composer keyboard handlers ignore active IME composition, preventing Enter, Escape, and picker actions from firing while composing text. + ## [0.1.37] - 2026-09-07 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index d9442e63..0d4684b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2214,7 +2214,7 @@ dependencies = [ [[package]] name = "monocode" -version = "0.1.37" +version = "0.1.41" dependencies = [ "base64 0.22.1", "block2", diff --git a/Cargo.toml b/Cargo.toml index 1dfd758a..73eca71d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["src-tauri"] exclude = ["vendor/portable-pty"] [workspace.package] -version = "0.1.37" +version = "0.1.41" edition = "2021" license = "MIT" diff --git a/NOTICE b/NOTICE index cda0c48f..42bd0b6d 100644 --- a/NOTICE +++ b/NOTICE @@ -1,4 +1,4 @@ MonoCode is not affiliated with, endorsed by, or sponsored by the makers of the agent harnesses it can drive. -Provider marks that appear in the UI (including Claude, Codex, Cursor, Grok, OpenCode, Pi, omp, and fx) are trademarks of their respective owners and are used only to identify those products. +Provider marks that appear in the UI (including Claude, Codex, Cursor, GitHub, GitLab, Linear, Grok, OpenCode, Pi, omp, and fx) are trademarks of their respective owners and are used only to identify those products. diff --git a/package-lock.json b/package-lock.json index f27bbb4a..3f500cda 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "monocode-desktop", - "version": "0.1.37", + "version": "0.1.41", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "monocode-desktop", - "version": "0.1.37", + "version": "0.1.41", "dependencies": { "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.12", @@ -45,6 +45,7 @@ "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", + "happy-dom": "20.14.0", "tailwindcss": "^4", "typescript": "~5.8.3", "vite": "^7.0.4", @@ -2620,6 +2621,16 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/node": { + "version": "26.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.0.tgz", + "integrity": "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, "node_modules/@types/react": { "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", @@ -2653,6 +2664,23 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", @@ -2887,6 +2915,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -3875,6 +3916,38 @@ "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", "license": "MIT" }, + "node_modules/happy-dom": { + "version": "20.14.0", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.14.0.tgz", + "integrity": "sha512-4bRh1KzRvKDnFNTlLhzT1RZTpkKhQbQDl9j+7GXszWsvuspYdo29k6OHRf4PwiM6oLb8r/pMWeYiJjkfod5AvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/happy-dom/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/hast-util-from-parse5": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", @@ -6212,6 +6285,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -6579,6 +6659,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -6596,6 +6686,28 @@ "node": ">=8" } }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 5a7becb2..9d343a51 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "monocode-desktop", "private": true, - "version": "0.1.37", + "version": "0.1.41", "type": "module", "scripts": { "dev": "vite", @@ -58,6 +58,7 @@ "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", + "happy-dom": "20.14.0", "tailwindcss": "^4", "typescript": "~5.8.3", "vite": "^7.0.4", diff --git a/src-tauri/src/chat_background.rs b/src-tauri/src/chat_background.rs new file mode 100644 index 00000000..dbf3597e --- /dev/null +++ b/src-tauri/src/chat_background.rs @@ -0,0 +1,210 @@ +use std::path::{Path, PathBuf}; + +use tauri::{AppHandle, Manager}; + +use crate::fs::expand_home; + +const MAX_BACKGROUND_BYTES: u64 = 25 * 1024 * 1024; +const ALLOWED_EXT: [&str; 5] = ["png", "jpg", "jpeg", "gif", "webp"]; + +fn backgrounds_dir(app: &AppHandle) -> Result { + let dir = app + .path() + .app_data_dir() + .map_err(|e| e.to_string())? + .join("backgrounds"); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + Ok(dir) +} + +fn remove_existing_backgrounds(dir: &Path) -> Result<(), String> { + let entries = std::fs::read_dir(dir).map_err(|e| e.to_string())?; + for entry in entries { + let entry = entry.map_err(|e| e.to_string())?; + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + if name == "chat-background" || name.starts_with("chat-background.") { + match std::fs::remove_file(entry.path()) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.to_string()), + } + } + } + Ok(()) +} + +fn project_background_stem(project: &str) -> String { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in project.trim().as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100_0000_01b3); + } + format!("project-{hash:016x}") +} + +fn remove_project_background(dir: &Path, project: &str) -> Result<(), String> { + let stem = project_background_stem(project); + let prefix = format!("{stem}."); + let entries = std::fs::read_dir(dir).map_err(|e| e.to_string())?; + for entry in entries { + let entry = entry.map_err(|e| e.to_string())?; + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + if name == stem || name.starts_with(&prefix) { + match std::fs::remove_file(entry.path()) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.to_string()), + } + } + } + Ok(()) +} + +fn background_extension(source: &Path) -> Result { + let ext = source + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + if ALLOWED_EXT.contains(&ext.as_str()) { + Ok(ext) + } else { + Err("Background must be a PNG, JPG, GIF, or WebP image.".into()) + } +} + +fn save_chat_background_sync(app: &AppHandle, source_path: &str) -> Result { + let source = expand_home(source_path); + let meta = std::fs::metadata(&source).map_err(|e| format!("{}: {e}", source.display()))?; + if !meta.is_file() { + return Err("Not a file".into()); + } + if meta.len() > MAX_BACKGROUND_BYTES { + return Err(format!( + "Background is too large (maximum {} MB).", + MAX_BACKGROUND_BYTES / 1024 / 1024 + )); + } + let ext = background_extension(&source)?; + let dir = backgrounds_dir(app)?; + let dest = dir.join(format!("chat-background.{ext}")); + let temp = dir.join(".chat-background-upload"); + + // Copy first so choosing the currently saved image remains safe. + std::fs::copy(&source, &temp).map_err(|e| format!("{}: {e}", temp.display()))?; + remove_existing_backgrounds(&dir)?; + std::fs::rename(&temp, &dest).map_err(|e| format!("{}: {e}", dest.display()))?; + Ok(dest.to_string_lossy().into_owned()) +} + +fn save_project_chat_background_sync( + app: &AppHandle, + project: &str, + source_path: &str, +) -> Result { + if project.trim().is_empty() { + return Err("Project is required".into()); + } + let source = expand_home(source_path); + let meta = std::fs::metadata(&source).map_err(|e| format!("{}: {e}", source.display()))?; + if !meta.is_file() { + return Err("Not a file".into()); + } + if meta.len() > MAX_BACKGROUND_BYTES { + return Err(format!( + "Background is too large (maximum {} MB).", + MAX_BACKGROUND_BYTES / 1024 / 1024 + )); + } + let ext = background_extension(&source)?; + let dir = backgrounds_dir(app)?; + let stem = project_background_stem(project); + let dest = dir.join(format!("{stem}.{ext}")); + let temp = dir.join(format!(".{stem}-upload")); + + std::fs::copy(&source, &temp).map_err(|e| format!("{}: {e}", temp.display()))?; + remove_project_background(&dir, project)?; + std::fs::rename(&temp, &dest).map_err(|e| format!("{}: {e}", dest.display()))?; + Ok(dest.to_string_lossy().into_owned()) +} + +#[tauri::command] +pub async fn save_chat_background(app: AppHandle, source_path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || save_chat_background_sync(&app, &source_path)) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn remove_chat_background(app: AppHandle) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let dir = backgrounds_dir(&app)?; + remove_existing_backgrounds(&dir) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn save_project_chat_background( + app: AppHandle, + project: String, + source_path: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + save_project_chat_background_sync(&app, &project, &source_path) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn remove_project_chat_background(app: AppHandle, project: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let dir = backgrounds_dir(&app)?; + remove_project_background(&dir, &project) + }) + .await + .map_err(|e| e.to_string())? +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_supported_extensions_case_insensitively() { + assert_eq!( + background_extension(Path::new("wallpaper.JPEG")).unwrap(), + "jpeg" + ); + assert_eq!( + background_extension(Path::new("wallpaper.webp")).unwrap(), + "webp" + ); + } + + #[test] + fn rejects_files_the_webview_cannot_render_as_backgrounds() { + assert!(background_extension(Path::new("wallpaper.txt")).is_err()); + assert!(background_extension(Path::new("wallpaper")).is_err()); + } + + #[test] + fn project_background_stems_are_stable_and_distinct() { + assert_eq!( + project_background_stem("/Users/me/agent"), + project_background_stem("/Users/me/agent") + ); + assert_ne!( + project_background_stem("/Users/me/agent"), + project_background_stem("/Users/other/agent") + ); + } +} diff --git a/src-tauri/src/cursor_usage.rs b/src-tauri/src/cursor_usage.rs new file mode 100644 index 00000000..af90caa0 --- /dev/null +++ b/src-tauri/src/cursor_usage.rs @@ -0,0 +1,397 @@ +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use rusqlite::{Connection, OpenFlags}; +use serde::Serialize; +use serde_json::Value; + +use crate::dirs_home; + +const USAGE_SUMMARY_URL: &str = "https://cursor.com/api/usage-summary"; +const USER_AGENT: &str = "MonoCode"; +const HTTP_TIMEOUT: Duration = Duration::from_secs(10); +const TOKEN_EXPIRY_BUFFER_SECS: i64 = 60; +const AUTH_TOKEN_KEY: &str = "cursorAuth/accessToken"; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CursorUsageFetch { + pub status: String, + pub http_status: Option, + pub body: Option, + pub error: Option, +} + +/// Fetch Cursor plan usage via the signed-in Cursor.app session. +/// The access token never leaves the host process. +#[tauri::command] +pub async fn fetch_cursor_usage() -> Result { + tauri::async_runtime::spawn_blocking(fetch_cursor_usage_sync) + .await + .map_err(|e| e.to_string())? +} + +fn fetch_cursor_usage_sync() -> Result { + let Some(token) = read_cursor_access_token() else { + return Ok(usage_result( + "unavailable", + None, + None, + Some("Cursor not signed in".into()), + )); + }; + if !jwt_is_usable(&token, now_secs()) { + return Ok(usage_result( + "unavailable", + None, + None, + Some("Cursor sign-in expired".into()), + )); + } + let Some(cookie) = cursor_cookie_header(&token) else { + return Ok(usage_result( + "unavailable", + None, + None, + Some("Cursor sign-in is invalid".into()), + )); + }; + Ok(fetch_usage_with_cookie(&cookie)) +} + +fn usage_result( + status: &str, + http_status: Option, + body: Option, + error: Option, +) -> CursorUsageFetch { + CursorUsageFetch { + status: status.into(), + http_status, + body, + error, + } +} + +fn fetch_usage_with_cookie(cookie: &str) -> CursorUsageFetch { + let agent = ureq::AgentBuilder::new().timeout(HTTP_TIMEOUT).build(); + let result = agent + .get(USAGE_SUMMARY_URL) + .set("Accept", "application/json") + .set("Cookie", cookie) + .set("User-Agent", USER_AGENT) + .call(); + + match result { + Ok(response) => { + let http_status = response.status(); + let body = response.into_string().unwrap_or_default(); + if (200..300).contains(&http_status) { + usage_result("ok", Some(http_status), Some(body), None) + } else { + usage_error(http_status) + } + } + Err(ureq::Error::Status(status, response)) => { + let _ = response.into_string(); + usage_error(status) + } + Err(error) => usage_result( + "error", + None, + None, + Some(format!("Cursor usage request failed: {error}")), + ), + } +} + +fn usage_error(status: u16) -> CursorUsageFetch { + let (kind, message) = if status == 401 || status == 403 { + ("unavailable", "Cursor not signed in".into()) + } else { + ("error", format!("Cursor usage request failed ({status})")) + }; + usage_result(kind, Some(status), None, Some(message)) +} + +fn read_cursor_access_token() -> Option { + let path = cursor_state_db_path()?; + if !path.is_file() { + return None; + } + let token = read_item_table_value(&path, AUTH_TOKEN_KEY).ok()??; + let trimmed = token.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn cursor_state_db_path() -> Option { + let home = dirs_home()?; + Some(cursor_state_db_path_for(&home)) +} + +pub(crate) fn cursor_state_db_path_for(home: &str) -> PathBuf { + let home = Path::new(home); + #[cfg(target_os = "macos")] + { + home.join("Library/Application Support/Cursor/User/globalStorage/state.vscdb") + } + #[cfg(target_os = "windows")] + { + home.join("AppData/Roaming/Cursor/User/globalStorage/state.vscdb") + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + let config = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .unwrap_or_else(|| home.join(".config")); + config.join("Cursor/User/globalStorage/state.vscdb") + } +} + +fn read_item_table_value(path: &Path, key: &str) -> Result, rusqlite::Error> { + let connection = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + )?; + connection.busy_timeout(Duration::from_millis(250))?; + let mut statement = connection.prepare("SELECT value FROM ItemTable WHERE key = ?1 LIMIT 1")?; + let mut rows = statement.query(rusqlite::params![key])?; + let Some(row) = rows.next()? else { + return Ok(None); + }; + Ok(decode_sqlite_text(row.get_ref(0)?)) +} + +fn decode_sqlite_text(value: rusqlite::types::ValueRef<'_>) -> Option { + match value { + rusqlite::types::ValueRef::Text(bytes) => String::from_utf8(bytes.to_vec()) + .ok() + .or_else(|| decode_utf16le(bytes)), + rusqlite::types::ValueRef::Blob(bytes) => { + decode_utf16le(bytes).or_else(|| String::from_utf8(bytes.to_vec()).ok()) + } + rusqlite::types::ValueRef::Null => None, + _ => None, + } +} + +fn decode_utf16le(bytes: &[u8]) -> Option { + if bytes.len() < 2 || !bytes.len().is_multiple_of(2) { + return None; + } + let (pairs, _) = bytes.as_chunks::<2>(); + let ascii_utf16le = pairs + .iter() + .all(|pair| (1..128).contains(&pair[0]) && pair[1] == 0); + if !ascii_utf16le { + return None; + } + String::from_utf16( + &pairs + .iter() + .map(|pair| u16::from_le_bytes(*pair)) + .collect::>(), + ) + .ok() +} + +pub(crate) fn jwt_is_usable(token: &str, now_secs: i64) -> bool { + let Some(payload) = jwt_payload(token) else { + return false; + }; + let Some(exp) = payload.get("exp").and_then(Value::as_i64) else { + return false; + }; + exp - now_secs > TOKEN_EXPIRY_BUFFER_SECS +} + +pub(crate) fn cursor_cookie_header(token: &str) -> Option { + let user_id = jwt_user_id(token)?; + Some(format!("WorkosCursorSessionToken={user_id}%3A%3A{token}")) +} + +pub(crate) fn jwt_user_id(token: &str) -> Option { + let payload = jwt_payload(token)?; + let subject = payload.get("sub")?.as_str()?.trim(); + if subject.is_empty() { + return None; + } + let user_id = subject.rsplit('|').next().unwrap_or(subject).trim(); + if user_id.is_empty() { + return None; + } + if !user_id + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-')) + { + return None; + } + Some(user_id.to_string()) +} + +fn jwt_payload(token: &str) -> Option { + let mut parts = token.split('.'); + let _header = parts.next()?; + let payload = parts.next()?; + if payload.is_empty() || parts.next().is_none() { + return None; + } + let mut encoded = payload.replace('-', "+").replace('_', "/"); + match encoded.len() % 4 { + 2 => encoded.push_str("=="), + 3 => encoded.push('='), + 0 => {} + _ => return None, + } + let bytes = base64::Engine::decode( + &base64::engine::general_purpose::STANDARD, + encoded.as_bytes(), + ) + .ok()?; + serde_json::from_slice(&bytes).ok() +} + +fn now_secs() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs() as i64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::params; + + fn test_jwt(sub: &str, exp: i64) -> String { + let header = base64::Engine::encode( + &base64::engine::general_purpose::URL_SAFE_NO_PAD, + br#"{"alg":"none"}"#, + ); + let payload = base64::Engine::encode( + &base64::engine::general_purpose::URL_SAFE_NO_PAD, + format!(r#"{{"sub":"{sub}","exp":{exp}}}"#).as_bytes(), + ); + format!("{header}.{payload}.sig") + } + + #[test] + fn jwt_user_id_takes_the_last_subject_segment() { + let token = test_jwt("auth0|user_abc-1", 2_000_000_000); + assert_eq!(jwt_user_id(&token).as_deref(), Some("user_abc-1")); + } + + #[test] + fn jwt_user_id_rejects_invalid_characters() { + let token = test_jwt("auth0|user/abc", 2_000_000_000); + assert_eq!(jwt_user_id(&token), None); + } + + #[test] + fn jwt_is_usable_requires_a_future_expiry() { + let token = test_jwt("user_1", 1_000_061); + assert!(jwt_is_usable(&token, 1_000_000)); + assert!(!jwt_is_usable(&token, 1_000_002)); + } + + #[test] + fn cookie_header_uses_the_cursor_session_shape() { + let token = test_jwt("auth0|user_1", 2_000_000_000); + let expected = format!("WorkosCursorSessionToken=user_1%3A%3A{token}"); + assert_eq!( + cursor_cookie_header(&token).as_deref(), + Some(expected.as_str()) + ); + } + + #[test] + fn macos_state_db_lives_under_application_support() { + let path = cursor_state_db_path_for("/Users/ada"); + #[cfg(target_os = "macos")] + assert_eq!( + path, + PathBuf::from( + "/Users/ada/Library/Application Support/Cursor/User/globalStorage/state.vscdb" + ) + ); + #[cfg(not(target_os = "macos"))] + let _ = path; + } + + #[test] + fn reads_item_table_text_and_utf16le_blobs() { + let connection = Connection::open_in_memory().unwrap(); + connection + .execute( + "CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value BLOB)", + [], + ) + .unwrap(); + connection + .execute( + "INSERT INTO ItemTable (key, value) VALUES (?1, ?2)", + params!["cursorAuth/accessToken", "plain-token"], + ) + .unwrap(); + let utf16: Vec = "utf16-token" + .encode_utf16() + .flat_map(|unit| unit.to_le_bytes()) + .collect(); + connection + .execute( + "INSERT INTO ItemTable (key, value) VALUES (?1, ?2)", + params!["other", utf16], + ) + .unwrap(); + + let mut statement = connection + .prepare("SELECT value FROM ItemTable WHERE key = ?1") + .unwrap(); + let text = statement + .query_row(params!["cursorAuth/accessToken"], |row| { + Ok(decode_sqlite_text(row.get_ref(0)?)) + }) + .unwrap(); + assert_eq!(text.as_deref(), Some("plain-token")); + let blob = statement + .query_row(params!["other"], |row| { + Ok(decode_sqlite_text(row.get_ref(0)?)) + }) + .unwrap(); + assert_eq!(blob.as_deref(), Some("utf16-token")); + } + + #[test] + fn even_length_utf8_jwt_blob_falls_back_to_utf8() { + let connection = Connection::open_in_memory().unwrap(); + connection + .execute( + "CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value BLOB)", + [], + ) + .unwrap(); + // Even-length ASCII JWT: naive UTF-16LE decoding would mojibake it. + let token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1In0.sig"; + assert_eq!(token.len() % 2, 0); + assert_eq!(decode_utf16le(token.as_bytes()), None); + connection + .execute( + "INSERT INTO ItemTable (key, value) VALUES (?1, ?2)", + params!["cursorAuth/accessToken", token.as_bytes()], + ) + .unwrap(); + let decoded = connection + .query_row( + "SELECT value FROM ItemTable WHERE key = ?1", + params!["cursorAuth/accessToken"], + |row| Ok(decode_sqlite_text(row.get_ref(0)?)), + ) + .unwrap(); + assert_eq!(decoded.as_deref(), Some(token)); + } +} diff --git a/src-tauri/src/fs.rs b/src-tauri/src/fs.rs index 3453f429..8a1a595d 100644 --- a/src-tauri/src/fs.rs +++ b/src-tauri/src/fs.rs @@ -254,12 +254,19 @@ pub struct GitFileDiff { pub too_large: bool, } -/// Working-tree vs index (or empty) contents for one changed file. +/// Contents for one changed file. Staged diffs compare HEAD to the index; +/// unstaged diffs compare the index to the working tree. #[tauri::command] -pub async fn git_file_diff(cwd: String, relative: String) -> Result { - tauri::async_runtime::spawn_blocking(move || git_file_diff_for(&expand_home(&cwd), &relative)) - .await - .map_err(|e| e.to_string())? +pub async fn git_file_diff( + cwd: String, + relative: String, + staged: bool, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + git_file_diff_for(&expand_home(&cwd), &relative, staged) + }) + .await + .map_err(|e| e.to_string())? } const GIT_HISTORY_DEFAULT: u32 = 200; @@ -571,6 +578,22 @@ pub async fn git_github_work_items( .map_err(|e| e.to_string())? } +/// One issue or pull request by number, used when session navigation misses +/// the existing Inbox cache. +#[tauri::command] +pub async fn git_github_work_item( + cwd: String, + repo: String, + kind: String, + number: i64, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + git_github_work_item_for(&expand_home(&cwd), &repo, &kind, number) + }) + .await + .map_err(|e| e.to_string())? +} + #[derive(Serialize, Clone, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct GitHubWorkItemDetails { @@ -1019,7 +1042,7 @@ fn mark_cached_and_unstaged(root: &Path, files: &mut HashMap) { } } -fn git_file_diff_for(root: &Path, relative: &str) -> Result { +fn git_file_diff_for(root: &Path, relative: &str, staged: bool) -> Result { let relative = normalize_diff_path(relative); if relative.is_empty() || relative.starts_with('/') @@ -1039,24 +1062,31 @@ fn git_file_diff_for(root: &Path, relative: &str) -> Result let prefix = git_stdout(root, &["rev-parse", "--show-prefix"]).unwrap_or_default(); let index_spec = format!(":{prefix}{relative}"); - let original = git_blob(root, &index_spec); - let in_index = original.is_some(); - let orig = original.unwrap_or_default(); - let current = if abs.is_file() { - std::fs::read(&abs).unwrap_or_default() + let (original, current) = if staged { + let head_spec = format!("HEAD:{prefix}{relative}"); + (git_blob(root, &head_spec), git_blob(root, &index_spec)) } else { - Vec::new() + let current = if abs.is_file() { + Some(std::fs::read(&abs).unwrap_or_default()) + } else { + None + }; + (git_blob(root, &index_spec), current) }; + let had_original = original.is_some(); + let had_current = current.is_some(); + let orig = original.unwrap_or_default(); + let current = current.unwrap_or_default(); let binary = orig.contains(&0) || current.contains(&0); let too_large = orig.len() as u64 > MAX_TEXT_FILE_BYTES || current.len() as u64 > MAX_TEXT_FILE_BYTES; - let status = if !in_index { - if abs.is_file() { - "untracked" + let status = if !had_original && had_current { + if staged { + "added" } else { - "deleted" + "untracked" } - } else if !abs.exists() { + } else if had_original && !had_current { "deleted" } else { "modified" @@ -1642,6 +1672,34 @@ fn git_github_work_items_for( parse_github_work_items(&json, kind, &repo) } +fn git_github_work_item_for( + root: &Path, + repo: &str, + kind: &str, + number: i64, +) -> Result { + let kind = kind.trim(); + if kind != "issue" && kind != "pr" { + return Err("Unknown GitHub task kind".into()); + } + if number <= 0 { + return Err("GitHub task number must be positive".into()); + } + let (owner, name) = split_github_repo(repo)?; + let repo = format!("{owner}/{name}"); + let number = number.to_string(); + let fields = if kind == "pr" { + "number,title,url,state,updatedAt,labels,assignees,isDraft" + } else { + "number,title,url,state,updatedAt,labels,assignees" + }; + let json = gh_checked( + root, + &[kind, "view", &number, "--repo", &repo, "--json", fields], + )?; + parse_github_work_item(&json, kind, &repo) +} + fn git_github_work_item_details_for( root: &Path, kind: &str, @@ -2413,6 +2471,14 @@ fn parse_github_work_items( .collect()) } +fn parse_github_work_item(json: &str, kind: &str, repo: &str) -> Result { + let wrapped = format!("[{json}]"); + parse_github_work_items(&wrapped, kind, repo)? + .into_iter() + .next() + .ok_or_else(|| "GitHub did not return a work item".into()) +} + fn parse_gh_pr_list(json: &str) -> Option { #[derive(Deserialize)] struct Row { @@ -4309,7 +4375,7 @@ mod tests { } std::fs::write(dir.0.join("a.txt"), "alpha\ngamma\ndelta\n").unwrap(); - let diff = git_file_diff_for(&dir.0, "a.txt").unwrap(); + let diff = git_file_diff_for(&dir.0, "a.txt", false).unwrap(); assert_eq!(diff.status, "modified"); assert_eq!(diff.original, "alpha\nbeta\ngamma\n"); assert_eq!(diff.current, "alpha\ngamma\ndelta\n"); @@ -4317,6 +4383,60 @@ mod tests { assert!(!diff.too_large); } + #[test] + fn git_file_diff_staged_reads_head_and_index() { + let dir = tmp("git-file-diff-staged"); + if !init_git_commit(&dir.0, &[("a.txt", "alpha\n")]) { + return; + } + std::fs::write(dir.0.join("a.txt"), "beta\n").unwrap(); + git_stage_file_for(&dir.0, "a.txt").unwrap(); + + let diff = git_file_diff_for(&dir.0, "a.txt", true).unwrap(); + assert_eq!(diff.status, "modified"); + assert_eq!(diff.original, "alpha\n"); + assert_eq!(diff.current, "beta\n"); + } + + #[test] + fn git_file_diff_staged_handles_additions_and_deletions() { + let dir = tmp("git-file-diff-staged-status"); + if !init_git_commit(&dir.0, &[("gone.txt", "old\n")]) { + return; + } + std::fs::write(dir.0.join("new.txt"), "new\n").unwrap(); + std::fs::remove_file(dir.0.join("gone.txt")).unwrap(); + assert!(git(&dir.0, &["add", "-A"])); + + let added = git_file_diff_for(&dir.0, "new.txt", true).unwrap(); + assert_eq!(added.status, "added"); + assert_eq!(added.original, ""); + assert_eq!(added.current, "new\n"); + + let deleted = git_file_diff_for(&dir.0, "gone.txt", true).unwrap(); + assert_eq!(deleted.status, "deleted"); + assert_eq!(deleted.original, "old\n"); + assert_eq!(deleted.current, ""); + } + + #[test] + fn git_file_diff_separates_staged_and_unstaged_changes() { + let dir = tmp("git-file-diff-partial"); + if !init_git_commit(&dir.0, &[("a.txt", "alpha\nbeta\ngamma\ndelta\n")]) { + return; + } + std::fs::write(dir.0.join("a.txt"), "alpha\nBETA\ngamma\nDELTA\n").unwrap(); + git_stage_contents_for(&dir.0, "a.txt", b"alpha\nBETA\ngamma\ndelta\n").unwrap(); + + let staged = git_file_diff_for(&dir.0, "a.txt", true).unwrap(); + assert_eq!(staged.original, "alpha\nbeta\ngamma\ndelta\n"); + assert_eq!(staged.current, "alpha\nBETA\ngamma\ndelta\n"); + + let unstaged = git_file_diff_for(&dir.0, "a.txt", false).unwrap(); + assert_eq!(unstaged.original, "alpha\nBETA\ngamma\ndelta\n"); + assert_eq!(unstaged.current, "alpha\nBETA\ngamma\nDELTA\n"); + } + #[test] fn git_file_diff_untracked_has_empty_original() { let dir = tmp("git-file-diff-new"); @@ -4325,7 +4445,7 @@ mod tests { } std::fs::write(dir.0.join("new.txt"), "hello\n").unwrap(); - let diff = git_file_diff_for(&dir.0, "new.txt").unwrap(); + let diff = git_file_diff_for(&dir.0, "new.txt", false).unwrap(); assert_eq!(diff.status, "untracked"); assert_eq!(diff.original, ""); assert_eq!(diff.current, "hello\n"); @@ -4339,7 +4459,7 @@ mod tests { } std::fs::remove_file(dir.0.join("a.txt")).unwrap(); - let diff = git_file_diff_for(&dir.0, "a.txt").unwrap(); + let diff = git_file_diff_for(&dir.0, "a.txt", false).unwrap(); assert_eq!(diff.status, "deleted"); assert_eq!(diff.original, "alpha\n"); assert_eq!(diff.current, ""); @@ -4348,14 +4468,14 @@ mod tests { #[test] fn git_file_diff_rejects_path_escape() { let dir = tmp("git-file-diff-escape"); - assert!(git_file_diff_for(&dir.0, "../secret.txt").is_err()); + assert!(git_file_diff_for(&dir.0, "../secret.txt", false).is_err()); } #[test] fn git_file_diff_rejects_outside_a_repo() { let dir = tmp("git-file-diff-none"); std::fs::write(dir.0.join("notes.txt"), "hello\n").unwrap(); - assert!(git_file_diff_for(&dir.0, "notes.txt").is_err()); + assert!(git_file_diff_for(&dir.0, "notes.txt", false).is_err()); } #[test] @@ -4586,7 +4706,7 @@ mod tests { assert!(file.staged); assert!(file.unstaged); - let diff = git_file_diff_for(&dir.0, "a.txt").unwrap(); + let diff = git_file_diff_for(&dir.0, "a.txt", false).unwrap(); assert_eq!(diff.original, "alpha\nBETA\ngamma\ndelta\n"); assert_eq!(diff.current, "alpha\nBETA\ngamma\nDELTA\n"); } @@ -4909,6 +5029,22 @@ mod tests { assert_eq!(items[0].repo, "acme/web"); } + #[test] + fn parse_github_work_item_reads_view_shape() { + let json = r#"{ + "number": 12, + "title": "WIP checkout", + "url": "https://github.com/acme/web/pull/12", + "state": "OPEN", + "isDraft": true + }"#; + let item = parse_github_work_item(json, "pr", "acme/web").unwrap(); + assert_eq!(item.number, 12); + assert_eq!(item.kind, "pr"); + assert_eq!(item.repo, "acme/web"); + assert!(item.draft); + } + #[test] fn parse_github_work_item_details_reads_body_and_author() { let json = r#"{ diff --git a/src-tauri/src/gitlab.rs b/src-tauri/src/gitlab.rs new file mode 100644 index 00000000..6085b520 --- /dev/null +++ b/src-tauri/src/gitlab.rs @@ -0,0 +1,1210 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tauri::{AppHandle, Manager}; + +const DEFAULT_GITLAB_URL: &str = "https://gitlab.com"; +const DEFAULT_LIMIT: u32 = 40; +const HTTP_TIMEOUT: Duration = Duration::from_secs(20); +const MAX_DIFF_BYTES: usize = 2 * 1024 * 1024; +const USER_AGENT: &str = "MonoCode"; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GitlabStatus { + pub connected: bool, + pub url: String, +} + +#[derive(Serialize, Deserialize, Clone)] +struct GitlabConfig { + url: String, + token: String, +} + +#[derive(Serialize, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct GitlabLabel { + pub name: String, + pub color: String, +} + +#[derive(Serialize, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct GitlabAssignee { + pub login: String, + pub avatar_url: String, +} + +#[derive(Serialize, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct GitlabWorkItem { + pub kind: String, + pub number: i64, + pub title: String, + pub url: String, + pub state: String, + pub updated_at: String, + pub labels: Vec, + pub assignees: Vec, + pub draft: bool, + pub repo: String, +} + +#[derive(Serialize, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct GitlabWorkItemDetails { + pub body: String, + pub author: String, + pub author_avatar_url: String, + pub base_ref_name: String, + pub head_ref_name: String, + pub review_decision: String, +} + +#[derive(Serialize, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct GitlabWorkItemComment { + pub id: String, + pub kind: String, + pub author: String, + pub author_avatar_url: String, + pub body: String, + pub created_at: String, + pub url: String, + pub state: String, + pub path: String, + pub line: Option, + pub resolved: bool, + pub thread_id: String, + pub replies: Vec, +} + +#[derive(Serialize, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct GitlabWorkItemThread { + pub comments: Vec, + pub truncated: bool, + pub review_decision: String, + pub base_ref_name: String, + pub head_ref_name: String, +} + +#[derive(Serialize, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct GitlabMrFile { + pub path: String, + pub additions: i64, + pub deletions: i64, +} + +#[derive(Serialize, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct GitlabMrDiff { + pub additions: i64, + pub deletions: i64, + pub files: Vec, + pub patch: String, + pub truncated: bool, +} + +#[tauri::command(async)] +pub fn gitlab_status(app: AppHandle) -> Result { + let config = read_config(&app)?; + Ok(GitlabStatus { + connected: config.is_some(), + url: config + .map(|config| config.url) + .unwrap_or_else(|| DEFAULT_GITLAB_URL.into()), + }) +} + +#[tauri::command] +pub async fn gitlab_set_config( + app: AppHandle, + url: String, + token: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let url = normalize_gitlab_url(&url)?; + let token = token.trim().to_string(); + if token.is_empty() { + delete_config(&app)?; + return Ok(GitlabStatus { + connected: false, + url, + }); + } + let config = GitlabConfig { url, token }; + let response = gitlab_get(&config, "/user")?; + if response.value.get("id").and_then(Value::as_i64).is_none() { + return Err("GitLab did not return the current user".into()); + } + write_config(&app, &config)?; + Ok(GitlabStatus { + connected: true, + url: config.url, + }) + }) + .await + .map_err(|error| error.to_string())? +} + +#[tauri::command] +pub async fn gitlab_repo(app: AppHandle, cwd: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let config = require_config(&app)?; + gitlab_repo_for(&expand_home(&cwd), &config.url) + }) + .await + .map_err(|error| error.to_string())? +} + +#[tauri::command] +pub async fn gitlab_list_work_items( + app: AppHandle, + cwd: String, + kind: String, + assigned_to_me: bool, + state: String, + limit: Option, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let config = require_config(&app)?; + let repo = gitlab_repo_for(&expand_home(&cwd), &config.url)?; + gitlab_list_work_items_for( + &config, + &repo, + &kind, + assigned_to_me, + &state, + limit.unwrap_or(DEFAULT_LIMIT), + ) + }) + .await + .map_err(|error| error.to_string())? +} + +#[tauri::command] +pub async fn gitlab_work_item_details( + app: AppHandle, + cwd: String, + kind: String, + number: i64, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let config = require_config(&app)?; + let repo = gitlab_repo_for(&expand_home(&cwd), &config.url)?; + gitlab_work_item_details_for(&config, &repo, &kind, number) + }) + .await + .map_err(|error| error.to_string())? +} + +#[tauri::command] +pub async fn gitlab_work_item_thread( + app: AppHandle, + cwd: String, + kind: String, + number: i64, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let config = require_config(&app)?; + let repo = gitlab_repo_for(&expand_home(&cwd), &config.url)?; + gitlab_work_item_thread_for(&config, &repo, &kind, number) + }) + .await + .map_err(|error| error.to_string())? +} + +#[tauri::command] +pub async fn gitlab_work_item_comment( + app: AppHandle, + cwd: String, + kind: String, + number: i64, + body: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let config = require_config(&app)?; + let repo = gitlab_repo_for(&expand_home(&cwd), &config.url)?; + gitlab_work_item_comment_for(&config, &repo, &kind, number, &body) + }) + .await + .map_err(|error| error.to_string())? +} + +#[tauri::command] +pub async fn gitlab_mr_diff( + app: AppHandle, + cwd: String, + number: i64, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let config = require_config(&app)?; + let repo = gitlab_repo_for(&expand_home(&cwd), &config.url)?; + gitlab_mr_diff_for(&config, &repo, number) + }) + .await + .map_err(|error| error.to_string())? +} + +fn gitlab_list_work_items_for( + config: &GitlabConfig, + repo: &str, + kind: &str, + assigned_to_me: bool, + state: &str, + limit: u32, +) -> Result, String> { + validate_kind(kind)?; + let resource = resource_for_kind(kind); + let state = if state.trim().eq_ignore_ascii_case("all") { + "all" + } else { + "opened" + }; + let limit = limit.clamp(1, 100); + let assigned = if assigned_to_me { + "&scope=assigned_to_me" + } else { + "&scope=all" + }; + let path = format!( + "/projects/{}/{resource}?state={state}&order_by=updated_at&sort=desc&per_page={limit}&with_labels_details=true{assigned}", + encode_path_component(repo) + ); + let response = gitlab_get(config, &path)?; + parse_work_items(&response.value, kind, repo) +} + +fn gitlab_work_item_details_for( + config: &GitlabConfig, + repo: &str, + kind: &str, + number: i64, +) -> Result { + validate_item(kind, number)?; + let path = item_path(repo, kind, number); + let response = gitlab_get(config, &path)?; + parse_work_item_details(&response.value, kind) +} + +fn gitlab_work_item_thread_for( + config: &GitlabConfig, + repo: &str, + kind: &str, + number: i64, +) -> Result { + validate_item(kind, number)?; + let path = format!( + "{}/notes?order_by=created_at&sort=desc&per_page=100", + item_path(repo, kind, number) + ); + let response = gitlab_get(config, &path)?; + parse_work_item_thread( + &response.value, + &config.url, + repo, + kind, + number, + response.has_next_page, + ) +} + +fn gitlab_work_item_comment_for( + config: &GitlabConfig, + repo: &str, + kind: &str, + number: i64, + body: &str, +) -> Result { + validate_item(kind, number)?; + let body = body.trim(); + if body.is_empty() { + return Err("Comment cannot be empty".into()); + } + let path = format!("{}/notes", item_path(repo, kind, number)); + let response = gitlab_post_form(config, &path, &[("body", body)])?; + let id = response + .value + .get("id") + .and_then(Value::as_i64) + .ok_or_else(|| "GitLab did not return a comment".to_string())?; + Ok(note_url(&config.url, repo, kind, number, id)) +} + +fn gitlab_mr_diff_for( + config: &GitlabConfig, + repo: &str, + number: i64, +) -> Result { + validate_item("pr", number)?; + let path = format!( + "/projects/{}/merge_requests/{number}/diffs?per_page=100", + encode_path_component(repo) + ); + let response = gitlab_get(config, &path)?; + parse_mr_diff(&response.value, response.has_next_page) +} + +fn validate_kind(kind: &str) -> Result<(), String> { + if kind == "issue" || kind == "pr" { + Ok(()) + } else { + Err("Unknown GitLab task kind".into()) + } +} + +fn validate_item(kind: &str, number: i64) -> Result<(), String> { + validate_kind(kind)?; + if number <= 0 { + return Err("Invalid GitLab item number".into()); + } + Ok(()) +} + +fn resource_for_kind(kind: &str) -> &'static str { + if kind == "pr" { + "merge_requests" + } else { + "issues" + } +} + +fn item_path(repo: &str, kind: &str, number: i64) -> String { + format!( + "/projects/{}/{}/{number}", + encode_path_component(repo), + resource_for_kind(kind) + ) +} + +fn parse_work_items(value: &Value, kind: &str, repo: &str) -> Result, String> { + let rows = value + .as_array() + .ok_or_else(|| "GitLab did not return work items".to_string())?; + Ok(rows + .iter() + .filter_map(|row| parse_work_item(row, kind, repo)) + .collect()) +} + +fn parse_work_item(row: &Value, kind: &str, repo: &str) -> Option { + let number = row.get("iid").and_then(Value::as_i64)?; + if number <= 0 { + return None; + } + let title = string_field(row, "title").unwrap_or_default(); + let title_lower = title.to_ascii_lowercase(); + let draft = kind == "pr" + && (row.get("draft").and_then(Value::as_bool).unwrap_or(false) + || row + .get("work_in_progress") + .and_then(Value::as_bool) + .unwrap_or(false) + || title_lower.starts_with("draft:") + || title_lower.starts_with("wip:")); + Some(GitlabWorkItem { + kind: kind.into(), + number, + title, + url: string_field(row, "web_url").unwrap_or_default(), + state: normalize_state(&string_field(row, "state").unwrap_or_default()), + updated_at: string_field(row, "updated_at").unwrap_or_default(), + labels: parse_labels(row), + assignees: parse_assignees(row), + draft, + repo: repo.into(), + }) +} + +fn parse_work_item_details(value: &Value, kind: &str) -> Result { + if !value.is_object() { + return Err("GitLab did not return that item".into()); + } + let author = value.get("author"); + Ok(GitlabWorkItemDetails { + body: string_field(value, "description").unwrap_or_default(), + author: author + .and_then(|author| string_field(author, "username")) + .or_else(|| author.and_then(|author| string_field(author, "name"))) + .unwrap_or_default(), + author_avatar_url: author + .and_then(|author| string_field(author, "avatar_url")) + .unwrap_or_default(), + base_ref_name: if kind == "pr" { + string_field(value, "target_branch").unwrap_or_default() + } else { + String::new() + }, + head_ref_name: if kind == "pr" { + string_field(value, "source_branch").unwrap_or_default() + } else { + String::new() + }, + review_decision: String::new(), + }) +} + +fn parse_work_item_thread( + value: &Value, + base_url: &str, + repo: &str, + kind: &str, + number: i64, + has_next_page: bool, +) -> Result { + let rows = value + .as_array() + .ok_or_else(|| "GitLab did not return comments".to_string())?; + let mut comments: Vec = rows + .iter() + .filter(|row| !row.get("system").and_then(Value::as_bool).unwrap_or(false)) + .filter_map(|row| { + let id = row.get("id").and_then(Value::as_i64)?; + let author = row.get("author"); + Some(GitlabWorkItemComment { + id: id.to_string(), + kind: "comment".into(), + author: author + .and_then(|author| string_field(author, "username")) + .or_else(|| author.and_then(|author| string_field(author, "name"))) + .unwrap_or_default(), + author_avatar_url: author + .and_then(|author| string_field(author, "avatar_url")) + .unwrap_or_default(), + body: string_field(row, "body").unwrap_or_default(), + created_at: string_field(row, "created_at").unwrap_or_default(), + url: note_url(base_url, repo, kind, number, id), + state: String::new(), + path: String::new(), + line: None, + resolved: row + .get("resolved") + .and_then(Value::as_bool) + .unwrap_or(false), + thread_id: String::new(), + replies: Vec::new(), + }) + }) + .collect(); + comments.reverse(); + Ok(GitlabWorkItemThread { + comments, + truncated: has_next_page, + review_decision: String::new(), + base_ref_name: String::new(), + head_ref_name: String::new(), + }) +} + +fn parse_mr_diff(value: &Value, has_next_page: bool) -> Result { + let rows = value + .as_array() + .ok_or_else(|| "GitLab did not return merge request diffs".to_string())?; + let mut files = Vec::new(); + let mut patch = String::new(); + let mut total_additions = 0; + let mut total_deletions = 0; + let mut truncated = has_next_page; + + for row in rows { + let old_path = string_field(row, "old_path").unwrap_or_default(); + let new_path = string_field(row, "new_path").unwrap_or_else(|| old_path.clone()); + if new_path.is_empty() && old_path.is_empty() { + continue; + } + let diff = string_field_preserve(row, "diff").unwrap_or_default(); + let (additions, deletions) = diff_counts(&diff); + total_additions += additions; + total_deletions += deletions; + files.push(GitlabMrFile { + path: if new_path.is_empty() { + old_path.clone() + } else { + new_path.clone() + }, + additions, + deletions, + }); + truncated |= row + .get("too_large") + .and_then(Value::as_bool) + .unwrap_or(false) + || row + .get("collapsed") + .and_then(Value::as_bool) + .unwrap_or(false); + if diff.is_empty() || patch.len() >= MAX_DIFF_BYTES { + continue; + } + let block = gitlab_diff_block(row, &old_path, &new_path, &diff); + if patch.len() + block.len() > MAX_DIFF_BYTES { + truncated = true; + continue; + } + patch.push_str(&block); + } + + Ok(GitlabMrDiff { + additions: total_additions, + deletions: total_deletions, + files, + patch, + truncated, + }) +} + +fn gitlab_diff_block(row: &Value, old_path: &str, new_path: &str, diff: &str) -> String { + let old = if old_path.is_empty() { + new_path + } else { + old_path + }; + let new = if new_path.is_empty() { + old_path + } else { + new_path + }; + let mut block = format!("diff --git a/{old} b/{new}\n"); + if row + .get("new_file") + .and_then(Value::as_bool) + .unwrap_or(false) + { + block.push_str("new file mode 100644\n"); + } + if row + .get("deleted_file") + .and_then(Value::as_bool) + .unwrap_or(false) + { + block.push_str("deleted file mode 100644\n"); + } + if row + .get("renamed_file") + .and_then(Value::as_bool) + .unwrap_or(false) + { + block.push_str(&format!("rename from {old}\nrename to {new}\n")); + } + let old_header = if row + .get("new_file") + .and_then(Value::as_bool) + .unwrap_or(false) + { + "/dev/null".to_string() + } else { + format!("a/{old}") + }; + let new_header = if row + .get("deleted_file") + .and_then(Value::as_bool) + .unwrap_or(false) + { + "/dev/null".to_string() + } else { + format!("b/{new}") + }; + block.push_str(&format!("--- {old_header}\n+++ {new_header}\n")); + block.push_str(diff); + if !diff.ends_with('\n') { + block.push('\n'); + } + block +} + +fn diff_counts(diff: &str) -> (i64, i64) { + let mut additions = 0; + let mut deletions = 0; + for line in diff.lines() { + if line.starts_with("+++") || line.starts_with("---") { + continue; + } + if line.starts_with('+') { + additions += 1; + } else if line.starts_with('-') { + deletions += 1; + } + } + (additions, deletions) +} + +fn parse_labels(row: &Value) -> Vec { + row.get("labels") + .and_then(Value::as_array) + .map(|labels| { + labels + .iter() + .filter_map(|label| { + if let Some(name) = label.as_str() { + return Some(GitlabLabel { + name: name.trim().to_string(), + color: String::new(), + }); + } + let name = string_field(label, "name")?; + Some(GitlabLabel { + name, + color: string_field(label, "color") + .unwrap_or_default() + .trim_start_matches('#') + .to_string(), + }) + }) + .filter(|label| !label.name.is_empty()) + .collect() + }) + .unwrap_or_default() +} + +fn parse_assignees(row: &Value) -> Vec { + let mut people: Vec<&Value> = row + .get("assignees") + .and_then(Value::as_array) + .map(|people| people.iter().collect()) + .unwrap_or_default(); + if people.is_empty() { + if let Some(assignee) = row.get("assignee").filter(|value| value.is_object()) { + people.push(assignee); + } + } + people + .into_iter() + .filter_map(|person| { + let login = + string_field(person, "username").or_else(|| string_field(person, "name"))?; + if login.is_empty() { + return None; + } + Some(GitlabAssignee { + login, + avatar_url: string_field(person, "avatar_url").unwrap_or_default(), + }) + }) + .collect() +} + +fn normalize_state(state: &str) -> String { + match state.trim().to_ascii_lowercase().as_str() { + "opened" | "reopened" => "open".into(), + other => other.into(), + } +} + +fn note_url(base_url: &str, repo: &str, kind: &str, number: i64, id: i64) -> String { + let item = if kind == "pr" { + "merge_requests" + } else { + "issues" + }; + format!( + "{}/{repo}/-/{item}/{number}#note_{id}", + base_url.trim_end_matches('/') + ) +} + +fn string_field(value: &Value, key: &str) -> Option { + value + .get(key) + .and_then(Value::as_str) + .map(|value| value.trim().to_string()) +} + +fn string_field_preserve(value: &Value, key: &str) -> Option { + value.get(key).and_then(Value::as_str).map(str::to_string) +} + +struct GitlabResponse { + value: Value, + has_next_page: bool, +} + +fn gitlab_get(config: &GitlabConfig, path: &str) -> Result { + let url = format!("{}/api/v4{}", config.url.trim_end_matches('/'), path); + let agent = gitlab_agent(); + read_gitlab_response( + agent + .get(&url) + .set("PRIVATE-TOKEN", &config.token) + .set("Accept", "application/json") + .set("User-Agent", USER_AGENT) + .call(), + ) +} + +fn gitlab_post_form( + config: &GitlabConfig, + path: &str, + fields: &[(&str, &str)], +) -> Result { + let url = format!("{}/api/v4{}", config.url.trim_end_matches('/'), path); + let agent = gitlab_agent(); + read_gitlab_response( + agent + .post(&url) + .set("PRIVATE-TOKEN", &config.token) + .set("Accept", "application/json") + .set("User-Agent", USER_AGENT) + .send_form(fields), + ) +} + +fn gitlab_agent() -> ureq::Agent { + ureq::AgentBuilder::new() + .timeout(HTTP_TIMEOUT) + .redirects(0) + .build() +} + +fn read_gitlab_response( + result: Result, +) -> Result { + let response = match result { + Ok(response) => response, + Err(ureq::Error::Status(401, _)) | Err(ureq::Error::Status(403, _)) => { + return Err("GitLab access token is invalid or lacks permission".into()); + } + Err(ureq::Error::Status(status, response)) => { + let body = response.into_string().unwrap_or_default(); + return Err(gitlab_http_error(status, &body)); + } + Err(_) => return Err("Could not reach GitLab".into()), + }; + let status = response.status(); + let has_next_page = response + .header("X-Next-Page") + .map(str::trim) + .is_some_and(|value| !value.is_empty()); + let body = response + .into_string() + .map_err(|_| "GitLab returned an unreadable response".to_string())?; + if !(200..300).contains(&status) { + return Err(gitlab_http_error(status, &body)); + } + let value = + serde_json::from_str(&body).map_err(|_| "GitLab returned invalid JSON".to_string())?; + Ok(GitlabResponse { + value, + has_next_page, + }) +} + +fn gitlab_http_error(status: u16, body: &str) -> String { + let message = serde_json::from_str::(body).ok().and_then(|value| { + value + .get("message") + .and_then(Value::as_str) + .or_else(|| value.get("error").and_then(Value::as_str)) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + }); + message.unwrap_or_else(|| format!("GitLab request failed ({status})")) +} + +fn normalize_gitlab_url(raw: &str) -> Result { + let raw = raw.trim(); + let raw = if raw.is_empty() { + DEFAULT_GITLAB_URL + } else { + raw + }; + if raw.contains("://") && !raw.starts_with("https://") && !raw.starts_with("http://") { + return Err("GitLab URL must use HTTP or HTTPS".into()); + } + let value = if raw.starts_with("https://") || raw.starts_with("http://") { + raw.to_string() + } else { + format!("https://{raw}") + }; + let (_, rest) = value + .split_once("://") + .ok_or_else(|| "GitLab URL must use HTTP or HTTPS".to_string())?; + let path = rest.split_once('/').map(|(_, path)| path).unwrap_or(""); + if rest.is_empty() + || rest.starts_with('/') + || rest.contains('@') + || rest.contains('?') + || rest.contains('#') + || rest.contains('\\') + || rest.chars().any(char::is_whitespace) + || path.split('/').any(|segment| { + matches!( + segment.to_ascii_lowercase().as_str(), + "." | ".." | "%2e" | "%2e%2e" | "%2e." | ".%2e" + ) + }) + { + return Err("GitLab URL is invalid".into()); + } + let normalized = value.trim_end_matches('/'); + let normalized = normalized.strip_suffix("/api/v4").unwrap_or(normalized); + Ok(normalized.trim_end_matches('/').to_string()) +} + +fn encode_path_component(value: &str) -> String { + let mut encoded = String::new(); + for byte in value.as_bytes() { + if byte.is_ascii_alphanumeric() || matches!(*byte, b'-' | b'_' | b'.' | b'~') { + encoded.push(*byte as char); + } else { + encoded.push_str(&format!("%{byte:02X}")); + } + } + encoded +} + +fn gitlab_repo_for(root: &Path, gitlab_url: &str) -> Result { + let output = Command::new("git") + .args(["config", "--get-regexp", r"^remote\..*\.url$"]) + .current_dir(root) + .output() + .map_err(|_| "Could not run git".to_string())?; + if !output.status.success() && output.status.code() != Some(1) { + return Err("Could not read git remotes".into()); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let mut matches = Vec::new(); + for line in stdout.lines() { + let Some((name, remote)) = line.split_once(char::is_whitespace) else { + continue; + }; + if let Some(repo) = project_from_remote(remote.trim(), gitlab_url) { + matches.push((name == "remote.origin.url", repo)); + } + } + matches + .iter() + .find(|(origin, _)| *origin) + .or_else(|| matches.first()) + .map(|(_, repo)| repo.clone()) + .ok_or_else(|| "No GitLab remote matches the configured host".to_string()) +} + +fn project_from_remote(remote: &str, gitlab_url: &str) -> Option { + let configured = configured_remote(gitlab_url)?; + let (authority, mut path) = remote_authority_path(remote)?; + if host_without_port(&authority) != host_without_port(&configured.authority) { + return None; + } + path = path.trim_matches('/').to_string(); + let prefix = configured.path.trim_matches('/'); + if !prefix.is_empty() { + path = path.strip_prefix(&format!("{prefix}/"))?.to_string(); + } + if let Some(stripped) = path.strip_suffix(".git") { + path = stripped.to_string(); + } + if !valid_project_path(&path) { + return None; + } + Some(path) +} + +struct ConfiguredRemote { + authority: String, + path: String, +} + +fn configured_remote(url: &str) -> Option { + let normalized = normalize_gitlab_url(url).ok()?; + let (_, rest) = normalized.split_once("://")?; + let (authority, path) = rest.split_once('/').unwrap_or((rest, "")); + Some(ConfiguredRemote { + authority: authority.to_ascii_lowercase(), + path: path.to_string(), + }) +} + +fn remote_authority_path(remote: &str) -> Option<(String, String)> { + let remote = remote.trim(); + if let Some((_, rest)) = remote.split_once("://") { + let (authority, path) = rest.split_once('/')?; + let authority = authority.rsplit('@').next()?.to_ascii_lowercase(); + return Some((authority, path.to_string())); + } + let (user_host, path) = remote.split_once(':')?; + let authority = user_host.rsplit('@').next()?.to_ascii_lowercase(); + Some((authority, path.to_string())) +} + +fn host_without_port(authority: &str) -> String { + let authority = authority.trim().to_ascii_lowercase(); + if authority.starts_with('[') { + return authority + .split(']') + .next() + .map(|host| format!("{host}]")) + .unwrap_or(authority); + } + authority + .split(':') + .next() + .unwrap_or(&authority) + .to_string() +} + +fn valid_project_path(path: &str) -> bool { + let parts: Vec<&str> = path.split('/').collect(); + parts.len() >= 2 + && parts.iter().all(|part| { + !part.is_empty() + && *part != "." + && *part != ".." + && !part.chars().any(char::is_whitespace) + && !part.contains(['?', '#', '\\']) + }) +} + +fn config_path(app: &AppHandle) -> Result { + Ok(app + .path() + .app_data_dir() + .map_err(|error| error.to_string())? + .join("gitlab-config.json")) +} + +fn read_config(app: &AppHandle) -> Result, String> { + let path = config_path(app)?; + match fs::read_to_string(path) { + Ok(raw) => { + let mut config: GitlabConfig = serde_json::from_str(&raw) + .map_err(|_| "GitLab settings are invalid".to_string())?; + config.url = normalize_gitlab_url(&config.url)?; + config.token = config.token.trim().to_string(); + if config.token.is_empty() { + Ok(None) + } else { + Ok(Some(config)) + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error.to_string()), + } +} + +fn require_config(app: &AppHandle) -> Result { + read_config(app)?.ok_or_else(|| "Connect GitLab in Settings".to_string()) +} + +fn write_config(app: &AppHandle, config: &GitlabConfig) -> Result<(), String> { + let path = config_path(app)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + let value = serde_json::to_string(config).map_err(|error| error.to_string())?; + write_secret_file(&path, &value) +} + +fn delete_config(app: &AppHandle) -> Result<(), String> { + let path = config_path(app)?; + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.to_string()), + } +} + +fn write_secret_file(path: &Path, value: &str) -> Result<(), String> { + #[cfg(unix)] + { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let mut file = fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .mode(0o600) + .open(path) + .map_err(|error| error.to_string())?; + file.write_all(value.as_bytes()) + .map_err(|error| error.to_string())?; + Ok(()) + } + #[cfg(not(unix))] + { + fs::write(path, value).map_err(|error| error.to_string()) + } +} + +fn expand_home(input: &str) -> PathBuf { + if input == "~" { + return crate::dirs_home() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(input)); + } + if let Some(rest) = input.strip_prefix("~/") { + return crate::dirs_home() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("~")) + .join(rest); + } + PathBuf::from(input) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn normalizes_host_and_api_suffix() { + assert_eq!(normalize_gitlab_url("").unwrap(), "https://gitlab.com"); + assert_eq!( + normalize_gitlab_url("gitlab.example.com/").unwrap(), + "https://gitlab.example.com" + ); + assert_eq!( + normalize_gitlab_url("https://gitlab.example.com/api/v4").unwrap(), + "https://gitlab.example.com" + ); + assert!(normalize_gitlab_url("ftp://gitlab.example.com").is_err()); + assert!(normalize_gitlab_url("https://user@host").is_err()); + assert!(normalize_gitlab_url("https://host/../admin").is_err()); + } + + #[test] + fn reads_https_and_ssh_project_remotes() { + assert_eq!( + project_from_remote( + "https://gitlab.example.com/acme/platform/web.git", + "https://gitlab.example.com" + ) + .as_deref(), + Some("acme/platform/web") + ); + assert_eq!( + project_from_remote( + "git@gitlab.example.com:acme/web.git", + "https://gitlab.example.com" + ) + .as_deref(), + Some("acme/web") + ); + assert_eq!( + project_from_remote( + "ssh://git@gitlab.example.com/acme/web.git", + "https://gitlab.example.com" + ) + .as_deref(), + Some("acme/web") + ); + assert!( + project_from_remote("git@github.com:acme/web.git", "https://gitlab.example.com") + .is_none() + ); + } + + #[test] + fn reads_relative_url_root_remotes() { + assert_eq!( + project_from_remote( + "https://code.example.com/gitlab/acme/web.git", + "https://code.example.com/gitlab" + ) + .as_deref(), + Some("acme/web") + ); + assert!(project_from_remote( + "https://code.example.com/gitlab-old/acme/web.git", + "https://code.example.com/gitlab" + ) + .is_none()); + } + + #[test] + fn encodes_project_path_for_api() { + assert_eq!( + encode_path_component("acme/platform web"), + "acme%2Fplatform%20web" + ); + } + + #[test] + fn parses_issue_and_merge_request_fields() { + let rows = json!([{ + "iid": 9, + "title": "Draft: Improve login", + "web_url": "https://gitlab.example.com/acme/web/-/merge_requests/9", + "state": "opened", + "updated_at": "2026-09-09T10:00:00Z", + "labels": [{ "name": "bug", "color": "#ff0000" }], + "assignees": [{ "username": "maya", "avatar_url": "https://gitlab.example.com/uploads/maya.png" }] + }]); + let items = parse_work_items(&rows, "pr", "acme/web").unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].kind, "pr"); + assert_eq!(items[0].state, "open"); + assert!(items[0].draft); + assert_eq!(items[0].labels[0].color, "ff0000"); + assert_eq!(items[0].assignees[0].login, "maya"); + } + + #[test] + fn parses_details_and_comments() { + let details = parse_work_item_details( + &json!({ + "description": "Body", + "author": { "username": "maya", "avatar_url": "https://example.com/maya.png" }, + "target_branch": "main", + "source_branch": "feature" + }), + "pr", + ) + .unwrap(); + assert_eq!(details.author, "maya"); + assert_eq!(details.base_ref_name, "main"); + assert_eq!(details.head_ref_name, "feature"); + + let thread = parse_work_item_thread( + &json!([ + { "id": 1, "body": "Started", "system": true }, + { + "id": 2, + "body": "Looks good", + "created_at": "2026-09-09T10:00:00Z", + "author": { "username": "ada" }, + "system": false + } + ]), + "https://gitlab.example.com", + "acme/web", + "pr", + 9, + true, + ) + .unwrap(); + assert!(thread.truncated); + assert_eq!(thread.comments.len(), 1); + assert_eq!(thread.comments[0].author, "ada"); + assert_eq!( + thread.comments[0].url, + "https://gitlab.example.com/acme/web/-/merge_requests/9#note_2" + ); + } + + #[test] + fn builds_merge_request_diff() { + let diff = parse_mr_diff( + &json!([{ + "old_path": "src/old.ts", + "new_path": "src/new.ts", + "renamed_file": true, + "diff": "@@ -1 +1 @@\n-old\n+new\n" + }]), + false, + ) + .unwrap(); + assert_eq!(diff.additions, 1); + assert_eq!(diff.deletions, 1); + assert_eq!(diff.files[0].path, "src/new.ts"); + assert!(diff.patch.contains("rename from src/old.ts")); + assert!(diff.patch.contains("@@ -1 +1 @@")); + } +} diff --git a/src-tauri/src/grok_usage.rs b/src-tauri/src/grok_usage.rs new file mode 100644 index 00000000..77f595f2 --- /dev/null +++ b/src-tauri/src/grok_usage.rs @@ -0,0 +1,167 @@ +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use serde::Serialize; +use serde_json::Value; + +use crate::dirs_home; + +const BILLING_CREDITS_URL: &str = "https://cli-chat-proxy.grok.com/v1/billing?format=credits"; +const USER_AGENT: &str = "MonoCode"; +const TOKEN_AUTH: &str = "xai-grok-cli"; +const HTTP_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GrokUsageFetch { + pub status: String, + pub http_status: Option, + pub body: Option, + pub error: Option, +} + +/// Fetch Grok credit usage via the signed-in Grok CLI session. +/// The access token never leaves the host process. +#[tauri::command] +pub async fn fetch_grok_usage() -> Result { + tauri::async_runtime::spawn_blocking(fetch_grok_usage_sync) + .await + .map_err(|e| e.to_string())? +} + +fn fetch_grok_usage_sync() -> Result { + let Some(token) = read_grok_access_token() else { + return Ok(usage_result( + "unavailable", + None, + None, + Some("Grok not signed in".into()), + )); + }; + Ok(fetch_usage_with_token(&token)) +} + +fn usage_result( + status: &str, + http_status: Option, + body: Option, + error: Option, +) -> GrokUsageFetch { + GrokUsageFetch { + status: status.into(), + http_status, + body, + error, + } +} + +fn fetch_usage_with_token(token: &str) -> GrokUsageFetch { + let agent = ureq::AgentBuilder::new().timeout(HTTP_TIMEOUT).build(); + let result = agent + .get(BILLING_CREDITS_URL) + .set("Accept", "application/json") + .set("Authorization", &format!("Bearer {token}")) + .set("x-xai-token-auth", TOKEN_AUTH) + .set("User-Agent", USER_AGENT) + .call(); + + match result { + Ok(response) => { + let http_status = response.status(); + let body = response.into_string().unwrap_or_default(); + if (200..300).contains(&http_status) { + usage_result("ok", Some(http_status), Some(body), None) + } else { + usage_error(http_status) + } + } + Err(ureq::Error::Status(status, response)) => { + let _ = response.into_string(); + usage_error(status) + } + Err(error) => usage_result( + "error", + None, + None, + Some(format!("Grok usage request failed: {error}")), + ), + } +} + +fn usage_error(status: u16) -> GrokUsageFetch { + let (kind, message) = if status == 401 || status == 403 { + ("unavailable", "Grok not signed in".into()) + } else { + ("error", format!("Grok usage request failed ({status})")) + }; + usage_result(kind, Some(status), None, Some(message)) +} + +fn read_grok_access_token() -> Option { + let path = grok_auth_path()?; + let raw = std::fs::read_to_string(path).ok()?; + extract_grok_access_token(&raw) +} + +fn grok_auth_path() -> Option { + Some(Path::new(&dirs_home()?).join(".grok/auth.json")) +} + +pub(crate) fn extract_grok_access_token(raw: &str) -> Option { + let value: Value = serde_json::from_str(raw.trim()).ok()?; + let object = value.as_object()?; + let mut best: Option<(String, String)> = None; + for entry in object.values() { + let Some(key) = entry.get("key").and_then(Value::as_str) else { + continue; + }; + let key = key.trim(); + if key.is_empty() { + continue; + } + let expires = entry + .get("expires_at") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let replace = match &best { + Some((current, _)) => expires > *current, + None => true, + }; + if replace { + best = Some((expires, key.to_string())); + } + } + best.map(|(_, key)| key) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_grok_access_token_picks_the_newest_entry() { + let raw = r#"{ + "https://auth.x.ai::old": { + "key": "old-token", + "expires_at": "2026-01-01T00:00:00Z" + }, + "https://auth.x.ai::new": { + "key": "new-token", + "expires_at": "2026-09-09T01:42:07Z" + } + }"#; + assert_eq!(extract_grok_access_token(raw).as_deref(), Some("new-token")); + } + + #[test] + fn extract_grok_access_token_skips_empty_keys() { + let raw = r#"{"https://auth.x.ai::a":{"key":" ","expires_at":"2099-01-01T00:00:00Z"}}"#; + assert_eq!(extract_grok_access_token(raw), None); + } + + #[test] + fn extract_grok_access_token_rejects_garbage() { + assert_eq!(extract_grok_access_token("not json"), None); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 33eb1886..3003f187 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,8 +1,12 @@ use tauri::Manager; +mod chat_background; mod checkpoint; mod cursor_store; +mod cursor_usage; mod fs; +mod gitlab; +mod grok_usage; mod harness; mod inbox_media; mod linear; @@ -225,12 +229,21 @@ pub fn run() { fs::git_pr_status, fs::git_pr_create, fs::git_github_repo, + fs::git_github_work_item, fs::git_github_work_items, fs::git_github_work_item_details, fs::git_github_work_item_thread, fs::git_github_work_item_comment, fs::git_github_pr_diff, inbox_media::fetch_inbox_media, + gitlab::gitlab_status, + gitlab::gitlab_set_config, + gitlab::gitlab_repo, + gitlab::gitlab_list_work_items, + gitlab::gitlab_work_item_details, + gitlab::gitlab_work_item_thread, + gitlab::gitlab_work_item_comment, + gitlab::gitlab_mr_diff, linear::linear_status, linear::linear_set_token, linear::linear_list_teams, @@ -278,6 +291,8 @@ pub fn run() { harness::harness_sse_close, harness::harness_exec, rate_limits::fetch_claude_usage, + cursor_usage::fetch_cursor_usage, + grok_usage::fetch_grok_usage, pty::pty_spawn, pty::pty_write, pty::pty_resize, @@ -286,6 +301,7 @@ pub fn run() { pty::pty_kill_all, session_store::session_upsert, session_store::session_list_by_project, + session_store::session_list_linked, session_store::session_search, session_store::session_get, session_store::session_delete, @@ -300,6 +316,8 @@ pub fn run() { notes::notes_get, notes::notes_upsert, notes::notes_delete, + notes::notes_save_image, + notes::notes_image_path, checkpoint::session_checkpoint_ensure, checkpoint::session_checkpoint_prepare, checkpoint::session_checkpoint_capture, @@ -314,9 +332,13 @@ pub fn run() { window::hide_window, window::destroy_window, window::confirm_quit, - window::enable_window_glass, + window::set_window_glass_enabled, window_transfer::stage_window_transfer, window_transfer::take_window_transfer, + chat_background::save_chat_background, + chat_background::remove_chat_background, + chat_background::save_project_chat_background, + chat_background::remove_project_chat_background, project_logo::save_project_logo, project_logo::remove_project_logo, project_logo::forget_logo_file, diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index 73e2312b..ee185cee 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -19,7 +19,7 @@ //! shadow without that outline. use std::cell::RefCell; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::ffi::{c_char, c_int, c_void}; use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; use std::sync::{Mutex, OnceLock}; @@ -54,6 +54,7 @@ const RTLD_DEFAULT: *mut c_void = -2isize as *mut c_void; static PINNED: AtomicBool = AtomicBool::new(false); static BLUR_RADIUS: AtomicU8 = AtomicU8::new(BLUR_DEFAULT); static WINDOW_BADGES: OnceLock>> = OnceLock::new(); +static GLASS_WINDOWS: OnceLock>> = OnceLock::new(); type CgsConnection = usize; type SetBlurFn = unsafe extern "C" fn(CgsConnection, c_int, c_int) -> c_int; @@ -77,7 +78,10 @@ pub fn install(window: &WebviewWindow) { WindowEvent::Resized(_) | WindowEvent::ScaleFactorChanged { .. } => { stretch_titlebar(&event_window); } - WindowEvent::Destroyed => set_window_badge(&event_window, 0), + WindowEvent::Destroyed => { + set_window_badge(&event_window, 0); + set_glass_enabled(&event_window, false); + } _ => {} }); } @@ -148,7 +152,31 @@ pub fn set_visible(window: &WebviewWindow, visible: bool) { pub fn set_background_blur_radius(window: &WebviewWindow, radius: u8) { let radius = radius.clamp(BLUR_MIN, BLUR_MAX); BLUR_RADIUS.store(radius, Ordering::Relaxed); - apply_blur(window, radius); + if glass_enabled(window) { + apply_blur(window, radius); + } +} + +fn glass_windows() -> &'static Mutex> { + GLASS_WINDOWS.get_or_init(|| Mutex::new(HashSet::new())) +} + +fn glass_enabled(window: &WebviewWindow) -> bool { + glass_windows() + .lock() + .unwrap_or_else(|err| err.into_inner()) + .contains(window.label()) +} + +fn set_glass_enabled(window: &WebviewWindow, enabled: bool) { + let mut windows = glass_windows() + .lock() + .unwrap_or_else(|err| err.into_inner()); + if enabled { + windows.insert(window.label().to_string()); + } else { + windows.remove(window.label()); + } } /// Solid field behind the dock bounce. Same colour as the HTML sheet. @@ -177,10 +205,18 @@ fn set_launch_background(window: &WebviewWindow, r: u8, g: u8, b: u8) { /// Turn on desktop blur after the first UI paint. pub fn enable_glass(window: &WebviewWindow) { + set_glass_enabled(window, true); prepare_glass(window); apply_blur(window, BLUR_RADIUS.load(Ordering::Relaxed)); } +/// Light mode stays opaque because pale desktop content makes translucent UI illegible. +pub fn disable_glass(window: &WebviewWindow) { + set_glass_enabled(window, false); + apply_blur(window, 0); + set_launch_background(window, 247, 247, 247); +} + fn prepare_glass(window: &WebviewWindow) { let Some(ns_window) = ns_window(window) else { return; @@ -208,11 +244,7 @@ fn apply_blur(window: &WebviewWindow, radius: u8) { return; } unsafe { - set_blur( - connection, - window_number as c_int, - radius.max(BLUR_MIN) as c_int, - ); + set_blur(connection, window_number as c_int, radius as c_int); } } diff --git a/src-tauri/src/notes.rs b/src-tauri/src/notes.rs index 0e028818..9b823547 100644 --- a/src-tauri/src/notes.rs +++ b/src-tauri/src/notes.rs @@ -1,11 +1,18 @@ +use std::path::{Component, Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + use rusqlite::{params, Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; -use tauri::State; +use tauri::{AppHandle, Manager, State}; +use crate::fs::expand_home; use crate::session_store::{now_millis, validate_id, SessionStore}; const TITLE_MAX: usize = 200; const BODY_MAX: usize = 1_000_000; +const IMAGE_MAX_BYTES: u64 = 20 * 1024 * 1024; +const IMAGE_EXTENSIONS: [&str; 6] = ["png", "jpg", "jpeg", "gif", "webp", "svg"]; +const NOTE_ASSET_DIR: &str = "note-assets"; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -34,6 +41,13 @@ pub struct NoteUpsert { pub source_cwd: Option, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NoteImageAsset { + pub name: String, + pub markdown_path: String, +} + pub fn ensure_notes_table(conn: &Connection) -> rusqlite::Result<()> { conn.execute_batch( "CREATE TABLE IF NOT EXISTS notes ( @@ -80,10 +94,160 @@ pub fn notes_upsert(store: State<'_, SessionStore>, note: NoteUpsert) -> Result< } #[tauri::command(async)] -pub fn notes_delete(store: State<'_, SessionStore>, id: String) -> Result<(), String> { +pub fn notes_delete( + app: AppHandle, + store: State<'_, SessionStore>, + id: String, +) -> Result<(), String> { validate_id(&id, "note")?; let conn = store.lock_conn()?; - delete_note(&conn, &id).map_err(|e| e.to_string()) + delete_note(&conn, &id).map_err(|e| e.to_string())?; + drop(conn); + // The note deletion is authoritative. A cleanup failure should not leave a + // successfully deleted note visible in the UI. + let _ = remove_note_assets(&app, &id); + Ok(()) +} + +#[tauri::command] +pub async fn notes_save_image( + app: AppHandle, + note_id: String, + source_path: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || save_note_image_sync(&app, ¬e_id, &source_path)) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command(async)] +pub fn notes_image_path(app: AppHandle, asset: String) -> Result { + let relative = validate_note_asset_path(&asset)?; + let path = app + .path() + .app_data_dir() + .map_err(|e| e.to_string())? + .join(relative); + if !path.is_file() { + return Err("Note image was not found".into()); + } + Ok(path.to_string_lossy().into_owned()) +} + +fn note_assets_dir(app: &AppHandle, note_id: &str) -> Result { + validate_id(note_id, "note")?; + Ok(app + .path() + .app_data_dir() + .map_err(|e| e.to_string())? + .join(NOTE_ASSET_DIR) + .join(note_id)) +} + +fn save_note_image_sync( + app: &AppHandle, + note_id: &str, + source_path: &str, +) -> Result { + let source = expand_home(source_path); + let meta = std::fs::metadata(&source).map_err(|e| format!("{}: {e}", source.display()))?; + if !meta.is_file() { + return Err("Not a file".into()); + } + if meta.len() > IMAGE_MAX_BYTES { + return Err(format!( + "Image is too large (maximum {} MB).", + IMAGE_MAX_BYTES / 1024 / 1024 + )); + } + + let (display_name, safe_name) = note_image_names(&source)?; + let dir = note_assets_dir(app, note_id)?; + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let stored_name = format!("{stamp}-{safe_name}"); + let destination = dir.join(&stored_name); + std::fs::copy(&source, &destination).map_err(|e| format!("{}: {e}", destination.display()))?; + + Ok(NoteImageAsset { + name: display_name, + markdown_path: format!("/{NOTE_ASSET_DIR}/{note_id}/{stored_name}"), + }) +} + +fn note_image_names(source: &Path) -> Result<(String, String), String> { + let extension = source + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + if !IMAGE_EXTENSIONS.contains(&extension.as_str()) { + return Err("Image must be a PNG, JPG, GIF, WebP, or SVG file.".into()); + } + let display_name = source + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("image") + .to_string(); + let stem = source + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("image"); + let mut safe_stem: String = stem + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '-' + } + }) + .take(80) + .collect(); + safe_stem = safe_stem.trim_matches('-').to_string(); + if safe_stem.is_empty() { + safe_stem = "image".into(); + } + Ok((display_name, format!("{safe_stem}.{extension}"))) +} + +fn validate_note_asset_path(asset: &str) -> Result { + let relative = asset + .strip_prefix('/') + .ok_or_else(|| "Invalid note image path".to_string())?; + let path = Path::new(relative); + let parts = path + .components() + .map(|part| match part { + Component::Normal(value) => value.to_str().map(str::to_string), + _ => None, + }) + .collect::>>() + .ok_or_else(|| "Invalid note image path".to_string())?; + if parts.len() != 3 || parts[0] != NOTE_ASSET_DIR { + return Err("Invalid note image path".into()); + } + validate_id(&parts[1], "note")?; + if parts[2].is_empty() + || !parts[2] + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + { + return Err("Invalid note image path".into()); + } + Ok(path.to_path_buf()) +} + +fn remove_note_assets(app: &AppHandle, note_id: &str) -> Result<(), String> { + let dir = note_assets_dir(app, note_id)?; + match std::fs::remove_dir_all(dir) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.to_string()), + } } fn list_notes(conn: &Connection) -> rusqlite::Result> { @@ -354,6 +518,28 @@ mod tests { assert_eq!(note.slug, "untitled"); } + #[test] + fn note_image_names_are_safe_and_keep_supported_extensions() { + assert_eq!( + note_image_names(Path::new("/tmp/Architecture draft [2].PNG")).unwrap(), + ( + "Architecture draft [2].PNG".into(), + "Architecture-draft--2.png".into() + ) + ); + assert!(note_image_names(Path::new("/tmp/archive.zip")).is_err()); + } + + #[test] + fn note_asset_paths_cannot_escape_app_data() { + assert_eq!( + validate_note_asset_path("/note-assets/note-1/123-image.png").unwrap(), + PathBuf::from("note-assets/note-1/123-image.png") + ); + assert!(validate_note_asset_path("/note-assets/note-1/../secret.png").is_err()); + assert!(validate_note_asset_path("/other/note-1/image.png").is_err()); + } + #[test] fn delete_removes_the_row() { let store = SessionStore::open_in_memory().unwrap(); diff --git a/src-tauri/src/search.rs b/src-tauri/src/search.rs index 089bb7ff..60d263ae 100644 --- a/src-tauri/src/search.rs +++ b/src-tauri/src/search.rs @@ -85,6 +85,9 @@ fn git_grep(root: &Path, options: &SearchOptions, query: &str) -> Option, exclude: &Option) -> Vec } specs } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::ErrorKind; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::time::{SystemTime, UNIX_EPOCH}; + + static TMP_SEQ: AtomicU64 = AtomicU64::new(0); + + struct Tmp(PathBuf); + + impl Drop for Tmp { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn tmp(label: &str) -> Tmp { + loop { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let seq = TMP_SEQ.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "monocode-search-{label}-{}-{stamp}-{seq}", + std::process::id() + )); + match std::fs::create_dir(&dir) { + Ok(()) => return Tmp(dir), + Err(error) if error.kind() == ErrorKind::AlreadyExists => continue, + Err(error) => panic!("{error}"), + } + } + } + + fn git(dir: &Path, args: &[&str]) -> bool { + Command::new("git") + .args(args) + .current_dir(dir) + .status() + .map(|status| status.success()) + .unwrap_or(false) + } + + #[test] + fn git_grep_treats_hyphen_prefixed_include_as_pathspec() { + let dir = tmp("hyphen-pathspec"); + if !git(&dir.0, &["init", "--quiet"]) { + return; + } + // `-l` is both a valid file name and git grep's files-with-matches flag. + std::fs::write(dir.0.join("-l"), "find me\n").unwrap(); + std::fs::write(dir.0.join("other.txt"), "find me too\n").unwrap(); + assert!(git(&dir.0, &["add", "--", "-l", "other.txt"])); + + let result = git_grep( + &dir.0, + &SearchOptions { + cwd: dir.0.to_string_lossy().into_owned(), + query: "find me".to_string(), + case_sensitive: true, + whole_word: false, + regex: false, + include: Some("-l".to_string()), + exclude: None, + }, + "find me", + ) + .unwrap(); + + assert_eq!(result.matches.len(), 1); + assert_eq!(result.matches[0].relative, "-l"); + assert_eq!(result.matches[0].preview, "find me"); + } +} diff --git a/src-tauri/src/session_store.rs b/src-tauri/src/session_store.rs index 88c15b04..e8db9872 100644 --- a/src-tauri/src/session_store.rs +++ b/src-tauri/src/session_store.rs @@ -91,6 +91,8 @@ pub struct SessionUpsert { pub branch: Option, #[serde(default)] pub worktree_cwd: Option, + #[serde(default)] + pub linked_work_item: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -116,6 +118,8 @@ pub struct SessionSummary { pub archived: bool, #[serde(default)] pub pinned: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub linked_work_item: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -139,6 +143,8 @@ pub struct SessionRecord { pub branch: Option, #[serde(skip_serializing_if = "Option::is_none")] pub worktree_cwd: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub linked_work_item: Option, pub created_at: i64, pub updated_at: i64, } @@ -180,6 +186,12 @@ pub fn session_list_by_project( list_by_project(&conn, &cwd).map_err(|e| e.to_string()) } +#[tauri::command(async)] +pub fn session_list_linked(store: State<'_, SessionStore>) -> Result, String> { + let conn = store.conn.lock().map_err(|_| "Session store is locked")?; + list_linked(&conn).map_err(|e| e.to_string()) +} + #[tauri::command(async)] pub fn session_get( store: State<'_, SessionStore>, @@ -481,6 +493,7 @@ fn migrate(conn: &Connection) -> rusqlite::Result<()> { ("worktree_cwd", "TEXT"), ("has_user_message", "INTEGER NOT NULL DEFAULT 0"), ("pinned", "INTEGER NOT NULL DEFAULT 0"), + ("linked_work_item_json", "TEXT"), ] { ensure_session_column(conn, column, decl)?; } @@ -534,6 +547,23 @@ fn migrate(conn: &Connection) -> rusqlite::Result<()> { params![now_millis()], )?; } + if current < 12 { + // The sidebar renders this metadata for every row, so keep it in the + // same covering index as the rest of the session-card projection. + ensure_column(conn, "linked_work_item_json", "TEXT")?; + conn.execute_batch( + "DROP INDEX IF EXISTS sessions_cwd_cover_idx; + CREATE INDEX IF NOT EXISTS sessions_cwd_cover_idx + ON sessions (cwd, has_user_message, updated_at DESC, id, harness, + model, runtime_mode, title, provider_session_id, + created_at, branch, archived, pinned, + linked_work_item_json);", + )?; + conn.execute( + "INSERT INTO schema_migrations (version, applied_at) VALUES (12, ?1)", + params![now_millis()], + )?; + } // Create even when a version row already exists (another build may have // used the same numbers, or a previous run recorded the version without // the table). Restore writes into these; missing tables look like a @@ -567,6 +597,12 @@ fn upsert_session(conn: &Connection, session: &SessionUpsert) -> rusqlite::Resul .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; let blocks_json = serde_json::to_string(&session.blocks) .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; + let linked_work_item_json = session + .linked_work_item + .as_ref() + .map(serde_json::to_string) + .transpose() + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; let provider_session_id = session .provider_session_id .as_ref() @@ -617,8 +653,9 @@ fn upsert_session(conn: &Connection, session: &SessionUpsert) -> rusqlite::Resul "INSERT INTO sessions ( id, cwd, harness, model, model_settings, runtime_mode, title, provider_session_id, blocks_json, created_at, updated_at, branch, - context_used, context_window, worktree_cwd, has_user_message - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16) + context_used, context_window, worktree_cwd, has_user_message, + linked_work_item_json + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17) ON CONFLICT(id) DO UPDATE SET cwd = excluded.cwd, harness = excluded.harness, @@ -633,7 +670,8 @@ fn upsert_session(conn: &Connection, session: &SessionUpsert) -> rusqlite::Resul context_used = excluded.context_used, context_window = excluded.context_window, worktree_cwd = excluded.worktree_cwd, - has_user_message = excluded.has_user_message", + has_user_message = excluded.has_user_message, + linked_work_item_json = excluded.linked_work_item_json", params![ session.id, session.cwd, @@ -651,6 +689,7 @@ fn upsert_session(conn: &Connection, session: &SessionUpsert) -> rusqlite::Resul session.context_window, worktree_cwd, i64::from(has_user_message), + linked_work_item_json, ], )?; @@ -670,6 +709,7 @@ fn upsert_session(conn: &Connection, session: &SessionUpsert) -> rusqlite::Resul updated_at, archived, pinned, + linked_work_item: session.linked_work_item.clone(), }) } @@ -923,7 +963,8 @@ fn list_by_project(conn: &Connection, cwd: &str) -> rusqlite::Result rusqlite::Result = row.get(9)?; let archived: i64 = row.get(10)?; let pinned: i64 = row.get(11)?; + let linked_work_item = optional_json(row.get(12)?); Ok(SessionSummary { id: row.get(0)?, cwd: row.get(1)?, @@ -950,6 +992,43 @@ fn list_by_project(conn: &Connection, cwd: &str) -> rusqlite::Result rusqlite::Result> { + let mut statement = conn.prepare( + "SELECT id, cwd, harness, model, runtime_mode, title, provider_session_id, + created_at, updated_at, branch, archived, pinned, + linked_work_item_json + FROM sessions + WHERE has_user_message = 1 + AND linked_work_item_json IS NOT NULL + AND id NOT IN (SELECT id FROM sessions WHERE inbox_ask IS NOT NULL) + ORDER BY updated_at DESC, id ASC", + )?; + let rows = statement.query_map([], |row| { + let archived: i64 = row.get(10)?; + let pinned: i64 = row.get(11)?; + Ok(SessionSummary { + id: row.get(0)?, + cwd: row.get(1)?, + harness: row.get(2)?, + model: row.get(3)?, + runtime_mode: row.get(4)?, + title: row.get(5)?, + provider_session_id: row.get(6)?, + created_at: row.get(7)?, + updated_at: row.get(8)?, + branch: nonempty(row.get(9)?), + repo: None, + additions: 0, + deletions: 0, + archived: archived != 0, + pinned: pinned != 0, + linked_work_item: optional_json(row.get(12)?), }) })?; rows.collect() @@ -976,6 +1055,10 @@ fn json_eq(raw: &str, incoming: &Value) -> bool { } } +fn optional_json(raw: Option) -> Option { + raw.and_then(|value| serde_json::from_str(&value).ok()) +} + fn delete_session(conn: &Connection, session_id: &str) -> rusqlite::Result<()> { conn.execute("DELETE FROM sessions WHERE id = ?1", params![session_id])?; Ok(()) @@ -1001,7 +1084,8 @@ fn get_session(conn: &Connection, session_id: &str) -> rusqlite::Result rusqlite::Result Result<(), String> { Ok(()) } -/// Desktop blur goes on after the first UI paint, not during the dock bounce. +/// Desktop blur goes on after the first UI paint and only in dark mode. #[tauri::command] -pub fn enable_window_glass(window: WebviewWindow) { +pub fn set_window_glass_enabled(window: WebviewWindow, enabled: bool) { #[cfg(target_os = "macos")] { - let _ = window.set_background_color(Some(Color(0, 0, 0, 3))); - crate::macos::enable_glass(&window); + if enabled { + let _ = window.set_background_color(Some(Color(0, 0, 0, 3))); + crate::macos::enable_glass(&window); + } else { + crate::macos::disable_glass(&window); + } } #[cfg(target_os = "windows")] { - let _ = window.set_background_color(Some(Color(0, 0, 0, 0))); - let _ = window.set_effects(EffectsBuilder::new().effect(Effect::Acrylic).build()); + if enabled { + let _ = window.set_background_color(Some(Color(0, 0, 0, 0))); + let _ = window.set_effects(EffectsBuilder::new().effect(Effect::Acrylic).build()); + } else { + let _ = window.set_effects(None); + let _ = window.set_background_color(Some(Color(247, 247, 247, 255))); + } } #[cfg(not(any(target_os = "macos", target_os = "windows")))] { - let _ = window; + let _ = (window, enabled); } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4a04aefb..1727ba4c 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "MonoCode", - "version": "0.1.37", + "version": "0.1.41", "identifier": "com.monocode.desktop", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index 9547b975..66417af4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -42,6 +42,7 @@ import { notifyGitChanged, pickFolder, restoreSessionCheckout, + type GitFileDiffKind, type GitHistoryCommit, } from "./lib/fs"; import { @@ -236,6 +237,7 @@ import { type Attachment, type Block, type HarnessId, + type LinkedWorkItem, type PlanBuildTarget, type RuntimeMode, type PlanStatus, @@ -253,6 +255,7 @@ import { dropContextWindow } from "./lib/contextUsage"; import { deleteSession, getSession, + listLinkedSessions, listSessionsByProject, persistFingerprint, replaceInFlightSessions, @@ -275,6 +278,7 @@ import { setWindowFocused, } from "./lib/notifications"; import { playCue } from "./lib/sounds"; +import { archiveFocusedSession } from "./lib/archiveShortcut"; import { adjacentItemId, deferUnhandledEscape, @@ -322,14 +326,23 @@ import type { InboxSessionPortal } from "./surfaces/InboxDiscussionPanel"; import { inboxAskKey, inboxAskPrompt } from "./lib/inboxAsk"; import { NotesView } from "./surfaces/NotesView"; import { inboxComposerCard, type InboxItem } from "./lib/githubTasks"; +import { + linkedWorkItemFromInboxItem, + resolveLinkedWorkItem, +} from "./lib/sessionWorkItem"; import { linearIssueDetails, peekLinearIssueDetails } from "./lib/linear"; +import { gitlabWorkItemDetails, peekGitlabWorkItemDetails } from "./lib/gitlab"; +import { usageFooterProviders } from "./lib/rateLimits"; import { + ALWAYS_SHOW_USAGE_DEFAULT, + loadAlwaysShowUsage, loadLiveAgentsEnabled, loadNotesEnabled, loadDiffViewer, loadFollowUpBehavior, loadSettingsSection, saveSettingsSection, + subscribeAlwaysShowUsage, subscribeLiveAgentsEnabled, subscribeNotesEnabled, type SettingsSectionId, @@ -610,7 +623,9 @@ export default function App({ const [searchViewOpen, setSearchViewOpen] = useState(false); const [searchViewFocusToken, setSearchViewFocusToken] = useState(0); const [inboxViewOpen, setInboxViewOpen] = useState(false); - const [inboxAskPortal, setInboxAskPortal] = useState(null); + const [inboxTarget, setInboxTarget] = useState(null); + const [inboxAskPortal, setInboxAskPortal] = + useState(null); const openingInboxSessions = useRef(new Map>()); const [notesViewOpen, setNotesViewOpen] = useState(false); const notesEnabled = useSyncExternalStore( @@ -623,6 +638,11 @@ export default function App({ loadLiveAgentsEnabled, () => true, ); + const alwaysShowUsage = useSyncExternalStore( + subscribeAlwaysShowUsage, + loadAlwaysShowUsage, + () => ALWAYS_SHOW_USAGE_DEFAULT, + ); const [settingsOpen, setSettingsOpen] = useState(false); const [updateNotice, setUpdateNotice] = useState(installedUpdate); const [whatsNewVersion, setWhatsNewVersion] = useState(null); @@ -641,6 +661,9 @@ export default function App({ () => new Map(), ); const [history, setHistory] = useState(() => bootHistory); + const [storedLinkedSessions, setStoredLinkedSessions] = useState< + SessionSummary[] + >(() => bootHistory.filter((session) => session.linkedWorkItem)); /** * Projects whose rows are already in `history`. This has to be state, not a * ref: `sidebarCwd` is derived during render, so the frame that first shows @@ -748,13 +771,12 @@ export default function App({ const stopSessionForRemoval = useCallback( async (sessionId: string): Promise => { - const open = sessionsRef.current.find((session) => session.id === sessionId); + const open = sessionsRef.current.find( + (session) => session.id === sessionId, + ); if (!open?.busy) return open; - turnGen.current.set( - sessionId, - (turnGen.current.get(sessionId) ?? 0) + 1, - ); + turnGen.current.set(sessionId, (turnGen.current.get(sessionId) ?? 0) + 1); flushHarnessEvents(); await Promise.all( sessionChildHarnesses(open).map((harness) => @@ -874,7 +896,9 @@ export default function App({ (session) => activeTab && leafIds(activeTab.layout).includes(session.id), ); const sessionDefaults = active ?? sessions[0]; - const activeSkillContext = active ? nativeSkillContextForSession(active) : null; + const activeSkillContext = active + ? nativeSkillContextForSession(active) + : null; const activeSkillCwd = activeSkillContext?.cwd; useEffect(() => { @@ -919,12 +943,14 @@ export default function App({ } const busySessionIds = busySessionIdsRef.current; - const usageProviders = useMemo(() => { - if (active?.harness === "claude" || active?.harness === "codex") { - return [active.harness]; - } - return []; - }, [active?.harness]); + const usageProviders = useMemo( + () => + usageFooterProviders({ + activeHarness: active?.harness, + alwaysShow: alwaysShowUsage, + }), + [active?.harness, alwaysShowUsage], + ); const usageSession = useMemo(() => { if (!active) return undefined; return { harness: active.harness }; @@ -965,7 +991,9 @@ export default function App({ } const approvalSessionIds = approvalSessionIdsRef.current; - const activeSessionId = inboxViewOpen ? inboxAskPortal?.sessionId : active?.id; + const activeSessionId = inboxViewOpen + ? inboxAskPortal?.sessionId + : active?.id; const activeSessionIdRef = useRef(activeSessionId); activeSessionIdRef.current = activeSessionId; @@ -1125,6 +1153,21 @@ export default function App({ void refreshHistory(sidebarCwd); }, [sidebarCwd, refreshHistory]); + useEffect(() => { + if (!inboxViewOpen) return; + let cancelled = false; + void listLinkedSessions() + .then((rows) => { + if (!cancelled) setStoredLinkedSessions(rows); + }) + .catch(() => { + // Already-loaded and live sessions still provide a useful fallback. + }); + return () => { + cancelled = true; + }; + }, [inboxViewOpen]); + useEffect(() => { prefetchProjectFiles(sidebarCwd); }, [sidebarCwd]); @@ -1134,7 +1177,8 @@ export default function App({ !session || !shouldPersistSession(session) || removingSessionIds.current.has(session.id) - ) return; + ) + return; const fingerprint = persistFingerprint(session); void upsertSession(session) .then((summary) => { @@ -1417,10 +1461,12 @@ export default function App({ item.provider === "linear" ? item.identifier?.trim() || `#${item.number}` : `#${item.number}`; + const linkedWorkItem = linkedWorkItemFromInboxItem(item); const session = { ...newDefaultSession(cwd, sessionDefaults?.runtimeMode), title: `${ref} ${item.title}`, inboxCard: inboxComposerCard(item, description), + ...(linkedWorkItem ? { linkedWorkItem } : {}), }; const tab = newTab(session.id); setSessions((prev) => [...prev, session]); @@ -2313,7 +2359,11 @@ export default function App({ ); const onOpenDiff = useCallback( - (path?: string, session?: { sessionId: string; cwd: string }) => { + ( + path?: string, + session?: { sessionId: string; cwd: string }, + changeKind?: GitFileDiffKind, + ) => { void (async () => { const diffCwd = session?.cwd ?? gitCwdRef.current; const resolved = path @@ -2332,12 +2382,17 @@ export default function App({ ); } if (loadDiffViewer() === "unified") { - return openChangesTab(tab, sidebarCwdRef.current, resolved); + return openChangesTab( + tab, + sidebarCwdRef.current, + resolved, + changeKind, + ); } if (!resolved) return tab; return openEditorTab( tab, - newFileTab(resolved, sidebarCwdRef.current, true), + newFileTab(resolved, sidebarCwdRef.current, true, changeKind), ); }), ); @@ -2348,6 +2403,23 @@ export default function App({ [activeTabId], ); + const onOpenWorkingTreeDiff = useCallback( + (path: string, kind?: GitFileDiffKind) => onOpenDiff(path, undefined, kind), + [onOpenDiff], + ); + + /** Stack every working-tree change in one review, whatever the diff-view setting. */ + const onOpenAllChanges = useCallback(() => { + setTabs((prev) => + prev.map((tab) => + tab.id === activeTabId + ? openChangesTab(tab, sidebarCwdRef.current) + : tab, + ), + ); + setComposerFocused(false); + }, [activeTabId]); + const onOpenCommit = useCallback( (commit: GitHistoryCommit) => { setTabs((prev) => @@ -2518,57 +2590,108 @@ export default function App({ [refreshHistory, sidebarCwd], ); - const onAskInboxItem = useCallback((item: InboxItem): Promise => { - const key = inboxAskKey(item); - const pending = openingInboxSessions.current.get(key); - if (pending) return pending; - const opening = (async () => { - let session = sessionsRef.current.find(entry => entry.inboxAsk?.key === key); - if (!session) { - const candidate = item.projectPath || sidebarCwd; - const cwd = candidate && candidate !== "~" ? candidate : await invoke("default_cwd"); - const description = item.provider === "linear" && item.id - ? (peekLinearIssueDetails(item.id) ?? await linearIssueDetails(item.id)).body - : undefined; - session = { - ...newDefaultSession(cwd), - title: `Ask · ${item.title}`, - inboxAsk: { key, title: item.title, url: item.url, provider: item.provider, description }, + const onAskInboxItem = useCallback( + (item: InboxItem): Promise => { + const key = inboxAskKey(item); + const pending = openingInboxSessions.current.get(key); + if (pending) return pending; + const opening = (async () => { + let session = sessionsRef.current.find( + (entry) => entry.inboxAsk?.key === key, + ); + if (!session) { + const candidate = item.projectPath || sidebarCwd; + const cwd = + candidate && candidate !== "~" + ? candidate + : await invoke("default_cwd"); + const description = + item.provider === "linear" && item.id + ? ( + peekLinearIssueDetails(item.id) ?? + (await linearIssueDetails(item.id)) + ).body + : item.provider === "gitlab" && + (item.kind === "issue" || item.kind === "pr") + ? ( + peekGitlabWorkItemDetails( + item.projectPath, + item.kind, + item.number, + ) ?? + (await gitlabWorkItemDetails( + item.projectPath, + item.kind, + item.number, + )) + ).body + : undefined; + session = { + ...newDefaultSession(cwd), + title: `Ask · ${item.title}`, + inboxAsk: { + key, + title: item.title, + url: item.url, + provider: item.provider, + description, + }, + }; + sessionsRef.current = [...sessionsRef.current, session]; + setSessions(sessionsRef.current); + } + return session.id; + })(); + openingInboxSessions.current.set(key, opening); + void opening.then( + () => openingInboxSessions.current.delete(key), + () => openingInboxSessions.current.delete(key), + ); + return opening; + }, + [sidebarCwd], + ); + + const onRestartInboxAsk = useCallback( + async (item: InboxItem): Promise => { + const id = await onAskInboxItem(item); + const current = sessionsRef.current.find((session) => session.id === id)!; + removingSessionIds.current.add(id); + try { + await stopSessionForRemoval(id); + await Promise.all( + sessionChildHarnesses(current).map((harness) => + forgetHarnessSession(harness, id), + ), + ); + const fresh = { + ...newSession( + current.harness, + current.cwd, + current.model, + current.runtimeMode, + current.modelSettings, + ), + title: current.title, + inboxAsk: current.inboxAsk, }; - sessionsRef.current = [...sessionsRef.current, session]; - setSessions(sessionsRef.current); + const next = sessionsRef.current.map((session) => + session.id === id ? fresh : session, + ); + sessionsRef.current = next; + setSessions(next); + setInboxAskPortal((portal) => + portal?.sessionId === id + ? { ...portal, sessionId: fresh.id } + : portal, + ); + return fresh.id; + } finally { + removingSessionIds.current.delete(id); } - return session.id; - })(); - openingInboxSessions.current.set(key, opening); - void opening.then( - () => openingInboxSessions.current.delete(key), - () => openingInboxSessions.current.delete(key), - ); - return opening; - }, [sidebarCwd]); - - const onRestartInboxAsk = useCallback(async (item: InboxItem): Promise => { - const id = await onAskInboxItem(item); - const current = sessionsRef.current.find(session => session.id === id)!; - removingSessionIds.current.add(id); - try { - await stopSessionForRemoval(id); - await Promise.all(sessionChildHarnesses(current).map(harness => forgetHarnessSession(harness, id))); - const fresh = { - ...newSession(current.harness, current.cwd, current.model, current.runtimeMode, current.modelSettings), - title: current.title, - inboxAsk: current.inboxAsk, - }; - const next = sessionsRef.current.map(session => session.id === id ? fresh : session); - sessionsRef.current = next; - setSessions(next); - setInboxAskPortal(portal => portal?.sessionId === id ? { ...portal, sessionId: fresh.id } : portal); - return fresh.id; - } finally { - removingSessionIds.current.delete(id); - } - }, [onAskInboxItem, stopSessionForRemoval]); + }, + [onAskInboxItem, stopSessionForRemoval], + ); useEffect(() => { if (!inboxAskPortal || !inboxViewOpen) return; @@ -2868,6 +2991,32 @@ export default function App({ [onRemoveHistorySession], ); + const onArchiveFocusedSession = useCallback( + (event: KeyboardEvent) => { + archiveFocusedSession( + event, + { + activeTabId: activeTabIdRef.current, + tabs: tabsRef.current, + sessions: sessionsRef.current, + projectTerminalFocused: projectTerminalFocusedRef.current, + surfaceOpen: Boolean( + searchViewOpenRef.current || + inboxViewOpenRef.current || + notesViewOpenRef.current || + settingsOpenRef.current || + filePickerOpenRef.current || + whatsNewVersionRef.current, + ), + }, + (sessionId) => { + void onArchiveHistorySession(sessionId, true); + }, + ); + }, + [onArchiveHistorySession], + ); + const onPinHistorySession = useCallback( async (sessionId: string, pinned: boolean) => { const open = sessionsRef.current.find( @@ -3568,7 +3717,10 @@ export default function App({ cwd: workCwd, model: current.model, modelSettings: current.modelSettings, - text: inboxAskPrompt(rawCommand ? undefined : current.inboxAsk, prompt), + text: inboxAskPrompt( + rawCommand ? undefined : current.inboxAsk, + prompt, + ), attachments: prepared, }); } catch (error: unknown) { @@ -3699,21 +3851,37 @@ export default function App({ ); if (isFirstTurn && live && placeholderTitle) { + const titleMessage = + harnessText || attachments.map((file) => file.name).join(", "); void generateHarnessTitle(current.harness, { sessionId, cwd: workCwd, - message: - harnessText || attachments.map((file) => file.name).join(", "), + message: titleMessage, }) - .then((title) => { - if (!title) return; + .then(async (generated) => { + const linkedWorkItem = await resolveLinkedWorkItem( + titleMessage, + workCwd, + generated?.workItem ?? null, + ); + if (!generated && !linkedWorkItem) return; setSessions((prev) => prev.map((s) => { if (s.id !== sessionId) return s; - if (!canReplaceSessionTitle(s.title, s.harness, titleSeed)) { - return s; + let next = s; + if ( + generated && + canReplaceSessionTitle(s.title, s.harness, titleSeed) + ) { + next = { + ...next, + title: formatSessionTitle(s.harness, generated.title), + }; + } + if (linkedWorkItem && !next.linkedWorkItem) { + next = { ...next, linkedWorkItem }; } - return { ...s, title: formatSessionTitle(s.harness, title) }; + return next; }), ); }) @@ -3818,14 +3986,17 @@ export default function App({ modelSettings: current.modelSettings, runtimeMode: current.runtimeMode, intent, - text: inboxAskPrompt(rawCommand ? undefined : current.inboxAsk, wrap && !rawCommand - ? wrapHandoffPrompt( - wrap.text, - wrap.from, - turnPrompt.trim() || CONTINUE_PROMPT, - earlier, - ) - : turnPrompt), + text: inboxAskPrompt( + rawCommand ? undefined : current.inboxAsk, + wrap && !rawCommand + ? wrapHandoffPrompt( + wrap.text, + wrap.from, + turnPrompt.trim() || CONTINUE_PROMPT, + earlier, + ) + : turnPrompt, + ), attachments: prepared, onEvent: (event) => { if (turnGen.current.get(sessionId) !== gen) return; @@ -3894,7 +4065,9 @@ export default function App({ // Next tick: the flush above has rendered by then, so the banner // quotes the reply's final text rather than the previous batch. window.setTimeout(() => { - const finished = sessionsRef.current.find((s) => s.id === sessionId); + const finished = sessionsRef.current.find( + (s) => s.id === sessionId, + ); const visible = sessionId === activeSessionIdRef.current; const sent = finished ? notifySession(finished, "finished", visible) @@ -4487,10 +4660,42 @@ export default function App({ }), [history, projectBranches, sessions, sidebarCwd], ); + const inboxRelatedSessions = useMemo(() => { + const byId = new Map(); + for (const session of storedLinkedSessions) byId.set(session.id, session); + for (const session of history) { + if (session.linkedWorkItem) byId.set(session.id, session); + } + for (const session of sessions) { + if (session.inboxAsk || !session.linkedWorkItem) continue; + const current = byId.get(session.id); + const summary = summaryFromSession(session); + byId.set( + session.id, + current + ? { + ...current, + harness: summary.harness, + model: summary.model, + runtimeMode: summary.runtimeMode, + title: summary.title, + cwd: summary.cwd, + linkedWorkItem: summary.linkedWorkItem, + } + : summary, + ); + } + return [...byId.values()].sort( + (a, b) => b.updatedAt - a.updatedAt || a.id.localeCompare(b.id), + ); + }, [history, sessions, storedLinkedSessions]); const openProjectSessions = useMemo( () => sessions - .filter((session) => !session.inboxAsk && sameProjectPath(session.cwd, sidebarCwd)) + .filter( + (session) => + !session.inboxAsk && sameProjectPath(session.cwd, sidebarCwd), + ) .map((session) => summaryFromSession(session, { ...(projectBranches?.current @@ -4554,13 +4759,34 @@ export default function App({ setSettingsOpen(false); setSearchViewOpen(false); setNotesViewOpen(false); + setInboxTarget(null); + setInboxViewOpen(true); + }, []); + + const onOpenLinkedWorkItem = useCallback((item: LinkedWorkItem) => { + setFilePickerOpen(false); + setSettingsOpen(false); + setSearchViewOpen(false); + setNotesViewOpen(false); + setInboxTarget(item); setInboxViewOpen(true); }, []); const onLeaveInbox = useCallback(() => { setInboxViewOpen(false); + setInboxTarget(null); }, []); + const onOpenInboxSession = useCallback( + (sessionId: string) => { + setInboxViewOpen(false); + setInboxTarget(null); + setSidebarTab("sessions"); + void onSelectHistorySession(sessionId); + }, + [onSelectHistorySession], + ); + const onOpenNotes = useCallback(() => { if (!loadNotesEnabled()) return; setFilePickerOpen(false); @@ -4703,6 +4929,7 @@ export default function App({ const actions = useRef({ onNew, + onArchiveFocusedSession, onCloseOtherTabs, onClosePane, onNext, @@ -4729,6 +4956,7 @@ export default function App({ }); actions.current = { onNew, + onArchiveFocusedSession, onCloseOtherTabs, onClosePane, onNext, @@ -4787,6 +5015,10 @@ export default function App({ } const cmd = tabCommand(e); if (cmd) { + if (cmd === "archive-session") { + actions.current.onArchiveFocusedSession(e); + return; + } const target = e.target instanceof Element ? e.target : null; const listNavigation = cmd === "prev-session" || @@ -5109,7 +5341,7 @@ export default function App({ onDeleteSession={onDeleteHistorySession} onDeleteSessions={onDeleteHistorySessions} onOpenFile={onOpenFile} - onOpenTerminal={(cwd) => onOpenTerminal(cwd)} + onOpenTerminal={onOpenTerminal} onFileMoved={onFileMoved} onFileDeleted={onFileDeleted} canGoBack={ @@ -5122,12 +5354,14 @@ export default function App({ canGoForward={tabVisitNav.canForward} onGoBack={onRailBack} onGoForward={onRailForward} - onOpenDiff={onOpenDiff} + onOpenDiff={onOpenWorkingTreeDiff} + onOpenAllChanges={onOpenAllChanges} onOpenCommit={onOpenCommit} onShowSourceControl={onToggleChanges} selectedDiffPath={ activeTab ? selectedChangePath(activeTab, gitCwd) : undefined } + selectedDiffKind={activeTab ? selectedChangeKind(activeTab) : undefined} selectedCommitSha={activeTab ? selectedCommitSha(activeTab) : undefined} textHarness={pickTextHarness(active?.harness)} recents={recents} @@ -5144,6 +5378,7 @@ export default function App({ onNewTerminal={onNewTerminal} onSearch={onOpenSearch} onOpenInbox={onOpenInbox} + onOpenInboxItem={onOpenLinkedWorkItem} onOpenNotes={notesEnabled ? onOpenNotes : undefined} onGoToFile={onGoToFile} searchActive={searchViewOpen} @@ -5342,7 +5577,7 @@ export default function App({ cwd={sidebarCwd} recents={recents} history={projectHistory} - sessions={sessions.filter(session => !session.inboxAsk)} + sessions={sessions.filter((session) => !session.inboxAsk)} focusToken={searchViewFocusToken} besideRail={projectRailOpen} onClose={onLeaveSearch} @@ -5354,9 +5589,10 @@ export default function App({ ) : null}
{sessions - .filter(session => session.inboxAsk) - .map(session => { - const visible = inboxViewOpen && inboxAskPortal?.sessionId === session.id; + .filter((session) => session.inboxAsk) + .map((session) => { + const visible = + inboxViewOpen && inboxAskPortal?.sessionId === session.id; return ( ) : null} {notesViewOpen ? ( @@ -5477,6 +5716,11 @@ function selectedChangePath( return displayPath(file.path, gitCwd || file.cwd); } +function selectedChangeKind(tab: WorkspaceTab): GitFileDiffKind | undefined { + const file = focusedFileTab(tab); + return file?.review ? file.changeKind : undefined; +} + function selectedCommitSha(tab: WorkspaceTab): string | undefined { const focused = focusedFileTab(tab); if (focused && isCommitTab(focused)) return focused.commit.sha; diff --git a/src/assets/providers/omp.svg b/src/assets/providers/omp.svg index 3343b1bc..9124432d 100644 --- a/src/assets/providers/omp.svg +++ b/src/assets/providers/omp.svg @@ -1,6 +1,10 @@ - - - - - + diff --git a/src/chrome/Composer.tsx b/src/chrome/Composer.tsx index 1e29f3c4..5361f327 100644 --- a/src/chrome/Composer.tsx +++ b/src/chrome/Composer.tsx @@ -72,6 +72,7 @@ import type { UserQuestionPrompt, UserQuestionReply, } from "../lib/userQuestion"; +import { isImeComposition } from "../lib/keyboard"; import { createBlankSkill, rankSkills, @@ -302,6 +303,7 @@ function MessageQueue({ rows={1} onChange={(event) => setEditDraft(event.target.value)} onKeyDown={(event) => { + if (isImeComposition(event.nativeEvent)) return; if (event.key === "Escape") { event.preventDefault(); cancelEdit(); @@ -941,6 +943,7 @@ export function Composer({ }; const onKeyDown = (e: KeyboardEvent) => { + if (isImeComposition(e.nativeEvent)) return; if (creatingSkill) return; if (mentionOpen) { @@ -1147,7 +1150,7 @@ export function Composer({
@@ -1528,7 +1531,7 @@ function ComposerAction({ aria-label="Send" disabled={!hasValue} onClick={onSend} - className="grid size-6.5 place-items-center rounded-md bg-white text-black hover:bg-white/90 disabled:cursor-default disabled:bg-white/30 disabled:text-black/40 disabled:hover:bg-white/30" + className="composer-send grid size-6.5 place-items-center rounded-md bg-white text-black hover:bg-white/90 disabled:cursor-default disabled:bg-white/30 disabled:text-black/40 disabled:hover:bg-white/30" > diff --git a/src/chrome/FileTree.test.ts b/src/chrome/FileTree.test.ts new file mode 100644 index 00000000..46ec60fe --- /dev/null +++ b/src/chrome/FileTree.test.ts @@ -0,0 +1,127 @@ +// @vitest-environment happy-dom +import { act, createElement, type ComponentProps } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + listCachedDir, + notifyDirsChanged, + saveExpanded, +} from "../lib/fileTree"; +import type { FsEntry } from "../lib/fs"; +import { FileTree } from "./FileTree"; + +const { iconRender, directories } = vi.hoisted(() => ({ + iconRender: vi.fn(), + directories: new Map(), +})); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(async (command: string, args: { path: string }) => { + if (command !== "list_dir") + throw new Error(`Unexpected command: ${command}`); + return directories.get(args.path) ?? []; + }), +})); + +// Count row renders independently of FileTypeIcon's own memoization. +vi.mock("./FileTypeIcon", () => ({ + FileTypeIcon: ({ name }: { name: string }) => { + iconRender(name); + return createElement("span", { "data-icon": name }); + }, +})); + +let container: HTMLDivElement; +let root: Root; +let cwd: string; +let props: ComponentProps; +let project = 0; + +function file(name: string): FsEntry { + return { name, path: `${cwd}/${name}`, isDir: false, ignored: false }; +} + +function render(tick = 0, hidden = false) { + root.render( + createElement( + "div", + { hidden, "data-tick": tick }, + createElement(FileTree, props), + ), + ); +} + +function row(name: string): HTMLButtonElement { + return container.querySelector(`[role="treeitem"][title="${cwd}/${name}"]`)!; +} + +beforeEach(async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + cwd = `/project-${++project}`; + props = { cwd, onOpenFile: vi.fn() }; + directories.set(cwd, [file("first.ts")]); + await listCachedDir(cwd); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("FileTree render isolation", () => { + it.each([false, true])( + "skips unchanged rows on parent updates (hidden=%s)", + async (hidden) => { + await act(async () => render(0, hidden)); + expect(row("first.ts")).not.toBeNull(); + iconRender.mockClear(); + + for (let tick = 1; tick <= 20; tick++) act(() => render(tick, hidden)); + + expect(iconRender.mock.calls.length).toBe(0); + }, + ); + + it("still updates Git decorations and uses a changed navigation callback", async () => { + await act(async () => render()); + const onOpenFile = vi.fn(); + props = { + ...props, + onOpenFile, + gitStatuses: { + files: new Map([[`${cwd}/first.ts`, "modified"]]), + dirs: new Map(), + }, + }; + act(() => render(1)); + expect(row("first.ts").querySelector(".text-amber-400")).not.toBeNull(); + act(() => row("first.ts").click()); + expect(onOpenFile).toHaveBeenCalledWith(`${cwd}/first.ts`); + }); + + it("still expands folders and refreshes rows after filesystem changes", async () => { + saveExpanded(cwd, new Set()); + await act(async () => render()); + expect(row("first.ts")).toBeNull(); + const expand = container.querySelector( + "button[aria-expanded]", + )!; + await act(async () => expand.click()); + expect(row("first.ts")).not.toBeNull(); + + vi.useFakeTimers(); + directories.set(cwd, [file("added.ts")]); + await act(async () => { + notifyDirsChanged(); + await vi.advanceTimersByTimeAsync(200); + }); + expect(row("added.ts")).not.toBeNull(); + expect(row("first.ts")).toBeNull(); + }); +}); diff --git a/src/chrome/FileTree.tsx b/src/chrome/FileTree.tsx index dcbb1e68..714e2014 100644 --- a/src/chrome/FileTree.tsx +++ b/src/chrome/FileTree.tsx @@ -9,6 +9,7 @@ import { } from "./icons"; import { createContext, + memo, useContext, useEffect, useRef, @@ -212,7 +213,9 @@ function explorerItems( ]; } -export function FileTree({ +// Chat updates rerender the sidebar even when Files is hidden. Keep its tree +// intact unless file-tree props, local state, or subscriptions actually change. +export const FileTree = memo(function FileTree({ cwd, onOpenFile, onOpenTerminal, @@ -723,7 +726,7 @@ export function FileTree({ ) : null} ); -} +}); function HeaderIcon({ label, diff --git a/src/chrome/FileTypeIcon.test.ts b/src/chrome/FileTypeIcon.test.ts new file mode 100644 index 00000000..465e9f84 --- /dev/null +++ b/src/chrome/FileTypeIcon.test.ts @@ -0,0 +1,92 @@ +// @vitest-environment happy-dom +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { FileTypeIcon } from "./FileTypeIcon"; + +vi.mock("react-material-icon-theme", () => ({ + getFileIcon: ({ fileExtension }: { fileExtension?: string }) => + fileExtension ?? "", + getFolderIcon: ({ isOpen }: { isOpen: boolean }) => + isOpen ? "folder-open" : "folder", + getIconSvg: (name: string) => ``, +})); + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("FileTypeIcon DOM updates", () => { + it("preserves unchanged SVGs through repeated parent updates, including hidden panels", async () => { + const render = (tick: number) => + root.render( + createElement( + "div", + { hidden: true, "data-tick": tick }, + Array.from({ length: 32 }, (_, key) => + createElement(FileTypeIcon, { key, name: "file.ts", isDir: false }), + ), + ), + ); + await act(async () => render(0)); + const svgs = [...container.querySelectorAll("svg")]; + expect(svgs).toHaveLength(32); + const writes = vi.spyOn(Element.prototype, "innerHTML", "set"); + + for (let tick = 1; tick <= 20; tick++) act(() => render(tick)); + + expect(writes.mock.calls.length).toBe(0); + container.querySelectorAll("svg").forEach((svg, index) => { + expect(svg).toBe(svgs[index]); + }); + }); + + it("updates dimensions and filenames without replacing an unchanged glyph", async () => { + const render = (name: string, size: number) => + root.render(createElement(FileTypeIcon, { name, size, isDir: false })); + await act(async () => render("first.ts", 16)); + const svg = container.querySelector("svg"); + const writes = vi.spyOn(Element.prototype, "innerHTML", "set"); + + act(() => render("second.ts", 24)); + expect(container.querySelector("svg")).toBe(svg); + expect((container.firstElementChild as HTMLElement).style.width).toBe( + "24px", + ); + expect(writes.mock.calls.length).toBe(0); + + act(() => render("second.rs", 24)); + expect(container.querySelector("svg")?.getAttribute("data-icon")).toBe( + "rs", + ); + expect(writes).toHaveBeenCalledTimes(1); + }); + + it("updates folder glyphs when their expansion state changes", async () => { + const render = (isOpen: boolean) => + root.render( + createElement(FileTypeIcon, { name: "src", isDir: true, isOpen }), + ); + await act(async () => render(false)); + expect(container.querySelector("svg")?.getAttribute("data-icon")).toBe( + "folder", + ); + act(() => render(true)); + expect(container.querySelector("svg")?.getAttribute("data-icon")).toBe( + "folder-open", + ); + }); +}); diff --git a/src/chrome/FileTypeIcon.tsx b/src/chrome/FileTypeIcon.tsx index 3d76ad01..92d444d4 100644 --- a/src/chrome/FileTypeIcon.tsx +++ b/src/chrome/FileTypeIcon.tsx @@ -1,4 +1,4 @@ -import { useSyncExternalStore } from "react"; +import { memo, useMemo, useSyncExternalStore } from "react"; type Props = { name: string; @@ -41,7 +41,7 @@ function getSnapshot() { } /** Filename maps to the matching Material Icon Theme icon. */ -export function FileTypeIcon({ +export const FileTypeIcon = memo(function FileTypeIcon({ name, isDir, isOpen = false, @@ -50,6 +50,16 @@ export function FileTypeIcon({ }: Props) { const icons = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + const iconName = icons + ? isDir + ? icons.getFolderIcon({ folderName: name, isOpen, isRoot }) + : resolveFileIcon(icons, name) + : ""; + const svg = icons?.getIconSvg(iconName) ?? ""; + // React compares this prop by identity. A fresh object replaces the SVG + // subtree even when the glyph is unchanged (for example, on resize). + const markup = useMemo(() => ({ __html: svg }), [svg]); + if (!icons) { return ( ); -} +}); /** * The package only checks `fileExtension` when that prop is set — it does not diff --git a/src/chrome/GitChangesPanel.tsx b/src/chrome/GitChangesPanel.tsx index b31c8434..7b087e42 100644 --- a/src/chrome/GitChangesPanel.tsx +++ b/src/chrome/GitChangesPanel.tsx @@ -6,8 +6,11 @@ import { ChevronRight, CloudUpload, ExternalLink, + FileDiff, + FolderTree, GitBranch, GitPullRequest, + ListBullet, Loader, Minus, Plus, @@ -19,6 +22,7 @@ import { useCallback, useEffect, useLayoutEffect, + useMemo, useRef, useState, type ReactNode, @@ -50,10 +54,16 @@ import { subscribeGitChanged, type GitChangedFile, type GitDiffIndex, + type GitFileDiffKind, type GitHistoryCommit, type GitPr, } from "../lib/fs"; import type { HarnessId } from "../lib/session"; +import { + loadChangesView, + saveChangesView, + type ChangesView, +} from "../lib/appearance"; import { generateCommitMessage, generatePrContent } from "../lib/harness"; import { invalidateWatchedFiles } from "../lib/fileWatch"; import { MOD } from "../lib/platform"; @@ -73,6 +83,9 @@ function confirmNative(message: string, okLabel?: string): Promise { let stagedOpen = true; let changesOpen = true; let graphOpen = true; +let changesView: ChangesView = loadChangesView(); +/** Folders the user collapsed in tree view, keyed `:`. */ +const collapsedDirs = new Set(); const indexByCwd = new Map(); const prByCwd = new Map(); @@ -81,8 +94,10 @@ type Props = { enabled: boolean; textHarness?: HarnessId; selectedPath?: string; + selectedKind?: GitFileDiffKind; selectedSha?: string; - onOpenFile: (path: string) => void; + onOpenFile: (path: string, kind: GitFileDiffKind) => void; + onOpenAllChanges: () => void; onOpenCommit: (commit: GitHistoryCommit) => void; }; @@ -91,8 +106,10 @@ export function GitChangesPanel({ enabled, textHarness, selectedPath, + selectedKind, selectedSha, onOpenFile, + onOpenAllChanges, onOpenCommit, }: Props) { const { index, reload } = useDiffIndex(cwd, enabled); @@ -123,14 +140,7 @@ export function GitChangesPanel({ className="flex h-full min-h-0 flex-1 flex-col overflow-hidden" >
- {(index?.additions ?? 0) > 0 || (index?.deletions ?? 0) > 0 ? ( - - ) : ( - Changes - )} + Changes {index?.branch ? ( @@ -156,9 +166,11 @@ export function GitChangesPanel({ index={index} files={files} selected={selectedPath} + selectedKind={selectedKind} enabled={enabled} fill onOpenFile={onOpenFile} + onOpenAllChanges={onOpenAllChanges} onMutated={(paths) => { reload(); notifyGitChanged(); @@ -212,9 +224,11 @@ function ChangedFiles({ index, files, selected, + selectedKind, enabled, fill, onOpenFile, + onOpenAllChanges, onMutated, }: { cwd: string; @@ -222,9 +236,11 @@ function ChangedFiles({ index: GitDiffIndex | null; files: GitChangedFile[]; selected?: string; + selectedKind?: GitFileDiffKind; enabled: boolean; fill: boolean; - onOpenFile: (path: string) => void; + onOpenFile: (path: string, kind: GitFileDiffKind) => void; + onOpenAllChanges: () => void; onMutated: (paths?: string[]) => void; }) { const lockOverscroll = useLockOverscroll(); @@ -235,9 +251,13 @@ function ChangedFiles({ const [menuOpen, setMenuOpen] = useState(false); const [stagedExpanded, setStagedExpanded] = useState(stagedOpen); const [changesExpanded, setChangesExpanded] = useState(changesOpen); + const [view, setView] = useState(changesView); const { pr, reload: reloadPr } = usePrStatus(cwd, index?.branch); - const staged = files.filter((file) => file.staged); - const unstaged = files.filter((file) => file.unstaged); + const staged = useMemo(() => files.filter((file) => file.staged), [files]); + const unstaged = useMemo( + () => files.filter((file) => file.unstaged), + [files], + ); const hasRemote = Boolean(index?.remote); const hasOpenPr = pr?.state === "open"; const diverged = (index?.ahead ?? 0) > 0 && (index?.behind ?? 0) > 0; @@ -282,6 +302,12 @@ function ChangedFiles({ return () => window.removeEventListener("pointerdown", onPointer); }, [menuOpen]); + const toggleView = () => { + changesView = view === "tree" ? "list" : "tree"; + saveChangesView(changesView); + setView(changesView); + }; + const fail = (error: unknown) => { window.alert(error instanceof Error ? error.message : String(error)); }; @@ -566,7 +592,14 @@ function ChangedFiles({ stagedOpen = !stagedExpanded; setStagedExpanded(stagedOpen); }} + view={view} + onToggleView={toggleView} headerActions={[ + { + title: "Open All Changes", + icon: , + onClick: onOpenAllChanges, + }, { title: "Unstage All Changes", icon: , @@ -574,17 +607,16 @@ function ChangedFiles({ }, ]} > - {staged.map((file) => ( - - ))} + ) : null} {unstaged.length > 0 ? ( @@ -596,7 +628,14 @@ function ChangedFiles({ changesOpen = !changesExpanded; setChangesExpanded(changesOpen); }} + view={view} + onToggleView={toggleView} headerActions={[ + { + title: "Open All Changes", + icon: , + onClick: onOpenAllChanges, + }, { title: "Discard All Changes", icon: , @@ -609,17 +648,16 @@ function ChangedFiles({ }, ]} > - {unstaged.map((file) => ( - - ))} + ) : null} @@ -837,6 +875,8 @@ function FileSection({ count, open, onToggle, + view, + onToggleView, headerActions, children, }: { @@ -844,12 +884,14 @@ function FileSection({ count: number; open: boolean; onToggle: () => void; + view: ChangesView; + onToggleView: () => void; headerActions: { title: string; icon: ReactNode; onClick: () => void }[]; children: ReactNode; }) { return (
-
+
-
- {headerActions.map((action) => ( - - {action.icon} - - ))} -
+ + {view === "tree" ? ( + + ) : ( + + )} + + {headerActions.map((action) => ( + + {action.icon} + + ))}
{open ?
    {children}
: null}
); } +type ChangeDir = { + name: string; + /** Path relative to the repo root; "" for the implicit root. */ + path: string; + dirs: ChangeDir[]; + files: GitChangedFile[]; + /** Status shared by every descendant, or null when they differ. */ + status: string | null; +}; + +type ChangeRowProps = { + files: GitChangedFile[]; + view: ChangesView; + kind: GitFileDiffKind; + selected?: string; + selectedKind?: GitFileDiffKind; + busy: string | null; + onOpenFile: (path: string, kind: GitFileDiffKind) => void; + onAction: ( + file: GitChangedFile, + action: "stage" | "unstage" | "discard", + ) => void; +}; + +function ChangeList({ files, view, ...rest }: ChangeRowProps) { + const tree = useMemo(() => buildChangeTree(files), [files]); + if (view === "tree") { + return ; + } + return ( + <> + {files.map((file) => ( + + ))} + + ); +} + +function ChangeDirChildren({ + dir, + depth, + kind, + selected, + selectedKind, + busy, + onOpenFile, + onAction, +}: Omit & { + dir: ChangeDir; + depth: number; +}) { + return ( + <> + {dir.dirs.map((child) => ( + + ))} + {dir.files.map((file) => ( + + ))} + + ); +} + +function ChangeDirRow({ + dir, + depth, + kind, + ...rest +}: Omit & { + dir: ChangeDir; + depth: number; +}) { + const key = `${kind}:${dir.path}`; + const [open, setOpen] = useState(() => !collapsedDirs.has(key)); + const toggle = () => { + if (open) collapsedDirs.add(key); + else collapsedDirs.delete(key); + setOpen(!open); + }; + return ( +
  • + + {open ? ( +
      + +
    + ) : null} +
  • + ); +} + +function isActive( + file: GitChangedFile, + selected: string | undefined, + selectedKind: GitFileDiffKind | undefined, + kind: GitFileDiffKind, +): boolean { + return selected === file.relative && (!selectedKind || selectedKind === kind); +} + +/** Nests changed files under their directories, VS Code's tree view. */ +function buildChangeTree(files: GitChangedFile[]): ChangeDir { + const root: ChangeDir = { + name: "", + path: "", + dirs: [], + files: [], + status: null, + }; + for (const file of files) { + const segments = file.relative.split("/"); + let node = root; + for (const segment of segments.slice(0, -1)) { + const path = node.path ? `${node.path}/${segment}` : segment; + let next = node.dirs.find((dir) => dir.path === path); + if (!next) { + next = { name: segment, path, dirs: [], files: [], status: null }; + node.dirs.push(next); + } + node = next; + } + node.files.push(file); + } + sortChangeDir(root); + return root; +} + +/** Sorts each level (folders first) and rolls descendant status upward. */ +function sortChangeDir(dir: ChangeDir): string | null { + dir.dirs.sort((a, b) => a.name.localeCompare(b.name)); + dir.files.sort((a, b) => + basename(a.relative).localeCompare(basename(b.relative)), + ); + let status: string | null = null; + let mixed = false; + const merge = (next: string | null) => { + if (next === null) mixed = true; + else if (status === null) status = next; + else if (status !== next) mixed = true; + }; + for (const child of dir.dirs) merge(sortChangeDir(child)); + for (const file of dir.files) merge(file.status); + dir.status = mixed ? null : status; + return dir.status; +} + function ChangeRow({ file, active, busy, kind, + depth, onOpenFile, onAction, }: { file: GitChangedFile; active: boolean; busy: boolean; - kind: "staged" | "unstaged"; - onOpenFile: (path: string) => void; + kind: GitFileDiffKind; + /** Set in tree view: nesting level, and the folder path moves to the tree. */ + depth?: number; + onOpenFile: (path: string, kind: GitFileDiffKind) => void; onAction: ( file: GitChangedFile, action: "stage" | "unstage" | "discard", ) => void; }) { const name = basename(file.relative); - const dir = dirname(file.relative); + const tree = depth !== undefined; + const dir = tree ? "" : dirname(file.relative); const canOpen = file.status !== "deleted"; return (
  • { - if (canOpen) onOpenFile(file.path); + if (canOpen) onOpenFile(file.path, kind); }} className="flex min-w-0 flex-1 items-center gap-1.5 text-left" > + {tree ? : null} {name} @@ -1003,26 +1266,6 @@ function IconAction({ ); } -function DiffCounts({ - additions, - deletions, -}: { - additions: number; - deletions: number; -}) { - if (additions <= 0 && deletions <= 0) return null; - return ( - - {additions > 0 ? ( - +{additions} - ) : null} - {deletions > 0 ? ( - -{deletions} - ) : null} - - ); -} - function dirname(relative: string): string { const i = relative.lastIndexOf("/"); return i > 0 ? relative.slice(0, i) : ""; diff --git a/src/chrome/HarnessIcon.tsx b/src/chrome/HarnessIcon.tsx index 73f978a9..e69fd963 100644 --- a/src/chrome/HarnessIcon.tsx +++ b/src/chrome/HarnessIcon.tsx @@ -26,7 +26,6 @@ export const MONOCHROME_HARNESSES = new Set([ "grok", "opencode", "pi", - "omp", "fx", ]); @@ -104,19 +103,6 @@ export function HarnessIcon({ ); } - if (harness === "omp") { - return ( - - - - - - - ); - } return ( toggleStatus("open")} /> - {source === "github" ? ( + {source !== "linear" ? ( toggleStatus("closed")} /> - {source === "github" ? ( + {source !== "linear" ? ( ))} - {source === "github" ? ( + {source !== "linear" ? ( <> Type {KIND_OPTIONS.map((option) => ( toggleKind(option.id)} @@ -219,7 +223,7 @@ export function InboxFiltersMenu({ ) : null} - {source === "github" && projects.length > 0 ? ( + {source !== "linear" && projects.length > 0 ? ( <> Projects {projects.map((project) => ( diff --git a/src/chrome/InboxMiniCard.tsx b/src/chrome/InboxMiniCard.tsx index 71ed552b..595878ed 100644 --- a/src/chrome/InboxMiniCard.tsx +++ b/src/chrome/InboxMiniCard.tsx @@ -10,8 +10,18 @@ type Props = { export function InboxMiniCard({ card, onDismiss }: Props) { const KindIcon = card.kind === "pr" ? GitPullRequest : CircleDot; - const kindLabel = card.kind === "pr" ? "Pull request" : "Issue"; - const providerLabel = card.provider === "linear" ? "Linear" : "GitHub"; + const kindLabel = + card.kind === "pr" + ? card.provider === "gitlab" + ? "Merge request" + : "Pull request" + : "Issue"; + const providerLabel = + card.provider === "linear" + ? "Linear" + : card.provider === "gitlab" + ? "GitLab" + : "GitHub"; return (
    diff --git a/src/chrome/InboxProviderMark.tsx b/src/chrome/InboxProviderMark.tsx index deda8953..f571e284 100644 --- a/src/chrome/InboxProviderMark.tsx +++ b/src/chrome/InboxProviderMark.tsx @@ -8,6 +8,28 @@ export function InboxProviderMark({ provider: InboxProvider; className?: string; }) { + if (provider === "gitlab") { + return ( + + + + + + + ); + } if (provider === "linear") { return ( , "style"> & { const FRAME = "isolate overflow-hidden rounded-xl border border-content/10 shadow-xl"; const BACKDROP = - "pointer-events-none absolute inset-0 z-0 bg-content/10 backdrop-blur-xl [backface-visibility:hidden] [transform:translateZ(0)]"; + "popover-backdrop pointer-events-none absolute inset-0 z-0 backdrop-blur-xl [backface-visibility:hidden] [transform:translateZ(0)]"; /** Which corner the open animation grows from, so it reads as anchored. */ function origin(side: PopoverSide, align: PopoverAlign): string { diff --git a/src/chrome/ProjectBackgroundDialog.tsx b/src/chrome/ProjectBackgroundDialog.tsx new file mode 100644 index 00000000..78d4b1ea --- /dev/null +++ b/src/chrome/ProjectBackgroundDialog.tsx @@ -0,0 +1,224 @@ +import { useState, type ReactNode } from "react"; +import { Loader } from "./icons"; +import { Modal } from "./Modal"; +import { + CHAT_BACKGROUND_OPACITY_MAX, + CHAT_BACKGROUND_OPACITY_MIN, + chatBackgroundSrc, + loadChatBackgroundOpacity, + loadChatBackgroundPath, + loadChatBackgroundScope, + type ChatBackgroundScope, +} from "../lib/appearance"; +import { + clearProjectChatBackground, + pickAndSaveProjectChatBackground, + projectChatBackgroundSrc, +} from "../lib/chatBackground"; +import { + clearProjectChatBackgroundSetting, + loadProjectChatBackground, + projectChatBackgroundRevision, + saveProjectChatBackground, +} from "../lib/projectChatBackground"; + +type Props = { + project: string; + name: string; + onClose: () => void; +}; + +export function ProjectBackgroundDialog({ project, name, onClose }: Props) { + const initial = loadProjectChatBackground(project); + const [path, setPath] = useState(initial?.path ?? null); + const [opacity, setOpacity] = useState( + initial?.opacity ?? loadChatBackgroundOpacity(), + ); + const [scope, setScope] = useState( + initial?.scope ?? loadChatBackgroundScope(), + ); + const [revision, setRevision] = useState(projectChatBackgroundRevision); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const globalPath = loadChatBackgroundPath(); + const previewSrc = path + ? projectChatBackgroundSrc(path, revision) + : chatBackgroundSrc(globalPath); + + const save = ( + nextPath: string, + nextOpacity: number, + nextScope: ChatBackgroundScope, + ) => { + saveProjectChatBackground(project, { + path: nextPath, + opacity: nextOpacity, + scope: nextScope, + }); + setRevision(projectChatBackgroundRevision()); + }; + + const choose = async () => { + setBusy(true); + setError(null); + try { + const nextPath = await pickAndSaveProjectChatBackground(project); + if (!nextPath) return; + save(nextPath, opacity, scope); + setPath(nextPath); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setBusy(false); + } + }; + + const removeImage = async () => { + setBusy(true); + setError(null); + try { + await clearProjectChatBackground(project); + clearProjectChatBackgroundSetting(project); + setPath(null); + setOpacity(loadChatBackgroundOpacity()); + setScope(loadChatBackgroundScope()); + setRevision(projectChatBackgroundRevision()); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setBusy(false); + } + }; + + const updateOpacity = (percent: number) => { + const next = Math.min( + CHAT_BACKGROUND_OPACITY_MAX, + Math.max(CHAT_BACKGROUND_OPACITY_MIN, percent / 100), + ); + setOpacity(next); + if (path) save(path, next, scope); + }; + + const updateScope = (next: ChatBackgroundScope) => { + setScope(next); + if (path) save(path, opacity, next); + }; + + return ( + +
    +
    +
    + {previewSrc ? ( + + ) : ( +
    + No background selected +
    + )} +
    + +

    + {path + ? "This image overrides the global background for this project." + : "This project currently follows the global Appearance setting."} +

    + {error ? ( +

    {error}

    + ) : null} +
    + + +
    + {[ + { value: "empty" as const, label: "Empty only" }, + { value: "all" as const, label: "All sessions" }, + ].map((option) => ( + + ))} +
    +
    + + +
    + updateOpacity(Number(event.target.value))} + /> + + {Math.round(opacity * 100)}% + +
    +
    + + {path ? ( + + ) : null} +
    + + ); +} + +function ProjectBackgroundRow({ + label, + children, +}: { + label: string; + children: ReactNode; +}) { + return ( +
    + {label} + {children} +
    + ); +} diff --git a/src/chrome/ProjectRail.tsx b/src/chrome/ProjectRail.tsx index 06d72ebe..32f51a58 100644 --- a/src/chrome/ProjectRail.tsx +++ b/src/chrome/ProjectRail.tsx @@ -5,6 +5,7 @@ import { ChevronUp, CircleAlert, FolderOpen, + ImagePlus, Inbox, MoreHorizontal, Pin, @@ -61,6 +62,7 @@ import { import { formatLiveElapsed, type LiveAgent } from "../lib/liveAgents"; import { HarnessIcon } from "./HarnessIcon"; import { ProjectLogoIcon } from "./ProjectLogoIcon"; +import { ProjectBackgroundDialog } from "./ProjectBackgroundDialog"; import { ProjectMascot } from "./ProjectMascot"; import { RailAction, RailSearch } from "./RailAction"; import { RemoveProjectDialog } from "./RemoveProjectDialog"; @@ -84,6 +86,11 @@ function projectMenuExtraItems( canRemove: boolean, ): TabGroupMenuExtraItem[] { const items: TabGroupMenuExtraItem[] = [ + { + id: "background", + label: "Background image", + icon: ImagePlus, + }, pinned ? { id: "unpin", label: "Unpin project", icon: PinOff } : { id: "pin", label: "Pin project", icon: Pin }, @@ -189,6 +196,10 @@ export function ProjectRail({ path: string; name: string; } | null>(null); + const [backgroundProject, setBackgroundProject] = useState<{ + project: string; + name: string; + } | null>(null); const lockOverscroll = useLockOverscroll(); const scrollRef = useRef(null); const groupLogos = useTabGroupLogos(); @@ -323,7 +334,12 @@ export function ProjectRail({ if (!projectMenu) return; const { path, projectKey } = projectMenu; if (action === "pin" || action === "unpin") onTogglePin(path); - else if (action === "reveal") void revealPath(path); + else if (action === "background") { + setBackgroundProject({ + project: projectKey, + name: resolveTabGroupLabel(projectKey, groupLabels, basename(path)), + }); + } else if (action === "reveal") void revealPath(path); else if (action === "archive") { onRemoveProject?.(path, { purgeData: false }); } else if (action === "delete") { @@ -472,7 +488,7 @@ export function ProjectRail({ onOpenWhatsNew={onOpenWhatsNew} onDismissUpdate={onDismissUpdate} /> -
    +
    setRemoving(null)} /> ) : null} + {backgroundProject ? ( + setBackgroundProject(null)} + /> + ) : null}
    ; + visible?: boolean; + /** Renders the turn that holds the block. Returns false when the block is unknown. */ + revealBlock?: (blockId: string) => boolean; +}; + +export function PromptOutline({ + blocks, + scope, + visible = true, + revealBlock, +}: Props) { + const prompts = useMemo(() => promptBlocks(blocks), [blocks]); + const [activeId, setActiveId] = useState(null); + const [stackBudget, setStackBudget] = useState(BAR_STACK_MAX_PX); + const [hover, setHover] = useState(null); + const [open, setOpen] = useState(false); + const [focusId, setFocusId] = useState(null); + const rail = useRef(null); + const frame = useRef(null); + const openTimer = useRef(null); + const pointerInside = useRef(false); + const lastPromptId = useRef(null); + lastPromptId.current = prompts[prompts.length - 1]?.id ?? null; + + const measure = useCallback(() => { + const scroller = scope.current?.querySelector(SCROLLER); + if (!scroller) { + setActiveId(null); + return; + } + const viewport = scroller.getBoundingClientRect(); + // A hidden tab has zero-size boxes. The rule would then select the last prompt. + if (viewport.height === 0) return; + setStackBudget( + Math.min( + BAR_STACK_MAX_PX, + Math.floor(viewport.height * BAR_STACK_PANE_SHARE), + ), + ); + // Streaming re-measures on every frame, and it almost always lands here: + // pinned to the end, where the last prompt wins whatever the anchors say. + // Answer from the block list and skip the walk. + const distanceToEnd = + scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight; + if (distanceToEnd <= NEAR_END_PX) { + setActiveId(lastPromptId.current); + return; + } + const anchors: OutlineAnchor[] = []; + for (const el of scroller.querySelectorAll(ANCHOR)) { + const id = el.dataset.promptAnchor; + if (id) anchors.push({ id, ...promptBand(el, viewport) }); + } + setActiveId( + activePromptId( + { top: viewport.top, bottom: viewport.bottom }, + anchors, + distanceToEnd, + ), + ); + }, [scope]); + + const schedule = useCallback(() => { + if (frame.current != null) return; + frame.current = window.requestAnimationFrame(() => { + frame.current = null; + measure(); + }); + }, [measure]); + + useEffect(() => { + const scroller = scope.current?.querySelector(SCROLLER); + if (!scroller) return; + scroller.addEventListener("scroll", schedule, { passive: true }); + const observer = new ResizeObserver(schedule); + observer.observe(scroller); + // Content growth moves the anchors without a scroll event. + if (scroller.firstElementChild) + observer.observe(scroller.firstElementChild); + schedule(); + return () => { + scroller.removeEventListener("scroll", schedule); + observer.disconnect(); + if (frame.current != null) { + window.cancelAnimationFrame(frame.current); + frame.current = null; + } + }; + }, [schedule, scope]); + + useEffect(() => { + schedule(); + }, [schedule, blocks, visible]); + + const cancelOpen = () => { + if (openTimer.current == null) return; + window.clearTimeout(openTimer.current); + openTimer.current = null; + }; + useEffect(() => cancelOpen, []); + + /** The ripple follows the pointer at once. The card waits out a pass-through. */ + const hoverBar = (id: string, el: HTMLElement) => { + setHover({ id, el }); + if (open || openTimer.current != null) return; + openTimer.current = window.setTimeout(() => { + openTimer.current = null; + setOpen(true); + }, OPEN_DELAY_MS); + }; + const showBar = (id: string, el: HTMLElement) => { + cancelOpen(); + setHover({ id, el }); + setOpen(true); + }; + const close = () => { + cancelOpen(); + setHover(null); + setOpen(false); + }; + + const leaveRail = () => { + pointerInside.current = false; + // Keyboard focus holds the card open after the pointer moves away. + if (keyboardFocused(rail.current)) return; + close(); + }; + const blurRail = (event: ReactFocusEvent) => { + if (event.currentTarget.contains(event.relatedTarget)) return; + if (pointerInside.current) return; + close(); + }; + + const hoverId = hover?.id ?? null; + const preview = useMemo( + () => (hoverId ? promptPreview(blocks, hoverId) : null), + [blocks, hoverId], + ); + + const jumpTo = (id: string) => { + const scroller = scope.current?.querySelector(SCROLLER); + if (!scroller) return; + // The transcript scrolls to the bottom on each streaming update until a + // wheel-up event occurs. Send one, so the jump stays. + scroller.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 })); + const selector = `[data-prompt-anchor="${CSS.escape(id)}"]`; + let anchor = scroller.querySelector(selector); + if (!anchor && revealBlock?.(id)) { + anchor = scroller.querySelector(selector); + } + if (!anchor) { + scroller.scrollTop = 0; + return; + } + const target = anchor.closest(TURN) ?? anchor; + const align = () => { + const delta = + target.getBoundingClientRect().top - + scroller.getBoundingClientRect().top - + SCROLL_INSET_PX; + if (Math.abs(delta) > 2) scroller.scrollTop += delta; + }; + align(); + // Turns that enter the screen get their real height. That can move the + // target. + window.requestAnimationFrame(align); + }; + + if (prompts.length < MIN_PROMPTS) return null; + + const activeIndex = prompts.findIndex((prompt) => prompt.id === activeId); + const stack = barStack( + prompts.length, + activeIndex >= 0 ? activeIndex : null, + stackBudget, + ); + const bars = prompts.slice(stack.start, stack.end); + const hoverIndex = hover ? bars.findIndex((bar) => bar.id === hover.id) : -1; + // One tab stop for the whole rail. Arrow keys walk it from there. + const tabId = + [focusId, activeId].find((id) => bars.some((bar) => bar.id === id)) ?? + bars[0].id; + + const onKeyDown = (event: ReactKeyboardEvent) => { + const step = + event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0; + if (step === 0) return; + event.preventDefault(); + const from = bars.findIndex((bar) => bar.id === tabId); + const next = bars[from + step]; + if (!next) return; + setFocusId(next.id); + rail.current + ?.querySelector(`[data-prompt-bar="${CSS.escape(next.id)}"]`) + ?.focus(); + }; + + return ( +
    { + pointerInside.current = true; + }} + onMouseLeave={leaveRail} + onBlur={blurRail} + onKeyDown={onKeyDown} + className="absolute top-1/2 right-4 z-30 flex -translate-y-1/2 flex-col items-end @max-[58rem]:hidden" + > + {bars.map((prompt, index) => { + const lift = barLift(index, hoverIndex); + const distance = hoverIndex < 0 ? 0 : Math.abs(index - hoverIndex); + // The pointer owns the fill while it is on the rail. Off the rail, the + // fill goes back to marking the scroll position. + const lit = + hoverIndex >= 0 ? index === hoverIndex : prompt.id === activeId; + return ( + + ); + })} + {open && preview && hoverIndex >= 0 ? ( + +

    + {preview.title} +

    + {preview.reply ? ( +

    + {preview.reply} +

    + ) : null} + {preview.detail ? ( +

    + {preview.detail} +

    + ) : null} +
    + ) : null} +
    + ); +} + +/** Clicking a bar focuses it as well. Only a keyboard focus holds the card open. */ +function keyboardFocused(rail: HTMLElement | null): boolean { + const el = document.activeElement; + return ( + el instanceof HTMLElement && + !!rail?.contains(el) && + el.matches(":focus-visible") + ); +} + +/** + * content-visibility skips off-screen turns. A read inside a skipped turn + * forces its layout. Use the turn box for an off-screen turn. Use the exact + * prompt box for an on-screen turn. + */ +function promptBand(anchor: HTMLElement, viewport: DOMRect): OutlineBand { + const turn = anchor.closest(TURN) ?? anchor; + const turnBox = turn.getBoundingClientRect(); + const onScreen = + turnBox.bottom > viewport.top && turnBox.top < viewport.bottom; + const box = + turn !== anchor && onScreen ? anchor.getBoundingClientRect() : turnBox; + return { top: box.top, bottom: box.bottom }; +} + +/** One bar per prompt while the bars fit the budget. The gap shrinks first. Past that, a window slides. */ +function barStack(count: number, activeIndex: number | null, budget: number) { + const fit = Math.max( + 1, + Math.floor((budget + BAR_GAP_MIN_PX) / (BAR_HEIGHT_PX + BAR_GAP_MIN_PX)), + ); + const window_ = barWindow(count, activeIndex, fit); + const shown = window_.end - window_.start; + const gap = + shown > 1 + ? Math.min( + BAR_GAP_PX, + Math.max( + BAR_GAP_MIN_PX, + Math.floor((budget - shown * BAR_HEIGHT_PX) / (shown - 1)), + ), + ) + : 0; + return { ...window_, gap }; +} diff --git a/src/chrome/Sidebar.tsx b/src/chrome/Sidebar.tsx index a0eb7751..475d076c 100644 --- a/src/chrome/Sidebar.tsx +++ b/src/chrome/Sidebar.tsx @@ -1,11 +1,14 @@ +import { openUrl } from "@tauri-apps/plugin-opener"; import { Archive, Check, ChevronDown, ChevronRight, CircleAlert, + CircleDot, Folder, GitBranch, + GitPullRequest, Inbox, ListFilter, Pin, @@ -29,7 +32,11 @@ import { saveSidebarTabOrder, type SidebarTabId, } from "../lib/appearance"; -import { basename, type GitHistoryCommit } from "../lib/fs"; +import { + basename, + type GitFileDiffKind, + type GitHistoryCommit, +} from "../lib/fs"; import { IS_MAC, MOD } from "../lib/platform"; import { resolveModel } from "../lib/models"; import { prettyParent, projectKey, projectName } from "../lib/paths"; @@ -83,7 +90,7 @@ import { saveSessionSidebarFilters, type SessionSidebarFilters, } from "../lib/sessionFilters"; -import type { HarnessId } from "../lib/session"; +import type { HarnessId, LinkedWorkItem } from "../lib/session"; import type { LiveAgent } from "../lib/liveAgents"; import type { SessionSummary } from "../lib/sessionStore"; import type { SettingsSectionId } from "../lib/settings"; @@ -202,9 +209,11 @@ type Props = { canGoForward?: boolean; onGoBack?: () => void; onGoForward?: () => void; - onOpenDiff?: (path: string) => void; + onOpenDiff?: (path: string, kind?: GitFileDiffKind) => void; + onOpenAllChanges?: () => void; onOpenCommit?: (commit: GitHistoryCommit) => void; selectedDiffPath?: string; + selectedDiffKind?: GitFileDiffKind; selectedCommitSha?: string; textHarness?: HarnessId; onShowSourceControl?: () => void; @@ -219,6 +228,7 @@ type Props = { onNewTerminal?: () => void; onSearch?: () => void; onOpenInbox?: () => void; + onOpenInboxItem?: (item: LinkedWorkItem) => void; onOpenNotes?: () => void; onGoToFile?: () => void; searchActive?: boolean; @@ -275,8 +285,10 @@ function SidebarComponent({ onGoBack, onGoForward, onOpenDiff, + onOpenAllChanges, onOpenCommit, selectedDiffPath, + selectedDiffKind, selectedCommitSha, textHarness, onShowSourceControl, @@ -290,6 +302,7 @@ function SidebarComponent({ onNew, onSearch, onOpenInbox, + onOpenInboxItem, onOpenNotes, onGoToFile, searchActive = false, @@ -331,6 +344,7 @@ function SidebarComponent({ const [selectedSessionIds, setSelectedSessionIds] = useState>( () => new Set(), ); + const contextSelectionRef = useRef(false); const [folderMenu, setFolderMenu] = useState<{ x: number; y: number; @@ -561,7 +575,7 @@ function SidebarComponent({ useEffect(() => { if (!sessionMenu && !folderMenu && !filterMenu) return; const onScroll = () => { - setSessionMenu(null); + closeSessionMenu(); setFolderMenu(null); setFilterMenu(null); }; @@ -572,13 +586,29 @@ function SidebarComponent({ useEffect(() => { if (selectedSessionIds.size === 0) return; - const onKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Escape") return; + const clear = () => { + contextSelectionRef.current = false; setSelectedSessionIds(new Set()); setSessionMenu(null); }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + clear(); + }; + // A pointer landing off the cards drops the selection; a menu acting on + // it stays open, and the cards handle their own clicks. + const onPointerDown = (event: PointerEvent) => { + const target = event.target; + const el = target instanceof Element ? target : null; + if (el?.closest("[data-session-card],[data-popover-side]")) return; + clear(); + }; window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); + window.addEventListener("pointerdown", onPointerDown); + return () => { + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("pointerdown", onPointerDown); + }; }, [selectedSessionIds.size]); const commitSessionFolders = (next: SessionFolder[]) => { @@ -713,11 +743,12 @@ function SidebarComponent({ const onSessionContextMenu = ( sessionId: string, - e: ReactMouseEvent, + e: ReactMouseEvent, ) => { e.preventDefault(); e.stopPropagation(); - if (!selectedSessionIds.has(sessionId)) { + contextSelectionRef.current = !selectedSessionIds.has(sessionId); + if (contextSelectionRef.current) { setSelectedSessionIds(new Set([sessionId])); } setFilterMenu(null); @@ -725,6 +756,13 @@ function SidebarComponent({ setSessionMenu({ x: e.clientX, y: e.clientY, sessionId }); }; + const closeSessionMenu = () => { + setSessionMenu(null); + if (!contextSelectionRef.current) return; + contextSelectionRef.current = false; + setSelectedSessionIds(new Set()); + }; + const onFolderContextMenu = ( folderId: string, e: ReactMouseEvent, @@ -742,7 +780,7 @@ function SidebarComponent({ const sessionIds = menuSessionIds; const archived = allMenuSessionsArchived; const pinned = allMenuSessionsPinned; - setSessionMenu(null); + closeSessionMenu(); if (id === "pin") { if (sessionIds.length > 1 && onPinSessions) { onPinSessions(sessionIds, !pinned); @@ -846,9 +884,10 @@ function SidebarComponent({ const onSessionCardSelect = ( sessionId: string, - event: ReactMouseEvent, + event: { shiftKey: boolean }, ) => { if (event.shiftKey) { + contextSelectionRef.current = false; setSessionMenu(null); setSelectedSessionIds((current) => toggleSessionSelection(current, sessionId), @@ -864,7 +903,6 @@ function SidebarComponent({ { onRenameSession(session.id, title); @@ -884,6 +922,7 @@ function SidebarComponent({ compact={compact} now={now} onSelect={onSessionCardSelect} + onOpenWorkItem={onOpenInboxItem} onPrefetch={onPrefetchSession} onPlaceOnPane={onPlaceSessionOnPane} onListDrop={onSessionListDrop} @@ -1373,8 +1412,10 @@ function SidebarComponent({ enabled={open} textHarness={textHarness} selectedPath={selectedDiffPath} + selectedKind={selectedDiffKind} selectedSha={selectedCommitSha} onOpenFile={onOpenDiff ?? onOpenFile} + onOpenAllChanges={onOpenAllChanges ?? (() => {})} onOpenCommit={onOpenCommit ?? (() => {})} />
    @@ -1386,7 +1427,7 @@ function SidebarComponent({ onOpenWhatsNew={onOpenWhatsNew} onDismissUpdate={onDismissUpdate} /> -
    +
    setSessionMenu(null)} + onClose={closeSessionMenu} /> ) : null} {folderMenu ? ( @@ -2104,6 +2145,7 @@ function SessionCard({ compact = false, now, onSelect, + onOpenWorkItem, onPrefetch, onPlaceOnPane, onListDrop, @@ -2122,15 +2164,13 @@ function SessionCard({ dropTarget?: boolean; compact?: boolean; now: number; - onSelect: ( - sessionId: string, - event: ReactMouseEvent, - ) => void; + onSelect: (sessionId: string, event: { shiftKey: boolean }) => void; + onOpenWorkItem?: (item: LinkedWorkItem) => void; onPrefetch?: (sessionId: string) => void; onPlaceOnPane?: (sessionId: string, targetId: string, edge: PaneEdge) => void; onListDrop?: (draggedId: string, target: SessionListDropTarget) => void; onListDropTargetChange?: (target: SessionListDropTarget | null) => void; - onContextMenu?: (e: ReactMouseEvent) => void; + onContextMenu?: (e: ReactMouseEvent) => void; onArchive?: () => void; onRename?: () => void; onDelete?: () => void; @@ -2175,7 +2215,49 @@ function SessionCard({ ); - const onKeyDown = (e: ReactKeyboardEvent) => { + const linkedWorkItem = session.linkedWorkItem; + const workItemBadge = linkedWorkItem ? ( + + ) : null; + + const onKeyDown = (e: ReactKeyboardEvent) => { + if (e.target !== e.currentTarget) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelect(session.id, { shiftKey: e.shiftKey }); + return; + } if (e.key === "F2" && onRename) { e.preventDefault(); onRename(); @@ -2187,7 +2269,7 @@ function SessionCard({ } }; - const onPointerDown = (event: ReactPointerEvent) => { + const onPointerDown = (event: ReactPointerEvent) => { if (event.button !== 0) return; // Warm the transcript during the press. Opening stays on click so a // drag-to-pane gesture does not switch conversations. @@ -2290,8 +2372,9 @@ function SessionCard({ return (
    - +
    {onArchive ? (
    diff --git a/src/chrome/UsageFooter.tsx b/src/chrome/UsageFooter.tsx index a25d142a..e6cf30bb 100644 --- a/src/chrome/UsageFooter.tsx +++ b/src/chrome/UsageFooter.tsx @@ -1,20 +1,20 @@ import { RefreshCw } from "./icons"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { Fragment, useEffect, useRef, useState, useSyncExternalStore } from "react"; import { HarnessIcon } from "./HarnessIcon"; import { Popover } from "./Popover"; import { - fetchClaudeRateLimits, - fetchCodexRateLimits, -} from "../lib/rateLimitsFetch"; + getRateLimitsSnapshot, + refreshRateLimits, + setRateLimitProviders, + subscribeRateLimits, +} from "../lib/rateLimitsStore"; import { clampUsedPercent, - fetchingRateLimits, formatRateLimitWindowChipLabel, formatUsagePercent, - idleRateLimits, - RATE_LIMIT_POLL_MS, + sharedWindowResetLabel, + isRateLimitProvider, rateLimitWindowTooltip, - shouldFetchProvider, type ProviderRateLimits, type RateLimitProvider, type RateLimitWindow, @@ -44,79 +44,52 @@ export function UsageFooter({ terminalOpen?: boolean; onToggleTerminal?: (fileId: string) => void; }) { - const wantClaude = providers.includes("claude"); - const wantCodex = providers.includes("codex"); - const [claude, setClaude] = useState(() => - idleRateLimits("claude"), - ); - const [codex, setCodex] = useState(() => - idleRateLimits("codex"), + const snapshot = useSyncExternalStore( + subscribeRateLimits, + getRateLimitsSnapshot, + getRateLimitsSnapshot, ); + const claude = snapshot.claude; + const codex = snapshot.codex; + const cursor = snapshot.cursor; + const grok = snapshot.grok; + const refreshing = snapshot.refreshing; const [now, setNow] = useState(() => Date.now()); - const [refreshing, setRefreshing] = useState(false); - const inflight = useRef | null>(null); - const claudeRef = useRef(claude); - const codexRef = useRef(codex); - claudeRef.current = claude; - codexRef.current = codex; - - const refresh = useCallback((force = false) => { - if (inflight.current) return inflight.current; - const visible = document.visibilityState === "visible"; - const fetchClaude = - wantClaude && - shouldFetchProvider(claudeRef.current, { force, visible }); - const fetchCodex = - wantCodex && - shouldFetchProvider(codexRef.current, { force, visible }); - if (!fetchClaude && !fetchCodex) return; - if (force) setRefreshing(true); - const jobs: Promise[] = []; - if (fetchClaude) { - setClaude((current) => fetchingRateLimits("claude", current)); - jobs.push( - fetchClaudeRateLimits().then((value) => { - setClaude(value); - }), - ); - } - if (fetchCodex) { - setCodex((current) => fetchingRateLimits("codex", current)); - jobs.push( - fetchCodexRateLimits().then((value) => { - setCodex(value); - }), - ); - } - const run = Promise.allSettled(jobs) - .then(() => undefined) - .finally(() => { - inflight.current = null; - setRefreshing(false); - }); - inflight.current = run; - return run; - }, [wantClaude, wantCodex]); useEffect(() => { - void refresh(); - const poll = window.setInterval(() => void refresh(), RATE_LIMIT_POLL_MS); - const onVisible = () => { - if (document.visibilityState === "visible") void refresh(); - }; - document.addEventListener("visibilitychange", onVisible); - return () => { - window.clearInterval(poll); - document.removeEventListener("visibilitychange", onVisible); - }; - }, [refresh]); + setRateLimitProviders(providers); + }, [providers]); + + const refresh = (force = false) => refreshRateLimits(force); useEffect(() => { const timer = window.setInterval(() => setNow(Date.now()), CLOCK_MS); return () => window.clearInterval(timer); }, []); - const showUsage = wantClaude || wantCodex; + const showUsage = providers.length > 0; + const usageChips = providers + .map((provider) => + provider === "claude" + ? claude + : provider === "codex" + ? codex + : provider === "cursor" + ? cursor + : provider === "grok" + ? grok + : null, + ) + .filter((limits): limits is ProviderRateLimits => limits != null); + // The session chip is the fallback when no usage chip covers the active + // provider. With the roster pinned it sits alongside the usage chips, so an + // OpenCode session still says "opencode" instead of vanishing behind them. + const showSession = + session != null && + !( + isRateLimitProvider(session.harness) && + providers.includes(session.harness) + ); const showTerminals = terminals.length > 0; const showRight = showUsage || showTerminals; const ariaLabel = showUsage @@ -132,13 +105,16 @@ export function UsageFooter({ aria-label={ariaLabel} className="flex h-7 shrink-0 items-center gap-3 overflow-x-auto border-t border-content/10 px-3 text-[11px] text-content/55" > - {showUsage ? ( - <> - {wantClaude ? : null} - {wantCodex ? : null} - - ) : session ? ( - + {showSession && session ? : null} + {usageChips.length > 0 ? ( + + {usageChips.map((limits, index) => ( + + {index > 0 ? : null} + + + ))} + ) : null} {showRight ? (
    @@ -181,6 +157,10 @@ function TerminalLiveMark() { ); } +function ProviderDivider() { + return ; +} + function SessionChip({ session }: { session: UsageFooterSession }) { return ( rateLimitWindowTooltip(entry.window, now)) .join(" · "); + const sharedReset = sharedWindowResetLabel( + windows.map((entry) => entry.window), + now, + ); return ( 0 ? · : null} {formatUsagePercent(entry.window.usedPercent)}{" "} - {formatRateLimitWindowChipLabel(entry.window, now)} + + {formatRateLimitWindowChipLabel(entry.window, now)} + ))} + {sharedReset ? ( + + · + {sharedReset} + + ) : null} )} diff --git a/src/chrome/icons.tsx b/src/chrome/icons.tsx index cc7d2f93..0a0b6511 100644 --- a/src/chrome/icons.tsx +++ b/src/chrome/icons.tsx @@ -33,12 +33,14 @@ import Delete02Icon from "@hugeicons/core-free-icons/Delete02Icon"; import DragDropVerticalIcon from "@hugeicons/core-free-icons/DragDropVerticalIcon"; import File01Icon from "@hugeicons/core-free-icons/File01Icon"; import FileAddIcon from "@hugeicons/core-free-icons/FileAddIcon"; +import FileDiffIcon from "@hugeicons/core-free-icons/FileDiffIcon"; import FilePlusCornerIcon from "@hugeicons/core-free-icons/FilePlusCornerIcon"; import FilterIcon from "@hugeicons/core-free-icons/FilterIcon"; import FlashIcon from "@hugeicons/core-free-icons/FlashIcon"; import Folder01Icon from "@hugeicons/core-free-icons/Folder01Icon"; import FolderAddIcon from "@hugeicons/core-free-icons/FolderAddIcon"; import FolderOpenIcon from "@hugeicons/core-free-icons/FolderOpenIcon"; +import FolderTreeIcon from "@hugeicons/core-free-icons/FolderTreeIcon"; import GaugeIcon from "@hugeicons/core-free-icons/GaugeIcon"; import GitBranchIcon from "@hugeicons/core-free-icons/GitBranchIcon"; import GitCompareIcon from "@hugeicons/core-free-icons/GitCompareIcon"; @@ -52,6 +54,7 @@ import KeyboardIcon from "@hugeicons/core-free-icons/KeyboardIcon"; import LayoutAlignRightIcon from "@hugeicons/core-free-icons/LayoutAlignRightIcon"; import LayoutBottomIcon from "@hugeicons/core-free-icons/LayoutBottomIcon"; import LayoutTopIcon from "@hugeicons/core-free-icons/LayoutTopIcon"; +import LeftToRightListBulletIcon from "@hugeicons/core-free-icons/LeftToRightListBulletIcon"; import ListEndIcon from "@hugeicons/core-free-icons/ListEndIcon"; import LinkSquare02Icon from "@hugeicons/core-free-icons/LinkSquare02Icon"; import Loading03Icon from "@hugeicons/core-free-icons/Loading03Icon"; @@ -162,12 +165,14 @@ export const CloudUpload = wrap(CloudUploadIcon, "CloudUpload"); export const Copy = wrap(Copy01Icon, "Copy"); export const ExternalLink = wrap(LinkSquare02Icon, "ExternalLink"); export const File = wrap(File01Icon, "File"); +export const FileDiff = wrap(FileDiffIcon, "FileDiff"); export const FilePlus = wrap(FileAddIcon, "FilePlus"); export const FilePlusCorner = wrap(FilePlusCornerIcon, "FilePlusCorner"); export const FoldVertical = wrap(FoldVerticalIcon, "FoldVertical"); export const Folder = wrap(Folder01Icon, "Folder"); export const FolderOpen = wrap(FolderOpenIcon, "FolderOpen"); export const FolderPlus = wrap(FolderAddIcon, "FolderPlus"); +export const FolderTree = wrap(FolderTreeIcon, "FolderTree"); export const Gauge = wrap(GaugeIcon, "Gauge"); export const GitBranch = wrap(GitBranchIcon, "GitBranch"); export const GitCompare = wrap(GitCompareIcon, "GitCompare"); @@ -185,6 +190,7 @@ export const GripVertical = wrap(DragDropVerticalIcon, "GripVertical"); export const ImagePlus = wrap(ImageAdd01Icon, "ImagePlus"); export const Inbox = wrap(InboxIcon, "Inbox"); export const Keyboard = wrap(KeyboardIcon, "Keyboard"); +export const ListBullet = wrap(LeftToRightListBulletIcon, "ListBullet"); export const ListEnd = wrap(ListEndIcon, "ListEnd"); export const ListFilter = wrap(FilterIcon, "ListFilter"); export const Loader = wrap(Loading03Icon, "Loader"); diff --git a/src/index.css b/src/index.css index e257f7d0..0d97a270 100644 --- a/src/index.css +++ b/src/index.css @@ -3,6 +3,8 @@ @source "../node_modules/@streamdown/code/dist/*.js"; @source "../node_modules/@streamdown/mermaid/dist/*.js"; +@custom-variant dark (&:where(html:not(.theme-light), html:not(.theme-light) *)); + @theme { --color-background-base: hsl( var(--theme-hue) var(--theme-saturation) var(--background-lightness) @@ -25,6 +27,7 @@ --theme-hue: 240; --theme-saturation: 0%; --sidebar-opacity: 0.85; + --chat-background-opacity: 0.24; --background-lightness: 9%; --content-lightness: 92%; --link-color: #7dd3fc; @@ -95,9 +98,9 @@ html:not(.is-mac) ::-webkit-scrollbar-corner { background: transparent; } -html.has-native-glass, -html.has-native-glass body, -html.has-native-glass #root { +html.has-native-glass:not(.theme-light), +html.has-native-glass:not(.theme-light) body, +html.has-native-glass:not(.theme-light) #root { background: transparent; } @@ -106,10 +109,10 @@ html.has-native-glass #root { } html.theme-light .sidebar-glass { - background: color-mix(in srgb, var(--color-background-base) 93%, black 7%); + background: var(--color-background-base); } -html.has-native-glass .sidebar-glass { +html.has-native-glass:not(.theme-light) .sidebar-glass { background: hsl( var(--theme-hue) var(--theme-saturation) var(--background-lightness) / var(--sidebar-opacity) @@ -120,6 +123,33 @@ html.has-native-glass .sidebar-glass { background: var(--color-background-base); } +.chat-pane-background::before { + content: ""; + position: absolute; + left: var(--chat-background-left, 0); + top: var(--chat-background-top, 0); + width: var(--chat-background-width, 100%); + height: var(--chat-background-height, 100%); + z-index: -1; + background-image: var(--chat-background-image, none); + background-position: center; + background-size: cover; + background-repeat: no-repeat; + opacity: 0; + pointer-events: none; +} + +html.has-chat-background .chat-pane-background::before, +.chat-pane-background[data-project-chat-background="true"]::before { + opacity: var(--chat-background-opacity); +} + +html.chat-background-empty-only + .chat-pane-background[data-project-chat-background="false"][data-session-empty="false"]::before, +.chat-pane-background[data-project-chat-background="true"][data-project-background-scope="empty"][data-session-empty="false"]::before { + opacity: 0; +} + .transcript-turn { content-visibility: auto; contain-intrinsic-block-size: auto 240px; @@ -143,7 +173,7 @@ html.has-native-glass .sidebar-glass { min-height: var(--transcript-viewport, 0px); } -html.has-native-glass.glass-body .body-glass { +html.has-native-glass.glass-body:not(.theme-light) .body-glass { background: color-mix( in srgb, var(--color-background-base) calc(var(--sidebar-opacity) * 100%), @@ -233,6 +263,31 @@ html.is-resizing * { scrollbar-width: none; } +html.theme-light [data-composer-box] { + background: var(--color-background-base); + box-shadow: + 0 6px 24px color-mix(in srgb, var(--color-content) 9%, transparent), + 0 2px 6px color-mix(in srgb, var(--color-content) 6%, transparent); +} + +html.theme-light .composer-send:not(:disabled) { + background: var(--color-content); + color: var(--color-background-base); +} + +html.theme-light .composer-send:not(:disabled):hover { + background: color-mix(in srgb, var(--color-content) 90%, transparent); +} + +html.theme-light .composer-send:disabled { + background: color-mix(in srgb, var(--color-content) 25%, transparent); + color: color-mix( + in srgb, + var(--color-background-base) 75%, + transparent + ); +} + .composer-field { color: transparent; caret-color: var(--color-content); @@ -1095,6 +1150,16 @@ header[data-tauri-drag-region] button { } } +/* Keep the original content tint in dark mode. In light mode, use the base + color at the same opacity so the surface stays translucent without graying. */ +.popover-backdrop { + background: color-mix(in srgb, var(--color-content) 2%, transparent); +} + +html.theme-light .popover-backdrop { + background: color-mix(in srgb, var(--color-background-base) 10%, transparent); +} + /* Popover contents grow in from the edge they hang off, sliding the last few pixels out of their trigger. The glass frame stays still: WebKit composites `backdrop-filter` on its own layer and can flash a stale backdrop if that diff --git a/src/instructions/inbox.md b/src/instructions/inbox.md index 98ad43c7..7b4e1607 100644 --- a/src/instructions/inbox.md +++ b/src/instructions/inbox.md @@ -4,8 +4,8 @@ You are discussing the Inbox item below with the user. These instructions apply ## Inspect remotely -- Use available integrations or read-only CLI requests, such as `gh pr view`, `gh pr diff`, `gh pr checks`, and read-only `gh api` requests. Target the item's URL and repository explicitly; the current local checkout may be unrelated or a different revision. -- Do not clone repositories, run `git pull`, fetch PR branches into local git, check out or switch branches, create worktrees, download repository archives, or save remote repository source locally. This applies to every location, including temporary directories, and to alternate tools or scripts. +- Use available integrations or read-only CLI requests. For GitHub, examples include `gh pr view`, `gh pr diff`, `gh pr checks`, and read-only `gh api`; for GitLab, use the equivalent read-only `glab mr view`, `glab mr diff`, or `glab api` requests. Target the item's URL and repository explicitly; the current local checkout may be unrelated or a different revision. +- Do not clone repositories, run `git pull`, fetch PR branches into local git, fetch merge-request branches into local git, check out or switch branches, create worktrees, download repository archives, or save remote repository source locally. This applies to every location, including temporary directories, and to alternate tools or scripts. - Read remote diffs, source, comments, and check results directly into tool output. If access is unavailable or results are incomplete, explain what is missing. Do not fall back to a local checkout or download. - Keep this discussion focused on analysis. Do not edit files, implement changes, post comments or reviews, or change the remote item. Describe proposed fixes; work requiring changes belongs in a separate project session. diff --git a/src/lib/appearance.test.ts b/src/lib/appearance.test.ts index 92b56a0b..dfca0c23 100644 --- a/src/lib/appearance.test.ts +++ b/src/lib/appearance.test.ts @@ -1,6 +1,14 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { + CHAT_BACKGROUND_OPACITY_DEFAULT, + CHAT_BACKGROUND_SCOPE_DEFAULT, + loadChatBackgroundOpacity, + loadChatBackgroundPath, + loadChatBackgroundScope, loadTranscriptLayout, + saveChatBackgroundOpacity, + saveChatBackgroundPath, + saveChatBackgroundScope, saveTranscriptLayout, TRANSCRIPT_LAYOUT_DEFAULT, loadTranscriptAnchor, @@ -15,6 +23,9 @@ import { const KEY = "monocode.transcriptLayout"; const SCHEME_KEY = "monocode.colorScheme"; const ANCHOR_KEY = "monocode.transcriptAnchor"; +const CHAT_BACKGROUND_PATH_KEY = "monocode.chatBackgroundPath"; +const CHAT_BACKGROUND_OPACITY_KEY = "monocode.chatBackgroundOpacity"; +const CHAT_BACKGROUND_SCOPE_KEY = "monocode.chatBackgroundScope"; function mockLocalStorage() { const data = new Map(); @@ -84,6 +95,43 @@ describe("transcript prompt-to-top setting", () => { }); }); +describe("chat background setting", () => { + beforeEach(mockLocalStorage); + afterEach(() => { + localStorage.removeItem(CHAT_BACKGROUND_PATH_KEY); + localStorage.removeItem(CHAT_BACKGROUND_OPACITY_KEY); + localStorage.removeItem(CHAT_BACKGROUND_SCOPE_KEY); + }); + + it("stores and clears the app-owned background path", () => { + expect(loadChatBackgroundPath()).toBeNull(); + saveChatBackgroundPath("/app-data/backgrounds/chat-background.webp"); + expect(loadChatBackgroundPath()).toBe( + "/app-data/backgrounds/chat-background.webp", + ); + saveChatBackgroundPath(null); + expect(loadChatBackgroundPath()).toBeNull(); + }); + + it("defaults and clamps background visibility", () => { + expect(loadChatBackgroundOpacity()).toBe(CHAT_BACKGROUND_OPACITY_DEFAULT); + saveChatBackgroundOpacity(1); + expect(loadChatBackgroundOpacity()).toBe(0.65); + saveChatBackgroundOpacity(0); + expect(loadChatBackgroundOpacity()).toBe(0.05); + }); + + it("persists where the background is shown", () => { + expect(loadChatBackgroundScope()).toBe(CHAT_BACKGROUND_SCOPE_DEFAULT); + saveChatBackgroundScope("empty"); + expect(loadChatBackgroundScope()).toBe("empty"); + saveChatBackgroundScope("all"); + expect(loadChatBackgroundScope()).toBe("all"); + localStorage.setItem(CHAT_BACKGROUND_SCOPE_KEY, "transcript"); + expect(loadChatBackgroundScope()).toBe(CHAT_BACKGROUND_SCOPE_DEFAULT); + }); +}); + function mockSystemScheme(scheme: "dark" | "light") { Object.defineProperty(globalThis, "window", { value: { diff --git a/src/lib/appearance.ts b/src/lib/appearance.ts index 38ae84d6..eb6dfaf9 100644 --- a/src/lib/appearance.ts +++ b/src/lib/appearance.ts @@ -1,4 +1,4 @@ -import { invoke } from "@tauri-apps/api/core"; +import { convertFileSrc, invoke } from "@tauri-apps/api/core"; import { HAS_NATIVE_GLASS, IS_MAC } from "./platform"; import { applyUiScale, loadUiScale } from "./uiScale"; @@ -13,10 +13,21 @@ const SIDEBAR_TAB_ORDER_KEY = "monocode.sidebarTabOrder"; const PROJECT_RAIL_WIDTH_KEY = "monocode.projectRailWidth"; const TRANSCRIPT_LAYOUT_KEY = "monocode.transcriptLayout"; const TRANSCRIPT_ANCHOR_KEY = "monocode.transcriptAnchor"; +const CHAT_BACKGROUND_PATH_KEY = "monocode.chatBackgroundPath"; +const CHAT_BACKGROUND_OPACITY_KEY = "monocode.chatBackgroundOpacity"; +const CHAT_BACKGROUND_SCOPE_KEY = "monocode.chatBackgroundScope"; +const CHANGES_VIEW_KEY = "monocode.changesView"; +let chatBackgroundRevision = Date.now(); +let nativeGlassReady = false; + +export const CHAT_BACKGROUND_PATH_CHANGE_EVENT = + "monocode:chat-background-path-change"; export type ColorScheme = "dark" | "light"; export type ThemePreference = ColorScheme | "system"; export type TranscriptLayout = "full" | "chat"; +export type ChatBackgroundScope = "empty" | "all"; +export type ChangesView = "list" | "tree"; export const THEME_PREFERENCE_DEFAULT: ThemePreference = "dark"; @@ -25,6 +36,8 @@ export const SCHEME_CHANGE_EVENT = "monocode:schemechange"; export const TRANSCRIPT_LAYOUT_DEFAULT: TranscriptLayout = "full"; +export const CHANGES_VIEW_DEFAULT: ChangesView = "list"; + export const TRANSCRIPT_ANCHOR_DEFAULT = true; /** Fired on `window` whenever prompt-to-top anchoring flips (detail: boolean). */ @@ -64,6 +77,11 @@ export const PROJECT_RAIL_WIDTH_DEFAULT = 200; export const BODY_GLASS_DEFAULT = true; +export const CHAT_BACKGROUND_OPACITY_MIN = 0.05; +export const CHAT_BACKGROUND_OPACITY_MAX = 0.65; +export const CHAT_BACKGROUND_OPACITY_DEFAULT = 0.24; +export const CHAT_BACKGROUND_SCOPE_DEFAULT: ChatBackgroundScope = "all"; + function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } @@ -163,6 +181,9 @@ export function initAppearance() { applySidebarOpacity(loadSidebarOpacity()); applySidebarBlur(loadSidebarBlur()); applyBodyGlass(loadBodyGlass()); + applyChatBackground(loadChatBackgroundPath()); + applyChatBackgroundOpacity(loadChatBackgroundOpacity()); + applyChatBackgroundScope(loadChatBackgroundScope()); void applyUiScale(loadUiScale()); } @@ -207,12 +228,23 @@ export function isLightScheme(): boolean { export function applyThemePreference(value: ThemePreference): ColorScheme { const next = resolveColorScheme(value); document.documentElement.classList.toggle("theme-light", next === "light"); + if (nativeGlassReady) syncNativeGlass(next); window.dispatchEvent( new CustomEvent(SCHEME_CHANGE_EVENT, { detail: next }), ); return next; } +function syncNativeGlass(scheme: ColorScheme) { + void invoke("set_window_glass_enabled", { enabled: scheme === "dark" }); +} + +/** Applies native transparency once the opaque launch cover can be removed. */ +export function activateWindowAppearance() { + nativeGlassReady = true; + syncNativeGlass(isLightScheme() ? "light" : "dark"); +} + /** Keeps the "system" preference in sync when the OS flips appearance. */ export function watchSystemColorScheme() { const query = systemQuery(); @@ -282,6 +314,112 @@ export function applyBodyGlass(value: boolean) { return value; } +export function loadChatBackgroundPath(): string | null { + try { + return localStorage.getItem(CHAT_BACKGROUND_PATH_KEY)?.trim() || null; + } catch { + return null; + } +} + +export function saveChatBackgroundPath(value: string | null) { + try { + if (value) localStorage.setItem(CHAT_BACKGROUND_PATH_KEY, value); + else localStorage.removeItem(CHAT_BACKGROUND_PATH_KEY); + } catch { + // private mode / quota + } + if (typeof window === "undefined") return; + window.dispatchEvent(new Event(CHAT_BACKGROUND_PATH_CHANGE_EVENT)); +} + +export function subscribeChatBackgroundPath(onStoreChange: () => void) { + if (typeof window === "undefined") return () => {}; + window.addEventListener(CHAT_BACKGROUND_PATH_CHANGE_EVENT, onStoreChange); + return () => + window.removeEventListener( + CHAT_BACKGROUND_PATH_CHANGE_EVENT, + onStoreChange, + ); +} + +export function applyChatBackground(path: string | null) { + const root = document.documentElement; + root.classList.toggle("has-chat-background", !!path); + if (!path) { + root.style.removeProperty("--chat-background-image"); + return null; + } + chatBackgroundRevision += 1; + const src = chatBackgroundSrc(path); + root.style.setProperty( + "--chat-background-image", + `url(${JSON.stringify(src)})`, + ); + return path; +} + +export function chatBackgroundSrc(path: string | null): string | null { + return path ? `${convertFileSrc(path)}?v=${chatBackgroundRevision}` : null; +} + +export function loadChatBackgroundOpacity(): number { + return clamp( + readNumber(CHAT_BACKGROUND_OPACITY_KEY) ?? CHAT_BACKGROUND_OPACITY_DEFAULT, + CHAT_BACKGROUND_OPACITY_MIN, + CHAT_BACKGROUND_OPACITY_MAX, + ); +} + +export function saveChatBackgroundOpacity(value: number) { + writeNumber( + CHAT_BACKGROUND_OPACITY_KEY, + clamp(value, CHAT_BACKGROUND_OPACITY_MIN, CHAT_BACKGROUND_OPACITY_MAX), + ); +} + +export function applyChatBackgroundOpacity(value: number) { + const next = clamp( + value, + CHAT_BACKGROUND_OPACITY_MIN, + CHAT_BACKGROUND_OPACITY_MAX, + ); + document.documentElement.style.setProperty( + "--chat-background-opacity", + String(next), + ); + return next; +} + +function isChatBackgroundScope(value: unknown): value is ChatBackgroundScope { + return value === "empty" || value === "all"; +} + +export function loadChatBackgroundScope(): ChatBackgroundScope { + try { + const raw = localStorage.getItem(CHAT_BACKGROUND_SCOPE_KEY); + return isChatBackgroundScope(raw) ? raw : CHAT_BACKGROUND_SCOPE_DEFAULT; + } catch { + return CHAT_BACKGROUND_SCOPE_DEFAULT; + } +} + +export function saveChatBackgroundScope(value: ChatBackgroundScope) { + try { + localStorage.setItem(CHAT_BACKGROUND_SCOPE_KEY, value); + } catch { + // private mode / quota + } +} + +export function applyChatBackgroundScope(value: ChatBackgroundScope) { + document.documentElement.classList.toggle( + "chat-background-empty-only", + value === "empty", + ); + return value; +} + function isSidebarTabId(value: unknown): value is SidebarTabId { return ( value === "files" || @@ -372,6 +510,27 @@ export function saveTranscriptLayout(value: TranscriptLayout) { ); } +function isChangesView(value: unknown): value is ChangesView { + return value === "list" || value === "tree"; +} + +export function loadChangesView(): ChangesView { + try { + const raw = localStorage.getItem(CHANGES_VIEW_KEY); + return isChangesView(raw) ? raw : CHANGES_VIEW_DEFAULT; + } catch { + return CHANGES_VIEW_DEFAULT; + } +} + +export function saveChangesView(value: ChangesView) { + try { + localStorage.setItem(CHANGES_VIEW_KEY, value); + } catch { + // private mode / quota + } +} + export function loadTranscriptAnchor(): boolean { return readFlag(TRANSCRIPT_ANCHOR_KEY) ?? TRANSCRIPT_ANCHOR_DEFAULT; } diff --git a/src/lib/archiveShortcut.test.ts b/src/lib/archiveShortcut.test.ts new file mode 100644 index 00000000..128b81a7 --- /dev/null +++ b/src/lib/archiveShortcut.test.ts @@ -0,0 +1,149 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { archiveFocusedSession } from "./archiveShortcut"; + +// Only the DOM operations used by the handler are needed in the Node suite. +class ElementStub { + constructor(readonly ancestors: string[] = []) {} + closest(selector: string) { + return selector.split(", ").some((part) => this.ancestors.includes(part)) + ? this + : null; + } + getClientRects = vi.fn(() => [{}]); +} + +afterEach(() => vi.unstubAllGlobals()); + +function fixture() { + const overlays: { selector: string; element: ElementStub }[] = []; + vi.stubGlobal("Element", ElementStub); + vi.stubGlobal("document", { + querySelectorAll: (selector: string) => + overlays + .filter((overlay) => selector.split(", ").includes(overlay.selector)) + .map((overlay) => overlay.element), + }); + const style = vi.fn(() => ({ visibility: "visible" })); + vi.stubGlobal("getComputedStyle", style); + const context = { + activeTabId: "tab", + tabs: [{ id: "tab", focusedId: "session", diffFocused: false }], + sessions: [{ id: "session" }, { id: "other" }], + projectTerminalFocused: false, + surfaceOpen: false, + }; + const event = { + defaultPrevented: false, + target: new ElementStub(["textarea", "[data-composer]"]), + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }; + const archive = vi.fn(); + return { + context, + event, + archive, + overlays, + style, + run: () => + archiveFocusedSession( + event as unknown as KeyboardEvent, + context, + archive, + ), + }; +} + +function expectUntouched(f: ReturnType) { + f.run(); + expect(f.event.preventDefault).not.toHaveBeenCalled(); + expect(f.event.stopPropagation).not.toHaveBeenCalled(); + expect(f.archive).not.toHaveBeenCalled(); +} + +describe("archive shortcut routing", () => { + it("consumes the event before archiving exactly the focused session", () => { + const f = fixture(); + f.context.tabs[0].focusedId = "other"; + f.archive.mockImplementation(() => { + expect(f.event.preventDefault).toHaveBeenCalledOnce(); + expect(f.event.stopPropagation).toHaveBeenCalledOnce(); + }); + f.run(); + expect(f.archive).toHaveBeenCalledExactlyOnceWith("other"); + }); + + it.each(["editor", "terminal"])( + "preserves the key in a focused %s pane", + (pane) => { + const f = fixture(); + f.context.tabs[0].focusedId = pane; + expectUntouched(f); + }, + ); + + it.each(["diff", "dock", "surface", "missing tab", "already handled"])( + "preserves the key when blocked by %s", + (reason) => { + const f = fixture(); + if (reason === "diff") f.context.tabs[0].diffFocused = true; + if (reason === "dock") f.context.projectTerminalFocused = true; + if (reason === "surface") f.context.surfaceOpen = true; + if (reason === "missing tab") f.context.activeTabId = "missing"; + if (reason === "already handled") f.event.defaultPrevented = true; + expectUntouched(f); + }, + ); + + it.each([".cm-editor", ".monocode-terminal", "input"])( + "respects %s DOM focus even before workspace focus updates", + (ancestor) => { + const f = fixture(); + f.event.target = new ElementStub([ancestor]); + expectUntouched(f); + }, + ); + + it.each([ + '[role="menu"]', + '[role="dialog"]', + '[role="alertdialog"]', + "[data-popover-side]", + "[data-skill-picker]", + "[data-mention-picker]", + ])( + "blocks an open %s even when focus remains in the composer", + (selector) => { + const f = fixture(); + f.overlays.push({ selector, element: new ElementStub() }); + expectUntouched(f); + f.overlays.pop(); + f.run(); + expect(f.archive).toHaveBeenCalledExactlyOnceWith("session"); + }, + ); + + it("leaves a TabGroupMenu rename input event untouched", () => { + const f = fixture(); + f.event.target = new ElementStub(['[role="menu"]', "input"]); + f.overlays.push({ selector: '[role="menu"]', element: new ElementStub() }); + expectUntouched(f); + }); + + it.each([ + "no layout", + "hidden visibility", + "[hidden]", + "[inert]", + '[aria-hidden="true"]', + ])("ignores an inactive overlay (%s)", (hidden) => { + const f = fixture(); + const element = new ElementStub([hidden]); + if (hidden === "no layout") element.getClientRects.mockReturnValue([]); + if (hidden === "hidden visibility") + f.style.mockReturnValue({ visibility: "hidden" }); + f.overlays.push({ selector: "[data-skill-picker]", element }); + f.run(); + expect(f.archive).toHaveBeenCalledExactlyOnceWith("session"); + }); +}); diff --git a/src/lib/archiveShortcut.ts b/src/lib/archiveShortcut.ts new file mode 100644 index 00000000..6ce9b61b --- /dev/null +++ b/src/lib/archiveShortcut.ts @@ -0,0 +1,52 @@ +type ArchiveContext = { + activeTabId: string; + tabs: readonly { id: string; focusedId: string; diffFocused?: boolean }[]; + sessions: readonly { id: string }[]; + projectTerminalFocused: boolean; + surfaceOpen: boolean; +}; + +/** Archive only after confirming that the focused conversation owns the key. */ +export function archiveFocusedSession( + event: KeyboardEvent, + context: ArchiveContext, + archive: (sessionId: string) => void, +): void { + if ( + event.defaultPrevented || + context.projectTerminalFocused || + context.surfaceOpen + ) + return; + + const tab = context.tabs.find((entry) => entry.id === context.activeTabId); + if (!tab || tab.diffFocused) return; + const session = context.sessions.find((entry) => entry.id === tab.focusedId); + if (!session) return; + + const target = event.target instanceof Element ? event.target : null; + if (target?.closest(".cm-editor, .monocode-terminal")) return; + if ( + target?.closest('input, textarea, select, [contenteditable="true"]') && + !target.closest("[data-composer]") + ) + return; + + // Popovers can leave focus in the composer. Check the whole document, + // excluding overlays in hidden or inactive surfaces. + const overlayOpen = Array.from( + document.querySelectorAll( + '[data-popover-side], [role="dialog"], [role="alertdialog"], [role="menu"], [data-skill-picker], [data-mention-picker]', + ), + ).some( + (element) => + element.getClientRects().length > 0 && + getComputedStyle(element).visibility !== "hidden" && + !element.closest('[hidden], [inert], [aria-hidden="true"]'), + ); + if (overlayOpen) return; + + event.preventDefault(); + event.stopPropagation(); + archive(session.id); +} diff --git a/src/lib/chatBackground.test.ts b/src/lib/chatBackground.test.ts new file mode 100644 index 00000000..6819cf37 --- /dev/null +++ b/src/lib/chatBackground.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearProjectChatBackground, + pickAndSaveChatBackground, + pickAndSaveProjectChatBackground, + projectChatBackgroundSrc, + removeChatBackground, +} from "./chatBackground"; + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(), + open: vi.fn(), +})); + +vi.mock("@tauri-apps/api/core", () => ({ + convertFileSrc: (path: string) => `asset://${path}`, + invoke: mocks.invoke, +})); + +vi.mock("@tauri-apps/plugin-dialog", () => ({ open: mocks.open })); + +describe("chat background image", () => { + beforeEach(() => { + mocks.invoke.mockReset(); + mocks.open.mockReset(); + }); + + it("copies a picked image into app storage", async () => { + mocks.open.mockResolvedValue("/Pictures/aurora.webp"); + mocks.invoke.mockResolvedValue( + "/app-data/backgrounds/chat-background.webp", + ); + + await expect(pickAndSaveChatBackground()).resolves.toBe( + "/app-data/backgrounds/chat-background.webp", + ); + expect(mocks.invoke).toHaveBeenCalledWith("save_chat_background", { + sourcePath: "/Pictures/aurora.webp", + }); + }); + + it("leaves the current background alone when picking is cancelled", async () => { + mocks.open.mockResolvedValue(null); + await expect(pickAndSaveChatBackground()).resolves.toBeNull(); + expect(mocks.invoke).not.toHaveBeenCalled(); + }); + + it("removes the saved background", async () => { + mocks.invoke.mockResolvedValue(undefined); + await removeChatBackground(); + expect(mocks.invoke).toHaveBeenCalledWith("remove_chat_background"); + }); + + it("copies a picked image into project-specific app storage", async () => { + mocks.open.mockResolvedValue("/Pictures/grid.png"); + mocks.invoke.mockResolvedValue("/app-data/backgrounds/project-abc.png"); + + await expect( + pickAndSaveProjectChatBackground("/work/agent-terminal"), + ).resolves.toBe("/app-data/backgrounds/project-abc.png"); + expect(mocks.invoke).toHaveBeenCalledWith("save_project_chat_background", { + project: "/work/agent-terminal", + sourcePath: "/Pictures/grid.png", + }); + }); + + it("removes only the selected project's saved background", async () => { + mocks.invoke.mockResolvedValue(undefined); + + await clearProjectChatBackground("/work/agent-terminal"); + + expect(mocks.invoke).toHaveBeenCalledWith( + "remove_project_chat_background", + { project: "/work/agent-terminal" }, + ); + }); + + it("cache-busts project background URLs after replacement", () => { + expect(projectChatBackgroundSrc("/app-data/background.png", 42)).toBe( + "asset:///app-data/background.png?v=42", + ); + }); +}); diff --git a/src/lib/chatBackground.ts b/src/lib/chatBackground.ts new file mode 100644 index 00000000..c68c44f7 --- /dev/null +++ b/src/lib/chatBackground.ts @@ -0,0 +1,54 @@ +import { convertFileSrc, invoke } from "@tauri-apps/api/core"; +import { open } from "@tauri-apps/plugin-dialog"; + +export async function pickAndSaveChatBackground(): Promise { + const sourcePath = await open({ + multiple: false, + directory: false, + title: "Choose chat background", + filters: [ + { + name: "Images", + extensions: ["png", "jpg", "jpeg", "gif", "webp"], + }, + ], + }); + if (typeof sourcePath !== "string" || !sourcePath) return null; + return invoke("save_chat_background", { sourcePath }); +} + +export function removeChatBackground(): Promise { + return invoke("remove_chat_background"); +} + +export async function pickAndSaveProjectChatBackground( + project: string, +): Promise { + const sourcePath = await open({ + multiple: false, + directory: false, + title: "Choose project chat background", + filters: [ + { + name: "Images", + extensions: ["png", "jpg", "jpeg", "gif", "webp"], + }, + ], + }); + if (typeof sourcePath !== "string" || !sourcePath) return null; + return invoke("save_project_chat_background", { + project, + sourcePath, + }); +} + +export function clearProjectChatBackground(project: string): Promise { + return invoke("remove_project_chat_background", { project }); +} + +export function projectChatBackgroundSrc( + path: string, + revision: number, +): string { + return `${convertFileSrc(path)}?v=${revision}`; +} diff --git a/src/lib/fs.ts b/src/lib/fs.ts index 9bb1cd83..660656a7 100644 --- a/src/lib/fs.ts +++ b/src/lib/fs.ts @@ -98,8 +98,18 @@ export type GitFileDiff = { tooLarge: boolean; }; -export function gitFileDiff(cwd: string, relative: string): Promise { - return invoke("git_file_diff", { cwd, relative }); +export type GitFileDiffKind = "staged" | "unstaged"; + +export function gitFileDiff( + cwd: string, + relative: string, + kind: GitFileDiffKind = "unstaged", +): Promise { + return invoke("git_file_diff", { + cwd, + relative, + staged: kind === "staged", + }); } export type GitHistoryRef = { diff --git a/src/lib/githubTasks.test.ts b/src/lib/githubTasks.test.ts index f12101fa..387a0205 100644 --- a/src/lib/githubTasks.test.ts +++ b/src/lib/githubTasks.test.ts @@ -101,6 +101,16 @@ describe("inboxPersonAvatarUrl", () => { it("does not invent a GitHub avatar for Linear names", () => { expect(inboxPersonAvatarUrl("linear", "Ada")).toBe(""); }); + + it("uses GitLab's explicit avatar URL", () => { + expect( + inboxPersonAvatarUrl( + "gitlab", + "maya", + "https://gitlab.example.com/uploads/maya.png", + ), + ).toBe("https://gitlab.example.com/uploads/maya.png"); + }); }); describe("formatRelativeTime", () => { @@ -122,7 +132,9 @@ describe("githubReviewDecisionLabel", () => { expect(githubReviewDecisionLabel("changes_requested")).toBe( "Changes requested", ); - expect(githubReviewDecisionLabel("REVIEW_REQUIRED")).toBe("Review required"); + expect(githubReviewDecisionLabel("REVIEW_REQUIRED")).toBe( + "Review required", + ); expect(githubReviewDecisionLabel("")).toBe(""); }); }); @@ -206,7 +218,10 @@ describe("dedupeInboxItems", () => { repo: "HardBeat920/monocode", }), ]; - const deduped = dedupeInboxItems(rows, ["/tmp/monocode", "/tmp/agent-terminal"]); + const deduped = dedupeInboxItems(rows, [ + "/tmp/monocode", + "/tmp/agent-terminal", + ]); expect(deduped).toHaveLength(1); expect(deduped[0]?.projectPath).toBe("/tmp/monocode"); expect(inboxItemKey(deduped[0]!)).toBe( @@ -278,9 +293,9 @@ describe("filterInboxItems", () => { }); it("matches title, number, kind, repo, and labels", () => { - expect(filterInboxItems(items, "checkout").map((row) => row.number)).toEqual( - [12], - ); + expect( + filterInboxItems(items, "checkout").map((row) => row.number), + ).toEqual([12]); expect(filterInboxItems(items, "#4").map((row) => row.number)).toEqual([4]); expect(filterInboxItems(items, "pull").map((row) => row.number)).toEqual([ 12, @@ -343,6 +358,23 @@ describe("inboxStartDraft", () => { ); }); + it("uses GitLab merge request wording", () => { + expect( + inboxStartDraft( + item({ + number: 12, + kind: "pr", + provider: "gitlab", + title: "Fix checkout", + url: "https://gitlab.example.com/acme/web/-/merge_requests/12", + updatedAt: "2026-08-27T10:00:00Z", + }), + ), + ).toBe( + "Work on this GitLab merge request:\n\n#12 Fix checkout\nhttps://gitlab.example.com/acme/web/-/merge_requests/12\n", + ); + }); + it("includes a Linear description when provided", () => { expect( inboxStartDraft( @@ -375,6 +407,20 @@ describe("inboxItemKey", () => { ), ).toBe("linear:eng-9"); }); + + it("keeps GitLab identities separate from GitHub", () => { + const gitlab = item({ + number: 9, + provider: "gitlab", + repo: "acme/web", + url: "https://gitlab.example.com/acme/web/-/issues/9", + updatedAt: "2026-08-27T10:00:00Z", + }); + expect(inboxItemKey(gitlab)).toBe("gitlab:acme/web:issue:9"); + expect(inboxItemKey(gitlab)).not.toBe( + inboxItemKey({ ...gitlab, provider: "github" }), + ); + }); }); describe("inboxComposerCard", () => { @@ -422,6 +468,25 @@ describe("inboxComposerCard", () => { source: "acme/web", }); }); + + it("builds a GitLab chip from the project and issue number", () => { + const card = inboxComposerCard( + item({ + number: 10, + provider: "gitlab", + title: "Normalize streamed plan", + url: "https://gitlab.example.com/acme/web/-/issues/10", + updatedAt: "2026-08-27T10:00:00Z", + }), + ); + expect(card).toMatchObject({ + provider: "gitlab", + kind: "issue", + identifier: "#10", + title: "Normalize streamed plan", + source: "acme/web", + }); + }); }); describe("composeInboxMessage", () => { diff --git a/src/lib/githubTasks.ts b/src/lib/githubTasks.ts index 7fca12b0..2fc78313 100644 --- a/src/lib/githubTasks.ts +++ b/src/lib/githubTasks.ts @@ -7,6 +7,13 @@ import { loadHiddenLinearTeamIds, type LinearIssue, } from "./linear"; +import { + clearGitlabCache, + gitlabConnected, + gitlabRepo, + listGitlabWorkItems, + type GitlabWorkItem, +} from "./gitlab"; import { collectRailProjects, normalizeProjectPath, @@ -40,7 +47,7 @@ export type GithubWorkItem = { repo: string; }; -export type InboxProvider = "github" | "linear"; +export type InboxProvider = "github" | "linear" | "gitlab"; export type InboxItem = Omit & { kind: InboxKind; @@ -134,6 +141,8 @@ type InboxListCache = InboxListResult & { let inboxListCache: InboxListCache | null = null; const inboxListInflight = new Map>(); const repoByPath = new Map(); +const workItemByKey = new Map(); +const workItemInflight = new Map>(); const detailsByKey = new Map(); const threadByKey = new Map(); const threadInflight = new Map>(); @@ -144,11 +153,14 @@ export function clearInboxCache() { inboxListCache = null; inboxListInflight.clear(); repoByPath.clear(); + workItemByKey.clear(); + workItemInflight.clear(); detailsByKey.clear(); threadByKey.clear(); threadInflight.clear(); prDiffByKey.clear(); prDiffInflight.clear(); + clearGitlabCache(); } export function inboxListCacheKey( @@ -214,6 +226,43 @@ export function listGithubWorkItems( }); } +function workItemLookupKey( + repo: string, + kind: GithubTaskKind, + number: number, +): string { + return `${repo.trim().toLowerCase()}:${kind}:${number}`; +} + +/** Fetch one exact item after targeted Inbox navigation misses its list cache. */ +export function githubWorkItem( + cwd: string, + repo: string, + kind: GithubTaskKind, + number: number, +): Promise { + const key = workItemLookupKey(repo, kind, number); + const cached = workItemByKey.get(key); + if (cached) return Promise.resolve(cached); + const pending = workItemInflight.get(key); + if (pending) return pending; + const promise = invoke("git_github_work_item", { + cwd, + repo, + kind, + number, + }) + .then((item) => { + workItemByKey.set(key, item); + return item; + }) + .finally(() => { + if (workItemInflight.get(key) === promise) workItemInflight.delete(key); + }); + workItemInflight.set(key, promise); + return promise; +} + export function formatGithubQuery(query: GithubWorkItemQuery): string { const parts: string[] = []; if (query.assignedToMe) parts.push("assignee:@me"); @@ -494,12 +543,58 @@ async function fetchInboxItems( } } + let gitlabItems: InboxItem[] = []; + if ((await gitlabConnected()).connected) { + const gitlab = await fetchGitlabInboxItems(unique, query, preferredPaths); + gitlabItems = gitlab.items; + if (gitlab.error) errors.gitlab = gitlab.error; + } + return { - items: dedupeInboxItems([...github.items, ...linearItems], preferredPaths), + items: dedupeInboxItems( + [...github.items, ...linearItems, ...gitlabItems], + preferredPaths, + ), errors, }; } +async function fetchGitlabInboxItems( + projects: readonly { path: string }[], + query: InboxQuery, + preferredPaths: readonly string[], +): Promise<{ items: InboxItem[]; error?: string }> { + const resolved = await Promise.all( + projects.map(async (project) => { + try { + return { + path: project.path, + repo: (await gitlabRepo(project.path)).trim(), + }; + } catch { + return { path: project.path, repo: "" }; + } + }), + ); + const grouped = groupProjectsByRepo( + resolved.filter((project) => project.repo.length > 0), + ); + const jobs = grouped.flatMap((project) => + (["issue", "pr"] as const).map(async (kind) => { + const items = await listGitlabWorkItems(project.path, { + kind, + assignedToMe: query.assignedToMe, + state: query.state, + limit: query.state === "all" ? INBOX_ALL_LIMIT : undefined, + }); + return items.map((item) => + gitlabWorkItemToInboxItem(item, project.path, project.repo), + ); + }), + ); + return collectInboxResults(await Promise.allSettled(jobs), preferredPaths); +} + async function fetchLinearInboxItems(query: InboxQuery): Promise { const hiddenIds = query.linearHiddenTeamIds ?? loadHiddenLinearTeamIds(); let teamIds: string[] | null = null; @@ -543,6 +638,19 @@ function linearIssueToInboxItem(issue: LinearIssue): InboxItem { }; } +function gitlabWorkItemToInboxItem( + item: GitlabWorkItem, + projectPath: string, + repo: string, +): InboxItem { + return { + ...item, + provider: "gitlab", + repo: item.repo || repo, + projectPath, + }; +} + export function inboxProjectsForRail( recents: RecentProject[], cwd: string, @@ -702,7 +810,9 @@ export function matchesInboxQuery(item: InboxItem, query: string): boolean { if (!needle) return true; const kind = item.kind === "pr" - ? "pull request pr" + ? item.provider === "gitlab" + ? "merge request mr" + : "pull request pr" : item.kind === "linear" ? "linear issue" : "issue"; @@ -756,9 +866,13 @@ export function inboxStartDraft(item: InboxItem, body?: string): string { return `${lines.join("\n")}\n`; } const kind = item.kind === "pr" ? "pull request" : "issue"; - const title = item.title.trim() || `GitHub ${kind} #${item.number}`; + const provider = item.provider === "gitlab" ? "GitLab" : "GitHub"; + const providerKind = + item.provider === "gitlab" && item.kind === "pr" ? "merge request" : kind; + const title = + item.title.trim() || `${provider} ${providerKind} #${item.number}`; const lines = [ - `Work on this GitHub ${kind}:`, + `Work on this ${provider} ${providerKind}:`, "", `#${item.number} ${title}`, ]; diff --git a/src/lib/gitlab.ts b/src/lib/gitlab.ts new file mode 100644 index 00000000..5b3d2237 --- /dev/null +++ b/src/lib/gitlab.ts @@ -0,0 +1,246 @@ +import { invoke } from "@tauri-apps/api/core"; +import { normalizeProjectPath } from "./recents"; + +export type GitlabKind = "issue" | "pr"; + +export type GitlabStatus = { + connected: boolean; + url: string; +}; + +export type GitlabWorkItem = { + kind: GitlabKind; + number: number; + title: string; + url: string; + state: string; + updatedAt: string; + labels: { name: string; color: string }[]; + assignees: { login: string; avatarUrl?: string }[]; + draft: boolean; + repo: string; +}; + +export type GitlabWorkItemDetails = { + body: string; + author: string; + authorAvatarUrl?: string; + baseRefName?: string; + headRefName?: string; + reviewDecision?: string; +}; + +export type GitlabWorkItemComment = { + id: string; + kind: string; + author: string; + authorAvatarUrl?: string; + body: string; + createdAt: string; + url: string; + state: string; + path: string; + line: number | null; + resolved: boolean; + threadId: string; + replies: GitlabWorkItemComment[]; +}; + +export type GitlabWorkItemThread = { + comments: GitlabWorkItemComment[]; + truncated: boolean; + reviewDecision: string; + baseRefName: string; + headRefName: string; +}; + +export type GitlabMrDiff = { + additions: number; + deletions: number; + files: { path: string; additions: number; deletions: number }[]; + patch: string; + truncated: boolean; +}; + +export const GITLAB_CHANGE_EVENT = "monocode:gitlab-change"; + +const repoByPath = new Map(); +const detailsByKey = new Map(); +const threadByKey = new Map(); +const threadInflight = new Map>(); +const diffByKey = new Map(); +const diffInflight = new Map>(); + +function itemKey(cwd: string, kind: GitlabKind, number: number): string { + return `${normalizeProjectPath(cwd)}:${kind}:${number}`; +} + +export function clearGitlabCache() { + repoByPath.clear(); + detailsByKey.clear(); + threadByKey.clear(); + threadInflight.clear(); + diffByKey.clear(); + diffInflight.clear(); +} + +export function gitlabConnected(): Promise { + return invoke("gitlab_status"); +} + +export async function saveGitlabConfig( + url: string, + token: string, +): Promise { + const status = await invoke("gitlab_set_config", { + url: url.trim(), + token: token.trim(), + }); + clearGitlabCache(); + notifyGitlabChange(); + return status; +} + +export async function disconnectGitlab(url: string): Promise { + const status = await invoke("gitlab_set_config", { + url: url.trim(), + token: "", + }); + clearGitlabCache(); + notifyGitlabChange(); + return status; +} + +export async function gitlabRepo(cwd: string): Promise { + const key = normalizeProjectPath(cwd); + const cached = repoByPath.get(key); + if (cached !== undefined) return cached; + const repo = await invoke("gitlab_repo", { cwd }); + repoByPath.set(key, repo); + return repo; +} + +export function listGitlabWorkItems( + cwd: string, + query: { + kind: GitlabKind; + assignedToMe: boolean; + state: "open" | "all"; + limit?: number; + }, +): Promise { + return invoke("gitlab_list_work_items", { + cwd, + kind: query.kind, + assignedToMe: query.assignedToMe, + state: query.state, + limit: query.limit, + }); +} + +export function peekGitlabWorkItemDetails( + cwd: string, + kind: GitlabKind, + number: number, +): GitlabWorkItemDetails | null { + return detailsByKey.get(itemKey(cwd, kind, number)) ?? null; +} + +export async function gitlabWorkItemDetails( + cwd: string, + kind: GitlabKind, + number: number, +): Promise { + const details = await invoke( + "gitlab_work_item_details", + { cwd, kind, number }, + ); + detailsByKey.set(itemKey(cwd, kind, number), details); + return details; +} + +export function peekGitlabWorkItemThread( + cwd: string, + kind: GitlabKind, + number: number, +): GitlabWorkItemThread | null { + return threadByKey.get(itemKey(cwd, kind, number)) ?? null; +} + +export async function gitlabWorkItemThread( + cwd: string, + kind: GitlabKind, + number: number, + options?: { force?: boolean }, +): Promise { + const key = itemKey(cwd, kind, number); + if (options?.force) { + threadByKey.delete(key); + threadInflight.delete(key); + } + const cached = threadInflight.get(key); + if (cached) return cached; + const pending = invoke("gitlab_work_item_thread", { + cwd, + kind, + number, + }) + .then((thread) => { + threadByKey.set(key, thread); + return thread; + }) + .finally(() => { + if (threadInflight.get(key) === pending) threadInflight.delete(key); + }); + threadInflight.set(key, pending); + return pending; +} + +export async function gitlabWorkItemComment( + cwd: string, + kind: GitlabKind, + number: number, + body: string, +): Promise { + const url = await invoke("gitlab_work_item_comment", { + cwd, + kind, + number, + body: body.trim(), + }); + const key = itemKey(cwd, kind, number); + threadByKey.delete(key); + threadInflight.delete(key); + return url; +} + +export function peekGitlabMrDiff( + cwd: string, + number: number, +): GitlabMrDiff | null { + return diffByKey.get(itemKey(cwd, "pr", number)) ?? null; +} + +export async function gitlabMrDiff( + cwd: string, + number: number, +): Promise { + const key = itemKey(cwd, "pr", number); + const cached = diffInflight.get(key); + if (cached) return cached; + const pending = invoke("gitlab_mr_diff", { cwd, number }) + .then((diff) => { + diffByKey.set(key, diff); + return diff; + }) + .finally(() => { + if (diffInflight.get(key) === pending) diffInflight.delete(key); + }); + diffInflight.set(key, pending); + return pending; +} + +export function notifyGitlabChange() { + if (typeof window === "undefined") return; + window.dispatchEvent(new Event(GITLAB_CHANGE_EVENT)); +} diff --git a/src/lib/harness/claudeTitle.ts b/src/lib/harness/claudeTitle.ts index dcdc921a..8580c9a9 100644 --- a/src/lib/harness/claudeTitle.ts +++ b/src/lib/harness/claudeTitle.ts @@ -1,6 +1,7 @@ import { buildThreadTitlePrompt, - parseGeneratedThreadTitle, + parseGeneratedSessionTitle, + type GeneratedSessionTitle, } from "../sessionTitle"; import { runClaudeTextPrompt } from "./claudeText"; @@ -10,14 +11,14 @@ export async function generateClaudeSessionTitle(input: { sessionId: string; cwd: string; message: string; -}): Promise { +}): Promise { try { const output = await runClaudeTextPrompt({ cwd: input.cwd, prompt: buildThreadTitlePrompt(input.message), timeoutMs: TITLE_TIMEOUT_MS, }); - return parseGeneratedThreadTitle(output); + return parseGeneratedSessionTitle(output, input.message); } catch (error) { console.debug("[monocode] session title", error); return null; diff --git a/src/lib/harness/codexTitle.ts b/src/lib/harness/codexTitle.ts index a7381020..b4b68290 100644 --- a/src/lib/harness/codexTitle.ts +++ b/src/lib/harness/codexTitle.ts @@ -1,6 +1,7 @@ import { buildThreadTitlePrompt, - parseGeneratedThreadTitle, + parseGeneratedSessionTitle, + type GeneratedSessionTitle, } from "../sessionTitle"; import { runCodexTextPrompt } from "./codexText"; @@ -11,14 +12,14 @@ export async function generateCodexSessionTitle(input: { sessionId: string; cwd: string; message: string; -}): Promise { +}): Promise { try { const output = await runCodexTextPrompt({ cwd: input.cwd, prompt: buildThreadTitlePrompt(input.message), timeoutMs: TITLE_TIMEOUT_MS, }); - return parseGeneratedThreadTitle(output); + return parseGeneratedSessionTitle(output, input.message); } catch (error) { console.debug("[monocode] session title", error); return null; diff --git a/src/lib/harness/cursorTitle.ts b/src/lib/harness/cursorTitle.ts index dd29588e..cdfa0bf2 100644 --- a/src/lib/harness/cursorTitle.ts +++ b/src/lib/harness/cursorTitle.ts @@ -1,6 +1,7 @@ import { buildThreadTitlePrompt, - parseGeneratedThreadTitle, + parseGeneratedSessionTitle, + type GeneratedSessionTitle, } from "../sessionTitle"; import { runCursorTextPrompt } from "./cursorText"; @@ -15,14 +16,14 @@ export async function generateCursorSessionTitle(input: { sessionId: string; cwd: string; message: string; -}): Promise { +}): Promise { try { const output = await runCursorTextPrompt({ cwd: input.cwd, prompt: buildThreadTitlePrompt(input.message), timeoutMs: TITLE_TIMEOUT_MS, }); - return parseGeneratedThreadTitle(output); + return parseGeneratedSessionTitle(output, input.message); } catch (error) { console.debug("[monocode] session title", error); return null; diff --git a/src/lib/harness/grokTitle.ts b/src/lib/harness/grokTitle.ts index a7b1a770..bb1ed5a3 100644 --- a/src/lib/harness/grokTitle.ts +++ b/src/lib/harness/grokTitle.ts @@ -1,6 +1,7 @@ import { buildThreadTitlePrompt, - parseGeneratedThreadTitle, + parseGeneratedSessionTitle, + type GeneratedSessionTitle, } from "../sessionTitle"; import { runGrokTextPrompt } from "./grokText"; @@ -10,14 +11,14 @@ export async function generateGrokSessionTitle(input: { sessionId: string; cwd: string; message: string; -}): Promise { +}): Promise { try { const output = await runGrokTextPrompt({ cwd: input.cwd, prompt: buildThreadTitlePrompt(input.message), timeoutMs: TITLE_TIMEOUT_MS, }); - return parseGeneratedThreadTitle(output); + return parseGeneratedSessionTitle(output, input.message); } catch (error) { console.debug("[monocode] session title", error); return null; diff --git a/src/lib/harness/ompLive.test.ts b/src/lib/harness/ompLive.test.ts index 65bc2c1b..da5df4b1 100644 --- a/src/lib/harness/ompLive.test.ts +++ b/src/lib/harness/ompLive.test.ts @@ -8,6 +8,8 @@ const transport = vi.hoisted(() => ({ }>, prompt: undefined as ((sessionId: string, command: Record) => void) | undefined, + fast: undefined as + ((sessionId: string, command: Record) => void) | undefined, writeChild: vi.fn(), spawnChild: vi.fn(), })); @@ -71,6 +73,7 @@ beforeEach(() => { transport.spawnChild.mockReset(); transport.spawnChild.mockResolvedValue(undefined); transport.prompt = (id, command) => response(id, command); + transport.fast = undefined; transport.writeChild.mockReset(); transport.writeChild.mockImplementation( async (sessionId: string, line: string) => { @@ -79,6 +82,8 @@ beforeEach(() => { if (command.type === "extension_ui_response") return; if (command.type === "prompt") return transport.prompt?.(sessionId, command); + if (command.type === "set_fast_mode" && transport.fast) + return transport.fast(sessionId, command); response( sessionId, command, @@ -122,6 +127,74 @@ async function started(turnInput = input()) { } describe("OMP command lifecycle over the real RPC multiplexer", () => { + it("applies fast mode through OMP RPC before prompting", async () => { + const running = await started({ + ...input(), + modelSettings: { fast: "true" }, + }); + const fast = transport.requests.find( + (request) => request.command.type === "set_fast_mode", + ); + const promptIndex = transport.requests.findIndex( + (request) => request.command.type === "prompt", + ); + + expect(fast?.command).toMatchObject({ + type: "set_fast_mode", + enabled: true, + }); + expect(transport.requests.indexOf(fast!)).toBeLessThan(promptIndex); + frame("omp-test", { type: "agent_end" }); + await running.turn; + }); + + it("keeps fast mode in sync with OMP config updates", async () => { + const running = await started(); + frame("omp-test", { + type: "config_update", + fastModeEnabled: true, + }); + + expect(events).toContainEqual({ + type: "session.configChanged", + modelSettings: { fast: "true" }, + }); + frame("omp-test", { type: "agent_end" }); + await running.turn; + }); + + it("falls back cleanly when the current model cannot use fast mode", async () => { + transport.fast = (sessionId, command) => { + frame(sessionId, { + type: "response", + id: command.id, + command: command.type, + success: false, + error: "Fast mode is unavailable for the current model.", + }); + }; + const running = await started({ + ...input(), + modelSettings: { fast: "true" }, + }); + + expect( + transport.requests.filter( + (request) => request.command.type === "set_fast_mode", + ), + ).toHaveLength(1); + expect(events).toContainEqual({ + type: "session.configChanged", + modelSettings: { fast: "false" }, + }); + expect(events).toContainEqual({ + type: "status", + text: "Fast mode is unavailable for the current model.", + }); + frame("omp-test", { type: "agent_end" }); + await running.turn; + }); + it("reflects command-driven model/settings and session changes in MonoCode", async () => { const running = await started(); frame("omp-test", { diff --git a/src/lib/harness/opencodeTitle.ts b/src/lib/harness/opencodeTitle.ts index 672ded97..79810a04 100644 --- a/src/lib/harness/opencodeTitle.ts +++ b/src/lib/harness/opencodeTitle.ts @@ -1,6 +1,7 @@ import { buildThreadTitlePrompt, - parseGeneratedThreadTitle, + parseGeneratedSessionTitle, + type GeneratedSessionTitle, } from "../sessionTitle"; import { runOpenCodeTextPrompt } from "./opencodeText"; @@ -10,14 +11,14 @@ export async function generateOpenCodeSessionTitle(input: { sessionId: string; cwd: string; message: string; -}): Promise { +}): Promise { try { const output = await runOpenCodeTextPrompt({ cwd: input.cwd, prompt: buildThreadTitlePrompt(input.message), timeoutMs: TITLE_TIMEOUT_MS, }); - return parseGeneratedThreadTitle(output); + return parseGeneratedSessionTitle(output, input.message); } catch (error) { console.debug("[monocode] session title", error); return null; diff --git a/src/lib/harness/piFamily.ts b/src/lib/harness/piFamily.ts index b13f4231..9a89e6b5 100644 --- a/src/lib/harness/piFamily.ts +++ b/src/lib/harness/piFamily.ts @@ -83,6 +83,8 @@ type Live = { contextWindow?: number; nativeModel: string; thinking: string; + fastModeEnabled?: boolean; + fastModeRequested?: boolean; planning: boolean; onEvent: (event: HarnessEvent) => void; approvals: Map; @@ -248,7 +250,7 @@ export async function compactContext( live = await ensureLive(flavor, input); } else { live.onEvent = input.onEvent; - await applyModel(live, input); + await applyModel(flavor, live, input); } if (state.cancelledThreads.delete(input.sessionId)) return; @@ -402,7 +404,7 @@ async function ensureLive( existing.planning === wantPlanning ) { existing.onEvent = input.onEvent; - await applyModel(existing, input); + await applyModel(flavor, existing, input); return existing; } if (existing) { @@ -458,6 +460,8 @@ async function startLive( providerSessionId: resume ?? "", nativeModel: native, thinking: input.modelSettings?.thinking ?? "", + fastModeEnabled: undefined, + fastModeRequested: undefined, planning: input.intent === "plan", onEvent: input.onEvent, approvals: new Map(), @@ -527,7 +531,7 @@ async function startLive( INIT_TIMEOUT_MS, ); bindState(flavor, input.sessionId, live, stateFrame.data); - await applyModel(live, input); + await applyModel(flavor, live, input); if (live.providerSessionId) { live.onEvent({ type: "session.providerBound", @@ -547,7 +551,7 @@ async function runTurn( live: Live, input: SendTurnInput, ): Promise { - await applyModel(live, input); + await applyModel(flavor, live, input); live.emittedAssistant = ""; live.emittedReasoning = ""; live.turnError = null; @@ -658,14 +662,31 @@ function handleFrame( const provider = stringField(model, "provider"); const modelId = stringField(model, "id"); const thinking = stringField(rec, "thinkingLevel"); + const fastModeEnabled = + typeof rec.fastModeEnabled === "boolean" + ? rec.fastModeEnabled + : undefined; const native = provider && modelId ? piNativeId(provider, modelId) : undefined; if (native) live.nativeModel = native; if (isPiThinkingLevel(thinking)) live.thinking = thinking; + if (fastModeEnabled != null) { + live.fastModeEnabled = fastModeEnabled; + live.fastModeRequested = fastModeEnabled; + } live.onEvent({ type: "session.configChanged", ...(native ? { model: `${flavor.id}:${native}` } : {}), - ...(isPiThinkingLevel(thinking) ? { modelSettings: { thinking } } : {}), + ...(isPiThinkingLevel(thinking) || fastModeEnabled != null + ? { + modelSettings: { + ...(isPiThinkingLevel(thinking) ? { thinking } : {}), + ...(fastModeEnabled != null + ? { fast: String(fastModeEnabled) } + : {}), + }, + } + : {}), }); return; } @@ -957,6 +978,7 @@ async function handleExtensionUi( } async function applyModel( + flavor: PiFlavor, live: Live, input: HarnessSessionInput, ): Promise { @@ -986,6 +1008,40 @@ async function applyModel( .catch(() => undefined); live.thinking = thinking; } + + const fast = input.modelSettings?.fast; + if ( + flavor.id === "omp" && + (fast === "true" || fast === "false") && + (fast === "true") !== live.fastModeRequested + ) { + const enabled = fast === "true"; + live.fastModeRequested = enabled; + try { + const response = await live.rpc.request({ + type: "set_fast_mode", + enabled, + }); + const data = asRecord(response.data); + live.fastModeEnabled = + typeof data?.enabled === "boolean" ? data.enabled : enabled; + } catch (error) { + if (enabled) { + live.fastModeEnabled = false; + live.onEvent({ + type: "session.configChanged", + modelSettings: { fast: "false" }, + }); + live.onEvent({ + type: "status", + text: + error instanceof Error + ? error.message + : "Fast mode is unavailable for the current model.", + }); + } + } + } } function bindState( @@ -1010,6 +1066,11 @@ function bindState( if (provider && modelId && !live.nativeModel) { live.nativeModel = piNativeId(provider, modelId); } + const fastModeEnabled = asRecord(data)?.fastModeEnabled; + if (flavor.id === "omp" && typeof fastModeEnabled === "boolean") { + live.fastModeEnabled = fastModeEnabled; + live.fastModeRequested = fastModeEnabled; + } } function upsertTool( diff --git a/src/lib/harness/piProtocol.test.ts b/src/lib/harness/piProtocol.test.ts index eacf227c..b34e6911 100644 --- a/src/lib/harness/piProtocol.test.ts +++ b/src/lib/harness/piProtocol.test.ts @@ -384,6 +384,33 @@ describe("tools and models", () => { expect(models[1]?.settings).toBeUndefined(); }); + it("adds fast mode to omp models without exposing it for Pi", () => { + const data = { + models: [ + { + id: "claude-opus-4-1", + name: "Claude Opus 4.1", + provider: "anthropic", + reasoning: true, + }, + ], + }; + const omp = modelsFromRpcData(OMP_FLAVOR, data)[0]; + const pi = modelsFromRpcData(PI_FLAVOR, data)[0]; + + expect(omp?.settings?.map((setting) => setting.id)).toEqual([ + "thinking", + "fast", + ]); + expect( + omp?.settings?.find((setting) => setting.id === "fast"), + ).toMatchObject({ + kind: "toggle", + value: "false", + }); + expect(pi?.settings?.some((setting) => setting.id === "fast")).toBe(false); + }); + it("reads session and context stats", () => { expect( providerSessionIdFromState({ diff --git a/src/lib/harness/piProtocol.ts b/src/lib/harness/piProtocol.ts index 7ab47a8f..c8405a21 100644 --- a/src/lib/harness/piProtocol.ts +++ b/src/lib/harness/piProtocol.ts @@ -686,13 +686,16 @@ export function modelsFromRpcData( seen.add(nativeId); const name = stringField(model, "name") || modelId; const contextWindow = numberField(model, "contextWindow"); - const settings = thinkingSetting(model.reasoning === true); + const settings = [ + thinkingSetting(model.reasoning === true), + flavor.id === "omp" ? fastModeSetting() : undefined, + ].filter((setting): setting is ModelSetting => setting != null); models.push({ id: `${flavor.id}:${nativeId}`, harness: flavor.id, name, nativeId, - ...(settings ? { settings: [settings] } : {}), + ...(settings.length > 0 ? { settings } : {}), ...(contextWindow && contextWindow > 0 ? { contextWindow } : {}), }); } @@ -713,6 +716,20 @@ export function thinkingSetting(reasoning: boolean): ModelSetting | undefined { }; } +export function fastModeSetting(): ModelSetting { + return { + id: "fast", + label: "Fast", + description: "Use priority processing when the current model supports it", + kind: "toggle", + value: "false", + options: [ + { value: "true", label: "On" }, + { value: "false", label: "Off" }, + ], + }; +} + export function isPiThinkingLevel( value: string | undefined, ): value is PiThinkingLevel { diff --git a/src/lib/harness/piTitle.ts b/src/lib/harness/piTitle.ts index 8de9f150..ecd79d43 100644 --- a/src/lib/harness/piTitle.ts +++ b/src/lib/harness/piTitle.ts @@ -1,6 +1,7 @@ import { buildThreadTitlePrompt, - parseGeneratedThreadTitle, + parseGeneratedSessionTitle, + type GeneratedSessionTitle, } from "../sessionTitle"; import { OMP_FLAVOR, PI_FLAVOR, type PiFlavor } from "./piFlavor"; import { runTextPrompt } from "./piText"; @@ -14,14 +15,14 @@ async function generateSessionTitle( cwd: string; message: string; }, -): Promise { +): Promise { try { const output = await runTextPrompt(flavor, { cwd: input.cwd, prompt: buildThreadTitlePrompt(input.message), timeoutMs: TITLE_TIMEOUT_MS, }); - return parseGeneratedThreadTitle(output); + return parseGeneratedSessionTitle(output, input.message); } catch (error) { console.debug("[monocode] session title", error); return null; @@ -32,7 +33,7 @@ export function generatePiSessionTitle(input: { sessionId: string; cwd: string; message: string; -}): Promise { +}): Promise { return generateSessionTitle(PI_FLAVOR, input); } @@ -40,6 +41,6 @@ export function generateOmpSessionTitle(input: { sessionId: string; cwd: string; message: string; -}): Promise { +}): Promise { return generateSessionTitle(OMP_FLAVOR, input); } diff --git a/src/lib/harness/registry.ts b/src/lib/harness/registry.ts index 9ea8c417..6e15caa1 100644 --- a/src/lib/harness/registry.ts +++ b/src/lib/harness/registry.ts @@ -1,4 +1,5 @@ import type { HarnessId } from "../session"; +import type { GeneratedSessionTitle } from "../sessionTitle"; import type { PrContent } from "../gitText"; import { hasLiveCatalog } from "../models"; import type { UserQuestionReply } from "../userQuestion"; @@ -51,7 +52,7 @@ export type HarnessAdapter = { /** Refresh the model catalog overlay when supported. */ refreshCatalog?(): Promise; /** Optional LLM tab title for the first turn. */ - generateTitle?(input: TitleInput): Promise; + generateTitle?(input: TitleInput): Promise; /** Optional LLM commit message from staged changes. */ generateCommitMessage?(cwd: string): Promise; /** Optional LLM pull request title/body from branch diff context. */ @@ -258,7 +259,7 @@ export async function refreshHarnessCatalogs( export async function generateHarnessTitle( harness: HarnessId, input: TitleInput, -): Promise { +): Promise { const adapter = getHarness(harness); if (!adapter?.generateTitle) return null; return adapter.generateTitle(input); diff --git a/src/lib/inboxAsk.test.ts b/src/lib/inboxAsk.test.ts index 0bec3235..2060acc5 100644 --- a/src/lib/inboxAsk.test.ts +++ b/src/lib/inboxAsk.test.ts @@ -38,6 +38,12 @@ describe("inbox sessions", () => { expect( inboxAskKey({ provider: "linear", id: "issue-uuid" } as InboxItem), ).toBe("linear:issue-uuid"); + expect( + inboxAskKey({ + provider: "gitlab", + url: "https://gitlab.example.com/acme/app/-/merge_requests/42", + } as InboxItem), + ).toBe("gitlab:gitlab.example.com:/acme/app/-/merge_requests/42"); }); it("adds the remote-access instruction on follow-ups and leaves ordinary sessions alone", () => { @@ -75,21 +81,39 @@ describe("inbox sessions", () => { busy: true, blocks: [ { id: "u1", role: "user" as const, text: "Explain the PR" }, - { id: "approval", role: "tool" as const, text: "Run command", approval: { requestId: 7 } }, + { + id: "approval", + role: "tool" as const, + text: "Run command", + approval: { requestId: 7 }, + }, ], }; const staleHistory = [summaryFromSession(session)]; - expect(historyWithLiveSessions(staleHistory, [session], session.cwd)).toEqual([]); - expect(liveAgentsFromSessions([session], new Set([session.id]))).toEqual([]); + expect( + historyWithLiveSessions(staleHistory, [session], session.cwd), + ).toEqual([]); + expect(liveAgentsFromSessions([session], new Set([session.id]))).toEqual( + [], + ); expect(hiddenApprovalNotices([session], "", [], true)).toEqual([]); }); it("omits Ask from workspace snapshots and restart recovery", () => { const project = newSession("codex", "/project"); - const session = { ...newSession("codex", "/other"), inboxAsk: context, busy: true }; + const session = { + ...newSession("codex", "/other"), + inboxAsk: context, + busy: true, + }; const tab = newTab(project.id); - const snapshot = collectWorkspaceSnapshot([tab], [project, session], tab.id, project.cwd); - expect(snapshot.sessions.map(entry => entry.id)).toEqual([project.id]); + const snapshot = collectWorkspaceSnapshot( + [tab], + [project, session], + tab.id, + project.cwd, + ); + expect(snapshot.sessions.map((entry) => entry.id)).toEqual([project.id]); const restored = hydrateWorkspaceSnapshot( snapshot, new Map([[session.id, session]]), @@ -98,8 +122,10 @@ describe("inbox sessions", () => { expect(restored.tabs).toEqual([tab]); expect(restored.activeTabId).toBe(tab.id); expect(restored.projectCwd).toBe(project.cwd); - expect(restored.sessions.map(entry => entry.id)).toEqual([project.id]); - expect(workspaceFromResumed([project, session])!.sessions).toEqual([project]); + expect(restored.sessions.map((entry) => entry.id)).toEqual([project.id]); + expect(workspaceFromResumed([project, session])!.sessions).toEqual([ + project, + ]); }); it("drops Ask tabs and stubs saved by the earlier implementation", () => { @@ -117,7 +143,13 @@ describe("inbox sessions", () => { const parsed = parseWorkspaceSnapshot(legacy)!; expect(parsed.tabs).toEqual([tab]); expect(parsed.activeTabId).toBe(tab.id); - expect(parsed.sessions.map(entry => entry.id)).toEqual([project.id]); - expect(parseWorkspaceSnapshot({ ...legacy, tabs: [askTab], sessions: [session] })).toBeNull(); + expect(parsed.sessions.map((entry) => entry.id)).toEqual([project.id]); + expect( + parseWorkspaceSnapshot({ + ...legacy, + tabs: [askTab], + sessions: [session], + }), + ).toBeNull(); }); }); diff --git a/src/lib/inboxAsk.ts b/src/lib/inboxAsk.ts index 58f74caa..e42d7fda 100644 --- a/src/lib/inboxAsk.ts +++ b/src/lib/inboxAsk.ts @@ -5,14 +5,14 @@ export type InboxAskContext = { key: string; title: string; url: string; - provider: "github" | "linear"; + provider: "github" | "linear" | "gitlab"; description?: string; }; export function inboxAskKey(item: InboxItem): string { if (item.provider === "linear") return `linear:${item.id}`; const url = new URL(item.url); - return `github:${url.host.toLowerCase()}:${url.pathname.replace(/\/$/, "").toLowerCase()}`; + return `${item.provider}:${url.host.toLowerCase()}:${url.pathname.replace(/\/$/, "").toLowerCase()}`; } export function inboxAskPrompt( diff --git a/src/lib/inboxFilters.test.ts b/src/lib/inboxFilters.test.ts index 12869373..8a0b2c54 100644 --- a/src/lib/inboxFilters.test.ts +++ b/src/lib/inboxFilters.test.ts @@ -37,8 +37,16 @@ function item( describe("filterInboxByProject", () => { it("hides selected projects", () => { const rows = [ - item({ number: 1, updatedAt: "2026-08-27T10:00:00Z", projectPath: "/tmp/web" }), - item({ number: 2, updatedAt: "2026-08-27T10:00:00Z", projectPath: "/tmp/docs" }), + item({ + number: 1, + updatedAt: "2026-08-27T10:00:00Z", + projectPath: "/tmp/web", + }), + item({ + number: 2, + updatedAt: "2026-08-27T10:00:00Z", + projectPath: "/tmp/docs", + }), ]; expect( filterInboxByProject(rows, ["/tmp/web/"]).map((row) => row.number), @@ -47,7 +55,11 @@ describe("filterInboxByProject", () => { it("keeps Linear issues that are not tied to a folder", () => { const rows = [ - item({ number: 1, updatedAt: "2026-08-27T10:00:00Z", projectPath: "/tmp/web" }), + item({ + number: 1, + updatedAt: "2026-08-27T10:00:00Z", + projectPath: "/tmp/web", + }), item({ number: 9, kind: "linear", @@ -101,9 +113,9 @@ describe("linearProjectOptions", () => { }); it("falls back to the id when a project has no name", () => { - expect(linearProjectOptions([linearItem(1, { id: "p1", name: "" })])).toEqual( - [{ id: "p1", name: "p1" }], - ); + expect( + linearProjectOptions([linearItem(1, { id: "p1", name: "" })]), + ).toEqual([{ id: "p1", name: "p1" }]); }); }); @@ -131,9 +143,9 @@ describe("filterInboxByLinearProject", () => { ]; it("keeps everything when nothing is hidden", () => { - expect(filterInboxByLinearProject(rows, []).map((row) => row.number)).toEqual( - [1, 2, 3], - ); + expect( + filterInboxByLinearProject(rows, []).map((row) => row.number), + ).toEqual([1, 2, 3]); }); it("hides the selected project", () => { @@ -174,16 +186,21 @@ describe("filterInboxByKind", () => { expect(filterInboxByKind(rows, ["pr"]).map((row) => row.number)).toEqual([ 1, 9, ]); - expect(filterInboxByKind(rows, ["linear"]).map((row) => row.number)).toEqual( - [1, 2], - ); + expect( + filterInboxByKind(rows, ["linear"]).map((row) => row.number), + ).toEqual([1, 2]); }); }); describe("filterInboxByProvider", () => { - it("keeps GitHub or Linear items", () => { + it("keeps GitHub, GitLab, or Linear items", () => { const rows = [ item({ number: 1, updatedAt: "2026-08-27T10:00:00Z" }), + item({ + number: 2, + provider: "gitlab", + updatedAt: "2026-08-27T10:00:00Z", + }), item({ number: 9, kind: "linear", @@ -197,6 +214,9 @@ describe("filterInboxByProvider", () => { expect( filterInboxByProvider(rows, "linear").map((row) => row.number), ).toEqual([9]); + expect( + filterInboxByProvider(rows, "gitlab").map((row) => row.number), + ).toEqual([2]); }); }); diff --git a/src/lib/inboxFilters.ts b/src/lib/inboxFilters.ts index 14b88dfb..86c05f4a 100644 --- a/src/lib/inboxFilters.ts +++ b/src/lib/inboxFilters.ts @@ -6,10 +6,7 @@ import { type InboxProvider, } from "./githubTasks"; import { normalizeProjectPath } from "./recents"; -import { - timeFilterStart, - type SessionTimeFilter, -} from "./sessionFilters"; +import { timeFilterStart, type SessionTimeFilter } from "./sessionFilters"; export type InboxTimeFilter = SessionTimeFilter; @@ -65,7 +62,7 @@ const SOURCE_KEY = "monocode.inboxSource"; export function loadInboxSource(): InboxSource { try { const raw = localStorage.getItem(SOURCE_KEY); - return raw === "linear" ? "linear" : "github"; + return raw === "linear" || raw === "gitlab" ? raw : "github"; } catch { return "github"; } @@ -88,7 +85,8 @@ export function loadInboxFilters(): InboxFilters { assignedToMe: parsed.assignedToMe === true, hiddenProjects: Array.isArray(parsed.hiddenProjects) ? parsed.hiddenProjects.filter( - (path): path is string => typeof path === "string" && path.length > 0, + (path): path is string => + typeof path === "string" && path.length > 0, ) : [], hiddenLinearProjects: Array.isArray(parsed.hiddenLinearProjects) @@ -312,5 +310,7 @@ function isGithubInboxKind(value: unknown): value is InboxKind { } function isTimeFilter(value: unknown): value is InboxTimeFilter { - return value === "all" || value === "today" || value === "7d" || value === "30d"; + return ( + value === "all" || value === "today" || value === "7d" || value === "30d" + ); } diff --git a/src/lib/keyboard.test.ts b/src/lib/keyboard.test.ts new file mode 100644 index 00000000..05ee12a7 --- /dev/null +++ b/src/lib/keyboard.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { isImeComposition } from "./keyboard"; + +function keyEvent( + overrides: Partial> = {}, +): Pick { + return { + isComposing: false, + keyCode: 13, + ...overrides, + }; +} + +describe("isImeComposition", () => { + it("detects an active IME composition", () => { + expect(isImeComposition(keyEvent({ isComposing: true }))).toBe(true); + }); + + it("detects the legacy IME key code used by WebKit", () => { + expect(isImeComposition(keyEvent({ keyCode: 229 }))).toBe(true); + }); + + it("leaves ordinary keyboard events alone", () => { + expect(isImeComposition(keyEvent())).toBe(false); + }); +}); diff --git a/src/lib/keyboard.ts b/src/lib/keyboard.ts new file mode 100644 index 00000000..46a34636 --- /dev/null +++ b/src/lib/keyboard.ts @@ -0,0 +1,5 @@ +type ImeKeyboardEvent = Pick; + +export function isImeComposition(event: ImeKeyboardEvent): boolean { + return event.isComposing || event.keyCode === 229; +} diff --git a/src/lib/layout.test.ts b/src/lib/layout.test.ts index ae1bbb01..d864c40d 100644 --- a/src/lib/layout.test.ts +++ b/src/lib/layout.test.ts @@ -154,12 +154,18 @@ describe("openSessionChangesTab", () => { describe("openChangesTab", () => { it("reuses one Changes tab and updates the focused file", () => { const cwd = "/repo"; - const first = openChangesTab(newTab("session-a"), cwd, "/repo/a.ts"); - const second = openChangesTab(first, cwd, "/repo/b.ts"); + const first = openChangesTab( + newTab("session-a"), + cwd, + "/repo/a.ts", + "staged", + ); + const second = openChangesTab(first, cwd, "/repo/b.ts", "unstaged"); const files = second.editorPanes[0]?.files ?? []; expect(files.filter(isChangesTab)).toHaveLength(1); expect(files.filter(isReviewTab)).toHaveLength(1); expect(files.find(isChangesTab)?.path).toBe("/repo/b.ts"); + expect(files.find(isChangesTab)?.changeKind).toBe("unstaged"); }); it("opens a Changes tab without a focused file", () => { diff --git a/src/lib/layout.ts b/src/lib/layout.ts index cd8f0abc..2608971d 100644 --- a/src/lib/layout.ts +++ b/src/lib/layout.ts @@ -1,4 +1,5 @@ import type { ReleaseNotesTabSource } from "./releaseNotes"; +import type { GitFileDiffKind } from "./fs"; import { applyTerminalMeta, defaultTerminalTitle, @@ -54,6 +55,8 @@ export type FilePaneTab = { review?: boolean; /** Single working-tree review of every changed file (unified diff). */ changes?: boolean; + /** Which side of a staged/unstaged path was selected in source control. */ + changeKind?: GitFileDiffKind; /** Read-only diff built from one session's captured before/after snapshots. */ sessionChanges?: SessionChangesSource; /** Historical commit review (unified diff, read-only). */ @@ -105,22 +108,29 @@ export function newFileTab( path: string, cwd: string, review = false, + changeKind?: GitFileDiffKind, ): FilePaneTab { return { id: crypto.randomUUID(), path, cwd, ...(review ? { review: true } : {}), + ...(changeKind ? { changeKind } : {}), }; } -export function newChangesTab(cwd: string, focusPath?: string): FilePaneTab { +export function newChangesTab( + cwd: string, + focusPath?: string, + focusKind?: GitFileDiffKind, +): FilePaneTab { return { id: crypto.randomUUID(), path: focusPath || cwd, cwd, review: true, changes: true, + ...(focusKind ? { changeKind: focusKind } : {}), }; } @@ -452,16 +462,23 @@ export function openChangesTab( tab: WorkspaceTab, cwd: string, focusPath?: string, + focusKind?: GitFileDiffKind, ): WorkspaceTab { tab = isolateTerminalPanes(tab); - const next = newChangesTab(cwd, focusPath); + const next = newChangesTab(cwd, focusPath, focusKind); const existingPane = tab.editorPanes.find((pane) => pane.files.some(isChangesTab), ); const existingFile = existingPane?.files.find(isChangesTab); if (existingPane && existingFile) { - const updated = focusPath ? { ...existingFile, path: focusPath } : existingFile; + const updated = focusPath + ? { + ...existingFile, + path: focusPath, + changeKind: focusKind, + } + : existingFile; return { ...tab, focusedId: existingPane.id, diff --git a/src/lib/noteImages.test.ts b/src/lib/noteImages.test.ts new file mode 100644 index 00000000..c7102f29 --- /dev/null +++ b/src/lib/noteImages.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { + insertNoteImagesMarkdown, + isNoteImagePath, + noteImageMarkdown, + type NoteImageAsset, +} from "./noteImages"; + +const image: NoteImageAsset = { + name: "Architecture [draft].png", + markdownPath: "/note-assets/note-1/123-architecture-draft.png", +}; + +describe("note image markdown", () => { + it("escapes image names used as alt text", () => { + expect(noteImageMarkdown(image)).toBe( + "![Architecture \\[draft\\].png](/note-assets/note-1/123-architecture-draft.png)", + ); + }); + + it("inserts images as blocks at the cursor", () => { + expect(insertNoteImagesMarkdown("BeforeAfter", 6, 6, [image])).toEqual({ + value: + "Before\n\n![Architecture \\[draft\\].png](/note-assets/note-1/123-architecture-draft.png)\n\nAfter", + cursor: 85, + }); + }); + + it("replaces the selection and separates multiple images", () => { + const second = { + name: "flow.png", + markdownPath: "/note-assets/note-1/456-flow.png", + }; + expect( + insertNoteImagesMarkdown("Top\nreplace\nBottom", 4, 12, [image, second]), + ).toEqual({ + value: [ + "Top", + "", + noteImageMarkdown(image), + "", + noteImageMarkdown(second), + "", + "Bottom", + ].join("\n"), + cursor: 129, + }); + }); + + it("recognizes only note asset references", () => { + expect(isNoteImagePath(image.markdownPath)).toBe(true); + expect(isNoteImagePath("https://example.com/image.png")).toBe(false); + }); +}); diff --git a/src/lib/noteImages.ts b/src/lib/noteImages.ts new file mode 100644 index 00000000..3c8e5498 --- /dev/null +++ b/src/lib/noteImages.ts @@ -0,0 +1,135 @@ +import { invoke } from "@tauri-apps/api/core"; +import { + attachmentsFromFiles, + attachmentsFromPaths, + revokeAttachment, +} from "./attachments"; +import type { Attachment } from "./session"; + +export const NOTE_IMAGE_PREFIX = "/note-assets/"; + +export type NoteImageAsset = { + name: string; + markdownPath: string; +}; + +export type MarkdownInsertion = { + value: string; + cursor: number; +}; + +export async function saveNoteImagesFromFiles( + noteId: string, + files: File[], +): Promise { + return saveNoteImageAttachments(noteId, await attachmentsFromFiles(files)); +} + +export async function saveNoteImagesFromPaths( + noteId: string, + paths: string[], +): Promise { + return saveNoteImageAttachments(noteId, await attachmentsFromPaths(paths)); +} + +async function saveNoteImageAttachments( + noteId: string, + attachments: Attachment[], +): Promise { + const images = attachments.filter((file) => file.kind === "image"); + if (images.length === 0) { + throw new Error("Drop a PNG, JPG, GIF, WebP, or SVG image."); + } + + const saved: NoteImageAsset[] = []; + let failure: unknown; + try { + for (const image of images) { + let sourcePath = image.path; + let temporary = false; + if (!sourcePath && image.data) { + sourcePath = await invoke("write_attachment", { + name: image.name, + data: image.data, + }); + temporary = true; + } + if (!sourcePath) continue; + try { + saved.push( + await invoke("notes_save_image", { + noteId, + sourcePath, + }), + ); + } catch (err: unknown) { + failure ??= err; + } finally { + if (temporary) { + await invoke("delete_path", { path: sourcePath }).catch( + () => undefined, + ); + } + } + } + } finally { + for (const image of images) revokeAttachment(image); + } + + if (saved.length === 0) { + if (failure instanceof Error) throw failure; + if (failure) throw new Error(String(failure)); + throw new Error("None of the dropped images could be added to the note."); + } + return saved; +} + +export function noteImageMarkdown(image: NoteImageAsset): string { + const alt = image.name + .replace(/[\r\n]+/g, " ") + .replace(/\\/g, "\\\\") + .replace(/([\[\]])/g, "\\$1"); + return `![${alt}](${image.markdownPath})`; +} + +export function insertNoteImagesMarkdown( + value: string, + start: number, + end: number, + images: NoteImageAsset[], +): MarkdownInsertion { + if (images.length === 0) { + const cursor = Math.max(0, Math.min(start, value.length)); + return { value, cursor }; + } + + const from = Math.max(0, Math.min(start, value.length)); + const to = Math.max(from, Math.min(end, value.length)); + const before = value.slice(0, from); + const after = value.slice(to); + const block = images.map(noteImageMarkdown).join("\n\n"); + const leading = before + ? before.endsWith("\n\n") + ? "" + : before.endsWith("\n") + ? "\n" + : "\n\n" + : ""; + const trailing = after + ? after.startsWith("\n\n") + ? "" + : after.startsWith("\n") + ? "\n" + : "\n\n" + : ""; + const inserted = `${leading}${block}`; + + return { + value: `${before}${inserted}${trailing}${after}`, + cursor: before.length + inserted.length, + }; +} + +export function isNoteImagePath(value: string): boolean { + return value.startsWith(NOTE_IMAGE_PREFIX); +} diff --git a/src/lib/projectChatBackground.test.ts b/src/lib/projectChatBackground.test.ts new file mode 100644 index 00000000..d7cab8e3 --- /dev/null +++ b/src/lib/projectChatBackground.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + clearProjectChatBackgroundSetting, + loadProjectChatBackground, + saveProjectChatBackground, +} from "./projectChatBackground"; + +const KEY = "monocode:project-chat-backgrounds"; + +function mockBrowserStorage() { + const data = new Map(); + Object.defineProperty(globalThis, "localStorage", { + value: { + getItem: (key: string) => data.get(key) ?? null, + setItem: (key: string, value: string) => data.set(key, value), + removeItem: (key: string) => data.delete(key), + clear: () => data.clear(), + key: (index: number) => [...data.keys()][index] ?? null, + get length() { + return data.size; + }, + }, + configurable: true, + }); + Object.defineProperty(globalThis, "window", { + value: { dispatchEvent: () => true }, + configurable: true, + }); +} + +describe("project chat background settings", () => { + beforeEach(mockBrowserStorage); + + it("stores independent overrides for each project", () => { + saveProjectChatBackground("/work/alpha", { + path: "/backgrounds/alpha.webp", + opacity: 0.22, + scope: "empty", + }); + saveProjectChatBackground("/work/beta", { + path: "/backgrounds/beta.png", + opacity: 0.48, + scope: "all", + }); + + expect(loadProjectChatBackground("/work/alpha")).toEqual({ + path: "/backgrounds/alpha.webp", + opacity: 0.22, + scope: "empty", + }); + expect(loadProjectChatBackground("/work/beta")).toEqual({ + path: "/backgrounds/beta.png", + opacity: 0.48, + scope: "all", + }); + }); + + it("clamps visibility to the supported range", () => { + saveProjectChatBackground("/work/alpha", { + path: "/backgrounds/alpha.webp", + opacity: 1, + scope: "all", + }); + saveProjectChatBackground("/work/beta", { + path: "/backgrounds/beta.webp", + opacity: 0, + scope: "all", + }); + + expect(loadProjectChatBackground("/work/alpha")?.opacity).toBe(0.65); + expect(loadProjectChatBackground("/work/beta")?.opacity).toBe(0.05); + }); + + it("falls back safely when stored project data is malformed", () => { + localStorage.setItem( + KEY, + JSON.stringify({ + "/work/alpha": { + path: "/backgrounds/alpha.webp", + opacity: "bright", + scope: "transcript", + }, + }), + ); + + expect(loadProjectChatBackground("/work/alpha")).toEqual({ + path: "/backgrounds/alpha.webp", + opacity: 0.24, + scope: "all", + }); + }); + + it("clears one project without changing the others", () => { + saveProjectChatBackground("/work/alpha", { + path: "/backgrounds/alpha.webp", + opacity: 0.2, + scope: "empty", + }); + saveProjectChatBackground("/work/beta", { + path: "/backgrounds/beta.webp", + opacity: 0.3, + scope: "all", + }); + + clearProjectChatBackgroundSetting("/work/alpha"); + + expect(loadProjectChatBackground("/work/alpha")).toBeNull(); + expect(loadProjectChatBackground("/work/beta")?.path).toBe( + "/backgrounds/beta.webp", + ); + }); +}); diff --git a/src/lib/projectChatBackground.ts b/src/lib/projectChatBackground.ts new file mode 100644 index 00000000..a8a5b300 --- /dev/null +++ b/src/lib/projectChatBackground.ts @@ -0,0 +1,113 @@ +import { + CHAT_BACKGROUND_OPACITY_MAX, + CHAT_BACKGROUND_OPACITY_MIN, + CHAT_BACKGROUND_SCOPE_DEFAULT, + loadChatBackgroundOpacity, + loadChatBackgroundScope, + type ChatBackgroundScope, +} from "./appearance"; + +const KEY = "monocode:project-chat-backgrounds"; + +export const PROJECT_CHAT_BACKGROUND_CHANGED = + "monocode:project-chat-background-changed"; + +export type ProjectChatBackground = { + path: string; + opacity: number; + scope: ChatBackgroundScope; +}; + +type StoredProjectChatBackground = Partial; + +let revision = Date.now(); + +function clampOpacity(value: number): number { + return Math.min( + CHAT_BACKGROUND_OPACITY_MAX, + Math.max(CHAT_BACKGROUND_OPACITY_MIN, value), + ); +} + +function read(): Record { + try { + const raw = localStorage.getItem(KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw) as unknown; + return parsed && typeof parsed === "object" + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +function write(value: Record) { + try { + localStorage.setItem(KEY, JSON.stringify(value)); + } catch { + // private mode / quota + } +} + +function validScope(value: unknown): value is ChatBackgroundScope { + return value === "empty" || value === "all"; +} + +export function loadProjectChatBackground( + project: string, +): ProjectChatBackground | null { + const stored = read()[project]; + const path = typeof stored?.path === "string" ? stored.path.trim() : ""; + if (!path) return null; + const opacity = + typeof stored.opacity === "number" && Number.isFinite(stored.opacity) + ? clampOpacity(stored.opacity) + : loadChatBackgroundOpacity(); + return { + path, + opacity, + scope: validScope(stored.scope) ? stored.scope : loadChatBackgroundScope(), + }; +} + +export function saveProjectChatBackground( + project: string, + value: ProjectChatBackground, +) { + const path = value.path.trim(); + if (!project || !path) return; + const next = read(); + next[project] = { + path, + opacity: clampOpacity(value.opacity), + scope: validScope(value.scope) + ? value.scope + : CHAT_BACKGROUND_SCOPE_DEFAULT, + }; + write(next); + notifyProjectChatBackgroundChanged(); +} + +export function clearProjectChatBackgroundSetting(project: string) { + const next = read(); + if (!(project in next)) return; + delete next[project]; + write(next); + notifyProjectChatBackgroundChanged(); +} + +export function notifyProjectChatBackgroundChanged() { + revision += 1; + window.dispatchEvent(new CustomEvent(PROJECT_CHAT_BACKGROUND_CHANGED)); +} + +export function projectChatBackgroundRevision(): number { + return revision; +} + +export function subscribeProjectChatBackground(listener: () => void) { + window.addEventListener(PROJECT_CHAT_BACKGROUND_CHANGED, listener); + return () => + window.removeEventListener(PROJECT_CHAT_BACKGROUND_CHANGED, listener); +} diff --git a/src/lib/projectData.ts b/src/lib/projectData.ts index cccfef0f..9b42f668 100644 --- a/src/lib/projectData.ts +++ b/src/lib/projectData.ts @@ -1,5 +1,7 @@ import { projectKey } from "./paths"; import { clearProjectLogo } from "./projectLogos"; +import { clearProjectChatBackground } from "./chatBackground"; +import { clearProjectChatBackgroundSetting } from "./projectChatBackground"; import { normalizeProjectPath } from "./recents"; import { deleteSession, listSessionsByProject } from "./sessionStore"; import { clearTabGroupSettings } from "./tabGroups"; @@ -20,5 +22,7 @@ export async function removeProjectData(path: string): Promise { } // Drops the copied image from app data; the localStorage entry goes with it. await clearProjectLogo(key).catch(() => undefined); + await clearProjectChatBackground(key).catch(() => undefined); + clearProjectChatBackgroundSetting(key); clearTabGroupSettings(key); } diff --git a/src/lib/promptOutline.test.ts b/src/lib/promptOutline.test.ts new file mode 100644 index 00000000..c5ef0e4e --- /dev/null +++ b/src/lib/promptOutline.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; +import { + activePromptId, + barLift, + previewLines, + promptPreview, +} from "./promptOutline"; +import type { Block } from "./session"; + +const viewport = { top: 100, bottom: 500 }; + +function anchor(id: string, top: number, bottom: number) { + return { id, top, bottom }; +} + +describe("activePromptId", () => { + it("returns null with no anchors", () => { + expect(activePromptId(viewport, [])).toBeNull(); + }); + + it("picks the topmost prompt inside the viewport", () => { + const anchors = [ + anchor("a", 0, 40), + anchor("b", 150, 190), + anchor("c", 300, 340), + anchor("d", 600, 640), + ]; + expect(activePromptId(viewport, anchors)).toBe("b"); + }); + + it("counts a prompt cut by the viewport top as inside", () => { + const anchors = [anchor("a", 80, 120), anchor("b", 200, 240)]; + expect(activePromptId(viewport, anchors)).toBe("a"); + }); + + it("lets a prompt that peeks in at the bottom win over the reply above", () => { + const anchors = [anchor("a", 0, 40), anchor("b", 480, 520)]; + expect(activePromptId(viewport, anchors)).toBe("b"); + }); + + it("falls back to the last prompt above the viewport", () => { + const anchors = [ + anchor("a", 0, 20), + anchor("b", 40, 60), + anchor("c", 600, 640), + ]; + expect(activePromptId(viewport, anchors)).toBe("b"); + }); + + it("treats a prompt that ends at the viewport top as above", () => { + const anchors = [anchor("a", 60, 100), anchor("b", 700, 740)]; + expect(activePromptId(viewport, anchors)).toBe("a"); + }); + + it("falls back to the first prompt when all sit below", () => { + const anchors = [anchor("a", 600, 640), anchor("b", 700, 740)]; + expect(activePromptId(viewport, anchors)).toBe("a"); + }); + + it("marks the last prompt at the end of the transcript", () => { + const anchors = [ + anchor("a", 120, 160), + anchor("b", 260, 300), + anchor("c", 400, 440), + ]; + expect(activePromptId(viewport, anchors, 0)).toBe("c"); + expect(activePromptId(viewport, anchors, 16)).toBe("c"); + }); + + it("keeps the topmost visible prompt while there is room to scroll", () => { + const anchors = [anchor("a", 120, 160), anchor("b", 400, 440)]; + expect(activePromptId(viewport, anchors, 17)).toBe("a"); + }); +}); + +describe("barLift", () => { + it("is flat with no hovered bar", () => { + expect(barLift(3, null)).toBe(0); + expect(barLift(3, -1)).toBe(0); + }); + + it("peaks on the hovered bar and tapers over the ripple span", () => { + expect(barLift(3, 3)).toBe(1); + expect(barLift(2, 3)).toBeCloseTo(2 / 3); + expect(barLift(5, 3)).toBeCloseTo(1 / 3); + expect(barLift(6, 3)).toBe(0); + }); +}); + +describe("previewLines", () => { + it("strips markers and collapses blank lines", () => { + const text = "# Heading\n\n- **first** point\n\n2) second point\n"; + expect(previewLines(text, 2)).toEqual(["Heading", "first point"]); + }); + + it("skips fenced code and rules", () => { + const text = "---\n```ts\nconst a = 1;\n```\nAfter the code."; + expect(previewLines(text, 2)).toEqual(["After the code."]); + }); + + it("stops at the line budget", () => { + expect(previewLines("a\nb\nc", 2)).toEqual(["a", "b"]); + }); +}); + +describe("promptPreview", () => { + const block = (id: string, role: Block["role"], text: string): Block => ({ + id, + role, + text, + }); + + it("pairs a prompt with the head of its reply", () => { + const blocks = [ + block("u1", "user", "First ask"), + block("t1", "tool", "ran something"), + block("a1", "assistant", "Yes.\nBuild the control plane."), + block("u2", "user", "Second ask"), + block("a2", "assistant", "Later reply"), + ]; + expect(promptPreview(blocks, "u1")).toEqual({ + title: "First ask", + reply: "Yes.", + detail: "Build the control plane.", + }); + }); + + it("leaves the reply out when the turn has none yet", () => { + const blocks = [block("u1", "user", "Only ask")]; + expect(promptPreview(blocks, "u1")).toEqual({ + title: "Only ask", + reply: undefined, + detail: undefined, + }); + }); + + it("returns null for an unknown prompt", () => { + expect(promptPreview([], "missing")).toBeNull(); + }); +}); diff --git a/src/lib/promptOutline.ts b/src/lib/promptOutline.ts new file mode 100644 index 00000000..00b50559 --- /dev/null +++ b/src/lib/promptOutline.ts @@ -0,0 +1,141 @@ +import type { Block } from "./session"; + +/** A vertical span in viewport coordinates. */ +export type OutlineBand = { top: number; bottom: number }; + +export type OutlineAnchor = OutlineBand & { id: string }; + +export const NEAR_END_PX = 16; + +export function promptBlocks(blocks: Block[]): Block[] { + return blocks.filter((block) => block.role === "user"); +} + +/** + * Selects the topmost prompt inside the viewport. With no prompt inside, + * selects the last prompt above the viewport, else the first prompt. Near the + * end of the transcript, selects the last prompt: the prompts on the final + * screen can not reach the top. + */ +export function activePromptId( + viewport: OutlineBand, + anchors: OutlineAnchor[], + distanceToEnd = Number.POSITIVE_INFINITY, +): string | null { + if (anchors.length === 0) return null; + if (distanceToEnd <= NEAR_END_PX) return anchors[anchors.length - 1].id; + const inside = anchors.find( + (anchor) => anchor.bottom > viewport.top && anchor.top < viewport.bottom, + ); + if (inside) return inside.id; + let above: OutlineAnchor | undefined; + for (const anchor of anchors) { + if (anchor.bottom <= viewport.top) above = anchor; + } + return (above ?? anchors[0]).id; +} + +/** Selects at most `max` prompts. The window slides to keep the active prompt inside. It prefers the newest prompts. */ +export function barWindow( + count: number, + activeIndex: number | null, + max: number, +): { start: number; end: number } { + if (count <= max) return { start: 0, end: count }; + const newest = count - max; + const start = + activeIndex == null ? newest : Math.max(0, Math.min(newest, activeIndex)); + return { start, end: start + max }; +} + +export function promptLabel(block: Block): string { + const card = block.secondOpinion; + const textShown = !card || card.kind === "handoff"; + const text = textShown ? firstLine(block.text) : ""; + if (text) return text; + if (card) { + if (card.kind === "handoff") return "Handoff"; + const request = firstLine(card.request ?? ""); + return request ? `Second opinion: ${request}` : "Second opinion"; + } + if (block.noteCard?.title) return block.noteCard.title; + const files = block.attachments ?? []; + if (files.length > 0) { + const [first] = files; + return files.length > 1 ? `${first.name} +${files.length - 1}` : first.name; + } + return "Empty message"; +} + +function firstLine(text: string): string { + const line = text + .split(/\r?\n/) + .map((part) => part.trim()) + .find(Boolean); + return (line ?? "").replace(/\s+/g, " "); +} + +/** How far the hover ripple reaches, in bars on each side. */ +export const RIPPLE_SPAN = 2; + +/** Dock-style magnification: 1 on the hovered bar, tapering to 0 past the ripple span. */ +export function barLift(index: number, hoverIndex: number | null): number { + if (hoverIndex == null || hoverIndex < 0) return 0; + const distance = Math.abs(index - hoverIndex); + if (distance > RIPPLE_SPAN) return 0; + return (RIPPLE_SPAN + 1 - distance) / (RIPPLE_SPAN + 1); +} + +/** The prompt and the head of its reply, shown while a bar is hovered. */ +export type PromptPreview = { + title: string; + reply?: string; + detail?: string; +}; + +const REPLY_SCAN_CHARS = 2000; + +export function promptPreview( + blocks: Block[], + promptId: string, +): PromptPreview | null { + const index = blocks.findIndex((block) => block.id === promptId); + if (index < 0) return null; + let reply: string[] = []; + for (let i = index + 1; i < blocks.length; i += 1) { + const block = blocks[i]; + if (block.role === "user") break; + if (block.role !== "assistant") continue; + reply = previewLines(block.text, 2); + if (reply.length > 0) break; + } + return { + title: promptLabel(blocks[index]), + reply: reply[0], + detail: reply[1], + }; +} + +/** The first `max` prose lines of a reply. Markers, fenced code and rules drop out. */ +export function previewLines(text: string, max: number): string[] { + const lines: string[] = []; + let fenced = false; + for (const raw of text.slice(0, REPLY_SCAN_CHARS).split(/\r?\n/)) { + const line = raw.trim(); + if (line.startsWith("```")) { + fenced = !fenced; + continue; + } + if (fenced) continue; + const plain = line + .replace(/^(?:[#>]+|[-*+]|\d+[.)])\s+/, "") + .replace(/\*\*|`/g, "") + .replace(/\s+/g, " ") + .trim(); + // Rules, table separators and lone punctuation read as noise in a preview. + if (!/[\p{L}\p{N}]/u.test(plain)) continue; + lines.push(plain); + if (lines.length === max) break; + } + return lines; +} diff --git a/src/lib/rateLimits.test.ts b/src/lib/rateLimits.test.ts index 86fe2e4c..5a77a00a 100644 --- a/src/lib/rateLimits.test.ts +++ b/src/lib/rateLimits.test.ts @@ -7,15 +7,20 @@ import { formatUsagePercent, formatWindowLabel, idleRateLimits, + isRateLimitProvider, isRateLimitSnapshotStale, mapUsageWindow, parseClaudeOAuthUsage, parseCodexRateLimits, + parseCursorUsageSummary, + parseGrokBilling, parseResetTimestamp, RATE_LIMIT_MIN_REFETCH_MS, rateLimitWindowTooltip, + sharedWindowResetLabel, shouldFetchProvider, shouldFetchRateLimits, + usageFooterProviders, } from "./rateLimits"; describe("formatWindowLabel", () => { @@ -82,6 +87,20 @@ describe("formatRateLimitWindowChipLabel", () => { ), ).toBe("wk"); }); + + it("prefers an explicit chip label over remaining time", () => { + expect( + formatRateLimitWindowChipLabel( + { + usedPercent: 32, + windowMinutes: 44_640, + resetsAt: now + 6 * 86_400_000, + chipLabel: "Auto", + }, + now, + ), + ).toBe("Auto"); + }); }); describe("formatUsagePercent", () => { @@ -194,6 +213,63 @@ describe("rateLimitWindowTooltip", () => { ), ).toBe("42% used · Resets in 2h 33m"); }); + + it("prefixes Cursor lane labels", () => { + const now = Date.parse("2026-08-27T08:00:00Z"); + expect( + rateLimitWindowTooltip( + { + usedPercent: 31.9, + windowMinutes: 44_640, + resetsAt: now + 6 * 86_400_000, + chipLabel: "Auto", + }, + now, + ), + ).toBe("Auto · 32% used · Resets in 6d"); + }); +}); + +describe("sharedWindowResetLabel", () => { + const now = Date.parse("2026-08-27T08:00:00Z"); + const resetsAt = now + 6 * 86_400_000; + + it("appends one countdown when labeled lanes share a reset", () => { + expect( + sharedWindowResetLabel( + [ + { + usedPercent: 32, + windowMinutes: 44_640, + resetsAt, + chipLabel: "Auto", + }, + { + usedPercent: 45, + windowMinutes: 44_640, + resetsAt, + chipLabel: "API", + }, + ], + now, + ), + ).toBe("6d"); + }); + + it("stays off when the chip already shows remaining time", () => { + expect( + sharedWindowResetLabel( + [ + { + usedPercent: 32, + windowMinutes: 300, + resetsAt, + }, + ], + now, + ), + ).toBeNull(); + }); }); describe("shouldFetchRateLimits", () => { @@ -311,3 +387,191 @@ describe("shouldFetchRateLimits", () => { ).toBe(true); }); }); + +describe("parseCursorUsageSummary", () => { + const cycle = { + billingCycleStart: "2026-08-14T12:56:47.000Z", + billingCycleEnd: "2026-09-14T12:56:47.000Z", + }; + const windowMinutes = Math.round( + (Date.parse(cycle.billingCycleEnd) - Date.parse(cycle.billingCycleStart)) / + 60_000, + ); + + it("maps Auto and API pools when both percents are present", () => { + const limits = parseCursorUsageSummary( + JSON.stringify({ + ...cycle, + individualUsage: { + plan: { + used: 40000, + limit: 40000, + autoPercentUsed: 31.96, + apiPercentUsed: 44.67, + totalPercentUsed: 33.77, + }, + }, + }), + ); + expect(limits.status).toBe("ok"); + expect(limits.provider).toBe("cursor"); + expect(limits.session).toEqual({ + usedPercent: 31.96, + windowMinutes, + resetsAt: Date.parse(cycle.billingCycleEnd), + chipLabel: "Auto", + }); + expect(limits.weekly).toEqual({ + usedPercent: 44.67, + windowMinutes, + resetsAt: Date.parse(cycle.billingCycleEnd), + chipLabel: "API", + }); + }); + + it("falls back to total plan percent when Auto/API are missing", () => { + const limits = parseCursorUsageSummary( + JSON.stringify({ + ...cycle, + individualUsage: { + plan: { totalPercentUsed: 18.2 }, + }, + }), + ); + expect(limits.session).toEqual({ + usedPercent: 18.2, + windowMinutes, + resetsAt: Date.parse(cycle.billingCycleEnd), + }); + expect(limits.weekly).toBeNull(); + }); + + it("uses used/limit when Cursor omits percent fields", () => { + const limits = parseCursorUsageSummary( + JSON.stringify({ + ...cycle, + individualUsage: { + overall: { used: 25, limit: 100 }, + }, + }), + ); + expect(limits.session?.usedPercent).toBe(25); + expect(limits.weekly).toBeNull(); + }); + + it("returns an error for garbage", () => { + const limits = parseCursorUsageSummary("not json"); + expect(limits.status).toBe("error"); + expect(limits.session).toBeNull(); + }); +}); + +describe("parseGrokBilling", () => { + const cycle = { + type: "USAGE_PERIOD_TYPE_WEEKLY", + start: "2026-09-03T16:48:05.298690+00:00", + end: "2026-09-10T16:48:05.298690+00:00", + }; + + it("maps credit usage and the weekly billing period", () => { + const limits = parseGrokBilling( + JSON.stringify({ + config: { + currentPeriod: cycle, + creditUsagePercent: 19, + productUsage: [ + { product: "GrokBuild", usagePercent: 17 }, + { product: "GrokChat", usagePercent: 2 }, + ], + }, + }), + ); + expect(limits.status).toBe("ok"); + expect(limits.provider).toBe("grok"); + expect(limits.session).toEqual({ + usedPercent: 19, + windowMinutes: 10_080, + resetsAt: Date.parse(cycle.end), + }); + expect(limits.weekly).toBeNull(); + }); + + it("falls back to GrokBuild when the headline percent is missing", () => { + const limits = parseGrokBilling( + JSON.stringify({ + config: { + currentPeriod: cycle, + productUsage: [{ product: "GrokBuild", usagePercent: 17 }], + }, + }), + ); + expect(limits.session?.usedPercent).toBe(17); + }); + + it("returns an error for garbage", () => { + const limits = parseGrokBilling("not json"); + expect(limits.status).toBe("error"); + expect(limits.session).toBeNull(); + }); +}); + +describe("usageFooterProviders", () => { + it("mirrors the active session by default", () => { + expect( + usageFooterProviders({ activeHarness: "claude", alwaysShow: false }), + ).toEqual(["claude"]); + expect( + usageFooterProviders({ activeHarness: "codex", alwaysShow: false }), + ).toEqual(["codex"]); + expect( + usageFooterProviders({ activeHarness: "cursor", alwaysShow: false }), + ).toEqual(["cursor"]); + expect( + usageFooterProviders({ activeHarness: "grok", alwaysShow: false }), + ).toEqual(["grok"]); + }); + + it("shows nothing by default for a provider without usage data", () => { + expect( + usageFooterProviders({ activeHarness: "opencode", alwaysShow: false }), + ).toEqual([]); + expect( + usageFooterProviders({ activeHarness: undefined, alwaysShow: false }), + ).toEqual([]); + expect( + usageFooterProviders({ activeHarness: null, alwaysShow: false }), + ).toEqual([]); + }); + + it("pins the full roster once the setting is on", () => { + expect( + usageFooterProviders({ activeHarness: "opencode", alwaysShow: true }), + ).toEqual(["claude", "codex", "cursor", "grok"]); + expect( + usageFooterProviders({ activeHarness: "claude", alwaysShow: true }), + ).toEqual(["claude", "codex", "cursor", "grok"]); + expect( + usageFooterProviders({ activeHarness: undefined, alwaysShow: true }), + ).toEqual(["claude", "codex", "cursor", "grok"]); + }); + + it("hands back a fresh array so callers cannot mutate the roster", () => { + const first = usageFooterProviders({ alwaysShow: true }); + first.pop(); + expect(usageFooterProviders({ alwaysShow: true })).toEqual([ + "claude", + "codex", + "cursor", + "grok", + ]); + }); + + it("recognises only the providers we can actually poll", () => { + expect(isRateLimitProvider("claude")).toBe(true); + expect(isRateLimitProvider("codex")).toBe(true); + expect(isRateLimitProvider("cursor")).toBe(true); + expect(isRateLimitProvider("grok")).toBe(true); + expect(isRateLimitProvider("opencode")).toBe(false); + expect(isRateLimitProvider(undefined)).toBe(false); + }); +}); diff --git a/src/lib/rateLimits.ts b/src/lib/rateLimits.ts index f242dea4..9099e773 100644 --- a/src/lib/rateLimits.ts +++ b/src/lib/rateLimits.ts @@ -1,6 +1,45 @@ import { asRecord } from "./harness/codexProtocol"; -export type RateLimitProvider = "claude" | "codex"; +export type RateLimitProvider = "claude" | "codex" | "cursor" | "grok"; + +/** + * Every provider that reports usage from a supported source: Claude Code over + * its OAuth usage endpoint, Codex over `account/rateLimits/read`, Cursor over + * cursor.com `usage-summary`, Grok over the CLI billing credits API. Ordered + * the way the footer renders them. + */ +export const RATE_LIMIT_PROVIDERS: RateLimitProvider[] = [ + "claude", + "codex", + "cursor", + "grok", +]; + +export function isRateLimitProvider( + value: unknown, +): value is RateLimitProvider { + return ( + value === "claude" || + value === "codex" || + value === "cursor" || + value === "grok" + ); +} + +/** + * Which chips the usage footer renders. By default the footer mirrors the + * active session, so it goes quiet the moment you switch to a provider we + * cannot report usage for. `alwaysShow` pins the whole roster instead, so an + * OpenCode (or any other) session no longer hides Claude, Codex, Cursor, and + * Grok. + */ +export function usageFooterProviders(input: { + activeHarness?: string | null; + alwaysShow: boolean; +}): RateLimitProvider[] { + if (input.alwaysShow) return [...RATE_LIMIT_PROVIDERS]; + return isRateLimitProvider(input.activeHarness) ? [input.activeHarness] : []; +} export type RateLimitStatus = "idle" | "fetching" | "ok" | "error" | "unavailable"; @@ -12,6 +51,8 @@ export type RateLimitWindow = { windowMinutes: number; /** Unix ms timestamp when the window resets, if known. */ resetsAt: number | null; + /** Compact chip suffix when remaining time would be ambiguous (Cursor Auto/API). */ + chipLabel?: string; }; export type ProviderRateLimits = { @@ -57,11 +98,15 @@ export function shouldFetchRateLimits(input: { visible: boolean; claude: ProviderRateLimits; codex: ProviderRateLimits; + cursor?: ProviderRateLimits; + grok?: ProviderRateLimits; now?: number; }): boolean { return ( shouldFetchProvider(input.claude, input) || - shouldFetchProvider(input.codex, input) + shouldFetchProvider(input.codex, input) || + (input.cursor != null && shouldFetchProvider(input.cursor, input)) || + (input.grok != null && shouldFetchProvider(input.grok, input)) ); } @@ -193,21 +238,41 @@ export function formatRateLimitWindowChipLabel( window: RateLimitWindow, now = Date.now(), ): string { + if (window.chipLabel) return window.chipLabel; if (window.resetsAt != null) { return formatResetDuration(window.resetsAt - now); } return formatWindowLabel(window.windowMinutes); } +/** + * Cursor Auto/API share one billing-cycle reset. The per-window chip label + * already names the lane, so show the countdown once at the end instead of + * repeating it on every percent. + */ +export function sharedWindowResetLabel( + windows: RateLimitWindow[], + now = Date.now(), +): string | null { + if (windows.length === 0 || !windows.every((window) => window.chipLabel)) { + return null; + } + const resetsAt = windows[0]?.resetsAt; + if (resetsAt == null) return null; + if (windows.some((window) => window.resetsAt !== resetsAt)) return null; + return formatResetDuration(resetsAt - now); +} + export function rateLimitWindowTooltip( window: RateLimitWindow, now = Date.now(), ): string { const used = `${formatUsagePercent(window.usedPercent)} used`; + const labeled = window.chipLabel ? `${window.chipLabel} · ${used}` : used; if (window.resetsAt == null) { - return `${used} · ${formatWindowLabel(window.windowMinutes)} window`; + return `${labeled} · ${formatWindowLabel(window.windowMinutes)} window`; } - return `${used} · ${formatResetCountdown(window.resetsAt - now)}`; + return `${labeled} · ${formatResetCountdown(window.resetsAt - now)}`; } export function parseResetTimestamp(value: unknown): number | null { @@ -283,6 +348,183 @@ type CodexWindowSnapshot = { resetsAt: unknown; }; +export function parseCursorUsageSummary(body: string): ProviderRateLimits { + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + return errorRateLimits("cursor", "Cursor usage response was not JSON"); + } + const rec = asRecord(parsed); + if (!rec) { + return errorRateLimits("cursor", "Cursor usage response was empty"); + } + + const individual = asRecord(rec.individualUsage); + const team = asRecord(rec.teamUsage); + const plan = asRecord(individual?.plan); + const overall = asRecord(individual?.overall); + const pooled = asRecord(team?.pooled); + const autoPercent = optionalPercent(plan, "autoPercentUsed"); + const apiPercent = optionalPercent(plan, "apiPercentUsed"); + const totalPercent = optionalPercent(plan, "totalPercentUsed"); + const planPercent = + totalPercent ?? + averagePercent(autoPercent, apiPercent) ?? + apiPercent ?? + autoPercent ?? + ratioPercent(plan) ?? + ratioPercent(overall) ?? + ratioPercent(pooled); + const resetsAt = + parseResetTimestamp(rec.billingCycleEnd) ?? + parseResetTimestamp(rec.billing_cycle_end); + const startedAt = + parseResetTimestamp(rec.billingCycleStart) ?? + parseResetTimestamp(rec.billing_cycle_start); + const windowMinutes = + startedAt != null && resetsAt != null && resetsAt > startedAt + ? Math.max(1, Math.round((resetsAt - startedAt) / 60_000)) + : 30 * 24 * 60; + + if (autoPercent == null && apiPercent == null && planPercent == null) { + return errorRateLimits("cursor", "No Cursor usage data"); + } + + const labeled = autoPercent != null && apiPercent != null; + return { + provider: "cursor", + session: labeled + ? cursorWindow(autoPercent, windowMinutes, resetsAt, "Auto") + : cursorWindow(planPercent ?? autoPercent ?? apiPercent, windowMinutes, resetsAt), + weekly: labeled + ? cursorWindow(apiPercent, windowMinutes, resetsAt, "API") + : null, + updatedAt: Date.now(), + error: null, + status: "ok", + }; +} + +function cursorWindow( + usedPercent: number | null | undefined, + windowMinutes: number, + resetsAt: number | null, + chipLabel?: string, +): RateLimitWindow | null { + if (usedPercent == null) return null; + return { + usedPercent: clampUsedPercent(usedPercent), + windowMinutes, + resetsAt, + ...(chipLabel ? { chipLabel } : {}), + }; +} + +function optionalPercent( + rec: Record | null, + key: string, +): number | null { + if (!rec) return null; + const value = numberField(rec, key); + return value == null ? null : clampUsedPercent(value); +} + +function averagePercent(left: number | null, right: number | null): number | null { + if (left == null || right == null) return null; + return clampUsedPercent((left + right) / 2); +} + +function ratioPercent(rec: Record | null): number | null { + if (!rec) return null; + const used = numberField(rec, "used"); + const limit = numberField(rec, "limit"); + if (used == null || limit == null || limit <= 0) return null; + return clampUsedPercent((used / limit) * 100); +} + +export function parseGrokBilling(body: string): ProviderRateLimits { + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + return errorRateLimits("grok", "Grok usage response was not JSON"); + } + const rec = asRecord(parsed); + const config = asRecord(rec?.config) ?? rec; + if (!config) { + return errorRateLimits("grok", "Grok usage response was empty"); + } + + const period = asRecord(config.currentPeriod); + const usedPercent = + optionalPercent(config, "creditUsagePercent") ?? + grokProductPercent(config, "GrokBuild") ?? + grokOnDemandPercent(config); + const resetsAt = + parseResetTimestamp(period?.end) ?? + parseResetTimestamp(config.billingPeriodEnd); + const startedAt = + parseResetTimestamp(period?.start) ?? + parseResetTimestamp(config.billingPeriodStart); + const periodType = typeof period?.type === "string" ? period.type : ""; + const windowMinutes = + startedAt != null && resetsAt != null && resetsAt > startedAt + ? Math.max(1, Math.round((resetsAt - startedAt) / 60_000)) + : /weekly/i.test(periodType) + ? WEEKLY_WINDOW_MINUTES + : 30 * 24 * 60; + + if (usedPercent == null) { + return errorRateLimits("grok", "No Grok usage data"); + } + return { + provider: "grok", + session: { + usedPercent, + windowMinutes, + resetsAt, + }, + weekly: null, + updatedAt: Date.now(), + error: null, + status: "ok", + }; +} + +function grokProductPercent( + config: Record, + product: string, +): number | null { + const products = config.productUsage; + if (!Array.isArray(products)) return null; + for (const item of products) { + const rec = asRecord(item); + if (rec?.product !== product) continue; + return optionalPercent(rec, "usagePercent"); + } + return null; +} + +function grokOnDemandPercent( + config: Record, +): number | null { + const used = nestedNumber(config, "onDemandUsed"); + const cap = nestedNumber(config, "onDemandCap"); + if (used == null || cap == null || cap <= 0) return null; + return clampUsedPercent((used / cap) * 100); +} + +function nestedNumber( + rec: Record, + key: string, +): number | null { + const direct = numberField(rec, key); + if (direct != null) return direct; + const nested = asRecord(rec[key]); + return nested ? numberField(nested, "val") : null; +} + export function parseCodexRateLimits(result: unknown): ProviderRateLimits { const rec = asRecord(result); const wrapper = asRecord(rec?.rateLimits) ?? rec; diff --git a/src/lib/rateLimitsFetch.ts b/src/lib/rateLimitsFetch.ts index 275f7526..16eb007b 100644 --- a/src/lib/rateLimitsFetch.ts +++ b/src/lib/rateLimitsFetch.ts @@ -4,8 +4,11 @@ import { errorRateLimits, parseClaudeOAuthUsage, parseCodexRateLimits, + parseCursorUsageSummary, + parseGrokBilling, unavailableRateLimits, type ProviderRateLimits, + type RateLimitProvider, } from "./rateLimits"; import { killChild, @@ -21,18 +24,26 @@ const USAGE_CHILD_ID = "monocode-codex-usage"; const DISCOVERY_TIMEOUT_MS = 15_000; const REQUEST_TIMEOUT_MS = 12_000; -type ClaudeUsageFetch = { +type UsageFetch = { status: "ok" | "error" | "unavailable" | string; httpStatus?: number | null; body?: string | null; error?: string | null; }; -export async function fetchClaudeRateLimits(): Promise { +type InvokeUsageProvider = Exclude; + +async function fetchInvokeRateLimits( + command: string, + provider: InvokeUsageProvider, + parse: (body: string) => ProviderRateLimits, + unavailableMessage: string, + errorMessage: string, +): Promise { try { - const result = await invoke("fetch_claude_usage"); + const result = await invoke(command); if (result.status === "ok" && result.body) { - const parsed = parseClaudeOAuthUsage(result.body); + const parsed = parse(result.body); if (parsed.session || parsed.weekly) return parsed; return { ...parsed, @@ -41,22 +52,49 @@ export async function fetchClaudeRateLimits(): Promise { } if (result.status === "unavailable") { return unavailableRateLimits( - "claude", - result.error?.trim() || "Claude not signed in", + provider, + result.error?.trim() || unavailableMessage, ); } - return errorRateLimits( - "claude", - result.error?.trim() || "Claude usage unavailable", - ); + return errorRateLimits(provider, result.error?.trim() || errorMessage); } catch (error) { return errorRateLimits( - "claude", - error instanceof Error ? error.message : "Claude usage unavailable", + provider, + error instanceof Error ? error.message : errorMessage, ); } } +export async function fetchClaudeRateLimits(): Promise { + return fetchInvokeRateLimits( + "fetch_claude_usage", + "claude", + parseClaudeOAuthUsage, + "Claude not signed in", + "Claude usage unavailable", + ); +} + +export async function fetchCursorRateLimits(): Promise { + return fetchInvokeRateLimits( + "fetch_cursor_usage", + "cursor", + parseCursorUsageSummary, + "Cursor not signed in", + "Cursor usage unavailable", + ); +} + +export async function fetchGrokRateLimits(): Promise { + return fetchInvokeRateLimits( + "fetch_grok_usage", + "grok", + parseGrokBilling, + "Grok not signed in", + "Grok usage unavailable", + ); +} + export async function fetchCodexRateLimits(): Promise { let path: string; try { diff --git a/src/lib/rateLimitsStore.test.ts b/src/lib/rateLimitsStore.test.ts new file mode 100644 index 00000000..f237b29f --- /dev/null +++ b/src/lib/rateLimitsStore.test.ts @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + RATE_LIMITS_CACHE_KEY, + RATE_LIMITS_LOCK_KEY, + getRateLimitsSnapshot, + refreshRateLimits, + resetRateLimitsStoreForTests, + setRateLimitFetchersForTests, + setRateLimitProviders, + subscribeRateLimits, +} from "./rateLimitsStore"; +import { idleRateLimits, type ProviderRateLimits } from "./rateLimits"; + +function mockLocalStorage() { + const data = new Map(); + const storage = { + getItem: (key: string) => data.get(key) ?? null, + setItem: (key: string, value: string) => { + data.set(key, value); + }, + removeItem: (key: string) => { + data.delete(key); + }, + clear: () => { + data.clear(); + }, + key: (index: number) => [...data.keys()][index] ?? null, + get length() { + return data.size; + }, + }; + Object.defineProperty(globalThis, "localStorage", { + value: storage, + configurable: true, + }); +} + +function okLimits( + provider: ProviderRateLimits["provider"], + usedPercent: number, +): ProviderRateLimits { + return { + provider, + session: { usedPercent, windowMinutes: 300, resetsAt: null }, + weekly: null, + updatedAt: Date.now(), + error: null, + status: "ok", + }; +} + +describe("rateLimitsStore", () => { + beforeEach(() => { + mockLocalStorage(); + resetRateLimitsStoreForTests(); + }); + + afterEach(() => { + resetRateLimitsStoreForTests(); + }); + + it("keeps the cache across unsubscribe so remounts do not refetch", async () => { + const claude = vi.fn(async () => okLimits("claude", 12)); + setRateLimitFetchersForTests({ + claude, + codex: vi.fn(async () => idleRateLimits("codex")), + cursor: vi.fn(async () => idleRateLimits("cursor")), + grok: vi.fn(async () => idleRateLimits("grok")), + }); + const stop = subscribeRateLimits(() => undefined); + setRateLimitProviders(["claude"]); + await refreshRateLimits(); + expect(claude).toHaveBeenCalledTimes(1); + expect(getRateLimitsSnapshot().claude.session?.usedPercent).toBe(12); + stop(); + + const stopAgain = subscribeRateLimits(() => undefined); + setRateLimitProviders(["claude"]); + await refreshRateLimits(); + expect(claude).toHaveBeenCalledTimes(1); + expect(getRateLimitsSnapshot().claude.session?.usedPercent).toBe(12); + stopAgain(); + }); + + it("queues a forced refresh after an in-flight poll", async () => { + let release: ((value: ProviderRateLimits) => void) | undefined; + const first = new Promise((resolve) => { + release = resolve; + }); + const claude = vi + .fn() + .mockImplementationOnce(() => first) + .mockImplementationOnce(async () => okLimits("claude", 99)); + setRateLimitFetchersForTests({ + claude, + codex: vi.fn(async () => idleRateLimits("codex")), + cursor: vi.fn(async () => idleRateLimits("cursor")), + grok: vi.fn(async () => idleRateLimits("grok")), + }); + subscribeRateLimits(() => undefined); + setRateLimitProviders(["claude"]); + const forced = refreshRateLimits(true); + expect(getRateLimitsSnapshot().refreshing).toBe(true); + release?.(okLimits("claude", 12)); + await forced; + expect(claude).toHaveBeenCalledTimes(2); + expect(getRateLimitsSnapshot().claude.session?.usedPercent).toBe(99); + expect(getRateLimitsSnapshot().refreshing).toBe(false); + }); + + it("skips a fetch when another window holds the lock", async () => { + const claude = vi.fn(async () => okLimits("claude", 12)); + setRateLimitFetchersForTests({ + claude, + codex: vi.fn(async () => idleRateLimits("codex")), + cursor: vi.fn(async () => idleRateLimits("cursor")), + grok: vi.fn(async () => idleRateLimits("grok")), + }); + localStorage.setItem( + RATE_LIMITS_LOCK_KEY, + JSON.stringify({ at: Date.now() }), + ); + subscribeRateLimits(() => undefined); + setRateLimitProviders(["claude"]); + await refreshRateLimits(); + expect(claude).not.toHaveBeenCalled(); + }); + + it("hydrates from a cache written by another window", () => { + localStorage.setItem( + RATE_LIMITS_CACHE_KEY, + JSON.stringify({ claude: okLimits("claude", 41) }), + ); + subscribeRateLimits(() => undefined); + expect(getRateLimitsSnapshot().claude.session?.usedPercent).toBe(41); + }); +}); diff --git a/src/lib/rateLimitsStore.ts b/src/lib/rateLimitsStore.ts new file mode 100644 index 00000000..1009e97a --- /dev/null +++ b/src/lib/rateLimitsStore.ts @@ -0,0 +1,285 @@ +import { + fetchClaudeRateLimits, + fetchCodexRateLimits, + fetchCursorRateLimits, + fetchGrokRateLimits, +} from "./rateLimitsFetch"; +import { + fetchingRateLimits, + idleRateLimits, + RATE_LIMIT_POLL_MS, + RATE_LIMIT_PROVIDERS, + shouldFetchProvider, + type ProviderRateLimits, + type RateLimitProvider, +} from "./rateLimits"; + +export const RATE_LIMITS_CACHE_KEY = "monocode.rateLimits.cache"; +export const RATE_LIMITS_LOCK_KEY = "monocode.rateLimits.lock"; +const LOCK_TTL_MS = 30_000; + +export type RateLimitSnapshot = { + claude: ProviderRateLimits; + codex: ProviderRateLimits; + cursor: ProviderRateLimits; + grok: ProviderRateLimits; + refreshing: boolean; +}; + +export type RateLimitFetcherMap = { + [K in RateLimitProvider]: () => Promise; +}; + +const defaultFetchers: RateLimitFetcherMap = { + claude: fetchClaudeRateLimits, + codex: fetchCodexRateLimits, + cursor: fetchCursorRateLimits, + grok: fetchGrokRateLimits, +}; + +let fetchers = defaultFetchers; +let snapshot = idleSnapshot(); +const listeners = new Set<() => void>(); +let wanted: RateLimitProvider[] = []; +let inflight: Promise | null = null; +let started = false; +let pollTimer: ReturnType | undefined; + +function idleSnapshot(): RateLimitSnapshot { + return { + claude: idleRateLimits("claude"), + codex: idleRateLimits("codex"), + cursor: idleRateLimits("cursor"), + grok: idleRateLimits("grok"), + refreshing: false, + }; +} + +function emit() { + for (const listener of listeners) listener(); +} + +function replace(next: RateLimitSnapshot) { + snapshot = next; + emit(); +} + +function isVisible(): boolean { + return typeof document === "undefined" || document.visibilityState !== "hidden"; +} + +function readCache(): Partial> | null { + try { + const raw = localStorage.getItem(RATE_LIMITS_CACHE_KEY); + if (!raw) return null; + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object") return null; + const rec = parsed as Record; + const out: Partial> = {}; + for (const provider of RATE_LIMIT_PROVIDERS) { + const limits = asLimits(provider, rec[provider]); + if (limits) out[provider] = limits; + } + return out; + } catch { + return null; + } +} + +function asLimits( + provider: RateLimitProvider, + value: unknown, +): ProviderRateLimits | null { + if (!value || typeof value !== "object") return null; + const rec = value as Record; + if (rec.provider !== provider || typeof rec.updatedAt !== "number") { + return null; + } + const status = rec.status; + if ( + status !== "idle" && + status !== "fetching" && + status !== "ok" && + status !== "error" && + status !== "unavailable" + ) { + return null; + } + return { + provider, + session: windowFromUnknown(rec.session), + weekly: windowFromUnknown(rec.weekly), + updatedAt: rec.updatedAt, + error: typeof rec.error === "string" ? rec.error : null, + status: status === "fetching" ? "ok" : status, + }; +} + +function windowFromUnknown( + value: unknown, +): ProviderRateLimits["session"] { + return value && typeof value === "object" + ? (value as ProviderRateLimits["session"]) + : null; +} + +function writeCache(current: RateLimitSnapshot) { + try { + const payload: Record = {}; + for (const provider of RATE_LIMIT_PROVIDERS) { + const limits = current[provider]; + if (limits.status === "idle" || limits.status === "fetching") continue; + payload[provider] = limits; + } + localStorage.setItem(RATE_LIMITS_CACHE_KEY, JSON.stringify(payload)); + } catch { + // private mode / quota + } +} + +function lockHeld(): boolean { + try { + const raw = localStorage.getItem(RATE_LIMITS_LOCK_KEY); + if (!raw) return false; + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object") return false; + const at = (parsed as { at?: unknown }).at; + return typeof at === "number" && Date.now() - at < LOCK_TTL_MS; + } catch { + return false; + } +} + +function acquireLock() { + try { + localStorage.setItem(RATE_LIMITS_LOCK_KEY, JSON.stringify({ at: Date.now() })); + } catch { + // private mode / quota + } +} + +function releaseLock() { + try { + localStorage.removeItem(RATE_LIMITS_LOCK_KEY); + } catch { + // private mode / quota + } +} + +function hydrateFromCache() { + const cached = readCache(); + if (!cached) return; + replace({ + claude: cached.claude ?? snapshot.claude, + codex: cached.codex ?? snapshot.codex, + cursor: cached.cursor ?? snapshot.cursor, + grok: cached.grok ?? snapshot.grok, + refreshing: snapshot.refreshing, + }); +} + +function onStorage(event: StorageEvent) { + if (event.key !== RATE_LIMITS_CACHE_KEY) return; + hydrateFromCache(); +} + +function onVisible() { + if (isVisible()) void refreshRateLimits(); +} + +function ensureStarted() { + if (started) return; + started = true; + hydrateFromCache(); + if (typeof window !== "undefined") { + window.addEventListener("storage", onStorage); + document.addEventListener("visibilitychange", onVisible); + } + pollTimer = setInterval(() => void refreshRateLimits(), RATE_LIMIT_POLL_MS); +} + +export function getRateLimitsSnapshot(): RateLimitSnapshot { + return snapshot; +} + +export function subscribeRateLimits(onStoreChange: () => void) { + listeners.add(onStoreChange); + ensureStarted(); + return () => { + listeners.delete(onStoreChange); + }; +} + +export function setRateLimitProviders(providers: RateLimitProvider[]) { + wanted = [...providers]; + ensureStarted(); + void refreshRateLimits(); +} + +export function refreshRateLimits(force = false): Promise | undefined { + ensureStarted(); + if (inflight) { + if (!force) return inflight; + if (!snapshot.refreshing) replace({ ...snapshot, refreshing: true }); + return inflight.then(async () => { + await refreshRateLimits(true); + }); + } + const visible = isVisible(); + const pending = RATE_LIMIT_PROVIDERS.filter( + (provider) => + wanted.includes(provider) && + shouldFetchProvider(snapshot[provider], { force, visible }), + ); + if (pending.length === 0) return; + if (!force && lockHeld()) return; + acquireLock(); + + let next = { ...snapshot, refreshing: force || snapshot.refreshing }; + for (const provider of pending) { + next = { + ...next, + [provider]: fetchingRateLimits(provider, snapshot[provider]), + }; + } + replace(next); + + const run = Promise.allSettled( + pending.map((provider) => + fetchers[provider]().then((value) => { + snapshot = { ...snapshot, [provider]: value }; + emit(); + }), + ), + ) + .then(() => undefined) + .finally(() => { + inflight = null; + replace({ ...snapshot, refreshing: false }); + writeCache(snapshot); + releaseLock(); + }); + inflight = run; + return run; +} + +export function setRateLimitFetchersForTests(next: RateLimitFetcherMap) { + fetchers = next; +} + +export function resetRateLimitsStoreForTests() { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = undefined; + } + if (typeof window !== "undefined") { + window.removeEventListener("storage", onStorage); + document.removeEventListener("visibilitychange", onVisible); + } + fetchers = defaultFetchers; + snapshot = idleSnapshot(); + listeners.clear(); + wanted = []; + inflight = null; + started = false; +} diff --git a/src/lib/session.ts b/src/lib/session.ts index d1cb780e..5d6735b5 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -177,6 +177,14 @@ export type Block = { export type RuntimeMode = "supervised" | "auto-accept-edits" | "auto" | "full-access"; +/** One GitHub issue or pull request associated with a coding session. */ +export type LinkedWorkItem = { + kind: "issue" | "pr"; + repo: string; + number: number; + url: string; +}; + export const RUNTIME_MODES: RuntimeMode[] = [ "supervised", "auto-accept-edits", @@ -240,6 +248,8 @@ export type Session = { composerSeed?: string; /** Inbox issue/PR chip shown above the composer. In-memory, one-shot. */ inboxCard?: InboxComposerCard; + /** GitHub issue or pull request shown on the persisted session card. */ + linkedWorkItem?: LinkedWorkItem; /** Note chip shown above the composer. In-memory, one-shot. */ noteCard?: NoteComposerCard; /** Handoff chip shown above the composer. In-memory, one-shot. */ diff --git a/src/lib/sessionHistory.ts b/src/lib/sessionHistory.ts index ab93f450..59fffd7d 100644 --- a/src/lib/sessionHistory.ts +++ b/src/lib/sessionHistory.ts @@ -101,6 +101,9 @@ export function summaryFromSession( runtimeMode: session.runtimeMode, title: session.title, providerSessionId: session.providerSessionId, + ...(session.linkedWorkItem + ? { linkedWorkItem: session.linkedWorkItem } + : {}), ...(git?.branch ? { branch: git.branch } : {}), ...(git?.repo ? { repo: git.repo } : {}), createdAt: 0, diff --git a/src/lib/sessionStore.test.ts b/src/lib/sessionStore.test.ts index 2dc0c72b..85487d02 100644 --- a/src/lib/sessionStore.test.ts +++ b/src/lib/sessionStore.test.ts @@ -20,6 +20,24 @@ describe("isPersistableId", () => { }); describe("sanitizeSessionForPersist", () => { + it("persists a canonical GitHub work-item identity", () => { + const session = newSession("codex", "/tmp/project"); + session.blocks = [{ id: "u1", role: "user", text: "fix PR #42" }]; + session.linkedWorkItem = { + kind: "pr", + repo: "openai/codex", + number: 42, + url: "https://example.com/not-trusted", + }; + + expect(sanitizeSessionForPersist(session).linkedWorkItem).toEqual({ + kind: "pr", + repo: "openai/codex", + number: 42, + url: "https://github.com/openai/codex/pull/42", + }); + }); + it("omits a path-like provider session id so upsert can still snapshot git", () => { const session = newSession("pi", "/tmp/project"); session.providerSessionId = "/Users/me/.pi/agent/sessions/abc.jsonl"; diff --git a/src/lib/sessionStore.ts b/src/lib/sessionStore.ts index f1d144e7..bd4a42e5 100644 --- a/src/lib/sessionStore.ts +++ b/src/lib/sessionStore.ts @@ -7,6 +7,7 @@ import type { HarnessId, HandoffMeta, HandoffStatus, + LinkedWorkItem, RuntimeMode, SecondOpinionMeta, Session, @@ -31,6 +32,7 @@ export type SessionSummary = { updatedAt: number; archived?: boolean; pinned?: boolean; + linkedWorkItem?: LinkedWorkItem; }; type SessionRecord = { @@ -47,6 +49,7 @@ type SessionRecord = { contextWindow?: number | null; branch?: string | null; worktreeCwd?: string | null; + linkedWorkItem?: LinkedWorkItem | null; createdAt: number; updatedAt: number; }; @@ -65,12 +68,15 @@ type SessionUpsertPayload = { contextWindow?: number; branch?: string; worktreeCwd?: string; + linkedWorkItem?: LinkedWorkItem; }; /** Only real chats belong in project history — blank tabs stay ephemeral. */ export function shouldPersistSession(session: Session): boolean { return ( - !session.inboxAsk && session.cwd !== "~" && session.blocks.some((block) => block.role === "user") + !session.inboxAsk && + session.cwd !== "~" && + session.blocks.some((block) => block.role === "user") ); } @@ -82,6 +88,7 @@ export function isPersistableId(value: string): boolean { function persistableMeta( session: Session, ): Omit { + const linkedWorkItem = sanitizeLinkedWorkItem(session.linkedWorkItem); return { id: session.id, cwd: normalizeProjectPath(session.cwd), @@ -99,6 +106,34 @@ function persistableMeta( : {}), ...(session.branch ? { branch: session.branch } : {}), ...(session.worktreeCwd ? { worktreeCwd: session.worktreeCwd } : {}), + ...(linkedWorkItem ? { linkedWorkItem } : {}), + }; +} + +export function sanitizeLinkedWorkItem( + value: unknown, +): LinkedWorkItem | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + const item = value as Partial; + const kind = item.kind; + const repo = typeof item.repo === "string" ? item.repo.trim() : ""; + const number = item.number; + if ( + (kind !== "issue" && kind !== "pr") || + !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || + typeof number !== "number" || + !Number.isSafeInteger(number) || + number <= 0 + ) { + return undefined; + } + return { + kind, + repo, + number, + url: `https://github.com/${repo}/${kind === "pr" ? "pull" : "issues"}/${number}`, }; } @@ -189,6 +224,11 @@ export async function listSessionsByProject( return rows.map(normalizeSummary); } +export async function listLinkedSessions(): Promise { + const rows = await invoke("session_list_linked"); + return rows.map(normalizeSummary); +} + export type SessionSearchHit = { kind: "conversation" | "message"; sessionId: string; @@ -432,6 +472,7 @@ function sanitizeTaskList(value: unknown): TaskListMeta | null { } function normalizeSummary(summary: SessionSummary): SessionSummary { + const linkedWorkItem = sanitizeLinkedWorkItem(summary.linkedWorkItem); return { ...summary, harness: asHarness(summary.harness), @@ -445,6 +486,7 @@ function normalizeSummary(summary: SessionSummary): SessionSummary { deletions: summary.deletions ?? 0, archived: summary.archived || undefined, pinned: summary.pinned || undefined, + linkedWorkItem, }; } @@ -454,6 +496,7 @@ function recordToSession(record: SessionRecord): Session { .map(sanitizeBlock) .filter((block): block is Block => block != null) : []; + const linkedWorkItem = sanitizeLinkedWorkItem(record.linkedWorkItem); return { id: record.id, cwd: record.cwd, @@ -472,6 +515,7 @@ function recordToSession(record: SessionRecord): Session { : {}), ...(record.branch ? { branch: record.branch } : {}), ...(record.worktreeCwd ? { worktreeCwd: record.worktreeCwd } : {}), + ...(linkedWorkItem ? { linkedWorkItem } : {}), ...(contextFromRecord(record) ?? {}), }; } diff --git a/src/lib/sessionTitle.test.ts b/src/lib/sessionTitle.test.ts new file mode 100644 index 00000000..e5b52f19 --- /dev/null +++ b/src/lib/sessionTitle.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { + buildThreadTitlePrompt, + parseGeneratedSessionTitle, +} from "./sessionTitle"; + +describe("session title metadata", () => { + it("asks the title pass for one optional work item", () => { + expect(buildThreadTitlePrompt("Fix PR #42")).toContain( + "title and workItem", + ); + }); + + it("accepts a referenced PR number", () => { + expect( + parseGeneratedSessionTitle( + '{"title":"Fix session links","workItem":{"kind":"pr","number":42}}', + "Please fix PR #42", + ), + ).toEqual({ + title: "Fix session links", + workItem: { kind: "pr", number: 42 }, + }); + }); + + it("drops a model-invented number without losing the title", () => { + expect( + parseGeneratedSessionTitle( + '{"title":"Fix session links","workItem":{"kind":"issue","number":99}}', + "Please fix the session links", + ), + ).toEqual({ title: "Fix session links", workItem: null }); + }); + + it("keeps compatibility with a bare generated title", () => { + expect(parseGeneratedSessionTitle("Fix session links", "anything")).toEqual( + { title: "Fix session links", workItem: null }, + ); + }); +}); diff --git a/src/lib/sessionTitle.ts b/src/lib/sessionTitle.ts index 38ae140c..7a2ef577 100644 --- a/src/lib/sessionTitle.ts +++ b/src/lib/sessionTitle.ts @@ -4,7 +4,10 @@ const MESSAGE_LIMIT = 8_000; const TITLE_LIMIT = 50; const THREAD_TITLE_PROMPT = `Generate a title that will help the user recognize this coding session weeks later. -Return JSON with exactly one key: title. +Also identify one GitHub issue or pull request only when the user explicitly refers to it by number or URL. +Return JSON with exactly two keys: title and workItem. +workItem must be null or an object with exactly two keys: kind ("issue" or "pr") and number (a positive integer copied from the user message). +Never invent a work item number. If the reference is ambiguous or has no number, return null. Do not call tools. Reply with JSON only. Before answering, silently reduce the request to: @@ -24,6 +27,16 @@ Editorial rules: - Do not copy and truncate the user's message. - Avoid quotes, labels, filler, and trailing punctuation.`; +export type GeneratedWorkItemHint = { + kind: "issue" | "pr"; + number: number; +}; + +export type GeneratedSessionTitle = { + title: string; + workItem: GeneratedWorkItemHint | null; +}; + export function buildThreadTitlePrompt(message: string): string { return `${THREAD_TITLE_PROMPT}\n\nUser message:\n${limitSection(message, MESSAGE_LIMIT)}`; } @@ -42,14 +55,40 @@ export function sanitizeThreadTitle(raw: string): string { return `${normalized.slice(0, TITLE_LIMIT - 3).trimEnd()}...`; } -export function parseGeneratedThreadTitle(raw: string): string | null { +function referencedNumber(message: string, number: number): boolean { + return new RegExp(`(^|\\D)${number}(?=\\D|$)`).test(message); +} + +export function parseGeneratedSessionTitle( + raw: string, + message: string, +): GeneratedSessionTitle | null { const json = extractJsonObject(raw); if (json) { try { const parsed: unknown = JSON.parse(json); if (parsed && typeof parsed === "object" && "title" in parsed) { - const title = sanitizeThreadTitle(String((parsed as { title: unknown }).title)); - if (title) return title; + const title = sanitizeThreadTitle( + String((parsed as { title: unknown }).title), + ); + if (title) { + const candidate = (parsed as { workItem?: unknown }).workItem; + const workItem = + candidate && typeof candidate === "object" + ? (candidate as { kind?: unknown; number?: unknown }) + : null; + const kind = workItem?.kind; + const number = workItem?.number; + const validWorkItem: GeneratedWorkItemHint | null = + (kind === "issue" || kind === "pr") && + typeof number === "number" && + Number.isSafeInteger(number) && + number > 0 && + referencedNumber(message, number) + ? { kind, number } + : null; + return { title, workItem: validWorkItem }; + } } } catch { // Fall through to a bare-title parse when the model skipped JSON. @@ -60,5 +99,10 @@ export function parseGeneratedThreadTitle(raw: string): string | null { if (!fallback || /[{}]/.test(fallback)) return null; const words = fallback.split(" ").filter(Boolean).length; if (words < 2 || words > 10) return null; - return fallback; + return { title: fallback, workItem: null }; +} + +/** Backwards-compatible title-only parser for callers that do not need metadata. */ +export function parseGeneratedThreadTitle(raw: string): string | null { + return parseGeneratedSessionTitle(raw, "")?.title ?? null; } diff --git a/src/lib/sessionWorkItem.test.ts b/src/lib/sessionWorkItem.test.ts new file mode 100644 index 00000000..8610bd48 --- /dev/null +++ b/src/lib/sessionWorkItem.test.ts @@ -0,0 +1,157 @@ +import { invoke } from "@tauri-apps/api/core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearInboxCache, + githubWorkItem, + inboxItemKey, + type GithubWorkItem, + type InboxItem, +} from "./githubTasks"; +import { + inboxItemMatchesLinkedWorkItem, + linkedWorkItemInboxKey, + linkedWorkItemFromInboxItem, + parseGithubWorkItemUrl, + relatedSessionsForInboxItem, + resolveLinkedWorkItem, +} from "./sessionWorkItem"; + +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); + +beforeEach(() => { + clearInboxCache(); + vi.mocked(invoke).mockReset(); +}); + +describe("session work items", () => { + it("parses a GitHub pull request URL without repository lookup", () => { + expect( + parseGithubWorkItemUrl( + "Please review https://github.com/openai/codex/pull/321?diff=split", + ), + ).toEqual({ + kind: "pr", + repo: "openai/codex", + number: 321, + url: "https://github.com/openai/codex/pull/321", + }); + }); + + it("creates a stable link from a GitHub Inbox item", () => { + const item = { + provider: "github", + kind: "issue", + repo: "openai/codex", + number: 12, + url: "https://github.com/openai/codex/issues/12", + } as InboxItem; + const linked = linkedWorkItemFromInboxItem(item); + expect(linked).toEqual({ + kind: "issue", + repo: "openai/codex", + number: 12, + url: "https://github.com/openai/codex/issues/12", + }); + expect(inboxItemMatchesLinkedWorkItem(item, linked!)).toBe(true); + expect(linkedWorkItemInboxKey(linked!)).toBe(inboxItemKey(item)); + }); + + it("resolves an explicit PR number against the session repository", async () => { + vi.mocked(invoke).mockResolvedValue("openai/codex"); + + await expect( + resolveLinkedWorkItem("Please fix PR #42", "/tmp/codex", null), + ).resolves.toEqual({ + kind: "pr", + repo: "openai/codex", + number: 42, + url: "https://github.com/openai/codex/pull/42", + }); + expect(invoke).toHaveBeenCalledWith("git_github_repo", { + cwd: "/tmp/codex", + }); + }); + + it("fetches an exact cache miss once and reuses that result", async () => { + const result: GithubWorkItem = { + kind: "pr", + repo: "openai/codex", + number: 42, + title: "Faster linked navigation", + url: "https://github.com/openai/codex/pull/42", + state: "open", + updatedAt: "2026-09-09T12:00:00Z", + labels: [], + assignees: [], + draft: false, + }; + vi.mocked(invoke).mockResolvedValue(result); + + await expect( + githubWorkItem("/tmp/codex", "openai/codex", "pr", 42), + ).resolves.toEqual(result); + await expect( + githubWorkItem("/tmp/codex", "openai/codex", "pr", 42), + ).resolves.toEqual(result); + + expect(invoke).toHaveBeenCalledTimes(1); + expect(invoke).toHaveBeenCalledWith("git_github_work_item", { + cwd: "/tmp/codex", + repo: "openai/codex", + kind: "pr", + number: 42, + }); + }); + + it("does not associate a Linear Inbox item", () => { + expect( + linkedWorkItemFromInboxItem({ + provider: "linear", + kind: "linear", + number: 12, + repo: "", + } as InboxItem), + ).toBeNull(); + }); + + it("finds sessions related to the same GitHub Inbox item", () => { + const item = { + provider: "github", + kind: "pr", + repo: "Acme/App", + number: 42, + } as InboxItem; + const matching = { + id: "matching", + linkedWorkItem: { + kind: "pr" as const, + repo: "acme/app", + number: 42, + url: "https://github.com/acme/app/pull/42", + }, + }; + const sessions = [ + matching, + { + id: "other-number", + linkedWorkItem: { ...matching.linkedWorkItem, number: 43 }, + }, + { + id: "other-kind", + linkedWorkItem: { + ...matching.linkedWorkItem, + kind: "issue" as const, + }, + }, + { id: "unlinked" }, + ]; + + expect(relatedSessionsForInboxItem(item, sessions)).toEqual([matching]); + expect( + relatedSessionsForInboxItem( + { ...item, provider: "linear", kind: "linear" } as InboxItem, + sessions, + ), + ).toEqual([]); + }); +}); diff --git a/src/lib/sessionWorkItem.ts b/src/lib/sessionWorkItem.ts new file mode 100644 index 00000000..1e8c086c --- /dev/null +++ b/src/lib/sessionWorkItem.ts @@ -0,0 +1,145 @@ +import { gitPrStatus } from "./fs"; +import { + githubRepo, + inboxIdentityKey, + type InboxItem, + type GithubTaskKind, +} from "./githubTasks"; +import type { LinkedWorkItem } from "./session"; +import type { GeneratedWorkItemHint } from "./sessionTitle"; + +const GITHUB_URL_RE = + /https?:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/(pull|issues)\/(\d+)\b/i; + +function validNumber(value: number): boolean { + return Number.isSafeInteger(value) && value > 0; +} + +function githubUrl(repo: string, kind: GithubTaskKind, number: number): string { + return `https://github.com/${repo}/${kind === "pr" ? "pull" : "issues"}/${number}`; +} + +export function parseGithubWorkItemUrl(message: string): LinkedWorkItem | null { + const match = GITHUB_URL_RE.exec(message); + if (!match) return null; + const number = Number(match[4]); + if (!validNumber(number)) return null; + const repo = `${match[1]}/${match[2]}`; + const kind = match[3].toLowerCase() === "pull" ? "pr" : "issue"; + return { kind, repo, number, url: githubUrl(repo, kind, number) }; +} + +function explicitHint(message: string): GeneratedWorkItemHint | null { + const patterns: Array<[GithubTaskKind, RegExp]> = [ + ["pr", /\b(?:pr|pull\s+request)\s*#?\s*(\d+)\b/i], + ["issue", /\bissue\s*#?\s*(\d+)\b/i], + ]; + for (const [kind, pattern] of patterns) { + const match = pattern.exec(message); + const number = Number(match?.[1]); + if (match && validNumber(number)) return { kind, number }; + } + return null; +} + +function validRepo(repo: string): boolean { + return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo); +} + +function repoFromGithubUrl(url: string): string | null { + const match = GITHUB_URL_RE.exec(url); + return match ? `${match[1]}/${match[2]}` : null; +} + +function referencesCurrentPr(message: string): boolean { + return /\b(?:this|the|current)\s+(?:pr|pull\s+request)\b/i.test(message); +} + +/** Resolve explicit first-message context to one stable GitHub identity. */ +export async function resolveLinkedWorkItem( + message: string, + cwd: string, + generatedHint: GeneratedWorkItemHint | null, +): Promise { + const fromUrl = parseGithubWorkItemUrl(message); + if (fromUrl) return fromUrl; + + const hint = explicitHint(message) ?? generatedHint; + if (hint && validNumber(hint.number)) { + try { + const repo = await githubRepo(cwd); + if (!validRepo(repo)) return null; + return { + ...hint, + repo, + url: githubUrl(repo, hint.kind, hint.number), + }; + } catch { + return null; + } + } + + if (!referencesCurrentPr(message)) return null; + try { + const pr = await gitPrStatus(cwd); + if (!pr || !validNumber(pr.number)) return null; + const repo = repoFromGithubUrl(pr.url) ?? (await githubRepo(cwd)); + if (!validRepo(repo)) return null; + return { + kind: "pr", + repo, + number: pr.number, + url: pr.url || githubUrl(repo, "pr", pr.number), + }; + } catch { + return null; + } +} + +export function linkedWorkItemFromInboxItem( + item: InboxItem, +): LinkedWorkItem | null { + if ( + item.provider !== "github" || + (item.kind !== "issue" && item.kind !== "pr") || + !validNumber(item.number) || + !validRepo(item.repo) + ) { + return null; + } + return { + kind: item.kind, + repo: item.repo, + number: item.number, + url: item.url || githubUrl(item.repo, item.kind, item.number), + }; +} + +export function inboxItemMatchesLinkedWorkItem( + item: InboxItem, + linked: LinkedWorkItem, +): boolean { + return ( + item.provider === "github" && + item.kind === linked.kind && + item.number === linked.number && + item.repo.trim().toLowerCase() === linked.repo.trim().toLowerCase() + ); +} + +/** Same key used by Inbox selection, without synthesizing a full Inbox item. */ +export function linkedWorkItemInboxKey(linked: LinkedWorkItem): string { + return `github:${inboxIdentityKey(linked)}`; +} + +/** Find local sessions whose persisted GitHub identity matches an Inbox row. */ +export function relatedSessionsForInboxItem< + T extends { linkedWorkItem?: LinkedWorkItem }, +>(item: InboxItem, sessions: readonly T[]): T[] { + if (item.provider !== "github") return []; + return sessions.filter( + (session) => + session.linkedWorkItem != null && + inboxItemMatchesLinkedWorkItem(item, session.linkedWorkItem), + ); +} diff --git a/src/lib/settings.test.ts b/src/lib/settings.test.ts index 1a472f1b..62bc71c0 100644 --- a/src/lib/settings.test.ts +++ b/src/lib/settings.test.ts @@ -1,11 +1,13 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { + ALWAYS_SHOW_USAGE_DEFAULT, COMPOSER_RUNNER_DEFAULT, DIFF_VIEWER_DEFAULT, FOLLOW_UP_BEHAVIOR_DEFAULT, GRID_ARCADE_ENABLED_DEFAULT, KEYBINDINGS, LIVE_AGENTS_ENABLED_DEFAULT, + loadAlwaysShowUsage, loadComposerRunner, loadDiffViewer, loadFollowUpBehavior, @@ -13,6 +15,7 @@ import { loadLiveAgentsEnabled, loadNotesEnabled, NOTES_ENABLED_DEFAULT, + saveAlwaysShowUsage, saveComposerRunner, saveDiffViewer, saveFollowUpBehavior, @@ -27,6 +30,7 @@ const LIVE_AGENTS_KEY = "monocode.liveAgentsEnabled"; const GRID_ARCADE_KEY = "monocode.gridArcadeEnabled"; const DIFF_VIEWER_KEY = "monocode.diffViewer"; const FOLLOW_UP_BEHAVIOR_KEY = "monocode.followUpBehavior"; +const ALWAYS_SHOW_USAGE_KEY = "monocode.alwaysShowUsage"; describe("follow-up behavior setting", () => { beforeEach(mockLocalStorage); @@ -157,9 +161,7 @@ describe("grid arcade enabled setting", () => { describe("workspace navigation keybindings", () => { it("documents session and project cycling in the shortcut list", () => { const rows = KEYBINDINGS.filter( - (row) => - row.command.startsWith("Session:") || - row.command.startsWith("Project:"), + (row) => /^(Session|Project): (Previous|Next)$/.test(row.command), ); expect(rows.map((row) => row.command)).toEqual([ "Session: Previous", @@ -199,3 +201,29 @@ describe("diff viewer setting", () => { expect(loadDiffViewer()).toBe("editor"); }); }); + +describe("always show provider usage setting", () => { + beforeEach(mockLocalStorage); + afterEach(() => { + localStorage.removeItem(ALWAYS_SHOW_USAGE_KEY); + }); + + it("defaults to off so the footer keeps following the active session", () => { + expect(ALWAYS_SHOW_USAGE_DEFAULT).toBe(false); + expect(loadAlwaysShowUsage()).toBe(false); + }); + + it("persists an on switch", () => { + saveAlwaysShowUsage(true); + expect(localStorage.getItem(ALWAYS_SHOW_USAGE_KEY)).toBe("1"); + expect(loadAlwaysShowUsage()).toBe(true); + saveAlwaysShowUsage(false); + expect(localStorage.getItem(ALWAYS_SHOW_USAGE_KEY)).toBe("0"); + expect(loadAlwaysShowUsage()).toBe(false); + }); + + it("reads the legacy truthy spelling", () => { + localStorage.setItem(ALWAYS_SHOW_USAGE_KEY, "true"); + expect(loadAlwaysShowUsage()).toBe(true); + }); +}); diff --git a/src/lib/settings.ts b/src/lib/settings.ts index d65f097c..97518c33 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -244,6 +244,43 @@ export function subscribeGridArcadeEnabled(onStoreChange: () => void) { window.removeEventListener(GRID_ARCADE_ENABLED_CHANGE_EVENT, onStoreChange); } +const ALWAYS_SHOW_USAGE_KEY = "monocode.alwaysShowUsage"; + +export const ALWAYS_SHOW_USAGE_DEFAULT = false; + +/** Fired on `window` when the always-show provider usage setting flips. */ +export const ALWAYS_SHOW_USAGE_CHANGE_EVENT = + "monocode:always-show-usage-change"; + +export function loadAlwaysShowUsage(): boolean { + try { + const raw = localStorage.getItem(ALWAYS_SHOW_USAGE_KEY); + if (raw == null) return ALWAYS_SHOW_USAGE_DEFAULT; + return raw === "1" || raw === "true"; + } catch { + return ALWAYS_SHOW_USAGE_DEFAULT; + } +} + +export function saveAlwaysShowUsage(value: boolean) { + try { + localStorage.setItem(ALWAYS_SHOW_USAGE_KEY, value ? "1" : "0"); + } catch { + // private mode / quota + } + if (typeof window === "undefined") return; + window.dispatchEvent( + new CustomEvent(ALWAYS_SHOW_USAGE_CHANGE_EVENT, { detail: value }), + ); +} + +export function subscribeAlwaysShowUsage(onStoreChange: () => void) { + if (typeof window === "undefined") return () => {}; + window.addEventListener(ALWAYS_SHOW_USAGE_CHANGE_EVENT, onStoreChange); + return () => + window.removeEventListener(ALWAYS_SHOW_USAGE_CHANGE_EVENT, onStoreChange); +} + const DIFF_VIEWER_KEY = "monocode.diffViewer"; export type DiffViewer = "editor" | "unified"; @@ -345,6 +382,11 @@ export const KEYBINDINGS: KeybindingRow[] = [ { command: "Tab: Forward", keys: `${MOD}]`, when: "Always" }, { command: "Tab: Activate 1–8", keys: `${MOD}1 … ${MOD}8`, when: "Always" }, { command: "Tab: Activate Last", keys: `${MOD}9`, when: "Always" }, + { + command: "Session: Archive", + keys: `${MOD}${SHIFT}A`, + when: "sessionFocus && !overlay", + }, { command: "Session: Previous", keys: `${MOD}${SHIFT}↑`, diff --git a/src/lib/tabKeys.test.ts b/src/lib/tabKeys.test.ts index 6000f949..55b575e9 100644 --- a/src/lib/tabKeys.test.ts +++ b/src/lib/tabKeys.test.ts @@ -39,6 +39,27 @@ function key( } describe("tabCommand", () => { + it("archives with Cmd+Shift+A or Ctrl+Shift+A", () => { + expect( + tabCommand(key({ key: "A", metaKey: true, shiftKey: true })), + ).toBe("archive-session"); + expect( + tabCommand(key({ key: "a", ctrlKey: true, shiftKey: true })), + ).toBe("archive-session"); + }); + + it.each([ + {}, + { metaKey: true }, + { ctrlKey: true }, + { shiftKey: true }, + { metaKey: true, shiftKey: true, altKey: true }, + { metaKey: true, shiftKey: true, isComposing: true }, + { metaKey: true, shiftKey: true, repeat: true }, + ])("leaves other A key events alone (%j)", (modifiers) => { + expect(tabCommand(key({ key: "a", ...modifiers }))).toBeNull(); + }); + it("opens a terminal pane with cmd-backtick", () => { expect(tabCommand(key({ key: "`", code: "Backquote", metaKey: true }))).toBe( "new-terminal", diff --git a/src/lib/tabKeys.ts b/src/lib/tabKeys.ts index 91be9aff..b8bd6b53 100644 --- a/src/lib/tabKeys.ts +++ b/src/lib/tabKeys.ts @@ -22,6 +22,7 @@ * Reset zoom cmd-0 * Previous session shift-cmd-up * Next session shift-cmd-down + * Archive session shift-cmd-a * Previous project shift-cmd-left * Next project shift-cmd-right * Stop focused turn escape @@ -44,6 +45,7 @@ export type TabCommand = | "toggle-terminal" | "prev-session" | "next-session" + | "archive-session" | "prev-project" | "next-project" | { activate: number } @@ -76,6 +78,7 @@ export function tabCommand(e: KeyboardEvent): TabCommand | null { const key = e.key.toLowerCase(); if (e.shiftKey) { + if (key === "a" && !e.repeat) return "archive-session"; if (e.key === "]" || e.key === "}") return "next"; if (e.key === "[" || e.key === "{") return "prev"; if (e.key === "ArrowUp") return "prev-session"; diff --git a/src/lib/workingTreeDiff.test.ts b/src/lib/workingTreeDiff.test.ts new file mode 100644 index 00000000..6b1aab7c --- /dev/null +++ b/src/lib/workingTreeDiff.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import type { GitChangedFile } from "./fs"; +import { + prioritizeWorkingTreeDiffEntries, + workingTreeDiffEntries, + workingTreeDiffEntryLabel, + workingTreeDiffFocusId, +} from "./workingTreeDiff"; + +function file( + relative: string, + staged: boolean, + unstaged: boolean, +): GitChangedFile { + return { + path: `/repo/${relative}`, + relative, + status: "modified", + additions: 1, + deletions: 0, + staged, + unstaged, + }; +} + +describe("workingTreeDiffEntries", () => { + it("creates a staged entry for a clean staged file", () => { + const entries = workingTreeDiffEntries([file("a.ts", true, false)]); + expect(entries.map(({ id, kind }) => ({ id, kind }))).toEqual([ + { id: "staged:a.ts", kind: "staged" }, + ]); + }); + + it("creates both comparisons for a partially staged file", () => { + const entries = workingTreeDiffEntries([file("a.ts", true, true)]); + expect(entries.map((entry) => entry.id)).toEqual([ + "staged:a.ts", + "unstaged:a.ts", + ]); + expect(entries.map(workingTreeDiffEntryLabel)).toEqual([ + "a.ts (Staged)", + "a.ts (Unstaged)", + ]); + }); + + it("focuses and prioritizes the selected comparison", () => { + const entries = workingTreeDiffEntries([ + file("a.ts", true, true), + file("b.ts", false, true), + ]); + expect(workingTreeDiffFocusId(entries, "/repo/a.ts", "unstaged")).toBe( + "unstaged:a.ts", + ); + expect( + prioritizeWorkingTreeDiffEntries(entries, "/repo/a.ts", "unstaged")[0] + ?.id, + ).toBe("unstaged:a.ts"); + }); +}); diff --git a/src/lib/workingTreeDiff.ts b/src/lib/workingTreeDiff.ts new file mode 100644 index 00000000..fb8b8e12 --- /dev/null +++ b/src/lib/workingTreeDiff.ts @@ -0,0 +1,63 @@ +import type { GitChangedFile, GitFileDiffKind } from "./fs"; + +export type WorkingTreeDiffEntry = { + id: string; + kind: GitFileDiffKind; + file: GitChangedFile; +}; + +export function workingTreeDiffEntryId( + kind: GitFileDiffKind, + relative: string, +): string { + return `${kind}:${relative}`; +} + +/** Staged entries come first, matching the source-control sidebar. */ +export function workingTreeDiffEntries( + files: readonly GitChangedFile[], +): WorkingTreeDiffEntry[] { + const entries: WorkingTreeDiffEntry[] = []; + for (const kind of ["staged", "unstaged"] as const) { + for (const file of files) { + if (kind === "staged" ? file.staged : file.unstaged) { + entries.push({ + id: workingTreeDiffEntryId(kind, file.relative), + kind, + file, + }); + } + } + } + return entries; +} + +export function workingTreeDiffEntryLabel(entry: WorkingTreeDiffEntry): string { + if (!entry.file.staged || !entry.file.unstaged) return entry.file.relative; + return `${entry.file.relative} (${entry.kind === "staged" ? "Staged" : "Unstaged"})`; +} + +export function workingTreeDiffFocusId( + entries: readonly WorkingTreeDiffEntry[], + focusPath: string | undefined, + focusKind?: GitFileDiffKind, +): string | undefined { + if (!focusPath) return undefined; + return entries.find( + (entry) => + (!focusKind || entry.kind === focusKind) && + (entry.file.path === focusPath || entry.file.relative === focusPath), + )?.id; +} + +export function prioritizeWorkingTreeDiffEntries( + entries: readonly WorkingTreeDiffEntry[], + focusPath: string | undefined, + focusKind?: GitFileDiffKind, +): WorkingTreeDiffEntry[] { + const focusId = workingTreeDiffFocusId(entries, focusPath, focusKind); + if (!focusId) return [...entries]; + const focused = entries.find((entry) => entry.id === focusId); + if (!focused) return [...entries]; + return [focused, ...entries.filter((entry) => entry !== focused)]; +} diff --git a/src/lib/workspaceSnapshot.test.ts b/src/lib/workspaceSnapshot.test.ts index faa9d028..2db6eacb 100644 --- a/src/lib/workspaceSnapshot.test.ts +++ b/src/lib/workspaceSnapshot.test.ts @@ -53,7 +53,7 @@ describe("collectWorkspaceSnapshot", () => { }); it("round-trips a unified Changes tab", () => { - const file = newChangesTab("/tmp/a", "/tmp/a/src/lib.rs"); + const file = newChangesTab("/tmp/a", "/tmp/a/src/lib.rs", "staged"); const tab = { ...newTab("s1"), id: "t1", @@ -65,6 +65,7 @@ describe("collectWorkspaceSnapshot", () => { expect(restored?.changes).toBe(true); expect(restored?.review).toBe(true); expect(restored?.path).toBe("/tmp/a/src/lib.rs"); + expect(restored?.changeKind).toBe("staged"); }); it("round-trips a session-scoped Changes tab", () => { diff --git a/src/lib/workspaceSnapshot.ts b/src/lib/workspaceSnapshot.ts index 2bf38133..419f24b7 100644 --- a/src/lib/workspaceSnapshot.ts +++ b/src/lib/workspaceSnapshot.ts @@ -451,6 +451,9 @@ function sanitizeFile(raw: unknown): FilePaneTab | null { ...(sessionChanges ? { sessionChanges, review: true } : {}), ...(value.review === true ? { review: true } : {}), ...(value.changes === true ? { changes: true, review: true } : {}), + ...(value.changeKind === "staged" || value.changeKind === "unstaged" + ? { changeKind: value.changeKind } + : {}), ...(value.terminal === true ? { terminal: true } : {}), }; } diff --git a/src/main.tsx b/src/main.tsx index e2fd8e81..9330f2f8 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,9 +1,8 @@ import React, { useLayoutEffect } from "react"; import ReactDOM from "react-dom/client"; -import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import App from "./App"; -import { initAppearance } from "./lib/appearance"; +import { activateWindowAppearance, initAppearance } from "./lib/appearance"; import { initSounds } from "./lib/sounds"; import { handleQuitRequested, loadBootWorkspace } from "./lib/appLifecycle"; import { consumeInstalledUpdate } from "./lib/updateNotice"; @@ -17,7 +16,7 @@ function dismissBootSplash() { if (!splash || splash.dataset.dismissed === "1") return; splash.dataset.dismissed = "1"; const fade = () => { - void invoke("enable_window_glass"); + activateWindowAppearance(); splash.classList.add("boot-splash-out"); window.setTimeout(() => splash.remove(), 180); }; diff --git a/src/surfaces/AgentMarkdown.test.ts b/src/surfaces/AgentMarkdown.test.ts index d56e639d..194ab6e8 100644 --- a/src/surfaces/AgentMarkdown.test.ts +++ b/src/surfaces/AgentMarkdown.test.ts @@ -66,3 +66,18 @@ describe("AgentMarkdown inline code", () => { expect(classes).not.toContain("h-6"); }); }); + +describe("AgentMarkdown note images", () => { + it("keeps app-owned note image references for the async image resolver", () => { + const markup = renderToStaticMarkup( + createElement(AgentMarkdown, { + text: "![Diagram](/note-assets/note-1/123-diagram.png)", + }), + ); + + expect(markup).toContain( + 'data-note-image="/note-assets/note-1/123-diagram.png"', + ); + expect(markup).toContain('alt="Diagram"'); + }); +}); diff --git a/src/surfaces/AgentMarkdown.tsx b/src/surfaces/AgentMarkdown.tsx index 529ccd7d..c6d09a30 100644 --- a/src/surfaces/AgentMarkdown.tsx +++ b/src/surfaces/AgentMarkdown.tsx @@ -1,4 +1,5 @@ import { code } from "@streamdown/code"; +import { convertFileSrc, invoke } from "@tauri-apps/api/core"; import { createContext, isValidElement, @@ -28,6 +29,7 @@ import { useColorScheme } from "../hooks/useColorScheme"; import { useLockOverscroll } from "../hooks/useLockOverscroll"; import { copyText } from "../lib/clipboard"; import { INBOX_MEDIA_PREFIXES, isInboxMediaUrl } from "../lib/inboxMedia"; +import { isNoteImagePath } from "../lib/noteImages"; import { InboxMedia } from "./InboxMedia"; const MERMAID_BASE_CONFIG = { @@ -51,7 +53,9 @@ const MARKDOWN_REHYPE_PLUGINS: PluggableList = [ [ harden, { - allowedImagePrefixes: [] as string[], + // MarkdownImage remains the final allowlist. The wildcard lets app-owned + // relative note URLs reach that component without changing link parsing. + allowedImagePrefixes: ["*"], allowedLinkPrefixes: ["*"], allowDataImages: true, imageBlockPolicy: "remove" as const, @@ -302,6 +306,42 @@ function CodeCopyButton({ code }: { code: string }) { type MarkdownImageProps = ComponentProps<"img"> & { node?: unknown }; +const noteImageSrcCache = new Map(); + +function NoteAssetImage({ + asset, + alt, + ...props +}: Omit & { asset: string }) { + const [src, setSrc] = useState(() => noteImageSrcCache.get(asset)); + + useEffect(() => { + if (src) return; + let cancelled = false; + void invoke("notes_image_path", { asset }) + .then((path) => { + const next = convertFileSrc(path); + noteImageSrcCache.set(asset, next); + if (!cancelled) setSrc(next); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [asset, src]); + + return ( + {alt + ); +} + function MarkdownImage({ src, alt, @@ -313,6 +353,9 @@ function MarkdownImage({ if (url.startsWith("data:image/")) { return {alt; } + if (isNoteImagePath(url)) { + return ; + } if (!allowRemoteMedia || !url || !isInboxMediaUrl(url)) return null; return ; } diff --git a/src/surfaces/AgentTranscript.tsx b/src/surfaces/AgentTranscript.tsx index b10bc82f..83373b84 100644 --- a/src/surfaces/AgentTranscript.tsx +++ b/src/surfaces/AgentTranscript.tsx @@ -22,6 +22,7 @@ import { useState, type ReactNode, } from "react"; +import { flushSync } from "react-dom"; import { AttachmentChip } from "../chrome/AttachmentChip"; import { FilePreview } from "../chrome/FilePreview"; import { FileTypeIcon } from "../chrome/FileTypeIcon"; @@ -116,6 +117,8 @@ type Props = { onHandoff?: (harness: HarnessId, turn: Block[], model: string) => void; onJumpToBottomChange?: (show: boolean) => void; onJumpToBottomReady?: (jump: () => void) => void; + /** Passes a function that renders the turn that holds a block. The render completes before the function returns. */ + onRevealReady?: (reveal: (blockId: string) => boolean) => void; /** False while another tab is in front; local transcript state is retained. */ visible?: boolean; }; @@ -138,6 +141,7 @@ function AgentTranscriptComponent({ onHandoff, onJumpToBottomChange, onJumpToBottomReady, + onRevealReady, visible = true, }: Props) { const lockOverscroll = useLockOverscroll(); @@ -293,6 +297,10 @@ function AgentTranscriptComponent({ const turns = groupTurns(blocks); const firstVisibleTurn = Math.max(0, turns.length - visibleTurnCount); const visibleTurns = turns.slice(firstVisibleTurn); + const turnsRef = useRef(turns); + turnsRef.current = turns; + const visibleTurnCountRef = useRef(visibleTurnCount); + visibleTurnCountRef.current = visibleTurnCount; useLayoutEffect(() => { const previousHeight = prependHeight.current; @@ -304,15 +312,40 @@ function AgentTranscriptComponent({ el.scrollHeight - el.scrollTop - el.clientHeight; }, [visibleTurnCount]); - const loadEarlier = () => { + const prepareToPrepend = useCallback(() => { const el = scroller.current; if (el) prependHeight.current = el.scrollHeight; stickToBottom.current = false; + }, []); + + const loadEarlier = () => { + prepareToPrepend(); setVisibleTurnCount((count) => Math.min(turns.length, count + TURN_PAGE_SIZE), ); }; + const revealBlock = useCallback( + (blockId: string): boolean => { + const all = turnsRef.current; + const index = all.findIndex((turn) => + turn.some((block) => block.id === blockId), + ); + if (index < 0) return false; + const needed = all.length - index; + if (needed <= visibleTurnCountRef.current) return true; + prepareToPrepend(); + // Synchronous. The caller finds the turn in the DOM after this call. + flushSync(() => setVisibleTurnCount(needed)); + return true; + }, + [prepareToPrepend], + ); + + useEffect(() => { + onRevealReady?.(revealBlock); + }, [revealBlock, onRevealReady]); + return (
    { + it("renders the arcade when no chat background is selected", () => { + const markup = renderToStaticMarkup( + createElement(EmptySession, { cwd: "/work/demo" }), + ); + + expect(markup).toContain(" { + const markup = renderToStaticMarkup( + createElement(EmptySession, { + cwd: "/work/demo", + hasChatBackground: true, + }), + ); + + expect(markup).not.toContain("(); const arcadeEnabled = useSyncExternalStore( subscribeGridArcadeEnabled, @@ -30,7 +31,7 @@ export function EmptySession({ cwd, composer }: Props) { ref={lockOverscroll} className="relative flex h-full min-h-0 overflow-y-auto overscroll-none" > - {arcadeEnabled ? : null} + {arcadeEnabled && !hasChatBackground ? : null} {composer ? (
    diff --git a/src/surfaces/FileEditor.tsx b/src/surfaces/FileEditor.tsx index 71a8f65c..081666b5 100644 --- a/src/surfaces/FileEditor.tsx +++ b/src/surfaces/FileEditor.tsx @@ -208,7 +208,16 @@ export function FileEditor({ setGitBase({ path, original: null }); const load = () => { - void gitFileDiff(cwd, relative) + void (async () => { + let diff = await gitFileDiff(cwd, relative, "unstaged"); + // A review tab opened from a clean staged file has no index-to-disk + // delta. Fall back to HEAD-to-index so the staged patch is still + // visible in the editor-style diff view. + if (!diff.binary && !diff.tooLarge && diff.original === diff.current) { + diff = await gitFileDiff(cwd, relative, "staged"); + } + return diff; + })() .then((diff) => { if (cancelled) return; if (diff.binary || diff.tooLarge) { diff --git a/src/surfaces/FilePane.tsx b/src/surfaces/FilePane.tsx index f0227a7f..152c4887 100644 --- a/src/surfaces/FilePane.tsx +++ b/src/surfaces/FilePane.tsx @@ -121,7 +121,11 @@ function FilePaneComponent({
    ) : unifiedReview && activeFile ? (
    - +
    ) : null} {pane.files.map((file) => { diff --git a/src/surfaces/InboxComments.tsx b/src/surfaces/InboxComments.tsx index fb5ff7c0..03a9edd5 100644 --- a/src/surfaces/InboxComments.tsx +++ b/src/surfaces/InboxComments.tsx @@ -79,7 +79,12 @@ export function InboxComments({ 0, ); const label = count === 1 ? "1 comment" : `${count} comments`; - const moreOn = provider === "linear" ? "Linear" : "GitHub"; + const moreOn = + provider === "linear" + ? "Linear" + : provider === "gitlab" + ? "GitLab" + : "GitHub"; return (
    @@ -208,9 +213,7 @@ export function InboxCommentForm({
    - {error ? ( -

    {error}

    - ) : null} + {error ?

    {error}

    : null} ); } @@ -280,7 +283,11 @@ function InboxComment({
    @@ -601,21 +704,33 @@ export function InboxView({ ? searchNarrowed ? source === "linear" ? "No matching Linear issues" - : "No matching issues or pull requests" + : source === "gitlab" + ? "No matching issues or merge requests" + : "No matching issues or pull requests" : source === "linear" ? "No Linear issues match these filters" - : "No issues or pull requests match these filters" + : source === "gitlab" + ? "No GitLab items match these filters" + : "No issues or pull requests match these filters" : source === "linear" ? "No Linear issues" - : projects.length === 0 - ? "Open a project to fill the inbox" - : "No matching issues or pull requests"} + : source === "gitlab" + ? projects.length === 0 + ? "Open a project to fill the inbox" + : "No matching issues or merge requests" + : projects.length === 0 + ? "Open a project to fill the inbox" + : "No matching issues or pull requests"}

    ) : (
      {visibleItems.map((item) => { const key = inboxItemKey(item); const projectId = projectKey(item.projectPath); + const relatedSessions = relatedSessionsForInboxItem( + item, + sessions, + ); return (
    • { markInboxItemSeen({ key, @@ -709,8 +825,12 @@ export function InboxView({ cwd={cwd} projects={projectOptions} revision={refresh} + relatedSessions={ + selected ? relatedSessionsForInboxItem(selected, sessions) : [] + } onDiscuss={() => setDiscussionOpen(true)} onStart={onStart} + onOpenSession={onOpenSession} />
    {discussionOpen && selected ? ( @@ -735,22 +855,26 @@ function InboxDetailBody({ cwd, projects, revision = 0, + relatedSessions, onDiscuss, onStart, + onOpenSession, }: { item: InboxItem | null; cwd: string; projects: InboxProjectOption[]; revision?: number; + relatedSessions: readonly SessionSummary[]; onDiscuss?: () => void; onStart?: (item: InboxItem, body?: string) => void | Promise; + onOpenSession?: (sessionId: string) => void | Promise; }) { if (!item) { return (

    - Select an issue or pull request + Select an inbox item

    ); @@ -762,8 +886,10 @@ function InboxDetailBody({ cwd={cwd} projects={projects} revision={revision} + relatedSessions={relatedSessions} onDiscuss={onDiscuss} onStart={onStart} + onOpenSession={onOpenSession} /> ); } @@ -808,6 +934,7 @@ function InboxCard({ logoPath, mascotName, mascotColor, + relatedSessionCount, onSelect, }: { item: InboxItem; @@ -815,11 +942,17 @@ function InboxCard({ logoPath: string | null; mascotName: string | null; mascotColor: string; + relatedSessionCount: number; onSelect: () => void; }) { useInboxSeenTick(); const status = inboxStatusMark(item); - const kindLabel = item.kind === "pr" ? "Pull request" : "Issue"; + const kindLabel = + item.kind === "pr" + ? item.provider === "gitlab" + ? "Merge request" + : "Pull request" + : "Issue"; const time = formatRelativeTime(item.updatedAt); const name = projectName(item.projectPath); const linear = item.provider === "linear"; @@ -836,7 +969,7 @@ function InboxCard({ aria-current={active ? "true" : undefined} aria-label={`${status.label} ${kindLabel.toLowerCase()} ${inboxItemRef( item, - )}: ${item.title}${unseen ? ", new" : ""}`} + )}: ${item.title}${unseen ? ", new" : ""}${relatedSessionCount > 0 ? `, ${relatedSessionCount} related ${relatedSessionCount === 1 ? "thread" : "threads"}` : ""}`} onClick={onSelect} className={`flex w-full flex-col rounded-md border px-2.5 py-2 text-left ${ active @@ -858,8 +991,17 @@ function InboxCard({ {kindLabel} · {inboxItemRef(item)} - {time || unseen ? ( + {relatedSessionCount > 0 || time || unseen ? ( + {relatedSessionCount > 0 ? ( + + + {relatedSessionCount} + + ) : null} {time ? ( {time} @@ -909,33 +1051,48 @@ function InboxDetail({ cwd, projects, revision, + relatedSessions, onDiscuss, onStart, + onOpenSession, }: { item: InboxItem; cwd: string; projects: InboxProjectOption[]; revision: number; + relatedSessions: readonly SessionSummary[]; onDiscuss?: () => void; onStart?: (item: InboxItem, body?: string) => void | Promise; + onOpenSession?: (sessionId: string) => void | Promise; }) { const linear = item.provider === "linear"; + const gitlab = item.provider === "gitlab"; const isPr = !linear && item.kind === "pr"; const githubKind = - item.kind === "issue" || item.kind === "pr" ? item.kind : null; + item.provider === "github" && (item.kind === "issue" || item.kind === "pr") + ? item.kind + : null; + const gitlabKind = + gitlab && (item.kind === "issue" || item.kind === "pr") ? item.kind : null; const cached = linear ? peekLinearIssueDetails(item.id ?? "") - : githubKind - ? peekGithubWorkItemDetails(item.projectPath, githubKind, item.number) - : null; + : gitlabKind + ? peekGitlabWorkItemDetails(item.projectPath, gitlabKind, item.number) + : githubKind + ? peekGithubWorkItemDetails(item.projectPath, githubKind, item.number) + : null; const cachedDiff = isPr - ? peekGithubPrDiff(item.projectPath, item.number) + ? gitlab + ? peekGitlabMrDiff(item.projectPath, item.number) + : peekGithubPrDiff(item.projectPath, item.number) : null; const cachedThread = linear ? peekLinearIssueThread(item.id ?? "") - : githubKind - ? peekGithubWorkItemThread(item.projectPath, githubKind, item.number) - : null; + : gitlabKind + ? peekGitlabWorkItemThread(item.projectPath, gitlabKind, item.number) + : githubKind + ? peekGithubWorkItemThread(item.projectPath, githubKind, item.number) + : null; const [details, setDetails] = useState(cached); const [loading, setLoading] = useState(cached == null); const [error, setError] = useState(null); @@ -944,7 +1101,7 @@ function InboxDetail({ const [diffLoading, setDiffLoading] = useState(isPr && cachedDiff == null); const [diffError, setDiffError] = useState(null); const [thread, setThread] = useState< - GithubWorkItemThread | LinearIssueThread | null + GithubWorkItemThread | LinearIssueThread | GitlabWorkItemThread | null >(cachedThread); const [threadLoading, setThreadLoading] = useState(cachedThread == null); const [threadError, setThreadError] = useState(null); @@ -993,9 +1150,11 @@ function InboxDetail({ let cancelled = false; const cachedDetails = linear ? peekLinearIssueDetails(item.id ?? "") - : githubKind - ? peekGithubWorkItemDetails(item.projectPath, githubKind, item.number) - : null; + : gitlabKind + ? peekGitlabWorkItemDetails(item.projectPath, gitlabKind, item.number) + : githubKind + ? peekGithubWorkItemDetails(item.projectPath, githubKind, item.number) + : null; if (cachedDetails) { setDetails(cachedDetails); setLoading(false); @@ -1009,9 +1168,11 @@ function InboxDetail({ ? item.id ? linearIssueDetails(item.id) : Promise.reject(new Error("Missing Linear issue")) - : githubKind - ? githubWorkItemDetails(item.projectPath, githubKind, item.number) - : Promise.reject(new Error("Unknown inbox item")); + : gitlabKind + ? gitlabWorkItemDetails(item.projectPath, gitlabKind, item.number) + : githubKind + ? githubWorkItemDetails(item.projectPath, githubKind, item.number) + : Promise.reject(new Error("Unknown inbox item")); void pending .then((next) => { if (cancelled) return; @@ -1029,7 +1190,15 @@ function InboxDetail({ return () => { cancelled = true; }; - }, [githubKind, item.id, item.number, item.projectPath, linear, revision]); + }, [ + githubKind, + gitlabKind, + item.id, + item.number, + item.projectPath, + linear, + revision, + ]); useEffect(() => { let cancelled = false; @@ -1063,6 +1232,39 @@ function InboxDetail({ cancelled = true; }; } + if (gitlabKind) { + const cachedThread = peekGitlabWorkItemThread( + item.projectPath, + gitlabKind, + item.number, + ); + if (cachedThread) { + setThread(cachedThread); + setThreadLoading(false); + setThreadError(null); + } else { + setThreadLoading(true); + setThreadError(null); + setThread(null); + } + void gitlabWorkItemThread(item.projectPath, gitlabKind, item.number) + .then((next) => { + if (cancelled) return; + setThread(next); + setThreadError(null); + }) + .catch((err: unknown) => { + if (cancelled) return; + if (cachedThread) return; + setThreadError(err instanceof Error ? err.message : String(err)); + }) + .finally(() => { + if (!cancelled) setThreadLoading(false); + }); + return () => { + cancelled = true; + }; + } if (!githubKind) return; const cachedThread = peekGithubWorkItemThread( item.projectPath, @@ -1095,12 +1297,22 @@ function InboxDetail({ return () => { cancelled = true; }; - }, [githubKind, item.id, item.number, item.projectPath, linear, revision]); + }, [ + githubKind, + gitlabKind, + item.id, + item.number, + item.projectPath, + linear, + revision, + ]); useEffect(() => { if (!isPr || tab !== "code") return; let cancelled = false; - const cachedDiff = peekGithubPrDiff(item.projectPath, item.number); + const cachedDiff = gitlab + ? peekGitlabMrDiff(item.projectPath, item.number) + : peekGithubPrDiff(item.projectPath, item.number); if (cachedDiff) { setPrDiff(cachedDiff); setDiffLoading(false); @@ -1110,7 +1322,10 @@ function InboxDetail({ setDiffError(null); setPrDiff(null); } - void githubPrDiff(item.projectPath, item.number) + const pending = gitlab + ? gitlabMrDiff(item.projectPath, item.number) + : githubPrDiff(item.projectPath, item.number); + void pending .then((next) => { if (cancelled) return; setPrDiff(next); @@ -1127,7 +1342,7 @@ function InboxDetail({ return () => { cancelled = true; }; - }, [isPr, item.number, item.projectPath, revision, tab]); + }, [gitlab, isPr, item.number, item.projectPath, revision, tab]); const postComment = async (body: string) => { setPosting(true); @@ -1144,6 +1359,28 @@ function InboxDetail({ } return; } + if (gitlabKind) { + await gitlabWorkItemComment( + item.projectPath, + gitlabKind, + item.number, + body, + ); + setReplyTo(null); + try { + setThread( + await gitlabWorkItemThread( + item.projectPath, + gitlabKind, + item.number, + { force: true }, + ), + ); + } catch (err: unknown) { + setPostError(err instanceof Error ? err.message : String(err)); + } + return; + } if (!githubKind) throw new Error("Unknown inbox item"); await githubWorkItemComment( item.projectPath, @@ -1180,7 +1417,13 @@ function InboxDetail({
    - {item.kind === "pr" ? "Pull request" : "Issue"} + + {item.kind === "pr" + ? gitlab + ? "Merge request" + : "Pull request" + : "Issue"} + {inboxItemRef(item)} @@ -1263,6 +1506,31 @@ function InboxDetail({ ))}
    ) : null} + {relatedSessions.length > 0 ? ( +
    + + + Related {relatedSessions.length === 1 ? "thread" : "threads"} + + {relatedSessions.map((session) => { + const title = sessionDisplayTitle(session.title, session.harness); + return ( + + ); + })} +
    + ) : null}
    {onStart && item.kind !== "pr" ? ( <> @@ -1315,10 +1583,14 @@ function InboxDetail({ > {item.kind === "pr" - ? "Review on GitHub" + ? gitlab + ? "Review on GitLab" + : "Review on GitHub" : linear ? "Open in Linear" - : "Open on GitHub"} + : gitlab + ? "Open on GitLab" + : "Open on GitHub"}
    {startError ? ( @@ -1328,7 +1600,9 @@ function InboxDetail({ {isPr ? (
    (null); + const [imageDrag, setImageDrag] = useState(false); + const [imageBusy, setImageBusy] = useState(false); const titleRef = useRef(title); const bodyRef = useRef(body); const noteRef = useRef(note); + const dropZoneRef = useRef(null); + const sourceFieldRef = useRef(null); + const lastDropAt = useRef(0); const skipSave = useRef(false); const saveTimer = useRef(null); const saveQueue = useRef(Promise.resolve()); @@ -553,6 +566,111 @@ function NoteEditor({ }, 400); }, [persist]); + const insertionRange = useCallback(() => { + const field = sourceFieldRef.current; + if (!field) { + const end = bodyRef.current.length; + return { start: end, end }; + } + return { + start: field.selectionStart, + end: field.selectionEnd, + }; + }, []); + + const addDroppedImages = useCallback( + async ( + load: () => Promise, + range: { start: number; end: number }, + ) => { + setImageBusy(true); + setImageDrag(false); + try { + const images = await load(); + const inserted = insertNoteImagesMarkdown( + bodyRef.current, + range.start, + range.end, + images, + ); + bodyRef.current = inserted.value; + setBody(inserted.value); + setSaveError(null); + scheduleSave(); + window.requestAnimationFrame(() => { + const field = sourceFieldRef.current; + if (!field) return; + field.focus(); + field.setSelectionRange(inserted.cursor, inserted.cursor); + }); + } catch (err: unknown) { + setSaveError(err instanceof Error ? err.message : String(err)); + } finally { + setImageBusy(false); + } + }, + [scheduleSave], + ); + + useEffect(() => { + let cancelled = false; + let unlisten: (() => void) | undefined; + + const toClientPoint = (x: number, y: number) => { + const scale = window.devicePixelRatio || 1; + if (scale !== 1 && (x > window.innerWidth || y > window.innerHeight)) { + return { x: x / scale, y: y / scale }; + } + return { x, y }; + }; + const overDropZone = (x: number, y: number) => { + const zone = dropZoneRef.current; + if (!zone) return false; + const point = toClientPoint(x, y); + const rect = zone.getBoundingClientRect(); + return ( + point.x >= rect.left && + point.x <= rect.right && + point.y >= rect.top && + point.y <= rect.bottom + ); + }; + + void getCurrentWebview() + .onDragDropEvent((event) => { + if (event.payload.type === "leave") { + setImageDrag(false); + return; + } + const { x, y } = event.payload.position; + const over = overDropZone(x, y); + if (event.payload.type === "enter" || event.payload.type === "over") { + setImageDrag(over); + return; + } + if (event.payload.type !== "drop") return; + setImageDrag(false); + if (!over || Date.now() - lastDropAt.current < 250) return; + lastDropAt.current = Date.now(); + const range = insertionRange(); + const paths = event.payload.paths; + void addDroppedImages( + () => saveNoteImagesFromPaths(note.id, paths), + range, + ); + }) + .then((fn) => { + if (cancelled) fn(); + else unlisten = fn; + }) + .catch(() => undefined); + + return () => { + cancelled = true; + unlisten?.(); + }; + }, [addDroppedImages, insertionRange, note.id]); + useEffect(() => { return () => { if (saveTimer.current != null) window.clearTimeout(saveTimer.current); @@ -658,20 +776,59 @@ function NoteEditor({ onSelect={() => setMode("source")} />
    - {mode === "source" ? ( - { - setBody(next); - scheduleSave(); - }} - /> - ) : body.trim() ? ( - - ) : ( -

    No description

    - )} +
    ) => { + if (!hasDroppedFiles(event.dataTransfer)) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + setImageDrag(true); + }} + onDragLeave={(event: ReactDragEvent) => { + const next = event.relatedTarget as Node | null; + if (next && event.currentTarget.contains(next)) return; + setImageDrag(false); + }} + onDrop={(event: ReactDragEvent) => { + if (!hasDroppedFiles(event.dataTransfer)) return; + event.preventDefault(); + setImageDrag(false); + if (Date.now() - lastDropAt.current < 250) return; + lastDropAt.current = Date.now(); + const files = [...event.dataTransfer.files]; + if (files.length === 0) return; + const range = insertionRange(); + void addDroppedImages( + () => saveNoteImagesFromFiles(note.id, files), + range, + ); + }} + > + {imageDrag || imageBusy ? ( +
    + {imageBusy ? "Adding images…" : "Drop images here"} +
    + ) : null} + {mode === "source" ? ( + { + setBody(next); + scheduleSave(); + }} + /> + ) : body.trim() ? ( + + ) : ( +

    No description

    + )} +
    ); @@ -680,10 +837,12 @@ function NoteEditor({ function NoteSource({ value, onChange, + textareaRef, autoFocus = false, }: { value: string; onChange: (value: string) => void; + textareaRef: { current: HTMLTextAreaElement | null }; autoFocus?: boolean; }) { const lines = value.split("\n"); @@ -716,6 +875,7 @@ function NoteSource({ style={{ left: gutterWidth }} />