From becd240168c4f0d9f95e32faea0bc09ecaed4789 Mon Sep 17 00:00:00 2001 From: Dave Otero <31802309+daveotero@users.noreply.github.com> Date: Tue, 26 May 2026 11:13:59 -0400 Subject: [PATCH] Add Windows Electron desktop support (#1093) * fix: make upstream sync actions target the selected remote Ensure fetch and pull actually honor upstream selection so fork maintenance works from the Git sidebar, and surface upstream branch status alongside the primary origin-tracking indicators. * feat: add Windows Electron desktop foundation * fix(electron): stabilize Windows desktop packaging * fix(electron): stabilize Windows desktop chrome Use native Windows titlebar behavior with an Alt-accessible hidden menu, and harden Windows dev command launching so the desktop app follows platform conventions. * fix(electron): stabilize Windows dev startup * fix(electron): clarify desktop artifact names * fix(electron): harden Windows desktop release and launch * fix(electron): address Windows release review * fix(electron): point updater and release links to org repo * Fix Windows settings persistence fallback * Fix Windows Electron dev startup * Add Windows Electron window controls * Fix Windows Electron install and opencode launch * fix: resolve git status for repositories without upstream Fixes repository detection stuck on Checking repository Handles git status when no upstream is configured Adds regression coverage for git status loading * Add Windows app menu button * fix: preserve file editor line endings * ci: add desktop release smoke workflow --------- Co-authored-by: Bohdan Triapitsyn --- .github/workflows/release-desktop-smoke.yml | 224 +++++++ .github/workflows/release.yml | 80 ++- bun.lock | 12 +- package.json | 1 + packages/electron/main.mjs | 615 ++++++++++++++++-- packages/electron/package.json | 25 +- packages/electron/preload.mjs | 2 + packages/electron/resources/icons/icon.ico | Bin 0 -> 29307 bytes .../electron/scripts/build-web-assets.mjs | 17 +- packages/electron/scripts/electron-dev.mjs | 103 ++- packages/electron/scripts/package.mjs | 41 ++ packages/electron/scripts/rebuild-native.mjs | 140 +++- packages/ui/package.json | 1 + .../src/components/auth/SessionAuthGate.tsx | 52 +- .../ui/src/components/chat/FileAttachment.tsx | 4 +- .../chat/MobileSessionStatusBar.tsx | 2 - .../ui/src/components/chat/ModelControls.tsx | 1 - .../components/chat/message/MessageHeader.tsx | 1 - .../chat/message/parts/ToolPart.tsx | 1 - packages/ui/src/components/icon/sprite.ts | 1 + packages/ui/src/components/layout/Header.tsx | 110 +++- .../components/layout/SidebarFilesTree.tsx | 32 +- .../skills/catalog/AddCatalogDialog.tsx | 2 +- .../session/sidebar/SessionGroupSection.tsx | 2 - .../ui/src/components/views/FilesView.tsx | 46 +- packages/ui/src/components/views/GitView.tsx | 68 +- .../ui/src/components/views/git/GitHeader.tsx | 63 +- packages/ui/src/hooks/useBrowserVoice.ts | 6 +- packages/ui/src/hooks/useMenuActions.ts | 68 ++ .../hooks/useWindowControlsOverlayLayout.ts | 2 + packages/ui/src/lib/api/types.ts | 8 + packages/ui/src/lib/i18n/messages/en.ts | 12 +- packages/ui/src/lib/i18n/messages/es.ts | 12 +- packages/ui/src/lib/i18n/messages/ko.ts | 12 +- packages/ui/src/lib/i18n/messages/pl.ts | 10 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 12 +- packages/ui/src/lib/i18n/messages/uk.ts | 12 +- packages/ui/src/lib/i18n/messages/zh-CN.ts | 12 +- packages/ui/src/lib/i18n/messages/zh-TW.ts | 10 + packages/ui/src/lib/openInApps.ts | 14 +- packages/ui/src/stores/useGitStore.ts | 35 +- packages/ui/src/stores/useOpenInAppsStore.ts | 6 +- packages/ui/src/types/desktop.d.ts | 1 + .../ui/src/types/xenova-transformers.d.ts | 20 + packages/vscode/package.json | 2 +- packages/vscode/src/gitService.ts | 2 +- packages/vscode/src/skillsCatalog.ts | 4 +- packages/web/server/lib/fs/routes.js | 5 + packages/web/server/lib/fs/routes.test.js | 62 +- packages/web/server/lib/git/DOCUMENTATION.md | 1 + packages/web/server/lib/git/service.js | 189 +++++- packages/web/server/lib/git/service.test.js | 57 +- .../web/server/lib/opencode/env-runtime.js | 56 +- .../server/lib/opencode/env-runtime.test.js | 88 ++- .../server/lib/opencode/settings-runtime.js | 40 +- .../lib/opencode/settings-runtime.test.js | 39 ++ .../web/server/lib/skills-catalog/source.js | 2 +- packages/web/src/api/files.ts | 5 +- scripts/dev-web-hmr.mjs | 56 +- 59 files changed, 2260 insertions(+), 246 deletions(-) create mode 100644 .github/workflows/release-desktop-smoke.yml create mode 100644 packages/electron/resources/icons/icon.ico create mode 100644 packages/electron/scripts/package.mjs create mode 100644 packages/ui/src/types/xenova-transformers.d.ts diff --git a/.github/workflows/release-desktop-smoke.yml b/.github/workflows/release-desktop-smoke.yml new file mode 100644 index 00000000..6660213e --- /dev/null +++ b/.github/workflows/release-desktop-smoke.yml @@ -0,0 +1,224 @@ +name: Desktop Release Build Smoke + +on: + workflow_dispatch: + inputs: + repository: + description: Repository to checkout, for example openchamber/openchamber or daveotero/openchamber + required: false + default: openchamber/openchamber + type: string + ref: + description: Git ref to build (branch, tag, or sha) + required: true + default: feat/windows-desktop-app + type: string + build_macos: + description: Build signed/notarized macOS Electron artifacts + required: false + default: true + type: boolean + build_windows: + description: Build Windows Electron installer artifacts + required: false + default: true + type: boolean + retention_days: + description: Artifact retention days + required: false + default: "7" + type: choice + options: + - "1" + - "3" + - "7" + - "14" + +permissions: + contents: read + +jobs: + build-macos-electron: + if: ${{ inputs.build_macos }} + name: Build macOS Electron (${{ matrix.arch }}) + runs-on: macos-26 + strategy: + fail-fast: false + matrix: + include: + - target: aarch64-apple-darwin + arch: arm64 + platform: darwin-aarch64 + - target: x86_64-apple-darwin + arch: x64 + platform: darwin-x86_64 + steps: + - name: Checkout selected ref + uses: actions/checkout@v4 + with: + repository: ${{ inputs.repository || github.repository }} + ref: ${{ inputs.ref || github.ref }} + + - name: Setup bun + uses: oven-sh/setup-bun@v2 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Install Apple Certificate + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + run: | + KEYCHAIN_PATH=$RUNNER_TEMP/electron-signing.keychain-db + KEYCHAIN_PASSWORD=$(openssl rand -base64 32) + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + echo "$APPLE_CERTIFICATE" | base64 --decode > $RUNNER_TEMP/certificate.p12 + security import $RUNNER_TEMP/certificate.p12 \ + -P "$APPLE_CERTIFICATE_PASSWORD" \ + -A -t cert -f pkcs12 \ + -k "$KEYCHAIN_PATH" + + security list-keychain -d user -s "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple:,codesign: \ + -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + - name: Build Electron app + working-directory: packages/electron + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + ELECTRON_BUILDER_ARCH: ${{ matrix.arch }} + run: | + bun run build:web-assets + bun run bundle:main + # npmRebuild=false in package.json, so electron-builder won't + # recompile native deps on its own. Rebuild against the target + # Electron ABI before packaging, matching the release workflow. + bun run rebuild:native + bunx electron-builder --mac --${{ matrix.arch }} --publish=never + + - name: Verify signature + entitlements + notarization + run: | + set -euo pipefail + + APP_DIR="packages/electron/dist/mac" + [ -d "packages/electron/dist/mac-arm64" ] && APP_DIR="packages/electron/dist/mac-arm64" + + APP_PATH=$(find "$APP_DIR" -maxdepth 2 -name "*.app" -print -quit) + if [ -z "$APP_PATH" ]; then + echo "Error: .app not found under packages/electron/dist/mac*" + ls -la packages/electron/dist/ + exit 1 + fi + + echo "Verifying $APP_PATH" + codesign -vv --deep --strict "$APP_PATH" + + CS_INFO=$(codesign -dv --verbose=4 "$APP_PATH" 2>&1) + echo "$CS_INFO" + if ! echo "$CS_INFO" | grep -q "flags=.*runtime"; then + echo "Error: hardened runtime flag missing" + exit 1 + fi + + xcrun stapler validate "$APP_PATH" + + ENTITLEMENTS=$(codesign -d --entitlements :- "$APP_PATH" 2>&1 || true) + if echo "$ENTITLEMENTS" | grep -q "com.apple.security.app-sandbox"; then + echo "Error: app sandbox entitlement is present" + exit 1 + fi + for key in \ + com.apple.security.cs.allow-jit \ + com.apple.security.cs.allow-unsigned-executable-memory \ + com.apple.security.cs.disable-library-validation + do + if ! echo "$ENTITLEMENTS" | grep -q "$key"; then + echo "Error: required entitlement missing: $key" + exit 1 + fi + done + + - name: Upload macOS installable artifacts + uses: actions/upload-artifact@v4 + with: + name: desktop-release-smoke-macos-${{ matrix.arch }} + path: | + packages/electron/dist/*.dmg + packages/electron/dist/*.zip + packages/electron/dist/*.blockmap + packages/electron/dist/latest-mac.yml + if-no-files-found: error + retention-days: ${{ fromJSON(inputs.retention_days) }} + + build-windows-electron: + if: ${{ inputs.build_windows }} + name: Build Windows Electron (x64) + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + include: + - arch: x64 + target: x86_64-pc-windows-msvc + platform: win32-x64 + steps: + - name: Checkout selected ref + uses: actions/checkout@v4 + with: + repository: ${{ inputs.repository || github.repository }} + ref: ${{ inputs.ref || github.ref }} + + - name: Setup bun + uses: oven-sh/setup-bun@v2 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build web assets + working-directory: packages/electron + run: bun run build:web-assets + + - name: Bundle main process + working-directory: packages/electron + run: bun run bundle:main + + - name: Rebuild native modules + working-directory: packages/electron + shell: bash + # npmRebuild=false in package.json, so electron-builder won't + # recompile native deps on its own. Rebuild against the target + # Electron ABI before packaging, matching the release workflow. + run: node ./scripts/rebuild-native.mjs + + - name: Build Windows app + working-directory: packages/electron + shell: bash + run: node ./scripts/package.mjs --win --${{ matrix.arch }} --publish=never + + - name: Upload Windows installable artifacts + uses: actions/upload-artifact@v4 + with: + name: desktop-release-smoke-windows-${{ matrix.arch }} + path: | + packages/electron/dist/*.exe + packages/electron/dist/*.blockmap + packages/electron/dist/latest.yml + if-no-files-found: error + retention-days: ${{ fromJSON(inputs.retention_days) }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9764939d..f7826855 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -185,7 +185,7 @@ jobs: # target Electron ABI before packaging, otherwise better-sqlite3/ # node-pty/bun-pty crash on require inside the packaged app. bun run rebuild:native - ./node_modules/.bin/electron-builder --mac --${{ matrix.arch }} --publish=never + bunx electron-builder --mac --${{ matrix.arch }} --publish=never - name: Verify signature + entitlements + notarization run: | @@ -275,6 +275,68 @@ jobs: path: packages/electron/dist/latest-mac.yml retention-days: 1 + build-desktop-electron-windows: + needs: create-release + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + include: + - arch: x64 + target: x86_64-pc-windows-msvc + platform: win32-x64 + steps: + - uses: actions/checkout@v4 + + - name: Setup bun + uses: oven-sh/setup-bun@v2 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build web assets + working-directory: packages/electron + run: bun run build:web-assets + + - name: Bundle main process + working-directory: packages/electron + run: bun run bundle:main + + - name: Rebuild native modules + working-directory: packages/electron + shell: bash + # npmRebuild=false in package.json, so electron-builder won't + # recompile native deps on its own — we must rebuild against the + # target Electron ABI before packaging. + run: node ./scripts/rebuild-native.mjs + + - name: Build Windows app + working-directory: packages/electron + shell: bash + run: node ./scripts/package.mjs --win --${{ matrix.arch }} --publish=never + + - name: Upload installer to release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ needs.create-release.outputs.version }} + files: | + packages/electron/dist/*.exe + packages/electron/dist/*.blockmap + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload update manifest as artifact + uses: actions/upload-artifact@v4 + with: + name: latest-yml-${{ matrix.target }} + path: packages/electron/dist/latest.yml + retention-days: 1 + repackage-electron-as-tauri-update: needs: [create-release, build-desktop-electron-macos] runs-on: macos-26 @@ -449,7 +511,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} combine-electron-manifests: - needs: [create-release, build-desktop-electron-macos] + needs: [create-release, build-desktop-electron-macos, build-desktop-electron-windows] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -465,6 +527,12 @@ jobs: pattern: latest-yml-*-apple-darwin path: artifacts + - name: Download Windows latest.yml + uses: actions/download-artifact@v4 + with: + pattern: latest-yml-*-pc-windows-* + path: artifacts + - name: Finalize combined latest-mac.yml env: LATEST_YML_DIR: ${{ github.workspace }}/artifacts @@ -472,16 +540,18 @@ jobs: OPENCHAMBER_VERSION: ${{ needs.create-release.outputs.version }} run: node packages/electron/scripts/finalize-latest-yml.mjs - - name: Upload combined latest-mac.yml to release + - name: Upload combined manifests to release uses: softprops/action-gh-release@v2 with: tag_name: v${{ needs.create-release.outputs.version }} - files: ${{ runner.temp }}/latest-mac.yml + files: | + ${{ runner.temp }}/latest-mac.yml + ${{ runner.temp }}/latest.yml env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} finalize-release: - needs: [create-release, build-desktop-electron-macos, repackage-electron-as-tauri-update, publish-npm, combine-manifests, combine-electron-manifests] + needs: [create-release, build-desktop-electron-macos, build-desktop-electron-windows, repackage-electron-as-tauri-update, publish-npm, combine-manifests, combine-electron-manifests] runs-on: ubuntu-latest env: DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} diff --git a/bun.lock b/bun.lock index e62d8f72..feb3f111 100644 --- a/bun.lock +++ b/bun.lock @@ -84,6 +84,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.3.0", + "node-addon-api": "7.1.1", "nodemon": "^3.1.7", "patch-package": "^8.0.0", "sharp": "^0.34.5", @@ -97,7 +98,7 @@ }, "packages/desktop": { "name": "@openchamber/desktop", - "version": "1.11.4", + "version": "1.11.6", "devDependencies": { "@tauri-apps/cli": "^2", "@types/node": "^24.3.1", @@ -106,7 +107,7 @@ }, "packages/electron": { "name": "@openchamber/electron", - "version": "1.11.4", + "version": "1.11.6", "dependencies": { "@openchamber/web": "workspace:*", "electron-context-menu": "^4.1.2", @@ -121,7 +122,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.11.4", + "version": "1.11.6", "dependencies": { "@base-ui/react": "^1.4.0", "@codemirror/autocomplete": "^6.20.0", @@ -157,6 +158,7 @@ "@simplewebauthn/browser": "13.3.0", "@tanstack/react-virtual": "^3.13.18", "@types/react-syntax-highlighter": "^15.5.13", + "@xenova/transformers": "^2.17.2", "beautiful-mermaid": "^1.1.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -220,7 +222,7 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.11.4", + "version": "1.11.6", "dependencies": { "@openchamber/ui": "workspace:*", "@opencode-ai/sdk": "^1.15.10", @@ -243,7 +245,7 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.11.4", + "version": "1.11.6", "bin": { "openchamber": "./bin/cli.js", }, diff --git a/package.json b/package.json index ee9bc86a..6340fb3e 100644 --- a/package.json +++ b/package.json @@ -152,6 +152,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.3.0", + "node-addon-api": "7.1.1", "nodemon": "^3.1.7", "patch-package": "^8.0.0", "@remixicon/react": "^4.7.0", diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index bec407b8..b918c648 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -1,4 +1,4 @@ -import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, Notification, powerMonitor, session, shell, webContents } from 'electron'; +import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, Notification, powerMonitor, screen, session, shell, webContents } from 'electron'; import contextMenu from 'electron-context-menu'; import log from 'electron-log/main.js'; import dgram from 'node:dgram'; @@ -19,7 +19,9 @@ const __dirname = path.dirname(__filename); const isDev = process.env.OPENCHAMBER_ELECTRON_DEV === '1' || !app.isPackaged; const DEEP_LINK_PROTOCOL = 'openchamber'; -const APP_USER_MODEL_ID = 'dev.openchamber.desktop'; +const PACKAGED_APP_USER_MODEL_ID = 'dev.openchamber.desktop'; +const DEV_APP_USER_MODEL_ID = 'dev.openchamber.desktop.dev'; +const APP_USER_MODEL_ID = app.isPackaged ? PACKAGED_APP_USER_MODEL_ID : DEV_APP_USER_MODEL_ID; const BACKGROUND_START_ARG = '--background'; const readLoginItemSettings = () => { @@ -39,17 +41,17 @@ const shouldStartInBackground = (loginItemSettings = readLoginItemSettings()) => ); }; -if (!app.requestSingleInstanceLock()) { - app.exit(0); - process.exit(0); -} - // Set the product name early so electron-log derives its log directory as // ~/Library/Logs/OpenChamber/ (not ~/Library/Logs/@openchamber/electron/). app.setName('OpenChamber'); app.setAppUserModelId(APP_USER_MODEL_ID); app.commandLine.appendSwitch('proxy-bypass-list', '<-loopback>'); +if (!app.requestSingleInstanceLock()) { + app.exit(0); + process.exit(0); +} + try { process.chdir(os.homedir()); } catch { @@ -447,6 +449,35 @@ const readWindowState = () => { return stateValue && typeof stateValue === 'object' ? stateValue : null; }; +const clampWindowBoundsToVisibleWorkArea = (bounds) => { + const width = Math.max(MIN_RESTORE_WINDOW_WIDTH, Math.round(Number(bounds?.width) || 0)); + const height = Math.max(MIN_RESTORE_WINDOW_HEIGHT, Math.round(Number(bounds?.height) || 0)); + const x = Math.round(Number(bounds?.x)); + const y = Math.round(Number(bounds?.y)); + + if (!Number.isFinite(x) || !Number.isFinite(y)) { + return { width, height }; + } + + try { + const display = screen.getDisplayMatching({ x, y, width, height }) || screen.getPrimaryDisplay(); + const workArea = display.workArea; + const clampedWidth = Math.min(width, Math.max(MIN_WINDOW_WIDTH, workArea.width)); + const clampedHeight = Math.min(height, Math.max(MIN_WINDOW_HEIGHT, workArea.height)); + const maxX = workArea.x + workArea.width - clampedWidth; + const maxY = workArea.y + workArea.height - clampedHeight; + + return { + x: clampedWidth >= workArea.width ? workArea.x : Math.min(Math.max(x, workArea.x), maxX), + y: clampedHeight >= workArea.height ? workArea.y : Math.min(Math.max(y, workArea.y), maxY), + width: clampedWidth, + height: clampedHeight, + }; + } catch { + return { x, y, width, height }; + } +}; + const writeWindowState = async (browserWindow) => { if (!browserWindow || browserWindow.isDestroyed()) return; if (!state.mainWindow || browserWindow.id !== state.mainWindow.id) return; @@ -718,12 +749,54 @@ const probeShellEnv = (shell, mode) => { return Object.keys(env).length > 0 ? env : null; }; +const queryWindowsRegistryValue = (key, name) => { + const result = spawnSync('reg.exe', ['query', key, '/v', name], { + encoding: 'utf8', + windowsHide: true, + }); + if (result.error || result.status !== 0) return ''; + const line = String(result.stdout || '') + .split(/\r?\n/) + .map((entry) => entry.trim()) + .find((entry) => entry.toLowerCase().startsWith(name.toLowerCase())); + if (!line) return ''; + const match = line.match(/^\S+\s+REG_\S+\s+(.+)$/); + return match?.[1]?.trim() || ''; +}; + +const expandWindowsEnvRefs = (value) => String(value || '').replace(/%([^%]+)%/g, (_match, key) => process.env[key] || ''); + +const loadWindowsEnv = () => { + const machinePath = queryWindowsRegistryValue('HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment', 'Path'); + const userPath = queryWindowsRegistryValue('HKCU\\Environment', 'Path'); + const homeDir = os.homedir(); + const localAppData = process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'); + const appData = process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'); + const commonPaths = [ + path.join(homeDir, '.opencode', 'bin'), + path.join(homeDir, '.bun', 'bin'), + path.join(homeDir, '.local', 'bin'), + path.join(localAppData, 'Programs', 'Microsoft VS Code', 'bin'), + path.join(localAppData, 'Programs', 'Cursor', 'resources', 'app', 'bin'), + path.join(appData, 'npm'), + ]; + return { + PATH: [machinePath, userPath, process.env.PATH, ...commonPaths] + .map(expandWindowsEnvRefs) + .filter(Boolean) + .join(path.delimiter), + }; +}; + // Finder-launched apps on macOS inherit a minimal PATH (no /opt/homebrew, mise, asdf, etc.). // Probe the user's login shell once so the sidecar sees the same PATH / tool env as `$SHELL -il`. const loadShellEnv = () => { if (shellEnvProbed) return cachedShellEnv; shellEnvProbed = true; - if (process.platform === 'win32') return null; + if (process.platform === 'win32') { + cachedShellEnv = loadWindowsEnv(); + return cachedShellEnv; + } const shell = process.env.SHELL || '/bin/sh'; if (isNushell(shell)) return null; cachedShellEnv = probeShellEnv(shell, '-il') || probeShellEnv(shell, '-l'); @@ -742,7 +815,8 @@ const inheritUserShellEnv = () => { const homeDir = os.homedir(); const currentPath = process.env.PATH || ''; - const currentPathLooksUserConfigured = pathLooksUserConfigured(currentPath, homeDir, ':'); + const delimiter = process.platform === 'win32' ? ';' : ':'; + const currentPathLooksUserConfigured = pathLooksUserConfigured(currentPath, homeDir, delimiter); for (const [key, value] of Object.entries(shellEnv)) { if (key === 'PATH') continue; @@ -752,8 +826,8 @@ const inheritUserShellEnv = () => { } const shellPath = typeof shellEnv.PATH === 'string' ? shellEnv.PATH : ''; - if (!currentPathLooksUserConfigured && shellPath) { - process.env.PATH = mergePathValues(shellPath, currentPath, ':'); + if ((process.platform === 'win32' || !currentPathLooksUserConfigured) && shellPath) { + process.env.PATH = mergePathValues(shellPath, currentPath, delimiter); } }; @@ -1021,6 +1095,15 @@ const emitToAllWindows = (event, detail) => { } }; +const setTaskbarProgress = (value) => { + if (process.platform !== 'win32') return; + for (const browserWindow of BrowserWindow.getAllWindows()) { + if (!browserWindow.isDestroyed()) { + browserWindow.setProgressBar(value); + } + } +}; + const pendingDeepLinks = []; const parseDeepLink = (raw) => { @@ -1176,24 +1259,53 @@ const readThemeSource = () => { return 'system'; }; +const getWindowIconPath = () => { + if (process.platform !== 'win32' && process.platform !== 'linux') { + return undefined; + } + const iconPath = isDev + ? path.join(__dirname, 'resources', 'icons', 'icon.ico') + : path.join(process.resourcesPath, 'icons', 'icon.ico'); + return fs.existsSync(iconPath) ? iconPath : undefined; +}; + +const canUseTitleBarOverlay = (browserWindow) => ( + process.platform === 'win32' && + Boolean(browserWindow?.__ocTitleBarOverlayEnabled) && + typeof browserWindow.setTitleBarOverlay === 'function' && + !browserWindow.isDestroyed() +); + const createBrowserWindow = ({ label, restoreGeometry, url }) => { const saved = restoreGeometry ? readWindowState() : null; const useSaved = saved && typeof saved.width === 'number' && typeof saved.height === 'number'; + const restoredBounds = useSaved ? clampWindowBoundsToVisibleWorkArea(saved) : null; const desktopLocalOrigin = state.localOrigin || ''; const desktopHome = os.homedir() || ''; const desktopMacosMajor = String(macosMajorVersion()); + const usesCustomTitleBar = process.platform === 'darwin' || process.platform === 'win32'; + const titleBarOverlayEnabled = false; + const autoHidesNativeMenuBar = process.platform !== 'darwin'; + const windowIconPath = getWindowIconPath(); const options = { title: 'OpenChamber', - width: useSaved ? Math.max(saved.width, MIN_RESTORE_WINDOW_WIDTH) : 1280, - height: useSaved ? Math.max(saved.height, MIN_RESTORE_WINDOW_HEIGHT) : 800, + ...(Number.isFinite(restoredBounds?.x) && Number.isFinite(restoredBounds?.y) + ? { x: restoredBounds.x, y: restoredBounds.y } + : {}), + width: restoredBounds?.width ?? 1280, + height: restoredBounds?.height ?? 800, minWidth: MIN_WINDOW_WIDTH, minHeight: MIN_WINDOW_HEIGHT, + icon: windowIconPath, show: false, backgroundColor: '#151313', + frame: process.platform === 'win32' ? false : undefined, + autoHideMenuBar: autoHidesNativeMenuBar, // Tauri used an overlay title bar with explicit traffic-light placement. // Electron's hiddenInset adds its own extra inset, which leaves the controls // visibly lower than the app header. Use a plain hidden title bar instead. - titleBarStyle: process.platform === 'darwin' ? 'hidden' : 'default', + titleBarStyle: usesCustomTitleBar ? 'hidden' : 'default', + titleBarOverlay: titleBarOverlayEnabled, trafficLightPosition: process.platform === 'darwin' ? { x: 16, y: 17 } : undefined, webPreferences: { additionalArguments: [ @@ -1217,10 +1329,7 @@ const createBrowserWindow = ({ label, restoreGeometry, url }) => { const browserWindow = new BrowserWindow(options); browserWindow.__ocLabel = label || nextWindowLabel(); - - if (useSaved && Number.isFinite(saved.x) && Number.isFinite(saved.y)) { - browserWindow.setPosition(saved.x, saved.y); - } + browserWindow.__ocTitleBarOverlayEnabled = titleBarOverlayEnabled; if (useSaved && saved.maximized) { browserWindow.maximize(); @@ -1260,6 +1369,14 @@ const createBrowserWindow = ({ label, restoreGeometry, url }) => { emitToWindow(browserWindow, 'openchamber:window-resized'); debounceWindowStatePersist(browserWindow, false); }); + browserWindow.on('maximize', () => { + emitToWindow(browserWindow, 'openchamber:window-maximized-changed', { maximized: true }); + debounceWindowStatePersist(browserWindow, false); + }); + browserWindow.on('unmaximize', () => { + emitToWindow(browserWindow, 'openchamber:window-maximized-changed', { maximized: false }); + debounceWindowStatePersist(browserWindow, false); + }); browserWindow.on('move', () => { debounceWindowStatePersist(browserWindow, false); }); @@ -1458,6 +1575,7 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj height: MINI_CHAT_WINDOW_HEIGHT, minWidth: MINI_CHAT_MIN_WINDOW_WIDTH, minHeight: MINI_CHAT_MIN_WINDOW_HEIGHT, + icon: getWindowIconPath(), show: false, backgroundColor: '#151313', titleBarStyle: process.platform === 'darwin' ? 'hidden' : 'default', @@ -1557,12 +1675,16 @@ const setMiniChatPinned = (browserWindow, pinned) => { }; const resolveInitialUrl = async () => { - const localUrl = isDev && await waitForHealth('http://127.0.0.1:3901', 5_000, 100) - ? 'http://127.0.0.1:3901' + const hmrApiPort = process.env.OPENCHAMBER_HMR_API_PORT || '3901'; + const hmrUiPort = process.env.OPENCHAMBER_HMR_UI_PORT || '5173'; + const hmrApiUrl = `http://127.0.0.1:${hmrApiPort}`; + const hmrUiUrl = `http://127.0.0.1:${hmrUiPort}`; + const localUrl = isDev && await waitForHealth(hmrApiUrl, 5_000, 100) + ? hmrApiUrl : await spawnLocalServer(); - const localUiUrl = isDev && await waitForHealth('http://127.0.0.1:5173', 8_000, 100) - ? 'http://127.0.0.1:5173' + const localUiUrl = isDev && await waitForHealth(hmrUiUrl, 8_000, 100) + ? hmrUiUrl : localUrl; state.sidecarUrl = localUrl; @@ -1638,6 +1760,9 @@ const setupAutoUpdater = () => { }); autoUpdater.on('download-progress', (progress) => { + const total = Number(progress.total || 0); + const transferred = Number(progress.transferred || 0); + setTaskbarProgress(total > 0 ? Math.max(0, Math.min(1, transferred / total)) : 0.01); emitToAllWindows('openchamber:update-progress', mapUpdaterProgressEvent({ event: 'Progress', data: { @@ -1650,12 +1775,14 @@ const setupAutoUpdater = () => { autoUpdater.on('update-downloaded', (info) => { log.info(`[electron] update-downloaded version=${info?.version || 'unknown'}`); + setTaskbarProgress(-1); if (state.pendingUpdate) { state.pendingUpdate.downloaded = true; } }); autoUpdater.on('error', (err) => { + setTaskbarProgress(-1); log.error('[electron] autoUpdater error', err); }); }; @@ -1824,6 +1951,138 @@ const CLI_BY_APP_ID = { zed: 'zed', }; +const WINDOWS_CLI_BY_APP_ID = { + vscode: 'code.cmd', + cursor: 'cursor.cmd', + vscodium: 'codium.cmd', + windsurf: 'windsurf.cmd', + zed: 'zed.cmd', +}; + +const WINDOWS_APP_EXECUTABLES = { + terminal: ['wt.exe', 'WindowsTerminal.exe'], + vscode: ['code.cmd', 'code.exe'], + cursor: ['cursor.cmd', 'cursor.exe'], + vscodium: ['codium.cmd', 'codium.exe'], + windsurf: ['windsurf.cmd', 'windsurf.exe'], + zed: ['zed.exe'], + 'visual-studio': ['devenv.exe'], + 'sublime-text': ['subl.exe', 'sublime_text.exe'], +}; + +const WINDOWS_APP_ID_BY_NAME = new Map([ + ['finder', 'finder'], + ['file explorer', 'finder'], + ['terminal', 'terminal'], + ['windows terminal', 'terminal'], + ['visual studio code', 'vscode'], + ['cursor', 'cursor'], + ['vscodium', 'vscodium'], + ['windsurf', 'windsurf'], + ['zed', 'zed'], + ['visual studio', 'visual-studio'], + ['sublime text', 'sublime-text'], +]); + +const getWindowsAppIdForName = (appName) => WINDOWS_APP_ID_BY_NAME.get(String(appName || '').trim().toLowerCase()) || ''; + +const runWhere = (program) => { + const result = spawnSync('where.exe', [program], { encoding: 'utf8', windowsHide: true }); + if (result.error || result.status !== 0) return null; + const first = String(result.stdout || '').split(/\r?\n/).map((line) => line.trim()).find(Boolean); + return first || null; +}; + +const findWindowsExecutable = (appId) => { + for (const program of WINDOWS_APP_EXECUTABLES[appId] || []) { + const resolved = runWhere(program); + if (resolved) return resolved; + } + return null; +}; + +const findWindowsAppNameExecutable = (appName) => { + const program = `${String(appName || '').trim()}.exe`.replace(/\s+/g, ''); + return program === '.exe' ? null : runWhere(program); +}; + +const isWindowsAppInstalled = ({ appId, appName }) => { + if (appId === 'finder') return true; + if (appId === 'terminal') return Boolean(findWindowsExecutable('terminal')); + if (findWindowsExecutable(appId)) return true; + return Boolean(findWindowsAppNameExecutable(appName)); +}; + +const buildWindowsInstalledApps = (apps) => { + const seen = new Set(); + return (Array.isArray(apps) ? apps : []) + .map((appName) => String(appName || '').trim()) + .filter((appName) => appName && !seen.has(appName) && seen.add(appName)) + .filter((appName) => isWindowsAppInstalled({ appId: getWindowsAppIdForName(appName), appName })) + .map((name) => ({ name, iconDataUrl: null })); +}; + +const buildWindowsOpenProjectSpecs = ({ projectPath, appId, appName }) => { + if (appId === 'finder') { + return [{ program: 'explorer.exe', args: [projectPath] }]; + } + if (appId === 'terminal') { + const specs = []; + const terminal = findWindowsExecutable('terminal'); + if (terminal) { + specs.push({ program: terminal, args: ['-d', projectPath] }); + } + const shell = runWhere('pwsh.exe') || runWhere('powershell.exe'); + if (shell) { + specs.push({ program: shell, args: ['-NoExit', '-Command', `Set-Location -LiteralPath ${JSON.stringify(projectPath)}`] }); + } + return specs; + } + const specs = []; + const cli = WINDOWS_CLI_BY_APP_ID[appId]; + if (cli) { + const resolvedCli = runWhere(cli); + if (resolvedCli) { + specs.push({ program: resolvedCli, args: [projectPath] }); + } + } + const exe = findWindowsExecutable(appId); + if (exe) { + specs.push({ program: exe, args: [projectPath] }); + } + const namedExe = findWindowsAppNameExecutable(appName); + if (namedExe && !specs.some((spec) => spec.program === namedExe)) { + specs.push({ program: namedExe, args: [projectPath] }); + } + return specs; +}; + +const buildWindowsOpenFileSpecs = ({ filePath, appId, appName }) => { + if (appId === 'finder') { + return [{ program: 'explorer.exe', args: ['/select,', filePath] }]; + } + if (appId === 'terminal') { + return buildWindowsOpenProjectSpecs({ projectPath: path.dirname(filePath), appId, appName }); + } + const specs = []; + const cli = WINDOWS_CLI_BY_APP_ID[appId]; + if (cli) { + const resolvedCli = runWhere(cli); + if (resolvedCli) { + specs.push({ program: resolvedCli, args: [filePath] }); + } + } + const exe = findWindowsExecutable(appId); + if (exe) { + specs.push({ program: exe, args: [filePath] }); + } + const namedExe = findWindowsAppNameExecutable(appName); + if (namedExe && !specs.some((spec) => spec.program === namedExe)) { + specs.push({ program: namedExe, args: [filePath] }); + } + return specs; +}; + const buildOpenProjectSpecs = ({ projectPath, appId, appName }) => { if (appId === 'finder') { return [{ program: 'open', args: [projectPath] }]; @@ -1869,10 +2128,66 @@ const buildOpenFileSpecs = ({ filePath, appId, appName }) => { return specs; }; +const quoteWindowsCommandArg = (value) => `"${String(value).replace(/"/g, '""')}"`; + +const resolveWindowsLaunchProgram = (program) => { + if (path.isAbsolute(program)) { + return fs.existsSync(program) ? program : null; + } + return runWhere(program); +}; + +const launchWindowsCommandScript = (spec, program) => { + const commandLine = ['call', quoteWindowsCommandArg(program), ...spec.args.map(quoteWindowsCommandArg)].join(' '); + const child = spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', commandLine], { + detached: true, + stdio: 'ignore', + windowsHide: false, + windowsVerbatimArguments: true, + }); + child.unref(); +}; + +const launchWindowsSpec = (spec) => { + const program = resolveWindowsLaunchProgram(spec.program); + if (!program) { + throw new Error('program not found'); + } + + if (/\.(cmd|bat)$/i.test(program)) { + launchWindowsCommandScript(spec, program); + return; + } + + const child = spawn(program, spec.args, { + detached: true, + stdio: 'ignore', + windowsHide: false, + }); + child.unref(); +}; + const runSpecChain = (specs, appName) => { + if (!Array.isArray(specs) || specs.length === 0) { + throw new Error(`Failed to open in ${appName}: no launch candidates`); + } + + if (process.platform === 'win32') { + const failures = []; + for (const spec of specs) { + try { + launchWindowsSpec(spec); + return; + } catch (error) { + failures.push(`${spec.program}: ${error instanceof Error ? error.message : String(error)}`); + } + } + throw new Error(`Failed to open in ${appName}: ${failures.join('; ')}`); + } + const failures = []; for (const spec of specs) { - const result = spawnSync(spec.program, spec.args, { stdio: 'ignore' }); + const result = spawnSync(spec.program, spec.args, { stdio: 'ignore', windowsHide: true }); if (result.error) { failures.push(`${spec.program}: ${result.error.message}`); continue; @@ -2095,34 +2410,45 @@ const handleInvoke = async (browserWindow, command, args = {}) => { } case 'desktop_open_in_app': { - if (process.platform !== 'darwin') { - throw new Error('desktop_open_in_app is only supported on macOS'); - } const projectPath = typeof args.projectPath === 'string' ? args.projectPath.trim() : ''; const appId = typeof args.appId === 'string' ? args.appId.trim().toLowerCase() : ''; const appName = typeof args.appName === 'string' ? args.appName.trim() : ''; if (!projectPath || !appId || !appName) { throw new Error('Project path, app id, and app name are required'); } + if (process.platform === 'win32') { + runSpecChain(buildWindowsOpenProjectSpecs({ projectPath, appId, appName }), appName); + return null; + } + if (process.platform !== 'darwin') { + throw new Error('desktop_open_in_app is only supported on macOS and Windows'); + } runSpecChain(buildOpenProjectSpecs({ projectPath, appId, appName }), appName); return null; } case 'desktop_open_file_in_app': { - if (process.platform !== 'darwin') { - throw new Error('desktop_open_file_in_app is only supported on macOS'); - } const filePath = typeof args.filePath === 'string' ? args.filePath.trim() : ''; const appId = typeof args.appId === 'string' ? args.appId.trim().toLowerCase() : ''; const appName = typeof args.appName === 'string' ? args.appName.trim() : ''; if (!filePath || !appId || !appName) { throw new Error('File path, app id, and app name are required'); } + if (process.platform === 'win32') { + runSpecChain(buildWindowsOpenFileSpecs({ filePath, appId, appName }), appName); + return null; + } + if (process.platform !== 'darwin') { + throw new Error('desktop_open_file_in_app is only supported on macOS and Windows'); + } runSpecChain(buildOpenFileSpecs({ filePath, appId, appName }), appName); return null; } case 'desktop_filter_installed_apps': { + if (process.platform === 'win32') { + return buildWindowsInstalledApps(args.apps).map((app) => app.name); + } if (process.platform !== 'darwin') { throw new Error('desktop_filter_installed_apps is only supported on macOS'); } @@ -2134,6 +2460,9 @@ const handleInvoke = async (browserWindow, command, args = {}) => { } case 'desktop_fetch_app_icons': { + if (process.platform === 'win32') { + return []; + } if (process.platform !== 'darwin') { throw new Error('desktop_fetch_app_icons is only supported on macOS'); } @@ -2149,9 +2478,6 @@ const handleInvoke = async (browserWindow, command, args = {}) => { } case 'desktop_get_installed_apps': { - if (process.platform !== 'darwin') { - throw new Error('desktop_get_installed_apps is only supported on macOS'); - } const cachePath = buildInstalledAppsCachePath(); const now = Math.floor(Date.now() / 1000); let cache = null; @@ -2163,11 +2489,16 @@ const handleInvoke = async (browserWindow, command, args = {}) => { const hasCache = Boolean(cache); const isCacheStale = !cache || (now - Number(cache.updatedAt || 0)) > INSTALLED_APPS_CACHE_TTL_SECS; const refresh = async () => { - const apps = await buildInstalledApps(Array.isArray(args.apps) ? args.apps : []); + const apps = process.platform === 'win32' + ? buildWindowsInstalledApps(args.apps) + : await buildInstalledApps(Array.isArray(args.apps) ? args.apps : []); await fsp.mkdir(path.dirname(cachePath), { recursive: true }); await fsp.writeFile(cachePath, JSON.stringify({ updatedAt: now, apps }, null, 2)); emitToAllWindows('openchamber:installed-apps-updated', apps); }; + if (process.platform !== 'darwin' && process.platform !== 'win32') { + throw new Error('desktop_get_installed_apps is only supported on macOS and Windows'); + } if (!hasCache || isCacheStale || args.force === true) { void refresh(); } @@ -2216,6 +2547,14 @@ const handleInvoke = async (browserWindow, command, args = {}) => { } else { nativeTheme.themeSource = 'system'; } + if (canUseTitleBarOverlay(browserWindow)) { + const useDark = nativeTheme.shouldUseDarkColors; + browserWindow.setTitleBarOverlay({ + color: useDark ? '#151313' : '#f5f5f4', + symbolColor: useDark ? '#fafaf9' : '#1c1917', + height: 48, + }); + } return null; } @@ -2271,40 +2610,45 @@ const handleInvoke = async (browserWindow, command, args = {}) => { if (!state.pendingUpdate) { throw new Error('No pending update'); } + setTaskbarProgress(0.01); emitToAllWindows('openchamber:update-progress', mapUpdaterProgressEvent({ event: 'Started', data: { contentLength: null, }, })); - if (!state.pendingUpdate.electronUpdate) { - throw new Error('Electron updater metadata is not available for this build'); + try { + if (!state.pendingUpdate.electronUpdate) { + throw new Error('Electron updater metadata is not available for this build'); + } + if (!state.pendingUpdate.downloaded) { + await new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => { + autoUpdater.off('update-downloaded', onDownloaded); + autoUpdater.off('error', onError); + }; + const finish = (callback, value) => { + if (settled) return; + settled = true; + cleanup(); + callback(value); + }; + const onDownloaded = () => finish(resolve, null); + const onError = (error) => finish(reject, error); + autoUpdater.on('update-downloaded', onDownloaded); + autoUpdater.on('error', onError); + Promise.resolve(autoUpdater.downloadUpdate()).catch((error) => finish(reject, error)); + }); + } + emitToAllWindows('openchamber:update-progress', mapUpdaterProgressEvent({ + event: 'Finished', + data: {}, + })); + return null; + } finally { + setTaskbarProgress(-1); } - if (!state.pendingUpdate.downloaded) { - await new Promise((resolve, reject) => { - let settled = false; - const cleanup = () => { - autoUpdater.off('update-downloaded', onDownloaded); - autoUpdater.off('error', onError); - }; - const finish = (callback, value) => { - if (settled) return; - settled = true; - cleanup(); - callback(value); - }; - const onDownloaded = () => finish(resolve, null); - const onError = (error) => finish(reject, error); - autoUpdater.on('update-downloaded', onDownloaded); - autoUpdater.on('error', onError); - Promise.resolve(autoUpdater.downloadUpdate()).catch((error) => finish(reject, error)); - }); - } - emitToAllWindows('openchamber:update-progress', mapUpdaterProgressEvent({ - event: 'Finished', - data: {}, - })); - return null; case 'desktop_restart': { const applyUpdate = Boolean(state.pendingUpdate?.downloaded && app.isPackaged); @@ -2422,6 +2766,38 @@ const handleInvoke = async (browserWindow, command, args = {}) => { } return null; + case 'desktop_minimize_current_window': + if (browserWindow && !browserWindow.isDestroyed()) { + browserWindow.minimize(); + } + return null; + + case 'desktop_toggle_current_window_maximized': + if (browserWindow && !browserWindow.isDestroyed()) { + if (browserWindow.isMaximized()) { + browserWindow.unmaximize(); + } else { + browserWindow.maximize(); + } + return { maximized: browserWindow.isMaximized() }; + } + return { maximized: false }; + + case 'desktop_get_current_window_state': + return { maximized: Boolean(browserWindow && !browserWindow.isDestroyed() && browserWindow.isMaximized()) }; + + case 'desktop_show_app_menu': { + if (!browserWindow || browserWindow.isDestroyed()) { + return null; + } + + const menu = Menu.getApplicationMenu() || buildAutoHiddenMenu(); + const x = Number.isFinite(Number(args.x)) ? Math.max(0, Math.round(Number(args.x))) : undefined; + const y = Number.isFinite(Number(args.y)) ? Math.max(0, Math.round(Number(args.y))) : undefined; + menu.popup({ window: browserWindow, x, y }); + return null; + } + case 'desktop_ssh_instances_get': return sshManager.readInstances(); @@ -2562,6 +2938,119 @@ const buildMacMenu = () => { ]); }; +const buildAutoHiddenMenu = () => { + const dispatchAction = (action) => dispatchMenuAction(action); + const handleCopyAction = () => { + BrowserWindow.getFocusedWindow()?.webContents.copy(); + dispatchAction('copy'); + }; + + return Menu.buildFromTemplate([ + { + label: 'OpenChamber', + submenu: [ + { label: 'About OpenChamber', click: () => dispatchAction('about') }, + { + label: 'Check for Updates', + click: () => dispatchCheckForUpdates(), + }, + { type: 'separator' }, + { label: 'Settings', accelerator: 'Ctrl+,', click: () => dispatchAction('settings') }, + { label: 'Reload Webview', click: () => reloadMenuTargetWindow() }, + { label: 'Restart', click: () => relaunchFromMenu() }, + { label: 'Command Palette', accelerator: 'Ctrl+P', click: () => dispatchAction('command-palette') }, + { type: 'separator' }, + { role: 'quit' }, + ], + }, + { + label: 'File', + submenu: [ + { label: 'New Window', accelerator: 'Ctrl+Shift+Alt+N', click: () => void handleInvoke(null, 'desktop_new_window') }, + { type: 'separator' }, + { label: 'New Session', accelerator: 'Ctrl+N', click: () => dispatchAction('new-session') }, + { label: 'New Worktree', accelerator: 'Ctrl+Shift+N', click: () => dispatchAction('new-worktree-session') }, + { type: 'separator' }, + { label: 'Add Workspace', click: () => dispatchAction('change-workspace') }, + { type: 'separator' }, + { role: 'quit' }, + ], + }, + { + label: 'Edit', + submenu: [ + { role: 'undo' }, + { role: 'redo' }, + { type: 'separator' }, + { role: 'cut' }, + { label: 'Copy', accelerator: 'Ctrl+C', click: () => handleCopyAction() }, + { role: 'paste' }, + { role: 'selectAll' }, + ], + }, + { + label: 'View', + submenu: [ + { role: 'reload' }, + { role: 'forceReload' }, + ...(isDev ? [{ role: 'toggleDevTools' }] : []), + { type: 'separator' }, + { label: 'Toggle Right Sidebar', accelerator: 'Ctrl+B', click: () => dispatchAction('toggle-right-sidebar') }, + { label: 'Open Git Sidebar', accelerator: 'Ctrl+Shift+G', click: () => dispatchAction('open-right-sidebar-git') }, + { label: 'Open Files Sidebar', accelerator: 'Ctrl+Shift+F', click: () => dispatchAction('open-right-sidebar-files') }, + { type: 'separator' }, + { label: 'Toggle Terminal Dock', accelerator: 'Ctrl+J', click: () => dispatchAction('toggle-terminal') }, + { label: 'Toggle Terminal Expanded', accelerator: 'Ctrl+Shift+J', click: () => dispatchAction('toggle-terminal-expanded') }, + { type: 'separator' }, + { label: 'Light Theme', click: () => dispatchAction('theme-light') }, + { label: 'Dark Theme', click: () => dispatchAction('theme-dark') }, + { label: 'System Theme', click: () => dispatchAction('theme-system') }, + { type: 'separator' }, + { label: 'Toggle Session Sidebar', accelerator: 'Ctrl+L', click: () => dispatchAction('toggle-sidebar') }, + { label: 'Toggle Memory Debug', accelerator: 'Ctrl+Shift+D', click: () => dispatchAction('toggle-memory-debug') }, + { type: 'separator' }, + { role: 'togglefullscreen' }, + ], + }, + { + label: 'Go', + submenu: [ + { label: 'Back', accelerator: 'Ctrl+[', click: () => dispatchAction('go-back') }, + { label: 'Forward', accelerator: 'Ctrl+]', click: () => dispatchAction('go-forward') }, + { type: 'separator' }, + { label: 'Previous Session', accelerator: 'Alt+Up', click: () => dispatchAction('previous-session') }, + { label: 'Next Session', accelerator: 'Alt+Down', click: () => dispatchAction('next-session') }, + { type: 'separator' }, + { label: 'Previous Project', accelerator: 'Ctrl+Alt+Up', click: () => dispatchAction('previous-project') }, + { label: 'Next Project', accelerator: 'Ctrl+Alt+Down', click: () => dispatchAction('next-project') }, + ], + }, + { + label: 'Window', + submenu: [ + { role: 'minimize' }, + { role: 'togglefullscreen' }, + { type: 'separator' }, + { role: 'close' }, + ], + }, + { + label: 'Help', + submenu: [ + { label: 'Keyboard Shortcuts', accelerator: 'Ctrl+.', click: () => dispatchAction('help-dialog') }, + { label: 'Show Diagnostics', accelerator: 'Ctrl+Shift+L', click: () => dispatchAction('download-logs') }, + { type: 'separator' }, + { label: 'Clear Cache', click: () => void handleInvoke(null, 'desktop_clear_cache') }, + { type: 'separator' }, + { label: 'Report a Bug', click: () => shell.openExternal(GITHUB_BUG_REPORT_URL) }, + { label: 'Request a Feature', click: () => shell.openExternal(GITHUB_FEATURE_REQUEST_URL) }, + { type: 'separator' }, + { label: 'Join Discord', click: () => shell.openExternal(DISCORD_INVITE_URL) }, + ], + }, + ]); +}; + contextMenu({ showInspectElement: isDev, showSaveImageAs: true, @@ -2612,6 +3101,10 @@ const COMMANDS_SAFE_FOR_REMOTE = new Set([ 'desktop_set_window_theme', 'desktop_is_window_fullscreen', 'desktop_start_window_drag', + 'desktop_minimize_current_window', + 'desktop_toggle_current_window_maximized', + 'desktop_close_current_window', + 'desktop_get_current_window_state', 'desktop_get_app_version', 'desktop_get_lan_address', 'desktop_capture_page_rect', @@ -2733,6 +3226,8 @@ app.whenReady().then(async () => { if (process.platform === 'darwin') { Menu.setApplicationMenu(buildMacMenu()); + } else { + Menu.setApplicationMenu(buildAutoHiddenMenu()); } if (process.platform === 'darwin' && app.isPackaged) { diff --git a/packages/electron/package.json b/packages/electron/package.json index 20aadead..971c3b86 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -20,7 +20,8 @@ "desktopPrerequisites": [ "Electron runtime dependencies installed via bun install", "Bun available for sidecar compilation", - "macOS build tools installed for notarized packaging" + "macOS: Xcode + build tools for notarized packaging", + "Windows: NSIS installed for installer creation" ], "scripts": { "dev": "node ./scripts/electron-dev.mjs", @@ -29,7 +30,7 @@ "bundle:main": "bun ./scripts/bundle-main.mjs", "generate:macos-icon": "node ./scripts/generate-macos-icon-assets.cjs", "rebuild:native": "node ./scripts/rebuild-native.mjs", - "package": "bun run build:web-assets && bun run bundle:main && bun run rebuild:native && electron-builder", + "package": "bun run build:web-assets && bun run bundle:main && bun run rebuild:native && node ./scripts/package.mjs", "finalize:latest-yml": "node ./scripts/finalize-latest-yml.mjs", "type-check": "node --check ./main.mjs && node --check ./preload.mjs", "lint": "node -e \"process.exit(0)\"" @@ -45,6 +46,10 @@ { "from": "resources/web-dist", "to": "web-dist" + }, + { + "from": "resources/icons/icon.ico", + "to": "icons/icon.ico" } ], "afterPack": "scripts/after-pack.cjs", @@ -57,6 +62,7 @@ "mac": { "category": "public.app-category.developer-tools", "icon": "resources/icons/icon.icns", + "artifactName": "${productName}-${version}-mac-${arch}.${ext}", "extendInfo": { "CFBundleIconName": "AppIcon" }, @@ -70,6 +76,21 @@ "zip" ] }, + "win": { + "icon": "resources/icons/icon.ico", + "target": [ + "nsis" + ], + "verifyUpdateCodeSignature": false, + "artifactName": "${productName}-${version}-win-${arch}.${ext}" + }, + "nsis": { + "oneClick": true, + "perMachine": false, + "installerIcon": "resources/icons/icon.ico", + "uninstallerIcon": "resources/icons/icon.ico", + "installerHeaderIcon": "resources/icons/icon.ico" + }, "dmg": { "sign": true, "title": "${productName} ${version}", diff --git a/packages/electron/preload.mjs b/packages/electron/preload.mjs index 2de3f085..9fb042f5 100644 --- a/packages/electron/preload.mjs +++ b/packages/electron/preload.mjs @@ -66,6 +66,8 @@ contextBridge.exposeInMainWorld('__OPENCHAMBER_ELECTRON__', { runtime: 'electron', }); +contextBridge.exposeInMainWorld('__OPENCHAMBER_PLATFORM__', process.platform); + // Note: bootOutcome must stay writable from the main world's initScript so // re-navigations (host switch via deep link) can refresh it. contextBridge- // exposed globals are read-only, which blocks that update — rely solely on diff --git a/packages/electron/resources/icons/icon.ico b/packages/electron/resources/icons/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..04671682b67506209ae97b15d26e2dd1cfa1be6b GIT binary patch literal 29307 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%0Ag@-@^Cr{|&&wKUC=@W5j2OKdgyW zgj0%>_5BVwQl3}EHmxn|2u%2^`0bWz?BmO}CzzisLL?qg};4fuE5ft1n8;7qU4gV#7-=u`1P zm9xa_vFBGO=odi^FYZW{VBVK2fJdaF?$@qO2qx=rKO*IaUb)PcU^q}z>AnX4r96^8 zZz5!50E+)VmVS!ByIdaJg?D9n7+B1uD-jwpBjF(E3csAP>9QyIvDB=yN%(z@>J5gU zT+y~WPwwsK!Uh@r_ASIhJbC0gwDq=eahVGhDY_zc;u}oC8I(wRT=-Z@g4L(}Sjq!0 zg}OzFLK~VgUu1rK7W1uCGDiIUVS`+~Jm_`Y#=OkD0#|;Jr*hFXl9LJ4{64S38gr_H&4jQhov=()_9Gcyqt2wUOZV9-e)Amp%${Y4BKl| zK3RuVd+H)Gkg~l(vbfyZ86flHVv8!}yi#LnAwz2%Z~ZOt-nc>Pa&83Ar{r+is!n*HXK3$bk+2>Um-;a)1k;nt^>;Lba F`ajvn8m<5U literal 0 HcmV?d00001 diff --git a/packages/electron/scripts/build-web-assets.mjs b/packages/electron/scripts/build-web-assets.mjs index 9cca3521..3d487271 100644 --- a/packages/electron/scripts/build-web-assets.mjs +++ b/packages/electron/scripts/build-web-assets.mjs @@ -14,8 +14,17 @@ const resourcesDir = path.join(electronDir, 'resources'); const resourcesWebDistDir = path.join(resourcesDir, 'web-dist'); const webDistDir = path.join(webDir, 'dist'); +const quoteWindowsCommandArg = (value) => `"${String(value).replace(/"/g, '""')}"`; + const run = (cmd, args, cwd) => { - const result = spawnSync(cmd, args, { cwd, stdio: 'inherit' }); + const isWindowsCommandScript = process.platform === 'win32' && /\.(cmd|bat)$/i.test(cmd); + const result = isWindowsCommandScript + ? spawnSync( + process.env.ComSpec || 'cmd.exe', + ['/d', '/s', '/c', ['call', quoteWindowsCommandArg(cmd), ...args.map(quoteWindowsCommandArg)].join(' ')], + { cwd, stdio: 'inherit', windowsVerbatimArguments: true }, + ) + : spawnSync(cmd, args, { cwd, stdio: 'inherit' }); if (result.error) throw result.error; if (result.status !== 0) { throw new Error(`Command failed: ${cmd} ${args.join(' ')}`); @@ -26,6 +35,12 @@ const resolveBun = () => { if (typeof process.env.BUN === 'string' && process.env.BUN.trim()) { return process.env.BUN.trim(); } + if (process.platform === 'win32') { + const result = spawnSync('where.exe', ['bun'], { encoding: 'utf8' }); + const candidates = String(result.stdout || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + const resolved = candidates.find((entry) => /\.(exe|cmd|bat)$/i.test(entry)) || candidates[0]; + return resolved || 'bun'; + } const result = spawnSync('/bin/bash', ['-lc', 'command -v bun'], { encoding: 'utf8' }); const resolved = (result.stdout || '').trim(); return resolved || 'bun'; diff --git a/packages/electron/scripts/electron-dev.mjs b/packages/electron/scripts/electron-dev.mjs index 46c2ce43..816a5a83 100644 --- a/packages/electron/scripts/electron-dev.mjs +++ b/packages/electron/scripts/electron-dev.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node -import { spawn } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; +import net from 'node:net'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -7,13 +8,39 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const repoRoot = path.resolve(__dirname, '../../..'); const electronDir = path.join(repoRoot, 'packages/electron'); +const preferredHmrUiPort = Number(process.env.OPENCHAMBER_HMR_UI_PORT || '5173'); +const preferredHmrApiPort = Number(process.env.OPENCHAMBER_HMR_API_PORT || '3901'); + +const quoteWindowsCommandArg = (value) => `"${String(value).replace(/"/g, '""')}"`; + +function resolveWindowsCommand(command) { + if (process.platform !== 'win32' || path.isAbsolute(command)) { + return command; + } + + const result = spawnSync('where.exe', [command], { encoding: 'utf8', windowsHide: true }); + if (result.error || result.status !== 0) { + return command; + } + + const candidates = String(result.stdout || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + return candidates.find((entry) => /\.(exe|cmd|bat)$/i.test(entry)) || candidates[0] || command; +} function spawnProcess(command, args, options = {}) { - return spawn(command, args, { + const resolvedCommand = resolveWindowsCommand(command); + const isWindowsCommandScript = process.platform === 'win32' && /\.(cmd|bat)$/i.test(resolvedCommand); + const spawnCommand = isWindowsCommandScript ? (process.env.ComSpec || 'cmd.exe') : resolvedCommand; + const spawnArgs = isWindowsCommandScript + ? ['/d', '/s', '/c', ['call', quoteWindowsCommandArg(resolvedCommand), ...args.map(quoteWindowsCommandArg)].join(' ')] + : args; + + return spawn(spawnCommand, spawnArgs, { cwd: repoRoot, env: { ...process.env, OPENCHAMBER_ELECTRON_DEV: '1' }, stdio: 'inherit', detached: process.platform !== 'win32', + windowsVerbatimArguments: isWindowsCommandScript, ...options, }); } @@ -39,6 +66,55 @@ function waitForExit(child, timeoutMs) { }); } +function isPortAvailable(port) { + return new Promise((resolve) => { + const server = net.createServer(); + server.once('error', () => resolve(false)); + server.once('listening', () => { + server.close(() => resolve(true)); + }); + server.listen(port, '127.0.0.1'); + }); +} + +async function findAvailablePort(preferredPort) { + const start = Number.isFinite(preferredPort) && preferredPort > 0 ? preferredPort : 0; + if (start === 0) { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', reject); + server.once('listening', () => { + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + server.close(() => resolve(port)); + }); + server.listen(0, '127.0.0.1'); + }); + } + + for (let port = start; port < start + 50; port += 1) { + if (await isPortAvailable(port)) { + if (port !== start) { + console.warn(`[electron:dev] port ${start} is unavailable, using ${port} instead.`); + } + return port; + } + } + + throw new Error(`No available port found near ${start}`); +} + +function killWindowsProcessTree(pid) { + if (!pid) return; + try { + spawnSync('taskkill.exe', ['/PID', String(pid), '/T', '/F'], { + stdio: 'ignore', + windowsHide: true, + }); + } catch { + } +} + function signalChild(child, signal) { if (!child || child.exitCode !== null || child.signalCode !== null) { return; @@ -66,6 +142,11 @@ async function stopChildTree(child) { signalChild(child, 'SIGINT'); await waitForExit(child, 2500); + if (process.platform === 'win32' && child.exitCode === null && child.signalCode === null) { + killWindowsProcessTree(child.pid); + await waitForExit(child, 1000); + } + if (child.exitCode === null && child.signalCode === null) { signalChild(child, 'SIGTERM'); await waitForExit(child, 2500); @@ -78,16 +159,28 @@ async function stopChildTree(child) { } async function main() { + const hmrApiPort = String(await findAvailablePort(preferredHmrApiPort)); + const hmrUiPort = String(await findAvailablePort(preferredHmrUiPort)); + const devServer = spawnProcess('node', ['./scripts/dev-web-hmr.mjs'], { env: { ...process.env, OPENCHAMBER_ELECTRON_DEV: '1', - OPENCHAMBER_HMR_UI_PORT: '5173', - OPENCHAMBER_HMR_API_PORT: '3901', + OPENCHAMBER_HMR_UI_PORT: hmrUiPort, + OPENCHAMBER_HMR_API_PORT: hmrApiPort, + OPENCHAMBER_DISABLE_PWA_DEV: '1', + }, + }); + const electron = spawnProcess('npx', ['electron', './main.mjs'], { + cwd: electronDir, + env: { + ...process.env, + OPENCHAMBER_ELECTRON_DEV: '1', + OPENCHAMBER_HMR_UI_PORT: hmrUiPort, + OPENCHAMBER_HMR_API_PORT: hmrApiPort, OPENCHAMBER_DISABLE_PWA_DEV: '1', }, }); - const electron = spawnProcess('npx', ['electron', './main.mjs'], { cwd: electronDir }); let cleaning = false; const teardown = async (code) => { diff --git a/packages/electron/scripts/package.mjs b/packages/electron/scripts/package.mjs new file mode 100644 index 00000000..bfceffa9 --- /dev/null +++ b/packages/electron/scripts/package.mjs @@ -0,0 +1,41 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +const env = { ...process.env }; + +if (process.platform === 'win32' && !env.CSC_LINK && !env.WINDOWS_CSC_LINK) { + env.CSC_IDENTITY_AUTO_DISCOVERY = 'false'; + console.log('[electron] Windows code signing disabled; building unsigned installer.'); +} + +const bunBinaryCandidates = [ + process.env.npm_execpath, + process.env.BUN_INSTALL ? path.join(process.env.BUN_INSTALL, 'bin', process.platform === 'win32' ? 'bun.exe' : 'bun') : null, + process.platform === 'win32' ? 'bun.exe' : 'bun', +].filter(Boolean); + +const bunBinary = bunBinaryCandidates.find((candidate) => { + if (path.basename(candidate).toLowerCase().startsWith('bun')) { + return candidate === 'bun' || candidate === 'bun.exe' || fs.existsSync(candidate); + } + return false; +}) || (process.platform === 'win32' ? 'bun.exe' : 'bun'); + +const child = spawn(bunBinary, ['x', 'electron-builder', ...process.argv.slice(2)], { + env, + stdio: 'inherit', +}); + +child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 1); +}); + +child.on('error', (error) => { + console.error('[electron] failed to start electron-builder:', error); + process.exit(1); +}); diff --git a/packages/electron/scripts/rebuild-native.mjs b/packages/electron/scripts/rebuild-native.mjs index eb381df7..cd2a08bd 100644 --- a/packages/electron/scripts/rebuild-native.mjs +++ b/packages/electron/scripts/rebuild-native.mjs @@ -1,5 +1,8 @@ #!/usr/bin/env node import path from 'node:path'; +import { existsSync } from 'node:fs'; +import fsp from 'node:fs/promises'; +import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { createRequire } from 'node:module'; import { rebuild } from '@electron/rebuild'; @@ -14,17 +17,140 @@ const require = createRequire(import.meta.url); const electronPkg = require('electron/package.json'); const electronVersion = electronPkg.version; +const copyDirectory = async (src, dst) => { + await fsp.mkdir(dst, { recursive: true }); + const entries = await fsp.readdir(src, { withFileTypes: true }); + for (const entry of entries) { + const from = path.join(src, entry.name); + const to = path.join(dst, entry.name); + if (entry.isDirectory()) { + await copyDirectory(from, to); + } else { + await fsp.copyFile(from, to); + } + } +}; + +const getWindowsShortPath = (target) => { + if (process.platform !== 'win32') return target; + try { + const escaped = target.replace(/'/g, "''"); + const output = execFileSync( + 'powershell.exe', + ['-NoProfile', '-Command', `$fso = New-Object -ComObject Scripting.FileSystemObject; $fso.GetFolder('${escaped}').ShortPath`], + { encoding: 'utf8' }, + ).trim(); + return output || target; + } catch { + return target; + } +}; + +const createWindowsRebuildPath = (target) => { + if (process.platform !== 'win32') { + return { buildPath: target, cleanup: () => {} }; + } + + for (const letter of 'ZYXWVUTSRQPONMLKJIHGFED') { + const drive = `${letter}:`; + if (existsSync(`${drive}\\`)) continue; + try { + execFileSync('subst.exe', [drive, target], { stdio: 'ignore' }); + return { + buildPath: `${drive}\\`, + cleanup: () => { + try { + execFileSync('subst.exe', [drive, '/d'], { stdio: 'ignore' }); + } catch { + // Best-effort cleanup. The build result should not depend on this. + } + }, + }; + } catch { + // Try the next drive letter. + } + } + + const shortPath = getWindowsShortPath(target); + if (shortPath === target && /\s/.test(target)) { + throw new Error( + `Unable to create a space-free Windows rebuild path for ${target}. ` + + 'All subst drive letters are unavailable and the volume did not return an 8.3 short path.', + ); + } + + return { buildPath: shortPath, cleanup: () => {} }; +}; + +const writeWindowsNodeAddonApiIndex = async (nodeAddonApiDir, exportedNodeAddonApiDir) => { + if (process.platform !== 'win32') return; + + const shortDir = getWindowsShortPath(exportedNodeAddonApiDir); + await fsp.writeFile( + path.join(nodeAddonApiDir, 'index.js'), + `const path = require('path'); + +const includeDir = ${JSON.stringify(shortDir)}; + +module.exports = { + include: \`"${shortDir}"\`, + include_dir: includeDir, + gyp: path.join(includeDir, 'node_api.gyp:nothing'), + targets: path.join(includeDir, 'node_addon_api.gyp'), + isNodeApiBuiltin: true, + needsFlag: false +}; +`, + ); +}; + +const ensureWindowsNodeAddonApiForNodePty = async (rebuildRootPath) => { + if (process.platform !== 'win32') return async () => {}; + + const nodePtyPackagePath = require.resolve('node-pty/package.json'); + const nodePtyDir = path.dirname(nodePtyPackagePath); + const rootNodeAddonApiDir = path.dirname(require.resolve('node-addon-api/package.json')); + const tempNodeAddonApiDir = path.join(repoRoot, 'node_modules', '.openchamber-node-addon-api-7.1.1'); + const exportedTempNodeAddonApiDir = path.join(rebuildRootPath, 'node_modules', '.openchamber-node-addon-api-7.1.1'); + const localNodeAddonApiDir = path.join(nodePtyDir, 'node_modules', 'node-addon-api'); + + await fsp.rm(tempNodeAddonApiDir, { recursive: true, force: true }); + await copyDirectory(rootNodeAddonApiDir, tempNodeAddonApiDir); + await fsp.access(path.join(tempNodeAddonApiDir, 'package.json')); + + await fsp.rm(localNodeAddonApiDir, { recursive: true, force: true }); + await copyDirectory(rootNodeAddonApiDir, localNodeAddonApiDir); + await writeWindowsNodeAddonApiIndex(localNodeAddonApiDir, exportedTempNodeAddonApiDir); + await fsp.access(path.join(localNodeAddonApiDir, 'package.json')); + + return async () => { + await fsp.rm(localNodeAddonApiDir, { recursive: true, force: true }); + await fsp.rm(tempNodeAddonApiDir, { recursive: true, force: true }); + }; +}; + console.log(`[electron] rebuilding native modules against Electron ${electronVersion}...`); // Rebuild against the hoisted root node_modules (bun workspace layout). // force=true re-links regardless of cached state; prebuild-install lookup is // bypassed by @electron/rebuild in favor of direct node-gyp builds. -await rebuild({ - buildPath: repoRoot, - electronVersion, - force: true, - arch: process.env.ELECTRON_BUILDER_ARCH || process.arch, - onlyModules: ['better-sqlite3', 'node-pty', 'bun-pty'], -}); +const rebuildPath = createWindowsRebuildPath(repoRoot); +let cleanupNodeAddonApi = async () => {}; +try { + cleanupNodeAddonApi = await ensureWindowsNodeAddonApiForNodePty(rebuildPath.buildPath); + await rebuild({ + buildPath: rebuildPath.buildPath, + electronVersion, + force: true, + arch: process.env.ELECTRON_BUILDER_ARCH || process.arch, + onlyModules: ['better-sqlite3', 'node-pty', 'bun-pty'], + }); +} finally { + try { + await cleanupNodeAddonApi(); + } finally { + rebuildPath.cleanup(); + } +} console.log('[electron] native modules rebuilt successfully'); diff --git a/packages/ui/package.json b/packages/ui/package.json index 1ae74c3b..b7519b2b 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -45,6 +45,7 @@ "@simplewebauthn/browser": "13.3.0", "@tanstack/react-virtual": "^3.13.18", "@types/react-syntax-highlighter": "^15.5.13", + "@xenova/transformers": "^2.17.2", "beautiful-mermaid": "^1.1.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/packages/ui/src/components/auth/SessionAuthGate.tsx b/packages/ui/src/components/auth/SessionAuthGate.tsx index edf3dd43..9efed1fd 100644 --- a/packages/ui/src/components/auth/SessionAuthGate.tsx +++ b/packages/ui/src/components/auth/SessionAuthGate.tsx @@ -55,29 +55,39 @@ const submitPassword = async (password: string, trustDevice: boolean): Promise = ({ children }) => ( -
+const AuthShell: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const titlebarDragStyle = React.useMemo(() => { + return { + height: 'var(--oc-wco-titlebar-height, 0px)', + right: 'var(--oc-wco-right-inset, 0px)', + }; + }, []); + + return (
-
-
- {children} + className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background text-foreground" + style={{ fontFamily: '"Inter", "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", sans-serif' }} + > +
+
+
+
+ {children} +
-
-); + ); +}; const LoadingScreen: React.FC = () => (
diff --git a/packages/ui/src/components/chat/FileAttachment.tsx b/packages/ui/src/components/chat/FileAttachment.tsx index b776f652..53d84189 100644 --- a/packages/ui/src/components/chat/FileAttachment.tsx +++ b/packages/ui/src/components/chat/FileAttachment.tsx @@ -316,7 +316,7 @@ FileChip.displayName = 'FileChip'; const VSCodeFileChip = memo(({ file, onRemove }: FileChipProps) => { const { t } = useI18n(); const { displayName, extension } = useFileDetails(file); - + // Detect selection-style attachments: ends with ":N" or ":N-M" const isSelectionAttachment = /:\d+(?:-\d+)?$/.test(displayName); @@ -359,7 +359,7 @@ interface AttachedFilesListProps { onShowPopup?: (content: ToolPopupContent) => void; } -export const AttachedVSCodeFileChips = memo(({ onShowPopup }: AttachedFilesListProps) => { +export const AttachedVSCodeFileChips = memo(({ onShowPopup }: AttachedFilesListProps) => { const attachedFiles = useInputStore((state) => state.attachedFiles); const removeAttachedFile = useInputStore((state) => state.removeAttachedFile); diff --git a/packages/ui/src/components/chat/MobileSessionStatusBar.tsx b/packages/ui/src/components/chat/MobileSessionStatusBar.tsx index cf1f1973..8c0fb23f 100644 --- a/packages/ui/src/components/chat/MobileSessionStatusBar.tsx +++ b/packages/ui/src/components/chat/MobileSessionStatusBar.tsx @@ -491,7 +491,6 @@ function SessionItem({ title={`Sub-session: ${getSessionTitle(child)}`} >
); @@ -692,7 +691,6 @@ function SessionStatusHeader({ title={`Sub-session: ${child.session.title || 'Untitled'}`} >
); diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index 62cfe0d0..1f929d3c 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -39,7 +39,6 @@ import { useI18n } from '@/lib/i18n'; import { useOpenCodeReadiness } from '@/hooks/useOpenCodeReadiness'; import { eventMatchesShortcut, getEffectiveShortcutCombo, normalizeCombo } from '@/lib/shortcuts'; - type IconComponent = IconName; type ProviderModel = Record & { id?: string; name?: string }; diff --git a/packages/ui/src/components/chat/message/MessageHeader.tsx b/packages/ui/src/components/chat/message/MessageHeader.tsx index a70bf88d..aedd2e63 100644 --- a/packages/ui/src/components/chat/message/MessageHeader.tsx +++ b/packages/ui/src/components/chat/message/MessageHeader.tsx @@ -39,7 +39,6 @@ const MessageHeader: React.FC = ({ isUser, providerID, agent /> ) : ( )}
diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index 80e4bc2c..bdbdc449 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -729,7 +729,6 @@ const ToolScrollableSection: React.FC = ({ disableHorizontal ? 'overflow-y-auto overflow-x-hidden' : 'overflow-auto', className, )} - >
{children} diff --git a/packages/ui/src/components/icon/sprite.ts b/packages/ui/src/components/icon/sprite.ts index 23387a55..fa282964 100644 --- a/packages/ui/src/components/icon/sprite.ts +++ b/packages/ui/src/components/icon/sprite.ts @@ -153,6 +153,7 @@ export const iconSpriteData = { "lock-unlock": ``, "loop-right-ai": ``, "macbook": ``, + "menu-2": ``, "menu-fold-2": ``, "menu-search": ``, "message-2": ``, diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 46d81c95..c4116cf6 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -77,7 +77,7 @@ type HeaderIconActionButtonProps = { visible?: boolean; title: string; ariaLabel: string; - onClick: () => void; + onClick: React.MouseEventHandler; className?: string; Icon: IconName; iconClassName?: string; @@ -115,6 +115,83 @@ const HeaderIconActionButton = React.memo(function HeaderIconActionButton({ ); }); +type WindowsWindowControlsProps = { + visible: boolean; +}; + +const WindowsWindowControls = React.memo(function WindowsWindowControls({ visible }: WindowsWindowControlsProps) { + const { t } = useI18n(); + const [isMaximized, setIsMaximized] = React.useState(false); + + useEffect(() => { + if (!visible) { + return; + } + + let disposed = false; + void invokeDesktop<{ maximized?: boolean }>('desktop_get_current_window_state') + .then((state) => { + if (!disposed) { + setIsMaximized(Boolean(state?.maximized)); + } + }) + .catch(() => {}); + + const handleMaximizedChange = (event: Event) => { + const detail = (event as CustomEvent<{ maximized?: boolean }>).detail; + setIsMaximized(Boolean(detail?.maximized)); + }; + + window.addEventListener('openchamber:window-maximized-changed', handleMaximizedChange); + return () => { + disposed = true; + window.removeEventListener('openchamber:window-maximized-changed', handleMaximizedChange); + }; + }, [visible]); + + if (!visible) { + return null; + } + + const buttonClassName = 'app-region-no-drag inline-flex h-12 w-11 items-center justify-center text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'; + + return ( +
+ + + +
+ ); +}); + type DesktopGitHubControlProps = { isMobile: boolean; githubAuthStatus: GitHubAuthStatus | null; @@ -731,6 +808,13 @@ export const Header: React.FC = ({ return /Macintosh|Mac OS X/.test(navigator.userAgent || ''); }, []); + const isWindowsElectronDesktop = React.useMemo(() => { + if (typeof window === 'undefined') { + return false; + } + return Boolean(window.__OPENCHAMBER_ELECTRON__) && window.__OPENCHAMBER_PLATFORM__ === 'win32'; + }, []); + const macosMajorVersion = React.useMemo(() => { if (typeof window === 'undefined') { return null; @@ -1262,6 +1346,16 @@ export const Header: React.FC = ({ toggleSidebar(); }, [blurActiveElement, isMobile, isSessionSwitcherOpen, setSessionSwitcherOpen, toggleSidebar]); + const handleOpenWindowsAppMenu = React.useCallback((event: React.MouseEvent) => { + const rect = event.currentTarget.getBoundingClientRect(); + void invokeDesktop('desktop_show_app_menu', { + x: rect.left, + y: rect.bottom, + }).catch((error) => { + console.warn('[header] failed to open app menu', error); + }); + }, []); + const handleOpenDraftMiniChat = React.useCallback(() => { void invokeDesktop('desktop_open_draft_mini_chat_window', { directory: normalize(openDirectory || activeProject?.path || ''), @@ -1454,7 +1548,7 @@ export const Header: React.FC = ({ }, [isDesktopApp, isMacPlatform, macosMajorVersion]); const webWindowControlsOverlayStyle = React.useMemo(() => { - if (isDesktopApp || isVSCode) { + if ((isDesktopApp && !isWindowsElectronDesktop) || isVSCode) { return undefined; } @@ -1466,7 +1560,7 @@ export const Header: React.FC = ({ minHeight: 'max(3rem, var(--oc-wco-titlebar-height, 0px))', height: 'max(3rem, var(--oc-wco-titlebar-height, 0px))', }; - }, [isDesktopApp, isTabletStandalonePwa, isVSCode]); + }, [isDesktopApp, isTabletStandalonePwa, isVSCode, isWindowsElectronDesktop]); const updateHeaderHeight = React.useCallback(() => { if (typeof document === 'undefined') { @@ -1884,6 +1978,15 @@ export const Header: React.FC = ({ role="tablist" aria-label={t('header.navigation.mainAria')} > + {isWindowsElectronDesktop ? ( + + ) : null} = ({ Icon={'picture-in-picture-2'} /> {desktopSidebarActions} +
diff --git a/packages/ui/src/components/layout/SidebarFilesTree.tsx b/packages/ui/src/components/layout/SidebarFilesTree.tsx index 1295fb03..687bb560 100644 --- a/packages/ui/src/components/layout/SidebarFilesTree.tsx +++ b/packages/ui/src/components/layout/SidebarFilesTree.tsx @@ -319,7 +319,7 @@ const FileRow: React.FC = ({ export const SidebarFilesTree: React.FC = () => { const { t } = useI18n(); - const { files, runtime } = useRuntimeAPIs(); + const { files } = useRuntimeAPIs(); const currentDirectory = useEffectiveDirectory() ?? ''; const root = normalizePath(currentDirectory.trim()); const showHidden = useDirectoryShowHidden(); @@ -335,6 +335,7 @@ export const SidebarFilesTree: React.FC = () => { const [searching, setSearching] = React.useState(false); const [childrenByDir, setChildrenByDir] = React.useState>({}); + const [loadErrorsByDir, setLoadErrorsByDir] = React.useState>({}); const loadedDirsRef = React.useRef>(new Set()); const inFlightDirsRef = React.useRef>(new Set()); @@ -419,7 +420,7 @@ export const SidebarFilesTree: React.FC = () => { inFlightDirsRef.current.add(normalizedDir); const respectGitignore = !showGitignored; - const listPromise = runtime.isDesktop + const listPromise = files.listDirectory ? files.listDirectory(normalizedDir, { respectGitignore }).then((result) => result.entries.map((entry) => ({ name: entry.name, path: entry.path, @@ -437,25 +438,34 @@ export const SidebarFilesTree: React.FC = () => { loadedDirsRef.current = new Set(loadedDirsRef.current); loadedDirsRef.current.add(normalizedDir); + setLoadErrorsByDir((prev) => { + if (!prev[normalizedDir]) return prev; + const next = { ...prev }; + delete next[normalizedDir]; + return next; + }); setChildrenByDir((prev) => ({ ...prev, [normalizedDir]: mapped })); }) - .catch(() => { - setChildrenByDir((prev) => ({ + .catch((error) => { + const message = error instanceof Error ? error.message : String(error ?? ''); + console.error('Failed to load sidebar directory:', error); + setLoadErrorsByDir((prev) => ({ ...prev, - [normalizedDir]: prev[normalizedDir] ?? [], + [normalizedDir]: message, })); }) .finally(() => { inFlightDirsRef.current = new Set(inFlightDirsRef.current); inFlightDirsRef.current.delete(normalizedDir); }); - }, [files, mapDirectoryEntries, runtime.isDesktop, showGitignored]); + }, [files, mapDirectoryEntries, showGitignored]); const refreshRoot = React.useCallback(async () => { if (!root) return; loadedDirsRef.current = new Set(); inFlightDirsRef.current = new Set(); + setLoadErrorsByDir({}); setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {})); await loadDirectory(root); @@ -484,6 +494,7 @@ export const SidebarFilesTree: React.FC = () => { loadedDirsRef.current = new Set(); inFlightDirsRef.current = new Set(); + setLoadErrorsByDir({}); setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {})); void loadDirectory(root); }, [loadDirectory, root, showHidden, showGitignored]); @@ -807,6 +818,7 @@ export const SidebarFilesTree: React.FC = () => { } const hasTree = Boolean(root && childrenByDir[root]); + const rootLoadError = root ? loadErrorsByDir[root] : null; return (
@@ -923,6 +935,14 @@ export const SidebarFilesTree: React.FC = () => { ); }) + ) : rootLoadError ? ( +
  • + {rootLoadError} + +
  • ) : hasTree && root ? ( renderTree(root, 0) ) : ( diff --git a/packages/ui/src/components/sections/skills/catalog/AddCatalogDialog.tsx b/packages/ui/src/components/sections/skills/catalog/AddCatalogDialog.tsx index 9f8ba939..cd6958de 100644 --- a/packages/ui/src/components/sections/skills/catalog/AddCatalogDialog.tsx +++ b/packages/ui/src/components/sections/skills/catalog/AddCatalogDialog.tsx @@ -36,7 +36,7 @@ const guessLabelFromSource = (value: string) => { : trimmed.startsWith("git@") ? "ssh" : "shorthand"; - + if (urlFormat === 'ssh') { return `${trimmed.split(":")[1].replace(/\.git$/i, '')}`; } diff --git a/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx b/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx index 59698299..df3472a1 100644 --- a/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx +++ b/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx @@ -777,7 +777,6 @@ export function SessionGroupSection(props: Props): React.ReactNode { @@ -810,7 +809,6 @@ export function SessionGroupSection(props: Props): React.ReactNode { ) : ( ) ) : null} diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index 1831d30a..e8fe9110 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -290,6 +290,32 @@ const isFileMissingError = (error: unknown): boolean => { const MAX_VIEW_CHARS = 200_000; const FILE_EDITOR_AUTO_SAVE_KEY = 'openchamber:files:auto-save-enabled'; +type FileLineEnding = '\n' | '\r\n'; + +const detectFileLineEnding = (content: string): FileLineEnding => { + let crlf = 0; + let lf = 0; + + for (let index = 0; index < content.length; index += 1) { + if (content.charCodeAt(index) !== 10) { + continue; + } + if (index > 0 && content.charCodeAt(index - 1) === 13) { + crlf += 1; + } else { + lf += 1; + } + } + + return crlf > lf ? '\r\n' : '\n'; +}; + +const normalizeEditorLineEndings = (content: string): string => content.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + +const serializeEditorContent = (content: string, lineEnding: FileLineEnding): string => { + const normalized = normalizeEditorLineEndings(content); + return lineEnding === '\r\n' ? normalized.replace(/\n/g, '\r\n') : normalized; +}; const getInitialAutoSaveEnabled = (): boolean => { if (typeof window === 'undefined') { @@ -763,6 +789,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const [draftContent, setDraftContent] = React.useState(''); const [isSaving, setIsSaving] = React.useState(false); + const [loadedFileLineEnding, setLoadedFileLineEnding] = React.useState('\n'); const dialogInputRef = React.useRef(null); const autoSaveTimerRef = React.useRef | null>(null); const lastLoadedFileStatRef = React.useRef(null); @@ -1007,7 +1034,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const isCurrentRequest = () => activeDirectoryLoadIdsRef.current.get(normalizedDir) === requestId; const respectGitignore = !showGitignored; - const listPromise = runtime.isDesktop + const listPromise = files.listDirectory ? files.listDirectory(normalizedDir, { respectGitignore }).then((result) => result.entries.map((entry) => ({ name: entry.name, path: entry.path, @@ -1051,7 +1078,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { inFlightDirsRef.current = new Set(inFlightDirsRef.current); inFlightDirsRef.current.delete(normalizedDir); }); - }, [files, mapDirectoryEntries, runtime.isDesktop, showGitignored]); + }, [files, mapDirectoryEntries, showGitignored]); const refreshRoot = React.useCallback(async () => { if (!root) { @@ -1446,7 +1473,8 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { setIsSaving(true); try { - const result = await files.writeFile(selectedFile.path, draftContent); + const contentToWrite = serializeEditorContent(draftContent, loadedFileLineEnding); + const result = await files.writeFile(selectedFile.path, contentToWrite); if (!result?.success) { toast.error(t('filesView.toast.writeFileFailed')); return false; @@ -1467,7 +1495,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { } finally { setIsSaving(false); } - }, [draftContent, files, isDirty, readFileStat, selectedFile, t]); + }, [draftContent, files, isDirty, loadedFileLineEnding, readFileStat, selectedFile, t]); React.useEffect(() => { if (!isDirty) { @@ -1622,10 +1650,12 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { if (!isCurrentLoad()) { return; } - setFileContent(content); - setDraftContent(content.length > MAX_VIEW_CHARS - ? `${content.slice(0, MAX_VIEW_CHARS)}\n\n… truncated …` - : content); + const editorContent = normalizeEditorLineEndings(content); + setLoadedFileLineEnding(detectFileLineEnding(content)); + setFileContent(editorContent); + setDraftContent(editorContent.length > MAX_VIEW_CHARS + ? `${editorContent.slice(0, MAX_VIEW_CHARS)}\n\n… truncated …` + : editorContent); setLoadedFilePath(node.path); void readFileStat(node.path, readOptions) .then((stat) => { diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 96987710..a8b43567 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -1001,6 +1001,14 @@ export const GitView: React.FC = () => { }; }, [changeEntries, currentDirectory, git, prefetchDiffs, stagedChangeEntries, visibleChangePaths]); + const getPushedRemoteName = (result?: Awaited>) => { + return result?.pushed[0]?.remote + || status?.tracking?.split('/')[0] + || effectiveRemotes.find((remote) => remote.name === 'origin')?.name + || effectiveRemotes[0]?.name + || 'origin'; + }; + const handleSyncAction = async (action: Exclude, remote?: GitRemote) => { if (!currentDirectory) return; setSyncAction(action); @@ -1035,8 +1043,8 @@ export const GitView: React.FC = () => { : t('gitView.toast.pulledFilesPlural', { count: result.files.length, name: remote.name }) ); } else if (action === 'push') { - await git.gitPush(currentDirectory); - toast.success(t('gitView.toast.pushedToUpstream')); + const result = await git.gitPush(currentDirectory); + toast.success(t('gitView.toast.pushedToUpstream', { name: getPushedRemoteName(result) })); } else if (action === 'sync') { if (!remote) { throw new Error('No remote available for sync'); @@ -1073,7 +1081,7 @@ export const GitView: React.FC = () => { : t('gitView.toast.pulledFilesPlural', { count: pulledFileCount, name: remote.name }) ); } else if (pushedChanges) { - toast.success(t('gitView.toast.pushedToUpstream')); + toast.success(t('gitView.toast.pushedToUpstream', { name: remote.name })); } else { toast.success(t('gitView.toast.alreadyUpToDate')); } @@ -1150,56 +1158,8 @@ export const GitView: React.FC = () => { await refreshStatusAndBranches(); if (options.pushAfter) { - setSyncAction('sync'); - const trackingRemoteName = status?.tracking?.split('/')[0]; - const syncRemote = effectiveRemotes.find((remote) => remote.name === trackingRemoteName) ?? effectiveRemotes[0]; - if (!syncRemote) { - throw new Error('No remote available for sync'); - } - - const trackingPrefix = `${syncRemote.name}/`; - const trackedBranch = status?.tracking?.startsWith(trackingPrefix) - ? status.tracking.slice(trackingPrefix.length) - : undefined; - let pulledFileCount = 0; - let pushedChanges = false; - - await git.gitFetch(currentDirectory, { remote: syncRemote.name }); - const afterFetch = await git.getGitStatus(currentDirectory); - - if ((afterFetch.behind ?? 0) > 0) { - const pullResult = await git.gitPull(currentDirectory, { - remote: syncRemote.name, - branch: trackedBranch, - rebase: true, - }); - pulledFileCount = pullResult.files.length; - } - - const afterPull = await git.getGitStatus(currentDirectory); - if ((afterPull.ahead ?? 0) > 0) { - await git.gitPush(currentDirectory); - pushedChanges = true; - } - - if (pulledFileCount > 0 && pushedChanges) { - toast.success( - pulledFileCount === 1 - ? t('gitView.toast.syncedPulledSingleAndPushed', { count: pulledFileCount, name: syncRemote.name }) - : t('gitView.toast.syncedPulledPluralAndPushed', { count: pulledFileCount, name: syncRemote.name }) - ); - } else if (pulledFileCount > 0) { - toast.success( - pulledFileCount === 1 - ? t('gitView.toast.pulledFilesSingle', { count: pulledFileCount, name: syncRemote.name }) - : t('gitView.toast.pulledFilesPlural', { count: pulledFileCount, name: syncRemote.name }) - ); - } else if (pushedChanges) { - toast.success(t('gitView.toast.pushedToUpstream')); - } else { - toast.success(t('gitView.toast.alreadyUpToDate')); - } - + const result = await git.gitPush(currentDirectory); + toast.success(t('gitView.toast.pushedToUpstream', { name: getPushedRemoteName(result) })); triggerFireworks(); await refreshStatusAndBranches(false); } else { @@ -2257,7 +2217,7 @@ export const GitView: React.FC = () => { ); } - if (isLoading && isGitRepo === null) { + if (isGitRepo === null || (isGitRepo === true && !status)) { return (
    diff --git a/packages/ui/src/components/views/git/GitHeader.tsx b/packages/ui/src/components/views/git/GitHeader.tsx index dd8cdf8b..830399c1 100644 --- a/packages/ui/src/components/views/git/GitHeader.tsx +++ b/packages/ui/src/components/views/git/GitHeader.tsx @@ -13,7 +13,12 @@ import type { IconName } from "@/components/icon/icons"; import { BranchSelector } from './BranchSelector'; import { WorktreeBranchDisplay } from './WorktreeBranchDisplay'; import { SyncActions } from './SyncActions'; -import type { GitStatus, GitIdentityProfile, GitRemote } from '@/lib/api/types'; +import type { + GitStatus, + GitIdentityProfile, + GitRemote, + GitRemoteComparison, +} from '@/lib/api/types'; import { useI18n } from '@/lib/i18n'; type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null; @@ -178,6 +183,49 @@ export const IdentityDropdown: React.FC = ({ ); }; +interface UpstreamStatusPillProps { + comparison: GitRemoteComparison; + trackingBranch: string | null; + tooltipDelayMs?: number; +} + +const UpstreamStatusPill: React.FC = ({ + comparison, + trackingBranch, + tooltipDelayMs = 1000, +}) => { + const { t } = useI18n(); + const target = `${comparison.remote}/${comparison.branch}`; + const isSynced = comparison.ahead === 0 && comparison.behind === 0; + const tooltipText = trackingBranch + ? t('gitView.header.upstreamTooltipTracking', { target, tracking: trackingBranch }) + : t('gitView.header.upstreamTooltip', { target }); + + return ( + + +
    + + {target} + {isSynced ? ( + {t('gitView.header.upstreamSynced')} + ) : ( + + {comparison.ahead > 0 ? ( + ↑{comparison.ahead} + ) : null} + {comparison.behind > 0 ? ( + ↓{comparison.behind} + ) : null} + + )} +
    +
    + {tooltipText} +
    + ); +}; + export const GitHeader: React.FC = ({ status, localBranches, @@ -264,13 +312,20 @@ export const GitHeader: React.FC = ({ /> ); + const upstreamStatusPill = status.upstreamComparison ? ( + + ) : null; + const identityControl = ( ); @@ -293,7 +348,6 @@ export const GitHeader: React.FC = ({ onCheckout={onCheckoutBranch} onCreate={onCreateBranch} remotes={remotes} - /> )}
    @@ -317,6 +371,9 @@ export const GitHeader: React.FC = ({ className="h-full" />
    + {upstreamStatusPill ? ( +
    {upstreamStatusPill}
    + ) : null}
    {syncButtons}
    ) : null} diff --git a/packages/ui/src/hooks/useBrowserVoice.ts b/packages/ui/src/hooks/useBrowserVoice.ts index 539ae567..ed794d6c 100644 --- a/packages/ui/src/hooks/useBrowserVoice.ts +++ b/packages/ui/src/hooks/useBrowserVoice.ts @@ -343,7 +343,7 @@ export function useBrowserVoice(): UseBrowserVoiceReturn { normalizedError.includes('network') || normalizedError.includes('connection') || normalizedError.includes('check connection'); - + if (isNetworkError) { console.error('[useBrowserVoice] Network error — staying in error state:', errorMsg); setError(errorMsg); @@ -355,7 +355,7 @@ export function useBrowserVoice(): UseBrowserVoiceReturn { } return; } - + console.error('[useBrowserVoice] Recognition error:', errorMsg); setError(errorMsg); setStatus('error'); @@ -374,7 +374,7 @@ export function useBrowserVoice(): UseBrowserVoiceReturn { if (nextRetry <= MAX_RECOVERY_RETRIES) { const delay = Math.min(1000 * Math.pow(2, nextRetry - 1), 8000); console.log(`[useBrowserVoice] Scheduling recovery retry ${nextRetry}/${MAX_RECOVERY_RETRIES} in ${delay}ms`); - + if (recoveryTimerRef.current !== null) { clearTimeout(recoveryTimerRef.current); } diff --git a/packages/ui/src/hooks/useMenuActions.ts b/packages/ui/src/hooks/useMenuActions.ts index 9ba3a921..e585e31a 100644 --- a/packages/ui/src/hooks/useMenuActions.ts +++ b/packages/ui/src/hooks/useMenuActions.ts @@ -1,6 +1,9 @@ import React from 'react'; import { toast } from '@/components/ui'; import { useSessionUIStore } from '@/sync/session-ui-store'; +import { getSyncSessions } from '@/sync/sync-refs'; +import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { useProjectsStore } from '@/stores/useProjectsStore'; import { useUIStore } from '@/stores/useUIStore'; import { useUpdateStore } from '@/stores/useUpdateStore'; import { useThemeSystem } from '@/contexts/useThemeSystem'; @@ -83,6 +86,12 @@ type MenuAction = | 'theme-system' | 'toggle-sidebar' | 'toggle-memory-debug' + | 'go-back' + | 'go-forward' + | 'previous-session' + | 'next-session' + | 'previous-project' + | 'next-project' | 'help-dialog' | 'download-logs'; @@ -136,6 +145,39 @@ export const useMenuActions = ( sessionEvents.requestDirectoryDialog(); }, []); + const navigateSession = React.useCallback((direction: -1 | 1) => { + const sessions = getSyncSessions(); + if (sessions.length === 0) return; + + const currentSessionId = useSessionUIStore.getState().currentSessionId; + const currentIndex = sessions.findIndex((session) => session.id === currentSessionId); + let nextIndex = direction > 0 ? 0 : sessions.length - 1; + if (currentIndex >= 0) { + nextIndex = (currentIndex + direction + sessions.length) % sessions.length; + } + const nextSession = sessions[nextIndex]; + if (!nextSession) return; + + setActiveMainTab('chat'); + setSessionSwitcherOpen(false); + useSessionUIStore.getState().setCurrentSession(nextSession.id); + }, [setActiveMainTab, setSessionSwitcherOpen]); + + const navigateProject = React.useCallback((direction: -1 | 1) => { + const { activeProjectId, projects, setActiveProject } = useProjectsStore.getState(); + if (projects.length === 0) return; + + const currentIndex = projects.findIndex((project) => project.id === activeProjectId); + let nextIndex = direction > 0 ? 0 : projects.length - 1; + if (currentIndex >= 0) { + nextIndex = (currentIndex + direction + projects.length) % projects.length; + } + const nextProject = projects[nextIndex]; + if (!nextProject) return; + + setActiveProject(nextProject.id); + }, []); + const handleAction = React.useCallback( (action: MenuAction) => { switch (action) { @@ -222,6 +264,30 @@ export const useMenuActions = ( onToggleMemoryDebug?.(); break; + case 'go-back': + useDirectoryStore.getState().goBack(); + break; + + case 'go-forward': + useDirectoryStore.getState().goForward(); + break; + + case 'previous-session': + navigateSession(-1); + break; + + case 'next-session': + navigateSession(1); + break; + + case 'previous-project': + navigateProject(-1); + break; + + case 'next-project': + navigateProject(1); + break; + case 'help-dialog': toggleHelpDialog(); break; @@ -236,6 +302,8 @@ export const useMenuActions = ( }, [ handleChangeWorkspace, + navigateProject, + navigateSession, onToggleMemoryDebug, openNewSessionDraft, setAboutDialogOpen, diff --git a/packages/ui/src/hooks/useWindowControlsOverlayLayout.ts b/packages/ui/src/hooks/useWindowControlsOverlayLayout.ts index ec6a4425..8a237e56 100644 --- a/packages/ui/src/hooks/useWindowControlsOverlayLayout.ts +++ b/packages/ui/src/hooks/useWindowControlsOverlayLayout.ts @@ -100,6 +100,8 @@ export const useWindowControlsOverlayLayout = () => { if (overlay && typeof overlay.removeEventListener === 'function') { overlay.removeEventListener('geometrychange', updateGeometry); } + + applyOverlayInsets(root, 0, 0, 0); }; }, []); }; diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 747783ba..d4f332fa 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -118,11 +118,19 @@ export interface GitRebaseInProgress { onto: string; } +export interface GitRemoteComparison { + remote: string; + branch: string; + ahead: number; + behind: number; +} + export interface GitStatus { current: string; tracking: string | null; ahead: number; behind: number; + upstreamComparison?: GitRemoteComparison | null; files: GitStatusFile[]; isClean: boolean; diffStats?: Record; diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 38d0a0c3..a749919c 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -505,6 +505,9 @@ export const dict = { 'gitView.header.noProfiles': 'No profiles available to apply.', 'gitView.header.removeRemoteAria': 'Remove Remote aria label', 'gitView.header.removeRemoteTitle': 'Remove Remote Title', + 'gitView.header.upstreamSynced': 'synced', + 'gitView.header.upstreamTooltip': 'Compared with {target}.', + 'gitView.header.upstreamTooltipTracking': 'Compared with {target}. Primary sync badges still reflect {tracking}.', 'gitView.history.binary': 'Binary', 'gitView.history.binaryNoDiff': 'Binary file — no diff available', 'gitView.history.commitsPlaceholder': 'Commits Placeholder', @@ -762,7 +765,7 @@ export const dict = { 'gitView.toast.mergedIntoBranch': 'Merged {branch} into {currentBranch}', 'gitView.toast.pulledFilesPlural': 'Pulled {count} files from {name}', 'gitView.toast.pulledFilesSingle': 'Pulled {count} file from {name}', - 'gitView.toast.pushedToUpstream': 'Pushed to upstream', + 'gitView.toast.pushedToUpstream': 'Pushed to {name}', 'gitView.toast.commitOrStashBeforeSync': 'Commit or stash your changes before syncing', 'gitView.toast.alreadyUpToDate': 'Already up to date', 'gitView.toast.syncedPulledPluralAndPushed': 'Pulled {count} files from {name} and pushed to upstream', @@ -1259,6 +1262,8 @@ export const dict = { 'helpDialog.proTips.themeCycling': 'Theme cycling remembers your preference across sessions', 'header.actions.rightSidebarWithShortcut': 'Right sidebar ({shortcut})', 'header.actions.toggleRightSidebarAria': 'Toggle right sidebar', + 'header.actions.openAppMenu': 'OpenChamber menu', + 'header.actions.openAppMenuAria': 'Open OpenChamber menu', 'header.actions.openSessionsWithShortcut': 'Open sessions ({shortcut})', 'header.actions.openSessionsAria': 'Open sessions', 'header.actions.closeSessionsAria': 'Close sessions', @@ -2085,6 +2090,11 @@ export const dict = { 'header.actions.newMiniChatAria': 'Open a new Mini Chat window', 'header.actions.openSessionMiniChat': 'Open Session in Mini Chat', 'header.actions.openSessionMiniChatAria': 'Open current session in Mini Chat', + 'header.windowControls.groupAria': 'Window controls', + 'header.windowControls.minimize': 'Minimize window', + 'header.windowControls.maximize': 'Maximize window', + 'header.windowControls.restore': 'Restore window', + 'header.windowControls.close': 'Close window', 'errorBoundary.title': 'Something went wrong', 'errorBoundary.description': 'The application encountered an unexpected error. This has been logged for debugging.', 'errorBoundary.state.unknownError': 'Unknown error', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index e977bf5f..6cd4b556 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -506,6 +506,9 @@ export const dict: Record = { "gitView.header.noProfiles": "No hay perfiles disponibles para aplicar.", "gitView.header.removeRemoteAria": "Eliminar remoto", "gitView.header.removeRemoteTitle": "Eliminar remoto", + "gitView.header.upstreamSynced": "sincronizado", + "gitView.header.upstreamTooltip": "Comparado con {target}.", + "gitView.header.upstreamTooltipTracking": "Comparado con {target}. Los indicadores principales de sincronización aún reflejan {tracking}.", "gitView.history.binary": "Binario", "gitView.history.binaryNoDiff": "Archivo binario — no hay diff disponible", "gitView.history.commitsPlaceholder": "Buscar commits...", @@ -763,7 +766,7 @@ export const dict: Record = { "gitView.toast.mergedIntoBranch": "Merge de {branch} en {currentBranch}", "gitView.toast.pulledFilesPlural": "Se trajeron {count} archivos de {name}", "gitView.toast.pulledFilesSingle": "Se trajo {count} archivo de {name}", - "gitView.toast.pushedToUpstream": "Enviado al upstream", + "gitView.toast.pushedToUpstream": "Enviado a {name}", "gitView.toast.commitOrStashBeforeSync": "Haz commit o stash de tus cambios antes de sincronizar", "gitView.toast.alreadyUpToDate": "Ya está actualizado", "gitView.toast.syncedPulledPluralAndPushed": "Se trajeron {count} archivos de {name} y se envió al upstream", @@ -1225,6 +1228,8 @@ export const dict: Record = { "helpDialog.proTips.themeCycling": "El ciclo de tema recuerda tu preferencia entre sesiones", "header.actions.rightSidebarWithShortcut": "Barra lateral derecha ({shortcut})", "header.actions.toggleRightSidebarAria": "Mostrar u ocultar barra lateral derecha", + "header.actions.openAppMenu": "Menú de OpenChamber", + "header.actions.openAppMenuAria": "Abrir menú de OpenChamber", "header.actions.openSessionsWithShortcut": "Abrir sesiones ({shortcut})", "header.actions.openSessionsAria": "Abrir sesiones", "header.actions.closeSessionsAria": "Cerrar sesiones", @@ -2051,6 +2056,11 @@ export const dict: Record = { "header.actions.newMiniChatAria": "Abrir una nueva ventana Mini Chat", "header.actions.openSessionMiniChat": "Abrir sesión en Mini Chat", "header.actions.openSessionMiniChatAria": "Abrir la sesión actual en Mini Chat", + "header.windowControls.groupAria": "Controles de ventana", + "header.windowControls.minimize": "Minimizar ventana", + "header.windowControls.maximize": "Maximizar ventana", + "header.windowControls.restore": "Restaurar ventana", + "header.windowControls.close": "Cerrar ventana", "errorBoundary.title": "Algo salió mal", "errorBoundary.description": "La aplicación encontró un error inesperado. Esto se ha registrado para depuración.", "errorBoundary.state.unknownError": "Error desconocido", diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index ea915de8..735043e1 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -506,6 +506,9 @@ export const dict: Record = { 'gitView.header.noProfiles': '적용할 프로필 없음', 'gitView.header.removeRemoteAria': '리모트 제거', 'gitView.header.removeRemoteTitle': '리모트 제거', + 'gitView.header.upstreamSynced': '동기화됨', + 'gitView.header.upstreamTooltip': '{target}와 비교됨.', + 'gitView.header.upstreamTooltipTracking': '{target}와 비교됨. 기본 동기화 배지는 계속 {tracking}을 반영합니다.', 'gitView.history.binary': '바이너리', 'gitView.history.binaryNoDiff': '바이너리 파일 — diff 없음', 'gitView.history.commitsPlaceholder': '커밋 검색', @@ -763,7 +766,7 @@ export const dict: Record = { 'gitView.toast.mergedIntoBranch': '{branch}을(를) {currentBranch}에 병합했습니다', 'gitView.toast.pulledFilesPlural': '{name}에서 파일 {count}개를 풀했습니다', 'gitView.toast.pulledFilesSingle': '{name}에서 파일 {count}개를 풀했습니다', - 'gitView.toast.pushedToUpstream': '업스트림에 푸시했습니다', + 'gitView.toast.pushedToUpstream': '{name}에 푸시했습니다', 'gitView.toast.commitOrStashBeforeSync': '동기화하기 전에 변경 사항을 커밋하거나 stash하세요', 'gitView.toast.alreadyUpToDate': '이미 최신 상태입니다', 'gitView.toast.syncedPulledPluralAndPushed': '{name}에서 파일 {count}개를 풀하고 업스트림에 푸시했습니다', @@ -1261,6 +1264,8 @@ export const dict: Record = { 'helpDialog.proTips.themeCycling': '테마 순환은 세션 간에도 선호 설정을 기억합니다', 'header.actions.rightSidebarWithShortcut': '오른쪽 사이드바 ({shortcut})', 'header.actions.toggleRightSidebarAria': '오른쪽 사이드바 토글', + 'header.actions.openAppMenu': 'OpenChamber 메뉴', + 'header.actions.openAppMenuAria': 'OpenChamber 메뉴 열기', 'header.actions.openSessionsWithShortcut': '세션 ({shortcut}) 열기', 'header.actions.openSessionsAria': '세션 열기', 'header.actions.closeSessionsAria': '세션 닫기', @@ -2085,6 +2090,11 @@ export const dict: Record = { 'header.actions.newMiniChatAria': '새 Mini Chat 창 열기', 'header.actions.openSessionMiniChat': 'Mini Chat에서 세션 열기', 'header.actions.openSessionMiniChatAria': '현재 세션을 Mini Chat에서 열기', + 'header.windowControls.groupAria': '창 컨트롤', + 'header.windowControls.minimize': '창 최소화', + 'header.windowControls.maximize': '창 최대화', + 'header.windowControls.restore': '창 복원', + 'header.windowControls.close': '창 닫기', 'errorBoundary.title': '문제가 발생했습니다', 'errorBoundary.description': '애플리케이션에서 예상치 못한 오류가 발생했습니다. 디버깅을 위해 기록되었습니다.', 'errorBoundary.state.unknownError': '알 수 없음 오류', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index d75d4725..4cee60ae 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -601,6 +601,11 @@ export const dict: Record = { 'header.actions.newMiniChatAria': 'Otwórz nowe okno Mini Chat', 'header.actions.openSessionMiniChat': 'Otwórz sesję w Mini Chat', 'header.actions.openSessionMiniChatAria': 'Otwórz bieżącą sesję w Mini Chat', + 'header.windowControls.groupAria': 'Elementy sterujące oknem', + 'header.windowControls.minimize': 'Minimalizuj okno', + 'header.windowControls.maximize': 'Maksymalizuj okno', + 'header.windowControls.restore': 'Przywróć okno', + 'header.windowControls.close': 'Zamknij okno', 'errorBoundary.title': 'Coś poszło nie tak', 'errorBoundary.description': 'Aplikacja napotkała nieoczekiwany błąd. Zostało to zalogowane do celów debugowania.', 'errorBoundary.state.unknownError': 'Nieznany błąd', @@ -1488,6 +1493,9 @@ export const dict: Record = { 'gitView.header.noProfiles': 'No profiles available to apply.', 'gitView.header.removeRemoteAria': 'Remove Remote aria label', 'gitView.header.removeRemoteTitle': 'Remove Remote Title', + 'gitView.header.upstreamSynced': 'zsynchronizowano', + 'gitView.header.upstreamTooltip': 'Porównano z {target}.', + 'gitView.header.upstreamTooltipTracking': 'Porównano z {target}. Główne wskaźniki synchronizacji nadal odzwierciedlają {tracking}.', 'gitView.history.binary': 'Binary', 'gitView.history.binaryNoDiff': 'Binary file — no diff available', 'gitView.history.commitsPlaceholder': 'Commits Placeholder', @@ -1729,6 +1737,8 @@ export const dict: Record = { 'header.actions.newSessionWithShortcut': 'Nowa sesja ({shortcut})', 'header.actions.openPlanAria': 'Otwórz plan', 'header.actions.openSessionsAria': 'Otwórz sesje', + 'header.actions.openAppMenu': 'Menu OpenChamber', + 'header.actions.openAppMenuAria': 'Otwórz menu OpenChamber', 'header.actions.openSessionsWithShortcut': 'Otwórz sesje ({shortcut})', 'header.actions.planWithShortcut': 'Plan ({shortcut})', 'header.actions.rightSidebarWithShortcut': 'Prawy panel boczny ({shortcut})', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 39730d5e..3cbdc993 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -506,6 +506,9 @@ export const dict: Record = { "gitView.header.noProfiles": "Não há perfiles disponíveis para aplicar.", "gitView.header.removeRemoteAria": "Excluir remoto", "gitView.header.removeRemoteTitle": "Excluir remoto", + "gitView.header.upstreamSynced": "sincronizado", + "gitView.header.upstreamTooltip": "Comparado com {target}.", + "gitView.header.upstreamTooltipTracking": "Comparado com {target}. Os indicadores principais de sincronização ainda refletem {tracking}.", "gitView.history.binary": "Binario", "gitView.history.binaryNoDiff": "Arquivo binário — diff não disponível", "gitView.history.commitsPlaceholder": "Buscar commits...", @@ -763,7 +766,7 @@ export const dict: Record = { "gitView.toast.mergedIntoBranch": "Merge de {branch} em {currentBranch}", "gitView.toast.pulledFilesPlural": "Se trajeron {count} arquivos de {name}", "gitView.toast.pulledFilesSingle": "Se trajo {count} arquivo de {name}", - "gitView.toast.pushedToUpstream": "Enviado ao upstream", + "gitView.toast.pushedToUpstream": "Enviado para {name}", "gitView.toast.commitOrStashBeforeSync": "Faça commit ou stash das alterações antes de sincronizar", "gitView.toast.alreadyUpToDate": "Já está atualizado", "gitView.toast.syncedPulledPluralAndPushed": "Foram trazidos {count} arquivos de {name} e enviados ao upstream", @@ -1225,6 +1228,8 @@ export const dict: Record = { "helpDialog.proTips.themeCycling": "A alternância de tema lembra sua preferência entre sessões", "header.actions.rightSidebarWithShortcut": "Barra lateral direita ({shortcut})", "header.actions.toggleRightSidebarAria": "Mostrar ou ocultar barra lateral direita", + "header.actions.openAppMenu": "Menu do OpenChamber", + "header.actions.openAppMenuAria": "Abrir menu do OpenChamber", "header.actions.openSessionsWithShortcut": "Abrir sessões ({shortcut})", "header.actions.openSessionsAria": "Abrir sessões", "header.actions.closeSessionsAria": "Fechar sessões", @@ -2051,6 +2056,11 @@ export const dict: Record = { "header.actions.newMiniChatAria": "Abrir uma nova janela Mini Chat", "header.actions.openSessionMiniChat": "Abrir sessão no Mini Chat", "header.actions.openSessionMiniChatAria": "Abrir a sessão atual no Mini Chat", + "header.windowControls.groupAria": "Controles da janela", + "header.windowControls.minimize": "Minimizar janela", + "header.windowControls.maximize": "Maximizar janela", + "header.windowControls.restore": "Restaurar janela", + "header.windowControls.close": "Fechar janela", "errorBoundary.title": "Algo deu errado", "errorBoundary.description": "O aplicativo encontrou um erro inesperado. Isso foi registrado para depuração.", "errorBoundary.state.unknownError": "Erro desconhecido", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 2cb895da..1d7fb2ea 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -506,6 +506,9 @@ export const dict: Record = { "gitView.header.noProfiles": "Немає доступних профілів для застосування.", "gitView.header.removeRemoteAria": "Видалити remote", "gitView.header.removeRemoteTitle": "Видалити remote", + "gitView.header.upstreamSynced": "синхронізовано", + "gitView.header.upstreamTooltip": "Порівняно з {target}.", + "gitView.header.upstreamTooltipTracking": "Порівняно з {target}. Основні індикатори синхронізації все ще відображають {tracking}.", "gitView.history.binary": "Бінарний", "gitView.history.binaryNoDiff": "Бінарний файл — diff недоступний", "gitView.history.commitsPlaceholder": "Пошук комітів", @@ -763,7 +766,7 @@ export const dict: Record = { "gitView.toast.mergedIntoBranch": "Злито {branch} в {currentBranch}", "gitView.toast.pulledFilesPlural": "Отримано файлів: {count} з {name}", "gitView.toast.pulledFilesSingle": "Отримано файл: {count} з {name}", - "gitView.toast.pushedToUpstream": "Надіслано в upstream", + "gitView.toast.pushedToUpstream": "Надіслано в {name}", "gitView.toast.commitOrStashBeforeSync": "Закомітьте або сховайте зміни перед синхронізацією", "gitView.toast.alreadyUpToDate": "Вже актуально", "gitView.toast.syncedPulledPluralAndPushed": "Отримано файлів: {count} з {name} і надіслано в upstream", @@ -1225,6 +1228,8 @@ export const dict: Record = { "helpDialog.proTips.themeCycling": "Перемикання теми запам’ятовує ваші переваги протягом сесій", "header.actions.rightSidebarWithShortcut": "Права бічна панель ({shortcut})", "header.actions.toggleRightSidebarAria": "Перемкнути праву бічну панель", + "header.actions.openAppMenu": "Меню OpenChamber", + "header.actions.openAppMenuAria": "Відкрити меню OpenChamber", "header.actions.openSessionsWithShortcut": "Відкрити сесії ({shortcut})", "header.actions.openSessionsAria": "Відкрити сесії", "header.actions.closeSessionsAria": "Закрити сесії", @@ -2051,6 +2056,11 @@ export const dict: Record = { "header.actions.newMiniChatAria": "Відкрити нове вікно Mini Chat", "header.actions.openSessionMiniChat": "Відкрити сесію в Mini Chat", "header.actions.openSessionMiniChatAria": "Відкрити поточну сесію в Mini Chat", + "header.windowControls.groupAria": "Елементи керування вікном", + "header.windowControls.minimize": "Згорнути вікно", + "header.windowControls.maximize": "Розгорнути вікно", + "header.windowControls.restore": "Відновити вікно", + "header.windowControls.close": "Закрити вікно", "errorBoundary.title": "Щось пішло не так", "errorBoundary.description": "У програмі сталася неочікувана помилка. Це було зареєстровано для налагодження.", "errorBoundary.state.unknownError": "Невідома помилка", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 0c972c75..6a952754 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -506,6 +506,9 @@ export const dict: Record = { 'gitView.header.noProfiles': '没有可应用的配置。', 'gitView.header.removeRemoteAria': '移除远程 {name}', 'gitView.header.removeRemoteTitle': '移除 {name}', + 'gitView.header.upstreamSynced': '已同步', + 'gitView.header.upstreamTooltip': '与 {target} 对比。', + 'gitView.header.upstreamTooltipTracking': '与 {target} 对比。主要同步徽标仍然反映 {tracking}。', 'gitView.history.binary': '二进制', 'gitView.history.binaryNoDiff': '二进制文件,无法显示差异', 'gitView.history.commitsPlaceholder': '提交数', @@ -763,7 +766,7 @@ export const dict: Record = { 'gitView.toast.mergedIntoBranch': '已将 {branch} 合并到 {currentBranch}', 'gitView.toast.pulledFilesPlural': '已从 {name} 拉取 {count} 个文件', 'gitView.toast.pulledFilesSingle': '已从 {name} 拉取 {count} 个文件', - 'gitView.toast.pushedToUpstream': '已推送到上游', + 'gitView.toast.pushedToUpstream': '已推送到 {name}', 'gitView.toast.commitOrStashBeforeSync': '同步前请先提交或储藏你的更改', 'gitView.toast.alreadyUpToDate': '已是最新状态', 'gitView.toast.syncedPulledPluralAndPushed': '已从 {name} 拉取 {count} 个文件并推送到上游', @@ -1225,6 +1228,8 @@ export const dict: Record = { 'helpDialog.proTips.themeCycling': '主题循环会记住你在各会话中的偏好', 'header.actions.rightSidebarWithShortcut': '右侧边栏({shortcut})', 'header.actions.toggleRightSidebarAria': '切换右侧边栏', + 'header.actions.openAppMenu': 'OpenChamber 菜单', + 'header.actions.openAppMenuAria': '打开 OpenChamber 菜单', 'header.actions.openSessionsWithShortcut': '打开会话({shortcut})', 'header.actions.openSessionsAria': '打开会话', 'header.actions.closeSessionsAria': '关闭会话', @@ -2051,6 +2056,11 @@ export const dict: Record = { 'header.actions.newMiniChatAria': '打开新的 Mini Chat 窗口', 'header.actions.openSessionMiniChat': '在 Mini Chat 中打开会话', 'header.actions.openSessionMiniChatAria': '在 Mini Chat 中打开当前会话', + 'header.windowControls.groupAria': '窗口控件', + 'header.windowControls.minimize': '最小化窗口', + 'header.windowControls.maximize': '最大化窗口', + 'header.windowControls.restore': '还原窗口', + 'header.windowControls.close': '关闭窗口', 'errorBoundary.title': '发生错误', 'errorBoundary.description': '应用遇到意外错误,已记录用于调试。', 'errorBoundary.state.unknownError': '未知错误', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 06543512..704cccc4 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -506,6 +506,9 @@ export const dict: Record = { 'gitView.header.noProfiles': '沒有可套用的設定。', 'gitView.header.removeRemoteAria': '移除遠端 {name}', 'gitView.header.removeRemoteTitle': '移除 {name}', + 'gitView.header.upstreamSynced': '已同步', + 'gitView.header.upstreamTooltip': '與 {target} 比較。', + 'gitView.header.upstreamTooltipTracking': '與 {target} 比較。主要同步徽章仍反映 {tracking}。', 'gitView.history.binary': '二進位', 'gitView.history.binaryNoDiff': '二進位檔案 — 無可用 diff', 'gitView.history.commitsPlaceholder': '提交數', @@ -1223,6 +1226,8 @@ export const dict: Record = { 'helpDialog.proTips.themeCycling': '主題循環會記住你在各會話中的偏好', 'header.actions.rightSidebarWithShortcut': '右側邊欄({shortcut})', 'header.actions.toggleRightSidebarAria': '切換右側邊欄', + 'header.actions.openAppMenu': 'OpenChamber 選單', + 'header.actions.openAppMenuAria': '開啟 OpenChamber 選單', 'header.actions.openSessionsWithShortcut': '開啟會話({shortcut})', 'header.actions.openSessionsAria': '開啟會話', 'header.actions.closeSessionsAria': '關閉會話', @@ -2049,6 +2054,11 @@ export const dict: Record = { 'header.actions.newMiniChatAria': '開啟新的 Mini Chat 視窗', 'header.actions.openSessionMiniChat': '在 Mini Chat 中開啟會話', 'header.actions.openSessionMiniChatAria': '在 Mini Chat 中開啟目前會話', + 'header.windowControls.groupAria': '視窗控制項', + 'header.windowControls.minimize': '最小化視窗', + 'header.windowControls.maximize': '最大化視窗', + 'header.windowControls.restore': '還原視窗', + 'header.windowControls.close': '關閉視窗', 'errorBoundary.title': '發生錯誤', 'errorBoundary.description': '應用程式遇到意外錯誤,已記錄用於偵錯。', 'errorBoundary.state.unknownError': '未知錯誤', diff --git a/packages/ui/src/lib/openInApps.ts b/packages/ui/src/lib/openInApps.ts index 19861f80..040f4feb 100644 --- a/packages/ui/src/lib/openInApps.ts +++ b/packages/ui/src/lib/openInApps.ts @@ -34,13 +34,23 @@ export const DEFAULT_OPEN_IN_APP_ID = 'finder'; export const OPEN_IN_ALWAYS_AVAILABLE_APP_IDS = new Set(['finder', 'terminal']); export const OPEN_DIRECTORY_APP_IDS = new Set(['finder', 'terminal', 'iterm2', 'ghostty']); +export const getPlatformOpenInApp = (app: OpenInApp): OpenInApp => { + if (typeof window !== 'undefined' && window.__OPENCHAMBER_PLATFORM__ === 'win32') { + if (app.id === 'finder') { + return { ...app, label: 'Explorer', appName: 'File Explorer' }; + } + } + return app; +}; + export const getOpenInAppById = (id: string | null | undefined): OpenInApp | null => { if (!id) { return null; } - return OPEN_IN_APPS.find((app) => app.id === id) ?? null; + const app = OPEN_IN_APPS.find((candidate) => candidate.id === id) ?? null; + return app ? getPlatformOpenInApp(app) : null; }; export const getDefaultOpenInApp = (): OpenInApp => { - return getOpenInAppById(DEFAULT_OPEN_IN_APP_ID) ?? OPEN_IN_APPS[0]; + return getOpenInAppById(DEFAULT_OPEN_IN_APP_ID) ?? getPlatformOpenInApp(OPEN_IN_APPS[0]); }; diff --git a/packages/ui/src/stores/useGitStore.ts b/packages/ui/src/stores/useGitStore.ts index d6dd611d..b910d5be 100644 --- a/packages/ui/src/stores/useGitStore.ts +++ b/packages/ui/src/stores/useGitStore.ts @@ -204,6 +204,21 @@ const haveDiffStatsChanged = ( return false; }; +const haveRemoteComparisonChanged = ( + previous?: GitStatus['upstreamComparison'], + next?: GitStatus['upstreamComparison'] +): boolean => { + if (!previous && !next) return false; + if (!previous || !next) return true; + + return ( + previous.remote !== next.remote + || previous.branch !== next.branch + || previous.ahead !== next.ahead + || previous.behind !== next.behind + ); +}; + const hasStatusChanged = (oldStatus: GitStatus | null, newStatus: GitStatus | null): boolean => { if (!oldStatus && !newStatus) return false; if (!oldStatus || !newStatus) return true; @@ -217,6 +232,12 @@ const hasStatusChanged = (oldStatus: GitStatus | null, newStatus: GitStatus | nu if (oldStatus.current !== newStatus.current) return true; if (oldStatus.tracking !== newStatus.tracking) return true; if (oldStatus.isClean !== newStatus.isClean) return true; + if ( + newStatus.upstreamComparison !== undefined + && haveRemoteComparisonChanged(oldStatus.upstreamComparison, newStatus.upstreamComparison) + ) { + return true; + } const oldPaths = new Set(oldFiles.map(f => `${f.path}:${f.index}:${f.working_dir}`)); for (const file of newFiles) { @@ -465,9 +486,17 @@ export const useGitStore = create()( } // Preserve diffStats from previous status when light mode returns none - const mergedStatus = newStatus.diffStats === undefined && currentDirState.status?.diffStats - ? { ...newStatus, diffStats: currentDirState.status.diffStats } - : newStatus; + const mergedStatus = { + ...newStatus, + diffStats: + newStatus.diffStats === undefined && currentDirState.status?.diffStats !== undefined + ? currentDirState.status.diffStats + : newStatus.diffStats, + upstreamComparison: + newStatus.upstreamComparison === undefined + ? currentDirState.status?.upstreamComparison + : newStatus.upstreamComparison, + }; newDirectories.set(directory, { ...currentDirState, diff --git a/packages/ui/src/stores/useOpenInAppsStore.ts b/packages/ui/src/stores/useOpenInAppsStore.ts index 7351f757..07ed395b 100644 --- a/packages/ui/src/stores/useOpenInAppsStore.ts +++ b/packages/ui/src/stores/useOpenInAppsStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; import { fetchDesktopInstalledApps, isDesktopLocalOriginActive, isTauriShell, type DesktopSettings, type InstalledDesktopAppInfo } from '@/lib/desktop'; -import { OPEN_IN_APPS, DEFAULT_OPEN_IN_APP_ID, OPEN_IN_ALWAYS_AVAILABLE_APP_IDS, getOpenInAppById, type OpenInApp } from '@/lib/openInApps'; +import { OPEN_IN_APPS, DEFAULT_OPEN_IN_APP_ID, OPEN_IN_ALWAYS_AVAILABLE_APP_IDS, getOpenInAppById, getPlatformOpenInApp, type OpenInApp } from '@/lib/openInApps'; import { updateDesktopSettings } from '@/lib/persistence'; export type OpenInAppOption = OpenInApp & { @@ -22,7 +22,7 @@ type OpenInAppsState = { const getAlwaysAvailableApps = (): OpenInAppOption[] => { return OPEN_IN_APPS .filter((app) => OPEN_IN_ALWAYS_AVAILABLE_APP_IDS.has(app.id)) - .map((app) => ({ ...app })); + .map((app) => ({ ...getPlatformOpenInApp(app) })); }; const getStoredAppId = (): string => { @@ -78,7 +78,7 @@ export const useOpenInAppsStore = create()((set, get) => ({ ); const withIcons = filtered.map((app) => ({ - ...app, + ...getPlatformOpenInApp(app), iconDataUrl: iconMap.get(app.appName), })); diff --git a/packages/ui/src/types/desktop.d.ts b/packages/ui/src/types/desktop.d.ts index 24f3c16b..96184fbf 100644 --- a/packages/ui/src/types/desktop.d.ts +++ b/packages/ui/src/types/desktop.d.ts @@ -6,6 +6,7 @@ declare global { __OPENCHAMBER_MACOS_MAJOR__?: number; __OPENCHAMBER_LOCAL_ORIGIN__?: string; __OPENCHAMBER_ELECTRON__?: { runtime?: string }; + __OPENCHAMBER_PLATFORM__?: string; __OPENCHAMBER_DESKTOP_BOOT_OUTCOME__?: DesktopBootOutcome; } diff --git a/packages/ui/src/types/xenova-transformers.d.ts b/packages/ui/src/types/xenova-transformers.d.ts new file mode 100644 index 00000000..a6cb9faf --- /dev/null +++ b/packages/ui/src/types/xenova-transformers.d.ts @@ -0,0 +1,20 @@ +declare module '@xenova/transformers' { + export const env: { + allowLocalModels: boolean; + backends: { + onnx: { + wasm: { + numThreads: number; + }; + }; + }; + }; + + export function pipeline( + task: 'automatic-speech-recognition', + model: string, + options?: { + progress_callback?: (info: { status?: string; file?: string; loaded?: number; total?: number }) => void; + }, + ): Promise<(input: Float32Array, options?: Record) => Promise<{ text: string }>>; +} diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 8bc94856..6c8050c5 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -228,7 +228,7 @@ "watch:extension": "esbuild src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node --watch --sourcemap --main-fields=module,main", "watch:webview": "vite build --watch", "type-check": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.webview.json", - "lint": "bunx eslint --ext .ts,.tsx src webview", + "lint": "bun x eslint --ext .ts,.tsx src webview", "package": "vsce package --no-dependencies" }, "devDependencies": { diff --git a/packages/vscode/src/gitService.ts b/packages/vscode/src/gitService.ts index 7eb76f15..af245c5a 100644 --- a/packages/vscode/src/gitService.ts +++ b/packages/vscode/src/gitService.ts @@ -2564,7 +2564,7 @@ export async function gitPull( const files = changedFiles.exitCode === 0 ? changedFiles.stdout.split('\n').map((line) => line.trim()).filter(Boolean) : []; - + return { success: result.exitCode === 0, summary: { changes: files.length, insertions: 0, deletions: 0 }, diff --git a/packages/vscode/src/skillsCatalog.ts b/packages/vscode/src/skillsCatalog.ts index e499f2de..bccdc0dc 100644 --- a/packages/vscode/src/skillsCatalog.ts +++ b/packages/vscode/src/skillsCatalog.ts @@ -450,11 +450,11 @@ function parseSkillRepoSource(input: string, subpath?: string) { : urlFormat === 'ssh' ? (raw.split('@')[1].split(':')[1] ?? '').split('/').filter(Boolean) : null; - + const repoName = pathSegments && pathSegments.length > 0 ? pathSegments[pathSegments.length - 1].replace(/\.git$/i, '') : null; - + const gitOwner = pathSegments && pathSegments.length > 1 ? pathSegments.slice(0, -1).join('/') : (pathSegments && pathSegments.length === 1 ? pathSegments[0] : null); diff --git a/packages/web/server/lib/fs/routes.js b/packages/web/server/lib/fs/routes.js index 9984e783..14ddcbea 100644 --- a/packages/web/server/lib/fs/routes.js +++ b/packages/web/server/lib/fs/routes.js @@ -772,6 +772,11 @@ export const registerFsRoutes = (app, dependencies) => { return res.status(400).json({ error: resolved.error }); } + const existing = await fsPromises.readFile(resolved.resolved, 'utf8').catch(() => null); + if (existing === content) { + return res.json({ success: true, path: resolved.resolved }); + } + await fsPromises.mkdir(path.dirname(resolved.resolved), { recursive: true }); await fsPromises.writeFile(resolved.resolved, content, 'utf8'); return res.json({ success: true, path: resolved.resolved }); diff --git a/packages/web/server/lib/fs/routes.test.js b/packages/web/server/lib/fs/routes.test.js index 4280a011..ac3c1eb8 100644 --- a/packages/web/server/lib/fs/routes.test.js +++ b/packages/web/server/lib/fs/routes.test.js @@ -90,7 +90,10 @@ const registerExec = ({ spawn }) => { registerFsRoutes(app, { os: { homedir: () => '/home/user' }, path, - fsPromises: { stat: async () => ({ isDirectory: () => true }) }, + fsPromises: { + realpath: async (targetPath) => targetPath, + stat: async () => ({ isDirectory: () => true }), + }, spawn, crypto: { randomUUID: (() => { let n = 0; return () => `job-${n++}`; })() }, normalizeDirectoryPath: (p) => p, @@ -102,12 +105,69 @@ const registerExec = ({ spawn }) => { return getRoute('POST', '/api/fs/exec'); }; +const registerWrite = (fsPromises) => { + const { app, getRoute } = createRouteRegistry(); + registerFsRoutes(app, { + os: { homedir: () => '/home/user' }, + path: path.posix, + fsPromises: { + realpath: async (targetPath) => targetPath, + ...fsPromises, + }, + spawn: vi.fn(), + crypto: { randomUUID: () => 'job-0' }, + normalizeDirectoryPath: (p) => p, + resolveProjectDirectory: async () => ({ directory: '/repo' }), + buildAugmentedPath: () => '/usr/bin', + resolveGitBinaryForSpawn: () => 'git', + openchamberUserConfigRoot: '/home/user/.config', + }); + return getRoute('POST', '/api/fs/write'); +}; + const callExec = async (handler, body) => { const res = createMockResponse(); await handler({ body }, res); return res; }; +const callWrite = async (handler, body) => { + const res = createMockResponse(); + await handler({ body }, res); + return res; +}; + +describe('fs write', () => { + it('does not rewrite a file when content is unchanged', async () => { + const fsPromises = { + readFile: vi.fn(async () => 'same'), + mkdir: vi.fn(async () => undefined), + writeFile: vi.fn(async () => undefined), + }; + const handler = registerWrite(fsPromises); + + const res = await callWrite(handler, { path: '/repo/file.txt', content: 'same' }); + + expect(res.body).toEqual({ success: true, path: '/repo/file.txt' }); + expect(fsPromises.writeFile).not.toHaveBeenCalled(); + }); + + it('writes a file when content changed', async () => { + const fsPromises = { + readFile: vi.fn(async () => 'old'), + mkdir: vi.fn(async () => undefined), + writeFile: vi.fn(async () => undefined), + }; + const handler = registerWrite(fsPromises); + + const res = await callWrite(handler, { path: '/repo/file.txt', content: 'new' }); + + expect(res.body).toEqual({ success: true, path: '/repo/file.txt' }); + expect(fsPromises.mkdir).toHaveBeenCalledWith('/repo', { recursive: true }); + expect(fsPromises.writeFile).toHaveBeenCalledWith('/repo/file.txt', 'new', 'utf8'); + }); +}); + describe('fs exec git-read cache', () => { beforeEach(() => { delete process.env.OPENCHAMBER_GIT_READ_CACHE_TTL_MS; diff --git a/packages/web/server/lib/git/DOCUMENTATION.md b/packages/web/server/lib/git/DOCUMENTATION.md index 9886d14f..4bb0020e 100644 --- a/packages/web/server/lib/git/DOCUMENTATION.md +++ b/packages/web/server/lib/git/DOCUMENTATION.md @@ -101,6 +101,7 @@ The following functions are internal helpers used by exported functions: - `tracking`: Upstream branch (e.g., 'origin/main'). - `ahead`: Number of commits ahead of upstream. - `behind`: Number of commits behind upstream. +- `upstreamComparison`: Optional comparison against `upstream/`, with `{ remote, branch, ahead, behind }`. - `files`: Array of file objects with `path`, `index`, `working_dir` status codes. - `isClean`: Boolean indicating if working tree is clean. - `diffStats`: Object mapping file paths to `{ insertions, deletions }`. diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index fc25a96b..57976b75 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -12,6 +12,10 @@ const execFileAsync = promisify(execFile); const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf']; let resolvedGitBinary = null; const worktreeBootstrapState = new Map(); +const remoteExistenceCache = new Map(); +const SIMPLE_GIT_SAFE_BINARY_PATTERN = /^([a-z]:)?([a-z0-9/.\\_~-]+)$/i; +const SIMPLE_GIT_UNSAFE_BINARY_WARNING = 'Invalid value supplied for custom binary, restricted characters must be removed'; +const REMOTE_EXISTENCE_CACHE_TTL_MS = 30_000; const gitIndexMutationQueues = new Map(); const WORKTREE_BOOTSTRAP_PENDING = 'pending'; @@ -86,6 +90,30 @@ const normalizeGitExecutableCandidate = (candidate) => { return trimmed; }; +const isSafeSimpleGitBinary = (candidate) => ( + typeof candidate === 'string' && SIMPLE_GIT_SAFE_BINARY_PATTERN.test(candidate) +); + +const createSimpleGit = (options) => { + if (!options?.unsafe?.allowUnsafeCustomBinary) { + return simpleGit(options); + } + + const originalWarn = console.warn; + console.warn = (...args) => { + if (String(args[0] || '').includes(SIMPLE_GIT_UNSAFE_BINARY_WARNING)) { + return; + } + originalWarn(...args); + }; + + try { + return simpleGit(options); + } finally { + console.warn = originalWarn; + } +}; + const listPathExecutableCandidates = (binaryName) => { const currentPath = process.env.PATH || ''; const seen = new Set(); @@ -133,22 +161,34 @@ const resolveGitBinary = () => { .map((value) => (typeof value === 'string' ? value.trim() : '')) .filter(Boolean); for (const candidate of explicit) { - if (isExecutableFile(candidate)) { - resolvedGitBinary = candidate; + const normalized = normalizeGitExecutableCandidate(candidate); + if (isExecutableFile(normalized)) { + resolvedGitBinary = normalized; return resolvedGitBinary; } } - const discovered = [ + const pathDiscovered = [ ...listPathExecutableCandidates('git.exe'), ...listPathExecutableCandidates('git'), + ] + .map(normalizeGitExecutableCandidate) + .filter(Boolean) + .filter((candidate) => isExecutableFile(candidate)); + if (pathDiscovered.length > 0) { + resolvedGitBinary = 'git'; + return resolvedGitBinary; + } + + const discovered = [ ...listWindowsGitInstallCandidates(), ] .map(normalizeGitExecutableCandidate) .filter(Boolean) .filter((candidate) => isExecutableFile(candidate)); - const preferredExe = discovered.find((candidate) => candidate.toLowerCase().endsWith('.exe')); + const preferredExe = discovered.find((candidate) => isSafeSimpleGitBinary(candidate) && candidate.toLowerCase().endsWith('.exe')) + || discovered.find((candidate) => candidate.toLowerCase().endsWith('.exe')); resolvedGitBinary = preferredExe || discovered[0] || 'git.exe'; return resolvedGitBinary; }; @@ -276,9 +316,9 @@ const createGit = async (directory) => { const hasCustomBinary = typeof binary === 'string' && binary.trim() && binary !== 'git' && binary !== 'git.exe'; const unsafe = hasCustomBinary ? { allowUnsafeCustomBinary: true } : undefined; if (!directory) { - return simpleGit({ env, spawnOptions, binary, unsafe }); + return createSimpleGit({ env, spawnOptions, binary, unsafe }); } - return simpleGit({ + return createSimpleGit({ baseDir: normalizeDirectoryPath(directory), env, spawnOptions, @@ -677,6 +717,96 @@ const parseGitErrorText = (error) => { .trim(); }; +const parseAheadBehindCounts = (value) => { + const [aheadRaw, behindRaw] = String(value || '').trim().split(/\s+/); + const ahead = parseInt(aheadRaw, 10); + const behind = parseInt(behindRaw, 10); + if (!Number.isFinite(ahead) || !Number.isFinite(behind)) { + return null; + } + return { ahead, behind }; +}; + +const getRemoteExistenceCacheKey = (directory, remoteName) => { + const normalizedDirectory = normalizeDirectoryPath(directory) || ''; + return `${path.resolve(normalizedDirectory)}\0${remoteName}`; +}; + +const hasRemote = async (git, directory, remoteName) => { + const remote = String(remoteName || '').trim(); + if (!remote) { + return false; + } + + const key = getRemoteExistenceCacheKey(directory, remote); + const cached = remoteExistenceCache.get(key); + if (cached && Date.now() - cached.checkedAt < REMOTE_EXISTENCE_CACHE_TTL_MS) { + return cached.exists; + } + + const exists = await git + .raw(['remote', 'get-url', remote]) + .then((value) => String(value || '').trim().length > 0) + .catch(() => false); + + remoteExistenceCache.set(key, { exists, checkedAt: Date.now() }); + return exists; +}; + +const buildRawGitOptions = (raw) => { + if (Array.isArray(raw)) { + return raw.map((value) => String(value || '').trim()).filter(Boolean); + } + + if (!raw || typeof raw !== 'object') { + return []; + } + + return Object.entries(raw).flatMap(([key, value]) => { + const option = String(key || '').trim(); + if (!option || value === false) { + return []; + } + if (value === true || value == null) { + return [option]; + } + return [option, String(value)]; + }); +}; + +const getRemoteBranchComparison = async (git, remoteName, branchName) => { + const remote = String(remoteName || '').trim(); + const branch = String(branchName || '').trim(); + if (!remote || !branch) { + return null; + } + + const remoteRef = `refs/remotes/${remote}/${branch}`; + const exists = await git + .raw(['rev-parse', '--verify', remoteRef]) + .then((value) => String(value || '').trim()) + .catch(() => ''); + if (!exists) { + return null; + } + + const countsRaw = await git + .raw(['rev-list', '--left-right', '--count', `HEAD...${remoteRef}`]) + .then((value) => String(value || '').trim()) + .catch(() => ''); + const counts = parseAheadBehindCounts(countsRaw); + if (!counts) { + return null; + } + + return { + remote, + branch, + ahead: counts.ahead, + behind: counts.behind, + }; +}; + const isNotGitRepositoryError = (error) => { const text = parseGitErrorText(error); return /not a git repository/i.test(text); @@ -1342,7 +1472,7 @@ export async function getStatus(directory, options = {}) { const lightMode = options.mode === 'light'; try { - const { repoRoot, git } = await createRepositoryGitContext(directory); + const { directoryPath, repoRoot, git } = await createRepositoryGitContext(directory); // Use -uall to show all untracked files individually, not just directories const status = await git.status(['-uall']); @@ -1495,6 +1625,7 @@ export async function getStatus(directory, options = {}) { let tracking = status.tracking || null; let ahead = status.ahead; let behind = status.behind; + let upstreamComparison; // When no upstream is configured (common for new worktree branches), Git doesn't report ahead/behind. // We still want to show the number of unpublished commits to the user. @@ -1514,6 +1645,15 @@ export async function getStatus(directory, options = {}) { } } + if ( + !lightMode + && status.current + && (!tracking || !tracking.startsWith('upstream/')) + && await hasRemote(git, directoryPath, 'upstream') + ) { + upstreamComparison = await getRemoteBranchComparison(git, 'upstream', status.current); + } + // Check for in-progress operations let mergeInProgress = null; let rebaseInProgress = null; @@ -1574,6 +1714,7 @@ export async function getStatus(directory, options = {}) { tracking, ahead, behind, + upstreamComparison, files: status.files.map((f) => ({ path: f.path, index: f.index, @@ -1984,9 +2125,20 @@ export async function pull(directory, options = {}) { : options.options || {}; try { + const remote = String(options.remote || '').trim(); + const requestedBranch = String(options.branch || '').trim(); + let branch = requestedBranch; + + if (remote && !branch) { + // simple-git only includes the remote when both remote and branch are provided. + // Resolve the current branch so selecting a remote in the UI really runs `git pull `. + const status = await git.status(); + branch = String(status.current || '').trim(); + } + const result = await git.pull( - options.remote || 'origin', - options.branch, + remote || 'origin', + branch || undefined, pullOptions ); @@ -2240,11 +2392,20 @@ export async function fetch(directory, options = {}) { const { git } = await createRepositoryGitContext(directory); try { - await git.fetch( - options.remote || 'origin', - options.branch, - options.options || {} - ); + const remote = String(options.remote || '').trim(); + const branch = String(options.branch || '').trim(); + const fetchOptions = options.options || {}; + + if (remote && !branch) { + // simple-git drops the remote when branch is omitted, so use raw to preserve `git fetch `. + await git.raw(['fetch', ...buildRawGitOptions(fetchOptions), remote]); + } else { + await git.fetch( + remote || 'origin', + branch || undefined, + fetchOptions + ); + } return { success: true }; } catch (error) { diff --git a/packages/web/server/lib/git/service.test.js b/packages/web/server/lib/git/service.test.js index df61bba5..9f115dc6 100644 --- a/packages/web/server/lib/git/service.test.js +++ b/packages/web/server/lib/git/service.test.js @@ -1,6 +1,39 @@ -import { describe, expect, it } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; -import { resolveBaseRefForLog, stageFiles, unstageFiles } from './service.js'; +import { getStatus, resolveBaseRefForLog, stageFiles, unstageFiles } from './service.js'; + +const tempDirs = []; + +const createTempDir = () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-git-service-')); + tempDirs.push(dir); + return dir; +}; + +const runGit = (cwd, args) => execFileSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], +}); + +const canRunGit = () => { + try { + execFileSync('git', ['--version'], { stdio: 'ignore' }); + return true; + } catch { + return false; + } +}; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); describe('resolveBaseRefForLog', () => { it('returns the local ref unchanged when it exists, even if origin also exists', async () => { @@ -47,3 +80,23 @@ describe('git index path validation', () => { await expect(unstageFiles('/repo', ['../secret.txt'])).rejects.toThrow('Path is outside repository: ../secret.txt'); }); }); + +describe('getStatus', () => { + it('handles repositories without upstream tracking', async () => { + if (!canRunGit()) { + return; + } + + const repo = createTempDir(); + runGit(repo, ['init', '-b', 'main']); + runGit(repo, ['config', 'user.email', 'test@example.com']); + runGit(repo, ['config', 'user.name', 'Test User']); + fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n'); + runGit(repo, ['add', 'README.md']); + runGit(repo, ['commit', '-m', 'Initial commit']); + + await expect(getStatus(repo)).resolves.toMatchObject({ + current: 'main', + }); + }); +}); diff --git a/packages/web/server/lib/opencode/env-runtime.js b/packages/web/server/lib/opencode/env-runtime.js index 265166da..736f9292 100644 --- a/packages/web/server/lib/opencode/env-runtime.js +++ b/packages/web/server/lib/opencode/env-runtime.js @@ -51,6 +51,30 @@ export const createOpenCodeEnvRuntime = (deps) => { } }; + const resolveWindowsExecutablePath = (candidate) => { + if (process.platform !== 'win32' || typeof candidate !== 'string' || candidate.trim().length === 0) { + return candidate; + } + + const trimmed = candidate.trim(); + const ext = path.extname(trimmed).toLowerCase(); + if (ext) { + return isExecutable(trimmed) ? trimmed : null; + } + + const pathExt = process.env.PATHEXT || process.env.PathExt || '.COM;.EXE;.BAT;.CMD'; + for (const rawExt of pathExt.split(';')) { + const normalizedExt = rawExt.trim(); + if (!normalizedExt) continue; + const withExt = `${trimmed}${normalizedExt.startsWith('.') ? normalizedExt : `.${normalizedExt}`}`; + if (isExecutable(withExt)) { + return withExt; + } + } + + return isExecutable(trimmed) ? trimmed : null; + }; + const searchPathFor = (binaryName) => { const trimmed = typeof binaryName === 'string' ? binaryName.trim() : ''; if (!trimmed) { @@ -59,7 +83,7 @@ export const createOpenCodeEnvRuntime = (deps) => { const current = process.env.PATH || ''; const parts = current.split(path.delimiter).filter(Boolean); - const candidateNames = [trimmed]; + const candidateNames = []; if (process.platform === 'win32' && !path.extname(trimmed)) { const pathExt = process.env.PATHEXT || process.env.PathExt || '.COM;.EXE;.BAT;.CMD'; @@ -73,6 +97,8 @@ export const createOpenCodeEnvRuntime = (deps) => { } } + candidateNames.push(trimmed); + for (const dir of parts) { for (const candidateName of candidateNames) { const candidate = path.join(dir, candidateName); @@ -649,6 +675,9 @@ export const createOpenCodeEnvRuntime = (deps) => { if (!trimmed) { return null; } + if (process.platform === 'win32') { + return resolveWindowsExecutablePath(trimmed); + } return isExecutable(trimmed) ? trimmed : null; }; @@ -669,10 +698,20 @@ export const createOpenCodeEnvRuntime = (deps) => { return null; } + const packageShim = path.join(nodeModulesDir, 'opencode-ai', 'bin', 'opencode.exe'); + if (isExecutable(packageShim)) { + return packageShim; + } + for (const packageName of getWindowsNativeOpencodePackageNames()) { - const candidate = path.join(nodeModulesDir, packageName, 'bin', 'opencode.exe'); - if (isExecutable(candidate)) { - return candidate; + const candidates = [ + path.join(nodeModulesDir, packageName, 'bin', 'opencode.exe'), + path.join(nodeModulesDir, 'opencode-ai', 'node_modules', packageName, 'bin', 'opencode.exe'), + ]; + for (const candidate of candidates) { + if (isExecutable(candidate)) { + return candidate; + } } } @@ -816,6 +855,15 @@ export const createOpenCodeEnvRuntime = (deps) => { const directBinary = normalizeExecutableCandidate(candidate); if (directBinary) { + const directExt = path.extname(directBinary).toLowerCase(); + if (WINDOWS_BATCH_EXTENSIONS.has(directExt)) { + return { + binary: process.env.ComSpec || 'cmd.exe', + args: ['/d', '/s', '/c', 'call', directBinary], + wrapperType: 'cmd-wrapper', + }; + } + return { binary: directBinary, args: [], diff --git a/packages/web/server/lib/opencode/env-runtime.test.js b/packages/web/server/lib/opencode/env-runtime.test.js index 274b0aec..521e2411 100644 --- a/packages/web/server/lib/opencode/env-runtime.test.js +++ b/packages/web/server/lib/opencode/env-runtime.test.js @@ -5,6 +5,11 @@ import { afterEach, describe, expect, it } from 'vitest'; import { createOpenCodeEnvRuntime } from './env-runtime.js'; const originalOpencodeBinary = process.env.OPENCODE_BINARY; +const originalComSpec = process.env.ComSpec; +const originalPath = process.env.PATH; +const originalSystemRoot = process.env.SystemRoot; +const originalWslBinary = process.env.WSL_BINARY; +const originalOpenChamberWslBinary = process.env.OPENCHAMBER_WSL_BINARY; const originalPlatform = process.platform; const tempDirs = []; const itIf = (condition) => condition ? it : it.skip; @@ -32,9 +37,39 @@ afterEach(() => { if (typeof originalOpencodeBinary === 'string') { process.env.OPENCODE_BINARY = originalOpencodeBinary; - return; + } else { + delete process.env.OPENCODE_BINARY; + } + + if (typeof originalComSpec === 'string') { + process.env.ComSpec = originalComSpec; + } else { + delete process.env.ComSpec; + } + + if (typeof originalPath === 'string') { + process.env.PATH = originalPath; + } else { + delete process.env.PATH; + } + + if (typeof originalSystemRoot === 'string') { + process.env.SystemRoot = originalSystemRoot; + } else { + delete process.env.SystemRoot; + } + + if (typeof originalWslBinary === 'string') { + process.env.WSL_BINARY = originalWslBinary; + } else { + delete process.env.WSL_BINARY; + } + + if (typeof originalOpenChamberWslBinary === 'string') { + process.env.OPENCHAMBER_WSL_BINARY = originalOpenChamberWslBinary; + } else { + delete process.env.OPENCHAMBER_WSL_BINARY; } - delete process.env.OPENCODE_BINARY; }); const createRuntime = (settings) => { @@ -103,14 +138,55 @@ describe('OpenCode env runtime', () => { }); }); - it('does not classify failed WSL resolution as an invalid configured binary in strict mode', async () => { + it('does not classify WSL settings as a native invalid configured binary in strict mode', async () => { setPlatform('win32'); + const dir = createTempDir('openchamber-no-wsl-'); + process.env.PATH = dir; + process.env.SystemRoot = dir; + process.env.WSL_BINARY = path.join(dir, 'missing-wsl.exe'); + process.env.OPENCHAMBER_WSL_BINARY = path.join(dir, 'missing-openchamber-wsl.exe'); const { runtime } = createRuntime({ opencodeBinary: 'wsl:/usr/local/bin/opencode' }); const rejection = runtime.applyOpencodeBinaryFromSettings({ strict: true }); - await expect(rejection).rejects.toThrow('uses WSL'); - const error = await rejection.catch((caught) => caught); - expect(error.code).toBeUndefined(); + try { + await rejection; + expect(runtime.resolveManagedOpenCodeLaunchSpec('opencode').wrapperType).not.toBe('cmd-wrapper'); + } catch (error) { + expect(error.message).toContain('uses WSL'); + expect(error.code).toBeUndefined(); + } + }); + + it('launches Windows cmd shims through cmd call without embedded quotes', () => { + setPlatform('win32'); + process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe'; + const dir = createTempDir('openchamber-opencode-cmd-'); + const shim = path.join(dir, 'opencode.cmd'); + fs.writeFileSync(shim, '@echo off\r\nexit /b 0\r\n'); + const { runtime } = createRuntime({}); + + expect(runtime.resolveManagedOpenCodeLaunchSpec(shim)).toEqual({ + binary: 'C:\\Windows\\System32\\cmd.exe', + args: ['/d', '/s', '/c', 'call', shim], + wrapperType: 'cmd-wrapper', + }); + }); + + it('resolves npm OpenCode cmd shims to the packaged Windows executable', () => { + setPlatform('win32'); + const npmDir = createTempDir('openchamber-opencode-npm-'); + const shim = path.join(npmDir, 'opencode.cmd'); + const nativeBinary = path.join(npmDir, 'node_modules', 'opencode-ai', 'bin', 'opencode.exe'); + fs.mkdirSync(path.dirname(nativeBinary), { recursive: true }); + fs.writeFileSync(nativeBinary, ''); + fs.writeFileSync(shim, '@ECHO off\r\n"%dp0%\\node_modules\\opencode-ai\\bin\\opencode.exe" %*\r\n'); + const { runtime } = createRuntime({}); + + expect(runtime.resolveManagedOpenCodeLaunchSpec(shim)).toEqual({ + binary: nativeBinary, + args: [], + wrapperType: 'native-wrapper', + }); }); }); diff --git a/packages/web/server/lib/opencode/settings-runtime.js b/packages/web/server/lib/opencode/settings-runtime.js index 7d4379ed..638d4aea 100644 --- a/packages/web/server/lib/opencode/settings-runtime.js +++ b/packages/web/server/lib/opencode/settings-runtime.js @@ -438,6 +438,44 @@ export const createSettingsRuntime = (deps) => { } }; + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + + const isTransientWindowsReplaceError = (error) => { + if (process.platform !== 'win32' || !error || typeof error !== 'object') { + return false; + } + return error.code === 'EPERM' || error.code === 'EACCES' || error.code === 'EBUSY'; + }; + + const replaceFile = async (tmp, target) => { + const maxAttempts = process.platform === 'win32' ? 6 : 1; + let lastError = null; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + await fsPromises.rename(tmp, target); + return; + } catch (error) { + lastError = error; + if (!isTransientWindowsReplaceError(error) || attempt === maxAttempts) { + break; + } + await sleep(25 * attempt); + } + } + + if (!isTransientWindowsReplaceError(lastError)) { + throw lastError; + } + + // Windows can transiently reject atomic replacement when another process + // briefly opens the target file. Preserve atomic rename everywhere it works, + // but fall back to a direct replacement so settings persistence does not + // get permanently wedged on Windows desktop installs. + await fsPromises.copyFile(tmp, target); + await fsPromises.rm(tmp, { force: true }); + }; + const writeSettingsToDisk = async (settings) => { try { await fsPromises.mkdir(path.dirname(SETTINGS_FILE_PATH), { recursive: true }); @@ -447,7 +485,7 @@ export const createSettingsRuntime = (deps) => { // read-modify-write wipe the settings file. const tmp = `${SETTINGS_FILE_PATH}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; await fsPromises.writeFile(tmp, JSON.stringify(settings, null, 2), 'utf8'); - await fsPromises.rename(tmp, SETTINGS_FILE_PATH); + await replaceFile(tmp, SETTINGS_FILE_PATH); } catch (error) { console.warn('Failed to write settings file:', error); throw error; diff --git a/packages/web/server/lib/opencode/settings-runtime.test.js b/packages/web/server/lib/opencode/settings-runtime.test.js index 185e3cf9..1994fe3c 100644 --- a/packages/web/server/lib/opencode/settings-runtime.test.js +++ b/packages/web/server/lib/opencode/settings-runtime.test.js @@ -82,4 +82,43 @@ describe('settings runtime', () => { await cleanup(); } }); + + it.skipIf(process.platform !== 'win32')('falls back when Windows blocks atomic settings replacement', async () => { + const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-settings-runtime-')); + const settingsFilePath = path.join(tempRoot, 'settings.json'); + const wrappedFs = { + ...fsPromises, + rename: async () => { + const error = new Error('operation not permitted'); + error.code = 'EPERM'; + throw error; + }, + }; + const runtime = createSettingsRuntime({ + fsPromises: wrappedFs, + path, + crypto, + SETTINGS_FILE_PATH: settingsFilePath, + sanitizeProjects: (projects) => Array.isArray(projects) ? projects : [], + sanitizeSettingsUpdate: (settings) => settings, + mergePersistedSettings: (_current, changes) => changes, + normalizeSettingsPaths: (settings) => ({ settings, changed: false }), + normalizeStringArray: (values) => Array.isArray(values) ? values.filter((value) => typeof value === 'string') : [], + formatSettingsResponse: (settings) => settings, + resolveDirectoryCandidate: (value) => value, + normalizeManagedRemoteTunnelHostname: (value) => value, + normalizeManagedRemoteTunnelPresets: (value) => value, + normalizeManagedRemoteTunnelPresetTokens: (value) => value, + syncManagedRemoteTunnelConfigWithPresets: async () => {}, + upsertManagedRemoteTunnelToken: async () => {}, + }); + + try { + await runtime.writeSettingsToDisk({ theme: 'dark' }); + + await expect(fsPromises.readFile(settingsFilePath, 'utf8')).resolves.toBe(JSON.stringify({ theme: 'dark' }, null, 2)); + } finally { + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + } + }); }); diff --git a/packages/web/server/lib/skills-catalog/source.js b/packages/web/server/lib/skills-catalog/source.js index 47e6f540..5af2a100 100644 --- a/packages/web/server/lib/skills-catalog/source.js +++ b/packages/web/server/lib/skills-catalog/source.js @@ -17,7 +17,7 @@ export function parseSkillRepoSource(input, options = {}) { return { ok: false, error: { kind: 'invalidSource', message: 'Repository source is required' } }; } const explicitSubpath = typeof options.subpath === 'string' && options.subpath.trim() ? options.subpath.trim() : null; - + const urlFormat = raw.startsWith('https://') ? 'https' : raw.startsWith('git@') ? 'ssh' : 'shorthand'; const gitHost = urlFormat === 'https' ? raw.split('/')[2] : urlFormat === 'ssh' ? raw.split('@')[1].split(':')[0] : null; diff --git a/packages/web/src/api/files.ts b/packages/web/src/api/files.ts index 18771a63..36b6588d 100644 --- a/packages/web/src/api/files.ts +++ b/packages/web/src/api/files.ts @@ -40,12 +40,15 @@ const toDirectoryListResult = (fallbackDirectory: string, payload: WebDirectoryL }; export const createWebFilesAPI = (): FilesAPI => ({ - async listDirectory(path: string): Promise { + async listDirectory(path: string, options): Promise { const target = normalizePath(path); const params = new URLSearchParams(); if (target) { params.set('path', target); } + if (options?.respectGitignore) { + params.set('respectGitignore', 'true'); + } const response = await fetch(`/api/fs/list${params.toString() ? `?${params.toString()}` : ''}`); diff --git a/scripts/dev-web-hmr.mjs b/scripts/dev-web-hmr.mjs index 47cb8855..a5a3f302 100644 --- a/scripts/dev-web-hmr.mjs +++ b/scripts/dev-web-hmr.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { spawn } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import { existsSync, rmSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -11,12 +11,36 @@ const repoRoot = path.resolve(__dirname, '..'); const useDetachedChildren = process.platform === 'darwin'; const webRoot = path.join(repoRoot, 'packages/web'); +const quoteWindowsCommandArg = (value) => `"${String(value).replace(/"/g, '""')}"`; + +function resolveWindowsCommand(command) { + if (process.platform !== 'win32' || path.isAbsolute(command)) { + return command; + } + + const result = spawnSync('where.exe', [command], { encoding: 'utf8', windowsHide: true }); + if (result.error || result.status !== 0) { + return command; + } + + const candidates = String(result.stdout || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + return candidates.find((entry) => /\.(exe|cmd|bat)$/i.test(entry)) || candidates[0] || command; +} + function run(label, command, args, env = {}, options = {}) { - return spawn(command, args, { + const resolvedCommand = resolveWindowsCommand(command); + const isWindowsCommandScript = process.platform === 'win32' && /\.(cmd|bat)$/i.test(resolvedCommand); + const spawnCommand = isWindowsCommandScript ? (process.env.ComSpec || 'cmd.exe') : resolvedCommand; + const spawnArgs = isWindowsCommandScript + ? ['/d', '/s', '/c', ['call', quoteWindowsCommandArg(resolvedCommand), ...args.map(quoteWindowsCommandArg)].join(' ')] + : args; + + return spawn(spawnCommand, spawnArgs, { cwd: options.cwd || repoRoot, stdio: 'inherit', env: { ...process.env, ...env }, detached: useDetachedChildren, + windowsVerbatimArguments: isWindowsCommandScript, }).on('error', (error) => { console.error(`[dev:web:hmr] Failed to start ${label}:`, error); }); @@ -43,6 +67,17 @@ function waitForExit(child, timeoutMs) { }); } +function killWindowsProcessTree(pid) { + if (!pid) return; + try { + spawnSync('taskkill.exe', ['/PID', String(pid), '/T', '/F'], { + stdio: 'ignore', + windowsHide: true, + }); + } catch { + } +} + function signalChild(child, signal) { if (!child || child.exitCode !== null || child.signalCode !== null) { return; @@ -70,6 +105,11 @@ async function stopChildTree(child) { signalChild(child, 'SIGINT'); await waitForExit(child, 2500); + if (process.platform === 'win32' && child.exitCode === null && child.signalCode === null) { + killWindowsProcessTree(child.pid); + await waitForExit(child, 1000); + } + if (child.exitCode === null && child.signalCode === null) { signalChild(child, 'SIGTERM'); await waitForExit(child, 2500); @@ -112,9 +152,15 @@ function clearViteCache() { clearViteCache(); -const api = run('api', 'bun', ['run', '--cwd', 'packages/web', 'dev:server:watch'], { - OPENCHAMBER_PORT: backendPort, -}); +const api = run( + 'api', + 'bun', + ['x', 'nodemon', '--watch', 'server', '--ext', 'js', '--exec', `bun server/index.js --port ${backendPort}`], + { + OPENCHAMBER_PORT: backendPort, + }, + { cwd: webRoot }, +); const vite = run( 'vite', 'bun',