mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-30 17:12:58 +08:00
Compare commits
7 Commits
fix/voice-
...
cloudcli-l
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6761f31a56 | ||
|
|
35da5d090d | ||
|
|
d882f80b6d | ||
|
|
053f244d14 | ||
|
|
97c9b67bfc | ||
|
|
ed4ae3114a | ||
|
|
591e8e7642 |
109
.github/workflows/desktop-macos-branch-build.yml
vendored
Normal file
109
.github/workflows/desktop-macos-branch-build.yml
vendored
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
name: Desktop macOS Branch Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- electron-app
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-macos:
|
||||||
|
name: Build macOS desktop artifact
|
||||||
|
runs-on: macos-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: npm run typecheck
|
||||||
|
|
||||||
|
- name: Resolve artifact metadata
|
||||||
|
id: artifact
|
||||||
|
run: |
|
||||||
|
SAFE_REF="$(printf '%s' "${GITHUB_REF_NAME}" | tr -c 'A-Za-z0-9._-' '-')"
|
||||||
|
echo "name=CloudCLI-macOS-${SAFE_REF}-${GITHUB_RUN_NUMBER}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "server_bundle_tag=cloudcli-local-server-${SAFE_REF}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Configure branch server bundle source
|
||||||
|
run: printf '{"releaseTag":"%s"}\n' "${{ steps.artifact.outputs.server_bundle_tag }}" > electron/server-bundle-config.json
|
||||||
|
|
||||||
|
- name: Verify signing secrets are configured
|
||||||
|
run: |
|
||||||
|
test -n "$CSC_LINK"
|
||||||
|
test -n "$CSC_KEY_PASSWORD"
|
||||||
|
test -n "$APPLE_ID"
|
||||||
|
test -n "$APPLE_APP_SPECIFIC_PASSWORD"
|
||||||
|
test -n "$APPLE_TEAM_ID"
|
||||||
|
env:
|
||||||
|
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||||
|
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||||
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
|
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||||
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
|
||||||
|
- name: Build signed and notarized macOS artifacts
|
||||||
|
run: npm run desktop:dist:mac -- --publish never
|
||||||
|
env:
|
||||||
|
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
|
||||||
|
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||||
|
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||||
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
|
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||||
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
|
||||||
|
- name: Build branch server bundle
|
||||||
|
run: node scripts/release/build-server-bundle.js
|
||||||
|
|
||||||
|
- name: Verify branch server runtime artifacts
|
||||||
|
run: |
|
||||||
|
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz' -print -quit)"
|
||||||
|
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz.sha256' -print -quit)"
|
||||||
|
|
||||||
|
- name: Publish branch server bundle
|
||||||
|
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||||
|
with:
|
||||||
|
tag_name: ${{ steps.artifact.outputs.server_bundle_tag }}
|
||||||
|
name: CloudCLI Desktop Local Runtime (${{ github.ref_name }})
|
||||||
|
body: |
|
||||||
|
This prerelease is used by CloudCLI Desktop branch builds to run Local mode.
|
||||||
|
|
||||||
|
To test this branch, download the desktop app from this workflow run's artifacts. When you open Local CloudCLI, the desktop app automatically downloads the matching runtime from this prerelease.
|
||||||
|
|
||||||
|
You do not need to download these runtime files manually.
|
||||||
|
prerelease: true
|
||||||
|
fail_on_unmatched_files: false
|
||||||
|
overwrite_files: true
|
||||||
|
files: |
|
||||||
|
release/local-server/*
|
||||||
|
|
||||||
|
- name: Verify macOS artifacts
|
||||||
|
run: |
|
||||||
|
test -n "$(find release/desktop -maxdepth 1 -name '*.dmg' -print -quit)"
|
||||||
|
shasum -a 256 release/desktop/*.dmg > release/SHASUMS256.txt
|
||||||
|
cat release/SHASUMS256.txt
|
||||||
|
|
||||||
|
- name: Upload branch build artifacts
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: ${{ steps.artifact.outputs.name }}
|
||||||
|
path: |
|
||||||
|
release/desktop/*.dmg
|
||||||
|
release/SHASUMS256.txt
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
305
.github/workflows/desktop-release.yml
vendored
Normal file
305
.github/workflows/desktop-release.yml
vendored
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
name: Desktop Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: "Release tag to create or update (defaults to v<package version>)"
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
release_name:
|
||||||
|
description: 'Release name (defaults to "CloudCLI Desktop <tag>")'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
prerelease:
|
||||||
|
description: "Mark the GitHub release as a prerelease"
|
||||||
|
required: true
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
resolve-release:
|
||||||
|
name: Resolve release metadata
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
outputs:
|
||||||
|
tag: ${{ steps.release.outputs.tag }}
|
||||||
|
release_name: ${{ steps.release.outputs.release_name }}
|
||||||
|
server_bundle_tag: ${{ steps.release.outputs.server_bundle_tag }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Resolve release metadata
|
||||||
|
id: release
|
||||||
|
env:
|
||||||
|
TAG_INPUT: ${{ inputs.tag }}
|
||||||
|
RELEASE_NAME_INPUT: ${{ inputs.release_name }}
|
||||||
|
run: |
|
||||||
|
VERSION="$(node -p "require('./package.json').version")"
|
||||||
|
TAG="$TAG_INPUT"
|
||||||
|
if [ -z "$TAG" ]; then
|
||||||
|
TAG="v${VERSION}"
|
||||||
|
fi
|
||||||
|
TAG="$(printf '%s' "$TAG" | tr -d '\r\n' | sed 's/[^A-Za-z0-9._-]/-/g')"
|
||||||
|
if [ -z "$TAG" ]; then
|
||||||
|
echo "Resolved release tag is empty after normalization." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
RELEASE_NAME="$RELEASE_NAME_INPUT"
|
||||||
|
if [ -z "$RELEASE_NAME" ]; then
|
||||||
|
RELEASE_NAME="CloudCLI Desktop ${TAG}"
|
||||||
|
fi
|
||||||
|
RELEASE_NAME_DELIMITER="release_name_${GITHUB_RUN_ID}_${GITHUB_RUN_ATTEMPT}"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "tag=$TAG"
|
||||||
|
echo "release_name<<$RELEASE_NAME_DELIMITER"
|
||||||
|
printf '%s\n' "$RELEASE_NAME"
|
||||||
|
echo "$RELEASE_NAME_DELIMITER"
|
||||||
|
echo "server_bundle_tag=cloudcli-local-server-${TAG}"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
build-macos:
|
||||||
|
name: Build signed macOS desktop app
|
||||||
|
needs: resolve-release
|
||||||
|
runs-on: macos-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: npm run typecheck
|
||||||
|
|
||||||
|
- name: Configure release server bundle source
|
||||||
|
env:
|
||||||
|
SERVER_BUNDLE_TAG: ${{ needs.resolve-release.outputs.server_bundle_tag }}
|
||||||
|
run: printf '{"releaseTag":"%s"}\n' "$SERVER_BUNDLE_TAG" > electron/server-bundle-config.json
|
||||||
|
|
||||||
|
- name: Verify macOS signing secrets are configured
|
||||||
|
run: |
|
||||||
|
test -n "$CSC_LINK"
|
||||||
|
test -n "$CSC_KEY_PASSWORD"
|
||||||
|
test -n "$APPLE_ID"
|
||||||
|
test -n "$APPLE_APP_SPECIFIC_PASSWORD"
|
||||||
|
test -n "$APPLE_TEAM_ID"
|
||||||
|
env:
|
||||||
|
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||||
|
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||||
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
|
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||||
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
|
||||||
|
- name: Build signed and notarized macOS artifacts
|
||||||
|
run: npm run desktop:dist:mac -- --publish never
|
||||||
|
env:
|
||||||
|
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
|
||||||
|
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||||
|
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||||
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
|
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||||
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
|
||||||
|
- name: Build macOS local server bundle
|
||||||
|
run: node scripts/release/build-server-bundle.js
|
||||||
|
|
||||||
|
- name: Stage macOS release assets
|
||||||
|
run: |
|
||||||
|
mkdir -p desktop-release-assets server-release-assets
|
||||||
|
test -n "$(find release/desktop -maxdepth 1 -name '*.dmg' -print -quit)"
|
||||||
|
shasum -a 256 release/desktop/*.dmg > desktop-release-assets/SHASUMS256-macos.txt
|
||||||
|
cp release/desktop/*.dmg desktop-release-assets/
|
||||||
|
|
||||||
|
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz' -print -quit)"
|
||||||
|
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz.sha256' -print -quit)"
|
||||||
|
cp release/local-server/* server-release-assets/
|
||||||
|
|
||||||
|
- name: Upload macOS desktop assets
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: desktop-release-macos
|
||||||
|
path: desktop-release-assets/*
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Upload macOS server assets
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: server-release-macos
|
||||||
|
path: server-release-assets/*
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
build-windows:
|
||||||
|
name: Build Windows desktop app
|
||||||
|
needs: resolve-release
|
||||||
|
runs-on: windows-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: npm run typecheck
|
||||||
|
|
||||||
|
- name: Configure release server bundle source
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
SERVER_BUNDLE_TAG: ${{ needs.resolve-release.outputs.server_bundle_tag }}
|
||||||
|
run: printf '{"releaseTag":"%s"}\n' "$SERVER_BUNDLE_TAG" > electron/server-bundle-config.json
|
||||||
|
|
||||||
|
- name: Check Windows signing secrets
|
||||||
|
id: windows-signing
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
WINDOWS_CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK }}
|
||||||
|
WINDOWS_CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
if [ -n "$WINDOWS_CSC_LINK" ] && [ -n "$WINDOWS_CSC_KEY_PASSWORD" ]; then
|
||||||
|
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build signed Windows artifacts
|
||||||
|
if: steps.windows-signing.outputs.enabled == 'true'
|
||||||
|
run: npm run desktop:dist:win -- --publish never
|
||||||
|
env:
|
||||||
|
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
|
||||||
|
CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK }}
|
||||||
|
CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Build unsigned Windows artifacts
|
||||||
|
if: steps.windows-signing.outputs.enabled != 'true'
|
||||||
|
run: npm run desktop:dist:win -- --publish never
|
||||||
|
env:
|
||||||
|
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
|
||||||
|
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||||
|
|
||||||
|
- name: Build Windows local server bundle
|
||||||
|
run: node scripts/release/build-server-bundle.js
|
||||||
|
|
||||||
|
- name: Stage Windows release assets
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p desktop-release-assets server-release-assets
|
||||||
|
test -n "$(find release/desktop -maxdepth 1 -name '*.exe' -print -quit)"
|
||||||
|
sha256sum release/desktop/*.exe > desktop-release-assets/SHASUMS256-windows.txt
|
||||||
|
cp release/desktop/*.exe desktop-release-assets/
|
||||||
|
|
||||||
|
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz' -print -quit)"
|
||||||
|
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz.sha256' -print -quit)"
|
||||||
|
cp release/local-server/* server-release-assets/
|
||||||
|
|
||||||
|
- name: Upload Windows desktop assets
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: desktop-release-windows
|
||||||
|
path: desktop-release-assets/*
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Upload Windows server assets
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: server-release-windows
|
||||||
|
path: server-release-assets/*
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
publish:
|
||||||
|
name: Publish desktop release
|
||||||
|
needs:
|
||||||
|
- resolve-release
|
||||||
|
- build-macos
|
||||||
|
- build-windows
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- name: Download desktop assets
|
||||||
|
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
|
||||||
|
with:
|
||||||
|
pattern: desktop-release-*
|
||||||
|
path: release/desktop
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Download server assets
|
||||||
|
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
|
||||||
|
with:
|
||||||
|
pattern: server-release-*
|
||||||
|
path: release/local-server
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Verify release assets
|
||||||
|
run: |
|
||||||
|
test -n "$(find release/desktop -maxdepth 1 -name '*.dmg' -print -quit)"
|
||||||
|
test -n "$(find release/desktop -maxdepth 1 -name '*.exe' -print -quit)"
|
||||||
|
test -f release/desktop/SHASUMS256-macos.txt
|
||||||
|
test -f release/desktop/SHASUMS256-windows.txt
|
||||||
|
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz' -print -quit)"
|
||||||
|
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz.sha256' -print -quit)"
|
||||||
|
find release -maxdepth 2 -type f -print | sort
|
||||||
|
|
||||||
|
- name: Publish local server runtime assets
|
||||||
|
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||||
|
with:
|
||||||
|
tag_name: ${{ needs.resolve-release.outputs.server_bundle_tag }}
|
||||||
|
target_commitish: ${{ github.sha }}
|
||||||
|
name: CloudCLI Local Server Runtime (${{ needs.resolve-release.outputs.tag }})
|
||||||
|
body: |
|
||||||
|
This prerelease contains the Local mode runtime for CloudCLI Desktop.
|
||||||
|
|
||||||
|
Download CloudCLI Desktop from the main ${{ needs.resolve-release.outputs.tag }} release. When you open Local CloudCLI, the desktop app automatically downloads the matching runtime from this prerelease.
|
||||||
|
|
||||||
|
You do not need to download these runtime files manually.
|
||||||
|
prerelease: true
|
||||||
|
fail_on_unmatched_files: false
|
||||||
|
overwrite_files: true
|
||||||
|
files: |
|
||||||
|
release/local-server/*
|
||||||
|
|
||||||
|
- name: Publish GitHub release assets
|
||||||
|
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||||
|
with:
|
||||||
|
tag_name: ${{ needs.resolve-release.outputs.tag }}
|
||||||
|
target_commitish: ${{ github.sha }}
|
||||||
|
name: ${{ needs.resolve-release.outputs.release_name }}
|
||||||
|
body: |
|
||||||
|
Download the CloudCLI Desktop installer for your platform.
|
||||||
|
|
||||||
|
The local server runtime used by local mode is installed automatically by the desktop app. You do not need to download any server bundle manually.
|
||||||
|
prerelease: ${{ inputs.prerelease }}
|
||||||
|
fail_on_unmatched_files: false
|
||||||
|
overwrite_files: true
|
||||||
|
files: |
|
||||||
|
release/desktop/*
|
||||||
95
.github/workflows/desktop-windows-branch-build.yml
vendored
Normal file
95
.github/workflows/desktop-windows-branch-build.yml
vendored
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
name: Desktop Windows Branch Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- electron-app
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-windows:
|
||||||
|
name: Build unsigned Windows desktop artifact
|
||||||
|
runs-on: windows-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: npm run typecheck
|
||||||
|
|
||||||
|
- name: Resolve artifact metadata
|
||||||
|
id: artifact
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
SAFE_REF="$(printf '%s' "${GITHUB_REF_NAME}" | tr -c 'A-Za-z0-9._-' '-')"
|
||||||
|
echo "name=CloudCLI-windows-${SAFE_REF}-${GITHUB_RUN_NUMBER}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "server_bundle_tag=cloudcli-local-server-${SAFE_REF}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Configure branch server bundle source
|
||||||
|
shell: bash
|
||||||
|
run: printf '{"releaseTag":"%s"}\n' "${{ steps.artifact.outputs.server_bundle_tag }}" > electron/server-bundle-config.json
|
||||||
|
|
||||||
|
- name: Build unsigned Windows artifacts
|
||||||
|
run: npm run desktop:dist:win -- --publish never
|
||||||
|
env:
|
||||||
|
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
|
||||||
|
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||||
|
|
||||||
|
- name: Build branch server bundle
|
||||||
|
run: node scripts/release/build-server-bundle.js
|
||||||
|
|
||||||
|
- name: Verify branch server runtime artifacts
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz' -print -quit)"
|
||||||
|
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz.sha256' -print -quit)"
|
||||||
|
|
||||||
|
- name: Publish branch server bundle
|
||||||
|
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||||
|
with:
|
||||||
|
tag_name: ${{ steps.artifact.outputs.server_bundle_tag }}
|
||||||
|
name: CloudCLI Desktop Local Runtime (${{ github.ref_name }})
|
||||||
|
body: |
|
||||||
|
This prerelease is used by CloudCLI Desktop branch builds to run Local mode.
|
||||||
|
|
||||||
|
To test this branch, download the desktop app from this workflow run's artifacts. When you open Local CloudCLI, the desktop app automatically downloads the matching runtime from this prerelease.
|
||||||
|
|
||||||
|
You do not need to download these runtime files manually.
|
||||||
|
prerelease: true
|
||||||
|
fail_on_unmatched_files: false
|
||||||
|
overwrite_files: true
|
||||||
|
files: |
|
||||||
|
release/local-server/*
|
||||||
|
|
||||||
|
- name: Verify Windows artifacts
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
test -n "$(find release/desktop -maxdepth 1 -name '*.exe' -print -quit)"
|
||||||
|
sha256sum release/desktop/*.exe > release/SHASUMS256.txt
|
||||||
|
cat release/SHASUMS256.txt
|
||||||
|
|
||||||
|
- name: Upload branch build artifacts
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: ${{ steps.artifact.outputs.name }}
|
||||||
|
path: |
|
||||||
|
release/desktop/*.exe
|
||||||
|
release/SHASUMS256.txt
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
106
.github/workflows/release.yml
vendored
106
.github/workflows/release.yml
vendored
@@ -4,28 +4,109 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
increment:
|
increment:
|
||||||
description: 'Version bump: patch, minor, major, or explicit (e.g. 1.27.0)'
|
description: "Version bump: patch, minor, major, or explicit (e.g. 1.27.0)"
|
||||||
required: true
|
required: true
|
||||||
default: 'patch'
|
default: "patch"
|
||||||
type: string
|
type: string
|
||||||
release_name:
|
release_name:
|
||||||
description: 'Custom release name (optional, defaults to "CloudCLI UI vX.Y.Z")'
|
description: 'Custom release name (optional, defaults to "CloudCLI UI vX.Y.Z")'
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
# This workflow publishes releases with write credentials, so actions are pinned
|
||||||
|
# to immutable commit SHAs. The trailing comments keep the original major tag
|
||||||
|
# visible for maintenance context.
|
||||||
jobs:
|
jobs:
|
||||||
|
build-macos-semantic-helper:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- runs_on: macos-15
|
||||||
|
target_dir: darwin-arm64
|
||||||
|
- runs_on: macos-15-intel
|
||||||
|
target_dir: darwin-x64
|
||||||
|
runs-on: ${{ matrix.runs_on }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
- name: Build macOS semantic helper
|
||||||
|
run: node scripts/build-computer-semantics.mjs
|
||||||
|
env:
|
||||||
|
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
|
||||||
|
- name: Verify macOS semantic helper target
|
||||||
|
run: test -x "server/modules/computer-use/semantics/bin/${{ matrix.target_dir }}/CloudCLISemantics"
|
||||||
|
- name: Stage macOS semantic helper artifact
|
||||||
|
run: |
|
||||||
|
mkdir -p "semantic-helper-artifact/${{ matrix.target_dir }}"
|
||||||
|
cp "server/modules/computer-use/semantics/bin/${{ matrix.target_dir }}/CloudCLISemantics" "semantic-helper-artifact/${{ matrix.target_dir }}/"
|
||||||
|
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: semantic-helper-${{ matrix.target_dir }}
|
||||||
|
path: semantic-helper-artifact/*
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
build-windows-semantic-helper:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- runs_on: windows-2025
|
||||||
|
target_dir: win32-x64
|
||||||
|
- runs_on: windows-11-arm
|
||||||
|
target_dir: win32-arm64
|
||||||
|
runs-on: ${{ matrix.runs_on }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
- name: Build Windows semantic helper
|
||||||
|
run: node scripts/build-computer-semantics.mjs
|
||||||
|
env:
|
||||||
|
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
|
||||||
|
- name: Verify Windows semantic helper target
|
||||||
|
shell: bash
|
||||||
|
run: test -f "server/modules/computer-use/semantics/bin/${{ matrix.target_dir }}/CloudCLISemantics.exe"
|
||||||
|
- name: Stage Windows semantic helper artifact
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p "semantic-helper-artifact/${{ matrix.target_dir }}"
|
||||||
|
cp "server/modules/computer-use/semantics/bin/${{ matrix.target_dir }}/CloudCLISemantics.exe" "semantic-helper-artifact/${{ matrix.target_dir }}/"
|
||||||
|
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: semantic-helper-${{ matrix.target_dir }}
|
||||||
|
path: semantic-helper-artifact/*
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
release:
|
release:
|
||||||
|
needs:
|
||||||
|
- build-macos-semantic-helper
|
||||||
|
- build-windows-semantic-helper
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.RELEASE_PAT }}
|
token: ${{ secrets.RELEASE_PAT }}
|
||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
registry-url: https://registry.npmjs.org
|
registry-url: https://registry.npmjs.org
|
||||||
@@ -37,6 +118,23 @@ jobs:
|
|||||||
|
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
|
||||||
|
with:
|
||||||
|
pattern: semantic-helper-*
|
||||||
|
path: server/modules/computer-use/semantics/bin
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Restore semantic helper permissions
|
||||||
|
run: find server/modules/computer-use/semantics/bin -path '*/darwin-*/CloudCLISemantics' -type f -exec chmod 755 {} +
|
||||||
|
|
||||||
|
- name: Verify bundled semantic helpers
|
||||||
|
run: |
|
||||||
|
test -x server/modules/computer-use/semantics/bin/darwin-arm64/CloudCLISemantics
|
||||||
|
test -x server/modules/computer-use/semantics/bin/darwin-x64/CloudCLISemantics
|
||||||
|
test -f server/modules/computer-use/semantics/bin/win32-x64/CloudCLISemantics.exe
|
||||||
|
test -f server/modules/computer-use/semantics/bin/win32-arm64/CloudCLISemantics.exe
|
||||||
|
find server/modules/computer-use/semantics/bin -maxdepth 2 -type f -print
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
run: |
|
run: |
|
||||||
ARGS="--ci --increment=${{ inputs.increment }}"
|
ARGS="--ci --increment=${{ inputs.increment }}"
|
||||||
|
|||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -143,3 +143,11 @@ tasks/
|
|||||||
|
|
||||||
# Git worktrees
|
# Git worktrees
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
|
||||||
|
# Local desktop packaging artifacts
|
||||||
|
/.desktop-build/
|
||||||
|
/release/
|
||||||
|
/electron/server-bundle-config.json
|
||||||
|
cloudcli-sidebar-app-source.tar.gz
|
||||||
|
cloudcli-sidebar.html
|
||||||
|
electron/*.tar.gz
|
||||||
|
|||||||
53
CHANGELOG.md
53
CHANGELOG.md
@@ -3,6 +3,59 @@
|
|||||||
All notable changes to CloudCLI UI will be documented in this file.
|
All notable changes to CloudCLI UI will be documented in this file.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.35.0](https://github.com/siteboon/claudecodeui/compare/v1.34.0...v1.35.0) (2026-06-29)
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
* add Electron desktop app ([97c9b67](https://github.com/siteboon/claudecodeui/commit/97c9b67bfc2d803560cd1559a4e79eea9731c7b5))
|
||||||
|
* **chat:** derive activity indicator from per-session state and unify provider lifecycle events ([afc717e](https://github.com/siteboon/claudecodeui/commit/afc717e69e67f53173c30d2230722236f9180d39))
|
||||||
|
* **chat:** unify session gateway with stable IDs and a single WS protocol ([f5eac2e](https://github.com/siteboon/claudecodeui/commit/f5eac2ec12c8575bf80202fafe807d9e04720105))
|
||||||
|
* **i18n:** add French (fr) locale ([#878](https://github.com/siteboon/claudecodeui/issues/878)) ([f319d2c](https://github.com/siteboon/claudecodeui/commit/f319d2cf8d61452deaf6adf345494dd3e6898284))
|
||||||
|
* play sound for pending tool requests ([#918](https://github.com/siteboon/claudecodeui/issues/918)) ([c947eaa](https://github.com/siteboon/claudecodeui/commit/c947eaaee5fbc959563efb917f4ec7c88847dd6b))
|
||||||
|
* render changelog as markdown in version upgrade modal ([6a53c31](https://github.com/siteboon/claudecodeui/commit/6a53c31e907fffa79320997c27f99660c946b4a6))
|
||||||
|
* **sidebar:** improve running session state tracking ([591b18e](https://github.com/siteboon/claudecodeui/commit/591b18e9e343fda23affe100a53911f76aaa8f57))
|
||||||
|
* **skills:** add provider skill management ([#909](https://github.com/siteboon/claudecodeui/issues/909)) ([c5fe127](https://github.com/siteboon/claudecodeui/commit/c5fe127958d830eee19d008d8634c0e7d77fe1b9))
|
||||||
|
* **version:** warn when the server was updated but not restarted ([#898](https://github.com/siteboon/claudecodeui/issues/898)) ([f6326c8](https://github.com/siteboon/claudecodeui/commit/f6326c8082dfbe8a65dcdb836d3e71c635594c26))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* changes provider logos to svg for fast load ([7bed675](https://github.com/siteboon/claudecodeui/commit/7bed675ad5fd1ecf7912d1a04afe9db5b1032823))
|
||||||
|
* **chat:** prevent chat interface crash on malformed AskUserQuestion payload ([#920](https://github.com/siteboon/claudecodeui/issues/920)) ([ed4ae31](https://github.com/siteboon/claudecodeui/commit/ed4ae3114aafc1d4ecb0b621eaf9d3b26dbca5b1))
|
||||||
|
* **chat:** prevent normalizeInlineCodeFences from breaking adjacent fenced code blocks ([#903](https://github.com/siteboon/claudecodeui/issues/903)) ([4712431](https://github.com/siteboon/claudecodeui/commit/4712431be81718dfb559ef43d7d7d5315bf4e01a))
|
||||||
|
* **chat:** sort messages appropriately ([123ae31](https://github.com/siteboon/claudecodeui/commit/123ae310207fe5969c3b313f62b9dee27e5d7489))
|
||||||
|
* **claude-sync:** skip subagent transcripts to prevent main session corruption ([#854](https://github.com/siteboon/claudecodeui/issues/854)) ([a12ca8e](https://github.com/siteboon/claudecodeui/commit/a12ca8eed373ef56cd37fbdd097845eaab34dee9))
|
||||||
|
* correct notification session id ([881e72d](https://github.com/siteboon/claudecodeui/commit/881e72d4a00ec9c1a5e1ae4799bffa900f27c1f8))
|
||||||
|
* create one unified function for frontend session processing ([677d330](https://github.com/siteboon/claudecodeui/commit/677d330981ef29a856f09e62b9f69bac0fa580d4))
|
||||||
|
* **i18n:** add missing sidebar message keys to all locales ([#896](https://github.com/siteboon/claudecodeui/issues/896)) ([7ca3556](https://github.com/siteboon/claudecodeui/commit/7ca355651f0a805965bc27af3d75def626c5fb96))
|
||||||
|
* keep running-session polling active ([39b0473](https://github.com/siteboon/claudecodeui/commit/39b0473e38201c29ff1e5388946452d2eed44527))
|
||||||
|
* normalize project session payloads ([d0adddb](https://github.com/siteboon/claudecodeui/commit/d0adddbbdafecfd5713a8ac5b95c87a8f7fc54f8))
|
||||||
|
* **opencode:** bind watcher sessions to app rows early ([5b9adbb](https://github.com/siteboon/claudecodeui/commit/5b9adbbdee8561439a27ad90744388225823427b))
|
||||||
|
* **opencode:** pass workspace dir explicitly ([416a737](https://github.com/siteboon/claudecodeui/commit/416a737d76e654d2fc649206c2b921a7db150775))
|
||||||
|
* recover pending permission requests ([56b2e14](https://github.com/siteboon/claudecodeui/commit/56b2e1405967c50301d0c773567349763edc8560))
|
||||||
|
* remove provider specific token usage calculator ([2abb456](https://github.com/siteboon/claudecodeui/commit/2abb45636b5e1109733cfa58c8ab92fd4c812165))
|
||||||
|
* resolve session provider on backend reads ([9fb2d91](https://github.com/siteboon/claudecodeui/commit/9fb2d91b26bef9579337d953a29718802c466fed))
|
||||||
|
* **sessions:** canonicalize sidebar ids and timestamps ([3bbb42c](https://github.com/siteboon/claudecodeui/commit/3bbb42c23324c3cbb5587f2bcab09b1dc23086a8))
|
||||||
|
* **shell:** prioritize user npm binaries ([#913](https://github.com/siteboon/claudecodeui/issues/913)) ([4a503b1](https://github.com/siteboon/claudecodeui/commit/4a503b1dc87ff58821670c8bfb1d8a8c1dab2bcf))
|
||||||
|
* **shell:** use correct session id ([89f0524](https://github.com/siteboon/claudecodeui/commit/89f05247eddec4fe53bd1616c6a5563e3ae2427a))
|
||||||
|
* **sidebar:** align session status controls across layouts ([1b336e9](https://github.com/siteboon/claudecodeui/commit/1b336e9aa9d2cccf0676d852815d9ba613ac04d2))
|
||||||
|
* upgrade gemini logo ([9cb2afd](https://github.com/siteboon/claudecodeui/commit/9cb2afd67eb25a4f869b88abcf86f7748b2b6d71))
|
||||||
|
* voice tts format settings ([#919](https://github.com/siteboon/claudecodeui/issues/919)) ([591e8e7](https://github.com/siteboon/claudecodeui/commit/591e8e7642589b0584f9b29b46b881aaab54624e))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
* update available plugin readmes ([f549bd9](https://github.com/siteboon/claudecodeui/commit/f549bd99e7106362a27cf4ccee6e9d434b8b5363))
|
||||||
|
* update session activity guard comment ([e23e6af](https://github.com/siteboon/claudecodeui/commit/e23e6af06a44cc4b016df5778984602d49e52629))
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
* add github issues board plugin ([21b0f14](https://github.com/siteboon/claudecodeui/commit/21b0f14e7a86f257c65484742c43b9f85152b32c))
|
||||||
|
* add more plugins list ([bc34085](https://github.com/siteboon/claudecodeui/commit/bc34085af9912da8d8592881a5845cff84a53f7d))
|
||||||
|
* move tests to appropriate folder ([d7a38a5](https://github.com/siteboon/claudecodeui/commit/d7a38a567a5e9039935353a886310b3c32b25a79))
|
||||||
|
* move tests to appropriate folder ([c6c153e](https://github.com/siteboon/claudecodeui/commit/c6c153e7f2a60572b08d687b59f010b4ad4f5d72))
|
||||||
|
* remove a log ([00e526b](https://github.com/siteboon/claudecodeui/commit/00e526b6e90ee0baf09ebf48873bc10824ab80ba))
|
||||||
|
* remove unused modelConstants from the project ([92de0ed](https://github.com/siteboon/claudecodeui/commit/92de0ed6137bf4571056deb3b930cc9fd22e2a08))
|
||||||
|
* upgrade gemini models ([3d94821](https://github.com/siteboon/claudecodeui/commit/3d948217ef3084e764171ebc5dda55f663150b2c))
|
||||||
|
|
||||||
## [](https://github.com/siteboon/claudecodeui/compare/v1.33.3...vnull) (2026-06-09)
|
## [](https://github.com/siteboon/claudecodeui/compare/v1.33.3...vnull) (2026-06-09)
|
||||||
|
|
||||||
### New Features
|
### New Features
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
- **Integrated Shell Terminal** - Direct access to the Agents CLI through built-in shell functionality
|
- **Integrated Shell Terminal** - Direct access to the Agents CLI through built-in shell functionality
|
||||||
- **File Explorer** - Interactive file tree with syntax highlighting and live editing
|
- **File Explorer** - Interactive file tree with syntax highlighting and live editing
|
||||||
- **Git Explorer** - View, stage and commit your changes. You can also switch branches
|
- **Git Explorer** - View, stage and commit your changes. You can also switch branches
|
||||||
|
- **Browser Use** - Open browser sessions for web research, testing, and agent-driven browser tasks
|
||||||
- **Session Management** - Resume conversations, manage multiple sessions, and track history
|
- **Session Management** - Resume conversations, manage multiple sessions, and track history
|
||||||
- **Plugin System** - Extend CloudCLI with custom plugins — add new tabs, backend services, and integrations. [Build your own →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
- **Plugin System** - Extend CloudCLI with custom plugins — add new tabs, backend services, and integrations. [Build your own →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||||
- **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation
|
- **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation
|
||||||
@@ -73,6 +74,11 @@ The fastest way to get started — no local setup required. Get a fully managed,
|
|||||||
|
|
||||||
**[Get started with CloudCLI Cloud](https://cloudcli.ai)**
|
**[Get started with CloudCLI Cloud](https://cloudcli.ai)**
|
||||||
|
|
||||||
|
### Desktop App
|
||||||
|
|
||||||
|
Download the latest macOS or Windows desktop app from the **[GitHub Releases](https://github.com/siteboon/claudecodeui/releases)** page.
|
||||||
|
|
||||||
|
Use the desktop app to open CloudCLI Cloud environments, switch between local and remote workspaces, copy mobile/browser URLs, and keep Local CloudCLI available from your menu bar or tray. To work locally, choose **Local CloudCLI** in the desktop app; it will use your running local server or start one for you.
|
||||||
|
|
||||||
### Self-Hosted (Open source)
|
### Self-Hosted (Open source)
|
||||||
|
|
||||||
|
|||||||
BIN
electron/assets/logo-macos.icns
Normal file
BIN
electron/assets/logo-macos.icns
Normal file
Binary file not shown.
BIN
electron/assets/logo-macos.png
Normal file
BIN
electron/assets/logo-macos.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
electron/assets/logo-windows.ico
Normal file
BIN
electron/assets/logo-windows.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
260
electron/cloud.js
Normal file
260
electron/cloud.js
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { safeStorage } from 'electron';
|
||||||
|
|
||||||
|
const CLOUD_API_TIMEOUT_MS = 15000;
|
||||||
|
|
||||||
|
function encryptSecret(secret) {
|
||||||
|
if (!safeStorage.isEncryptionAvailable()) {
|
||||||
|
return { encrypted: false, value: secret };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
encrypted: true,
|
||||||
|
value: safeStorage.encryptString(secret).toString('base64'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function decryptSecret(record) {
|
||||||
|
if (!record?.value) return null;
|
||||||
|
if (!record.encrypted) return record.value;
|
||||||
|
try {
|
||||||
|
return safeStorage.decryptString(Buffer.from(record.value, 'base64'));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CloudController {
|
||||||
|
constructor({ storePath, controlPlaneUrl, callbackUrl, onChange }) {
|
||||||
|
this.storePath = storePath;
|
||||||
|
this.controlPlaneUrl = controlPlaneUrl;
|
||||||
|
this.callbackUrl = callbackUrl;
|
||||||
|
this.onChange = onChange;
|
||||||
|
this.cloudAccount = null;
|
||||||
|
this.cloudEnvironments = [];
|
||||||
|
this.authState = 'logged_out';
|
||||||
|
}
|
||||||
|
|
||||||
|
getAccount() {
|
||||||
|
return this.cloudAccount;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAuthState() {
|
||||||
|
return this.authState;
|
||||||
|
}
|
||||||
|
|
||||||
|
getEnvironments() {
|
||||||
|
return this.cloudEnvironments;
|
||||||
|
}
|
||||||
|
|
||||||
|
getEnvironmentUrl(environment) {
|
||||||
|
return environment.access_url || `https://${environment.subdomain}.cloudcli.ai`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEnvironmentLaunchUrl(environment) {
|
||||||
|
if (!environment?.id) {
|
||||||
|
return this.getEnvironmentUrl(environment);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await this.cloudApi(`/api/v1/environments/${encodeURIComponent(environment.id)}/launch`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
return data.launch_url || data.environment_url || this.getEnvironmentUrl(environment);
|
||||||
|
}
|
||||||
|
|
||||||
|
findEnvironment(environmentId) {
|
||||||
|
return this.cloudEnvironments.find((item) => item.id === environmentId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadCloudAccount() {
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(this.storePath, 'utf8');
|
||||||
|
const stored = JSON.parse(raw);
|
||||||
|
const apiKey = decryptSecret(stored.apiKey);
|
||||||
|
this.cloudAccount = {
|
||||||
|
deviceId: stored.deviceId || crypto.randomUUID(),
|
||||||
|
email: stored.email || null,
|
||||||
|
apiKey: apiKey || null,
|
||||||
|
};
|
||||||
|
this.authState = apiKey ? 'connected' : (stored.email ? 'expired' : 'logged_out');
|
||||||
|
return this.cloudAccount;
|
||||||
|
} catch {
|
||||||
|
this.cloudAccount = {
|
||||||
|
deviceId: crypto.randomUUID(),
|
||||||
|
email: null,
|
||||||
|
apiKey: null,
|
||||||
|
};
|
||||||
|
this.authState = 'logged_out';
|
||||||
|
return this.cloudAccount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveCloudAccount(account) {
|
||||||
|
const payload = {
|
||||||
|
deviceId: account.deviceId || crypto.randomUUID(),
|
||||||
|
email: account.email || null,
|
||||||
|
apiKey: account.apiKey ? encryptSecret(account.apiKey) : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
await fs.mkdir(path.dirname(this.storePath), { recursive: true });
|
||||||
|
await fs.writeFile(this.storePath, JSON.stringify(payload, null, 2), 'utf8');
|
||||||
|
this.cloudAccount = {
|
||||||
|
deviceId: payload.deviceId,
|
||||||
|
email: payload.email,
|
||||||
|
apiKey: account.apiKey || null,
|
||||||
|
};
|
||||||
|
this.authState = account.apiKey ? 'connected' : 'logged_out';
|
||||||
|
this.onChange?.();
|
||||||
|
return this.cloudAccount;
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearCloudAccount() {
|
||||||
|
this.cloudAccount = {
|
||||||
|
deviceId: crypto.randomUUID(),
|
||||||
|
email: null,
|
||||||
|
apiKey: null,
|
||||||
|
};
|
||||||
|
this.cloudEnvironments = [];
|
||||||
|
this.authState = 'logged_out';
|
||||||
|
await fs.rm(this.storePath, { force: true });
|
||||||
|
this.onChange?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
async invalidateCloudAccount() {
|
||||||
|
this.cloudEnvironments = [];
|
||||||
|
if (!this.cloudAccount) {
|
||||||
|
this.cloudAccount = {
|
||||||
|
deviceId: crypto.randomUUID(),
|
||||||
|
email: null,
|
||||||
|
apiKey: null,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
this.cloudAccount = {
|
||||||
|
...this.cloudAccount,
|
||||||
|
apiKey: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
this.authState = this.cloudAccount.email ? 'expired' : 'logged_out';
|
||||||
|
const payload = {
|
||||||
|
deviceId: this.cloudAccount.deviceId,
|
||||||
|
email: this.cloudAccount.email || null,
|
||||||
|
apiKey: null,
|
||||||
|
};
|
||||||
|
await fs.mkdir(path.dirname(this.storePath), { recursive: true });
|
||||||
|
await fs.writeFile(this.storePath, JSON.stringify(payload, null, 2), 'utf8');
|
||||||
|
this.onChange?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
async cloudApi(pathname, options = {}) {
|
||||||
|
if (!this.cloudAccount?.apiKey) {
|
||||||
|
throw new Error('Connect your CloudCLI account first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), CLOUD_API_TIMEOUT_MS);
|
||||||
|
let response;
|
||||||
|
|
||||||
|
try {
|
||||||
|
response = await fetch(`${this.controlPlaneUrl}${pathname}`, {
|
||||||
|
...options,
|
||||||
|
signal: options.signal || controller.signal,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-API-Key': this.cloudAccount.apiKey,
|
||||||
|
...(options.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name === 'AbortError') {
|
||||||
|
throw new Error(`CloudCLI API request timed out after ${Math.round(CLOUD_API_TIMEOUT_MS / 1000)} seconds.`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
await this.invalidateCloudAccount();
|
||||||
|
}
|
||||||
|
throw new Error(body.error || `CloudCLI API request failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshCloudEnvironments() {
|
||||||
|
if (!this.cloudAccount?.apiKey) {
|
||||||
|
this.cloudEnvironments = [];
|
||||||
|
this.onChange?.();
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await this.cloudApi('/api/v1/environments');
|
||||||
|
this.cloudEnvironments = data.environments || [];
|
||||||
|
this.onChange?.();
|
||||||
|
return this.cloudEnvironments;
|
||||||
|
}
|
||||||
|
|
||||||
|
async startEnvironment(environment) {
|
||||||
|
await this.cloudApi(`/api/v1/environments/${encodeURIComponent(environment.id)}/start`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopEnvironment(environment) {
|
||||||
|
await this.cloudApi(`/api/v1/environments/${encodeURIComponent(environment.id)}/stop`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEnvironmentCredentials(environment) {
|
||||||
|
return this.cloudApi(`/api/v1/environments/${encodeURIComponent(environment.id)}/credentials`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async startEnvironmentAndWait(environment, timeoutMs) {
|
||||||
|
await this.startEnvironment(environment);
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
while (Date.now() - startedAt < timeoutMs) {
|
||||||
|
const environments = await this.refreshCloudEnvironments();
|
||||||
|
const current = environments.find((env) => env.id === environment.id);
|
||||||
|
if (current?.status === 'running') {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`${environment.name} did not become ready in time.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
buildConnectUrl() {
|
||||||
|
if (!this.cloudAccount?.deviceId) {
|
||||||
|
this.cloudAccount = {
|
||||||
|
deviceId: crypto.randomUUID(),
|
||||||
|
email: null,
|
||||||
|
apiKey: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectUrl = new URL('/auth/app-connect', this.controlPlaneUrl);
|
||||||
|
connectUrl.searchParams.set('device_id', this.cloudAccount.deviceId);
|
||||||
|
connectUrl.searchParams.set('callback_url', this.callbackUrl);
|
||||||
|
connectUrl.searchParams.set('app_surface', 'cloudcli_desktop');
|
||||||
|
connectUrl.searchParams.set('client_platform', 'desktop');
|
||||||
|
return connectUrl.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveFromCallback({ apiKey, email }) {
|
||||||
|
await this.saveCloudAccount({
|
||||||
|
deviceId: this.cloudAccount?.deviceId || crypto.randomUUID(),
|
||||||
|
email,
|
||||||
|
apiKey,
|
||||||
|
});
|
||||||
|
return this.cloudAccount;
|
||||||
|
}
|
||||||
|
}
|
||||||
378
electron/desktopNotifications.js
Normal file
378
electron/desktopNotifications.js
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { Notification } from 'electron';
|
||||||
|
import WebSocket from 'ws';
|
||||||
|
|
||||||
|
const RECONNECT_MIN_MS = 1000;
|
||||||
|
const RECONNECT_MAX_MS = 30000;
|
||||||
|
const TARGET_REGISTER_TIMEOUT_MS = 8000;
|
||||||
|
|
||||||
|
function toNotificationsWsUrl(httpUrl) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(httpUrl);
|
||||||
|
parsed.protocol = parsed.protocol === 'http:' ? 'ws:' : 'wss:';
|
||||||
|
parsed.pathname = '/desktop-notifications';
|
||||||
|
parsed.search = '';
|
||||||
|
parsed.hash = '';
|
||||||
|
return parsed.toString();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJsonMessage(raw) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(String(raw));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJson(url, { method = 'POST', body = null, headers = {} } = {}) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), TARGET_REGISTER_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
...(body == null ? {} : { body: JSON.stringify(body) }),
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload.error || `Request failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DesktopNotificationsController {
|
||||||
|
constructor({
|
||||||
|
settingsPath,
|
||||||
|
appVersion,
|
||||||
|
appName,
|
||||||
|
getDeviceId,
|
||||||
|
getAccountEmail,
|
||||||
|
getRunningEnvironmentUrls,
|
||||||
|
getApiKey,
|
||||||
|
getAuthToken,
|
||||||
|
getIconPath,
|
||||||
|
openNotificationTarget,
|
||||||
|
onChange,
|
||||||
|
}) {
|
||||||
|
this.settingsPath = settingsPath;
|
||||||
|
this.appVersion = appVersion;
|
||||||
|
this.appName = appName;
|
||||||
|
this.getDeviceId = getDeviceId;
|
||||||
|
this.getAccountEmail = getAccountEmail;
|
||||||
|
this.getRunningEnvironmentUrls = getRunningEnvironmentUrls;
|
||||||
|
this.getApiKey = getApiKey;
|
||||||
|
this.getAuthToken = getAuthToken;
|
||||||
|
this.getIconPath = getIconPath;
|
||||||
|
this.openNotificationTarget = openNotificationTarget;
|
||||||
|
this.onChange = onChange;
|
||||||
|
this.settings = { enabled: false };
|
||||||
|
this.connections = new Map();
|
||||||
|
this.lastEvent = null;
|
||||||
|
this.lastError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getState() {
|
||||||
|
const connectedTargets = [];
|
||||||
|
for (const [url, connection] of this.connections.entries()) {
|
||||||
|
if (connection.ws?.readyState === WebSocket.OPEN) {
|
||||||
|
connectedTargets.push(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: this.settings.enabled,
|
||||||
|
supported: Notification.isSupported(),
|
||||||
|
targetCount: this.connections.size,
|
||||||
|
connectedCount: connectedTargets.length,
|
||||||
|
connectedTargets,
|
||||||
|
lastEvent: this.lastEvent,
|
||||||
|
lastError: this.lastError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadSettings() {
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(this.settingsPath, 'utf8');
|
||||||
|
const stored = JSON.parse(raw);
|
||||||
|
this.settings = { enabled: Boolean(stored.enabled) };
|
||||||
|
} catch {
|
||||||
|
this.settings = { enabled: false };
|
||||||
|
}
|
||||||
|
return this.settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveSettings(next) {
|
||||||
|
const enabled = Boolean(next?.enabled);
|
||||||
|
if (!enabled && this.settings.enabled) {
|
||||||
|
await this.disableCurrentTargets();
|
||||||
|
}
|
||||||
|
this.settings = { enabled };
|
||||||
|
await fs.mkdir(path.dirname(this.settingsPath), { recursive: true });
|
||||||
|
await fs.writeFile(this.settingsPath, JSON.stringify(this.settings, null, 2), 'utf8');
|
||||||
|
await this.sync();
|
||||||
|
this.onChange?.();
|
||||||
|
return this.settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
async sync() {
|
||||||
|
if (!this.settings.enabled) {
|
||||||
|
this.stop();
|
||||||
|
this.lastEvent = 'disabled';
|
||||||
|
this.onChange?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Notification.isSupported()) {
|
||||||
|
this.stop();
|
||||||
|
this.lastEvent = 'unsupported';
|
||||||
|
this.lastError = 'Native notifications are not supported on this system.';
|
||||||
|
this.onChange?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceId = this.getDeviceId?.();
|
||||||
|
if (!deviceId) {
|
||||||
|
this.stop();
|
||||||
|
this.lastEvent = 'missing-device';
|
||||||
|
this.lastError = 'Connect a CloudCLI account before enabling desktop notifications.';
|
||||||
|
this.onChange?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = (this.getRunningEnvironmentUrls?.() || [])
|
||||||
|
.map((httpUrl) => ({
|
||||||
|
httpUrl,
|
||||||
|
wsUrl: toNotificationsWsUrl(httpUrl),
|
||||||
|
}))
|
||||||
|
.filter((target) => target.wsUrl);
|
||||||
|
|
||||||
|
const nextWsUrls = new Set(targets.map((target) => target.wsUrl));
|
||||||
|
for (const [wsUrl, connection] of this.connections.entries()) {
|
||||||
|
if (!nextWsUrls.has(wsUrl)) {
|
||||||
|
this.closeConnection(connection);
|
||||||
|
this.connections.delete(wsUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
if (!this.connections.has(target.wsUrl)) {
|
||||||
|
void this.connect(target).catch((error) => {
|
||||||
|
this.lastEvent = 'connect-error';
|
||||||
|
this.lastError = error instanceof Error ? error.message : String(error);
|
||||||
|
this.onChange?.();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastEvent = targets.length ? 'sync' : 'no-targets';
|
||||||
|
this.onChange?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(target, attempt = 0) {
|
||||||
|
const existing = this.connections.get(target.wsUrl);
|
||||||
|
if (existing?.ws && [WebSocket.CONNECTING, WebSocket.OPEN].includes(existing.ws.readyState)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = {
|
||||||
|
...target,
|
||||||
|
ws: null,
|
||||||
|
reconnectTimer: null,
|
||||||
|
closed: false,
|
||||||
|
attempt,
|
||||||
|
};
|
||||||
|
this.connections.set(target.wsUrl, connection);
|
||||||
|
|
||||||
|
const headers = await this.getTargetAuthHeaders(target.httpUrl);
|
||||||
|
if (connection.closed || this.connections.get(target.wsUrl) !== connection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ws = new WebSocket(target.wsUrl, { headers: Object.keys(headers).length ? headers : undefined });
|
||||||
|
connection.ws = ws;
|
||||||
|
|
||||||
|
ws.on('open', async () => {
|
||||||
|
try {
|
||||||
|
await this.registerTarget(target.httpUrl);
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'register',
|
||||||
|
deviceId: this.getDeviceId?.(),
|
||||||
|
label: this.getAccountEmail?.() || this.appName,
|
||||||
|
platform: process.platform,
|
||||||
|
appVersion: this.appVersion,
|
||||||
|
}));
|
||||||
|
connection.attempt = 0;
|
||||||
|
this.lastEvent = 'connected';
|
||||||
|
this.lastError = null;
|
||||||
|
this.onChange?.();
|
||||||
|
} catch (error) {
|
||||||
|
this.lastEvent = 'register-error';
|
||||||
|
this.lastError = error instanceof Error ? error.message : String(error);
|
||||||
|
this.onChange?.();
|
||||||
|
try { ws.close(); } catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('message', (raw) => this.handleMessage(target, ws, raw));
|
||||||
|
ws.on('close', () => this.scheduleReconnect(target.wsUrl));
|
||||||
|
ws.on('error', (error) => {
|
||||||
|
this.lastEvent = 'socket-error';
|
||||||
|
this.lastError = error instanceof Error ? error.message : String(error);
|
||||||
|
this.onChange?.();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async registerTarget(httpUrl) {
|
||||||
|
const url = new URL('/api/notifications/endpoints/current', httpUrl).toString();
|
||||||
|
await requestJson(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await this.getTargetAuthHeaders(httpUrl),
|
||||||
|
body: {
|
||||||
|
channel: 'desktop',
|
||||||
|
endpointId: this.getDeviceId?.(),
|
||||||
|
label: this.getAccountEmail?.() || this.appName,
|
||||||
|
metadata: {
|
||||||
|
platform: process.platform,
|
||||||
|
appVersion: this.appVersion,
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async disableCurrentTargets() {
|
||||||
|
const deviceId = this.getDeviceId?.();
|
||||||
|
if (!deviceId) return;
|
||||||
|
|
||||||
|
const targets = new Set([
|
||||||
|
...[...this.connections.values()].map((connection) => connection.httpUrl).filter(Boolean),
|
||||||
|
...(this.getRunningEnvironmentUrls?.() || []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const results = await Promise.allSettled([...targets].map(async (httpUrl) => {
|
||||||
|
const url = new URL(`/api/notifications/endpoints/desktop/${encodeURIComponent(deviceId)}`, httpUrl).toString();
|
||||||
|
await requestJson(url, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: await this.getTargetAuthHeaders(httpUrl),
|
||||||
|
body: { enabled: false },
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
const rejected = results.find((result) => result.status === 'rejected');
|
||||||
|
if (rejected) {
|
||||||
|
this.lastEvent = 'disable-endpoint-error';
|
||||||
|
this.lastError = rejected.reason instanceof Error ? rejected.reason.message : String(rejected.reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTargetAuthHeaders(httpUrl) {
|
||||||
|
const headers = {};
|
||||||
|
const apiKey = this.getApiKey?.();
|
||||||
|
if (apiKey) {
|
||||||
|
headers['X-API-Key'] = apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authToken = await Promise.resolve(this.getAuthToken?.(httpUrl)).catch(() => null);
|
||||||
|
if (authToken) {
|
||||||
|
headers.Authorization = `Bearer ${authToken}`;
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMessage(target, ws, raw) {
|
||||||
|
const message = readJsonMessage(raw);
|
||||||
|
if (!message || message.type !== 'notification' || !message.payload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shown = this.showNativeNotification(target, message.payload);
|
||||||
|
if (shown && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'notification_ack',
|
||||||
|
id: message.id || message.payload?.data?.tag || null,
|
||||||
|
action: 'shown',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showNativeNotification(target, payload) {
|
||||||
|
if (!Notification.isSupported()) return false;
|
||||||
|
|
||||||
|
const notification = new Notification({
|
||||||
|
title: payload.title || this.appName,
|
||||||
|
body: payload.body || '',
|
||||||
|
icon: this.getIconPath?.(),
|
||||||
|
silent: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
notification.on('click', () => {
|
||||||
|
void this.openNotificationTarget?.({
|
||||||
|
environmentUrl: target.httpUrl,
|
||||||
|
sessionId: payload.data?.sessionId || null,
|
||||||
|
provider: payload.data?.provider || null,
|
||||||
|
}).catch((error) => {
|
||||||
|
this.lastEvent = 'click-error';
|
||||||
|
this.lastError = error instanceof Error ? error.message : String(error);
|
||||||
|
this.onChange?.();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
notification.show();
|
||||||
|
this.lastEvent = 'notification-shown';
|
||||||
|
this.lastError = null;
|
||||||
|
this.onChange?.();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleReconnect(wsUrl) {
|
||||||
|
const connection = this.connections.get(wsUrl);
|
||||||
|
if (!connection || connection.closed || !this.settings.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attempt = connection.attempt + 1;
|
||||||
|
connection.attempt = attempt;
|
||||||
|
const delay = Math.min(RECONNECT_MAX_MS, RECONNECT_MIN_MS * (2 ** Math.min(attempt, 5)));
|
||||||
|
connection.reconnectTimer = setTimeout(() => {
|
||||||
|
if (!this.connections.has(wsUrl) || !this.settings.enabled) return;
|
||||||
|
void this.connect({
|
||||||
|
httpUrl: connection.httpUrl,
|
||||||
|
wsUrl: connection.wsUrl,
|
||||||
|
}, attempt).catch((error) => {
|
||||||
|
this.lastEvent = 'connect-error';
|
||||||
|
this.lastError = error instanceof Error ? error.message : String(error);
|
||||||
|
this.onChange?.();
|
||||||
|
});
|
||||||
|
}, delay);
|
||||||
|
this.lastEvent = 'reconnecting';
|
||||||
|
this.onChange?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
closeConnection(connection) {
|
||||||
|
connection.closed = true;
|
||||||
|
if (connection.reconnectTimer) {
|
||||||
|
clearTimeout(connection.reconnectTimer);
|
||||||
|
connection.reconnectTimer = null;
|
||||||
|
}
|
||||||
|
try { connection.ws?.close(); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
for (const connection of this.connections.values()) {
|
||||||
|
this.closeConnection(connection);
|
||||||
|
}
|
||||||
|
this.connections.clear();
|
||||||
|
this.onChange?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
766
electron/desktopWindow.js
Normal file
766
electron/desktopWindow.js
Normal file
@@ -0,0 +1,766 @@
|
|||||||
|
import { BrowserWindow, Menu, Tray, clipboard, nativeImage, nativeTheme, session, webContents as electronWebContents } from 'electron';
|
||||||
|
|
||||||
|
import { ViewHost } from './viewHost.js';
|
||||||
|
|
||||||
|
const TITLEBAR_HEIGHT = 44;
|
||||||
|
const AUTH_TOKEN_STORAGE_KEY = 'auth-token';
|
||||||
|
function isAllowedPermissionOrigin(sourceUrl, controlPlaneUrl) {
|
||||||
|
try {
|
||||||
|
const source = new URL(sourceUrl);
|
||||||
|
if ((source.hostname === '127.0.0.1' || source.hostname === 'localhost') && source.protocol === 'http:') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (source.protocol !== 'https:') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const controlPlane = new URL(controlPlaneUrl);
|
||||||
|
return source.origin === controlPlane.origin || source.hostname.endsWith('.cloudcli.ai');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWebContentsProcessId(contents) {
|
||||||
|
return {
|
||||||
|
osProcessId: typeof contents.getOSProcessId === 'function' ? contents.getOSProcessId() : null,
|
||||||
|
processId: typeof contents.getProcessId === 'function' ? contents.getProcessId() : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DesktopWindowManager {
|
||||||
|
constructor({
|
||||||
|
appName,
|
||||||
|
getWindowIconPath,
|
||||||
|
getLauncherPath,
|
||||||
|
getPreloadPath,
|
||||||
|
openExternalUrl,
|
||||||
|
getDesktopState,
|
||||||
|
getDisplayTargetName,
|
||||||
|
getRemoteEnvironmentMenuItems,
|
||||||
|
getCloudState,
|
||||||
|
getLocalState,
|
||||||
|
actions,
|
||||||
|
tabs,
|
||||||
|
}) {
|
||||||
|
this.appName = appName;
|
||||||
|
this.getWindowIconPath = getWindowIconPath;
|
||||||
|
this.getLauncherPath = getLauncherPath;
|
||||||
|
this.getPreloadPath = getPreloadPath;
|
||||||
|
this.openExternalUrl = openExternalUrl;
|
||||||
|
this.getDesktopState = getDesktopState;
|
||||||
|
this.getDisplayTargetName = getDisplayTargetName;
|
||||||
|
this.getRemoteEnvironmentMenuItems = getRemoteEnvironmentMenuItems;
|
||||||
|
this.getCloudState = getCloudState;
|
||||||
|
this.getLocalState = getLocalState;
|
||||||
|
this.actions = actions;
|
||||||
|
this.tabs = tabs;
|
||||||
|
|
||||||
|
this.mainWindow = null;
|
||||||
|
this.settingsWindow = null;
|
||||||
|
this.tray = null;
|
||||||
|
this.launcherLoaded = false;
|
||||||
|
this.viewHost = new ViewHost({
|
||||||
|
appName: this.appName,
|
||||||
|
getMainWindow: () => this.mainWindow,
|
||||||
|
getContentViewBounds: () => this.getContentViewBounds(),
|
||||||
|
getPreloadPath: this.getPreloadPath,
|
||||||
|
openExternalUrl: this.openExternalUrl,
|
||||||
|
showError: this.actions.showError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getMainWindow() {
|
||||||
|
return this.mainWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTrayImage() {
|
||||||
|
const image = nativeImage.createFromPath(this.getWindowIconPath());
|
||||||
|
return image.resize({ width: 18, height: 18 });
|
||||||
|
}
|
||||||
|
|
||||||
|
getContentViewBounds() {
|
||||||
|
if (!this.mainWindow) return { x: 0, y: TITLEBAR_HEIGHT, width: 0, height: 0 };
|
||||||
|
const [width, height] = this.mainWindow.getContentSize();
|
||||||
|
return {
|
||||||
|
x: 0,
|
||||||
|
y: TITLEBAR_HEIGHT,
|
||||||
|
width,
|
||||||
|
height: Math.max(0, height - TITLEBAR_HEIGHT),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
detachActiveContentView() {
|
||||||
|
this.viewHost.detachAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
async showTabPlaceholder(target, message) {
|
||||||
|
const tabId = this.tabs.getTabIdForTarget(target);
|
||||||
|
await this.viewHost.showTabPlaceholder(tabId, target, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async showLocalStartupTarget(target, logs) {
|
||||||
|
const tabId = this.tabs.getTabIdForTarget(target);
|
||||||
|
await this.viewHost.showLocalStartupTarget(tabId, target, logs);
|
||||||
|
}
|
||||||
|
|
||||||
|
async showContentTarget(target) {
|
||||||
|
const tabId = this.tabs.getTabIdForTarget(target);
|
||||||
|
await this.viewHost.showContentTarget(tabId, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroyTabView(tabId) {
|
||||||
|
this.viewHost.destroyTabView(tabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
emitDesktopState() {
|
||||||
|
const state = this.getDesktopState();
|
||||||
|
if (this.mainWindow && !this.mainWindow.webContents.isDestroyed()) {
|
||||||
|
this.mainWindow.webContents.send('cloudcli-desktop:state-updated', state);
|
||||||
|
}
|
||||||
|
if (this.settingsWindow && !this.settingsWindow.webContents.isDestroyed()) {
|
||||||
|
this.settingsWindow.webContents.send('cloudcli-desktop:state-updated', state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emitLauncherCommand(command) {
|
||||||
|
if (!this.mainWindow || this.mainWindow.webContents.isDestroyed()) return;
|
||||||
|
this.mainWindow.webContents.send('cloudcli-desktop:launcher-command', command);
|
||||||
|
}
|
||||||
|
|
||||||
|
emitSettingsCommand(command) {
|
||||||
|
if (!this.settingsWindow || this.settingsWindow.webContents.isDestroyed()) return;
|
||||||
|
this.settingsWindow.webContents.send('cloudcli-desktop:launcher-command', command);
|
||||||
|
}
|
||||||
|
|
||||||
|
syncSettingsWindowBounds() {
|
||||||
|
if (!this.mainWindow || !this.settingsWindow || this.settingsWindow.isDestroyed()) return;
|
||||||
|
this.settingsWindow.setBounds(this.mainWindow.getBounds());
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureSettingsWindow(sheet = 'desktop-settings') {
|
||||||
|
if (!this.mainWindow) return null;
|
||||||
|
|
||||||
|
if (this.settingsWindow && !this.settingsWindow.isDestroyed()) {
|
||||||
|
this.syncSettingsWindowBounds();
|
||||||
|
this.emitSettingsCommand({ type: 'open-sheet', sheet });
|
||||||
|
this.settingsWindow.focus();
|
||||||
|
return this.settingsWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.settingsWindow = new BrowserWindow({
|
||||||
|
parent: this.mainWindow,
|
||||||
|
show: false,
|
||||||
|
frame: false,
|
||||||
|
transparent: true,
|
||||||
|
hasShadow: false,
|
||||||
|
resizable: false,
|
||||||
|
minimizable: false,
|
||||||
|
maximizable: false,
|
||||||
|
fullscreenable: false,
|
||||||
|
movable: false,
|
||||||
|
skipTaskbar: true,
|
||||||
|
backgroundColor: '#00000000',
|
||||||
|
webPreferences: {
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: true,
|
||||||
|
preload: this.getPreloadPath(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.syncSettingsWindowBounds();
|
||||||
|
this.viewHost.configureChildWebContents(this.settingsWindow.webContents);
|
||||||
|
this.settingsWindow.once('ready-to-show', () => this.settingsWindow?.show());
|
||||||
|
this.settingsWindow.on('closed', () => {
|
||||||
|
this.settingsWindow = null;
|
||||||
|
});
|
||||||
|
await this.settingsWindow.loadFile(this.getLauncherPath(), {
|
||||||
|
query: { modal: '1', sheet },
|
||||||
|
});
|
||||||
|
return this.settingsWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeSettingsWindow() {
|
||||||
|
if (!this.settingsWindow || this.settingsWindow.isDestroyed()) return;
|
||||||
|
this.settingsWindow.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async showTarget(target, { trackTab = true } = {}) {
|
||||||
|
if (!this.mainWindow) return;
|
||||||
|
if (trackTab) {
|
||||||
|
this.tabs.upsertTarget(target);
|
||||||
|
}
|
||||||
|
this.actions.setActiveTarget(target);
|
||||||
|
this.buildAppMenu();
|
||||||
|
this.mainWindow.setTitle(`${this.appName} - ${target.name}`);
|
||||||
|
const finalUrl = await this.showContentTarget(target);
|
||||||
|
this.emitDesktopState();
|
||||||
|
return finalUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
async showLauncher() {
|
||||||
|
if (!this.mainWindow) return;
|
||||||
|
const target = { kind: 'launcher', name: this.appName, url: null };
|
||||||
|
this.tabs.upsertTarget(target);
|
||||||
|
this.actions.setActiveTarget(target);
|
||||||
|
this.detachActiveContentView();
|
||||||
|
this.buildAppMenu();
|
||||||
|
this.mainWindow.setTitle(this.appName);
|
||||||
|
this.mainWindow.webContents.focus();
|
||||||
|
if (!this.launcherLoaded) {
|
||||||
|
await this.mainWindow.loadFile(this.getLauncherPath());
|
||||||
|
this.launcherLoaded = true;
|
||||||
|
} else {
|
||||||
|
this.emitDesktopState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async switchDesktopTab(tabId) {
|
||||||
|
const tab = this.tabs.activate(tabId);
|
||||||
|
if (!tab || !this.mainWindow) return this.getDesktopState();
|
||||||
|
|
||||||
|
if (tab.id === 'home' || tab.kind === 'launcher') {
|
||||||
|
await this.showLauncher();
|
||||||
|
return this.getDesktopState();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tab.target?.url) {
|
||||||
|
throw new Error('This tab does not have a target URL.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.showTarget(tab.target, { trackTab: false });
|
||||||
|
return this.getDesktopState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async reloadActiveTab() {
|
||||||
|
const activeTab = this.tabs.getActiveTab();
|
||||||
|
if (!activeTab || activeTab.id === 'home' || activeTab.kind === 'launcher') {
|
||||||
|
this.emitDesktopState();
|
||||||
|
return this.getDesktopState();
|
||||||
|
}
|
||||||
|
|
||||||
|
const reloaded = this.viewHost.reloadTab(activeTab.id);
|
||||||
|
if (!reloaded && activeTab.target?.url) {
|
||||||
|
await this.showTarget(activeTab.target, { trackTab: false });
|
||||||
|
}
|
||||||
|
this.emitDesktopState();
|
||||||
|
return this.getDesktopState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async navigateActiveView(url) {
|
||||||
|
const navigated = await this.viewHost.navigateActiveView(url);
|
||||||
|
this.emitDesktopState();
|
||||||
|
return navigated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async readAuthTokenForTarget(url) {
|
||||||
|
return this.viewHost.readLocalStorageValueForOrigin(url, AUTH_TOKEN_STORAGE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
openActiveTabDevTools() {
|
||||||
|
if (this.viewHost.openActiveViewDevTools()) return;
|
||||||
|
void this.actions.showError('No active BrowserView', new Error('Switch to a non-launcher tab before opening active tab DevTools.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
reloadActiveBrowserViewForDiagnostics() {
|
||||||
|
if (this.viewHost.reloadActiveView()) return;
|
||||||
|
void this.actions.showError('No active BrowserView', new Error('Switch to a non-launcher tab before reloading the active BrowserView.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
detachActiveBrowserViewForDiagnostics() {
|
||||||
|
if (this.viewHost.detachActiveView()) return;
|
||||||
|
void this.actions.showError('No active BrowserView', new Error('Switch to a non-launcher tab before detaching the active BrowserView.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
copyWebContentsDiagnostics() {
|
||||||
|
const tabViewDiagnostics = this.viewHost.getTabViewDiagnostics();
|
||||||
|
const tabViewByContentsId = new Map(
|
||||||
|
tabViewDiagnostics
|
||||||
|
.filter((item) => item.webContentsId != null)
|
||||||
|
.map((item) => [item.webContentsId, item])
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = electronWebContents.getAllWebContents().map((contents) => {
|
||||||
|
const destroyed = contents.isDestroyed();
|
||||||
|
const processIds = destroyed ? { osProcessId: null, processId: null } : getWebContentsProcessId(contents);
|
||||||
|
const tabView = tabViewByContentsId.get(contents.id);
|
||||||
|
let owner = 'unknown';
|
||||||
|
if (this.mainWindow?.webContents?.id === contents.id) {
|
||||||
|
owner = 'main-window';
|
||||||
|
} else if (this.settingsWindow?.webContents?.id === contents.id) {
|
||||||
|
owner = 'settings-window';
|
||||||
|
} else if (tabView) {
|
||||||
|
owner = `browser-view:${tabView.tabId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: contents.id,
|
||||||
|
owner,
|
||||||
|
osProcessId: processIds.osProcessId,
|
||||||
|
processId: processIds.processId,
|
||||||
|
url: destroyed ? null : contents.getURL(),
|
||||||
|
title: destroyed ? null : contents.getTitle(),
|
||||||
|
destroyed,
|
||||||
|
focused: destroyed || typeof contents.isFocused !== 'function' ? false : contents.isFocused(),
|
||||||
|
attached: tabView ? tabView.attached : null,
|
||||||
|
active: tabView ? tabView.active : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeTab = this.tabs.getActiveTab();
|
||||||
|
const diagnostics = {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
activeTabId: this.tabs.activeTabId,
|
||||||
|
activeTab: activeTab
|
||||||
|
? {
|
||||||
|
id: activeTab.id,
|
||||||
|
title: activeTab.title,
|
||||||
|
kind: activeTab.kind,
|
||||||
|
targetUrl: activeTab.target?.url || null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
tabViews: tabViewDiagnostics,
|
||||||
|
webContents: rows,
|
||||||
|
};
|
||||||
|
|
||||||
|
clipboard.writeText(JSON.stringify(diagnostics, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
async closeDesktopTab(tabId) {
|
||||||
|
const tab = this.tabs.remove(tabId);
|
||||||
|
if (!tab) return this.getDesktopState();
|
||||||
|
this.destroyTabView(tabId);
|
||||||
|
if (this.tabs.activeTabId === 'home') {
|
||||||
|
await this.showLauncher();
|
||||||
|
} else {
|
||||||
|
this.emitDesktopState();
|
||||||
|
}
|
||||||
|
return this.getDesktopState();
|
||||||
|
}
|
||||||
|
|
||||||
|
buildEnvironmentActionsSubmenu(environment) {
|
||||||
|
const items = [];
|
||||||
|
const statusSuffix = environment.status === 'running' ? '' : ` (${environment.status})`;
|
||||||
|
items.push({
|
||||||
|
label: 'Open Environment',
|
||||||
|
click: () => void this.actions.openEnvironmentInDesktop(environment)
|
||||||
|
.catch((error) => this.actions.showError(`Could not open ${environment.name || environment.subdomain}${statusSuffix}`, error)),
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
label: 'Open in Browser',
|
||||||
|
click: () => void this.actions.openEnvironmentInBrowser(environment)
|
||||||
|
.catch((error) => this.actions.showError('Could not open environment in browser', error)),
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
label: 'Open in VS Code',
|
||||||
|
click: () => void this.actions.openEnvironmentInIde(environment, 'vscode')
|
||||||
|
.catch((error) => this.actions.showError('Could not open environment in VS Code', error)),
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
label: 'Open in Cursor',
|
||||||
|
click: () => void this.actions.openEnvironmentInIde(environment, 'cursor')
|
||||||
|
.catch((error) => this.actions.showError('Could not open environment in Cursor', error)),
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
label: 'Open SSH Terminal',
|
||||||
|
click: () => void this.actions.openEnvironmentInSsh(environment)
|
||||||
|
.catch((error) => this.actions.showError('Could not open SSH terminal', error)),
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
label: 'Copy Mobile/Web URL',
|
||||||
|
click: () => this.actions.copyText(this.actions.getEnvironmentUrl(environment)),
|
||||||
|
});
|
||||||
|
if (environment.status !== 'running') {
|
||||||
|
items.unshift({
|
||||||
|
label: environment.status === 'paused' ? 'Resume' : 'Start',
|
||||||
|
click: () => void this.actions.startEnvironment(environment)
|
||||||
|
.catch((error) => this.actions.showError('Could not start environment', error)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (environment.status === 'running') {
|
||||||
|
items.push({
|
||||||
|
label: 'Stop',
|
||||||
|
click: () => void this.actions.stopEnvironment(environment)
|
||||||
|
.catch((error) => this.actions.showError('Could not stop environment', error)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTrayEnvironmentSection() {
|
||||||
|
const cloudState = this.getCloudState();
|
||||||
|
if (!cloudState.account?.apiKey) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: cloudState.account?.email ? `Reconnect ${cloudState.account.email}` : 'Login',
|
||||||
|
click: () => void this.actions.connectCloudAccount()
|
||||||
|
.catch((error) => this.actions.showError('Could not connect CloudCLI account', error)),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cloudState.environments.length) {
|
||||||
|
return [{ label: 'No environments found', enabled: false }];
|
||||||
|
}
|
||||||
|
|
||||||
|
return cloudState.environments.map((environment) => ({
|
||||||
|
label: `${environment.name || environment.subdomain} - ${environment.status}`,
|
||||||
|
submenu: this.buildEnvironmentActionsSubmenu(environment),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
buildAppMenu() {
|
||||||
|
if (!this.mainWindow) return;
|
||||||
|
const cloudState = this.getCloudState();
|
||||||
|
const localState = this.getLocalState();
|
||||||
|
const remoteItems = this.getRemoteEnvironmentMenuItems();
|
||||||
|
const cloudAccountLabel = cloudState.account?.apiKey
|
||||||
|
? (cloudState.account?.email ? `Connected: ${cloudState.account.email}` : 'CloudCLI Connected')
|
||||||
|
: (cloudState.account?.email ? `Reconnect: ${cloudState.account.email}` : 'Connect CloudCLI Account...');
|
||||||
|
|
||||||
|
const template = [
|
||||||
|
{
|
||||||
|
label: this.appName,
|
||||||
|
submenu: [
|
||||||
|
{ label: `About ${this.appName}`, role: 'about' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Show Launcher',
|
||||||
|
accelerator: 'CmdOrCtrl+Shift+L',
|
||||||
|
click: () => void this.showLauncher().catch((error) => this.actions.showError('Could not show launcher', error)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Switch Environment',
|
||||||
|
accelerator: 'CmdOrCtrl+Shift+E',
|
||||||
|
click: () => void this.actions.showEnvironmentPicker().catch((error) => this.actions.showError('Could not switch environment', error)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Diagnostics',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'Copy Diagnostics',
|
||||||
|
click: () => void this.actions.copyDiagnostics(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: process.platform === 'darwin' ? `Hide ${this.appName}` : 'Hide',
|
||||||
|
role: 'hide',
|
||||||
|
visible: process.platform === 'darwin',
|
||||||
|
},
|
||||||
|
{ label: 'Hide Others', role: 'hideOthers', visible: process.platform === 'darwin' },
|
||||||
|
{ label: 'Show All', role: 'unhide', visible: process.platform === 'darwin' },
|
||||||
|
{ type: 'separator', visible: process.platform === 'darwin' },
|
||||||
|
{ label: `Quit ${this.appName}`, accelerator: 'CmdOrCtrl+Q', role: 'quit' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Environment',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'Show Launcher',
|
||||||
|
accelerator: 'CmdOrCtrl+Shift+L',
|
||||||
|
click: () => void this.showLauncher().catch((error) => this.actions.showError('Could not show launcher', error)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Switch Environment',
|
||||||
|
accelerator: 'CmdOrCtrl+Shift+E',
|
||||||
|
click: () => void this.actions.showEnvironmentPicker().catch((error) => this.actions.showError('Could not switch environment', error)),
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Open Local CloudCLI',
|
||||||
|
accelerator: 'CmdOrCtrl+L',
|
||||||
|
click: () => void this.actions.openLocalInDesktop().catch((error) => this.actions.showError('Could not open local CloudCLI', error)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Open Local Web UI in Browser',
|
||||||
|
accelerator: 'CmdOrCtrl+Shift+W',
|
||||||
|
click: () => void this.actions.openLocalWebUi().catch((error) => this.actions.showError('Could not open local web UI', error)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Copy Local Web URL',
|
||||||
|
accelerator: 'CmdOrCtrl+Shift+U',
|
||||||
|
click: () => void this.actions.copyLocalWebUrl().catch((error) => this.actions.showError('Could not copy local web URL', error)),
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Keep Local Server Running After Quit',
|
||||||
|
type: 'checkbox',
|
||||||
|
checked: localState.desktopSettings.keepLocalServerRunning,
|
||||||
|
click: (menuItem) => void this.actions.updateDesktopSetting('keepLocalServerRunning', menuItem.checked)
|
||||||
|
.catch((error) => this.actions.showError('Could not update desktop setting', error)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Allow LAN Access to Local Server',
|
||||||
|
type: 'checkbox',
|
||||||
|
checked: localState.desktopSettings.exposeLocalServerOnNetwork,
|
||||||
|
click: (menuItem) => void this.actions.updateDesktopSetting('exposeLocalServerOnNetwork', menuItem.checked)
|
||||||
|
.catch((error) => this.actions.showError('Could not update desktop setting', error)),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Cloud',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: cloudAccountLabel,
|
||||||
|
accelerator: 'CmdOrCtrl+Shift+C',
|
||||||
|
click: () => void this.actions.connectCloudAccount().catch((error) => this.actions.showError('Could not connect CloudCLI account', error)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Refresh Cloud Environments',
|
||||||
|
click: () => void this.actions.refreshCloudEnvironments().catch((error) => this.actions.showError('Could not load CloudCLI environments', error)),
|
||||||
|
enabled: Boolean(cloudState.account?.apiKey),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Logout CloudCLI Account',
|
||||||
|
click: () => void this.actions.clearCloudAccount().catch((error) => this.actions.showError('Could not logout', error)),
|
||||||
|
enabled: Boolean(cloudState.account?.apiKey),
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Remote Environments',
|
||||||
|
submenu: remoteItems,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Edit',
|
||||||
|
submenu: [
|
||||||
|
{ role: 'undo' },
|
||||||
|
{ role: 'redo' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'cut' },
|
||||||
|
{ role: 'copy' },
|
||||||
|
{ role: 'paste' },
|
||||||
|
{ role: 'selectAll' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'View',
|
||||||
|
submenu: [
|
||||||
|
{ role: 'reload' },
|
||||||
|
{ role: 'forceReload' },
|
||||||
|
{ role: 'toggleDevTools' },
|
||||||
|
{
|
||||||
|
label: 'Open Active Tab DevTools',
|
||||||
|
click: () => this.openActiveTabDevTools(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Copy WebContents Diagnostics',
|
||||||
|
click: () => this.copyWebContentsDiagnostics(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Reload Active BrowserView',
|
||||||
|
click: () => this.reloadActiveBrowserViewForDiagnostics(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Detach Active BrowserView',
|
||||||
|
click: () => this.detachActiveBrowserViewForDiagnostics(),
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'resetZoom' },
|
||||||
|
{ role: 'zoomIn' },
|
||||||
|
{ role: 'zoomOut' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'togglefullscreen' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Window',
|
||||||
|
submenu: [
|
||||||
|
{ role: 'minimize' },
|
||||||
|
{ role: 'zoom' },
|
||||||
|
...(process.platform === 'darwin' ? [{ type: 'separator' }, { role: 'front' }] : []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Help',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'Open cloudcli.ai',
|
||||||
|
click: () => void this.actions.openCloudDashboard(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Copy Diagnostics',
|
||||||
|
click: () => void this.actions.copyDiagnostics(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
|
||||||
|
this.buildTrayMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTrayMenu() {
|
||||||
|
if (!this.tray) return;
|
||||||
|
const cloudState = this.getCloudState();
|
||||||
|
const localState = this.getLocalState();
|
||||||
|
|
||||||
|
const template = [
|
||||||
|
{
|
||||||
|
label: 'Local',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: localState.localServerRunning ? 'Open Local in CloudCLI' : 'Start Local in CloudCLI',
|
||||||
|
click: () => void this.actions.openLocalInDesktop().catch((error) => this.actions.showError('Could not open local CloudCLI', error)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Open Local in Browser',
|
||||||
|
click: () => void this.actions.openLocalWebUi().catch((error) => this.actions.showError('Could not open local web UI', error)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Copy Local URL',
|
||||||
|
click: () => void this.actions.copyLocalWebUrl().catch((error) => this.actions.showError('Could not copy local web URL', error)),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Cloud Environments',
|
||||||
|
submenu: this.buildTrayEnvironmentSection(),
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: cloudState.account?.email ? `Connected: ${cloudState.account.email}` : 'Login',
|
||||||
|
click: () => void this.actions.connectCloudAccount().catch((error) => this.actions.showError('Could not connect CloudCLI account', error)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Logout CloudCLI Account',
|
||||||
|
click: () => void this.actions.clearCloudAccount().catch((error) => this.actions.showError('Could not logout', error)),
|
||||||
|
enabled: Boolean(cloudState.account?.apiKey),
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: `Quit ${this.appName}`,
|
||||||
|
role: 'quit',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
this.tray.setToolTip(`${this.appName}${this.actions.getActiveTarget()?.name ? ` - ${this.actions.getActiveTarget().name}` : ''}`);
|
||||||
|
this.tray.setContextMenu(Menu.buildFromTemplate(template));
|
||||||
|
}
|
||||||
|
|
||||||
|
async showDesktopSettings() {
|
||||||
|
if (!this.mainWindow) return this.getDesktopState();
|
||||||
|
await this.ensureSettingsWindow('desktop-settings');
|
||||||
|
return this.getDesktopState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async showLocalSettings() {
|
||||||
|
if (!this.mainWindow) return this.getDesktopState();
|
||||||
|
await this.ensureSettingsWindow('local-settings');
|
||||||
|
return this.getDesktopState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async showActiveEnvironmentActionsMenu() {
|
||||||
|
if (!this.mainWindow) return this.getDesktopState();
|
||||||
|
const activeTarget = this.actions.getActiveTarget();
|
||||||
|
if (activeTarget?.kind !== 'remote') return this.getDesktopState();
|
||||||
|
|
||||||
|
const environment = this.getCloudState().environments.find((item) => item.id === activeTarget.id);
|
||||||
|
if (!environment) return this.getDesktopState();
|
||||||
|
|
||||||
|
const menu = Menu.buildFromTemplate(this.buildEnvironmentActionsSubmenu(environment));
|
||||||
|
menu.popup({ window: this.mainWindow });
|
||||||
|
return this.getDesktopState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async showEnvironmentActionsMenu(environmentId) {
|
||||||
|
if (!this.mainWindow) return this.getDesktopState();
|
||||||
|
const environment = this.getCloudState().environments.find((item) => item.id === environmentId);
|
||||||
|
if (!environment) return this.getDesktopState();
|
||||||
|
|
||||||
|
const menu = Menu.buildFromTemplate(this.buildEnvironmentActionsSubmenu(environment));
|
||||||
|
menu.popup({ window: this.mainWindow });
|
||||||
|
return this.getDesktopState();
|
||||||
|
}
|
||||||
|
|
||||||
|
configurePermissions() {
|
||||||
|
const isAllowedPermission = (webContents, permission) => {
|
||||||
|
const sourceUrl = webContents.getURL();
|
||||||
|
const allowedPermissions = new Set(['clipboard-read', 'media', 'notifications']);
|
||||||
|
return isAllowedPermissionOrigin(sourceUrl, this.getCloudState().controlPlaneUrl) && allowedPermissions.has(permission);
|
||||||
|
};
|
||||||
|
|
||||||
|
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => {
|
||||||
|
callback(isAllowedPermission(webContents, permission));
|
||||||
|
});
|
||||||
|
session.defaultSession.setPermissionCheckHandler((webContents, permission) => {
|
||||||
|
if (!webContents) return false;
|
||||||
|
return isAllowedPermission(webContents, permission);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createTray() {
|
||||||
|
if (this.tray) return;
|
||||||
|
this.tray = new Tray(this.getTrayImage());
|
||||||
|
this.tray.on('click', () => {
|
||||||
|
if (!this.mainWindow) return;
|
||||||
|
if (this.mainWindow.isVisible()) {
|
||||||
|
this.mainWindow.focus();
|
||||||
|
} else {
|
||||||
|
this.mainWindow.show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.buildTrayMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
async createWindow() {
|
||||||
|
this.mainWindow = new BrowserWindow({
|
||||||
|
width: 1440,
|
||||||
|
height: 960,
|
||||||
|
minWidth: 1024,
|
||||||
|
minHeight: 720,
|
||||||
|
show: false,
|
||||||
|
backgroundColor: '#0f172a',
|
||||||
|
title: this.appName,
|
||||||
|
icon: this.getWindowIconPath(),
|
||||||
|
titleBarStyle: 'hidden',
|
||||||
|
...(process.platform === 'darwin'
|
||||||
|
? { trafficLightPosition: { x: 18, y: 14 } }
|
||||||
|
: {
|
||||||
|
titleBarOverlay: {
|
||||||
|
color: nativeTheme.shouldUseDarkColors ? '#111111' : '#f7f8fa',
|
||||||
|
symbolColor: nativeTheme.shouldUseDarkColors ? '#a1a1a1' : '#5b6470',
|
||||||
|
height: 44,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
webPreferences: {
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: true,
|
||||||
|
preload: this.getPreloadPath(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.mainWindow.once('ready-to-show', () => {
|
||||||
|
this.mainWindow?.show();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
void this.openExternalUrl(url).catch((error) => this.actions.showError('Could not open external link', error));
|
||||||
|
return { action: 'deny' };
|
||||||
|
});
|
||||||
|
|
||||||
|
this.mainWindow.on('resize', () => {
|
||||||
|
this.viewHost.resizeActiveView();
|
||||||
|
this.syncSettingsWindowBounds();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.mainWindow.on('move', () => {
|
||||||
|
this.syncSettingsWindowBounds();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.mainWindow.on('closed', () => {
|
||||||
|
this.viewHost.clear();
|
||||||
|
this.settingsWindow = null;
|
||||||
|
this.mainWindow = null;
|
||||||
|
this.launcherLoaded = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.buildAppMenu();
|
||||||
|
await this.showLauncher();
|
||||||
|
}
|
||||||
|
}
|
||||||
14
electron/launcher/index.html
Normal file
14
electron/launcher/index.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' data:; connect-src *; img-src 'self' data:" />
|
||||||
|
<title>CloudCLI Desktop</title>
|
||||||
|
<link rel="stylesheet" href="./launcher.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script src="./launcher.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
801
electron/launcher/launcher.css
Normal file
801
electron/launcher/launcher.css
Normal file
@@ -0,0 +1,801 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.cc-modal-window,
|
||||||
|
body.cc-modal-window {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #111315;
|
||||||
|
--s1: #171a1d;
|
||||||
|
--s2: #1e2328;
|
||||||
|
--s3: #262d34;
|
||||||
|
--b-subtle: #28303a;
|
||||||
|
--b: #313b46;
|
||||||
|
--b-strong: #42505f;
|
||||||
|
--tx: #f5f7fa;
|
||||||
|
--tx2: #adb8c5;
|
||||||
|
--tx3: #7f8b98;
|
||||||
|
--brand: #0a66d9;
|
||||||
|
--brand-2: #5fa5ff;
|
||||||
|
--brand-faint: rgba(10, 102, 217, 0.14);
|
||||||
|
--ok: #2aa775;
|
||||||
|
--warn: #d48b20;
|
||||||
|
--err: #d65252;
|
||||||
|
--tab-hover-bg: rgba(255, 255, 255, 0.08);
|
||||||
|
--tab-active-bg: rgba(255, 255, 255, 0.14);
|
||||||
|
--mono: "SF Mono", "Geist Mono", "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, sans-serif;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] {
|
||||||
|
--bg: #f3f5f8;
|
||||||
|
--s1: #ffffff;
|
||||||
|
--s2: #f7f9fb;
|
||||||
|
--s3: #edf1f5;
|
||||||
|
--b-subtle: #e5eaf0;
|
||||||
|
--b: #d8dee6;
|
||||||
|
--b-strong: #c3ccd6;
|
||||||
|
--tx: #11151a;
|
||||||
|
--tx2: #566171;
|
||||||
|
--tx3: #7f8b98;
|
||||||
|
--brand: #0a66d9;
|
||||||
|
--brand-2: #0f5fc6;
|
||||||
|
--brand-faint: rgba(10, 102, 217, 0.09);
|
||||||
|
--ok: #1f8e61;
|
||||||
|
--warn: #b67515;
|
||||||
|
--err: #c24747;
|
||||||
|
--tab-hover-bg: rgba(15, 23, 42, 0.05);
|
||||||
|
--tab-active-bg: rgba(15, 23, 42, 0.08);
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--tx);
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 14px;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
font: inherit;
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--b);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lbl {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 1.1px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--tx3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--tx3);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar {
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-bottom: 1px solid var(--b-subtle);
|
||||||
|
background: color-mix(in srgb, var(--s1) 90%, transparent);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar button,
|
||||||
|
.titlebar input,
|
||||||
|
.titlebar .no-drag {
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand .mk {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
display: block;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 7px;
|
||||||
|
min-width: 0;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 13px;
|
||||||
|
border-radius: 9px;
|
||||||
|
border: 1px solid var(--b);
|
||||||
|
background: var(--s1);
|
||||||
|
color: var(--tx);
|
||||||
|
font-weight: 500;
|
||||||
|
transition: border-color 0.12s, background 0.12s, filter 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
border-color: var(--b-strong);
|
||||||
|
background: var(--s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.pri {
|
||||||
|
background: var(--brand);
|
||||||
|
border-color: var(--brand);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.pri:hover {
|
||||||
|
filter: brightness(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.sm {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 9px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--tx2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover {
|
||||||
|
background: var(--s2);
|
||||||
|
border-color: var(--b);
|
||||||
|
color: var(--tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 22px;
|
||||||
|
padding: 0 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
background: var(--s2);
|
||||||
|
color: var(--tx2);
|
||||||
|
border: 1px solid var(--b-subtle);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.ok {
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.warn {
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.idle {
|
||||||
|
color: var(--tx3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusbar {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
height: 27px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-top: 1px solid var(--b-subtle);
|
||||||
|
background: var(--s1);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--tx2);
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusbar .sep {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-msg.progress {
|
||||||
|
color: var(--brand-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-msg.error {
|
||||||
|
color: var(--err);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(6, 8, 11, 0.28);
|
||||||
|
display: none;
|
||||||
|
z-index: 50;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-overlay.open {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-sheet {
|
||||||
|
width: 620px;
|
||||||
|
max-width: min(92vw, 620px);
|
||||||
|
max-height: min(720px, 82vh);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--b);
|
||||||
|
background: color-mix(in srgb, var(--s1) 98%, transparent);
|
||||||
|
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-sheet-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px 20px 18px;
|
||||||
|
border-bottom: 1px solid var(--b-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-sheet-copy {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-sheet-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-sheet-subtitle {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--tx2);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-sheet-close {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-sheet-body {
|
||||||
|
overflow: auto;
|
||||||
|
padding: 16px 20px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-sheet-footer {
|
||||||
|
padding: 14px 20px 18px;
|
||||||
|
border-top: 1px solid var(--b-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-section-head {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-section-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-section-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-surface {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--b-subtle);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(180deg, color-mix(in srgb, var(--s2) 86%, transparent), var(--s1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-row2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-meta {
|
||||||
|
color: var(--tx2);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-toggle,
|
||||||
|
.cc-choice {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 18px minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
color: var(--tx2);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-toggle input,
|
||||||
|
.cc-choice input {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-top: 2px;
|
||||||
|
accent-color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-toggle b,
|
||||||
|
.cc-choice b {
|
||||||
|
color: var(--tx);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-choice-group {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-permissions {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--b-subtle);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--s1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-note {
|
||||||
|
color: var(--tx2);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-permission-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid var(--b-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-permission-title {
|
||||||
|
color: var(--tx);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-permission-detail {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--tx2);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-permission-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-kv {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 2px 0;
|
||||||
|
color: var(--tx2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-kv span:last-child {
|
||||||
|
color: var(--tx);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-actions-inline {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
align-self: flex-start;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--b-subtle);
|
||||||
|
background: var(--s2);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-status-badge.ok {
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-status-badge.warn {
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-status-badge.idle {
|
||||||
|
color: var(--tx3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-sidebar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 248px 1fr;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px 12px;
|
||||||
|
border-right: 1px solid var(--b-subtle);
|
||||||
|
background: var(--s1);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-grp {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-grp .lbl {
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--tx2);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-item > span:nth-child(2) {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-item .sb-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--tx3);
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-item:hover {
|
||||||
|
background: var(--s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-item.active {
|
||||||
|
background: var(--brand-faint);
|
||||||
|
color: var(--tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-item.active svg {
|
||||||
|
color: var(--brand-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-main {
|
||||||
|
overflow: auto;
|
||||||
|
padding: 24px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-h {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-sub {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--tx2);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border: 1px solid var(--b);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: color-mix(in srgb, var(--s1) 94%, transparent);
|
||||||
|
padding: 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
max-width: 620px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-t {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-sub {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--tx2);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.env {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--b);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--s1);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.env:hover {
|
||||||
|
border-color: var(--b-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.env-i {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.env-n {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.env-u {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--tx3);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.env-tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--tx2);
|
||||||
|
background: var(--s2);
|
||||||
|
border: 1px solid var(--b-subtle);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
border: 1px dashed var(--b);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 28px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--tx2);
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mac .titlebar {
|
||||||
|
padding-left: 92px;
|
||||||
|
padding-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.win .titlebar {
|
||||||
|
padding-right: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar .brand {
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-tabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-tab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 112px;
|
||||||
|
max-width: 232px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 7px 0 12px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--tx2);
|
||||||
|
font-size: 12px;
|
||||||
|
background: transparent;
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-tab:hover {
|
||||||
|
background: var(--tab-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-tab.active {
|
||||||
|
background: var(--tab-active-bg);
|
||||||
|
color: var(--tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-tab span:first-child {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 20ch;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-close {
|
||||||
|
display: grid;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin-left: 8px;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--tx3);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-close:hover {
|
||||||
|
background: var(--tab-hover-bg);
|
||||||
|
color: var(--tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-action {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.v-sidebar {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.env-tags {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-sheet {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-row2 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
687
electron/launcher/launcher.js
Normal file
687
electron/launcher/launcher.js
Normal file
@@ -0,0 +1,687 @@
|
|||||||
|
window.__APP_VERSION__ = '1.34.0';
|
||||||
|
window.__MOCK_STATE__ = {
|
||||||
|
account: { connected: true, email: 'you@cloudcli.ai' },
|
||||||
|
activeTarget: { kind: 'launcher', name: 'Launcher', url: null },
|
||||||
|
cloudLoading: false,
|
||||||
|
desktopSettings: { keepLocalServerRunning: false, exposeLocalServerOnNetwork: false, themeMode: 'system' },
|
||||||
|
localWebUrl: 'http://localhost:3001',
|
||||||
|
shareableWebUrl: 'http://localhost:3001',
|
||||||
|
localServerRunning: false,
|
||||||
|
localStartupLogs: [],
|
||||||
|
environments: [
|
||||||
|
{ id: 'env-api', name: 'api-gateway', subdomain: 'api-gateway', access_url: 'https://api-gateway.cloudcli.ai', status: 'running', region: 'fra1', agent: 'Claude Code' },
|
||||||
|
{ id: 'env-web', name: 'web-frontend', subdomain: 'web-frontend', access_url: 'https://web-frontend.cloudcli.ai', status: 'stopped', region: 'sfo1', agent: 'Codex' },
|
||||||
|
{ id: 'env-data', name: 'data-pipeline', subdomain: 'data-pipeline', access_url: 'https://data-pipeline.cloudcli.ai', status: 'stopped', region: 'fra1', agent: 'Cursor' },
|
||||||
|
{ id: 'env-ml', name: 'ml-trainer', subdomain: 'ml-trainer', access_url: 'https://ml-trainer.cloudcli.ai', status: 'paused', region: 'iad1', agent: 'Gemini' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
(function cloudCliLauncher() {
|
||||||
|
var MOCK = window.__MOCK_STATE__ || {};
|
||||||
|
var VERSION = window.__APP_VERSION__ || '';
|
||||||
|
var LOGO_URL = new URL('../../public/logo-32.png', window.location.href).toString();
|
||||||
|
var SEARCH = new URLSearchParams(window.location.search || '');
|
||||||
|
|
||||||
|
function clone(value) {
|
||||||
|
return JSON.parse(JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
var mockState = clone(MOCK);
|
||||||
|
var mockBridge = {
|
||||||
|
getState: function () { return Promise.resolve(clone(mockState)); },
|
||||||
|
openLocal: function () {
|
||||||
|
mockState.localServerRunning = true;
|
||||||
|
mockState.activeTarget = { kind: 'local', name: 'Local CloudCLI', url: mockState.localWebUrl };
|
||||||
|
return Promise.resolve(clone(mockState));
|
||||||
|
},
|
||||||
|
openLocalWebUi: function () {
|
||||||
|
mockState.localServerRunning = true;
|
||||||
|
return Promise.resolve(clone(mockState));
|
||||||
|
},
|
||||||
|
copyLocalWebUrl: function () { return Promise.resolve(clone(mockState)); },
|
||||||
|
connectCloud: function () {
|
||||||
|
mockState.account = { connected: true, email: 'you@cloudcli.ai' };
|
||||||
|
return Promise.resolve(clone(mockState));
|
||||||
|
},
|
||||||
|
disconnectCloud: function () {
|
||||||
|
mockState.account = { connected: false, email: null };
|
||||||
|
mockState.environments = [];
|
||||||
|
mockState.tabs = (mockState.tabs || []).filter(function (tab) { return tab.kind !== 'remote'; });
|
||||||
|
mockState.activeTabId = 'home';
|
||||||
|
mockState.activeTarget = { kind: 'launcher', name: 'Launcher', url: null };
|
||||||
|
return Promise.resolve(clone(mockState));
|
||||||
|
},
|
||||||
|
refreshEnvironments: function () { return Promise.resolve(clone(mockState)); },
|
||||||
|
refreshActiveTab: function () { return Promise.resolve(clone(mockState)); },
|
||||||
|
copyDiagnostics: function () { return Promise.resolve(clone(mockState)); },
|
||||||
|
showEnvironmentPicker: function () { return Promise.resolve(clone(mockState)); },
|
||||||
|
showLauncher: function () { return Promise.resolve(clone(mockState)); },
|
||||||
|
showLocalSettings: function () { return Promise.resolve(clone(mockState)); },
|
||||||
|
showDesktopSettings: function () { return Promise.resolve(clone(mockState)); },
|
||||||
|
closeSettingsWindow: function () { return Promise.resolve(clone(mockState)); },
|
||||||
|
showActiveEnvironmentActionsMenu: function () { return Promise.resolve(clone(mockState)); },
|
||||||
|
openCloudDashboard: function () { return Promise.resolve(clone(mockState)); },
|
||||||
|
runActiveEnvironmentAction: function () { return Promise.resolve(clone(mockState)); },
|
||||||
|
switchTab: function (id) { mockState.activeTabId = id; return Promise.resolve(clone(mockState)); },
|
||||||
|
closeTab: function (id) {
|
||||||
|
mockState.tabs = (mockState.tabs || []).filter(function (tab) { return tab.id === 'home' || tab.id !== id; });
|
||||||
|
if (mockState.activeTabId === id) mockState.activeTabId = 'home';
|
||||||
|
return Promise.resolve(clone(mockState));
|
||||||
|
},
|
||||||
|
updateSetting: function (key, value) {
|
||||||
|
mockState.desktopSettings = mockState.desktopSettings || {};
|
||||||
|
mockState.desktopSettings[key] = key === 'themeMode' ? value : !!value;
|
||||||
|
return Promise.resolve(clone(mockState));
|
||||||
|
},
|
||||||
|
openEnvironment: function (id) {
|
||||||
|
var env = (mockState.environments || []).filter(function (item) { return item.id === id; })[0];
|
||||||
|
if (env) {
|
||||||
|
env.status = 'starting';
|
||||||
|
setTimeout(function () {
|
||||||
|
env.status = 'running';
|
||||||
|
mockState.activeTarget = { kind: 'remote', id: id, name: env.name, url: env.access_url };
|
||||||
|
}, 1700);
|
||||||
|
}
|
||||||
|
return Promise.resolve(clone(mockState));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var bridge = window.cloudcliDesktop || mockBridge;
|
||||||
|
|
||||||
|
var ICONS = {
|
||||||
|
terminal: '<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>',
|
||||||
|
cloud: '<path d="M17.5 19a4.5 4.5 0 0 0 .5-8.97A6 6 0 0 0 6.34 9 4 4 0 0 0 7 19z"/>',
|
||||||
|
refresh: '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>',
|
||||||
|
settings: '<line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/>',
|
||||||
|
gear: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 .6l-.03.08a2 2 0 1 1-3.94 0L10 20a1.7 1.7 0 0 0-1-.6 1.7 1.7 0 0 0-1.88.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-.6-1l-.08-.03a2 2 0 1 1 0-3.94L4 10a1.7 1.7 0 0 0 .6-1 1.7 1.7 0 0 0-.34-1.88l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-.6l.03-.08a2 2 0 1 1 3.94 0L14 4a1.7 1.7 0 0 0 1 .6 1.7 1.7 0 0 0 1.88-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9c.2.36.4.7.6 1l.08.03a2 2 0 1 1 0 3.94L20 14a1.7 1.7 0 0 0-.6 1z"/>',
|
||||||
|
play: '<polygon points="6 4 20 12 6 20 6 4"/>',
|
||||||
|
arrow: '<line x1="7" y1="17" x2="17" y2="7"/><polyline points="8 7 17 7 17 16"/>',
|
||||||
|
copy: '<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
|
||||||
|
cloudPlus: '<path d="M17.5 19a4.5 4.5 0 0 0 .5-8.97A6 6 0 0 0 6.34 9 4 4 0 0 0 7 19z"/><line x1="12" y1="9" x2="12" y2="15"/><line x1="9" y1="12" x2="15" y2="12"/>',
|
||||||
|
monitor: '<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>',
|
||||||
|
phone: '<rect x="7" y="2" width="10" height="20" rx="2"/><line x1="11" y1="18" x2="13" y2="18"/>',
|
||||||
|
x: '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
|
||||||
|
logOut: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>',
|
||||||
|
};
|
||||||
|
var FILLED = { play: true };
|
||||||
|
|
||||||
|
function icon(name, size) {
|
||||||
|
size = size || 16;
|
||||||
|
return '<svg width="' + size + '" height="' + size + '" viewBox="0 0 24 24" fill="' + (FILLED[name] ? 'currentColor' : 'none') + '" stroke="' + (FILLED[name] ? 'none' : 'currentColor') + '" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">' + (ICONS[name] || '') + '</svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(value) {
|
||||||
|
return String(value == null ? '' : value)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusMeta(status) {
|
||||||
|
var map = {
|
||||||
|
running: { label: 'Running', cls: 'ok', dot: '#10b981', verb: 'Opening', open: 'Open' },
|
||||||
|
starting: { label: 'Starting', cls: 'warn', dot: '#f59e0b', verb: 'Starting', open: 'Open', busy: true },
|
||||||
|
stopped: { label: 'Stopped', cls: 'idle', dot: '#6b7280', verb: 'Starting', open: 'Start & open' },
|
||||||
|
paused: { label: 'Paused', cls: 'warn', dot: '#f59e0b', verb: 'Resuming', open: 'Resume' },
|
||||||
|
};
|
||||||
|
return map[status] || { label: status || 'Unknown', cls: 'idle', dot: '#6b7280', verb: 'Starting', open: 'Start & open' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function connected(state) {
|
||||||
|
return !!(state && state.account && state.account.connected);
|
||||||
|
}
|
||||||
|
|
||||||
|
function authState(state) {
|
||||||
|
return state && state.account ? (state.account.authState || (state.account.connected ? 'connected' : 'logged_out')) : 'logged_out';
|
||||||
|
}
|
||||||
|
|
||||||
|
function accountLabel(state) {
|
||||||
|
if (authState(state) === 'expired') return 'Reconnect';
|
||||||
|
if (state && state.account && state.account.email) return state.account.email;
|
||||||
|
if (connected(state)) return 'Connected';
|
||||||
|
return 'Log in';
|
||||||
|
}
|
||||||
|
|
||||||
|
function localUrl(state) {
|
||||||
|
return (state && (state.shareableWebUrl || state.localWebUrl)) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function envCount(state) {
|
||||||
|
var count = state && state.environments ? state.environments.length : 0;
|
||||||
|
return count + ' environment' + (count === 1 ? '' : 's');
|
||||||
|
}
|
||||||
|
|
||||||
|
function errMsg(error) {
|
||||||
|
return error && error.message ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTheme(state) {
|
||||||
|
var settings = state && state.desktopSettings ? state.desktopSettings : {};
|
||||||
|
var mode = settings.themeMode || 'system';
|
||||||
|
if (mode === 'light' || mode === 'dark') return mode;
|
||||||
|
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
var CC = {
|
||||||
|
icon: icon,
|
||||||
|
esc: esc,
|
||||||
|
statusMeta: statusMeta,
|
||||||
|
connected: connected,
|
||||||
|
authState: authState,
|
||||||
|
accountLabel: accountLabel,
|
||||||
|
localUrl: localUrl,
|
||||||
|
envCount: envCount,
|
||||||
|
version: VERSION,
|
||||||
|
logoUrl: LOGO_URL,
|
||||||
|
platform: 'win',
|
||||||
|
state: clone(MOCK),
|
||||||
|
ui: {},
|
||||||
|
_busyEnv: null,
|
||||||
|
_status: { msg: '', tone: '' },
|
||||||
|
_reg: {},
|
||||||
|
_wired: false,
|
||||||
|
_poll: null,
|
||||||
|
modalMode: SEARCH.get('modal') === '1',
|
||||||
|
};
|
||||||
|
|
||||||
|
window.CC = CC;
|
||||||
|
|
||||||
|
var app;
|
||||||
|
var overlay;
|
||||||
|
|
||||||
|
CC.setState = function (state) {
|
||||||
|
var currentSheet = CC.ui.openSheet || (CC.modalMode ? (CC.ui.initialSheet || 'desktop-settings') : null);
|
||||||
|
var sheetBody = overlay ? overlay.querySelector('.cc-sheet-body') : null;
|
||||||
|
var scrollTop = sheetBody ? sheetBody.scrollTop : 0;
|
||||||
|
if (state && typeof state === 'object') CC.state = state;
|
||||||
|
CC.applyTheme(CC.state);
|
||||||
|
CC.render(CC.state);
|
||||||
|
if (currentSheet) {
|
||||||
|
CC.openSheet(currentSheet, { scrollTop: scrollTop });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
CC.applyTheme = function (state) {
|
||||||
|
var settings = state && state.desktopSettings ? state.desktopSettings : {};
|
||||||
|
var themeMode = settings.themeMode || 'system';
|
||||||
|
var resolvedTheme = resolveTheme(state);
|
||||||
|
document.documentElement.setAttribute('data-theme', resolvedTheme);
|
||||||
|
document.documentElement.setAttribute('data-theme-mode', themeMode);
|
||||||
|
};
|
||||||
|
|
||||||
|
CC.refresh = function () {
|
||||||
|
return Promise.resolve(bridge.getState()).then(function (state) {
|
||||||
|
CC.setState(state);
|
||||||
|
return state;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
CC.run = function (label, fn) {
|
||||||
|
CC._status = { msg: label, tone: 'progress' };
|
||||||
|
CC.render(CC.state);
|
||||||
|
return Promise.resolve()
|
||||||
|
.then(fn)
|
||||||
|
.then(function (state) {
|
||||||
|
if (state && state.environments) CC.state = state;
|
||||||
|
return CC.refresh();
|
||||||
|
})
|
||||||
|
.then(function () {
|
||||||
|
CC._status = { msg: '', tone: '' };
|
||||||
|
CC.render(CC.state);
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
CC._status = { msg: errMsg(error), tone: 'error' };
|
||||||
|
CC.render(CC.state);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
CC.startPolling = function () {
|
||||||
|
if (CC._poll) return;
|
||||||
|
var ticks = 0;
|
||||||
|
CC._poll = setInterval(function () {
|
||||||
|
ticks += 1;
|
||||||
|
Promise.resolve(bridge.getState()).then(function (state) {
|
||||||
|
CC.setState(state);
|
||||||
|
var anyStarting = (state.environments || []).some(function (environment) { return environment.status === 'starting'; });
|
||||||
|
if (!anyStarting || ticks > 16) {
|
||||||
|
clearInterval(CC._poll);
|
||||||
|
CC._poll = null;
|
||||||
|
if (!anyStarting) {
|
||||||
|
CC._status = { msg: '', tone: '' };
|
||||||
|
CC.render(CC.state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
CC.openEnv = function (id) {
|
||||||
|
var env = (CC.state.environments || []).filter(function (environment) { return environment.id === id; })[0];
|
||||||
|
var meta = statusMeta(env ? env.status : '');
|
||||||
|
CC._busyEnv = id;
|
||||||
|
CC._status = { msg: (meta.verb || 'Opening') + ' ' + ((env && (env.name || env.subdomain)) || 'environment') + '...', tone: 'progress' };
|
||||||
|
if (env) {
|
||||||
|
var tabId = 'remote:' + env.id;
|
||||||
|
var tabs = CC.state.tabs && CC.state.tabs.length ? CC.state.tabs : [{ id: 'home', title: 'Launcher', kind: 'launcher', closable: false }];
|
||||||
|
tabs = tabs.map(function (tab) {
|
||||||
|
tab.active = false;
|
||||||
|
return tab;
|
||||||
|
});
|
||||||
|
var existing = tabs.filter(function (tab) { return tab.id === tabId; })[0];
|
||||||
|
if (existing) {
|
||||||
|
existing.active = true;
|
||||||
|
existing.title = env.name || env.subdomain;
|
||||||
|
} else {
|
||||||
|
tabs.push({ id: tabId, title: env.name || env.subdomain, kind: 'remote', closable: true, active: true });
|
||||||
|
}
|
||||||
|
CC.state.tabs = tabs;
|
||||||
|
CC.state.activeTabId = tabId;
|
||||||
|
}
|
||||||
|
if (env && env.status !== 'running') env.status = 'starting';
|
||||||
|
CC.render(CC.state);
|
||||||
|
return Promise.resolve(bridge.openEnvironment(id)).then(function (state) {
|
||||||
|
if (state && state.environments) CC.setState(state);
|
||||||
|
CC.startPolling();
|
||||||
|
}).catch(function (error) {
|
||||||
|
CC._busyEnv = null;
|
||||||
|
if (env) env.status = 'stopped';
|
||||||
|
CC._status = { msg: errMsg(error), tone: 'error' };
|
||||||
|
CC.render(CC.state);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
CC.act = function (name, node) {
|
||||||
|
switch (name) {
|
||||||
|
case 'local':
|
||||||
|
return CC.run('Starting Local CloudCLI...', function () { return bridge.openLocal(); });
|
||||||
|
case 'connect':
|
||||||
|
return CC.run('Opening cloudcli.ai to connect your account...', function () { return bridge.connectCloud(); });
|
||||||
|
case 'logout':
|
||||||
|
return CC.run('Logging out...', function () { return bridge.disconnectCloud(); });
|
||||||
|
case 'open-web':
|
||||||
|
return CC.run('Opening local web UI in your browser...', function () { return bridge.openLocalWebUi(); });
|
||||||
|
case 'copy-web':
|
||||||
|
return CC.run('Copied local URL to clipboard', function () { return bridge.copyLocalWebUrl(); });
|
||||||
|
case 'diagnostics':
|
||||||
|
return CC.run('Copied diagnostics to clipboard', function () { return bridge.copyDiagnostics(); });
|
||||||
|
case 'set-setting':
|
||||||
|
return CC.run('Saved', function () { return bridge.updateSetting(node.key, node.value); });
|
||||||
|
case 'set-theme-mode':
|
||||||
|
return CC.run('Saved', function () { return bridge.updateSetting('themeMode', node.value); });
|
||||||
|
case 'settings-toggle':
|
||||||
|
return CC.run('Opening desktop settings...', function () { return bridge.showDesktopSettings(); });
|
||||||
|
case 'desktop-settings-toggle':
|
||||||
|
return CC.run('Opening desktop settings...', function () { return bridge.showDesktopSettings(); });
|
||||||
|
case 'local-settings-toggle':
|
||||||
|
return CC.run('Opening local settings...', function () { return bridge.showLocalSettings(); });
|
||||||
|
case 'settings-close':
|
||||||
|
return CC.closeSheet();
|
||||||
|
case 'dashboard':
|
||||||
|
return CC.run('Opening CloudCLI dashboard...', function () { return bridge.openCloudDashboard(); });
|
||||||
|
case 'refresh-environments':
|
||||||
|
return CC.run('Refreshing cloud environments...', function () { return bridge.refreshEnvironments(); });
|
||||||
|
case 'refresh-tab':
|
||||||
|
return CC.run('Refreshing tab...', function () { return bridge.refreshActiveTab(); });
|
||||||
|
case 'env-action':
|
||||||
|
return CC.run('Opening environment...', function () { return bridge.runActiveEnvironmentAction(node.getAttribute('data-cc-env-action')); });
|
||||||
|
case 'env-menu':
|
||||||
|
return CC.run('Opening environment actions...', function () { return bridge.showActiveEnvironmentActionsMenu(); });
|
||||||
|
case 'env-row-menu':
|
||||||
|
return CC.run('Opening environment actions...', function () { return bridge.showEnvironmentActionsMenu(node.getAttribute('data-cc-environment-id')); });
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderTabs(state) {
|
||||||
|
var tabs = state.tabs && state.tabs.length ? state.tabs : [{ id: 'home', title: 'Home', closable: false, active: true }];
|
||||||
|
return tabs.map(function (tab) {
|
||||||
|
var title = tab.title || '';
|
||||||
|
var visibleChars = Math.min(title.length, 20);
|
||||||
|
var tabWidth = Math.max(112, Math.min(232, (visibleChars * 8) + (tab.closable ? 56 : 38)));
|
||||||
|
return '<button class="tb-tab no-drag' + (tab.active ? ' active' : '') + '" data-cc-tab="' + esc(tab.id) + '" title="' + esc(title) + '" style="width:' + tabWidth + 'px;flex-basis:' + tabWidth + 'px">' +
|
||||||
|
'<span>' + esc(title) + '</span>' +
|
||||||
|
(tab.closable ? '<span class="tb-close" data-cc-close-tab="' + esc(tab.id) + '" title="Close tab">×</span>' : '') +
|
||||||
|
'</button>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
CC.titlebar = function (state) {
|
||||||
|
var conn = connected(state);
|
||||||
|
var activeTab = (state.tabs || []).filter(function (tab) { return tab.active; })[0] || null;
|
||||||
|
var activeEnvironmentId = state.activeTarget && state.activeTarget.kind === 'remote' ? state.activeTarget.id : null;
|
||||||
|
if (!activeEnvironmentId && activeTab && /^remote:/.test(activeTab.id || '')) {
|
||||||
|
activeEnvironmentId = activeTab.id.replace(/^remote:/, '');
|
||||||
|
}
|
||||||
|
var activeRefreshable = (state.activeTarget && (state.activeTarget.kind === 'remote' || state.activeTarget.kind === 'local')) ||
|
||||||
|
(activeTab && activeTab.id !== 'home');
|
||||||
|
var envActions = activeEnvironmentId ? '<button class="btn sm tb-action no-drag" data-cc-action="env-row-menu" data-cc-environment-id="' + esc(activeEnvironmentId) + '" title="Open environment actions">Open environment in...</button>' : '';
|
||||||
|
var refreshAction = activeRefreshable ? '<button class="icon-btn tb-action no-drag" data-cc-action="refresh-tab" title="Refresh tab">' + icon('refresh', 16) + '</button>' : '';
|
||||||
|
var logoutAction = (conn || authState(state) === 'expired') ? '<button class="icon-btn tb-action no-drag" data-cc-action="logout" title="Logout">' + icon('logOut', 16) + '</button>' : '';
|
||||||
|
return '<div class="titlebar">' +
|
||||||
|
'<div class="brand"><img class="mk" src="' + esc(LOGO_URL) + '" alt=""><span>CloudCLI</span></div>' +
|
||||||
|
'<div class="tb-tabs no-drag">' + renderTabs(state) + '</div>' +
|
||||||
|
'<span style="flex:1"></span>' +
|
||||||
|
refreshAction +
|
||||||
|
envActions +
|
||||||
|
'<button class="btn sm tb-action no-drag" data-cc-action="connect" title="' + esc(authState(state) === 'expired' ? 'Reconnect your CloudCLI account' : accountLabel(state)) + '"><span class="dot" style="background:' + (conn ? 'var(--ok)' : (authState(state) === 'expired' ? 'var(--warn)' : 'var(--tx3)')) + '"></span>' + esc(accountLabel(state)) + '</button>' +
|
||||||
|
logoutAction +
|
||||||
|
'<button class="icon-btn tb-action no-drag" data-cc-action="settings-toggle" title="Settings">' + icon('settings', 16) + '</button>' +
|
||||||
|
'</div>';
|
||||||
|
};
|
||||||
|
|
||||||
|
CC.statusbar = function (state) {
|
||||||
|
var status = CC._status || {};
|
||||||
|
var running = !!state.localServerRunning;
|
||||||
|
return '<div class="statusbar">' +
|
||||||
|
'<span><span class="dot" style="width:7px;height:7px;background:' + (running ? 'var(--ok)' : 'var(--tx3)') + '"></span> local ' + (running ? 'running · ' + esc(localUrl(state)) : 'idle') + '</span>' +
|
||||||
|
'<span class="sep">·</span><span>' + esc(envCount(state)) + '</span>' +
|
||||||
|
'<span class="sep">·</span><span>' + (authState(state) === 'expired' ? 'session expired' : (connected(state) ? esc(accountLabel(state)) : 'not connected')) + '</span>' +
|
||||||
|
'<span style="flex:1"></span>' +
|
||||||
|
(status.msg ? '<span class="status-msg ' + esc(status.tone) + '">' + esc(status.msg) + '</span><span class="sep">·</span>' : '') +
|
||||||
|
'<span>v' + esc(VERSION) + '</span>' +
|
||||||
|
'</div>';
|
||||||
|
};
|
||||||
|
|
||||||
|
CC.renderSheet = function (title, subtitle, sections, footer) {
|
||||||
|
overlay.innerHTML =
|
||||||
|
'<div class="cc-sheet cc-modal">' +
|
||||||
|
'<div class="cc-sheet-header">' +
|
||||||
|
'<div class="cc-sheet-copy"><div class="cc-sheet-title">' + esc(title) + '</div><div class="cc-sheet-subtitle">' + esc(subtitle || '') + '</div></div>' +
|
||||||
|
'<button class="icon-btn cc-sheet-close" data-cc-action="settings-close" title="Close">' + icon('x', 16) + '</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="cc-sheet-body">' + sections.join('') + '</div>' +
|
||||||
|
(footer ? '<div class="cc-sheet-footer">' + footer + '</div>' : '') +
|
||||||
|
'</div>';
|
||||||
|
};
|
||||||
|
|
||||||
|
CC.renderSection = function (eyebrow, title, body) {
|
||||||
|
return '<section class="cc-section">' +
|
||||||
|
'<div class="cc-section-head">' +
|
||||||
|
'<div class="lbl">' + esc(eyebrow) + '</div>' +
|
||||||
|
'<div class="cc-section-title">' + esc(title) + '</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="cc-section-body">' + body + '</div>' +
|
||||||
|
'</section>';
|
||||||
|
};
|
||||||
|
|
||||||
|
CC.renderRadioOption = function (name, value, checked, title, description) {
|
||||||
|
return '<label class="cc-choice">' +
|
||||||
|
'<input type="radio" name="' + esc(name) + '" value="' + esc(value) + '"' + (checked ? ' checked' : '') + '>' +
|
||||||
|
'<span><b>' + esc(title) + '</b><br>' + esc(description) + '</span>' +
|
||||||
|
'</label>';
|
||||||
|
};
|
||||||
|
|
||||||
|
CC.openSheet = function (sheet, options) {
|
||||||
|
options = options || {};
|
||||||
|
if (sheet === 'desktop-settings') {
|
||||||
|
CC.renderDesktopSettings();
|
||||||
|
} else {
|
||||||
|
CC.renderLocalSettings();
|
||||||
|
}
|
||||||
|
CC.ui.openSheet = sheet;
|
||||||
|
overlay.classList.add('open');
|
||||||
|
if (typeof options.scrollTop === 'number') {
|
||||||
|
var body = overlay.querySelector('.cc-sheet-body');
|
||||||
|
if (body) body.scrollTop = options.scrollTop;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
CC.closeSheet = function () {
|
||||||
|
if (CC.modalMode && bridge.closeSettingsWindow) {
|
||||||
|
CC.ui.openSheet = null;
|
||||||
|
return bridge.closeSettingsWindow();
|
||||||
|
}
|
||||||
|
CC.ui.openSheet = null;
|
||||||
|
overlay.classList.remove('open');
|
||||||
|
};
|
||||||
|
|
||||||
|
CC.buildLocalServerSection = function (state, options) {
|
||||||
|
options = options || {};
|
||||||
|
var settings = state.desktopSettings || {};
|
||||||
|
var url = localUrl(state) || 'starts on demand';
|
||||||
|
var body = '<div class="cc-surface">' +
|
||||||
|
'<div class="cc-meta mono">' + esc(url) + '</div>' +
|
||||||
|
'<div class="cc-row2"><button class="btn sm" data-cc-action="open-web">' + icon('arrow', 14) + 'Open in browser</button><button class="btn sm" data-cc-action="copy-web">' + icon('copy', 14) + 'Copy URL</button></div>';
|
||||||
|
if (options.includePreferences) {
|
||||||
|
body +=
|
||||||
|
'<label class="cc-toggle"><input type="checkbox" data-cc-setting="keepLocalServerRunning"' + (settings.keepLocalServerRunning ? ' checked' : '') + '><span><b>Keep server running</b><br>Leave Local CloudCLI available after you quit the app.</span></label>' +
|
||||||
|
'<label class="cc-toggle"><input type="checkbox" data-cc-setting="exposeLocalServerOnNetwork"' + (settings.exposeLocalServerOnNetwork ? ' checked' : '') + '><span><b>Allow LAN access</b><br>Use the copied URL from another device on this network.</span></label>';
|
||||||
|
}
|
||||||
|
body += '</div>';
|
||||||
|
return CC.renderSection(
|
||||||
|
options.eyebrow || 'LOCAL SERVER',
|
||||||
|
options.title || 'Run Local CloudCLI on this machine',
|
||||||
|
body
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
CC.buildThemeSection = function (state) {
|
||||||
|
var settings = state.desktopSettings || {};
|
||||||
|
return CC.renderSection('APPEARANCE', 'Desktop theme', '' +
|
||||||
|
'<div class="cc-surface cc-choice-group">' +
|
||||||
|
CC.renderRadioOption('desktop-theme', 'system', settings.themeMode === 'system', 'System', 'Follow the operating system appearance.') +
|
||||||
|
CC.renderRadioOption('desktop-theme', 'light', settings.themeMode === 'light', 'Light', 'Use the light interface appearance.') +
|
||||||
|
CC.renderRadioOption('desktop-theme', 'dark', settings.themeMode === 'dark', 'Dark', 'Use the dark interface appearance.') +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
CC.renderLocalSettings = function () {
|
||||||
|
var state = CC.state || {};
|
||||||
|
var sections = [
|
||||||
|
CC.buildLocalServerSection(state, { includePreferences: false }),
|
||||||
|
CC.renderSection('PREFERENCES', 'How the local service behaves', '' +
|
||||||
|
'<div class="cc-surface">' +
|
||||||
|
'<label class="cc-toggle"><input type="checkbox" data-cc-setting="keepLocalServerRunning"' + ((state.desktopSettings || {}).keepLocalServerRunning ? ' checked' : '') + '><span><b>Keep server running</b><br>Leave Local CloudCLI available after you quit the app.</span></label>' +
|
||||||
|
'<label class="cc-toggle"><input type="checkbox" data-cc-setting="exposeLocalServerOnNetwork"' + ((state.desktopSettings || {}).exposeLocalServerOnNetwork ? ' checked' : '') + '><span><b>Allow LAN access</b><br>Use the copied URL from another device on this network.</span></label>' +
|
||||||
|
'</div>'
|
||||||
|
),
|
||||||
|
];
|
||||||
|
CC.renderSheet('Local Settings', 'Manage how Local CloudCLI runs on this computer.', sections);
|
||||||
|
};
|
||||||
|
|
||||||
|
CC.renderDesktopSettings = function () {
|
||||||
|
var sections = [
|
||||||
|
CC.buildThemeSection(CC.state || {}),
|
||||||
|
];
|
||||||
|
CC.renderSheet('Desktop Settings', 'Manage the desktop app appearance.', sections);
|
||||||
|
};
|
||||||
|
|
||||||
|
CC.render = function (state) {
|
||||||
|
state = state || CC.state;
|
||||||
|
var titlebar = (CC._reg.titlebar || CC.titlebar)(state);
|
||||||
|
var statusbar = (CC._reg.statusbar || CC.statusbar)(state);
|
||||||
|
var body = CC._reg.renderBody ? CC._reg.renderBody(state) : '';
|
||||||
|
if (CC.modalMode) {
|
||||||
|
app.innerHTML = '';
|
||||||
|
} else {
|
||||||
|
app.innerHTML = titlebar + '<div class="cc-body ' + (CC._reg.bodyClass || '') + '">' + body + '</div>' + statusbar;
|
||||||
|
}
|
||||||
|
if (CC._reg.afterRender) CC._reg.afterRender(state);
|
||||||
|
};
|
||||||
|
|
||||||
|
function wireEvents() {
|
||||||
|
if (CC._wired) return;
|
||||||
|
CC._wired = true;
|
||||||
|
|
||||||
|
document.addEventListener('click', function (event) {
|
||||||
|
if (CC._reg.onClick && CC._reg.onClick(event)) return;
|
||||||
|
var closeTab = event.target.closest('[data-cc-close-tab]');
|
||||||
|
if (closeTab) {
|
||||||
|
event.stopPropagation();
|
||||||
|
CC.run('Closing tab...', function () { return bridge.closeTab(closeTab.getAttribute('data-cc-close-tab')); });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var tab = event.target.closest('[data-cc-tab]');
|
||||||
|
if (tab) {
|
||||||
|
CC.run('Switching tab...', function () { return bridge.switchTab(tab.getAttribute('data-cc-tab')); });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var action = event.target.closest('[data-cc-action]');
|
||||||
|
if (action) {
|
||||||
|
CC.act(action.getAttribute('data-cc-action'), action);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var env = event.target.closest('[data-cc-env]');
|
||||||
|
if (env) {
|
||||||
|
CC.openEnv(env.getAttribute('data-cc-env'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (overlay.classList.contains('open') && !event.target.closest('.cc-sheet')) {
|
||||||
|
CC.closeSheet();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('change', function (event) {
|
||||||
|
var setting = event.target.closest('[data-cc-setting]');
|
||||||
|
if (setting) {
|
||||||
|
CC.act('set-setting', {
|
||||||
|
key: setting.getAttribute('data-cc-setting'),
|
||||||
|
value: setting.checked,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var theme = event.target.closest('[name="desktop-theme"]');
|
||||||
|
if (theme) {
|
||||||
|
CC.act('set-theme-mode', { value: theme.value });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (event) {
|
||||||
|
if (event.key === 'Escape' && overlay.classList.contains('open')) {
|
||||||
|
CC.closeSheet();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((event.metaKey || event.ctrlKey) && event.key === ',') {
|
||||||
|
event.preventDefault();
|
||||||
|
CC.act('settings-toggle');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (overlay.classList.contains('open')) return;
|
||||||
|
if (CC._reg.onKey) CC._reg.onKey(event, CC.state);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function boot() {
|
||||||
|
app = document.getElementById('app');
|
||||||
|
overlay = document.createElement('div');
|
||||||
|
overlay.id = 'cc-overlay';
|
||||||
|
overlay.className = 'cc-overlay';
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
var isMac = /Mac/i.test(navigator.platform) || /Mac OS X/i.test(navigator.userAgent);
|
||||||
|
var isWin = /Win/i.test(navigator.platform);
|
||||||
|
CC.platform = isMac ? 'mac' : (isWin ? 'win' : 'linux');
|
||||||
|
document.body.classList.add(CC.platform);
|
||||||
|
CC.ui.initialSheet = SEARCH.get('sheet') || 'desktop-settings';
|
||||||
|
if (CC.modalMode) {
|
||||||
|
document.documentElement.classList.add('cc-modal-window');
|
||||||
|
document.body.classList.add('cc-modal-window');
|
||||||
|
}
|
||||||
|
|
||||||
|
wireEvents();
|
||||||
|
if (window.matchMedia) {
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () {
|
||||||
|
CC.applyTheme(CC.state);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (bridge.onStateUpdated) {
|
||||||
|
bridge.onStateUpdated(function (state) { CC.setState(state); });
|
||||||
|
}
|
||||||
|
if (bridge.onLauncherCommand) {
|
||||||
|
bridge.onLauncherCommand(function (command) {
|
||||||
|
if (command && command.type === 'open-sheet') {
|
||||||
|
CC.ui.initialSheet = command.sheet || CC.ui.initialSheet || 'desktop-settings';
|
||||||
|
CC.openSheet(command.sheet);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
CC.refresh().catch(function (error) {
|
||||||
|
CC._status = { msg: errMsg(error), tone: 'error' };
|
||||||
|
CC.render(CC.state);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
CC.register = function (registry) {
|
||||||
|
CC._reg = registry || {};
|
||||||
|
};
|
||||||
|
|
||||||
|
CC.start = function () {
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', boot);
|
||||||
|
} else {
|
||||||
|
boot();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
(function sidebarApp() {
|
||||||
|
var CC = window.CC;
|
||||||
|
|
||||||
|
function navItem(id, iconName, label, meta, selected) {
|
||||||
|
return '<button class="sb-item' + (selected === id ? ' active' : '') + '" data-cc-nav="' + id + '">' +
|
||||||
|
CC.icon(iconName, 16) + '<span>' + label + '</span><span class="sb-meta">' + CC.esc(meta) + '</span></button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function localPane(state) {
|
||||||
|
return '<div class="pane-h"><div><h2 class="pane-title">Local servers</h2><p class="pane-sub">Manage Local CloudCLI on this machine. No account required.</p></div></div>' +
|
||||||
|
'<div class="card"><div class="card-head"><div><div class="card-t">Local server</div><div class="card-sub mono">' + CC.esc(CC.localUrl(state) || 'Starts on demand') + '</div></div><div class="card-tools"><span class="dot" style="background:' + (state.localServerRunning ? 'var(--ok)' : 'var(--tx3)') + '"></span><button class="icon-btn" data-cc-action="local-settings-toggle" title="Local settings">' + CC.icon('gear', 16) + '</button></div></div>' +
|
||||||
|
'<div class="card-actions"><button class="btn pri" data-cc-action="local">' + CC.icon('play', 15) + 'Open Local CloudCLI</button><button class="btn" data-cc-action="open-web">' + CC.icon('arrow', 14) + 'Open in browser</button><button class="btn" data-cc-action="copy-web">' + CC.icon('copy', 14) + 'Copy URL</button></div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function envRow(environment) {
|
||||||
|
var meta = CC.statusMeta(environment.status);
|
||||||
|
var tags = (environment.agent ? '<span class="tag">' + CC.esc(environment.agent) + '</span>' : '') + (environment.region ? '<span class="tag">' + CC.esc(environment.region) + '</span>' : '');
|
||||||
|
return '<div class="env" data-cc-env="' + environment.id + '"><span class="dot" style="background:' + meta.dot + '"></span>' +
|
||||||
|
'<div class="env-i"><div class="env-n">' + CC.esc(environment.name || environment.subdomain) + '</div><div class="env-u mono">' + CC.esc(environment.access_url || '') + '</div></div>' +
|
||||||
|
'<div class="env-tags">' + tags + '</div>' +
|
||||||
|
'<span class="badge ' + meta.cls + '">' + meta.label + '</span>' +
|
||||||
|
'<button class="btn sm" data-cc-action="env-row-menu" data-cc-environment-id="' + environment.id + '">Open environment in...</button>' +
|
||||||
|
'<button class="btn sm ' + (environment.status === 'running' ? 'pri' : '') + '">' + CC.icon(meta.busy ? 'refresh' : (environment.status === 'running' ? 'arrow' : 'play'), 14) + meta.open + '</button></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloudPane(state) {
|
||||||
|
var header = '<div class="pane-h"><div><h2 class="pane-title">Environments</h2><p class="pane-sub">' + CC.esc(CC.envCount(state)) + '</p></div><button class="btn sm" data-cc-action="dashboard">' + CC.icon('arrow', 14) + 'Dashboard</button></div>';
|
||||||
|
if (CC.authState(state) === 'expired') {
|
||||||
|
return header + '<div class="empty">Your CloudCLI session expired.<div style="margin-top:14px"><button class="btn pri" data-cc-action="connect">' + CC.icon('cloudPlus', 15) + 'Reconnect account</button></div></div>';
|
||||||
|
}
|
||||||
|
if (!CC.connected(state)) {
|
||||||
|
return header + '<div class="empty">Connect your CloudCLI account to list hosted environments.<div style="margin-top:14px"><button class="btn pri" data-cc-action="connect">' + CC.icon('cloudPlus', 15) + 'Connect account</button></div></div>';
|
||||||
|
}
|
||||||
|
if (state.cloudLoading && !(state.environments || []).length) {
|
||||||
|
return header + '<div class="empty">Loading your CloudCLI environments...</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
var list = (state.environments || []).map(envRow).join('');
|
||||||
|
if (!list) list = '<div class="empty">No hosted environments yet.</div>';
|
||||||
|
return header + list;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBody(state) {
|
||||||
|
var section = CC.ui.section || ((CC.connected(state) || CC.authState(state) === 'expired') ? 'cloud' : 'local');
|
||||||
|
CC.ui.section = section;
|
||||||
|
var nav = '<div class="sb"><div class="sb-grp"><div class="lbl">Launcher</div>' +
|
||||||
|
navItem('local', 'terminal', 'Local servers', state.localServerRunning ? 'on' : 'idle', section) +
|
||||||
|
navItem('cloud', 'cloud', 'Cloud environments', (state.environments || []).length, section) +
|
||||||
|
'</div></div>';
|
||||||
|
return nav + '<div class="sb-main">' + (section === 'local' ? localPane(state) : cloudPane(state)) + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClick(event) {
|
||||||
|
var nav = event.target.closest('[data-cc-nav]');
|
||||||
|
if (!nav) return false;
|
||||||
|
CC.ui.section = nav.getAttribute('data-cc-nav');
|
||||||
|
CC.render(CC.state);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
CC.register({
|
||||||
|
bodyClass: 'v-sidebar',
|
||||||
|
renderBody: renderBody,
|
||||||
|
onClick: onClick,
|
||||||
|
});
|
||||||
|
CC.start();
|
||||||
|
})();
|
||||||
550
electron/localServer.js
Normal file
550
electron/localServer.js
Normal file
@@ -0,0 +1,550 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import http from 'node:http';
|
||||||
|
import net from 'node:net';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { ServerInstaller } from './serverInstaller.js';
|
||||||
|
|
||||||
|
const DEFAULT_PORT = 3001;
|
||||||
|
const HOST = '127.0.0.1';
|
||||||
|
const DISPLAY_HOST = 'localhost';
|
||||||
|
const HEALTH_TIMEOUT_MS = 1000;
|
||||||
|
const SERVER_START_TIMEOUT_MS = 30000;
|
||||||
|
const MAX_STARTUP_LOG_LINES = 300;
|
||||||
|
const SERVER_MARKER_PATH = path.join(os.homedir(), '.cloudcli', 'local-server.json');
|
||||||
|
const LOCAL_SERVER_URL_ENV_KEYS = [
|
||||||
|
'CLOUDCLI_DESKTOP_LOCAL_SERVER_URL',
|
||||||
|
'CLOUDCLI_LOCAL_SERVER_URL',
|
||||||
|
'ELECTRON_LOCAL_SERVER_URL',
|
||||||
|
];
|
||||||
|
const LOCAL_SERVER_PORT_ENV_KEYS = [
|
||||||
|
'CLOUDCLI_DESKTOP_LOCAL_SERVER_PORT',
|
||||||
|
'CLOUDCLI_SERVER_PORT',
|
||||||
|
'SERVER_PORT',
|
||||||
|
'PORT',
|
||||||
|
];
|
||||||
|
|
||||||
|
function requestJson(url, timeoutMs = HEALTH_TIMEOUT_MS) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const req = http.get(url, { timeout: timeoutMs }, (res) => {
|
||||||
|
let body = '';
|
||||||
|
|
||||||
|
res.setEncoding('utf8');
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
body += chunk;
|
||||||
|
});
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
resolve({
|
||||||
|
ok: res.statusCode >= 200 && res.statusCode < 300,
|
||||||
|
json: JSON.parse(body),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
resolve({ ok: false, json: null });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('timeout', () => {
|
||||||
|
req.destroy();
|
||||||
|
resolve({ ok: false, json: null });
|
||||||
|
});
|
||||||
|
req.on('error', () => resolve({ ok: false, json: null }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isCloudCliServer(baseUrl) {
|
||||||
|
const response = await requestJson(`${baseUrl}/health`);
|
||||||
|
return response.ok
|
||||||
|
&& response.json?.status === 'ok'
|
||||||
|
&& typeof response.json?.installMode === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPortAvailable(port, host = HOST) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
|
||||||
|
server.once('error', () => resolve(false));
|
||||||
|
server.once('listening', () => {
|
||||||
|
server.close(() => resolve(true));
|
||||||
|
});
|
||||||
|
server.listen(port, host);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFreePort() {
|
||||||
|
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 : DEFAULT_PORT;
|
||||||
|
server.close(() => resolve(port));
|
||||||
|
});
|
||||||
|
server.listen(0, HOST);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseServerPort(host) {
|
||||||
|
if (await isPortAvailable(DEFAULT_PORT, host)) {
|
||||||
|
return DEFAULT_PORT;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getFreePort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDesktopPath() {
|
||||||
|
const currentPath = process.env.PATH || '';
|
||||||
|
const commonPaths = process.platform === 'win32'
|
||||||
|
? []
|
||||||
|
: ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin'];
|
||||||
|
|
||||||
|
return [...commonPaths, currentPath].filter(Boolean).join(path.delimiter);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodeRuntime(usePackagedElectronRuntime) {
|
||||||
|
if (process.env.ELECTRON_NODE_PATH) {
|
||||||
|
return { command: process.env.ELECTRON_NODE_PATH, env: {}, label: 'ELECTRON_NODE_PATH' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usePackagedElectronRuntime && process.versions.electron) {
|
||||||
|
return {
|
||||||
|
command: process.execPath,
|
||||||
|
env: { ELECTRON_RUN_AS_NODE: '1' },
|
||||||
|
label: `Electron ${process.versions.electron} Node ${process.versions.node}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.npm_node_execpath) {
|
||||||
|
return { command: process.env.npm_node_execpath, env: {}, label: 'npm_node_execpath' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { command: 'node', env: {}, label: 'PATH node' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripTrailingSlash(value) {
|
||||||
|
return value.endsWith('/') ? value.slice(0, -1) : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCandidateUrl(urls, rawUrl) {
|
||||||
|
if (!rawUrl) return;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(String(rawUrl));
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return;
|
||||||
|
parsed.hash = '';
|
||||||
|
parsed.search = '';
|
||||||
|
const normalized = stripTrailingSlash(parsed.toString());
|
||||||
|
if (!urls.includes(normalized)) urls.push(normalized);
|
||||||
|
} catch {
|
||||||
|
// Ignore invalid user-provided discovery values.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCandidatePort(urls, rawPort) {
|
||||||
|
const port = Number.parseInt(String(rawPort || ''), 10);
|
||||||
|
if (!Number.isInteger(port) || port < 1 || port > 65535) return;
|
||||||
|
addCandidateUrl(urls, `http://${HOST}:${port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPortFromUrl(baseUrl) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(baseUrl);
|
||||||
|
if (parsed.port) return Number.parseInt(parsed.port, 10);
|
||||||
|
return parsed.protocol === 'https:' ? 443 : 80;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDisplayUrl(baseUrl) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(baseUrl);
|
||||||
|
if (parsed.hostname === HOST) {
|
||||||
|
parsed.hostname = DISPLAY_HOST;
|
||||||
|
}
|
||||||
|
return stripTrailingSlash(parsed.toString());
|
||||||
|
} catch {
|
||||||
|
return baseUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pathExists(filePath) {
|
||||||
|
try {
|
||||||
|
await fs.access(filePath);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readServerBundleConfig(appRoot) {
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(path.join(appRoot, 'electron', 'server-bundle-config.json'), 'utf8');
|
||||||
|
const config = JSON.parse(raw);
|
||||||
|
return {
|
||||||
|
releaseTag: typeof config.releaseTag === 'string' && config.releaseTag.trim()
|
||||||
|
? config.releaseTag.trim()
|
||||||
|
: '',
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { releaseTag: '' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerCwd(appRoot, serverEntry) {
|
||||||
|
const normalizedEntry = path.resolve(serverEntry);
|
||||||
|
const bundledEntry = path.resolve(appRoot, 'dist-server', 'server', 'index.js');
|
||||||
|
if (normalizedEntry === bundledEntry) {
|
||||||
|
return appRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Installed server entries are laid out as <root>/dist-server/server/index.js.
|
||||||
|
return path.resolve(path.dirname(normalizedEntry), '..', '..');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readServerMarkerUrl() {
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(SERVER_MARKER_PATH, 'utf8');
|
||||||
|
const marker = JSON.parse(raw);
|
||||||
|
return marker.url || (marker.port ? `http://${marker.host || HOST}:${marker.port}` : null);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getExistingServerCandidateUrls(defaultUrl) {
|
||||||
|
const urls = [];
|
||||||
|
|
||||||
|
for (const key of LOCAL_SERVER_URL_ENV_KEYS) {
|
||||||
|
addCandidateUrl(urls, process.env[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
addCandidateUrl(urls, await readServerMarkerUrl());
|
||||||
|
|
||||||
|
for (const key of LOCAL_SERVER_PORT_ENV_KEYS) {
|
||||||
|
addCandidatePort(urls, process.env[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
addCandidateUrl(urls, defaultUrl);
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForCloudCliServer(baseUrl, timeoutMs) {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
while (Date.now() - startedAt < timeoutMs) {
|
||||||
|
if (await isCloudCliServer(baseUrl)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LocalServerController {
|
||||||
|
constructor({ appRoot, settingsPath, isPackaged = false, appVersion, onChange }) {
|
||||||
|
this.appRoot = appRoot;
|
||||||
|
this.settingsPath = settingsPath;
|
||||||
|
this.isPackaged = isPackaged;
|
||||||
|
this.appVersion = appVersion;
|
||||||
|
this.onChange = onChange;
|
||||||
|
this.localServerUrl = null;
|
||||||
|
this.localServerPort = null;
|
||||||
|
this.ownedServerProcess = null;
|
||||||
|
this.startupLogs = [];
|
||||||
|
this.desktopSettings = {
|
||||||
|
keepLocalServerRunning: false,
|
||||||
|
exposeLocalServerOnNetwork: false,
|
||||||
|
themeMode: 'system',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getSettings() {
|
||||||
|
return this.desktopSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
getLocalServerUrl() {
|
||||||
|
return this.localServerUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
getHealthCheckUrl() {
|
||||||
|
if (!this.localServerPort) return this.localServerUrl;
|
||||||
|
return `http://${HOST}:${this.localServerPort}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
appendStartupLog(line) {
|
||||||
|
const text = String(line || '').trimEnd();
|
||||||
|
if (!text) return;
|
||||||
|
const timestamp = new Date().toLocaleTimeString();
|
||||||
|
this.startupLogs.push(`[${timestamp}] ${text}`);
|
||||||
|
if (this.startupLogs.length > MAX_STARTUP_LOG_LINES) {
|
||||||
|
this.startupLogs.splice(0, this.startupLogs.length - MAX_STARTUP_LOG_LINES);
|
||||||
|
}
|
||||||
|
this.onChange?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
getStartupLogs() {
|
||||||
|
return [...this.startupLogs];
|
||||||
|
}
|
||||||
|
|
||||||
|
getPendingTarget() {
|
||||||
|
return {
|
||||||
|
kind: 'local',
|
||||||
|
name: 'Local CloudCLI',
|
||||||
|
url: this.localServerUrl || `http://${DISPLAY_HOST}:${this.localServerPort || DEFAULT_PORT}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getLanAddress() {
|
||||||
|
const interfaces = os.networkInterfaces();
|
||||||
|
for (const entries of Object.values(interfaces)) {
|
||||||
|
for (const entry of entries || []) {
|
||||||
|
if (entry.family === 'IPv4' && !entry.internal) {
|
||||||
|
return entry.address;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getShareableWebUrl() {
|
||||||
|
if (!this.localServerUrl || !this.localServerPort) return null;
|
||||||
|
if (this.desktopSettings.exposeLocalServerOnNetwork) {
|
||||||
|
const lanAddress = this.getLanAddress();
|
||||||
|
if (lanAddress) {
|
||||||
|
return `http://${lanAddress}:${this.localServerPort}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.getLocalServerUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
getServerBindHost() {
|
||||||
|
return this.desktopSettings.exposeLocalServerOnNetwork ? '0.0.0.0' : HOST;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadDesktopSettings() {
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(this.settingsPath, 'utf8');
|
||||||
|
const stored = JSON.parse(raw);
|
||||||
|
this.desktopSettings = {
|
||||||
|
keepLocalServerRunning: Boolean(stored.keepLocalServerRunning),
|
||||||
|
exposeLocalServerOnNetwork: Boolean(stored.exposeLocalServerOnNetwork),
|
||||||
|
themeMode: stored.themeMode === 'light' || stored.themeMode === 'dark' ? stored.themeMode : 'system',
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
this.desktopSettings = {
|
||||||
|
keepLocalServerRunning: false,
|
||||||
|
exposeLocalServerOnNetwork: false,
|
||||||
|
themeMode: 'system',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveDesktopSettings(nextSettings = this.desktopSettings) {
|
||||||
|
this.desktopSettings = {
|
||||||
|
keepLocalServerRunning: Boolean(nextSettings.keepLocalServerRunning),
|
||||||
|
exposeLocalServerOnNetwork: Boolean(nextSettings.exposeLocalServerOnNetwork),
|
||||||
|
themeMode: nextSettings.themeMode === 'light' || nextSettings.themeMode === 'dark' ? nextSettings.themeMode : 'system',
|
||||||
|
};
|
||||||
|
await fs.mkdir(path.dirname(this.settingsPath), { recursive: true });
|
||||||
|
await fs.writeFile(this.settingsPath, JSON.stringify(this.desktopSettings, null, 2), 'utf8');
|
||||||
|
this.onChange?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateDesktopSetting(key, value) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(this.desktopSettings, key)) {
|
||||||
|
throw new Error(`Unknown desktop setting: ${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wasExposeSetting = key === 'exposeLocalServerOnNetwork';
|
||||||
|
const wasLocalRunning = Boolean(this.localServerUrl);
|
||||||
|
const nextValue = key === 'themeMode' ? value : Boolean(value);
|
||||||
|
await this.saveDesktopSettings({ ...this.desktopSettings, [key]: nextValue });
|
||||||
|
|
||||||
|
return {
|
||||||
|
desktopSettings: this.desktopSettings,
|
||||||
|
requiresRestartNotice: wasExposeSetting && wasLocalRunning,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves the local server entry, installing the matching runtime if needed. */
|
||||||
|
async resolveServerEntry() {
|
||||||
|
if (process.env.ELECTRON_SERVER_ENTRY) {
|
||||||
|
return process.env.ELECTRON_SERVER_ENTRY;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundledEntry = path.join(this.appRoot, 'dist-server', 'server', 'index.js');
|
||||||
|
if (process.env.CLOUDCLI_USE_INSTALLED_SERVER !== '1' && await pathExists(bundledEntry)) {
|
||||||
|
return bundledEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.appVersion) {
|
||||||
|
throw new Error('Cannot install local server: app version is unknown.');
|
||||||
|
}
|
||||||
|
const bundleConfig = await readServerBundleConfig(this.appRoot);
|
||||||
|
const installer = new ServerInstaller({
|
||||||
|
version: this.appVersion,
|
||||||
|
bundleReleaseTag: bundleConfig.releaseTag,
|
||||||
|
onLog: (line) => this.appendStartupLog(line),
|
||||||
|
});
|
||||||
|
return installer.ensureInstalled();
|
||||||
|
}
|
||||||
|
|
||||||
|
startBundledServer(port, serverEntry) {
|
||||||
|
const bindHost = this.getServerBindHost();
|
||||||
|
const runtime = getNodeRuntime(this.isPackaged);
|
||||||
|
const serverCwd = getServerCwd(this.appRoot, serverEntry);
|
||||||
|
|
||||||
|
const command = `${runtime.command} ${serverEntry}`;
|
||||||
|
this.appendStartupLog(`$ ${command}`);
|
||||||
|
this.appendStartupLog(`runtime: ${runtime.label}`);
|
||||||
|
this.appendStartupLog(`cwd: ${serverCwd}`);
|
||||||
|
this.appendStartupLog(`HOST=${bindHost} SERVER_PORT=${port} NODE_ENV=production`);
|
||||||
|
|
||||||
|
this.ownedServerProcess = spawn(runtime.command, [serverEntry], {
|
||||||
|
cwd: serverCwd,
|
||||||
|
detached: true,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
...runtime.env,
|
||||||
|
HOST: bindHost,
|
||||||
|
SERVER_PORT: String(port),
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
PATH: getDesktopPath(),
|
||||||
|
},
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ownedServerProcess.once('error', (error) => {
|
||||||
|
this.appendStartupLog(`failed to start process: ${error.message}`);
|
||||||
|
this.ownedServerProcess = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ownedServerProcess.stdout?.on('data', (chunk) => {
|
||||||
|
for (const line of String(chunk).split(/\r?\n/)) {
|
||||||
|
this.appendStartupLog(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ownedServerProcess.stderr?.on('data', (chunk) => {
|
||||||
|
for (const line of String(chunk).split(/\r?\n/)) {
|
||||||
|
this.appendStartupLog(`stderr: ${line}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ownedServerProcess.once('exit', (code, signal) => {
|
||||||
|
this.appendStartupLog(`process exited with code ${code ?? 'null'} and signal ${signal ?? 'null'}`);
|
||||||
|
if (this.ownedServerProcess) {
|
||||||
|
console.error(`CloudCLI desktop server exited with code ${code ?? 'null'} and signal ${signal ?? 'null'}`);
|
||||||
|
}
|
||||||
|
this.ownedServerProcess = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveLocalServerUrl() {
|
||||||
|
const defaultUrl = `http://${HOST}:${DEFAULT_PORT}`;
|
||||||
|
const defaultDisplayUrl = `http://${DISPLAY_HOST}:${DEFAULT_PORT}`;
|
||||||
|
const devUrl = process.env.ELECTRON_DEV_URL;
|
||||||
|
const forceOwnServer = process.env.ELECTRON_FORCE_OWN_SERVER === '1';
|
||||||
|
|
||||||
|
if (devUrl) {
|
||||||
|
const ready = await waitForCloudCliServer(defaultUrl, SERVER_START_TIMEOUT_MS);
|
||||||
|
if (!ready) {
|
||||||
|
throw new Error(`Development backend did not become ready at ${defaultDisplayUrl}`);
|
||||||
|
}
|
||||||
|
this.localServerPort = DEFAULT_PORT;
|
||||||
|
return devUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!forceOwnServer) {
|
||||||
|
const candidateUrls = await getExistingServerCandidateUrls(defaultUrl);
|
||||||
|
for (const candidateUrl of candidateUrls) {
|
||||||
|
if (await isCloudCliServer(candidateUrl)) {
|
||||||
|
const displayUrl = getDisplayUrl(candidateUrl);
|
||||||
|
this.localServerPort = getPortFromUrl(candidateUrl);
|
||||||
|
this.appendStartupLog(`Using existing Local CloudCLI at ${displayUrl}`);
|
||||||
|
return displayUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverEntry = await this.resolveServerEntry();
|
||||||
|
|
||||||
|
const port = await chooseServerPort(this.getServerBindHost());
|
||||||
|
const serverUrl = `http://${HOST}:${port}`;
|
||||||
|
const displayUrl = `http://${DISPLAY_HOST}:${port}`;
|
||||||
|
this.localServerPort = port;
|
||||||
|
this.startBundledServer(port, serverEntry);
|
||||||
|
|
||||||
|
const ready = await waitForCloudCliServer(serverUrl, SERVER_START_TIMEOUT_MS);
|
||||||
|
if (!ready) {
|
||||||
|
const recentLogs = this.getStartupLogs().slice(-20).join('\n');
|
||||||
|
await this.shutdownOwnedServer();
|
||||||
|
this.localServerPort = null;
|
||||||
|
throw new Error([
|
||||||
|
`Bundled backend did not become ready at ${displayUrl}.`,
|
||||||
|
recentLogs ? `Recent startup output:\n${recentLogs}` : 'No startup output was captured.',
|
||||||
|
].join('\n\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.appendStartupLog(`Local CloudCLI ready at ${displayUrl}`);
|
||||||
|
this.localServerUrl = displayUrl;
|
||||||
|
return displayUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureLocalServer() {
|
||||||
|
if (!this.localServerUrl) {
|
||||||
|
this.localServerUrl = await this.resolveLocalServerUrl();
|
||||||
|
}
|
||||||
|
return this.localServerUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getResolvedTarget() {
|
||||||
|
await this.ensureLocalServer();
|
||||||
|
return {
|
||||||
|
kind: 'local',
|
||||||
|
name: 'Local CloudCLI',
|
||||||
|
url: this.localServerUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadLocalTarget() {
|
||||||
|
return {
|
||||||
|
pendingTarget: this.getPendingTarget(),
|
||||||
|
target: await this.getResolvedTarget(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
hasOwnedServer() {
|
||||||
|
return Boolean(this.ownedServerProcess);
|
||||||
|
}
|
||||||
|
|
||||||
|
detachOwnedServer() {
|
||||||
|
if (!this.ownedServerProcess) return;
|
||||||
|
this.ownedServerProcess.unref();
|
||||||
|
this.ownedServerProcess = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async shutdownOwnedServer() {
|
||||||
|
if (!this.ownedServerProcess) return;
|
||||||
|
|
||||||
|
const child = this.ownedServerProcess;
|
||||||
|
this.ownedServerProcess = null;
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
const timeout = setTimeout(resolve, 3000);
|
||||||
|
child.once('exit', () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DEFAULT_PORT, HOST };
|
||||||
944
electron/main.js
Normal file
944
electron/main.js
Normal file
@@ -0,0 +1,944 @@
|
|||||||
|
import { app, BrowserWindow, clipboard, dialog, ipcMain, session, shell } from 'electron';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { CloudController } from './cloud.js';
|
||||||
|
import { DesktopWindowManager } from './desktopWindow.js';
|
||||||
|
import { DesktopNotificationsController } from './desktopNotifications.js';
|
||||||
|
import { LocalServerController } from './localServer.js';
|
||||||
|
import { TabsController } from './tabs.js';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
const APP_NAME = 'CloudCLI';
|
||||||
|
const APP_USER_MODEL_ID = 'ai.cloudcli.desktop';
|
||||||
|
const CALLBACK_PROTOCOL = 'cloudcli';
|
||||||
|
const CALLBACK_URL = `${CALLBACK_PROTOCOL}://auth/callback`;
|
||||||
|
const CLOUDCLI_CONTROL_PLANE_URL = process.env.CLOUDCLI_CONTROL_PLANE_URL || 'https://cloudcli.ai';
|
||||||
|
const REMOTE_START_TIMEOUT_MS = 30000;
|
||||||
|
const AUTH_CALLBACK_TTL_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
const tabs = new TabsController();
|
||||||
|
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
app.setAppUserModelId(APP_USER_MODEL_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
let activeTarget = { kind: 'launcher', name: APP_NAME, url: null };
|
||||||
|
let desktopWindow = null;
|
||||||
|
let localServer = null;
|
||||||
|
let cloud = null;
|
||||||
|
let desktopNotifications = null;
|
||||||
|
let isQuitting = false;
|
||||||
|
let isRefreshingCloud = false;
|
||||||
|
let pendingCloudConnectStartedAt = 0;
|
||||||
|
|
||||||
|
function getAppRoot() {
|
||||||
|
return app.isPackaged ? app.getAppPath() : path.resolve(__dirname, '..');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLauncherPath() {
|
||||||
|
return path.join(__dirname, 'launcher', 'index.html');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreloadPath() {
|
||||||
|
return path.join(__dirname, 'preload.cjs');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWindowIconPath() {
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
return path.join(getAppRoot(), 'electron', 'assets', 'logo-macos.png');
|
||||||
|
}
|
||||||
|
return path.join(getAppRoot(), 'public', 'logo-512.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStorePath() {
|
||||||
|
return path.join(app.getPath('userData'), 'cloud-account.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSettingsPath() {
|
||||||
|
return path.join(app.getPath('userData'), 'desktop-settings.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDesktopNotificationsSettingsPath() {
|
||||||
|
return path.join(app.getPath('userData'), 'desktop-notifications-settings.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRunningEnvironmentUrls() {
|
||||||
|
return cloud.getEnvironments()
|
||||||
|
.filter((environment) => environment.status === 'running')
|
||||||
|
.map((environment) => cloud.getEnvironmentUrl(environment))
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDisplayTargetName() {
|
||||||
|
return activeTarget?.name || APP_NAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCloudState() {
|
||||||
|
return {
|
||||||
|
account: cloud.getAccount(),
|
||||||
|
environments: cloud.getEnvironments(),
|
||||||
|
controlPlaneUrl: CLOUDCLI_CONTROL_PLANE_URL,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocalState() {
|
||||||
|
return {
|
||||||
|
desktopSettings: localServer.getSettings(),
|
||||||
|
localServerRunning: Boolean(localServer.getLocalServerUrl()),
|
||||||
|
localWebUrl: localServer.getLocalServerUrl(),
|
||||||
|
shareableWebUrl: localServer.getShareableWebUrl(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeEnvironment(environment) {
|
||||||
|
return {
|
||||||
|
id: environment.id,
|
||||||
|
name: environment.name,
|
||||||
|
subdomain: environment.subdomain,
|
||||||
|
access_url: cloud.getEnvironmentUrl(environment),
|
||||||
|
status: environment.status,
|
||||||
|
created_at: environment.created_at,
|
||||||
|
github_url: environment.github_url || null,
|
||||||
|
region: environment.region || null,
|
||||||
|
agent: environment.agent || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDesktopState() {
|
||||||
|
const cloudAccount = cloud.getAccount();
|
||||||
|
const localState = getLocalState();
|
||||||
|
const authState = cloud.getAuthState();
|
||||||
|
return {
|
||||||
|
account: {
|
||||||
|
connected: authState === 'connected',
|
||||||
|
email: cloudAccount?.email || null,
|
||||||
|
authState,
|
||||||
|
requiresReconnect: authState === 'expired',
|
||||||
|
},
|
||||||
|
activeTarget,
|
||||||
|
desktopSettings: localState.desktopSettings,
|
||||||
|
localWebUrl: localState.localWebUrl,
|
||||||
|
shareableWebUrl: localState.shareableWebUrl,
|
||||||
|
localServerRunning: localState.localServerRunning,
|
||||||
|
localStartupLogs: localServer.getStartupLogs(),
|
||||||
|
cloudLoading: isRefreshingCloud,
|
||||||
|
tabs: tabs.getSerializableTabs(),
|
||||||
|
activeTabId: tabs.activeTabId,
|
||||||
|
environments: cloud.getEnvironments().map(serializeEnvironment),
|
||||||
|
desktopNotifications: desktopNotifications?.getState() || { enabled: false, supported: false, connectedCount: 0, targetCount: 0 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openExternalUrl(url) {
|
||||||
|
if (String(url).startsWith(CALLBACK_PROTOCOL + "://")) {
|
||||||
|
await handleDeepLink(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await shell.openExternal(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showError(title, error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`${title}: ${message}`);
|
||||||
|
await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
|
||||||
|
type: 'error',
|
||||||
|
title,
|
||||||
|
message: title,
|
||||||
|
detail: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExpectedNavigationAbort(error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return error?.code === 'ERR_ABORTED' || message.includes('ERR_ABORTED') || message.includes('(-3)');
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncDesktopState() {
|
||||||
|
if (!desktopWindow) return;
|
||||||
|
desktopWindow.buildAppMenu();
|
||||||
|
desktopWindow.emitDesktopState();
|
||||||
|
if (activeTarget?.kind === 'local' && !localServer?.getLocalServerUrl()) {
|
||||||
|
void desktopWindow.showLocalStartupTarget(localServer.getPendingTarget(), localServer.getStartupLogs())
|
||||||
|
.catch((error) => {
|
||||||
|
if (isExpectedNavigationAbort(error)) return;
|
||||||
|
void showError('Could not update local startup log', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveTarget(target) {
|
||||||
|
activeTarget = target;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEnvironmentTarget(environment) {
|
||||||
|
return {
|
||||||
|
kind: 'remote',
|
||||||
|
id: environment.id,
|
||||||
|
name: environment.name || environment.subdomain,
|
||||||
|
url: cloud.getEnvironmentUrl(environment),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getEnvironmentLaunchTarget(environment) {
|
||||||
|
const environmentUrl = cloud.getEnvironmentUrl(environment);
|
||||||
|
return {
|
||||||
|
...getEnvironmentTarget(environment),
|
||||||
|
url: environmentUrl,
|
||||||
|
loadUrl: await cloud.getEnvironmentLaunchUrl(environment),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasCloudWebSession() {
|
||||||
|
const cookies = await session.defaultSession.cookies.get({});
|
||||||
|
return cookies.some((cookie) => {
|
||||||
|
const cookieDomain = String(cookie.domain || '');
|
||||||
|
return cookieDomain.includes('cloudcli.ai')
|
||||||
|
&& /-auth-token(?:\.\d+)?$/.test(cookie.name)
|
||||||
|
&& Boolean(cookie.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCloudAuthRedirect(url) {
|
||||||
|
if (!url) return false;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const controlPlane = new URL(CLOUDCLI_CONTROL_PLANE_URL);
|
||||||
|
return parsed.origin === controlPlane.origin
|
||||||
|
&& (parsed.pathname === '/login' || parsed.pathname.startsWith('/auth/'));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDiagnosticsText() {
|
||||||
|
const cloudAccount = cloud.getAccount();
|
||||||
|
const localState = getLocalState();
|
||||||
|
return JSON.stringify({
|
||||||
|
app: APP_NAME,
|
||||||
|
version: app.getVersion(),
|
||||||
|
electron: process.versions.electron,
|
||||||
|
node: process.versions.node,
|
||||||
|
platform: process.platform,
|
||||||
|
arch: process.arch,
|
||||||
|
appPath: getAppRoot(),
|
||||||
|
userDataPath: app.getPath('userData'),
|
||||||
|
activeTarget,
|
||||||
|
localServerUrl: localState.localWebUrl,
|
||||||
|
localServerPort: localServer.localServerPort,
|
||||||
|
localWebUrl: localState.localWebUrl,
|
||||||
|
shareableWebUrl: localState.shareableWebUrl,
|
||||||
|
desktopSettings: localState.desktopSettings,
|
||||||
|
cloudConnected: Boolean(cloudAccount?.apiKey),
|
||||||
|
cloudEmail: cloudAccount?.email || null,
|
||||||
|
cloudEnvironmentCount: cloud.getEnvironments().length,
|
||||||
|
cloudRunningEnvironmentCount: getRunningEnvironmentUrls().length,
|
||||||
|
cloudAuthState: cloud.getAuthState(),
|
||||||
|
cloudAccountPath: getStorePath(),
|
||||||
|
controlPlaneUrl: CLOUDCLI_CONTROL_PLANE_URL,
|
||||||
|
}, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyDiagnostics() {
|
||||||
|
clipboard.writeText(getDiagnosticsText());
|
||||||
|
await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
|
||||||
|
type: 'info',
|
||||||
|
title: 'Diagnostics copied',
|
||||||
|
message: 'CloudCLI desktop diagnostics were copied to the clipboard.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshCloudEnvironments({ showErrors = false } = {}) {
|
||||||
|
isRefreshingCloud = true;
|
||||||
|
syncDesktopState();
|
||||||
|
try {
|
||||||
|
return await cloud.refreshCloudEnvironments();
|
||||||
|
} catch (error) {
|
||||||
|
const authState = cloud.getAuthState();
|
||||||
|
if (authState === 'expired') {
|
||||||
|
const expiredError = new Error('Your CloudCLI session expired. Reconnect your account.');
|
||||||
|
if (showErrors) {
|
||||||
|
await showError('CloudCLI login required', expiredError);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
throw expiredError;
|
||||||
|
}
|
||||||
|
if (showErrors) {
|
||||||
|
await showError('Could not load CloudCLI environments', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
isRefreshingCloud = false;
|
||||||
|
void desktopNotifications?.sync().catch((error) => console.error('[DesktopNotifications] sync failed:', error?.message || error));
|
||||||
|
syncDesktopState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectCloudAccount() {
|
||||||
|
const connectUrl = cloud.buildConnectUrl();
|
||||||
|
pendingCloudConnectStartedAt = Date.now();
|
||||||
|
clipboard.writeText(connectUrl);
|
||||||
|
await openExternalUrl(connectUrl);
|
||||||
|
return connectUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeepLink(url) {
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = new URL(url);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.protocol !== `${CALLBACK_PROTOCOL}:` || parsed.hostname !== 'auth') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pendingCloudConnectStartedAt || Date.now() - pendingCloudConnectStartedAt > AUTH_CALLBACK_TTL_MS) {
|
||||||
|
await showError('CloudCLI account connection failed', new Error('No recent CloudCLI account connection was started from this app.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = parsed.searchParams.get('api_key');
|
||||||
|
if (!apiKey) {
|
||||||
|
await showError('CloudCLI account connection failed', new Error('The callback did not include an API key.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await cloud.saveFromCallback({
|
||||||
|
apiKey,
|
||||||
|
email: parsed.searchParams.get('email'),
|
||||||
|
});
|
||||||
|
pendingCloudConnectStartedAt = 0;
|
||||||
|
await refreshCloudEnvironments({ showErrors: true });
|
||||||
|
|
||||||
|
dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
|
||||||
|
type: 'info',
|
||||||
|
title: 'CloudCLI account connected',
|
||||||
|
message: cloud.getAccount()?.email ? `Connected as ${cloud.getAccount().email}.` : 'CloudCLI account connected.',
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyLocalWebUrl() {
|
||||||
|
await localServer.ensureLocalServer();
|
||||||
|
const shareableUrl = localServer.getShareableWebUrl();
|
||||||
|
const localUrl = localServer.getLocalServerUrl();
|
||||||
|
|
||||||
|
if (!shareableUrl) {
|
||||||
|
throw new Error('Local CloudCLI URL is not available yet.');
|
||||||
|
}
|
||||||
|
|
||||||
|
clipboard.writeText(shareableUrl);
|
||||||
|
const isLanUrl = shareableUrl !== localUrl;
|
||||||
|
await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
|
||||||
|
type: 'info',
|
||||||
|
title: 'Web URL copied',
|
||||||
|
message: isLanUrl ? 'LAN web URL copied.' : 'Local web URL copied.',
|
||||||
|
detail: isLanUrl
|
||||||
|
? `${shareableUrl}\n\nUse this URL from another device on the same network.`
|
||||||
|
: `${shareableUrl}\n\nThis URL works on this computer. Enable LAN access before starting Local CloudCLI to copy a phone-accessible URL.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return getDesktopState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openLocalWebUi() {
|
||||||
|
await localServer.ensureLocalServer();
|
||||||
|
const url = localServer.getShareableWebUrl() || localServer.getLocalServerUrl();
|
||||||
|
if (!url) {
|
||||||
|
throw new Error('Local CloudCLI URL is not available yet.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await openExternalUrl(url);
|
||||||
|
return getDesktopState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateDesktopSetting(key, value) {
|
||||||
|
const result = await localServer.updateDesktopSetting(key, value);
|
||||||
|
syncDesktopState();
|
||||||
|
|
||||||
|
if (result.requiresRestartNotice) {
|
||||||
|
await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
|
||||||
|
type: 'info',
|
||||||
|
title: 'Restart local server to apply',
|
||||||
|
message: 'LAN access changes apply the next time the local server starts.',
|
||||||
|
detail: 'Quit CloudCLI and stop the local server, then open Local CloudCLI again.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return getDesktopState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showEnvironmentPicker() {
|
||||||
|
let environments = cloud.getEnvironments();
|
||||||
|
let refreshError = null;
|
||||||
|
|
||||||
|
if (cloud.getAccount()?.apiKey) {
|
||||||
|
try {
|
||||||
|
environments = await refreshCloudEnvironments({ showErrors: false });
|
||||||
|
} catch (error) {
|
||||||
|
refreshError = error;
|
||||||
|
console.warn('[Cloud] Could not refresh environments before showing picker:', error?.message || error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const choices = ['Local CloudCLI', ...environments.map((environment) => {
|
||||||
|
const status = environment.status === 'running' ? '' : ` (${environment.status})`;
|
||||||
|
return `${environment.name || environment.subdomain}${status}`;
|
||||||
|
})];
|
||||||
|
|
||||||
|
const response = await dialog.showMessageBox(desktopWindow?.getMainWindow(), {
|
||||||
|
type: 'question',
|
||||||
|
buttons: [...choices, 'Cancel'],
|
||||||
|
defaultId: 0,
|
||||||
|
cancelId: choices.length,
|
||||||
|
title: 'Switch CloudCLI Environment',
|
||||||
|
message: 'Choose where this desktop window should connect.',
|
||||||
|
detail: refreshError ? `Cloud environments could not be refreshed. Showing cached environments.\n\n${refreshError.message || refreshError}` : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.response === choices.length) return getDesktopState();
|
||||||
|
if (response.response === 0) return openLocalInDesktop();
|
||||||
|
return openEnvironmentInDesktop(environments[response.response - 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startEnvironment(environment) {
|
||||||
|
await cloud.startEnvironmentAndWait(environment, REMOTE_START_TIMEOUT_MS);
|
||||||
|
await refreshCloudEnvironments({ showErrors: true });
|
||||||
|
return getDesktopState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopEnvironment(environment) {
|
||||||
|
await cloud.stopEnvironment(environment);
|
||||||
|
await refreshCloudEnvironments({ showErrors: true });
|
||||||
|
return getDesktopState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEnvironmentInBrowser(environment) {
|
||||||
|
await openExternalUrl(await cloud.getEnvironmentLaunchUrl(environment));
|
||||||
|
return getDesktopState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProjectFolder(environment) {
|
||||||
|
return String(environment.name || environment.subdomain || 'workspace').replace(/[^a-zA-Z0-9-]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSshTarget(credentials) {
|
||||||
|
if (credentials.ssh_command) {
|
||||||
|
const parts = String(credentials.ssh_command).split(/\s+/);
|
||||||
|
if (parts.length >= 2) return parts[1];
|
||||||
|
}
|
||||||
|
return `${credentials.username}@ssh.cloudcli.ai`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSshHost(credentials) {
|
||||||
|
const target = getSshTarget(credentials);
|
||||||
|
const atIndex = target.indexOf('@');
|
||||||
|
return atIndex >= 0 ? target.slice(atIndex + 1) : 'ssh.cloudcli.ai';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSafeSshUsername(credentials) {
|
||||||
|
const username = String(credentials.username || '');
|
||||||
|
if (!/^[a-zA-Z0-9._-]+$/.test(username)) {
|
||||||
|
throw new Error('Cloud environment returned an invalid SSH username.');
|
||||||
|
}
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSafeSshHost(credentials) {
|
||||||
|
const host = getSshHost(credentials);
|
||||||
|
if (!/^[a-zA-Z0-9.-]+$/.test(host)) {
|
||||||
|
throw new Error('Cloud environment returned an invalid SSH host.');
|
||||||
|
}
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shellQuote(value) {
|
||||||
|
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getEnvironmentCredentials(environment) {
|
||||||
|
const credentials = await cloud.getEnvironmentCredentials(environment);
|
||||||
|
if (credentials.password) {
|
||||||
|
clipboard.writeText(credentials.password);
|
||||||
|
}
|
||||||
|
return credentials;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEnvironmentInIde(environment, ide) {
|
||||||
|
const credentials = await getEnvironmentCredentials(environment);
|
||||||
|
const scheme = ide === 'cursor' ? 'cursor' : 'vscode';
|
||||||
|
const remoteUri = `${scheme}://vscode-remote/ssh-remote+${getSafeSshUsername(credentials)}@${getSafeSshHost(credentials)}/workspace/${getProjectFolder(environment)}?windowId=_blank`;
|
||||||
|
await shell.openExternal(remoteUri);
|
||||||
|
return getDesktopState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEnvironmentInSsh(environment) {
|
||||||
|
const credentials = await getEnvironmentCredentials(environment);
|
||||||
|
const remoteCommand = `cd /workspace/${getProjectFolder(environment)} && exec $SHELL -l`;
|
||||||
|
const sshCommand = `ssh -t ${shellQuote(getSshTarget(credentials))} ${shellQuote(remoteCommand)}`;
|
||||||
|
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
const escaped = sshCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
|
spawn('osascript', ['-e', `tell application "Terminal" to do script "${escaped}"`], {
|
||||||
|
detached: true,
|
||||||
|
stdio: 'ignore',
|
||||||
|
}).unref();
|
||||||
|
} else {
|
||||||
|
clipboard.writeText(sshCommand);
|
||||||
|
await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
|
||||||
|
type: 'info',
|
||||||
|
title: 'SSH command copied',
|
||||||
|
message: 'The SSH command was copied to the clipboard.',
|
||||||
|
detail: sshCommand,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return getDesktopState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyEnvironmentMobileUrl(environment) {
|
||||||
|
const url = cloud.getEnvironmentUrl(environment);
|
||||||
|
clipboard.writeText(url);
|
||||||
|
await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
|
||||||
|
type: 'info',
|
||||||
|
title: 'Environment URL copied',
|
||||||
|
message: 'Use this URL from your mobile browser.',
|
||||||
|
detail: url,
|
||||||
|
});
|
||||||
|
return getDesktopState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCloudDashboard() {
|
||||||
|
await openExternalUrl(CLOUDCLI_CONTROL_PLANE_URL);
|
||||||
|
return getDesktopState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveRemoteEnvironment() {
|
||||||
|
if (activeTarget?.kind !== 'remote') return null;
|
||||||
|
return cloud.findEnvironment(activeTarget.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runActiveEnvironmentAction(action) {
|
||||||
|
const environment = getActiveRemoteEnvironment();
|
||||||
|
if (!environment) {
|
||||||
|
throw new Error('Open a cloud environment first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'web':
|
||||||
|
return openEnvironmentInBrowser(environment);
|
||||||
|
case 'vscode':
|
||||||
|
return openEnvironmentInIde(environment, 'vscode');
|
||||||
|
case 'cursor':
|
||||||
|
return openEnvironmentInIde(environment, 'cursor');
|
||||||
|
case 'ssh':
|
||||||
|
return openEnvironmentInSsh(environment);
|
||||||
|
case 'mobile':
|
||||||
|
return copyEnvironmentMobileUrl(environment);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown environment action: ${action}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openLocalInDesktop() {
|
||||||
|
const existingTab = tabs.getTab('local');
|
||||||
|
if (existingTab && localServer.getLocalServerUrl()) {
|
||||||
|
await desktopWindow.showTarget(await localServer.getResolvedTarget());
|
||||||
|
return getDesktopState();
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingTarget = localServer.getPendingTarget();
|
||||||
|
tabs.upsertTarget(pendingTarget);
|
||||||
|
setActiveTarget(pendingTarget);
|
||||||
|
await desktopWindow.showLocalStartupTarget(pendingTarget, localServer.getStartupLogs());
|
||||||
|
desktopWindow.emitDesktopState();
|
||||||
|
|
||||||
|
const target = await localServer.getResolvedTarget();
|
||||||
|
await desktopWindow.showTarget(target);
|
||||||
|
return getDesktopState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEnvironmentInDesktop(environment) {
|
||||||
|
const pendingTarget = getEnvironmentTarget(environment);
|
||||||
|
const tabId = tabs.getTabIdForTarget(pendingTarget);
|
||||||
|
const hadTab = Boolean(tabs.getTab(tabId));
|
||||||
|
const previousTabId = tabs.activeTabId;
|
||||||
|
|
||||||
|
if (!hadTab) {
|
||||||
|
await desktopWindow.showTabPlaceholder(
|
||||||
|
pendingTarget,
|
||||||
|
`${environment.status === 'running' ? 'Opening' : 'Starting'} ${pendingTarget.name}...`,
|
||||||
|
);
|
||||||
|
tabs.upsertTarget(pendingTarget);
|
||||||
|
desktopWindow.emitDesktopState();
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextEnvironment = environment;
|
||||||
|
|
||||||
|
if (environment.status !== 'running') {
|
||||||
|
const response = await dialog.showMessageBox(desktopWindow?.getMainWindow(), {
|
||||||
|
type: 'question',
|
||||||
|
buttons: ['Start Environment', 'Cancel'],
|
||||||
|
defaultId: 0,
|
||||||
|
cancelId: 1,
|
||||||
|
title: 'Start environment?',
|
||||||
|
message: `${pendingTarget.name} is ${environment.status}.`,
|
||||||
|
detail: 'CloudCLI can start it before opening the remote app.',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.response !== 0) {
|
||||||
|
if (!hadTab) {
|
||||||
|
tabs.remove(tabId);
|
||||||
|
desktopWindow.destroyTabView(tabId);
|
||||||
|
if (previousTabId && previousTabId !== tabId) {
|
||||||
|
await desktopWindow.switchDesktopTab(previousTabId);
|
||||||
|
} else {
|
||||||
|
await desktopWindow.showLauncher();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return getDesktopState();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hadTab) {
|
||||||
|
await desktopWindow.showTabPlaceholder(pendingTarget, `Starting ${pendingTarget.name}...`);
|
||||||
|
tabs.upsertTarget(pendingTarget);
|
||||||
|
desktopWindow.emitDesktopState();
|
||||||
|
}
|
||||||
|
|
||||||
|
nextEnvironment = await cloud.startEnvironmentAndWait(environment, REMOTE_START_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
let target = getEnvironmentTarget(nextEnvironment);
|
||||||
|
if (!(await hasCloudWebSession())) {
|
||||||
|
target = await getEnvironmentLaunchTarget(nextEnvironment);
|
||||||
|
}
|
||||||
|
|
||||||
|
const usedBootstrap = Boolean(target.loadUrl);
|
||||||
|
const finalUrl = await desktopWindow.showTarget(target);
|
||||||
|
if (!usedBootstrap && isCloudAuthRedirect(finalUrl)) {
|
||||||
|
const bootstrapTarget = await getEnvironmentLaunchTarget(nextEnvironment);
|
||||||
|
bootstrapTarget.forceLoad = true;
|
||||||
|
await desktopWindow.showTarget(bootstrapTarget);
|
||||||
|
}
|
||||||
|
return getDesktopState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function findEnvironmentByUrl(environmentUrl) {
|
||||||
|
const targetOrigin = (() => {
|
||||||
|
try {
|
||||||
|
return new URL(environmentUrl).origin;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
if (!targetOrigin) return null;
|
||||||
|
|
||||||
|
return cloud.getEnvironments().find((environment) => {
|
||||||
|
try {
|
||||||
|
return new URL(cloud.getEnvironmentUrl(environment)).origin === targetOrigin;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openNotificationTarget({ environmentUrl, sessionId = null }) {
|
||||||
|
const window = desktopWindow?.getMainWindow();
|
||||||
|
if (window) {
|
||||||
|
if (window.isMinimized()) window.restore();
|
||||||
|
window.show();
|
||||||
|
window.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
const environment = findEnvironmentByUrl(environmentUrl);
|
||||||
|
if (environment) {
|
||||||
|
await openEnvironmentInDesktop(environment);
|
||||||
|
} else {
|
||||||
|
const parsed = new URL(environmentUrl);
|
||||||
|
await desktopWindow.showTarget({
|
||||||
|
kind: 'remote',
|
||||||
|
name: parsed.hostname,
|
||||||
|
url: parsed.origin,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUrl = new URL(sessionId ? `/session/${encodeURIComponent(sessionId)}` : '/', environmentUrl).toString();
|
||||||
|
await desktopWindow.navigateActiveView(targetUrl);
|
||||||
|
return getDesktopState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getEnvironmentAuthToken(environmentUrl) {
|
||||||
|
return (await desktopWindow?.readAuthTokenForTarget(environmentUrl)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearCloudAccount() {
|
||||||
|
await cloud.clearCloudAccount();
|
||||||
|
desktopNotifications?.stop();
|
||||||
|
const removedTabs = tabs.removeByKind('remote');
|
||||||
|
for (const tab of removedTabs) {
|
||||||
|
desktopWindow?.destroyTabView(tab.id);
|
||||||
|
}
|
||||||
|
if (activeTarget?.kind === 'remote') {
|
||||||
|
await desktopWindow?.showLauncher();
|
||||||
|
} else {
|
||||||
|
syncDesktopState();
|
||||||
|
}
|
||||||
|
return getDesktopState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRemoteEnvironmentMenuItems() {
|
||||||
|
const cloudAccount = cloud.getAccount();
|
||||||
|
const environments = cloud.getEnvironments();
|
||||||
|
|
||||||
|
if (!cloudAccount?.apiKey) {
|
||||||
|
return [{ label: 'Connect CloudCLI Account...', click: () => void connectCloudAccount() }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!environments.length) {
|
||||||
|
return [{ label: 'No environments found', enabled: false }];
|
||||||
|
}
|
||||||
|
|
||||||
|
return environments.map((environment) => ({
|
||||||
|
label: `${environment.name || environment.subdomain}${environment.status === 'running' ? '' : ` (${environment.status})`}`,
|
||||||
|
click: () => void openEnvironmentInDesktop(environment)
|
||||||
|
.catch((error) => showError('Could not open environment', error)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerProtocolHandler() {
|
||||||
|
const appEntry = path.join(getAppRoot(), 'electron', 'main.js');
|
||||||
|
if (process.defaultApp && process.argv.length >= 2) {
|
||||||
|
app.setAsDefaultProtocolClient(CALLBACK_PROTOCOL, process.execPath, [appEntry]);
|
||||||
|
} else {
|
||||||
|
app.setAsDefaultProtocolClient(CALLBACK_PROTOCOL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerIpcHandlers() {
|
||||||
|
ipcMain.handle('cloudcli-desktop:connect-cloud', async () => ({
|
||||||
|
...getDesktopState(),
|
||||||
|
connectUrl: await connectCloudAccount(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
ipcMain.handle('cloudcli-desktop:copy-diagnostics', async () => {
|
||||||
|
await copyDiagnostics();
|
||||||
|
return getDesktopState();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('cloudcli-desktop:copy-local-web-url', async () => copyLocalWebUrl());
|
||||||
|
ipcMain.handle('cloudcli-desktop:get-state', () => getDesktopState());
|
||||||
|
ipcMain.handle('cloudcli-desktop:open-cloud-dashboard', async () => openCloudDashboard());
|
||||||
|
ipcMain.handle('cloudcli-desktop:run-active-environment-action', async (_event, action) => runActiveEnvironmentAction(action));
|
||||||
|
ipcMain.handle('cloudcli-desktop:open-environment', async (_event, environmentId) => {
|
||||||
|
const environment = cloud.findEnvironment(environmentId);
|
||||||
|
if (!environment) {
|
||||||
|
throw new Error('Environment not found. Refresh and try again.');
|
||||||
|
}
|
||||||
|
return openEnvironmentInDesktop(environment);
|
||||||
|
});
|
||||||
|
ipcMain.handle('cloudcli-desktop:open-local', async () => openLocalInDesktop());
|
||||||
|
ipcMain.handle('cloudcli-desktop:open-local-web-ui', async () => openLocalWebUi());
|
||||||
|
ipcMain.handle('cloudcli-desktop:refresh-environments', async () => {
|
||||||
|
await refreshCloudEnvironments({ showErrors: true });
|
||||||
|
return getDesktopState();
|
||||||
|
});
|
||||||
|
ipcMain.handle('cloudcli-desktop:disconnect-cloud', async () => clearCloudAccount());
|
||||||
|
ipcMain.handle('cloudcli-desktop:reload-active-tab', async () => desktopWindow.reloadActiveTab());
|
||||||
|
ipcMain.handle('cloudcli-desktop:show-environment-picker', async () => showEnvironmentPicker());
|
||||||
|
ipcMain.handle('cloudcli-desktop:show-launcher', async () => {
|
||||||
|
await desktopWindow.showLauncher();
|
||||||
|
return getDesktopState();
|
||||||
|
});
|
||||||
|
ipcMain.handle('cloudcli-desktop:update-desktop-notifications', async (_event, settings) => {
|
||||||
|
await desktopNotifications?.saveSettings(settings);
|
||||||
|
return getDesktopState();
|
||||||
|
});
|
||||||
|
ipcMain.handle('cloudcli-desktop:show-desktop-settings', async () => desktopWindow.showDesktopSettings());
|
||||||
|
ipcMain.handle('cloudcli-desktop:show-local-settings', async () => desktopWindow.showLocalSettings());
|
||||||
|
ipcMain.handle('cloudcli-desktop:close-settings-window', async () => {
|
||||||
|
desktopWindow.closeSettingsWindow();
|
||||||
|
return getDesktopState();
|
||||||
|
});
|
||||||
|
ipcMain.handle('cloudcli-desktop:show-active-environment-actions-menu', async () => desktopWindow.showActiveEnvironmentActionsMenu());
|
||||||
|
ipcMain.handle('cloudcli-desktop:show-environment-actions-menu', async (_event, environmentId) => desktopWindow.showEnvironmentActionsMenu(environmentId));
|
||||||
|
ipcMain.handle('cloudcli-desktop:switch-tab', async (_event, tabId) => desktopWindow.switchDesktopTab(tabId));
|
||||||
|
ipcMain.handle('cloudcli-desktop:close-tab', async (_event, tabId) => desktopWindow.closeDesktopTab(tabId));
|
||||||
|
ipcMain.handle('cloudcli-desktop:update-setting', async (_event, key, value) => updateDesktopSetting(key, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerAppEvents() {
|
||||||
|
app.on('open-url', (event, url) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void handleDeepLink(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
if (desktopWindow) {
|
||||||
|
void desktopWindow.createWindow();
|
||||||
|
} else {
|
||||||
|
void createDesktopWindow();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const window = desktopWindow?.getMainWindow();
|
||||||
|
if (window) {
|
||||||
|
window.show();
|
||||||
|
window.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('before-quit', () => {
|
||||||
|
desktopNotifications?.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('before-quit', (event) => {
|
||||||
|
if (isQuitting || !localServer?.hasOwnedServer()) return;
|
||||||
|
if (localServer.getSettings().keepLocalServerRunning) {
|
||||||
|
localServer.detachOwnedServer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
isQuitting = true;
|
||||||
|
void localServer.shutdownOwnedServer().finally(() => app.quit());
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDesktopWindow() {
|
||||||
|
desktopWindow = new DesktopWindowManager({
|
||||||
|
appName: APP_NAME,
|
||||||
|
getWindowIconPath,
|
||||||
|
getLauncherPath,
|
||||||
|
getPreloadPath,
|
||||||
|
openExternalUrl,
|
||||||
|
getDesktopState,
|
||||||
|
getDisplayTargetName,
|
||||||
|
getRemoteEnvironmentMenuItems,
|
||||||
|
getCloudState,
|
||||||
|
getLocalState,
|
||||||
|
tabs,
|
||||||
|
actions: {
|
||||||
|
copyDiagnostics,
|
||||||
|
copyText: (text) => clipboard.writeText(text),
|
||||||
|
clearCloudAccount,
|
||||||
|
connectCloudAccount,
|
||||||
|
getActiveTarget: () => activeTarget,
|
||||||
|
getEnvironmentUrl: (environment) => cloud.getEnvironmentUrl(environment),
|
||||||
|
openEnvironmentInBrowser,
|
||||||
|
openEnvironmentInDesktop,
|
||||||
|
openEnvironmentInIde,
|
||||||
|
openEnvironmentInSsh,
|
||||||
|
openLocalInDesktop,
|
||||||
|
openLocalWebUi,
|
||||||
|
openCloudDashboard,
|
||||||
|
refreshCloudEnvironments: () => refreshCloudEnvironments({ showErrors: true }),
|
||||||
|
setActiveTarget,
|
||||||
|
showEnvironmentPicker,
|
||||||
|
showError,
|
||||||
|
startEnvironment,
|
||||||
|
stopEnvironment,
|
||||||
|
updateDesktopSetting,
|
||||||
|
copyLocalWebUrl,
|
||||||
|
openNotificationTarget,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
desktopWindow.createTray();
|
||||||
|
desktopWindow.configurePermissions();
|
||||||
|
await desktopWindow.createWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerSingleInstance() {
|
||||||
|
const gotSingleInstanceLock = app.requestSingleInstanceLock();
|
||||||
|
if (!gotSingleInstanceLock) {
|
||||||
|
app.quit();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.on('second-instance', (_event, argv) => {
|
||||||
|
const deepLink = argv.find((arg) => arg.startsWith(`${CALLBACK_PROTOCOL}://`));
|
||||||
|
if (deepLink) {
|
||||||
|
void handleDeepLink(deepLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
const window = desktopWindow?.getMainWindow();
|
||||||
|
if (window) {
|
||||||
|
if (window.isMinimized()) window.restore();
|
||||||
|
window.show();
|
||||||
|
window.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
app.name = APP_NAME;
|
||||||
|
app.setName(APP_NAME);
|
||||||
|
process.title = APP_NAME;
|
||||||
|
|
||||||
|
await app.whenReady();
|
||||||
|
app.setName(APP_NAME);
|
||||||
|
app.setAboutPanelOptions({
|
||||||
|
applicationName: APP_NAME,
|
||||||
|
applicationVersion: app.getVersion(),
|
||||||
|
copyright: 'CloudCLI',
|
||||||
|
});
|
||||||
|
|
||||||
|
localServer = new LocalServerController({
|
||||||
|
appRoot: getAppRoot(),
|
||||||
|
settingsPath: getSettingsPath(),
|
||||||
|
isPackaged: app.isPackaged,
|
||||||
|
appVersion: app.getVersion(),
|
||||||
|
onChange: syncDesktopState,
|
||||||
|
});
|
||||||
|
cloud = new CloudController({
|
||||||
|
storePath: getStorePath(),
|
||||||
|
controlPlaneUrl: CLOUDCLI_CONTROL_PLANE_URL,
|
||||||
|
callbackUrl: CALLBACK_URL,
|
||||||
|
onChange: syncDesktopState,
|
||||||
|
});
|
||||||
|
desktopNotifications = new DesktopNotificationsController({
|
||||||
|
settingsPath: getDesktopNotificationsSettingsPath(),
|
||||||
|
appVersion: app.getVersion(),
|
||||||
|
appName: APP_NAME,
|
||||||
|
getDeviceId: () => cloud.getAccount()?.deviceId || '',
|
||||||
|
getAccountEmail: () => cloud.getAccount()?.email || null,
|
||||||
|
getRunningEnvironmentUrls,
|
||||||
|
getApiKey: () => cloud.getAccount()?.apiKey || '',
|
||||||
|
getAuthToken: getEnvironmentAuthToken,
|
||||||
|
getIconPath: getWindowIconPath,
|
||||||
|
openNotificationTarget,
|
||||||
|
onChange: syncDesktopState,
|
||||||
|
});
|
||||||
|
|
||||||
|
await localServer.loadDesktopSettings();
|
||||||
|
await cloud.loadCloudAccount();
|
||||||
|
await desktopNotifications.loadSettings();
|
||||||
|
|
||||||
|
registerProtocolHandler();
|
||||||
|
registerIpcHandlers();
|
||||||
|
registerAppEvents();
|
||||||
|
await createDesktopWindow();
|
||||||
|
void refreshCloudEnvironments({ showErrors: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registerSingleInstance()) {
|
||||||
|
bootstrap().catch(async (error) => {
|
||||||
|
await showError('CloudCLI failed to start', error);
|
||||||
|
app.quit();
|
||||||
|
});
|
||||||
|
}
|
||||||
60
electron/preload.cjs
Normal file
60
electron/preload.cjs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
|
|
||||||
|
function isCloudCliAppOrigin(location) {
|
||||||
|
if (location.protocol === 'file:') return true;
|
||||||
|
|
||||||
|
if (location.protocol === 'http:') {
|
||||||
|
return location.hostname === '127.0.0.1' || location.hostname === 'localhost';
|
||||||
|
}
|
||||||
|
|
||||||
|
return location.protocol === 'https:' && (
|
||||||
|
location.hostname === 'cloudcli.ai' || location.hostname.endsWith('.cloudcli.ai')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDesktopStateUpdated(callback) {
|
||||||
|
const listener = (_event, state) => callback(state);
|
||||||
|
ipcRenderer.on('cloudcli-desktop:state-updated', listener);
|
||||||
|
return () => {
|
||||||
|
ipcRenderer.removeListener('cloudcli-desktop:state-updated', listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCloudCliAppOrigin(window.location)) {
|
||||||
|
contextBridge.exposeInMainWorld('cloudcliDesktopNotifications', {
|
||||||
|
getState: () => ipcRenderer.invoke('cloudcli-desktop:get-state'),
|
||||||
|
update: (settings) => ipcRenderer.invoke('cloudcli-desktop:update-desktop-notifications', settings),
|
||||||
|
onStateUpdated: onDesktopStateUpdated,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.location.protocol === 'file:') {
|
||||||
|
contextBridge.exposeInMainWorld('cloudcliDesktop', {
|
||||||
|
connectCloud: () => ipcRenderer.invoke('cloudcli-desktop:connect-cloud'),
|
||||||
|
disconnectCloud: () => ipcRenderer.invoke('cloudcli-desktop:disconnect-cloud'),
|
||||||
|
copyDiagnostics: () => ipcRenderer.invoke('cloudcli-desktop:copy-diagnostics'),
|
||||||
|
copyLocalWebUrl: () => ipcRenderer.invoke('cloudcli-desktop:copy-local-web-url'),
|
||||||
|
getState: () => ipcRenderer.invoke('cloudcli-desktop:get-state'),
|
||||||
|
openCloudDashboard: () => ipcRenderer.invoke('cloudcli-desktop:open-cloud-dashboard'),
|
||||||
|
openEnvironment: (environmentId) => ipcRenderer.invoke('cloudcli-desktop:open-environment', environmentId),
|
||||||
|
runActiveEnvironmentAction: (action) => ipcRenderer.invoke('cloudcli-desktop:run-active-environment-action', action),
|
||||||
|
openLocal: () => ipcRenderer.invoke('cloudcli-desktop:open-local'),
|
||||||
|
openLocalWebUi: () => ipcRenderer.invoke('cloudcli-desktop:open-local-web-ui'),
|
||||||
|
refreshEnvironments: () => ipcRenderer.invoke('cloudcli-desktop:refresh-environments'),
|
||||||
|
refreshActiveTab: () => ipcRenderer.invoke('cloudcli-desktop:reload-active-tab'),
|
||||||
|
showEnvironmentPicker: () => ipcRenderer.invoke('cloudcli-desktop:show-environment-picker'),
|
||||||
|
showLauncher: () => ipcRenderer.invoke('cloudcli-desktop:show-launcher'),
|
||||||
|
showLocalSettings: () => ipcRenderer.invoke('cloudcli-desktop:show-local-settings'),
|
||||||
|
showDesktopSettings: () => ipcRenderer.invoke('cloudcli-desktop:show-desktop-settings'),
|
||||||
|
closeSettingsWindow: () => ipcRenderer.invoke('cloudcli-desktop:close-settings-window'),
|
||||||
|
showActiveEnvironmentActionsMenu: () => ipcRenderer.invoke('cloudcli-desktop:show-active-environment-actions-menu'),
|
||||||
|
showEnvironmentActionsMenu: (environmentId) => ipcRenderer.invoke('cloudcli-desktop:show-environment-actions-menu', environmentId),
|
||||||
|
switchTab: (tabId) => ipcRenderer.invoke('cloudcli-desktop:switch-tab', tabId),
|
||||||
|
closeTab: (tabId) => ipcRenderer.invoke('cloudcli-desktop:close-tab', tabId),
|
||||||
|
updateSetting: (key, value) => ipcRenderer.invoke('cloudcli-desktop:update-setting', key, value),
|
||||||
|
onStateUpdated: onDesktopStateUpdated,
|
||||||
|
onLauncherCommand: (callback) => {
|
||||||
|
ipcRenderer.on('cloudcli-desktop:launcher-command', (_event, command) => callback(command));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
62
electron/scripts/generate-macos-icon.js
Normal file
62
electron/scripts/generate-macos-icon.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
const size = 1024;
|
||||||
|
const assetsDir = 'electron/assets';
|
||||||
|
const iconPath = 'electron/assets/logo-macos.png';
|
||||||
|
const icnsPath = 'electron/assets/logo-macos.icns';
|
||||||
|
|
||||||
|
function renderSvg(entrySize) {
|
||||||
|
const scale = entrySize / 32;
|
||||||
|
return `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="${entrySize}" height="${entrySize}" viewBox="0 0 ${entrySize} ${entrySize}">
|
||||||
|
<rect width="${entrySize}" height="${entrySize}" fill="#2563eb"/>
|
||||||
|
<path
|
||||||
|
d="M${8 * scale} ${9 * scale}C${8 * scale} ${8.44772 * scale} ${8.44772 * scale} ${8 * scale} ${9 * scale} ${8 * scale}H${23 * scale}C${23.5523 * scale} ${8 * scale} ${24 * scale} ${8.44772 * scale} ${24 * scale} ${9 * scale}V${18 * scale}C${24 * scale} ${18.5523 * scale} ${23.5523 * scale} ${19 * scale} ${23 * scale} ${19 * scale}H${12 * scale}L${8 * scale} ${23 * scale}V${9 * scale}Z"
|
||||||
|
stroke="white"
|
||||||
|
stroke-width="${2 * scale}"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderPng(entrySize) {
|
||||||
|
return sharp(Buffer.from(renderSvg(entrySize)))
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.mkdir(assetsDir, { recursive: true });
|
||||||
|
await fs.writeFile(iconPath, await renderPng(size));
|
||||||
|
|
||||||
|
const icnsEntries = [
|
||||||
|
['icp4', 16],
|
||||||
|
['icp5', 32],
|
||||||
|
['icp6', 64],
|
||||||
|
['ic07', 128],
|
||||||
|
['ic08', 256],
|
||||||
|
['ic09', 512],
|
||||||
|
['ic10', 1024],
|
||||||
|
['ic11', 32],
|
||||||
|
['ic12', 64],
|
||||||
|
['ic13', 256],
|
||||||
|
['ic14', 512],
|
||||||
|
];
|
||||||
|
|
||||||
|
const blocks = await Promise.all(icnsEntries.map(async ([type, entrySize]) => {
|
||||||
|
const png = await renderPng(entrySize);
|
||||||
|
const block = Buffer.alloc(8 + png.length);
|
||||||
|
block.write(type, 0, 4, 'ascii');
|
||||||
|
block.writeUInt32BE(block.length, 4);
|
||||||
|
png.copy(block, 8);
|
||||||
|
return block;
|
||||||
|
}));
|
||||||
|
|
||||||
|
const totalLength = 8 + blocks.reduce((sum, block) => sum + block.length, 0);
|
||||||
|
const header = Buffer.alloc(8);
|
||||||
|
header.write('icns', 0, 4, 'ascii');
|
||||||
|
header.writeUInt32BE(totalLength, 4);
|
||||||
|
|
||||||
|
await fs.writeFile(icnsPath, Buffer.concat([header, ...blocks], totalLength));
|
||||||
277
electron/serverInstaller.js
Normal file
277
electron/serverInstaller.js
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import { createReadStream, createWriteStream } from 'node:fs';
|
||||||
|
import https from 'node:https';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs the versioned local server runtime used by CloudCLI Desktop.
|
||||||
|
*
|
||||||
|
* Server bundles are cached under:
|
||||||
|
* ~/.cloudcli/server/<version>/dist-server/server/index.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DEFAULT_INSTALL_ROOT = path.join(os.homedir(), '.cloudcli', 'server');
|
||||||
|
const DEFAULT_BUNDLE_BASE_URL = 'https://github.com/siteboon/claudecodeui/releases/download';
|
||||||
|
const MAX_REDIRECTS = 5;
|
||||||
|
const LOCAL_DOWNLOAD_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
|
||||||
|
|
||||||
|
function mapArch(arch = process.arch) {
|
||||||
|
return arch === 'arm64' ? 'arm64' : 'x64';
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapPlatform(platform = process.platform) {
|
||||||
|
if (platform === 'darwin') return 'mac';
|
||||||
|
if (platform === 'win32') return 'win';
|
||||||
|
return 'linux';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ServerInstaller {
|
||||||
|
constructor({
|
||||||
|
version,
|
||||||
|
platform = process.platform,
|
||||||
|
arch = process.arch,
|
||||||
|
installRoot = process.env.CLOUDCLI_SERVER_DIR || DEFAULT_INSTALL_ROOT,
|
||||||
|
bundleBaseUrl = process.env.CLOUDCLI_SERVER_BUNDLE_URL || DEFAULT_BUNDLE_BASE_URL,
|
||||||
|
bundleReleaseTag = process.env.CLOUDCLI_SERVER_BUNDLE_RELEASE_TAG || '',
|
||||||
|
onLog,
|
||||||
|
} = {}) {
|
||||||
|
if (!version) throw new Error('ServerInstaller requires the app version');
|
||||||
|
this.version = version;
|
||||||
|
this.platform = mapPlatform(platform);
|
||||||
|
this.arch = mapArch(arch);
|
||||||
|
this.installRoot = installRoot;
|
||||||
|
this.bundleBaseUrl = bundleBaseUrl.replace(/\/+$/, '');
|
||||||
|
this.bundleReleaseTag = bundleReleaseTag || `v${this.version}`;
|
||||||
|
this.onLog = typeof onLog === 'function' ? onLog : () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Directory the current version's server is (or will be) installed in. */
|
||||||
|
getVersionDir() {
|
||||||
|
return path.join(this.installRoot, this.version);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Absolute path to the server entry once installed. */
|
||||||
|
getServerEntry() {
|
||||||
|
return path.join(this.getVersionDir(), 'dist-server', 'server', 'index.js');
|
||||||
|
}
|
||||||
|
|
||||||
|
getBundleName() {
|
||||||
|
return `cloudcli-local-server-${this.version}-${this.platform}-${this.arch}.tar.gz`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getBundleUrl() {
|
||||||
|
const url = new URL(`${this.bundleBaseUrl}/${this.bundleReleaseTag}/${this.getBundleName()}`);
|
||||||
|
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && LOCAL_DOWNLOAD_HOSTS.has(url.hostname))) {
|
||||||
|
throw new Error(`Refusing unsupported server bundle URL: ${url.toString()}`);
|
||||||
|
}
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
log(line) {
|
||||||
|
this.onLog(String(line));
|
||||||
|
}
|
||||||
|
|
||||||
|
async isInstalled() {
|
||||||
|
try {
|
||||||
|
const marker = JSON.parse(
|
||||||
|
await fs.readFile(path.join(this.getVersionDir(), '.installed.json'), 'utf8'),
|
||||||
|
);
|
||||||
|
if (marker.version !== this.version) return false;
|
||||||
|
await fs.access(this.getServerEntry());
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures the server for this version is installed, downloading + extracting
|
||||||
|
* it if needed. Returns the resolved server entry path.
|
||||||
|
*/
|
||||||
|
async ensureInstalled() {
|
||||||
|
if (await this.isInstalled()) {
|
||||||
|
this.log(`Local server ${this.version} already installed.`);
|
||||||
|
return this.getServerEntry();
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionDir = this.getVersionDir();
|
||||||
|
const tmpDir = path.join(this.installRoot, `.tmp-${this.version}-${process.pid}`);
|
||||||
|
const archivePath = path.join(tmpDir, this.getBundleName());
|
||||||
|
|
||||||
|
await fs.mkdir(tmpDir, { recursive: true });
|
||||||
|
try {
|
||||||
|
const url = this.getBundleUrl();
|
||||||
|
this.log(`Downloading local server bundle…`);
|
||||||
|
this.log(url);
|
||||||
|
await this.#download(url, archivePath);
|
||||||
|
await this.#verifyChecksum(url, archivePath);
|
||||||
|
|
||||||
|
this.log('Extracting local server…');
|
||||||
|
await fs.rm(versionDir, { recursive: true, force: true });
|
||||||
|
await fs.mkdir(versionDir, { recursive: true });
|
||||||
|
await this.#validateArchive(archivePath);
|
||||||
|
await this.#extract(archivePath, versionDir);
|
||||||
|
|
||||||
|
const entry = this.getServerEntry();
|
||||||
|
await fs.access(entry);
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(versionDir, '.installed.json'),
|
||||||
|
JSON.stringify({ version: this.version, installedAt: new Date().toISOString() }, null, 2),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
this.log(`Local server ${this.version} installed.`);
|
||||||
|
return entry;
|
||||||
|
} catch (error) {
|
||||||
|
await fs.rm(versionDir, { recursive: true, force: true }).catch(() => {});
|
||||||
|
throw new Error(`Failed to install local server: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#download(url, destPath, redirectsLeft = MAX_REDIRECTS) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = https.get(url, (res) => {
|
||||||
|
const { statusCode, headers } = res;
|
||||||
|
|
||||||
|
if (statusCode >= 300 && statusCode < 400 && headers.location) {
|
||||||
|
res.resume();
|
||||||
|
if (redirectsLeft <= 0) {
|
||||||
|
reject(new Error('Too many redirects'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = new URL(headers.location, url).toString();
|
||||||
|
resolve(this.#download(next, destPath, redirectsLeft - 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusCode !== 200) {
|
||||||
|
res.resume();
|
||||||
|
reject(new Error(`Download failed with HTTP ${statusCode}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = Number(headers['content-length']) || 0;
|
||||||
|
let received = 0;
|
||||||
|
let lastPct = -1;
|
||||||
|
const out = createWriteStream(destPath);
|
||||||
|
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
received += chunk.length;
|
||||||
|
if (total) {
|
||||||
|
const pct = Math.floor((received / total) * 100);
|
||||||
|
if (pct !== lastPct && pct % 10 === 0) {
|
||||||
|
lastPct = pct;
|
||||||
|
this.log(`Downloading… ${pct}%`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.pipe(out);
|
||||||
|
out.on('finish', () => out.close(resolve));
|
||||||
|
out.on('error', reject);
|
||||||
|
res.on('error', reject);
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async #verifyChecksum(url, archivePath) {
|
||||||
|
let expected;
|
||||||
|
try {
|
||||||
|
expected = (await this.#fetchText(`${url}.sha256`)).trim().split(/\s+/)[0];
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Could not verify server bundle checksum: ${error.message}`);
|
||||||
|
}
|
||||||
|
const actual = await this.#sha256(archivePath);
|
||||||
|
if (expected.toLowerCase() !== actual.toLowerCase()) {
|
||||||
|
throw new Error('Checksum mismatch — refusing to install');
|
||||||
|
}
|
||||||
|
this.log('Checksum verified.');
|
||||||
|
}
|
||||||
|
|
||||||
|
#fetchText(url, redirectsLeft = MAX_REDIRECTS) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
https
|
||||||
|
.get(url, (res) => {
|
||||||
|
const { statusCode, headers } = res;
|
||||||
|
if (statusCode >= 300 && statusCode < 400 && headers.location) {
|
||||||
|
res.resume();
|
||||||
|
if (redirectsLeft <= 0) return reject(new Error('Too many redirects'));
|
||||||
|
return resolve(this.#fetchText(new URL(headers.location, url).toString(), redirectsLeft - 1));
|
||||||
|
}
|
||||||
|
if (statusCode !== 200) {
|
||||||
|
res.resume();
|
||||||
|
return reject(new Error(`HTTP ${statusCode}`));
|
||||||
|
}
|
||||||
|
let body = '';
|
||||||
|
res.setEncoding('utf8');
|
||||||
|
res.on('data', (c) => (body += c));
|
||||||
|
res.on('end', () => resolve(body));
|
||||||
|
res.on('error', reject);
|
||||||
|
})
|
||||||
|
.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#sha256(filePath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const hash = crypto.createHash('sha256');
|
||||||
|
const stream = createReadStream(filePath);
|
||||||
|
stream.on('data', (c) => hash.update(c));
|
||||||
|
stream.on('end', () => resolve(hash.digest('hex')));
|
||||||
|
stream.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#extract(archivePath, destDir) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn('tar', ['-xzf', archivePath, '-C', destDir], {
|
||||||
|
stdio: ['ignore', 'ignore', 'pipe'],
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
let stderr = '';
|
||||||
|
child.stderr?.on('data', (c) => (stderr += c));
|
||||||
|
child.once('error', reject);
|
||||||
|
child.once('exit', (code) => {
|
||||||
|
if (code === 0) resolve();
|
||||||
|
else reject(new Error(`tar exited with code ${code}: ${stderr.trim()}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#validateArchive(archivePath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn('tar', ['-tzf', archivePath], {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
child.stdout?.on('data', (c) => { stdout += c; });
|
||||||
|
child.stderr?.on('data', (c) => { stderr += c; });
|
||||||
|
child.once('error', reject);
|
||||||
|
child.once('exit', (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(`tar list exited with code ${code}: ${stderr.trim()}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const entry of stdout.split(/\r?\n/).filter(Boolean)) {
|
||||||
|
const normalized = entry.replace(/\\/g, '/');
|
||||||
|
if (
|
||||||
|
path.isAbsolute(normalized)
|
||||||
|
|| /^[a-zA-Z]:\//.test(normalized)
|
||||||
|
|| normalized.split('/').includes('..')
|
||||||
|
) {
|
||||||
|
reject(new Error(`Refusing unsafe archive entry: ${entry}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
87
electron/tabs.js
Normal file
87
electron/tabs.js
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
export class TabsController {
|
||||||
|
constructor() {
|
||||||
|
this.activeTabId = 'home';
|
||||||
|
this.tabs = [
|
||||||
|
{
|
||||||
|
id: 'home',
|
||||||
|
title: 'Launcher',
|
||||||
|
kind: 'launcher',
|
||||||
|
closable: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
getTabIdForTarget(target) {
|
||||||
|
if (target.kind === 'launcher') return 'home';
|
||||||
|
if (target.kind === 'remote' && target.id) return `remote:${target.id}`;
|
||||||
|
return target.kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertTarget(target) {
|
||||||
|
const tabId = this.getTabIdForTarget(target);
|
||||||
|
const existingTab = this.tabs.find((tab) => tab.id === tabId);
|
||||||
|
const nextTab = {
|
||||||
|
id: tabId,
|
||||||
|
title: target.kind === 'launcher' ? 'Launcher' : target.name,
|
||||||
|
kind: target.kind,
|
||||||
|
target,
|
||||||
|
closable: tabId !== 'home',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existingTab) {
|
||||||
|
Object.assign(existingTab, nextTab);
|
||||||
|
} else {
|
||||||
|
this.tabs.push(nextTab);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.activeTabId = tabId;
|
||||||
|
return nextTab;
|
||||||
|
}
|
||||||
|
|
||||||
|
activate(tabId) {
|
||||||
|
const tab = this.tabs.find((item) => item.id === tabId);
|
||||||
|
if (!tab) return null;
|
||||||
|
this.activeTabId = tab.id;
|
||||||
|
return tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(tabId) {
|
||||||
|
const tab = this.tabs.find((item) => item.id === tabId);
|
||||||
|
if (!tab || !tab.closable) return null;
|
||||||
|
this.tabs = this.tabs.filter((item) => item.id !== tabId);
|
||||||
|
if (this.activeTabId === tabId) {
|
||||||
|
this.activeTabId = 'home';
|
||||||
|
}
|
||||||
|
return tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeByKind(kind) {
|
||||||
|
const removed = this.tabs.filter((tab) => tab.kind === kind && tab.closable);
|
||||||
|
if (!removed.length) return [];
|
||||||
|
|
||||||
|
const removedIds = new Set(removed.map((tab) => tab.id));
|
||||||
|
this.tabs = this.tabs.filter((tab) => !removedIds.has(tab.id));
|
||||||
|
if (removedIds.has(this.activeTabId)) {
|
||||||
|
this.activeTabId = 'home';
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveTab() {
|
||||||
|
return this.getTab(this.activeTabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getTab(tabId) {
|
||||||
|
return this.tabs.find((item) => item.id === tabId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSerializableTabs() {
|
||||||
|
return this.tabs.map((tab) => ({
|
||||||
|
id: tab.id,
|
||||||
|
title: tab.title,
|
||||||
|
kind: tab.kind,
|
||||||
|
closable: tab.closable,
|
||||||
|
active: tab.id === this.activeTabId,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
331
electron/viewHost.js
Normal file
331
electron/viewHost.js
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
import { BrowserView } from 'electron';
|
||||||
|
|
||||||
|
const TARGET_LOAD_TIMEOUT_MS = 20000;
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value == null ? '' : value)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlaceholderHtml(title, message, logs = []) {
|
||||||
|
const logHtml = logs.length
|
||||||
|
? `<pre>${logs.map(escapeHtml).join('\n')}</pre>`
|
||||||
|
: '<pre>Waiting for process output...</pre>';
|
||||||
|
return [
|
||||||
|
'<!doctype html><meta charset="utf-8">',
|
||||||
|
'<style>',
|
||||||
|
'html,body{margin:0;height:100%;background:#0a0a0a;color:#fafafa;font:14px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}',
|
||||||
|
'body{padding:28px;overflow:hidden}',
|
||||||
|
'.shell{height:100%;display:flex;flex-direction:column;gap:16px}',
|
||||||
|
'.box{display:flex;align-items:center;gap:10px;color:#d4d4d4;flex:0 0 auto}',
|
||||||
|
'.dot{width:8px;height:8px;border-radius:50%;background:#0b60ea;box-shadow:0 0 0 6px rgba(11,96,234,.15)}',
|
||||||
|
'pre{margin:0;flex:1;overflow:auto;border:1px solid #262626;border-radius:10px;background:#050505;color:#d4d4d4;padding:14px;font:12px/1.55 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;white-space:pre-wrap;user-select:text}',
|
||||||
|
'</style>',
|
||||||
|
'<div class="shell">',
|
||||||
|
`<div class="box"><span class="dot"></span><span>${escapeHtml(message || `Opening ${title}...`)}</span></div>`,
|
||||||
|
logHtml,
|
||||||
|
'</div>',
|
||||||
|
].join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHttpUrl(url) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUrlWithTimeout(webContents, url, timeoutMs = TARGET_LOAD_TIMEOUT_MS) {
|
||||||
|
let timedOut = false;
|
||||||
|
let timeout = null;
|
||||||
|
const loadPromise = webContents.loadURL(url);
|
||||||
|
const timeoutPromise = new Promise((_, reject) => {
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
try {
|
||||||
|
webContents.stop();
|
||||||
|
} catch {
|
||||||
|
// Ignore teardown races while reporting the original timeout.
|
||||||
|
}
|
||||||
|
reject(new Error(`Timed out loading ${url} after ${Math.round(timeoutMs / 1000)} seconds.`));
|
||||||
|
}, timeoutMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.race([loadPromise, timeoutPromise]);
|
||||||
|
} catch (error) {
|
||||||
|
if (timedOut) {
|
||||||
|
loadPromise.catch(() => {});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ViewHost {
|
||||||
|
constructor({ appName, getMainWindow, getContentViewBounds, getPreloadPath, openExternalUrl, showError }) {
|
||||||
|
this.appName = appName;
|
||||||
|
this.getMainWindow = getMainWindow;
|
||||||
|
this.getContentViewBounds = getContentViewBounds;
|
||||||
|
this.getPreloadPath = getPreloadPath;
|
||||||
|
this.openExternalUrl = openExternalUrl;
|
||||||
|
this.showError = showError;
|
||||||
|
this.activeContentView = null;
|
||||||
|
this.tabViews = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
configureChildWebContents(webContents) {
|
||||||
|
webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
void this.openExternalUrl(url).catch((error) => this.showError('Could not open external link', error));
|
||||||
|
return { action: 'deny' };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
detachAll() {
|
||||||
|
const mainWindow = this.getMainWindow();
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||||
|
try {
|
||||||
|
for (const view of mainWindow.getBrowserViews()) {
|
||||||
|
mainWindow.removeBrowserView(view);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// BrowserViews may already be gone during BrowserWindow teardown.
|
||||||
|
}
|
||||||
|
this.activeContentView = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
detachActiveView() {
|
||||||
|
const mainWindow = this.getMainWindow();
|
||||||
|
const view = this.activeContentView;
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed() || !view) return false;
|
||||||
|
try {
|
||||||
|
if (mainWindow.getBrowserViews().includes(view)) {
|
||||||
|
mainWindow.removeBrowserView(view);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.activeContentView = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveView() {
|
||||||
|
const view = this.activeContentView;
|
||||||
|
if (!view || view.webContents.isDestroyed()) return null;
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
openActiveViewDevTools() {
|
||||||
|
const view = this.getActiveView();
|
||||||
|
if (!view) return false;
|
||||||
|
view.webContents.openDevTools({ mode: 'detach' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
reloadActiveView() {
|
||||||
|
const view = this.getActiveView();
|
||||||
|
if (!view) return false;
|
||||||
|
view.webContents.reloadIgnoringCache();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async readLocalStorageValueForOrigin(originUrl, key) {
|
||||||
|
let targetOrigin;
|
||||||
|
try {
|
||||||
|
targetOrigin = new URL(originUrl).origin;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const view of this.tabViews.values()) {
|
||||||
|
if (!view || view.webContents.isDestroyed()) continue;
|
||||||
|
let viewOrigin;
|
||||||
|
try {
|
||||||
|
viewOrigin = new URL(view.webContents.getURL()).origin;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (viewOrigin !== targetOrigin) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const value = await view.webContents.executeJavaScript(
|
||||||
|
`window.localStorage.getItem(${JSON.stringify(key)})`,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
return typeof value === 'string' && value ? value : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTabViewDiagnostics() {
|
||||||
|
const mainWindow = this.getMainWindow();
|
||||||
|
const attachedViews = new Set();
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
try {
|
||||||
|
for (const view of mainWindow.getBrowserViews()) {
|
||||||
|
attachedViews.add(view);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore teardown races while gathering best-effort diagnostics.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(this.tabViews.entries()).map(([tabId, view]) => {
|
||||||
|
const { webContents } = view;
|
||||||
|
const destroyed = webContents.isDestroyed();
|
||||||
|
return {
|
||||||
|
tabId,
|
||||||
|
webContentsId: destroyed ? null : webContents.id,
|
||||||
|
url: destroyed ? null : webContents.getURL(),
|
||||||
|
title: destroyed ? null : webContents.getTitle(),
|
||||||
|
osProcessId: destroyed || typeof webContents.getOSProcessId !== 'function' ? null : webContents.getOSProcessId(),
|
||||||
|
processId: destroyed || typeof webContents.getProcessId !== 'function' ? null : webContents.getProcessId(),
|
||||||
|
attached: attachedViews.has(view),
|
||||||
|
active: this.activeContentView === view,
|
||||||
|
destroyed,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getOrCreateTabView(tabId) {
|
||||||
|
let view = this.tabViews.get(tabId);
|
||||||
|
if (view) return view;
|
||||||
|
|
||||||
|
view = new BrowserView({
|
||||||
|
webPreferences: {
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: true,
|
||||||
|
preload: this.getPreloadPath(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.configureChildWebContents(view.webContents);
|
||||||
|
this.tabViews.set(tabId, view);
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
attach(view) {
|
||||||
|
const mainWindow = this.getMainWindow();
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||||
|
if (this.activeContentView && this.activeContentView !== view) {
|
||||||
|
this.detachAll();
|
||||||
|
}
|
||||||
|
this.activeContentView = view;
|
||||||
|
try {
|
||||||
|
if (!mainWindow.getBrowserViews().includes(view)) {
|
||||||
|
mainWindow.addBrowserView(view);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
view.setBounds(this.getContentViewBounds());
|
||||||
|
view.setAutoResize({ width: true, height: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
resizeActiveView() {
|
||||||
|
if (this.activeContentView) {
|
||||||
|
this.activeContentView.setBounds(this.getContentViewBounds());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async showTabPlaceholder(tabId, target, message) {
|
||||||
|
const view = this.getOrCreateTabView(tabId);
|
||||||
|
this.attach(view);
|
||||||
|
const html = buildPlaceholderHtml(target.name || this.appName, message);
|
||||||
|
await view.webContents.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
|
||||||
|
view.__cloudcliStartupHtml = html;
|
||||||
|
view.__cloudcliLoadedUrl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async showLocalStartupTarget(tabId, target, logs) {
|
||||||
|
const view = this.getOrCreateTabView(tabId);
|
||||||
|
if (view.__cloudcliLoadingUrl) return;
|
||||||
|
this.attach(view);
|
||||||
|
const html = buildPlaceholderHtml(target.name || this.appName, 'Starting Local CloudCLI...', logs);
|
||||||
|
if (view.__cloudcliStartupHtml === html) return;
|
||||||
|
await view.webContents.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
|
||||||
|
view.__cloudcliStartupHtml = html;
|
||||||
|
view.__cloudcliLoadedUrl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async showContentTarget(tabId, target) {
|
||||||
|
const loadUrl = target.loadUrl || target.url;
|
||||||
|
if (!isHttpUrl(loadUrl)) {
|
||||||
|
throw new Error(`Refusing to load unsupported app URL: ${loadUrl}`);
|
||||||
|
}
|
||||||
|
const view = this.getOrCreateTabView(tabId);
|
||||||
|
this.attach(view);
|
||||||
|
if (target.forceLoad || view.__cloudcliLoadedUrl !== target.url) {
|
||||||
|
view.__cloudcliLoadingUrl = loadUrl;
|
||||||
|
try {
|
||||||
|
await loadUrlWithTimeout(view.webContents, loadUrl);
|
||||||
|
view.__cloudcliLoadedUrl = target.url;
|
||||||
|
view.__cloudcliStartupHtml = null;
|
||||||
|
delete target.loadUrl;
|
||||||
|
delete target.forceLoad;
|
||||||
|
} finally {
|
||||||
|
if (view.__cloudcliLoadingUrl === loadUrl) {
|
||||||
|
view.__cloudcliLoadingUrl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return view.webContents.getURL();
|
||||||
|
}
|
||||||
|
|
||||||
|
reloadTab(tabId) {
|
||||||
|
const view = this.tabViews.get(tabId);
|
||||||
|
if (!view || view.webContents.isDestroyed()) return false;
|
||||||
|
view.webContents.reloadIgnoringCache();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async navigateActiveView(url) {
|
||||||
|
const view = this.getActiveView();
|
||||||
|
if (!view) return false;
|
||||||
|
await loadUrlWithTimeout(view.webContents, url);
|
||||||
|
view.__cloudcliLoadedUrl = url;
|
||||||
|
view.__cloudcliStartupHtml = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroyTabView(tabId) {
|
||||||
|
const view = this.tabViews.get(tabId);
|
||||||
|
if (!view) return;
|
||||||
|
const mainWindow = this.getMainWindow();
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
try {
|
||||||
|
if (mainWindow.getBrowserViews().includes(view)) {
|
||||||
|
mainWindow.removeBrowserView(view);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore teardown races; Electron owns final destruction during quit.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.activeContentView === view) {
|
||||||
|
this.activeContentView = null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!view.webContents.isDestroyed()) {
|
||||||
|
view.webContents.destroy();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// The view may already be destroyed by its parent BrowserWindow.
|
||||||
|
}
|
||||||
|
this.tabViews.delete(tabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.tabViews.clear();
|
||||||
|
this.activeContentView = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
1488
package-lock.json
generated
1488
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
39
package.json
39
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@cloudcli-ai/cloudcli",
|
"name": "@cloudcli-ai/cloudcli",
|
||||||
"version": "1.34.0",
|
"version": "1.35.0",
|
||||||
"productName": "CloudCLI",
|
"productName": "CloudCLI",
|
||||||
"description": "A web-based UI for Claude Code CLI",
|
"description": "A web-based UI for Claude Code CLI",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -34,8 +34,12 @@
|
|||||||
"client": "vite",
|
"client": "vite",
|
||||||
"desktop": "electron electron/main.js",
|
"desktop": "electron electron/main.js",
|
||||||
"desktop:dev": "cross-env ELECTRON_DEV_URL=http://127.0.0.1:5173 electron electron/main.js",
|
"desktop:dev": "cross-env ELECTRON_DEV_URL=http://127.0.0.1:5173 electron electron/main.js",
|
||||||
"desktop:pack": "npm run build && electron-builder --dir",
|
"desktop:stage": "node scripts/release/prepare-desktop-app.js",
|
||||||
"desktop:dist:mac": "npm run build && electron-builder --mac dmg zip",
|
"desktop:pack": "npm run build && npm run desktop:stage && electron-builder --projectDir .desktop-build/desktop-app --dir",
|
||||||
|
"desktop:dist:mac": "npm run build && npm run desktop:stage && electron-builder --projectDir .desktop-build/desktop-app --mac dmg",
|
||||||
|
"desktop:dist:win": "npm run build && npm run desktop:stage && electron-builder --projectDir .desktop-build/desktop-app --win nsis",
|
||||||
|
"server:bundle": "npm run build && node scripts/release/build-server-bundle.js",
|
||||||
|
"desktop:icon:mac": "node electron/scripts/generate-macos-icon.js",
|
||||||
"build": "npm run build:client && npm run build:server",
|
"build": "npm run build:client && npm run build:server",
|
||||||
"build:client": "vite build",
|
"build:client": "vite build",
|
||||||
"prebuild:server": "node -e \"require('node:fs').rmSync('dist-server', { recursive: true, force: true })\"",
|
"prebuild:server": "node -e \"require('node:fs').rmSync('dist-server', { recursive: true, force: true })\"",
|
||||||
@@ -54,9 +58,10 @@
|
|||||||
"build": {
|
"build": {
|
||||||
"appId": "ai.cloudcli.desktop",
|
"appId": "ai.cloudcli.desktop",
|
||||||
"productName": "CloudCLI",
|
"productName": "CloudCLI",
|
||||||
"artifactName": "CloudCLI-${version}-${arch}.${ext}",
|
"asar": false,
|
||||||
|
"artifactName": "cloudcli-desktop-${version}-${os}-${arch}.${ext}",
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "release"
|
"output": "release/desktop"
|
||||||
},
|
},
|
||||||
"extraMetadata": {
|
"extraMetadata": {
|
||||||
"main": "electron/main.js"
|
"main": "electron/main.js"
|
||||||
@@ -68,7 +73,8 @@
|
|||||||
"dist-server/",
|
"dist-server/",
|
||||||
"shared/",
|
"shared/",
|
||||||
"server/",
|
"server/",
|
||||||
"package.json"
|
"package.json",
|
||||||
|
"!**/node_modules/@anthropic-ai/claude-agent-sdk-{darwin,linux,win32}-*/**"
|
||||||
],
|
],
|
||||||
"protocols": [
|
"protocols": [
|
||||||
{
|
{
|
||||||
@@ -80,9 +86,10 @@
|
|||||||
],
|
],
|
||||||
"mac": {
|
"mac": {
|
||||||
"category": "public.app-category.developer-tools",
|
"category": "public.app-category.developer-tools",
|
||||||
|
"icon": "electron/assets/logo-macos.icns",
|
||||||
|
"notarize": true,
|
||||||
"target": [
|
"target": [
|
||||||
"dmg",
|
"dmg"
|
||||||
"zip"
|
|
||||||
],
|
],
|
||||||
"extendInfo": {
|
"extendInfo": {
|
||||||
"CFBundleName": "CloudCLI",
|
"CFBundleName": "CloudCLI",
|
||||||
@@ -96,6 +103,16 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"win": {
|
||||||
|
"icon": "electron/assets/logo-windows.ico",
|
||||||
|
"target": [
|
||||||
|
"nsis"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nsis": {
|
||||||
|
"installerIcon": "electron/assets/logo-windows.ico",
|
||||||
|
"uninstallerIcon": "electron/assets/logo-windows.ico"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -130,7 +147,7 @@
|
|||||||
"@codemirror/theme-one-dark": "^6.1.2",
|
"@codemirror/theme-one-dark": "^6.1.2",
|
||||||
"@iarna/toml": "^2.2.5",
|
"@iarna/toml": "^2.2.5",
|
||||||
"@octokit/rest": "^22.0.0",
|
"@octokit/rest": "^22.0.0",
|
||||||
"@openai/codex-sdk": "^0.125.0",
|
"@openai/codex-sdk": "^0.141.0",
|
||||||
"@replit/codemirror-minimap": "^0.5.2",
|
"@replit/codemirror-minimap": "^0.5.2",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@uiw/react-codemirror": "^4.23.13",
|
"@uiw/react-codemirror": "^4.23.13",
|
||||||
@@ -223,5 +240,9 @@
|
|||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"src/**/*.{ts,tsx,js,jsx}": "eslint",
|
"src/**/*.{ts,tsx,js,jsx}": "eslint",
|
||||||
"server/**/*.{js,ts}": "eslint"
|
"server/**/*.{js,ts}": "eslint"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@nut-tree-fork/nut-js": "^4.2.6",
|
||||||
|
"screenshot-desktop": "^1.15.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
176
scripts/release/build-server-bundle.js
Normal file
176
scripts/release/build-server-bundle.js
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { createReadStream, readFileSync } from 'node:fs';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const rootDir = path.resolve(__dirname, '..', '..');
|
||||||
|
const packageJson = JSON.parse(
|
||||||
|
await fs.readFile(path.join(rootDir, 'package.json'), 'utf8'),
|
||||||
|
);
|
||||||
|
|
||||||
|
function getElectronVersion() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(
|
||||||
|
readFileSync(path.join(rootDir, 'node_modules', 'electron', 'package.json'), 'utf8'),
|
||||||
|
).version;
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
return JSON.parse(
|
||||||
|
readFileSync(path.join(rootDir, 'package-lock.json'), 'utf8'),
|
||||||
|
).packages['node_modules/electron'].version;
|
||||||
|
} catch {
|
||||||
|
throw new Error('Could not resolve an exact Electron version for server native rebuild.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapArch(arch = process.arch) {
|
||||||
|
return arch === 'arm64' ? 'arm64' : 'x64';
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapPlatform(platform = process.platform) {
|
||||||
|
if (platform === 'darwin') return 'mac';
|
||||||
|
if (platform === 'win32') return 'win';
|
||||||
|
return 'linux';
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(command, args, options = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(command, args, {
|
||||||
|
stdio: 'inherit',
|
||||||
|
shell: process.platform === 'win32',
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
child.once('error', reject);
|
||||||
|
child.once('exit', (code) => {
|
||||||
|
if (code === 0) resolve();
|
||||||
|
else reject(new Error(`${command} ${args.join(' ')} exited with code ${code}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pathExists(filePath) {
|
||||||
|
try {
|
||||||
|
await fs.access(filePath);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyRequired(stageDir, relativePath) {
|
||||||
|
const from = path.join(rootDir, relativePath);
|
||||||
|
if (!(await pathExists(from))) {
|
||||||
|
throw new Error(`Required server bundle input is missing: ${relativePath}`);
|
||||||
|
}
|
||||||
|
await fs.cp(from, path.join(stageDir, relativePath), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyIfExists(stageDir, relativePath) {
|
||||||
|
const from = path.join(rootDir, relativePath);
|
||||||
|
if (!(await pathExists(from))) return;
|
||||||
|
await fs.cp(from, path.join(stageDir, relativePath), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeServerPackageJson(stageDir) {
|
||||||
|
const stagedPackageJson = {
|
||||||
|
...packageJson,
|
||||||
|
scripts: {
|
||||||
|
...(packageJson.scripts || {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// The bundle stage is not a git checkout with dev dependencies, so lifecycle
|
||||||
|
// scripts such as Husky prepare must not run there. Dependency install scripts
|
||||||
|
// still run; native modules need them before the Electron ABI rebuild below.
|
||||||
|
delete stagedPackageJson.scripts.postinstall;
|
||||||
|
delete stagedPackageJson.scripts.prepare;
|
||||||
|
delete stagedPackageJson.scripts.prepublishOnly;
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(stageDir, 'package.json'),
|
||||||
|
`${JSON.stringify(stagedPackageJson, null, 2)}\n`,
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256(filePath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const hash = crypto.createHash('sha256');
|
||||||
|
const stream = createReadStream(filePath);
|
||||||
|
stream.on('data', (chunk) => hash.update(chunk));
|
||||||
|
stream.on('end', () => resolve(hash.digest('hex')));
|
||||||
|
stream.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const platform = mapPlatform(process.env.CLOUDCLI_BUNDLE_PLATFORM || process.platform);
|
||||||
|
const arch = mapArch(process.env.CLOUDCLI_BUNDLE_ARCH || process.arch);
|
||||||
|
const version = packageJson.version;
|
||||||
|
const bundleName = `cloudcli-local-server-${version}-${platform}-${arch}.tar.gz`;
|
||||||
|
const bundleRoot = path.join(rootDir, 'release', 'local-server');
|
||||||
|
const stageDir = path.join(bundleRoot, `.stage-${version}-${platform}-${arch}`);
|
||||||
|
const archivePath = path.join(bundleRoot, bundleName);
|
||||||
|
|
||||||
|
await fs.rm(stageDir, { recursive: true, force: true });
|
||||||
|
await fs.mkdir(stageDir, { recursive: true });
|
||||||
|
await fs.mkdir(bundleRoot, { recursive: true });
|
||||||
|
|
||||||
|
await copyRequired(stageDir, 'dist');
|
||||||
|
await copyRequired(stageDir, 'dist-server');
|
||||||
|
await copyRequired(stageDir, 'public');
|
||||||
|
await copyRequired(stageDir, 'shared');
|
||||||
|
await copyRequired(stageDir, 'package-lock.json');
|
||||||
|
await copyIfExists(stageDir, 'scripts/fix-node-pty.js');
|
||||||
|
await writeServerPackageJson(stageDir);
|
||||||
|
|
||||||
|
console.log('Installing production server dependencies into bundle stage...');
|
||||||
|
await run('npm', ['ci', '--omit=dev'], {
|
||||||
|
cwd: stageDir,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
npm_config_audit: 'false',
|
||||||
|
npm_config_fund: 'false',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const electronVersion = getElectronVersion();
|
||||||
|
const electronRebuild = process.platform === 'win32'
|
||||||
|
? path.join(rootDir, 'node_modules', '.bin', 'electron-rebuild.cmd')
|
||||||
|
: path.join(rootDir, 'node_modules', '.bin', 'electron-rebuild');
|
||||||
|
console.log(`Rebuilding native server dependencies for Electron ${electronVersion} (${arch})...`);
|
||||||
|
await run(electronRebuild, ['--version', electronVersion, '--module-dir', stageDir, '--arch', arch, '--force'], {
|
||||||
|
cwd: rootDir,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
npm_config_audit: 'false',
|
||||||
|
npm_config_fund: 'false',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (await pathExists(path.join(stageDir, 'scripts', 'fix-node-pty.js'))) {
|
||||||
|
await run(process.execPath, ['scripts/fix-node-pty.js'], { cwd: stageDir });
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(stageDir, '.installed.json'),
|
||||||
|
JSON.stringify({ version, platform, arch, builtAt: new Date().toISOString() }, null, 2),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
await fs.rm(archivePath, { force: true });
|
||||||
|
const tarArgs = process.platform === 'win32'
|
||||||
|
? ['-czf', archivePath, '-C', stageDir, '.']
|
||||||
|
: ['-czf', archivePath, '-C', stageDir, '.'];
|
||||||
|
await run('tar', tarArgs);
|
||||||
|
|
||||||
|
const digest = await sha256(archivePath);
|
||||||
|
const checksumPath = `${archivePath}.sha256`;
|
||||||
|
await fs.writeFile(checksumPath, `${digest} ${bundleName}\n`, 'utf8');
|
||||||
|
await fs.rm(stageDir, { recursive: true, force: true });
|
||||||
|
|
||||||
|
const size = (await fs.stat(archivePath)).size / 1024 / 1024;
|
||||||
|
console.log(`Wrote ${path.relative(rootDir, archivePath)} (${size.toFixed(1)} MB)`);
|
||||||
|
console.log(`Wrote ${path.relative(rootDir, checksumPath)}`);
|
||||||
152
scripts/release/prepare-desktop-app.js
Normal file
152
scripts/release/prepare-desktop-app.js
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const rootDir = path.resolve(__dirname, '..', '..');
|
||||||
|
const stageDir = path.join(rootDir, '.desktop-build', 'desktop-app');
|
||||||
|
|
||||||
|
const packageJson = JSON.parse(
|
||||||
|
await fs.readFile(path.join(rootDir, 'package.json'), 'utf8'),
|
||||||
|
);
|
||||||
|
|
||||||
|
function getElectronVersion() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(
|
||||||
|
readFileSync(path.join(rootDir, 'node_modules', 'electron', 'package.json'), 'utf8'),
|
||||||
|
).version;
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
return JSON.parse(
|
||||||
|
readFileSync(path.join(rootDir, 'package-lock.json'), 'utf8'),
|
||||||
|
).packages['node_modules/electron'].version;
|
||||||
|
} catch {
|
||||||
|
throw new Error('Could not resolve an exact Electron version for desktop packaging.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pathExists(filePath) {
|
||||||
|
try {
|
||||||
|
await fs.access(filePath);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyRequired(relativePath) {
|
||||||
|
const from = path.join(rootDir, relativePath);
|
||||||
|
const to = path.join(stageDir, relativePath);
|
||||||
|
if (!(await pathExists(from))) {
|
||||||
|
throw new Error(`Required desktop build input is missing: ${relativePath}`);
|
||||||
|
}
|
||||||
|
await fs.cp(from, to, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyIfExists(relativePath) {
|
||||||
|
const from = path.join(rootDir, relativePath);
|
||||||
|
if (!(await pathExists(from))) return false;
|
||||||
|
await fs.cp(from, path.join(stageDir, relativePath), { recursive: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyNodeModule(packageName) {
|
||||||
|
const parts = packageName.split('/');
|
||||||
|
const source = path.join(rootDir, 'node_modules', ...parts);
|
||||||
|
if (!(await pathExists(source))) return false;
|
||||||
|
|
||||||
|
const target = path.join(stageDir, 'node_modules', ...parts);
|
||||||
|
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||||
|
await fs.cp(source, target, { recursive: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDesktopPackageJson(copiedOptionalDependencies) {
|
||||||
|
return {
|
||||||
|
name: `${packageJson.name}-desktop`,
|
||||||
|
version: packageJson.version,
|
||||||
|
productName: packageJson.productName,
|
||||||
|
description: `${packageJson.productName} desktop shell`,
|
||||||
|
author: packageJson.author,
|
||||||
|
license: packageJson.license,
|
||||||
|
type: 'module',
|
||||||
|
main: 'electron/main.js',
|
||||||
|
dependencies: {
|
||||||
|
ws: packageJson.dependencies.ws,
|
||||||
|
},
|
||||||
|
optionalDependencies: copiedOptionalDependencies,
|
||||||
|
build: {
|
||||||
|
appId: packageJson.build.appId,
|
||||||
|
productName: packageJson.build.productName,
|
||||||
|
asar: packageJson.build.asar,
|
||||||
|
artifactName: packageJson.build.artifactName,
|
||||||
|
electronVersion: getElectronVersion(),
|
||||||
|
directories: {
|
||||||
|
output: '../../release/desktop',
|
||||||
|
},
|
||||||
|
extraMetadata: {
|
||||||
|
main: 'electron/main.js',
|
||||||
|
},
|
||||||
|
files: [
|
||||||
|
'electron/**',
|
||||||
|
'public/**',
|
||||||
|
'dist/**',
|
||||||
|
'dist-server/**',
|
||||||
|
'node_modules/**',
|
||||||
|
'package.json',
|
||||||
|
],
|
||||||
|
protocols: packageJson.build.protocols,
|
||||||
|
mac: packageJson.build.mac,
|
||||||
|
win: packageJson.build.win,
|
||||||
|
nsis: packageJson.build.nsis,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.rm(stageDir, { recursive: true, force: true });
|
||||||
|
await fs.mkdir(stageDir, { recursive: true });
|
||||||
|
|
||||||
|
await copyRequired('electron');
|
||||||
|
await copyRequired('dist');
|
||||||
|
await copyRequired('public');
|
||||||
|
|
||||||
|
const copiedRuntimeDependencies = [];
|
||||||
|
if (await copyNodeModule('ws')) {
|
||||||
|
copiedRuntimeDependencies.push('ws');
|
||||||
|
} else {
|
||||||
|
throw new Error('Required desktop dependency is missing from node_modules: ws');
|
||||||
|
}
|
||||||
|
|
||||||
|
const copiedOptionalDependencies = {};
|
||||||
|
for (const [name, version] of Object.entries(packageJson.optionalDependencies || {})) {
|
||||||
|
if (await copyNodeModule(name)) {
|
||||||
|
copiedOptionalDependencies[name] = version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const name of [
|
||||||
|
'@nut-tree-fork/default-clipboard-provider',
|
||||||
|
'@nut-tree-fork/libnut',
|
||||||
|
'@nut-tree-fork/provider-interfaces',
|
||||||
|
'@nut-tree-fork/shared',
|
||||||
|
'jimp',
|
||||||
|
'node-abort-controller',
|
||||||
|
'temp',
|
||||||
|
]) {
|
||||||
|
await copyNodeModule(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(stageDir, 'package.json'),
|
||||||
|
`${JSON.stringify(buildDesktopPackageJson(copiedOptionalDependencies), null, 2)}\n`,
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Prepared thin desktop app at ${path.relative(rootDir, stageDir)}`);
|
||||||
|
console.log(`Runtime dependencies: ${copiedRuntimeDependencies.join(', ')}`);
|
||||||
|
if (Object.keys(copiedOptionalDependencies).length) {
|
||||||
|
console.log(`Optional dependencies: ${Object.keys(copiedOptionalDependencies).join(', ')}`);
|
||||||
|
}
|
||||||
@@ -57,10 +57,12 @@ import commandsRoutes from './routes/commands.js';
|
|||||||
import settingsRoutes from './routes/settings.js';
|
import settingsRoutes from './routes/settings.js';
|
||||||
import agentRoutes from './routes/agent.js';
|
import agentRoutes from './routes/agent.js';
|
||||||
import projectModuleRoutes from './modules/projects/projects.routes.js';
|
import projectModuleRoutes from './modules/projects/projects.routes.js';
|
||||||
|
import notificationRoutes from './modules/notifications/notifications.routes.js';
|
||||||
import userRoutes from './routes/user.js';
|
import userRoutes from './routes/user.js';
|
||||||
import geminiRoutes from './routes/gemini.js';
|
import geminiRoutes from './routes/gemini.js';
|
||||||
import pluginsRoutes from './routes/plugins.js';
|
import pluginsRoutes from './routes/plugins.js';
|
||||||
import providerRoutes from './modules/providers/provider.routes.js';
|
import providerRoutes from './modules/providers/provider.routes.js';
|
||||||
|
import voiceRoutes from './voice-proxy.js';
|
||||||
import browserUseRoutes from './modules/browser-use/browser-use.routes.js';
|
import browserUseRoutes from './modules/browser-use/browser-use.routes.js';
|
||||||
import browserUseMcpRoutes from './modules/browser-use/browser-use-mcp.routes.js';
|
import browserUseMcpRoutes from './modules/browser-use/browser-use-mcp.routes.js';
|
||||||
import { browserUseService } from './modules/browser-use/browser-use.service.js';
|
import { browserUseService } from './modules/browser-use/browser-use.service.js';
|
||||||
@@ -201,6 +203,8 @@ app.use('/api/commands', authenticateToken, commandsRoutes);
|
|||||||
// Settings API Routes (protected)
|
// Settings API Routes (protected)
|
||||||
app.use('/api/settings', authenticateToken, settingsRoutes);
|
app.use('/api/settings', authenticateToken, settingsRoutes);
|
||||||
|
|
||||||
|
app.use('/api/notifications', authenticateToken, notificationRoutes);
|
||||||
|
|
||||||
// User API Routes (protected)
|
// User API Routes (protected)
|
||||||
app.use('/api/user', authenticateToken, userRoutes);
|
app.use('/api/user', authenticateToken, userRoutes);
|
||||||
|
|
||||||
@@ -222,6 +226,8 @@ app.use('/api/providers', authenticateToken, providerRoutes);
|
|||||||
// Agent API Routes (uses API key authentication)
|
// Agent API Routes (uses API key authentication)
|
||||||
app.use('/api/agent', agentRoutes);
|
app.use('/api/agent', agentRoutes);
|
||||||
|
|
||||||
|
app.use('/api/voice', authenticateToken, voiceRoutes);
|
||||||
|
|
||||||
// Serve public files (like api-docs.html)
|
// Serve public files (like api-docs.html)
|
||||||
app.use(express.static(path.join(APP_ROOT, 'public')));
|
app.use(express.static(path.join(APP_ROOT, 'public')));
|
||||||
|
|
||||||
@@ -1679,6 +1685,40 @@ const SERVER_PORT = process.env.SERVER_PORT || 3001;
|
|||||||
const HOST = process.env.HOST || '0.0.0.0';
|
const HOST = process.env.HOST || '0.0.0.0';
|
||||||
const DISPLAY_HOST = getConnectableHost(HOST);
|
const DISPLAY_HOST = getConnectableHost(HOST);
|
||||||
const VITE_PORT = process.env.VITE_PORT || 5173;
|
const VITE_PORT = process.env.VITE_PORT || 5173;
|
||||||
|
const LOCAL_SERVER_MARKER_PATH = path.join(os.homedir(), '.cloudcli', 'local-server.json');
|
||||||
|
|
||||||
|
async function writeLocalServerMarker() {
|
||||||
|
const marker = {
|
||||||
|
pid: process.pid,
|
||||||
|
host: HOST,
|
||||||
|
port: Number.parseInt(String(SERVER_PORT), 10),
|
||||||
|
url: `http://${DISPLAY_HOST}:${SERVER_PORT}`,
|
||||||
|
installMode,
|
||||||
|
appRoot: APP_ROOT,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await fsPromises.mkdir(path.dirname(LOCAL_SERVER_MARKER_PATH), { recursive: true });
|
||||||
|
await fsPromises.writeFile(LOCAL_SERVER_MARKER_PATH, JSON.stringify(marker, null, 2), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeLocalServerMarker() {
|
||||||
|
try {
|
||||||
|
const raw = await fsPromises.readFile(LOCAL_SERVER_MARKER_PATH, 'utf8');
|
||||||
|
const marker = JSON.parse(raw);
|
||||||
|
if (marker.pid && marker.pid !== process.pid) return;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fsPromises.unlink(LOCAL_SERVER_MARKER_PATH);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== 'ENOENT') {
|
||||||
|
console.warn('[WARN] Could not remove local server marker:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize database and start server
|
// Initialize database and start server
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
@@ -1705,6 +1745,9 @@ async function startServer() {
|
|||||||
|
|
||||||
server.listen(SERVER_PORT, HOST, async () => {
|
server.listen(SERVER_PORT, HOST, async () => {
|
||||||
const appInstallPath = APP_ROOT;
|
const appInstallPath = APP_ROOT;
|
||||||
|
await writeLocalServerMarker().catch((error) => {
|
||||||
|
console.warn('[WARN] Could not write local server marker:', error.message);
|
||||||
|
});
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(c.dim('═'.repeat(63)));
|
console.log(c.dim('═'.repeat(63)));
|
||||||
@@ -1738,6 +1781,11 @@ async function startServer() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Plugins] Error stopping plugins during shutdown:', err?.message || err);
|
console.error('[Plugins] Error stopping plugins during shutdown:', err?.message || err);
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
await removeLocalServerMarker();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Local Server] Error removing server marker during shutdown:', err?.message || err);
|
||||||
|
}
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
};
|
};
|
||||||
process.on('SIGTERM', () => void shutdownRuntimeServices());
|
process.on('SIGTERM', () => void shutdownRuntimeServices());
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ try {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('No .env file found or error reading it:', e.message);
|
console.error('No .env file found or error reading it:', e.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep the default database in a stable user-level location so rebuilding dist-server
|
// Keep the default database in a stable user-level location so rebuilding dist-server
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export { apiKeysDb } from '@/modules/database/repositories/api-keys.js';
|
|||||||
export { appConfigDb } from '@/modules/database/repositories/app-config.js';
|
export { appConfigDb } from '@/modules/database/repositories/app-config.js';
|
||||||
export { credentialsDb } from '@/modules/database/repositories/credentials.js';
|
export { credentialsDb } from '@/modules/database/repositories/credentials.js';
|
||||||
export { githubTokensDb } from '@/modules/database/repositories/github-tokens.js';
|
export { githubTokensDb } from '@/modules/database/repositories/github-tokens.js';
|
||||||
|
export { notificationChannelEndpointsDb } from '@/modules/database/repositories/notification-channel-endpoints.js';
|
||||||
export { notificationPreferencesDb } from '@/modules/database/repositories/notification-preferences.js';
|
export { notificationPreferencesDb } from '@/modules/database/repositories/notification-preferences.js';
|
||||||
export { projectsDb } from '@/modules/database/repositories/projects.db.js';
|
export { projectsDb } from '@/modules/database/repositories/projects.db.js';
|
||||||
export { pushSubscriptionsDb } from '@/modules/database/repositories/push-subscriptions.js';
|
export { pushSubscriptionsDb } from '@/modules/database/repositories/push-subscriptions.js';
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Database } from 'better-sqlite3';
|
|||||||
import {
|
import {
|
||||||
APP_CONFIG_TABLE_SCHEMA_SQL,
|
APP_CONFIG_TABLE_SCHEMA_SQL,
|
||||||
LAST_SCANNED_AT_SQL,
|
LAST_SCANNED_AT_SQL,
|
||||||
|
NOTIFICATION_CHANNEL_ENDPOINTS_TABLE_SCHEMA_SQL,
|
||||||
PROJECTS_TABLE_SCHEMA_SQL,
|
PROJECTS_TABLE_SCHEMA_SQL,
|
||||||
PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL,
|
PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL,
|
||||||
SESSIONS_TABLE_SCHEMA_SQL,
|
SESSIONS_TABLE_SCHEMA_SQL,
|
||||||
@@ -440,6 +441,9 @@ export const runMigrations = (db: Database) => {
|
|||||||
db.exec(VAPID_KEYS_TABLE_SCHEMA_SQL);
|
db.exec(VAPID_KEYS_TABLE_SCHEMA_SQL);
|
||||||
db.exec(PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL);
|
db.exec(PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL);
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id)');
|
db.exec('CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id)');
|
||||||
|
db.exec(NOTIFICATION_CHANNEL_ENDPOINTS_TABLE_SCHEMA_SQL);
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_notification_channel_endpoints_user_channel ON notification_channel_endpoints(user_id, channel)');
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_notification_channel_endpoints_enabled ON notification_channel_endpoints(enabled)');
|
||||||
|
|
||||||
db.exec(PROJECTS_TABLE_SCHEMA_SQL);
|
db.exec(PROJECTS_TABLE_SCHEMA_SQL);
|
||||||
rebuildProjectsTableWithPrimaryKeySchema(db);
|
rebuildProjectsTableWithPrimaryKeySchema(db);
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { getConnection } from '@/modules/database/connection.js';
|
||||||
|
|
||||||
|
type NotificationChannelEndpointRow = {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
channel: string;
|
||||||
|
endpoint_id: string;
|
||||||
|
label: string | null;
|
||||||
|
metadata_json: string | null;
|
||||||
|
enabled: number;
|
||||||
|
last_seen_at: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UpsertNotificationChannelEndpointInput = {
|
||||||
|
userId: number;
|
||||||
|
channel: string;
|
||||||
|
endpointId: string;
|
||||||
|
label?: string | null;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeRequiredText(value: unknown): string {
|
||||||
|
if (typeof value !== 'string') return '';
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNullableText(value: unknown): string | null {
|
||||||
|
if (typeof value !== 'string') return null;
|
||||||
|
const normalized = value.trim();
|
||||||
|
return normalized || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeMetadata(metadata: Record<string, unknown> | null | undefined): string | null {
|
||||||
|
if (!metadata || typeof metadata !== 'object') return null;
|
||||||
|
return JSON.stringify(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMetadata(metadataJson: string | null): Record<string, unknown> {
|
||||||
|
if (!metadataJson) return {};
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(metadataJson);
|
||||||
|
return parsed && typeof parsed === 'object' ? parsed as Record<string, unknown> : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notificationChannelEndpointsDb = {
|
||||||
|
upsertEndpoint(input: UpsertNotificationChannelEndpointInput): NotificationChannelEndpointRow {
|
||||||
|
const channel = normalizeRequiredText(input.channel);
|
||||||
|
const endpointId = normalizeRequiredText(input.endpointId);
|
||||||
|
if (!channel) throw new Error('channel is required');
|
||||||
|
if (!endpointId) throw new Error('endpointId is required');
|
||||||
|
|
||||||
|
const enabled = input.enabled === false ? 0 : 1;
|
||||||
|
const db = getConnection();
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO notification_channel_endpoints (
|
||||||
|
user_id,
|
||||||
|
channel,
|
||||||
|
endpoint_id,
|
||||||
|
label,
|
||||||
|
metadata_json,
|
||||||
|
enabled,
|
||||||
|
last_seen_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(user_id, channel, endpoint_id) DO UPDATE SET
|
||||||
|
label = excluded.label,
|
||||||
|
metadata_json = excluded.metadata_json,
|
||||||
|
enabled = excluded.enabled,
|
||||||
|
last_seen_at = CURRENT_TIMESTAMP,
|
||||||
|
updated_at = CURRENT_TIMESTAMP`
|
||||||
|
).run(
|
||||||
|
input.userId,
|
||||||
|
channel,
|
||||||
|
endpointId,
|
||||||
|
normalizeNullableText(input.label),
|
||||||
|
serializeMetadata(input.metadata),
|
||||||
|
enabled
|
||||||
|
);
|
||||||
|
|
||||||
|
return notificationChannelEndpointsDb.getEndpoint(input.userId, channel, endpointId)!;
|
||||||
|
},
|
||||||
|
|
||||||
|
getEndpoint(userId: number, channel: string, endpointId: string): NotificationChannelEndpointRow | null {
|
||||||
|
const db = getConnection();
|
||||||
|
const row = db.prepare(
|
||||||
|
`SELECT id, user_id, channel, endpoint_id, label, metadata_json, enabled, last_seen_at, created_at, updated_at
|
||||||
|
FROM notification_channel_endpoints
|
||||||
|
WHERE user_id = ? AND channel = ? AND endpoint_id = ?`
|
||||||
|
).get(
|
||||||
|
userId,
|
||||||
|
normalizeRequiredText(channel),
|
||||||
|
normalizeRequiredText(endpointId)
|
||||||
|
) as NotificationChannelEndpointRow | undefined;
|
||||||
|
return row || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
getEndpoints(userId: number, channel: string): NotificationChannelEndpointRow[] {
|
||||||
|
const db = getConnection();
|
||||||
|
return db.prepare(
|
||||||
|
`SELECT id, user_id, channel, endpoint_id, label, metadata_json, enabled, last_seen_at, created_at, updated_at
|
||||||
|
FROM notification_channel_endpoints
|
||||||
|
WHERE user_id = ? AND channel = ?
|
||||||
|
ORDER BY last_seen_at DESC`
|
||||||
|
).all(userId, normalizeRequiredText(channel)) as NotificationChannelEndpointRow[];
|
||||||
|
},
|
||||||
|
|
||||||
|
getEnabledEndpoints(userId: number, channel: string): NotificationChannelEndpointRow[] {
|
||||||
|
const db = getConnection();
|
||||||
|
return db.prepare(
|
||||||
|
`SELECT id, user_id, channel, endpoint_id, label, metadata_json, enabled, last_seen_at, created_at, updated_at
|
||||||
|
FROM notification_channel_endpoints
|
||||||
|
WHERE user_id = ? AND channel = ? AND enabled = 1
|
||||||
|
ORDER BY last_seen_at DESC`
|
||||||
|
).all(userId, normalizeRequiredText(channel)) as NotificationChannelEndpointRow[];
|
||||||
|
},
|
||||||
|
|
||||||
|
setEndpointEnabled(userId: number, channel: string, endpointId: string, enabled: boolean): boolean {
|
||||||
|
const db = getConnection();
|
||||||
|
const result = db.prepare(
|
||||||
|
`UPDATE notification_channel_endpoints
|
||||||
|
SET enabled = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE user_id = ? AND channel = ? AND endpoint_id = ?`
|
||||||
|
).run(enabled ? 1 : 0, userId, normalizeRequiredText(channel), normalizeRequiredText(endpointId));
|
||||||
|
return result.changes > 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
touchEndpoint(userId: number, channel: string, endpointId: string): boolean {
|
||||||
|
const db = getConnection();
|
||||||
|
const result = db.prepare(
|
||||||
|
`UPDATE notification_channel_endpoints
|
||||||
|
SET last_seen_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE user_id = ? AND channel = ? AND endpoint_id = ?`
|
||||||
|
).run(userId, normalizeRequiredText(channel), normalizeRequiredText(endpointId));
|
||||||
|
return result.changes > 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
removeEndpoint(userId: number, channel: string, endpointId: string): boolean {
|
||||||
|
const db = getConnection();
|
||||||
|
const result = db.prepare(
|
||||||
|
'DELETE FROM notification_channel_endpoints WHERE user_id = ? AND channel = ? AND endpoint_id = ?'
|
||||||
|
).run(userId, normalizeRequiredText(channel), normalizeRequiredText(endpointId));
|
||||||
|
return result.changes > 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
parseMetadata,
|
||||||
|
};
|
||||||
@@ -10,7 +10,9 @@ type NotificationPreferences = {
|
|||||||
channels: {
|
channels: {
|
||||||
inApp: boolean;
|
inApp: boolean;
|
||||||
webPush: boolean;
|
webPush: boolean;
|
||||||
|
desktop: boolean;
|
||||||
sound: boolean;
|
sound: boolean;
|
||||||
|
[key: string]: boolean;
|
||||||
};
|
};
|
||||||
events: {
|
events: {
|
||||||
actionRequired: boolean;
|
actionRequired: boolean;
|
||||||
@@ -23,6 +25,7 @@ const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
|
|||||||
channels: {
|
channels: {
|
||||||
inApp: false,
|
inApp: false,
|
||||||
webPush: false,
|
webPush: false,
|
||||||
|
desktop: false,
|
||||||
sound: true,
|
sound: true,
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
@@ -34,11 +37,20 @@ const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
|
|||||||
|
|
||||||
function normalizeNotificationPreferences(value: unknown): NotificationPreferences {
|
function normalizeNotificationPreferences(value: unknown): NotificationPreferences {
|
||||||
const source = value && typeof value === 'object' ? (value as Record<string, any>) : {};
|
const source = value && typeof value === 'object' ? (value as Record<string, any>) : {};
|
||||||
|
const sourceChannels = source.channels && typeof source.channels === 'object'
|
||||||
|
? source.channels as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
const extraChannels = Object.fromEntries(
|
||||||
|
Object.entries(sourceChannels)
|
||||||
|
.filter(([key, channelValue]) => !['inApp', 'webPush', 'desktop', 'sound'].includes(key) && typeof channelValue === 'boolean')
|
||||||
|
) as Record<string, boolean>;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
channels: {
|
channels: {
|
||||||
|
...extraChannels,
|
||||||
inApp: source.channels?.inApp === true,
|
inApp: source.channels?.inApp === true,
|
||||||
webPush: source.channels?.webPush === true,
|
webPush: source.channels?.webPush === true,
|
||||||
|
desktop: source.channels?.desktop === true,
|
||||||
sound: source.channels?.sound !== false,
|
sound: source.channels?.sound !== false,
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
@@ -103,4 +115,3 @@ export const notificationPreferencesDb = {
|
|||||||
return notificationPreferencesDb.updateNotificationPreferences(userId, preferences);
|
return notificationPreferencesDb.updateNotificationPreferences(userId, preferences);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,23 @@ CREATE TABLE IF NOT EXISTS push_subscriptions (
|
|||||||
);
|
);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const NOTIFICATION_CHANNEL_ENDPOINTS_TABLE_SCHEMA_SQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS notification_channel_endpoints (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
channel TEXT NOT NULL,
|
||||||
|
endpoint_id TEXT NOT NULL,
|
||||||
|
label TEXT,
|
||||||
|
metadata_json TEXT,
|
||||||
|
enabled BOOLEAN DEFAULT 1,
|
||||||
|
last_seen_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(user_id, channel, endpoint_id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
export const PROJECTS_TABLE_SCHEMA_SQL = `
|
export const PROJECTS_TABLE_SCHEMA_SQL = `
|
||||||
CREATE TABLE IF NOT EXISTS projects (
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
project_id TEXT PRIMARY KEY NOT NULL,
|
project_id TEXT PRIMARY KEY NOT NULL,
|
||||||
@@ -144,6 +161,10 @@ ${VAPID_KEYS_TABLE_SCHEMA_SQL}
|
|||||||
${PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL}
|
${PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL}
|
||||||
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id);
|
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id);
|
||||||
|
|
||||||
|
${NOTIFICATION_CHANNEL_ENDPOINTS_TABLE_SCHEMA_SQL}
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notification_channel_endpoints_user_channel ON notification_channel_endpoints(user_id, channel);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notification_channel_endpoints_enabled ON notification_channel_endpoints(enabled);
|
||||||
|
|
||||||
${PROJECTS_TABLE_SCHEMA_SQL}
|
${PROJECTS_TABLE_SCHEMA_SQL}
|
||||||
-- NOTE: These indexes are created in migrations after legacy table-shape repairs.
|
-- NOTE: These indexes are created in migrations after legacy table-shape repairs.
|
||||||
-- Creating them here can fail on upgraded installs where projects lacks those columns.
|
-- Creating them here can fail on upgraded installs where projects lacks those columns.
|
||||||
|
|||||||
13
server/modules/notifications/index.ts
Normal file
13
server/modules/notifications/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export {
|
||||||
|
buildNotificationPayload,
|
||||||
|
createNotificationEvent,
|
||||||
|
notifyUserIfEnabled,
|
||||||
|
notifyRunFailed,
|
||||||
|
notifyRunStopped,
|
||||||
|
} from '@/modules/notifications/services/notification-orchestrator.service.js';
|
||||||
|
export {
|
||||||
|
registerDesktopNotificationClient,
|
||||||
|
sendDesktopNotification,
|
||||||
|
unregisterDesktopNotificationClient,
|
||||||
|
} from '@/modules/notifications/services/desktop-notification-clients.service.js';
|
||||||
|
export { handleDesktopNotificationsConnection } from '@/modules/notifications/websocket/desktop-notifications-websocket.service.js';
|
||||||
127
server/modules/notifications/notifications.routes.ts
Normal file
127
server/modules/notifications/notifications.routes.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import express from 'express';
|
||||||
|
|
||||||
|
import { notificationChannelEndpointsDb, notificationPreferencesDb } from '@/modules/database/index.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function readText(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeEndpoint(endpoint: any) {
|
||||||
|
return {
|
||||||
|
id: endpoint.id,
|
||||||
|
channel: endpoint.channel,
|
||||||
|
endpointId: endpoint.endpoint_id,
|
||||||
|
label: endpoint.label,
|
||||||
|
metadata: notificationChannelEndpointsDb.parseMetadata(endpoint.metadata_json),
|
||||||
|
enabled: Boolean(endpoint.enabled),
|
||||||
|
lastSeenAt: endpoint.last_seen_at,
|
||||||
|
createdAt: endpoint.created_at,
|
||||||
|
updatedAt: endpoint.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readUserId(req: express.Request): number {
|
||||||
|
const userId = Number((req as any).user?.id);
|
||||||
|
if (!Number.isInteger(userId) || userId <= 0) {
|
||||||
|
throw new Error('Authenticated user is missing');
|
||||||
|
}
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChannelPreference(userId: number, channel: string): unknown {
|
||||||
|
const currentPrefs = notificationPreferencesDb.getPreferences(userId);
|
||||||
|
const hasEnabledEndpoint = notificationChannelEndpointsDb.getEnabledEndpoints(userId, channel).length > 0;
|
||||||
|
return notificationPreferencesDb.updatePreferences(userId, {
|
||||||
|
...currentPrefs,
|
||||||
|
channels: { ...currentPrefs.channels, [channel]: hasEnabledEndpoint },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/endpoints', (req, res) => {
|
||||||
|
try {
|
||||||
|
const channel = readText(req.query.channel);
|
||||||
|
if (!channel) {
|
||||||
|
return res.status(400).json({ error: 'channel is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = readUserId(req);
|
||||||
|
const endpoints = notificationChannelEndpointsDb
|
||||||
|
.getEndpoints(userId, channel)
|
||||||
|
.map(sanitizeEndpoint);
|
||||||
|
return res.json({ success: true, endpoints });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching notification endpoints:', error);
|
||||||
|
return res.status(500).json({ error: 'Failed to fetch notification endpoints' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/endpoints/current', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { channel, endpointId, label, metadata = {}, enabled = true } = req.body || {};
|
||||||
|
const normalizedChannel = readText(channel);
|
||||||
|
const normalizedEndpointId = readText(endpointId);
|
||||||
|
if (!normalizedChannel || !normalizedEndpointId) {
|
||||||
|
return res.status(400).json({ error: 'channel and endpointId are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = readUserId(req);
|
||||||
|
const endpoint = notificationChannelEndpointsDb.upsertEndpoint({
|
||||||
|
userId,
|
||||||
|
channel: normalizedChannel,
|
||||||
|
endpointId: normalizedEndpointId,
|
||||||
|
label,
|
||||||
|
metadata: metadata && typeof metadata === 'object' ? metadata : {},
|
||||||
|
enabled: enabled !== false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const preferences = updateChannelPreference(userId, normalizedChannel);
|
||||||
|
return res.json({ success: true, endpoint: sanitizeEndpoint(endpoint), preferences });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error registering notification endpoint:', error);
|
||||||
|
return res.status(500).json({ error: 'Failed to register notification endpoint' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch('/endpoints/:channel/:endpointId', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { channel, endpointId } = req.params;
|
||||||
|
const { enabled } = req.body || {};
|
||||||
|
if (typeof enabled !== 'boolean') {
|
||||||
|
return res.status(400).json({ error: 'enabled must be a boolean' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = readUserId(req);
|
||||||
|
const updated = notificationChannelEndpointsDb.setEndpointEnabled(userId, channel, endpointId, enabled);
|
||||||
|
if (!updated) {
|
||||||
|
return res.status(404).json({ error: 'Notification endpoint not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = notificationChannelEndpointsDb.getEndpoint(userId, channel, endpointId);
|
||||||
|
const preferences = updateChannelPreference(userId, channel);
|
||||||
|
return res.json({ success: true, endpoint: endpoint ? sanitizeEndpoint(endpoint) : null, preferences });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating notification endpoint:', error);
|
||||||
|
return res.status(500).json({ error: 'Failed to update notification endpoint' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/endpoints/:channel/:endpointId', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { channel, endpointId } = req.params;
|
||||||
|
const userId = readUserId(req);
|
||||||
|
const removed = notificationChannelEndpointsDb.removeEndpoint(userId, channel, endpointId);
|
||||||
|
if (!removed) {
|
||||||
|
return res.status(404).json({ error: 'Notification endpoint not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferences = updateChannelPreference(userId, channel);
|
||||||
|
return res.json({ success: true, preferences });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing notification endpoint:', error);
|
||||||
|
return res.status(500).json({ error: 'Failed to remove notification endpoint' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import type { WebSocket } from 'ws';
|
||||||
|
|
||||||
|
import { notificationChannelEndpointsDb } from '@/modules/database/index.js';
|
||||||
|
|
||||||
|
const DESKTOP_CHANNEL = 'desktop';
|
||||||
|
|
||||||
|
const clientsByUserId = new Map<number, Map<string, WebSocket>>();
|
||||||
|
const clientBySocket = new WeakMap<WebSocket, { userId: number; endpointId: string }>();
|
||||||
|
|
||||||
|
function normalizeUserId(userId: unknown): number | null {
|
||||||
|
const numeric = Number(userId);
|
||||||
|
return Number.isInteger(numeric) && numeric > 0 ? numeric : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEndpointId(endpointId: unknown): string {
|
||||||
|
if (typeof endpointId !== 'string') return '';
|
||||||
|
return endpointId.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserClients(userId: unknown, create = false): Map<string, WebSocket> | null {
|
||||||
|
const normalizedUserId = normalizeUserId(userId);
|
||||||
|
if (!normalizedUserId) return null;
|
||||||
|
let clients = clientsByUserId.get(normalizedUserId);
|
||||||
|
if (!clients && create) {
|
||||||
|
clients = new Map();
|
||||||
|
clientsByUserId.set(normalizedUserId, clients);
|
||||||
|
}
|
||||||
|
return clients || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerDesktopNotificationClient({
|
||||||
|
userId,
|
||||||
|
deviceId,
|
||||||
|
label = null,
|
||||||
|
platform = null,
|
||||||
|
appVersion = null,
|
||||||
|
ws,
|
||||||
|
}: {
|
||||||
|
userId: number;
|
||||||
|
deviceId: string;
|
||||||
|
label?: string | null;
|
||||||
|
platform?: string | null;
|
||||||
|
appVersion?: string | null;
|
||||||
|
ws: WebSocket;
|
||||||
|
}) {
|
||||||
|
const normalizedUserId = normalizeUserId(userId);
|
||||||
|
const endpointId = normalizeEndpointId(deviceId);
|
||||||
|
if (!normalizedUserId || !endpointId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = notificationChannelEndpointsDb.upsertEndpoint({
|
||||||
|
userId: normalizedUserId,
|
||||||
|
channel: DESKTOP_CHANNEL,
|
||||||
|
endpointId,
|
||||||
|
label,
|
||||||
|
metadata: { platform, appVersion },
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const clients = getUserClients(normalizedUserId, true)!;
|
||||||
|
const previous = clients.get(endpointId);
|
||||||
|
if (previous && previous !== ws && previous.readyState === previous.OPEN) {
|
||||||
|
previous.close(4000, 'Device reconnected');
|
||||||
|
}
|
||||||
|
|
||||||
|
clients.set(endpointId, ws);
|
||||||
|
clientBySocket.set(ws, { userId: normalizedUserId, endpointId });
|
||||||
|
return endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregisterDesktopNotificationClient(ws: WebSocket): void {
|
||||||
|
const registration = clientBySocket.get(ws);
|
||||||
|
if (!registration) return;
|
||||||
|
|
||||||
|
const clients = getUserClients(registration.userId);
|
||||||
|
if (clients?.get(registration.endpointId) === ws) {
|
||||||
|
clients.delete(registration.endpointId);
|
||||||
|
if (clients.size === 0) {
|
||||||
|
clientsByUserId.delete(registration.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clientBySocket.delete(ws);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendDesktopNotification(userId: unknown, payload: unknown): { attempted: number; sent: number } {
|
||||||
|
const normalizedUserId = normalizeUserId(userId);
|
||||||
|
if (!normalizedUserId) return { attempted: 0, sent: 0 };
|
||||||
|
|
||||||
|
const clients = getUserClients(normalizedUserId);
|
||||||
|
if (!clients?.size) return { attempted: 0, sent: 0 };
|
||||||
|
|
||||||
|
const enabledEndpointIds = new Set(
|
||||||
|
notificationChannelEndpointsDb
|
||||||
|
.getEnabledEndpoints(normalizedUserId, DESKTOP_CHANNEL)
|
||||||
|
.map((endpoint) => endpoint.endpoint_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const message = JSON.stringify({
|
||||||
|
type: 'notification',
|
||||||
|
id: typeof (payload as any)?.data?.tag === 'string' ? (payload as any).data.tag : `${Date.now()}`,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
let attempted = 0;
|
||||||
|
let sent = 0;
|
||||||
|
for (const [endpointId, ws] of clients.entries()) {
|
||||||
|
if (!enabledEndpointIds.has(endpointId)) continue;
|
||||||
|
attempted += 1;
|
||||||
|
if (ws.readyState !== ws.OPEN) {
|
||||||
|
unregisterDesktopNotificationClient(ws);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ws.send(message);
|
||||||
|
notificationChannelEndpointsDb.touchEndpoint(normalizedUserId, DESKTOP_CHANNEL, endpointId);
|
||||||
|
sent += 1;
|
||||||
|
} catch {
|
||||||
|
unregisterDesktopNotificationClient(ws);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { attempted, sent };
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import webPush from 'web-push';
|
||||||
|
|
||||||
|
import { notificationPreferencesDb, pushSubscriptionsDb, sessionsDb } from '@/modules/database/index.js';
|
||||||
|
import { sendDesktopNotification as sendDesktopNotificationToClients } from '@/modules/notifications/services/desktop-notification-clients.service.js';
|
||||||
|
|
||||||
|
const KIND_TO_PREF_KEY = {
|
||||||
|
action_required: 'actionRequired',
|
||||||
|
stop: 'stop',
|
||||||
|
error: 'error'
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROVIDER_LABELS = {
|
||||||
|
claude: 'Claude',
|
||||||
|
cursor: 'Cursor',
|
||||||
|
codex: 'Codex',
|
||||||
|
gemini: 'Gemini',
|
||||||
|
system: 'System'
|
||||||
|
};
|
||||||
|
|
||||||
|
const recentEventKeys = new Map();
|
||||||
|
const DEDUPE_WINDOW_MS = 20000;
|
||||||
|
|
||||||
|
const cleanupOldEventKeys = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, timestamp] of recentEventKeys.entries()) {
|
||||||
|
if (now - timestamp > DEDUPE_WINDOW_MS) {
|
||||||
|
recentEventKeys.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function isNotificationEventEnabled(preferences, event) {
|
||||||
|
const prefEventKey = KIND_TO_PREF_KEY[event.kind];
|
||||||
|
const eventEnabled = prefEventKey ? Boolean(preferences?.events?.[prefEventKey]) : true;
|
||||||
|
|
||||||
|
return eventEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDuplicate(event) {
|
||||||
|
cleanupOldEventKeys();
|
||||||
|
const key = event.dedupeKey || `${event.provider}:${event.kind || 'info'}:${event.code || 'generic'}:${event.sessionId || 'none'}`;
|
||||||
|
if (recentEventKeys.has(key)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
recentEventKeys.set(key, Date.now());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNotificationEvent({
|
||||||
|
provider,
|
||||||
|
sessionId = null,
|
||||||
|
kind = 'info',
|
||||||
|
code = 'generic.info',
|
||||||
|
meta = {},
|
||||||
|
severity = 'info',
|
||||||
|
dedupeKey = null,
|
||||||
|
requiresUserAction = false
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
sessionId,
|
||||||
|
kind,
|
||||||
|
code,
|
||||||
|
meta,
|
||||||
|
severity,
|
||||||
|
requiresUserAction,
|
||||||
|
dedupeKey,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeErrorMessage(error) {
|
||||||
|
if (typeof error === 'string') {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && typeof error.message === 'string') {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error == null) {
|
||||||
|
return 'Unknown error';
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSessionName(sessionName) {
|
||||||
|
if (typeof sessionName !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = sessionName.replace(/\s+/g, ' ').trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowMatchesProvider(row, provider) {
|
||||||
|
return row && (!provider || row.provider === provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSessionRow(sessionId, provider) {
|
||||||
|
if (!sessionId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const appSessionRow = sessionsDb.getSessionById(sessionId);
|
||||||
|
if (rowMatchesProvider(appSessionRow, provider)) {
|
||||||
|
return appSessionRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerSessionRow = sessionsDb.getSessionByProviderSessionId(sessionId);
|
||||||
|
if (rowMatchesProvider(providerSessionRow, provider)) {
|
||||||
|
return providerSessionRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNotificationSession(event) {
|
||||||
|
if (!event?.sessionId || !event.provider || event.provider === 'system') {
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = resolveSessionRow(event.sessionId, event.provider);
|
||||||
|
if (!row || row.session_id === event.sessionId) {
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...event,
|
||||||
|
sessionId: row.session_id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSessionName(event) {
|
||||||
|
const explicitSessionName = normalizeSessionName(event.meta?.sessionName);
|
||||||
|
if (explicitSessionName) {
|
||||||
|
return explicitSessionName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event.sessionId || !event.provider) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeSessionName(sessionsDb.getSessionName(event.sessionId, event.provider));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNotificationPayload(event) {
|
||||||
|
const normalizedEvent = normalizeNotificationSession(event);
|
||||||
|
const CODE_MAP = {
|
||||||
|
'permission.required': normalizedEvent.meta?.toolName
|
||||||
|
? `Action Required: Tool "${normalizedEvent.meta.toolName}" needs approval`
|
||||||
|
: 'Action Required: A tool needs your approval',
|
||||||
|
'run.stopped': normalizedEvent.meta?.stopReason || 'Run Stopped: The run has stopped',
|
||||||
|
'run.failed': normalizedEvent.meta?.error ? `Run Failed: ${normalizedEvent.meta.error}` : 'Run Failed: The run encountered an error',
|
||||||
|
'agent.notification': normalizedEvent.meta?.message ? String(normalizedEvent.meta.message) : 'You have a new notification',
|
||||||
|
'push.enabled': 'Push notifications are now enabled!'
|
||||||
|
};
|
||||||
|
const providerLabel = PROVIDER_LABELS[normalizedEvent.provider] || 'Assistant';
|
||||||
|
const sessionName = resolveSessionName(normalizedEvent);
|
||||||
|
const message = CODE_MAP[normalizedEvent.code] || 'You have a new notification';
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: sessionName || 'CloudCLI',
|
||||||
|
body: `${providerLabel}: ${message}`,
|
||||||
|
data: {
|
||||||
|
sessionId: normalizedEvent.sessionId || null,
|
||||||
|
code: normalizedEvent.code,
|
||||||
|
provider: normalizedEvent.provider || null,
|
||||||
|
sessionName,
|
||||||
|
tag: `${normalizedEvent.provider || 'assistant'}:${normalizedEvent.sessionId || 'none'}:${normalizedEvent.code}`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendWebPushPayload(userId, payload) {
|
||||||
|
const subscriptions = pushSubscriptionsDb.getSubscriptions(userId);
|
||||||
|
if (!subscriptions.length) return Promise.resolve();
|
||||||
|
|
||||||
|
const serializedPayload = JSON.stringify(payload);
|
||||||
|
return Promise.allSettled(
|
||||||
|
subscriptions.map((sub) =>
|
||||||
|
webPush.sendNotification(
|
||||||
|
{
|
||||||
|
endpoint: sub.endpoint,
|
||||||
|
keys: {
|
||||||
|
p256dh: sub.keys_p256dh,
|
||||||
|
auth: sub.keys_auth
|
||||||
|
}
|
||||||
|
},
|
||||||
|
serializedPayload
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).then((results) => {
|
||||||
|
results.forEach((result, index) => {
|
||||||
|
if (result.status === 'rejected') {
|
||||||
|
const statusCode = result.reason?.statusCode;
|
||||||
|
if (statusCode === 410 || statusCode === 404) {
|
||||||
|
pushSubscriptionsDb.removeSubscription(subscriptions[index].endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const notificationChannels = [
|
||||||
|
{
|
||||||
|
id: 'webPush',
|
||||||
|
// TODO: Web push still uses push_subscriptions. Do not remove that table until
|
||||||
|
// browser push subscriptions are migrated into notification_channel_endpoints.
|
||||||
|
isEnabled: (preferences) => Boolean(preferences?.channels?.webPush),
|
||||||
|
send: ({ userId, payload }) => sendWebPushPayload(userId, payload)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'desktop',
|
||||||
|
isEnabled: (preferences) => Boolean(preferences?.channels?.desktop),
|
||||||
|
send: ({ userId, payload }) => sendDesktopNotificationToClients(userId, payload)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function notifyUserIfEnabled({ userId, event }) {
|
||||||
|
if (!userId || !event) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedEvent = normalizeNotificationSession(event);
|
||||||
|
const preferences = notificationPreferencesDb.getPreferences(userId);
|
||||||
|
if (!isNotificationEventEnabled(preferences, normalizedEvent)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isDuplicate(normalizedEvent)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = buildNotificationPayload(normalizedEvent);
|
||||||
|
for (const channel of notificationChannels) {
|
||||||
|
if (!channel.isEnabled(preferences)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Promise.resolve(channel.send({ userId, event: normalizedEvent, payload })).catch((err) => {
|
||||||
|
console.error(`Notification channel "${channel.id}" send error:`, err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyRunStopped({ userId, provider, sessionId = null, stopReason = 'completed', sessionName = null }) {
|
||||||
|
notifyUserIfEnabled({
|
||||||
|
userId,
|
||||||
|
event: createNotificationEvent({
|
||||||
|
provider,
|
||||||
|
sessionId,
|
||||||
|
kind: 'stop',
|
||||||
|
code: 'run.stopped',
|
||||||
|
meta: { stopReason, sessionName },
|
||||||
|
severity: 'info',
|
||||||
|
dedupeKey: `${provider}:run:stop:${sessionId || 'none'}:${stopReason}`
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyRunFailed({ userId, provider, sessionId = null, error, sessionName = null }) {
|
||||||
|
const errorMessage = normalizeErrorMessage(error);
|
||||||
|
|
||||||
|
notifyUserIfEnabled({
|
||||||
|
userId,
|
||||||
|
event: createNotificationEvent({
|
||||||
|
provider,
|
||||||
|
sessionId,
|
||||||
|
kind: 'error',
|
||||||
|
code: 'run.failed',
|
||||||
|
meta: { error: errorMessage, sessionName },
|
||||||
|
severity: 'error',
|
||||||
|
dedupeKey: `${provider}:run:error:${sessionId || 'none'}:${errorMessage}`
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
buildNotificationPayload,
|
||||||
|
createNotificationEvent,
|
||||||
|
notifyUserIfEnabled,
|
||||||
|
notifyRunStopped,
|
||||||
|
notifyRunFailed
|
||||||
|
};
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import type { WebSocket } from 'ws';
|
||||||
|
|
||||||
|
import {
|
||||||
|
registerDesktopNotificationClient,
|
||||||
|
unregisterDesktopNotificationClient,
|
||||||
|
} from '@/modules/notifications/services/desktop-notification-clients.service.js';
|
||||||
|
import type { AuthenticatedWebSocketRequest } from '@/shared/types.js';
|
||||||
|
import { parseIncomingJsonObject } from '@/shared/utils.js';
|
||||||
|
|
||||||
|
type DesktopNotificationRegisterMessage = {
|
||||||
|
type?: unknown;
|
||||||
|
kind?: unknown;
|
||||||
|
deviceId?: unknown;
|
||||||
|
label?: unknown;
|
||||||
|
platform?: unknown;
|
||||||
|
appVersion?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function readRequestUserId(request: AuthenticatedWebSocketRequest): number | null {
|
||||||
|
const user = request.user;
|
||||||
|
const rawUserId = typeof user?.id === 'number' || typeof user?.id === 'string'
|
||||||
|
? user.id
|
||||||
|
: typeof user?.userId === 'number' || typeof user?.userId === 'string'
|
||||||
|
? user.userId
|
||||||
|
: null;
|
||||||
|
const numericUserId = Number(rawUserId);
|
||||||
|
return Number.isInteger(numericUserId) && numericUserId > 0 ? numericUserId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOptionalString(value: unknown): string | null {
|
||||||
|
if (typeof value !== 'string') return null;
|
||||||
|
const normalized = value.trim();
|
||||||
|
return normalized || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(ws: WebSocket, payload: unknown): void {
|
||||||
|
if (ws.readyState === ws.OPEN) {
|
||||||
|
ws.send(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleDesktopNotificationsConnection(
|
||||||
|
ws: WebSocket,
|
||||||
|
request: AuthenticatedWebSocketRequest
|
||||||
|
): void {
|
||||||
|
const userId = readRequestUserId(request);
|
||||||
|
if (!userId) {
|
||||||
|
ws.close(1008, 'Missing authenticated user');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let registered = false;
|
||||||
|
|
||||||
|
ws.on('message', (rawMessage) => {
|
||||||
|
const data = parseIncomingJsonObject(rawMessage) as DesktopNotificationRegisterMessage | null;
|
||||||
|
if (!data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = typeof data.type === 'string' ? data.type : typeof data.kind === 'string' ? data.kind : '';
|
||||||
|
if (type === 'notification_ack') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type !== 'register' || registered) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceId = readOptionalString(data.deviceId);
|
||||||
|
if (!deviceId) {
|
||||||
|
sendJson(ws, {
|
||||||
|
type: 'error',
|
||||||
|
code: 'DEVICE_ID_REQUIRED',
|
||||||
|
message: 'Desktop notification registration requires deviceId.',
|
||||||
|
});
|
||||||
|
ws.close(1008, 'Missing deviceId');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const device = registerDesktopNotificationClient({
|
||||||
|
userId,
|
||||||
|
deviceId,
|
||||||
|
label: readOptionalString(data.label),
|
||||||
|
platform: readOptionalString(data.platform),
|
||||||
|
appVersion: readOptionalString(data.appVersion),
|
||||||
|
ws,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
ws.close(1011, 'Registration failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
registered = true;
|
||||||
|
sendJson(ws, {
|
||||||
|
type: 'registered',
|
||||||
|
deviceId: device.endpoint_id,
|
||||||
|
enabled: Boolean(device.enabled),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
unregisterDesktopNotificationClient(ws);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('error', () => {
|
||||||
|
unregisterDesktopNotificationClient(ws);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -430,6 +430,17 @@ router.post(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/:provider/skills/:directoryName',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const provider = parseProvider(req.params.provider);
|
||||||
|
const result = await providerSkillsService.removeProviderSkill(provider, {
|
||||||
|
directoryName: readPathParam(req.params.directoryName, 'directoryName'),
|
||||||
|
});
|
||||||
|
res.json(createApiSuccessResponse(result));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// ----------------- MCP routes -----------------
|
// ----------------- MCP routes -----------------
|
||||||
router.get(
|
router.get(
|
||||||
'/:provider/mcp/servers',
|
'/:provider/mcp/servers',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
ProviderSkill,
|
ProviderSkill,
|
||||||
ProviderSkillCreateInput,
|
ProviderSkillCreateInput,
|
||||||
ProviderSkillListOptions,
|
ProviderSkillListOptions,
|
||||||
|
ProviderSkillRemoveInput,
|
||||||
} from '@/shared/types.js';
|
} from '@/shared/types.js';
|
||||||
|
|
||||||
export const providerSkillsService = {
|
export const providerSkillsService = {
|
||||||
@@ -27,4 +28,12 @@ export const providerSkillsService = {
|
|||||||
const provider = providerRegistry.resolveProvider(providerName);
|
const provider = providerRegistry.resolveProvider(providerName);
|
||||||
return provider.skills.addSkills(input);
|
return provider.skills.addSkills(input);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async removeProviderSkill(
|
||||||
|
providerName: string,
|
||||||
|
input: ProviderSkillRemoveInput,
|
||||||
|
): Promise<{ removed: boolean; provider: string; directoryName: string }> {
|
||||||
|
const provider = providerRegistry.resolveProvider(providerName);
|
||||||
|
return provider.skills.removeSkill(input);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
import { mkdir, rm, stat, writeFile } from 'node:fs/promises';
|
||||||
|
|
||||||
import type { IProviderSkills } from '@/shared/interfaces.js';
|
import type { IProviderSkills } from '@/shared/interfaces.js';
|
||||||
import type {
|
import type {
|
||||||
LLMProvider,
|
LLMProvider,
|
||||||
ProviderSkillCreateInput,
|
ProviderSkillCreateInput,
|
||||||
|
ProviderSkillRemoveInput,
|
||||||
ProviderSkill,
|
ProviderSkill,
|
||||||
ProviderSkillListOptions,
|
ProviderSkillListOptions,
|
||||||
ProviderSkillSource,
|
ProviderSkillSource,
|
||||||
@@ -236,6 +237,48 @@ export abstract class SkillsProvider implements IProviderSkills {
|
|||||||
return pendingInstalls.map((install) => install.skill);
|
return pendingInstalls.map((install) => install.skill);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async removeSkill(
|
||||||
|
input: ProviderSkillRemoveInput,
|
||||||
|
): Promise<{ removed: boolean; provider: LLMProvider; directoryName: string }> {
|
||||||
|
const globalSkillSource = await this.getGlobalSkillSource();
|
||||||
|
if (!globalSkillSource) {
|
||||||
|
throw new AppError(`${this.provider} does not support managed global skills.`, {
|
||||||
|
code: 'PROVIDER_SKILLS_WRITE_UNSUPPORTED',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const directoryName = normalizeSkillDirectoryName(input.directoryName);
|
||||||
|
if (!directoryName) {
|
||||||
|
throw new AppError('Skill directoryName is required.', {
|
||||||
|
code: 'PROVIDER_SKILL_DIRECTORY_REQUIRED',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const skillDirectoryPath = path.join(globalSkillSource.rootDir, directoryName);
|
||||||
|
const resolvedRoot = path.resolve(globalSkillSource.rootDir);
|
||||||
|
const resolvedSkillDirectoryPath = path.resolve(skillDirectoryPath);
|
||||||
|
if (
|
||||||
|
resolvedSkillDirectoryPath !== resolvedRoot
|
||||||
|
&& !resolvedSkillDirectoryPath.startsWith(`${resolvedRoot}${path.sep}`)
|
||||||
|
) {
|
||||||
|
throw new AppError('Skill directory must stay inside the managed skill root.', {
|
||||||
|
code: 'PROVIDER_SKILL_DIRECTORY_INVALID',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const removed = await stat(resolvedSkillDirectoryPath)
|
||||||
|
.then((stats) => stats.isDirectory())
|
||||||
|
.catch(() => false);
|
||||||
|
if (removed) {
|
||||||
|
await rm(resolvedSkillDirectoryPath, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { removed, provider: this.provider, directoryName };
|
||||||
|
}
|
||||||
|
|
||||||
protected abstract getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]>;
|
protected abstract getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]>;
|
||||||
|
|
||||||
protected async getGlobalSkillSource(): Promise<ProviderSkillSource | null> {
|
protected async getGlobalSkillSource(): Promise<ProviderSkillSource | null> {
|
||||||
|
|||||||
@@ -662,6 +662,19 @@ test('providerSkillsService adds global skills for claude, codex, gemini, and cu
|
|||||||
const listedCursorSkills = await providerSkillsService.listProviderSkills('cursor');
|
const listedCursorSkills = await providerSkillsService.listProviderSkills('cursor');
|
||||||
assert.equal(listedCursorSkills.some((skill) => skill.name === 'cursor-global'), true);
|
assert.equal(listedCursorSkills.some((skill) => skill.name === 'cursor-global'), true);
|
||||||
|
|
||||||
|
const removedCodexSkill = await providerSkillsService.removeProviderSkill('codex', {
|
||||||
|
directoryName: 'uploaded-codex-folder',
|
||||||
|
});
|
||||||
|
assert.equal(removedCodexSkill.removed, true);
|
||||||
|
assert.equal(removedCodexSkill.provider, 'codex');
|
||||||
|
assert.equal(removedCodexSkill.directoryName, 'uploaded-codex-folder');
|
||||||
|
await assert.rejects(fs.stat(path.dirname(createdCodexSkill.sourcePath)), { code: 'ENOENT' });
|
||||||
|
|
||||||
|
const removedMissingSkill = await providerSkillsService.removeProviderSkill('codex', {
|
||||||
|
directoryName: 'uploaded-codex-folder',
|
||||||
|
});
|
||||||
|
assert.equal(removedMissingSkill.removed, false);
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
providerSkillsService.addProviderSkills('codex', {
|
providerSkillsService.addProviderSkills('codex', {
|
||||||
entries: [
|
entries: [
|
||||||
@@ -701,4 +714,11 @@ test('providerSkillsService rejects managed skill creation for opencode', { conc
|
|||||||
}),
|
}),
|
||||||
/does not support managed global skills/i,
|
/does not support managed global skills/i,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
providerSkillsService.removeProviderSkill('opencode', {
|
||||||
|
directoryName: 'opencode-global-dir',
|
||||||
|
}),
|
||||||
|
/does not support managed global skills/i,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { handleChatConnection } from '@/modules/websocket/services/chat-websocke
|
|||||||
import { verifyWebSocketClient } from '@/modules/websocket/services/websocket-auth.service.js';
|
import { verifyWebSocketClient } from '@/modules/websocket/services/websocket-auth.service.js';
|
||||||
import { handlePluginWsProxy } from '@/modules/websocket/services/plugin-websocket-proxy.service.js';
|
import { handlePluginWsProxy } from '@/modules/websocket/services/plugin-websocket-proxy.service.js';
|
||||||
import { handleShellConnection } from '@/modules/websocket/services/shell-websocket.service.js';
|
import { handleShellConnection } from '@/modules/websocket/services/shell-websocket.service.js';
|
||||||
|
import { handleDesktopNotificationsConnection } from '@/modules/notifications/index.js';
|
||||||
import type { AuthenticatedWebSocketRequest } from '@/shared/types.js';
|
import type { AuthenticatedWebSocketRequest } from '@/shared/types.js';
|
||||||
|
|
||||||
type WebSocketServerDependencies = {
|
type WebSocketServerDependencies = {
|
||||||
@@ -63,6 +64,11 @@ export function createWebSocketServer(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pathname === '/desktop-notifications') {
|
||||||
|
handleDesktopNotificationsConnection(ws, incomingRequest);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (pathname.startsWith('/plugin-ws/')) {
|
if (pathname.startsWith('/plugin-ws/')) {
|
||||||
handlePluginWsProxy(ws, pathname, dependencies.getPluginPort);
|
handlePluginWsProxy(ws, pathname, dependencies.getPluginPort);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
|
||||||
import { apiKeysDb, credentialsDb, notificationPreferencesDb, pushSubscriptionsDb } from '../modules/database/index.js';
|
import {
|
||||||
|
apiKeysDb,
|
||||||
|
credentialsDb,
|
||||||
|
notificationPreferencesDb,
|
||||||
|
pushSubscriptionsDb,
|
||||||
|
} from '../modules/database/index.js';
|
||||||
import { getPublicKey } from '../services/vapid-keys.js';
|
import { getPublicKey } from '../services/vapid-keys.js';
|
||||||
import { createNotificationEvent, notifyUserIfEnabled } from '../services/notification-orchestrator.js';
|
import { createNotificationEvent, notifyUserIfEnabled } from '../services/notification-orchestrator.js';
|
||||||
|
|
||||||
|
|||||||
@@ -1,268 +1,7 @@
|
|||||||
import webPush from 'web-push';
|
|
||||||
|
|
||||||
import { notificationPreferencesDb, pushSubscriptionsDb, sessionsDb } from '../modules/database/index.js';
|
|
||||||
|
|
||||||
const KIND_TO_PREF_KEY = {
|
|
||||||
action_required: 'actionRequired',
|
|
||||||
stop: 'stop',
|
|
||||||
error: 'error'
|
|
||||||
};
|
|
||||||
|
|
||||||
const PROVIDER_LABELS = {
|
|
||||||
claude: 'Claude',
|
|
||||||
cursor: 'Cursor',
|
|
||||||
codex: 'Codex',
|
|
||||||
gemini: 'Gemini',
|
|
||||||
system: 'System'
|
|
||||||
};
|
|
||||||
|
|
||||||
const recentEventKeys = new Map();
|
|
||||||
const DEDUPE_WINDOW_MS = 20000;
|
|
||||||
|
|
||||||
const cleanupOldEventKeys = () => {
|
|
||||||
const now = Date.now();
|
|
||||||
for (const [key, timestamp] of recentEventKeys.entries()) {
|
|
||||||
if (now - timestamp > DEDUPE_WINDOW_MS) {
|
|
||||||
recentEventKeys.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function shouldSendPush(preferences, event) {
|
|
||||||
const webPushEnabled = Boolean(preferences?.channels?.webPush);
|
|
||||||
const prefEventKey = KIND_TO_PREF_KEY[event.kind];
|
|
||||||
const eventEnabled = prefEventKey ? Boolean(preferences?.events?.[prefEventKey]) : true;
|
|
||||||
|
|
||||||
return webPushEnabled && eventEnabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDuplicate(event) {
|
|
||||||
cleanupOldEventKeys();
|
|
||||||
const key = event.dedupeKey || `${event.provider}:${event.kind || 'info'}:${event.code || 'generic'}:${event.sessionId || 'none'}`;
|
|
||||||
if (recentEventKeys.has(key)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
recentEventKeys.set(key, Date.now());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createNotificationEvent({
|
|
||||||
provider,
|
|
||||||
sessionId = null,
|
|
||||||
kind = 'info',
|
|
||||||
code = 'generic.info',
|
|
||||||
meta = {},
|
|
||||||
severity = 'info',
|
|
||||||
dedupeKey = null,
|
|
||||||
requiresUserAction = false
|
|
||||||
}) {
|
|
||||||
return {
|
|
||||||
provider,
|
|
||||||
sessionId,
|
|
||||||
kind,
|
|
||||||
code,
|
|
||||||
meta,
|
|
||||||
severity,
|
|
||||||
requiresUserAction,
|
|
||||||
dedupeKey,
|
|
||||||
createdAt: new Date().toISOString()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeErrorMessage(error) {
|
|
||||||
if (typeof error === 'string') {
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error && typeof error.message === 'string') {
|
|
||||||
return error.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error == null) {
|
|
||||||
return 'Unknown error';
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSessionName(sessionName) {
|
|
||||||
if (typeof sessionName !== 'string') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalized = sessionName.replace(/\s+/g, ' ').trim();
|
|
||||||
if (!normalized) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rowMatchesProvider(row, provider) {
|
|
||||||
return row && (!provider || row.provider === provider);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveSessionRow(sessionId, provider) {
|
|
||||||
if (!sessionId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const appSessionRow = sessionsDb.getSessionById(sessionId);
|
|
||||||
if (rowMatchesProvider(appSessionRow, provider)) {
|
|
||||||
return appSessionRow;
|
|
||||||
}
|
|
||||||
|
|
||||||
const providerSessionRow = sessionsDb.getSessionByProviderSessionId(sessionId);
|
|
||||||
if (rowMatchesProvider(providerSessionRow, provider)) {
|
|
||||||
return providerSessionRow;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeNotificationSession(event) {
|
|
||||||
if (!event?.sessionId || !event.provider || event.provider === 'system') {
|
|
||||||
return event;
|
|
||||||
}
|
|
||||||
|
|
||||||
const row = resolveSessionRow(event.sessionId, event.provider);
|
|
||||||
if (!row || row.session_id === event.sessionId) {
|
|
||||||
return event;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...event,
|
|
||||||
sessionId: row.session_id
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveSessionName(event) {
|
|
||||||
const explicitSessionName = normalizeSessionName(event.meta?.sessionName);
|
|
||||||
if (explicitSessionName) {
|
|
||||||
return explicitSessionName;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!event.sessionId || !event.provider) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalizeSessionName(sessionsDb.getSessionName(event.sessionId, event.provider));
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPushBody(event) {
|
|
||||||
const normalizedEvent = normalizeNotificationSession(event);
|
|
||||||
const CODE_MAP = {
|
|
||||||
'permission.required': normalizedEvent.meta?.toolName
|
|
||||||
? `Action Required: Tool "${normalizedEvent.meta.toolName}" needs approval`
|
|
||||||
: 'Action Required: A tool needs your approval',
|
|
||||||
'run.stopped': normalizedEvent.meta?.stopReason || 'Run Stopped: The run has stopped',
|
|
||||||
'run.failed': normalizedEvent.meta?.error ? `Run Failed: ${normalizedEvent.meta.error}` : 'Run Failed: The run encountered an error',
|
|
||||||
'agent.notification': normalizedEvent.meta?.message ? String(normalizedEvent.meta.message) : 'You have a new notification',
|
|
||||||
'push.enabled': 'Push notifications are now enabled!'
|
|
||||||
};
|
|
||||||
const providerLabel = PROVIDER_LABELS[normalizedEvent.provider] || 'Assistant';
|
|
||||||
const sessionName = resolveSessionName(normalizedEvent);
|
|
||||||
const message = CODE_MAP[normalizedEvent.code] || 'You have a new notification';
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: sessionName || 'CloudCLI',
|
|
||||||
body: `${providerLabel}: ${message}`,
|
|
||||||
data: {
|
|
||||||
sessionId: normalizedEvent.sessionId || null,
|
|
||||||
code: normalizedEvent.code,
|
|
||||||
provider: normalizedEvent.provider || null,
|
|
||||||
sessionName,
|
|
||||||
tag: `${normalizedEvent.provider || 'assistant'}:${normalizedEvent.sessionId || 'none'}:${normalizedEvent.code}`
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendWebPush(userId, event) {
|
|
||||||
const subscriptions = pushSubscriptionsDb.getSubscriptions(userId);
|
|
||||||
if (!subscriptions.length) return;
|
|
||||||
|
|
||||||
const payload = JSON.stringify(buildPushBody(event));
|
|
||||||
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
subscriptions.map((sub) =>
|
|
||||||
webPush.sendNotification(
|
|
||||||
{
|
|
||||||
endpoint: sub.endpoint,
|
|
||||||
keys: {
|
|
||||||
p256dh: sub.keys_p256dh,
|
|
||||||
auth: sub.keys_auth
|
|
||||||
}
|
|
||||||
},
|
|
||||||
payload
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Clean up gone subscriptions (410 Gone or 404)
|
|
||||||
results.forEach((result, index) => {
|
|
||||||
if (result.status === 'rejected') {
|
|
||||||
const statusCode = result.reason?.statusCode;
|
|
||||||
if (statusCode === 410 || statusCode === 404) {
|
|
||||||
pushSubscriptionsDb.removeSubscription(subscriptions[index].endpoint);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function notifyUserIfEnabled({ userId, event }) {
|
|
||||||
if (!userId || !event) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedEvent = normalizeNotificationSession(event);
|
|
||||||
const preferences = notificationPreferencesDb.getPreferences(userId);
|
|
||||||
if (!shouldSendPush(preferences, normalizedEvent)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isDuplicate(normalizedEvent)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
sendWebPush(userId, normalizedEvent).catch((err) => {
|
|
||||||
console.error('Web push send error:', err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function notifyRunStopped({ userId, provider, sessionId = null, stopReason = 'completed', sessionName = null }) {
|
|
||||||
notifyUserIfEnabled({
|
|
||||||
userId,
|
|
||||||
event: createNotificationEvent({
|
|
||||||
provider,
|
|
||||||
sessionId,
|
|
||||||
kind: 'stop',
|
|
||||||
code: 'run.stopped',
|
|
||||||
meta: { stopReason, sessionName },
|
|
||||||
severity: 'info',
|
|
||||||
dedupeKey: `${provider}:run:stop:${sessionId || 'none'}:${stopReason}`
|
|
||||||
})
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function notifyRunFailed({ userId, provider, sessionId = null, error, sessionName = null }) {
|
|
||||||
const errorMessage = normalizeErrorMessage(error);
|
|
||||||
|
|
||||||
notifyUserIfEnabled({
|
|
||||||
userId,
|
|
||||||
event: createNotificationEvent({
|
|
||||||
provider,
|
|
||||||
sessionId,
|
|
||||||
kind: 'error',
|
|
||||||
code: 'run.failed',
|
|
||||||
meta: { error: errorMessage, sessionName },
|
|
||||||
severity: 'error',
|
|
||||||
dedupeKey: `${provider}:run:error:${sessionId || 'none'}:${errorMessage}`
|
|
||||||
})
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
buildNotificationPayload,
|
||||||
createNotificationEvent,
|
createNotificationEvent,
|
||||||
notifyUserIfEnabled,
|
notifyUserIfEnabled,
|
||||||
notifyRunStopped,
|
notifyRunStopped,
|
||||||
notifyRunFailed
|
notifyRunFailed,
|
||||||
};
|
} from '../modules/notifications/services/notification-orchestrator.service.js';
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ import {
|
|||||||
pushSubscriptionsDb,
|
pushSubscriptionsDb,
|
||||||
sessionsDb,
|
sessionsDb,
|
||||||
userDb,
|
userDb,
|
||||||
} from '../modules/database/index.js';
|
} from '../../modules/database/index.js';
|
||||||
|
|
||||||
import { notifyRunStopped } from './notification-orchestrator.js';
|
import { notifyRunStopped } from '../notification-orchestrator.js';
|
||||||
|
|
||||||
async function withIsolatedDatabase(runTest) {
|
async function withIsolatedDatabase(runTest) {
|
||||||
const previousDatabasePath = process.env.DATABASE_PATH;
|
const previousDatabasePath = process.env.DATABASE_PATH;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
ProviderMcpServer,
|
ProviderMcpServer,
|
||||||
ProviderSessionActiveModelChange,
|
ProviderSessionActiveModelChange,
|
||||||
ProviderSkillCreateInput,
|
ProviderSkillCreateInput,
|
||||||
|
ProviderSkillRemoveInput,
|
||||||
UpsertProviderMcpServerInput,
|
UpsertProviderMcpServerInput,
|
||||||
} from '@/shared/types.js';
|
} from '@/shared/types.js';
|
||||||
|
|
||||||
@@ -111,6 +112,10 @@ export interface IProviderSkills {
|
|||||||
* records that were written.
|
* records that were written.
|
||||||
*/
|
*/
|
||||||
addSkills(input: ProviderSkillCreateInput): Promise<ProviderSkill[]>;
|
addSkills(input: ProviderSkillCreateInput): Promise<ProviderSkill[]>;
|
||||||
|
|
||||||
|
removeSkill(
|
||||||
|
input: ProviderSkillRemoveInput,
|
||||||
|
): Promise<{ removed: boolean; provider: LLMProvider; directoryName: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
|
|||||||
@@ -361,6 +361,10 @@ export type ProviderSkillCreateInput = {
|
|||||||
entries: ProviderSkillCreateEntry[];
|
entries: ProviderSkillCreateEntry[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ProviderSkillRemoveInput = {
|
||||||
|
directoryName: string;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalized skill record returned by provider skill adapters.
|
* Normalized skill record returned by provider skill adapters.
|
||||||
*
|
*
|
||||||
|
|||||||
224
server/voice-proxy.js
Normal file
224
server/voice-proxy.js
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
// Optional voice proxy — forwards STT/TTS to an OpenAI-compatible audio backend.
|
||||||
|
//
|
||||||
|
// The backend is whatever the user points at: OpenAI, Groq, or a local server
|
||||||
|
// (LocalAI / Speaches / Kokoro-FastAPI / openedai-speech / etc.). It must expose the
|
||||||
|
// standard OpenAI audio endpoints:
|
||||||
|
// POST {base}/audio/transcriptions (multipart 'file' + 'model') -> { text }
|
||||||
|
// POST {base}/audio/speech ({ model, voice, input }) -> audio bytes
|
||||||
|
//
|
||||||
|
// Config is resolved per-request from headers (set by the client's voice settings),
|
||||||
|
// falling back to server env defaults. Mounted at /api/voice behind authenticateToken.
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
|
|
||||||
|
import express from 'express';
|
||||||
|
|
||||||
|
const ENV = {
|
||||||
|
baseUrl: (process.env.VOICE_API_BASE_URL || '').replace(/\/$/, ''),
|
||||||
|
apiKey: process.env.VOICE_API_KEY || '',
|
||||||
|
sttModel: process.env.VOICE_STT_MODEL || 'whisper-1',
|
||||||
|
ttsModel: process.env.VOICE_TTS_MODEL || 'tts-1',
|
||||||
|
ttsVoice: process.env.VOICE_TTS_VOICE || 'alloy',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the voice backend config for a request. Client headers (set from the
|
||||||
|
* user's in-app voice settings) take precedence over the server env defaults.
|
||||||
|
* @param {import('express').Request} req
|
||||||
|
* @returns {{baseUrl: string, apiKey: string, sttModel: string, ttsModel: string, ttsVoice: string, ttsFormat: string}}
|
||||||
|
*/
|
||||||
|
function resolveConfig(req) {
|
||||||
|
const h = req.headers;
|
||||||
|
return {
|
||||||
|
// Security: do not allow clients to control the outbound backend host.
|
||||||
|
// Always use the server-side configured base URL.
|
||||||
|
baseUrl: ENV.baseUrl,
|
||||||
|
apiKey: String(h['x-voice-api-key'] || '') || ENV.apiKey,
|
||||||
|
sttModel: String(h['x-voice-stt-model'] || '') || ENV.sttModel,
|
||||||
|
ttsModel: String(h['x-voice-tts-model'] || '') || ENV.ttsModel,
|
||||||
|
ttsVoice: String(h['x-voice-tts-voice'] || '') || ENV.ttsVoice,
|
||||||
|
ttsFormat: String(h['x-voice-tts-format'] || '').trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Generous by default — local TTS can synthesize long messages at ~real-time on CPU.
|
||||||
|
// Guard against a non-numeric/zero override that would make setTimeout fire immediately.
|
||||||
|
const DEFAULT_VOICE_TIMEOUT_MS = 300000;
|
||||||
|
const _parsedTimeout = Number(process.env.VOICE_TIMEOUT_MS);
|
||||||
|
const VOICE_TIMEOUT_MS = Number.isFinite(_parsedTimeout) && _parsedTimeout > 0
|
||||||
|
? _parsedTimeout
|
||||||
|
: DEFAULT_VOICE_TIMEOUT_MS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fetch() with an AbortController timeout so a stalled backend can't hold the
|
||||||
|
* request open indefinitely. Aborts after VOICE_TIMEOUT_MS.
|
||||||
|
* @param {string} url
|
||||||
|
* @param {RequestInit} [options]
|
||||||
|
* @returns {Promise<Response>}
|
||||||
|
*/
|
||||||
|
async function fetchWithTimeout(url, options = {}) {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
if (!['http:', 'https:'].includes(parsed.protocol) || !isAllowedBackendUrl(parsed.origin)) {
|
||||||
|
throw new Error('Blocked outbound voice backend URL');
|
||||||
|
}
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), VOICE_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
return await fetch(parsed.toString(), { redirect: 'manual', ...options, signal: controller.signal });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn a backend fetch failure into a clear, actionable client response:
|
||||||
|
* 504 on timeout (AbortError), 502 otherwise.
|
||||||
|
* @param {import('express').Response} res
|
||||||
|
* @param {Error} e
|
||||||
|
*/
|
||||||
|
function backendError(res, e) {
|
||||||
|
if (e && e.name === 'AbortError') {
|
||||||
|
return res.status(504).json({
|
||||||
|
error: `Voice backend timed out after ${Math.round(VOICE_TIMEOUT_MS / 1000)}s. Check your voice backend.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res.status(502).json({ error: `Voice backend unreachable: ${e.message}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSRF guard for the user-configurable backend URL: allow http/https only and
|
||||||
|
* block the link-local / cloud-metadata range (169.254.x). localhost and private
|
||||||
|
* ranges are allowed on purpose so users can point at a local voice server
|
||||||
|
* (LocalAI, Speaches, Kokoro-FastAPI, etc.).
|
||||||
|
* @param {string} raw
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isAllowedBackendUrl(raw) {
|
||||||
|
let u;
|
||||||
|
try {
|
||||||
|
u = new URL(raw);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
|
||||||
|
if (u.hostname === '169.254.169.254' || u.hostname.startsWith('169.254.')) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relay an upstream (backend) error to the client without making an upstream
|
||||||
|
* 401/403 look like the user's own app login failed.
|
||||||
|
* @param {import('express').Response} res
|
||||||
|
* @param {number} status
|
||||||
|
* @param {string} [text]
|
||||||
|
*/
|
||||||
|
function upstreamError(res, status, text) {
|
||||||
|
if (status === 401 || status === 403) {
|
||||||
|
return res.status(502).json({ error: 'Voice backend rejected the request (check the API key).' });
|
||||||
|
}
|
||||||
|
return res.status(status).json({ error: text || 'voice backend error' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let _upload = null;
|
||||||
|
/**
|
||||||
|
* Lazily build a memory-storage multer instance (25 MB cap) for audio uploads,
|
||||||
|
* so multer is only imported when the voice feature is actually used.
|
||||||
|
* @returns {Promise<import('multer').Multer>}
|
||||||
|
*/
|
||||||
|
async function getUpload() {
|
||||||
|
if (!_upload) {
|
||||||
|
const multer = (await import('multer')).default;
|
||||||
|
_upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } });
|
||||||
|
}
|
||||||
|
return _upload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the Authorization header for the backend, or an empty object when no
|
||||||
|
* key is configured (e.g. a local server that needs none).
|
||||||
|
* @param {string} apiKey
|
||||||
|
* @returns {Record<string, string>}
|
||||||
|
*/
|
||||||
|
function authHeader(apiKey) {
|
||||||
|
return apiKey ? { Authorization: `Bearer ${apiKey}` } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/voice/health -> { configured } (true when a backend base URL is set).
|
||||||
|
*/
|
||||||
|
router.get('/health', (req, res) => {
|
||||||
|
res.json({ configured: Boolean(resolveConfig(req).baseUrl) });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/voice/transcribe (multipart 'audio') -> { text }.
|
||||||
|
* Forwards the uploaded audio to the backend's /audio/transcriptions endpoint.
|
||||||
|
*/
|
||||||
|
router.post('/transcribe', async (req, res) => {
|
||||||
|
const cfg = resolveConfig(req);
|
||||||
|
if (!cfg.baseUrl) return res.status(503).json({ error: 'No voice backend configured' });
|
||||||
|
if (!isAllowedBackendUrl(cfg.baseUrl)) return res.status(400).json({ error: 'Invalid voice backend URL.' });
|
||||||
|
const upload = await getUpload();
|
||||||
|
upload.single('audio')(req, res, async (err) => {
|
||||||
|
if (err) return res.status(400).json({ error: err.message });
|
||||||
|
if (!req.file) return res.status(400).json({ error: 'No audio uploaded' });
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append(
|
||||||
|
'file',
|
||||||
|
new Blob([req.file.buffer], { type: req.file.mimetype || 'audio/webm' }),
|
||||||
|
req.file.originalname || 'recording.webm',
|
||||||
|
);
|
||||||
|
fd.append('model', cfg.sttModel);
|
||||||
|
const r = await fetchWithTimeout(`${cfg.baseUrl}/audio/transcriptions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeader(cfg.apiKey),
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
|
const text = await r.text();
|
||||||
|
if (!r.ok) return upstreamError(res, r.status, text);
|
||||||
|
let data;
|
||||||
|
try { data = JSON.parse(text); } catch { data = { text }; }
|
||||||
|
res.json({ text: data.text ?? '' });
|
||||||
|
} catch (e) {
|
||||||
|
backendError(res, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/voice/tts { text } -> audio bytes.
|
||||||
|
* Forwards the text to the backend's /audio/speech endpoint and streams the audio back.
|
||||||
|
*/
|
||||||
|
router.post('/tts', async (req, res) => {
|
||||||
|
const cfg = resolveConfig(req);
|
||||||
|
if (!cfg.baseUrl) return res.status(503).json({ error: 'No voice backend configured' });
|
||||||
|
if (!isAllowedBackendUrl(cfg.baseUrl)) return res.status(400).json({ error: 'Invalid voice backend URL.' });
|
||||||
|
const text = req.body?.text;
|
||||||
|
if (typeof text !== 'string' || !text.trim()) return res.status(400).json({ error: 'text required' });
|
||||||
|
try {
|
||||||
|
const r = await fetchWithTimeout(`${cfg.baseUrl}/audio/speech`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...authHeader(cfg.apiKey) },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: cfg.ttsModel,
|
||||||
|
voice: cfg.ttsVoice,
|
||||||
|
input: text,
|
||||||
|
...(cfg.ttsFormat ? { response_format: cfg.ttsFormat } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const errText = await r.text().catch(() => 'tts failed');
|
||||||
|
return upstreamError(res, r.status, errText);
|
||||||
|
}
|
||||||
|
res.setHeader('Content-Type', r.headers.get('content-type') || 'audio/mpeg');
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
if (!r.body) return res.end();
|
||||||
|
Readable.fromWeb(r.body).on('error', (error) => res.destroy(error)).pipe(res);
|
||||||
|
} catch (e) {
|
||||||
|
backendError(res, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -204,6 +204,8 @@ export function useChatComposerState({
|
|||||||
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const inputHighlightRef = useRef<HTMLDivElement>(null);
|
const inputHighlightRef = useRef<HTMLDivElement>(null);
|
||||||
|
const textareaLineHeightRef = useRef<number | null>(null);
|
||||||
|
const lastAutosizedInputRef = useRef<string | null>(null);
|
||||||
const handleSubmitRef = useRef<
|
const handleSubmitRef = useRef<
|
||||||
((event: FormEvent<HTMLFormElement> | MouseEvent | TouchEvent | KeyboardEvent<HTMLTextAreaElement>) => Promise<void>) | null
|
((event: FormEvent<HTMLFormElement> | MouseEvent | TouchEvent | KeyboardEvent<HTMLTextAreaElement>) => Promise<void>) | null
|
||||||
>(null);
|
>(null);
|
||||||
@@ -457,6 +459,22 @@ export function useChatComposerState({
|
|||||||
inputHighlightRef.current.scrollLeft = target.scrollLeft;
|
inputHighlightRef.current.scrollLeft = target.scrollLeft;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const resizeTextarea = useCallback((target: HTMLTextAreaElement) => {
|
||||||
|
target.style.height = 'auto';
|
||||||
|
const nextHeight = Math.max(22, target.scrollHeight);
|
||||||
|
target.style.height = `${nextHeight}px`;
|
||||||
|
|
||||||
|
let lineHeight = textareaLineHeightRef.current;
|
||||||
|
if (!lineHeight) {
|
||||||
|
lineHeight = parseInt(window.getComputedStyle(target).lineHeight);
|
||||||
|
textareaLineHeightRef.current = Number.isFinite(lineHeight) ? lineHeight : 24;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expanded = nextHeight > (textareaLineHeightRef.current || 24) * 2;
|
||||||
|
setIsTextareaExpanded((previous) => previous === expanded ? previous : expanded);
|
||||||
|
lastAutosizedInputRef.current = target.value;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleImageFiles = useCallback((files: File[]) => {
|
const handleImageFiles = useCallback((files: File[]) => {
|
||||||
const validFiles = files.filter((file) => {
|
const validFiles = files.filter((file) => {
|
||||||
try {
|
try {
|
||||||
@@ -775,6 +793,17 @@ export function useChatComposerState({
|
|||||||
handleSubmitRef.current = handleSubmit;
|
handleSubmitRef.current = handleSubmit;
|
||||||
}, [handleSubmit]);
|
}, [handleSubmit]);
|
||||||
|
|
||||||
|
// A voice transcript either fills the input (to edit before sending) or, when the
|
||||||
|
// user tapped "stop and send", is submitted straight away. Mirror the value into
|
||||||
|
// inputValueRef synchronously so handleSubmit reads the new text, not the stale state.
|
||||||
|
const handleVoiceTranscript = useCallback((text: string, send?: boolean) => {
|
||||||
|
const base = inputValueRef.current.trim();
|
||||||
|
const next = base ? `${base} ${text}` : text;
|
||||||
|
setInput(next);
|
||||||
|
inputValueRef.current = next;
|
||||||
|
if (send) handleSubmitRef.current?.(createFakeSubmitEvent());
|
||||||
|
}, [setInput]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
inputValueRef.current = input;
|
inputValueRef.current = input;
|
||||||
}, [input]);
|
}, [input]);
|
||||||
@@ -806,13 +835,13 @@ export function useChatComposerState({
|
|||||||
if (!textareaRef.current) {
|
if (!textareaRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Re-run when input changes so restored drafts get the same autosize behavior as typed text.
|
if (lastAutosizedInputRef.current === input) {
|
||||||
textareaRef.current.style.height = 'auto';
|
return;
|
||||||
textareaRef.current.style.height = `${Math.max(22, textareaRef.current.scrollHeight)}px`;
|
}
|
||||||
const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight);
|
// Re-run for restored drafts and programmatic input changes. User typing is
|
||||||
const expanded = textareaRef.current.scrollHeight > lineHeight * 2;
|
// already resized in onInput, so this avoids doing the same forced layout twice.
|
||||||
setIsTextareaExpanded(expanded);
|
resizeTextarea(textareaRef.current);
|
||||||
}, [input]);
|
}, [input, resizeTextarea]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!textareaRef.current || input.trim()) {
|
if (!textareaRef.current || input.trim()) {
|
||||||
@@ -894,15 +923,11 @@ export function useChatComposerState({
|
|||||||
const handleTextareaInput = useCallback(
|
const handleTextareaInput = useCallback(
|
||||||
(event: FormEvent<HTMLTextAreaElement>) => {
|
(event: FormEvent<HTMLTextAreaElement>) => {
|
||||||
const target = event.currentTarget;
|
const target = event.currentTarget;
|
||||||
target.style.height = 'auto';
|
resizeTextarea(target);
|
||||||
target.style.height = `${Math.max(22, target.scrollHeight)}px`;
|
|
||||||
setCursorPosition(target.selectionStart);
|
setCursorPosition(target.selectionStart);
|
||||||
syncInputOverlayScroll(target);
|
syncInputOverlayScroll(target);
|
||||||
|
|
||||||
const lineHeight = parseInt(window.getComputedStyle(target).lineHeight);
|
|
||||||
setIsTextareaExpanded(target.scrollHeight > lineHeight * 2);
|
|
||||||
},
|
},
|
||||||
[setCursorPosition, syncInputOverlayScroll],
|
[resizeTextarea, setCursorPosition, syncInputOverlayScroll],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleClearInput = useCallback(() => {
|
const handleClearInput = useCallback(() => {
|
||||||
@@ -1013,6 +1038,7 @@ export function useChatComposerState({
|
|||||||
isDragActive,
|
isDragActive,
|
||||||
openImagePicker: open,
|
openImagePicker: open,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
handleVoiceTranscript,
|
||||||
handleInputChange,
|
handleInputChange,
|
||||||
handleKeyDown,
|
handleKeyDown,
|
||||||
handlePaste,
|
handlePaste,
|
||||||
|
|||||||
@@ -207,6 +207,15 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A result with a toolId but no matching tool_use in the loaded set is
|
||||||
|
// almost always a tool_use/tool_result pair split across a pagination
|
||||||
|
// boundary (older page not loaded yet). Rendering its raw content here
|
||||||
|
// produces an unstyled dump that "fixes itself" once the older page
|
||||||
|
// loads; skip it and let it attach to its tool_use when that arrives.
|
||||||
|
if (msg.toolId) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
const content = formatToolResultContent(msg.content || '');
|
const content = formatToolResultContent(msg.content || '');
|
||||||
if (!content.trim()) {
|
if (!content.trim()) {
|
||||||
break;
|
break;
|
||||||
|
|||||||
33
src/components/chat/hooks/useTts.ts
Normal file
33
src/components/chat/hooks/useTts.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { voicePlayer, voiceId, type VoiceSnapshot } from '../../../lib/voicePlayer';
|
||||||
|
|
||||||
|
export type TtsState = VoiceSnapshot['state'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin adapter over the app-level voicePlayer. Playback lives outside React (see
|
||||||
|
* lib/voicePlayer), so switching chats or re-rendering a message no longer cuts the
|
||||||
|
* audio off. This hook just reflects the player's state for one message and forwards taps.
|
||||||
|
*/
|
||||||
|
export function useTts(getText: () => string) {
|
||||||
|
const content = getText();
|
||||||
|
const id = voiceId(content);
|
||||||
|
|
||||||
|
const [snap, setSnap] = useState<VoiceSnapshot>(() => voicePlayer.getSnapshot(id));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const update = () =>
|
||||||
|
setSnap((prev) => {
|
||||||
|
const next = voicePlayer.getSnapshot(id);
|
||||||
|
return prev.state === next.state && prev.error === next.error ? prev : next;
|
||||||
|
});
|
||||||
|
update();
|
||||||
|
return voicePlayer.subscribe(update);
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
voicePlayer.unlock(); // synchronous, within the click gesture (iOS)
|
||||||
|
voicePlayer.toggle(content);
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
|
return { state: snap.state, toggle, error: snap.error };
|
||||||
|
}
|
||||||
85
src/components/chat/hooks/useVoiceAvailable.ts
Normal file
85
src/components/chat/hooks/useVoiceAvailable.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { authenticatedFetch } from '../../../utils/api';
|
||||||
|
import { readVoiceConfig, VOICE_CONFIG_SYNC_EVENT } from '../../../hooks/useVoiceConfig';
|
||||||
|
|
||||||
|
// Voice UI is gated on the `voiceEnabled` UI preference (toggled in Quick Settings /
|
||||||
|
// the Settings modal) and a configured voice backend.
|
||||||
|
const STORAGE_KEY = 'uiPreferences';
|
||||||
|
const SYNC_EVENT = 'ui-preferences:sync';
|
||||||
|
let healthRequest: Promise<boolean> | null = null;
|
||||||
|
|
||||||
|
function checkVoiceHealth(): Promise<boolean> {
|
||||||
|
if (healthRequest) return healthRequest;
|
||||||
|
const request = authenticatedFetch('/api/voice/health')
|
||||||
|
.then(async (response) => {
|
||||||
|
if (!response.ok) throw new Error(`Voice health check failed (${response.status})`);
|
||||||
|
const data = await response.json();
|
||||||
|
return data?.configured === true;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
healthRequest = null;
|
||||||
|
});
|
||||||
|
healthRequest = request;
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readVoiceEnabled(): boolean {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return false;
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return parsed?.voiceEnabled === true || parsed?.voiceEnabled === 'true';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVoiceAvailable(): boolean {
|
||||||
|
const [enabled, setEnabled] = useState<boolean>(() =>
|
||||||
|
typeof window === 'undefined' ? false : readVoiceEnabled(),
|
||||||
|
);
|
||||||
|
const [available, setAvailable] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const update = () => setEnabled(readVoiceEnabled());
|
||||||
|
window.addEventListener('storage', update);
|
||||||
|
window.addEventListener(SYNC_EVENT, update as EventListener);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('storage', update);
|
||||||
|
window.removeEventListener(SYNC_EVENT, update as EventListener);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
let requestId = 0;
|
||||||
|
|
||||||
|
const check = async () => {
|
||||||
|
if (!enabled) {
|
||||||
|
setAvailable(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (readVoiceConfig().baseUrl.trim()) {
|
||||||
|
setAvailable(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = ++requestId;
|
||||||
|
try {
|
||||||
|
const result = await checkVoiceHealth();
|
||||||
|
if (active && id === requestId) setAvailable(result);
|
||||||
|
} catch {
|
||||||
|
if (active && id === requestId) setAvailable(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void check();
|
||||||
|
window.addEventListener(VOICE_CONFIG_SYNC_EVENT, check);
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
window.removeEventListener(VOICE_CONFIG_SYNC_EVENT, check);
|
||||||
|
};
|
||||||
|
}, [enabled]);
|
||||||
|
|
||||||
|
return enabled && available;
|
||||||
|
}
|
||||||
149
src/components/chat/hooks/useVoiceInput.ts
Normal file
149
src/components/chat/hooks/useVoiceInput.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { transcribeVoice } from '../../../lib/voiceApi';
|
||||||
|
|
||||||
|
// Mobile-safe recording: iOS Safari 18.4+ supports webm/opus; older iOS needs mp4.
|
||||||
|
const MIME_CANDIDATES = [
|
||||||
|
'audio/webm;codecs=opus',
|
||||||
|
'audio/webm',
|
||||||
|
'audio/mp4',
|
||||||
|
'audio/ogg;codecs=opus',
|
||||||
|
'audio/ogg',
|
||||||
|
];
|
||||||
|
|
||||||
|
function pickMime(): string {
|
||||||
|
for (const t of MIME_CANDIDATES) {
|
||||||
|
try {
|
||||||
|
if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(t)) return t;
|
||||||
|
} catch {
|
||||||
|
/* isTypeSupported can throw on some iOS versions */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VoiceInputState = 'idle' | 'recording' | 'transcribing';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push-to-talk dictation. Records the mic, uploads to /api/voice/transcribe
|
||||||
|
* (an OpenAI-compatible speech-to-text backend via the Express proxy), and
|
||||||
|
* returns the transcript through onTranscript.
|
||||||
|
*/
|
||||||
|
export function useVoiceInput(
|
||||||
|
onTranscript: (text: string, send?: boolean) => void,
|
||||||
|
onError?: (msg: string) => void,
|
||||||
|
) {
|
||||||
|
const [state, setState] = useState<VoiceInputState>('idle');
|
||||||
|
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||||
|
const chunksRef = useRef<Blob[]>([]);
|
||||||
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
|
const cancelledRef = useRef(false);
|
||||||
|
const startingRef = useRef(false);
|
||||||
|
// Whether the in-progress stop should auto-send the transcript (vs just fill the box).
|
||||||
|
const sendRef = useRef(false);
|
||||||
|
|
||||||
|
const stopTracks = () => {
|
||||||
|
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||||
|
streamRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stop the mic if the component unmounts mid-recording.
|
||||||
|
useEffect(() => {
|
||||||
|
cancelledRef.current = false;
|
||||||
|
return () => {
|
||||||
|
cancelledRef.current = true;
|
||||||
|
startingRef.current = false;
|
||||||
|
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||||
|
streamRef.current = null;
|
||||||
|
recorderRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const start = useCallback(async () => {
|
||||||
|
if (startingRef.current || (recorderRef.current && recorderRef.current.state !== 'inactive')) return;
|
||||||
|
startingRef.current = true;
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: { echoCancellation: true, noiseSuppression: true },
|
||||||
|
});
|
||||||
|
if (cancelledRef.current) {
|
||||||
|
stream.getTracks().forEach((t) => t.stop());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
streamRef.current = stream;
|
||||||
|
const mimeType = pickMime();
|
||||||
|
const rec = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
|
||||||
|
recorderRef.current = rec;
|
||||||
|
chunksRef.current = [];
|
||||||
|
|
||||||
|
rec.ondataavailable = (e) => {
|
||||||
|
if (e.data.size > 0) chunksRef.current.push(e.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
rec.onstop = async () => {
|
||||||
|
stopTracks();
|
||||||
|
if (cancelledRef.current) return;
|
||||||
|
// Capture and clear the send intent for this stop before any async work.
|
||||||
|
const shouldSend = sendRef.current;
|
||||||
|
sendRef.current = false;
|
||||||
|
const type = rec.mimeType || 'audio/webm';
|
||||||
|
const blob = new Blob(chunksRef.current, { type });
|
||||||
|
if (blob.size < 800) {
|
||||||
|
setState('idle');
|
||||||
|
onError?.('Recording too short');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState('transcribing');
|
||||||
|
try {
|
||||||
|
const ext = type.includes('mp4') ? 'm4a' : type.includes('ogg') ? 'ogg' : 'webm';
|
||||||
|
const res = await transcribeVoice(blob, `recording.${ext}`);
|
||||||
|
if (!res.ok) throw new Error(`transcribe ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (cancelledRef.current) return;
|
||||||
|
const text = String(data?.text || '').trim();
|
||||||
|
if (text) onTranscript(text, shouldSend);
|
||||||
|
else onError?.('No speech detected');
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelledRef.current) {
|
||||||
|
onError?.(`Transcription failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelledRef.current) setState('idle');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rec.start();
|
||||||
|
setState('recording');
|
||||||
|
} catch (e) {
|
||||||
|
recorderRef.current = null;
|
||||||
|
stopTracks();
|
||||||
|
if (cancelledRef.current) return;
|
||||||
|
const err = e as { name?: string; message?: string };
|
||||||
|
let msg = `Mic error: ${err?.message || e}`;
|
||||||
|
if (err?.name === 'NotAllowedError') msg = 'Microphone access denied.';
|
||||||
|
else if (err?.name === 'NotFoundError') msg = 'No microphone found.';
|
||||||
|
onError?.(msg);
|
||||||
|
setState('idle');
|
||||||
|
} finally {
|
||||||
|
startingRef.current = false;
|
||||||
|
}
|
||||||
|
}, [onTranscript, onError]);
|
||||||
|
|
||||||
|
// Stop recording. Pass { send: true } to auto-send the transcript once it's ready.
|
||||||
|
// Guard on the recorder's own state (not React state) so a double tap, or the mic
|
||||||
|
// and Send buttons both firing, can't call stop() on an already-inactive recorder.
|
||||||
|
const stop = useCallback((opts?: { send?: boolean }) => {
|
||||||
|
const rec = recorderRef.current;
|
||||||
|
if (rec && rec.state !== 'inactive') {
|
||||||
|
sendRef.current = opts?.send ?? false;
|
||||||
|
rec.stop();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
if (state === 'recording') stop();
|
||||||
|
else if (state === 'idle') start();
|
||||||
|
}, [state, start, stop]);
|
||||||
|
|
||||||
|
return { state, toggle, stop };
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import type { Project } from '../../../types/app';
|
|||||||
import type { SubagentChildTool } from '../types/types';
|
import type { SubagentChildTool } from '../types/types';
|
||||||
|
|
||||||
import { getToolConfig } from './configs/toolConfigs';
|
import { getToolConfig } from './configs/toolConfigs';
|
||||||
import { OneLineDisplay, CollapsibleDisplay, ToolDiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent, SubagentContainer } from './components';
|
import { OneLineDisplay, BashCommandDisplay, CollapsibleDisplay, ToolDiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent, SubagentContainer } from './components';
|
||||||
import { PlanDisplay } from './components/PlanDisplay';
|
import { PlanDisplay } from './components/PlanDisplay';
|
||||||
import { ToolStatusBadge } from './components/ToolStatusBadge';
|
import { ToolStatusBadge } from './components/ToolStatusBadge';
|
||||||
import type { ToolStatus } from './components/ToolStatusBadge';
|
import type { ToolStatus } from './components/ToolStatusBadge';
|
||||||
@@ -125,6 +125,39 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
|
|||||||
|
|
||||||
if (!displayConfig) return null;
|
if (!displayConfig) return null;
|
||||||
|
|
||||||
|
// Bash renders as a Codex-style command row: the command on a single line with
|
||||||
|
// a chevron that expands to show the output inline. The combined view lives on
|
||||||
|
// the input render; the separate result section is suppressed in MessageComponent.
|
||||||
|
if (toolName === 'Bash' && mode === 'input') {
|
||||||
|
const command = typeof parsedData === 'object' && parsedData !== null && 'command' in parsedData
|
||||||
|
? String(parsedData.command || '')
|
||||||
|
: typeof toolInput === 'string'
|
||||||
|
? toolInput
|
||||||
|
: typeof rawToolInput === 'string'
|
||||||
|
? rawToolInput
|
||||||
|
: '';
|
||||||
|
const description = typeof parsedData === 'object' && parsedData !== null && 'description' in parsedData
|
||||||
|
? String(parsedData.description || '')
|
||||||
|
: undefined;
|
||||||
|
const output = typeof toolResult?.content === 'string'
|
||||||
|
? toolResult.content
|
||||||
|
: toolResult?.content != null
|
||||||
|
? String(toolResult.content)
|
||||||
|
: '';
|
||||||
|
return (
|
||||||
|
<BashCommandDisplay
|
||||||
|
command={command}
|
||||||
|
description={description}
|
||||||
|
output={output}
|
||||||
|
isError={Boolean(toolResult?.isError)}
|
||||||
|
status={toolStatus !== 'completed' ? toolStatus : undefined}
|
||||||
|
// Commands stay collapsed by default (even consecutive ones); only
|
||||||
|
// failures auto-expand so they remain visible.
|
||||||
|
defaultOpen={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (displayConfig.type === 'one-line') {
|
if (displayConfig.type === 'one-line') {
|
||||||
const value = displayConfig.getValue?.(parsedData) || '';
|
const value = displayConfig.getValue?.(parsedData) || '';
|
||||||
const secondary = displayConfig.getSecondary?.(parsedData);
|
const secondary = displayConfig.getSecondary?.(parsedData);
|
||||||
|
|||||||
156
src/components/chat/tools/components/BashCommandDisplay.tsx
Normal file
156
src/components/chat/tools/components/BashCommandDisplay.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { ChevronRight, Copy, Check } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '../../../../lib/utils';
|
||||||
|
import { copyTextToClipboard } from '../../../../utils/clipboard';
|
||||||
|
import { ToolStatusBadge } from './ToolStatusBadge';
|
||||||
|
import type { ToolStatus } from './ToolStatusBadge';
|
||||||
|
|
||||||
|
interface BashCommandDisplayProps {
|
||||||
|
command: string;
|
||||||
|
description?: string;
|
||||||
|
/** Combined stdout/stderr from the tool result (empty while running). */
|
||||||
|
output?: string;
|
||||||
|
isError?: boolean;
|
||||||
|
status?: ToolStatus;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Codex-in-VSCode style command row: a compact, single-line command with a
|
||||||
|
* chevron on the left. When the command produced output, the row becomes a
|
||||||
|
* dropdown that expands to reveal the output inline. Theme-integrated surfaces
|
||||||
|
* keep it clean in both light and dark mode; consecutive commands stack tightly
|
||||||
|
* into a clean list.
|
||||||
|
*/
|
||||||
|
export const BashCommandDisplay: React.FC<BashCommandDisplayProps> = ({
|
||||||
|
command,
|
||||||
|
description,
|
||||||
|
output,
|
||||||
|
isError = false,
|
||||||
|
status,
|
||||||
|
defaultOpen = false,
|
||||||
|
}) => {
|
||||||
|
const trimmedOutput = (output || '').replace(/\s+$/, '');
|
||||||
|
const hasOutput = trimmedOutput.length > 0;
|
||||||
|
const outputLineCount = hasOutput ? trimmedOutput.split('\n').length : 0;
|
||||||
|
const isRunning = status === 'running';
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
// Output (and errors) often arrive after this component first mounts, so apply
|
||||||
|
// the auto-open intent once when there is finally something to show. After that
|
||||||
|
// the user is in control of the toggle.
|
||||||
|
const autoAppliedRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoAppliedRef.current && hasOutput && (defaultOpen || isError)) {
|
||||||
|
autoAppliedRef.current = true;
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
}, [hasOutput, defaultOpen, isError]);
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
if (hasOutput) {
|
||||||
|
setOpen((prev) => !prev);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = async (event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
const didCopy = await copyTextToClipboard(command);
|
||||||
|
if (!didCopy) return;
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'group/cmd overflow-hidden rounded-lg border bg-muted/40 backdrop-blur-sm transition-all duration-200',
|
||||||
|
isError ? 'border-red-500/30' : 'border-border/60',
|
||||||
|
hasOutput && !open && 'hover:border-border hover:bg-muted/60',
|
||||||
|
open && 'bg-muted/50 shadow-sm',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Command header — clickable when there is output to expand */}
|
||||||
|
<div
|
||||||
|
role={hasOutput ? 'button' : undefined}
|
||||||
|
tabIndex={hasOutput ? 0 : undefined}
|
||||||
|
aria-expanded={hasOutput ? open : undefined}
|
||||||
|
onClick={toggle}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (hasOutput && (event.key === 'Enter' || event.key === ' ')) {
|
||||||
|
event.preventDefault();
|
||||||
|
toggle();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-2.5 py-1.5 outline-none',
|
||||||
|
hasOutput && 'cursor-pointer focus-visible:ring-1 focus-visible:ring-ring',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
'h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/70 transition-transform duration-200',
|
||||||
|
open && 'rotate-90',
|
||||||
|
!hasOutput && 'opacity-0',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="flex-shrink-0 select-none font-mono text-xs font-semibold text-emerald-500 dark:text-emerald-400">
|
||||||
|
$
|
||||||
|
</span>
|
||||||
|
<code
|
||||||
|
className={cn(
|
||||||
|
'min-w-0 flex-1 font-mono text-xs text-foreground',
|
||||||
|
open ? 'whitespace-pre-wrap break-all' : 'truncate',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{command}
|
||||||
|
</code>
|
||||||
|
|
||||||
|
{isRunning && (
|
||||||
|
<span className="h-2.5 w-2.5 flex-shrink-0 animate-spin rounded-full border-[1.5px] border-muted-foreground/30 border-t-emerald-400" />
|
||||||
|
)}
|
||||||
|
{status && status !== 'running' && <ToolStatusBadge status={status} className="flex-shrink-0" />}
|
||||||
|
{!open && hasOutput && !isRunning && (
|
||||||
|
<span className="flex-shrink-0 text-[10px] tabular-nums text-muted-foreground/70 transition-opacity group-hover/cmd:opacity-0">
|
||||||
|
{outputLineCount} {outputLineCount === 1 ? 'line' : 'lines'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
onKeyDown={(event) => event.stopPropagation()}
|
||||||
|
className="flex-shrink-0 rounded p-0.5 text-muted-foreground/60 opacity-0 transition-all hover:bg-foreground/10 hover:text-foreground focus:opacity-100 group-hover/cmd:opacity-100"
|
||||||
|
title="Copy command"
|
||||||
|
aria-label="Copy command"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="h-3.5 w-3.5 text-emerald-500" /> : <Copy className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{description && !open && (
|
||||||
|
<div className="truncate px-2.5 pb-1.5 pl-[2.4rem] text-[11px] italic text-muted-foreground/70">
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Expanded output */}
|
||||||
|
{open && hasOutput && (
|
||||||
|
<div className="settings-content-enter border-t border-border/50 bg-background/50">
|
||||||
|
{description && (
|
||||||
|
<div className="px-3 pt-2 text-[11px] italic text-muted-foreground/70">{description}</div>
|
||||||
|
)}
|
||||||
|
<pre
|
||||||
|
className={cn(
|
||||||
|
'max-h-80 overflow-auto whitespace-pre-wrap break-all px-3 py-2 font-mono text-xs leading-relaxed',
|
||||||
|
isError ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{trimmedOutput}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import React from 'react';
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
|
import { QuestionAnswerContent } from './QuestionAnswerContent';
|
||||||
|
|
||||||
|
// Regression coverage for the chat-interface crash where an AskUserQuestion
|
||||||
|
// payload loaded from a session transcript arrives with a non-array `questions`
|
||||||
|
// or a question missing its `options` array. Rendering must degrade gracefully
|
||||||
|
// instead of throwing "TypeError: e.map is not a function".
|
||||||
|
|
||||||
|
test('renders without throwing when questions is a non-array value', () => {
|
||||||
|
assert.doesNotThrow(() => {
|
||||||
|
renderToStaticMarkup(
|
||||||
|
React.createElement(QuestionAnswerContent, {
|
||||||
|
// Malformed: object instead of an array
|
||||||
|
questions: { 0: { question: 'q?', options: [{ label: 'a' }] } } as never,
|
||||||
|
answers: {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders without throwing when a question is missing options[]', () => {
|
||||||
|
assert.doesNotThrow(() => {
|
||||||
|
renderToStaticMarkup(
|
||||||
|
React.createElement(QuestionAnswerContent, {
|
||||||
|
questions: [{ question: 'Pick one?', header: 'H' } as never],
|
||||||
|
answers: { 'Pick one?': 'X' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders without throwing when options[] contains malformed entries', () => {
|
||||||
|
assert.doesNotThrow(() => {
|
||||||
|
renderToStaticMarkup(
|
||||||
|
React.createElement(QuestionAnswerContent, {
|
||||||
|
questions: [{ question: 'Pick one?', options: [null, 'oops', { label: 'A' }] } as never],
|
||||||
|
answers: { 'Pick one?': 'A, Custom' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders without throwing when a questions entry is null/non-object', () => {
|
||||||
|
assert.doesNotThrow(() => {
|
||||||
|
renderToStaticMarkup(
|
||||||
|
React.createElement(QuestionAnswerContent, {
|
||||||
|
questions: [null, 'oops', { question: 'Ok?', options: [{ label: 'A' }] }] as never,
|
||||||
|
answers: {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders without throwing when an answer is a non-string value', () => {
|
||||||
|
assert.doesNotThrow(() => {
|
||||||
|
renderToStaticMarkup(
|
||||||
|
React.createElement(QuestionAnswerContent, {
|
||||||
|
questions: [{ question: 'Pick one?', options: [{ label: 'A' }] }],
|
||||||
|
// Malformed: answer is an object instead of the expected string
|
||||||
|
answers: { 'Pick one?': { unexpected: true } } as never,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('still renders a well-formed question + answer', () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
React.createElement(QuestionAnswerContent, {
|
||||||
|
questions: [{ question: 'Pick one?', header: 'H', options: [{ label: 'A' }, { label: 'B' }] }],
|
||||||
|
answers: { 'Pick one?': 'A' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.ok(html.includes('Pick one?'));
|
||||||
|
});
|
||||||
@@ -15,7 +15,11 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [expandedIdx, setExpandedIdx] = useState<number | null>(null);
|
const [expandedIdx, setExpandedIdx] = useState<number | null>(null);
|
||||||
|
|
||||||
if (!questions || questions.length === 0) {
|
// Tool inputs are runtime data loaded from session transcripts and may be
|
||||||
|
// malformed (e.g. `questions` arriving as a non-array). Guard with
|
||||||
|
// Array.isArray so a single bad payload can't crash the whole chat view
|
||||||
|
// with "e.map is not a function".
|
||||||
|
if (!Array.isArray(questions) || questions.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,11 +28,23 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`space-y-2 ${className}`}>
|
<div className={`space-y-2 ${className}`}>
|
||||||
{questions.map((q, idx) => {
|
{questions.map((rawQuestion, idx) => {
|
||||||
|
// Entries come from session transcripts and may be malformed; skip
|
||||||
|
// anything that isn't a proper question object with a string prompt.
|
||||||
|
if (!rawQuestion || typeof rawQuestion !== 'object' || typeof rawQuestion.question !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const q = rawQuestion;
|
||||||
const answer = answers?.[q.question];
|
const answer = answers?.[q.question];
|
||||||
const answerLabels = answer ? answer.split(', ') : [];
|
// `answer` may be a non-string (or absent) in malformed payloads.
|
||||||
|
const answerLabels = typeof answer === 'string' ? answer.split(', ') : [];
|
||||||
const skipped = !answer;
|
const skipped = !answer;
|
||||||
const isExpanded = expandedIdx === idx;
|
const isExpanded = expandedIdx === idx;
|
||||||
|
// `options` is typed as an array but comes from untrusted runtime data;
|
||||||
|
// keep only valid entries so `.some`/`.map` below never throw.
|
||||||
|
const options = Array.isArray(q.options)
|
||||||
|
? q.options.filter((opt) => opt && typeof opt === 'object' && typeof opt.label === 'string')
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -74,7 +90,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
|||||||
{!isExpanded && answerLabels.length > 0 && (
|
{!isExpanded && answerLabels.length > 0 && (
|
||||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||||
{answerLabels.map((lbl) => {
|
{answerLabels.map((lbl) => {
|
||||||
const isCustom = !q.options.some(o => o.label === lbl);
|
const isCustom = !options.some(o => o.label === lbl);
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={lbl}
|
key={lbl}
|
||||||
@@ -110,7 +126,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
|||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="border-t border-gray-100 px-3 pb-2.5 pt-0.5 dark:border-gray-700/40">
|
<div className="border-t border-gray-100 px-3 pb-2.5 pt-0.5 dark:border-gray-700/40">
|
||||||
<div className="ml-6.5 space-y-1">
|
<div className="ml-6.5 space-y-1">
|
||||||
{q.options.map((opt) => {
|
{options.map((opt) => {
|
||||||
const wasSelected = answerLabels.includes(opt.label);
|
const wasSelected = answerLabels.includes(opt.label);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -148,7 +164,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{answerLabels.filter(lbl => !q.options.some(o => o.label === lbl)).map(lbl => (
|
{answerLabels.filter(lbl => !options.some(o => o.label === lbl)).map(lbl => (
|
||||||
<div
|
<div
|
||||||
key={lbl}
|
key={lbl}
|
||||||
className="flex items-start gap-2 rounded-lg border border-blue-200/60 bg-blue-50/80 px-2.5 py-1.5 text-[12px] dark:border-blue-800/40 dark:bg-blue-900/20"
|
className="flex items-start gap-2 rounded-lg border border-blue-200/60 bg-blue-50/80 px-2.5 py-1.5 text-[12px] dark:border-blue-800/40 dark:bg-blue-900/20"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export { CollapsibleSection } from './CollapsibleSection';
|
export { CollapsibleSection } from './CollapsibleSection';
|
||||||
export { ToolDiffViewer } from './ToolDiffViewer';
|
export { ToolDiffViewer } from './ToolDiffViewer';
|
||||||
export { OneLineDisplay } from './OneLineDisplay';
|
export { OneLineDisplay } from './OneLineDisplay';
|
||||||
|
export { BashCommandDisplay } from './BashCommandDisplay';
|
||||||
export { CollapsibleDisplay } from './CollapsibleDisplay';
|
export { CollapsibleDisplay } from './CollapsibleDisplay';
|
||||||
export { SubagentContainer } from './SubagentContainer';
|
export { SubagentContainer } from './SubagentContainer';
|
||||||
export * from './ContentRenderers';
|
export * from './ContentRenderers';
|
||||||
|
|||||||
62
src/components/chat/utils/toolGrouping.ts
Normal file
62
src/components/chat/utils/toolGrouping.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import type { ChatMessage } from '../types/types';
|
||||||
|
|
||||||
|
export const TOOL_GROUP_THRESHOLD = 3;
|
||||||
|
|
||||||
|
export interface ToolGroupItem {
|
||||||
|
_isGroup: true;
|
||||||
|
toolName: string;
|
||||||
|
messages: ChatMessage[];
|
||||||
|
timestamp: ChatMessage['timestamp'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MessageListItem = ChatMessage | ToolGroupItem;
|
||||||
|
|
||||||
|
export function isToolGroupItem(item: MessageListItem): item is ToolGroupItem {
|
||||||
|
return '_isGroup' in item && (item as ToolGroupItem)._isGroup === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGroupableToolMessage(message: ChatMessage): message is ChatMessage & { toolName: string } {
|
||||||
|
return Boolean(message.isToolUse && message.toolName && !message.isSubagentContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupConsecutiveTools(messages: ChatMessage[]): MessageListItem[] {
|
||||||
|
const items: MessageListItem[] = [];
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
while (index < messages.length) {
|
||||||
|
const message = messages[index];
|
||||||
|
|
||||||
|
if (!isGroupableToolMessage(message)) {
|
||||||
|
items.push(message);
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const run: ChatMessage[] = [message];
|
||||||
|
let nextIndex = index + 1;
|
||||||
|
|
||||||
|
while (
|
||||||
|
nextIndex < messages.length &&
|
||||||
|
isGroupableToolMessage(messages[nextIndex]) &&
|
||||||
|
messages[nextIndex].toolName === message.toolName
|
||||||
|
) {
|
||||||
|
run.push(messages[nextIndex]);
|
||||||
|
nextIndex += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (run.length >= TOOL_GROUP_THRESHOLD) {
|
||||||
|
items.push({
|
||||||
|
_isGroup: true,
|
||||||
|
toolName: message.toolName,
|
||||||
|
messages: run,
|
||||||
|
timestamp: message.timestamp,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
items.push(...run);
|
||||||
|
}
|
||||||
|
|
||||||
|
index = nextIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
@@ -173,6 +173,7 @@ function ChatInterface({
|
|||||||
isDragActive,
|
isDragActive,
|
||||||
openImagePicker,
|
openImagePicker,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
handleVoiceTranscript,
|
||||||
handleInputChange,
|
handleInputChange,
|
||||||
handleKeyDown,
|
handleKeyDown,
|
||||||
handlePaste,
|
handlePaste,
|
||||||
@@ -310,7 +311,7 @@ function ChatInterface({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PermissionContext.Provider value={permissionContextValue}>
|
<PermissionContext.Provider value={permissionContextValue}>
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
<ChatMessagesPane
|
<ChatMessagesPane
|
||||||
scrollContainerRef={scrollContainerRef}
|
scrollContainerRef={scrollContainerRef}
|
||||||
onWheel={handleScroll}
|
onWheel={handleScroll}
|
||||||
@@ -406,6 +407,7 @@ function ChatInterface({
|
|||||||
renderInputWithMentions={renderInputWithMentions}
|
renderInputWithMentions={renderInputWithMentions}
|
||||||
textareaRef={textareaRef}
|
textareaRef={textareaRef}
|
||||||
input={input}
|
input={input}
|
||||||
|
onVoiceTranscript={handleVoiceTranscript}
|
||||||
onInputChange={handleInputChange}
|
onInputChange={handleInputChange}
|
||||||
onTextareaClick={handleTextareaClick}
|
onTextareaClick={handleTextareaClick}
|
||||||
onTextareaKeyDown={handleKeyDown}
|
onTextareaKeyDown={handleKeyDown}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import type {
|
import type {
|
||||||
ChangeEvent,
|
ChangeEvent,
|
||||||
ClipboardEvent,
|
ClipboardEvent,
|
||||||
@@ -9,8 +11,10 @@ import type {
|
|||||||
RefObject,
|
RefObject,
|
||||||
TouchEvent,
|
TouchEvent,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon } from 'lucide-react';
|
import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useVoiceInput } from '../../hooks/useVoiceInput';
|
||||||
|
import { useVoiceAvailable } from '../../hooks/useVoiceAvailable';
|
||||||
import type { SessionActivity } from '../../../../hooks/useSessionProtection';
|
import type { SessionActivity } from '../../../../hooks/useSessionProtection';
|
||||||
import type { PendingPermissionRequest, PermissionMode } from '../../types/types';
|
import type { PendingPermissionRequest, PermissionMode } from '../../types/types';
|
||||||
import {
|
import {
|
||||||
@@ -27,6 +31,7 @@ import {
|
|||||||
import CommandMenu from './CommandMenu';
|
import CommandMenu from './CommandMenu';
|
||||||
import ActivityIndicator from './ActivityIndicator';
|
import ActivityIndicator from './ActivityIndicator';
|
||||||
import ImageAttachment from './ImageAttachment';
|
import ImageAttachment from './ImageAttachment';
|
||||||
|
import VoiceInputButton from './VoiceInputButton';
|
||||||
import PermissionRequestsBanner from './PermissionRequestsBanner';
|
import PermissionRequestsBanner from './PermissionRequestsBanner';
|
||||||
import TokenUsageSummary from './TokenUsageSummary';
|
import TokenUsageSummary from './TokenUsageSummary';
|
||||||
|
|
||||||
@@ -89,6 +94,7 @@ interface ChatComposerProps {
|
|||||||
renderInputWithMentions: (text: string) => ReactNode;
|
renderInputWithMentions: (text: string) => ReactNode;
|
||||||
textareaRef: RefObject<HTMLTextAreaElement>;
|
textareaRef: RefObject<HTMLTextAreaElement>;
|
||||||
input: string;
|
input: string;
|
||||||
|
onVoiceTranscript?: (text: string, send?: boolean) => void;
|
||||||
onInputChange: (event: ChangeEvent<HTMLTextAreaElement>) => void;
|
onInputChange: (event: ChangeEvent<HTMLTextAreaElement>) => void;
|
||||||
onTextareaClick: (event: MouseEvent<HTMLTextAreaElement>) => void;
|
onTextareaClick: (event: MouseEvent<HTMLTextAreaElement>) => void;
|
||||||
onTextareaKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
|
onTextareaKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||||
@@ -142,6 +148,7 @@ export default function ChatComposer({
|
|||||||
renderInputWithMentions,
|
renderInputWithMentions,
|
||||||
textareaRef,
|
textareaRef,
|
||||||
input,
|
input,
|
||||||
|
onVoiceTranscript,
|
||||||
onInputChange,
|
onInputChange,
|
||||||
onTextareaClick,
|
onTextareaClick,
|
||||||
onTextareaKeyDown,
|
onTextareaKeyDown,
|
||||||
@@ -154,12 +161,38 @@ export default function ChatComposer({
|
|||||||
sendByCtrlEnter,
|
sendByCtrlEnter,
|
||||||
}: ChatComposerProps) {
|
}: ChatComposerProps) {
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
const textareaRect = textareaRef.current?.getBoundingClientRect();
|
const commandMenuPosition = useMemo(() => {
|
||||||
const commandMenuPosition = {
|
if (!isCommandMenuOpen) {
|
||||||
top: textareaRect ? Math.max(16, textareaRect.top - 316) : 0,
|
return { top: 0, left: 16, bottom: 90 };
|
||||||
left: textareaRect ? textareaRect.left : 16,
|
}
|
||||||
bottom: textareaRect ? window.innerHeight - textareaRect.top + 8 : 90,
|
const textareaRect = textareaRef.current?.getBoundingClientRect();
|
||||||
};
|
return {
|
||||||
|
top: textareaRect ? Math.max(16, textareaRect.top - 316) : 0,
|
||||||
|
left: textareaRect ? textareaRect.left : 16,
|
||||||
|
bottom: textareaRect ? window.innerHeight - textareaRect.top + 8 : 90,
|
||||||
|
};
|
||||||
|
}, [input, isCommandMenuOpen, textareaRef]);
|
||||||
|
|
||||||
|
// Voice state is hosted here (not in the mic button) so the main Send button can stop
|
||||||
|
// recording and send the transcript in one tap, the way the mic button drops it in the box.
|
||||||
|
const voiceAvailable = useVoiceAvailable();
|
||||||
|
const [voiceError, setVoiceError] = useState<string | null>(null);
|
||||||
|
const voiceErrorTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const handleVoiceError = useCallback((msg: string) => {
|
||||||
|
setVoiceError(msg);
|
||||||
|
if (voiceErrorTimer.current) clearTimeout(voiceErrorTimer.current);
|
||||||
|
voiceErrorTimer.current = setTimeout(() => setVoiceError(null), 4000);
|
||||||
|
}, []);
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (voiceErrorTimer.current) clearTimeout(voiceErrorTimer.current);
|
||||||
|
}, []);
|
||||||
|
const noopTranscript = useCallback(() => {}, []);
|
||||||
|
const { state: voiceState, toggle: voiceToggle, stop: voiceStop } = useVoiceInput(
|
||||||
|
onVoiceTranscript ?? noopTranscript,
|
||||||
|
handleVoiceError,
|
||||||
|
);
|
||||||
|
const isRecording = voiceState === 'recording';
|
||||||
|
const isTranscribing = voiceState === 'transcribing';
|
||||||
|
|
||||||
// Detect if the AskUserQuestion interactive panel is active
|
// Detect if the AskUserQuestion interactive panel is active
|
||||||
const hasQuestionPanel = pendingPermissionRequests.some(
|
const hasQuestionPanel = pendingPermissionRequests.some(
|
||||||
@@ -170,7 +203,7 @@ export default function ChatComposer({
|
|||||||
const hasPendingPermissions = pendingPermissionRequests.length > 0;
|
const hasPendingPermissions = pendingPermissionRequests.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6">
|
<div className="chat-composer-shell flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6">
|
||||||
{!hasPendingPermissions && (
|
{!hasPendingPermissions && (
|
||||||
<ActivityIndicator activity={activity} onAbort={onAbortSession} />
|
<ActivityIndicator activity={activity} onAbort={onAbortSession} />
|
||||||
)}
|
)}
|
||||||
@@ -309,6 +342,10 @@ export default function ChatComposer({
|
|||||||
<ImageIcon />
|
<ImageIcon />
|
||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
|
|
||||||
|
{onVoiceTranscript && voiceAvailable && (
|
||||||
|
<VoiceInputButton state={voiceState} onToggle={voiceToggle} errorMsg={voiceError} />
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onModeSwitch}
|
onClick={onModeSwitch}
|
||||||
@@ -387,10 +424,21 @@ export default function ChatComposer({
|
|||||||
{sendByCtrlEnter ? t('input.hintText.ctrlEnter') : t('input.hintText.enter')}
|
{sendByCtrlEnter ? t('input.hintText.ctrlEnter') : t('input.hintText.enter')}
|
||||||
</div>
|
</div>
|
||||||
<PromptInputSubmit
|
<PromptInputSubmit
|
||||||
onClick={isLoading ? onAbortSession : undefined}
|
onClick={
|
||||||
disabled={!isLoading && !input.trim()}
|
isLoading
|
||||||
|
? onAbortSession
|
||||||
|
: isRecording
|
||||||
|
? (e: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
voiceStop({ send: true });
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
disabled={isLoading ? false : isRecording ? false : isTranscribing ? true : !input.trim()}
|
||||||
className="h-10 w-10 sm:h-10 sm:w-10"
|
className="h-10 w-10 sm:h-10 sm:w-10"
|
||||||
/>
|
>
|
||||||
|
{isTranscribing ? <Loader2 className="h-4 w-4 animate-spin" /> : undefined}
|
||||||
|
</PromptInputSubmit>
|
||||||
</div>
|
</div>
|
||||||
</PromptInputFooter>
|
</PromptInputFooter>
|
||||||
</PromptInput>
|
</PromptInput>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useCallback, useRef } from 'react';
|
import { memo, useCallback, useMemo, useRef } from 'react';
|
||||||
import type { Dispatch, RefObject, SetStateAction } from 'react';
|
import type { Dispatch, RefObject, SetStateAction } from 'react';
|
||||||
|
|
||||||
import type { ChatMessage } from '../../types/types';
|
import type { ChatMessage } from '../../types/types';
|
||||||
@@ -10,9 +10,11 @@ import type {
|
|||||||
ProviderModelsDefinition,
|
ProviderModelsDefinition,
|
||||||
} from '../../../../types/app';
|
} from '../../../../types/app';
|
||||||
import { getIntrinsicMessageKey } from '../../utils/messageKeys';
|
import { getIntrinsicMessageKey } from '../../utils/messageKeys';
|
||||||
|
import { groupConsecutiveTools, isToolGroupItem } from '../../utils/toolGrouping';
|
||||||
|
|
||||||
import MessageComponent from './MessageComponent';
|
import MessageComponent from './MessageComponent';
|
||||||
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
|
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
|
||||||
|
import ToolGroupContainer from './ToolGroupContainer';
|
||||||
|
|
||||||
interface ChatMessagesPaneProps {
|
interface ChatMessagesPaneProps {
|
||||||
scrollContainerRef: RefObject<HTMLDivElement>;
|
scrollContainerRef: RefObject<HTMLDivElement>;
|
||||||
@@ -65,7 +67,7 @@ interface ChatMessagesPaneProps {
|
|||||||
selectedProject: Project;
|
selectedProject: Project;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ChatMessagesPane({
|
function ChatMessagesPane({
|
||||||
scrollContainerRef,
|
scrollContainerRef,
|
||||||
onWheel,
|
onWheel,
|
||||||
onTouchMove,
|
onTouchMove,
|
||||||
@@ -118,6 +120,7 @@ export default function ChatMessagesPane({
|
|||||||
const messageKeyMapRef = useRef<WeakMap<ChatMessage, string>>(new WeakMap());
|
const messageKeyMapRef = useRef<WeakMap<ChatMessage, string>>(new WeakMap());
|
||||||
const allocatedKeysRef = useRef<Set<string>>(new Set());
|
const allocatedKeysRef = useRef<Set<string>>(new Set());
|
||||||
const generatedMessageKeyCounterRef = useRef(0);
|
const generatedMessageKeyCounterRef = useRef(0);
|
||||||
|
const groupedVisibleMessages = useMemo(() => groupConsecutiveTools(visibleMessages), [visibleMessages]);
|
||||||
|
|
||||||
// Keep keys stable across prepends so existing MessageComponent instances retain local state.
|
// Keep keys stable across prepends so existing MessageComponent instances retain local state.
|
||||||
const getMessageKey = useCallback((message: ChatMessage) => {
|
const getMessageKey = useCallback((message: ChatMessage) => {
|
||||||
@@ -148,7 +151,7 @@ export default function ChatMessagesPane({
|
|||||||
ref={scrollContainerRef}
|
ref={scrollContainerRef}
|
||||||
onWheel={onWheel}
|
onWheel={onWheel}
|
||||||
onTouchMove={onTouchMove}
|
onTouchMove={onTouchMove}
|
||||||
className="relative flex-1 space-y-3 overflow-y-auto overflow-x-hidden px-0 py-3 sm:space-y-4 sm:p-4"
|
className="chat-messages-pane relative min-h-0 flex-1 space-y-3 overflow-y-auto overflow-x-hidden px-0 py-3 sm:space-y-4 sm:p-4"
|
||||||
>
|
>
|
||||||
{(isLoadingSessionMessages || isProcessing) && chatMessages.length === 0 ? (
|
{(isLoadingSessionMessages || isProcessing) && chatMessages.length === 0 ? (
|
||||||
<div className="mt-8 text-center text-gray-500 dark:text-gray-400">
|
<div className="mt-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
@@ -252,28 +255,58 @@ export default function ChatMessagesPane({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{visibleMessages.map((message, index) => {
|
{(() => {
|
||||||
const prevMessage = index > 0 ? visibleMessages[index - 1] : null;
|
let prevMessage: ChatMessage | null = null;
|
||||||
return (
|
|
||||||
<MessageComponent
|
return groupedVisibleMessages.map((item) => {
|
||||||
key={getMessageKey(message)}
|
if (isToolGroupItem(item)) {
|
||||||
message={message}
|
const groupPrevMessage = prevMessage;
|
||||||
prevMessage={prevMessage}
|
prevMessage = item.messages[item.messages.length - 1] || prevMessage;
|
||||||
createDiff={createDiff}
|
|
||||||
onFileOpen={onFileOpen}
|
return (
|
||||||
onShowSettings={onShowSettings}
|
<ToolGroupContainer
|
||||||
onGrantToolPermission={onGrantToolPermission}
|
key={`tool-group-${getMessageKey(item.messages[0])}`}
|
||||||
autoExpandTools={autoExpandTools}
|
group={item}
|
||||||
showRawParameters={showRawParameters}
|
prevMessage={groupPrevMessage}
|
||||||
showThinking={showThinking}
|
createDiff={createDiff}
|
||||||
selectedProject={selectedProject}
|
getMessageKey={getMessageKey}
|
||||||
provider={provider}
|
onFileOpen={onFileOpen}
|
||||||
/>
|
onShowSettings={onShowSettings}
|
||||||
);
|
onGrantToolPermission={onGrantToolPermission}
|
||||||
})}
|
autoExpandTools={autoExpandTools}
|
||||||
|
showRawParameters={showRawParameters}
|
||||||
|
showThinking={showThinking}
|
||||||
|
selectedProject={selectedProject}
|
||||||
|
provider={provider}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const messagePrevMessage = prevMessage;
|
||||||
|
prevMessage = item;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MessageComponent
|
||||||
|
key={getMessageKey(item)}
|
||||||
|
message={item}
|
||||||
|
prevMessage={messagePrevMessage}
|
||||||
|
createDiff={createDiff}
|
||||||
|
onFileOpen={onFileOpen}
|
||||||
|
onShowSettings={onShowSettings}
|
||||||
|
onGrantToolPermission={onGrantToolPermission}
|
||||||
|
autoExpandTools={autoExpandTools}
|
||||||
|
showRawParameters={showRawParameters}
|
||||||
|
showThinking={showThinking}
|
||||||
|
selectedProject={selectedProject}
|
||||||
|
provider={provider}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default memo(ChatMessagesPane);
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ import { useMemo, useState } from 'react';
|
|||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
Check,
|
|
||||||
CircleHelp,
|
CircleHelp,
|
||||||
Clipboard,
|
|
||||||
Coins,
|
Coins,
|
||||||
Cpu,
|
Cpu,
|
||||||
Gauge,
|
Gauge,
|
||||||
@@ -59,19 +57,6 @@ type ModelOption = {
|
|||||||
description?: string;
|
description?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatUpdatedAt = (value?: string) => {
|
|
||||||
if (!value) {
|
|
||||||
return 'Not cached yet';
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = new Date(value);
|
|
||||||
if (Number.isNaN(parsed.getTime())) {
|
|
||||||
return 'Not cached yet';
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsed.toLocaleString();
|
|
||||||
};
|
|
||||||
|
|
||||||
const PROVIDER_LABELS: Record<string, string> = {
|
const PROVIDER_LABELS: Record<string, string> = {
|
||||||
claude: 'Claude',
|
claude: 'Claude',
|
||||||
cursor: 'Cursor',
|
cursor: 'Cursor',
|
||||||
@@ -246,7 +231,6 @@ function HelpContent({ data }: { data: HelpCommandData }) {
|
|||||||
function ModelsContent({
|
function ModelsContent({
|
||||||
data,
|
data,
|
||||||
providerModelCatalog,
|
providerModelCatalog,
|
||||||
providerModelCacheCatalog,
|
|
||||||
providerModelsRefreshing,
|
providerModelsRefreshing,
|
||||||
onHardRefreshProviderModels,
|
onHardRefreshProviderModels,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
@@ -254,14 +238,12 @@ function ModelsContent({
|
|||||||
}: {
|
}: {
|
||||||
data: ModelCommandData;
|
data: ModelCommandData;
|
||||||
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
|
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
|
||||||
providerModelCacheCatalog: Partial<Record<LLMProvider, ProviderModelsCacheInfo>>;
|
|
||||||
providerModelsRefreshing: boolean;
|
providerModelsRefreshing: boolean;
|
||||||
onHardRefreshProviderModels: () => void;
|
onHardRefreshProviderModels: () => void;
|
||||||
currentSessionId: string | null;
|
currentSessionId: string | null;
|
||||||
onSelectProviderModel: CommandResultModalProps['onSelectProviderModel'];
|
onSelectProviderModel: CommandResultModalProps['onSelectProviderModel'];
|
||||||
}) {
|
}) {
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [copiedModel, setCopiedModel] = useState<string | null>(null);
|
|
||||||
const [changingModel, setChangingModel] = useState<string | null>(null);
|
const [changingModel, setChangingModel] = useState<string | null>(null);
|
||||||
const [pendingSessionModel, setPendingSessionModel] = useState<string | null>(null);
|
const [pendingSessionModel, setPendingSessionModel] = useState<string | null>(null);
|
||||||
const [selectionNotice, setSelectionNotice] = useState<string | null>(null);
|
const [selectionNotice, setSelectionNotice] = useState<string | null>(null);
|
||||||
@@ -269,7 +251,6 @@ function ModelsContent({
|
|||||||
const currentModel = data?.current?.model || 'Unknown';
|
const currentModel = data?.current?.model || 'Unknown';
|
||||||
const providerLabel = data?.current?.providerLabel || getProviderLabel(currentProvider);
|
const providerLabel = data?.current?.providerLabel || getProviderLabel(currentProvider);
|
||||||
const liveDefinition = providerModelCatalog[currentProvider];
|
const liveDefinition = providerModelCatalog[currentProvider];
|
||||||
const currentCache = providerModelCacheCatalog[currentProvider] ?? data?.cache;
|
|
||||||
const availableOptions = useMemo<ModelOption[]>(() => {
|
const availableOptions = useMemo<ModelOption[]>(() => {
|
||||||
if (liveDefinition?.OPTIONS && liveDefinition.OPTIONS.length > 0) {
|
if (liveDefinition?.OPTIONS && liveDefinition.OPTIONS.length > 0) {
|
||||||
return liveDefinition.OPTIONS;
|
return liveDefinition.OPTIONS;
|
||||||
@@ -282,7 +263,6 @@ function ModelsContent({
|
|||||||
const availableModels = Array.isArray(data?.availableModels) ? data.availableModels : [];
|
const availableModels = Array.isArray(data?.availableModels) ? data.availableModels : [];
|
||||||
return availableModels.map((model) => ({ value: model, label: model }));
|
return availableModels.map((model) => ({ value: model, label: model }));
|
||||||
}, [data, liveDefinition]);
|
}, [data, liveDefinition]);
|
||||||
const defaultModel = liveDefinition?.DEFAULT || data?.defaultModel || currentModel;
|
|
||||||
|
|
||||||
const filteredOptions = useMemo(() => {
|
const filteredOptions = useMemo(() => {
|
||||||
const normalized = query.trim().toLowerCase();
|
const normalized = query.trim().toLowerCase();
|
||||||
@@ -296,18 +276,8 @@ function ModelsContent({
|
|||||||
});
|
});
|
||||||
}, [availableOptions, query]);
|
}, [availableOptions, query]);
|
||||||
|
|
||||||
const activeOption = availableOptions.find((option) => option.value === currentModel);
|
|
||||||
const hasConcreteSessionId = typeof currentSessionId === 'string' && currentSessionId.trim().length > 0;
|
const hasConcreteSessionId = typeof currentSessionId === 'string' && currentSessionId.trim().length > 0;
|
||||||
|
const showSearch = availableOptions.length > 6;
|
||||||
const copyModel = (model: string) => {
|
|
||||||
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
|
||||||
void navigator.clipboard.writeText(model).catch(() => undefined);
|
|
||||||
}
|
|
||||||
setCopiedModel(model);
|
|
||||||
window.setTimeout(() => {
|
|
||||||
setCopiedModel((current) => (current === model ? null : current));
|
|
||||||
}, 1300);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectModel = async (model: string) => {
|
const handleSelectModel = async (model: string) => {
|
||||||
setChangingModel(model);
|
setChangingModel(model);
|
||||||
@@ -330,162 +300,106 @@ function ModelsContent({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col gap-2.5">
|
<div className="flex h-full min-h-0 flex-col gap-3">
|
||||||
<div className="rounded-2xl border border-border/70 bg-muted/20 p-2.5">
|
{/* Compact context bar: active model + refresh, no clutter */}
|
||||||
<div className="grid gap-2.5 lg:grid-cols-[minmax(0,1.55fr)_minmax(12rem,0.7fr)_minmax(15rem,0.9fr)] lg:items-start">
|
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border/70 bg-muted/20 px-3.5 py-2.5">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
<Badge variant="secondary" className="rounded-lg border border-primary/20 bg-primary/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-primary">
|
Active model · {providerLabel}
|
||||||
{providerLabel}
|
</p>
|
||||||
</Badge>
|
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
||||||
<Badge variant="secondary" className="rounded-lg px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-foreground">
|
<span className="break-all font-mono text-sm font-semibold text-foreground">{currentModel}</span>
|
||||||
{availableOptions.length} models
|
{pendingSessionModel && pendingSessionModel !== currentModel && (
|
||||||
</Badge>
|
<span className="text-[11px] font-semibold uppercase tracking-[0.14em] text-emerald-500 dark:text-emerald-400">
|
||||||
</div>
|
→ {pendingSessionModel} next
|
||||||
|
</span>
|
||||||
<div className="mt-2 rounded-xl border border-primary/15 bg-primary/[0.06] px-3 py-2">
|
)}
|
||||||
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-primary">Active Model</p>
|
</p>
|
||||||
<p className="mt-1 break-all font-mono text-[0.98rem] font-semibold leading-5 text-foreground sm:text-[1.05rem]">
|
|
||||||
{currentModel}
|
|
||||||
</p>
|
|
||||||
{activeOption?.label && activeOption.label !== currentModel && (
|
|
||||||
<p className="mt-1 text-[11px] font-medium text-foreground/85">{activeOption.label}</p>
|
|
||||||
)}
|
|
||||||
{activeOption?.description && (
|
|
||||||
<p className="mt-0.5 line-clamp-1 text-[11px] text-muted-foreground">{activeOption.description}</p>
|
|
||||||
)}
|
|
||||||
{pendingSessionModel && pendingSessionModel !== currentModel && (
|
|
||||||
<p className="mt-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-primary">
|
|
||||||
Next response: {pendingSessionModel}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-1">
|
|
||||||
<div className="rounded-xl border border-border/60 bg-background/55 px-2.5 py-1.5">
|
|
||||||
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-foreground/80">Default</p>
|
|
||||||
<p className="mt-1 break-all font-mono text-[11px] font-medium text-foreground">{defaultModel}</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl border border-border/60 bg-background/55 px-2.5 py-1.5">
|
|
||||||
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-foreground/80">Updated</p>
|
|
||||||
<p className="mt-1 text-[11px] font-medium text-foreground">{formatUpdatedAt(currentCache?.updatedAt)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-xl border border-border/60 bg-background/55 p-2.5">
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
|
||||||
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-foreground/80">
|
|
||||||
Catalog Refresh
|
|
||||||
</p>
|
|
||||||
<Badge variant="secondary" className="rounded-md px-1.5 py-0 text-[9px] uppercase tracking-[0.14em]">
|
|
||||||
All providers
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1.5 text-[11px] leading-4 text-muted-foreground">
|
|
||||||
Model lists are cached for 3 days. Refresh after CLI, auth, or config changes,
|
|
||||||
or when a new model is missing.
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={onHardRefreshProviderModels}
|
|
||||||
disabled={providerModelsRefreshing}
|
|
||||||
className="mt-2 h-8 w-full rounded-xl px-3"
|
|
||||||
>
|
|
||||||
<RefreshCw className={providerModelsRefreshing ? 'animate-spin' : ''} />
|
|
||||||
{providerModelsRefreshing ? 'Refreshing catalogs...' : 'Refresh from providers'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-2 border-t border-border/50 pt-1.5 text-[11px] text-muted-foreground">
|
|
||||||
{hasConcreteSessionId
|
|
||||||
? 'Selecting a model stores a session override and applies it on the next response for this session.'
|
|
||||||
: 'Selecting a model updates the default model used for new turns in this provider.'}
|
|
||||||
{selectionNotice && <span className="ml-2 text-foreground">{selectionNotice}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onHardRefreshProviderModels}
|
||||||
|
disabled={providerModelsRefreshing}
|
||||||
|
title="Refresh model list from providers"
|
||||||
|
aria-label="Refresh model list from providers"
|
||||||
|
className="h-9 w-9 shrink-0 rounded-xl text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${providerModelsRefreshing ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col rounded-3xl border border-border/70 bg-muted/15 p-3 sm:p-4">
|
{showSearch && (
|
||||||
<div className="mb-2.5 grid gap-2 sm:grid-cols-[1fr_auto] sm:items-center">
|
<SearchField value={query} onChange={setQuery} placeholder={`Search ${providerLabel} models...`} />
|
||||||
<div className="min-w-0">
|
)}
|
||||||
<SearchField value={query} onChange={setQuery} placeholder={`Search ${providerLabel} models...`} />
|
|
||||||
</div>
|
|
||||||
<Badge variant="secondary" className="h-9 justify-center rounded-xl px-3 font-mono text-xs">
|
|
||||||
{filteredOptions.length} shown
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{filteredOptions.length > 0 ? (
|
{filteredOptions.length > 0 ? (
|
||||||
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto pr-1">
|
<div className="scrollbar-thin -mr-1 min-h-0 flex-1 overflow-y-auto pr-1">
|
||||||
<div className="grid gap-2 md:grid-cols-2">
|
<div className="grid gap-2 md:grid-cols-2">
|
||||||
{filteredOptions.map((option, index) => {
|
{filteredOptions.map((option, index) => {
|
||||||
const isCurrent = option.value === currentModel;
|
const isCurrent = option.value === currentModel;
|
||||||
const wasCopied = copiedModel === option.value;
|
const isPendingSelection = option.value === pendingSessionModel;
|
||||||
const isPendingSelection = option.value === pendingSessionModel;
|
const isChanging = option.value === changingModel;
|
||||||
const isChanging = option.value === changingModel;
|
return (
|
||||||
return (
|
<button
|
||||||
<div
|
key={option.value}
|
||||||
key={option.value}
|
type="button"
|
||||||
className={`settings-content-enter group flex min-h-[4.5rem] items-start gap-3 rounded-2xl border p-3 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md ${
|
onClick={() => handleSelectModel(option.value)}
|
||||||
isCurrent
|
disabled={Boolean(changingModel)}
|
||||||
? 'border-primary/45 bg-primary/10'
|
aria-label={`Select model ${option.value}`}
|
||||||
: isPendingSelection
|
className={`settings-content-enter group flex min-h-[4rem] flex-col rounded-2xl border p-3 text-left shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:opacity-60 ${
|
||||||
? 'border-emerald-500/35 bg-emerald-500/10'
|
isCurrent
|
||||||
: 'border-border/70 bg-background/80 hover:border-primary/30 hover:bg-background'
|
? 'border-primary/45 bg-primary/10'
|
||||||
}`}
|
: isPendingSelection
|
||||||
style={{ animationDelay: `${Math.min(index * 14, 180)}ms` }}
|
? 'border-emerald-500/35 bg-emerald-500/10'
|
||||||
>
|
: 'border-border/70 bg-background/80 hover:border-primary/30 hover:bg-background'
|
||||||
<button
|
}`}
|
||||||
type="button"
|
style={{ animationDelay: `${Math.min(index * 14, 180)}ms` }}
|
||||||
onClick={() => handleSelectModel(option.value)}
|
>
|
||||||
disabled={Boolean(changingModel)}
|
<span className="flex items-center justify-between gap-2">
|
||||||
className="min-w-0 flex-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
<span className="break-all font-mono text-sm font-semibold text-foreground">{option.value}</span>
|
||||||
aria-label={`Use model ${option.value}`}
|
{isCurrent ? (
|
||||||
>
|
<BadgeCheck className="h-4 w-4 shrink-0 text-primary" />
|
||||||
<span className="flex items-center gap-2">
|
) : isChanging ? (
|
||||||
<span className="break-all font-mono text-sm font-semibold text-foreground">{option.value}</span>
|
<RefreshCw className="h-4 w-4 shrink-0 animate-spin text-primary" />
|
||||||
{isCurrent && <BadgeCheck className="h-4 w-4 shrink-0 text-primary" />}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
{option.label && option.label !== option.value && (
|
{option.label && option.label !== option.value && (
|
||||||
<span className="mt-1 block text-xs text-muted-foreground">{option.label}</span>
|
<span className="mt-1 text-xs font-medium text-foreground/85">{option.label}</span>
|
||||||
)}
|
)}
|
||||||
{option.description && (
|
{option.description && (
|
||||||
<span className="mt-1 block text-xs leading-5 text-muted-foreground">{option.description}</span>
|
<span className="mt-1 text-xs leading-5 text-muted-foreground">{option.description}</span>
|
||||||
)}
|
)}
|
||||||
{isCurrent && <span className="mt-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-primary">Current selection</span>}
|
{isCurrent && (
|
||||||
{isPendingSelection && !isCurrent && (
|
<span className="mt-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-primary">Current selection</span>
|
||||||
<span className="mt-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-400">
|
)}
|
||||||
Next response selection
|
{isPendingSelection && !isCurrent && (
|
||||||
</span>
|
<span className="mt-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-500 dark:text-emerald-400">
|
||||||
)}
|
Applies next response
|
||||||
{isChanging && (
|
</span>
|
||||||
<span className="mt-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-primary">
|
)}
|
||||||
Applying...
|
</button>
|
||||||
</span>
|
);
|
||||||
)}
|
})}
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => copyModel(option.value)}
|
|
||||||
className="rounded-lg border border-border/70 bg-muted/30 p-2 text-muted-foreground transition-colors group-hover:text-primary"
|
|
||||||
aria-label={`Copy model id ${option.value}`}
|
|
||||||
>
|
|
||||||
{wasCopied ? <Check className="h-4 w-4" /> : <Clipboard className="h-4 w-4" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-2xl border border-dashed border-border bg-background/60 px-4 py-10 text-center text-sm text-muted-foreground">
|
||||||
|
No models match that search.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Single quiet line of guidance / feedback */}
|
||||||
|
<p className="shrink-0 text-[11px] leading-4 text-muted-foreground">
|
||||||
|
{selectionNotice ? (
|
||||||
|
<span className="text-foreground">{selectionNotice}</span>
|
||||||
|
) : hasConcreteSessionId ? (
|
||||||
|
'Your choice applies to this session on the next response.'
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-2xl border border-dashed border-border bg-background/60 px-4 py-10 text-center text-sm text-muted-foreground">
|
'Your choice becomes the default model for new turns.'
|
||||||
No models match that search.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -606,7 +520,6 @@ export default function CommandResultModal({
|
|||||||
payload,
|
payload,
|
||||||
onClose,
|
onClose,
|
||||||
providerModelCatalog,
|
providerModelCatalog,
|
||||||
providerModelCacheCatalog,
|
|
||||||
providerModelsRefreshing,
|
providerModelsRefreshing,
|
||||||
onHardRefreshProviderModels,
|
onHardRefreshProviderModels,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
@@ -624,9 +537,9 @@ export default function CommandResultModal({
|
|||||||
icon: CircleHelp,
|
icon: CircleHelp,
|
||||||
},
|
},
|
||||||
models: {
|
models: {
|
||||||
eyebrow: 'Model inventory',
|
eyebrow: 'Model selection',
|
||||||
title: 'Available Models',
|
title: 'Choose a Model',
|
||||||
subtitle: 'Browse, search, and copy model IDs for the active provider.',
|
subtitle: 'Pick the model this provider should use.',
|
||||||
icon: Cpu,
|
icon: Cpu,
|
||||||
},
|
},
|
||||||
cost: {
|
cost: {
|
||||||
@@ -700,7 +613,6 @@ export default function CommandResultModal({
|
|||||||
<ModelsContent
|
<ModelsContent
|
||||||
data={payload.data as ModelCommandData}
|
data={payload.data as ModelCommandData}
|
||||||
providerModelCatalog={providerModelCatalog}
|
providerModelCatalog={providerModelCatalog}
|
||||||
providerModelCacheCatalog={providerModelCacheCatalog}
|
|
||||||
providerModelsRefreshing={providerModelsRefreshing}
|
providerModelsRefreshing={providerModelsRefreshing}
|
||||||
onHardRefreshProviderModels={onHardRefreshProviderModels}
|
onHardRefreshProviderModels={onHardRefreshProviderModels}
|
||||||
currentSessionId={currentSessionId}
|
currentSessionId={currentSessionId}
|
||||||
|
|||||||
@@ -8,12 +8,48 @@ import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { normalizeInlineCodeFences } from '../../utils/chatFormatting';
|
import { normalizeInlineCodeFences } from '../../utils/chatFormatting';
|
||||||
import { copyTextToClipboard } from '../../../../utils/clipboard';
|
import { copyTextToClipboard } from '../../../../utils/clipboard';
|
||||||
|
import { usePaletteOps } from '../../../../contexts/PaletteOpsContext';
|
||||||
|
|
||||||
type MarkdownProps = {
|
type MarkdownProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Links to the wider web (or in-page anchors) keep normal browser navigation;
|
||||||
|
// everything else is treated as a workspace file reference.
|
||||||
|
const isExternalHref = (href?: string): boolean =>
|
||||||
|
!!href && (/^(https?:|mailto:|tel:|data:)/i.test(href) || href.startsWith('#'));
|
||||||
|
|
||||||
|
// Strip a trailing `:line` / `:line:col` suffix (e.g. `src/foo.ts:130`).
|
||||||
|
const stripLineSuffix = (value: string): string => value.replace(/:\d+(?::\d+)?$/, '');
|
||||||
|
|
||||||
|
// A usable file path contains a separator or a filename with an extension.
|
||||||
|
const looksLikeFilePath = (value?: string): value is string => {
|
||||||
|
if (!value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const cleaned = stripLineSuffix(value.trim());
|
||||||
|
if (!cleaned || cleaned === '#') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return /[\\/]/.test(cleaned) || /\.[a-z0-9]+$/i.test(cleaned);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract plain text from link children so a reference rendered only as link
|
||||||
|
// text (e.g. `[src/foo.ts]()` with an empty href) can still be opened.
|
||||||
|
const childrenToText = (children: React.ReactNode): string => {
|
||||||
|
if (typeof children === 'string' || typeof children === 'number') {
|
||||||
|
return String(children);
|
||||||
|
}
|
||||||
|
if (Array.isArray(children)) {
|
||||||
|
return children.map(childrenToText).join('');
|
||||||
|
}
|
||||||
|
if (React.isValidElement(children)) {
|
||||||
|
return childrenToText((children.props as { children?: React.ReactNode }).children);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
type CodeBlockProps = {
|
type CodeBlockProps = {
|
||||||
node?: any;
|
node?: any;
|
||||||
inline?: boolean;
|
inline?: boolean;
|
||||||
@@ -123,11 +159,6 @@ const markdownComponents = {
|
|||||||
{children}
|
{children}
|
||||||
</blockquote>
|
</blockquote>
|
||||||
),
|
),
|
||||||
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
|
|
||||||
<a href={href} className="text-blue-600 hover:underline dark:text-blue-400" target="_blank" rel="noopener noreferrer">
|
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
),
|
|
||||||
p: ({ children }: { children?: React.ReactNode }) => <div className="mb-2 last:mb-0">{children}</div>,
|
p: ({ children }: { children?: React.ReactNode }) => <div className="mb-2 last:mb-0">{children}</div>,
|
||||||
table: ({ children }: { children?: React.ReactNode }) => (
|
table: ({ children }: { children?: React.ReactNode }) => (
|
||||||
<div className="my-2 overflow-x-auto">
|
<div className="my-2 overflow-x-auto">
|
||||||
@@ -147,10 +178,50 @@ export function Markdown({ children, className }: MarkdownProps) {
|
|||||||
const content = normalizeInlineCodeFences(String(children ?? ''));
|
const content = normalizeInlineCodeFences(String(children ?? ''));
|
||||||
const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []);
|
const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []);
|
||||||
const rehypePlugins = useMemo(() => [rehypeKatex], []);
|
const rehypePlugins = useMemo(() => [rehypeKatex], []);
|
||||||
|
const { openFileInEditor } = usePaletteOps();
|
||||||
|
|
||||||
|
const components = useMemo(
|
||||||
|
() => ({
|
||||||
|
...markdownComponents,
|
||||||
|
a: ({ href, children: linkChildren }: { href?: string; children?: React.ReactNode }) => {
|
||||||
|
// Prefer the href when it is a real path; otherwise fall back to the
|
||||||
|
// link text, since models often emit `[src/foo.ts]()` with an empty href.
|
||||||
|
const linkText = childrenToText(linkChildren);
|
||||||
|
const fileRef = looksLikeFilePath(href) ? href : looksLikeFilePath(linkText) ? linkText : undefined;
|
||||||
|
|
||||||
|
if (fileRef && !isExternalHref(href)) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href || fileRef}
|
||||||
|
className="cursor-pointer text-blue-600 hover:underline dark:text-blue-400"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
openFileInEditor(stripLineSuffix(fileRef));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{linkChildren}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
className="text-blue-600 hover:underline dark:text-blue-400"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{linkChildren}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[openFileInEditor],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<ReactMarkdown remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} components={markdownComponents as any}>
|
<ReactMarkdown remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} components={components as any}>
|
||||||
{content}
|
{content}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { Reasoning, ReasoningTrigger, ReasoningContent } from '../../../../share
|
|||||||
|
|
||||||
import { Markdown } from './Markdown';
|
import { Markdown } from './Markdown';
|
||||||
import MessageCopyControl from './MessageCopyControl';
|
import MessageCopyControl from './MessageCopyControl';
|
||||||
|
import MessageSpeakControl from './MessageSpeakControl';
|
||||||
|
|
||||||
type DiffLine = {
|
type DiffLine = {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -217,8 +218,8 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tool Result Section */}
|
{/* Tool Result Section — Bash renders its output inside the command row above. */}
|
||||||
{message.toolResult && !shouldHideToolResult(message.toolName || 'UnknownTool', message.toolResult) && (
|
{message.toolResult && message.toolName !== 'Bash' && !shouldHideToolResult(message.toolName || 'UnknownTool', message.toolResult) && (
|
||||||
message.toolResult.isError ? (
|
message.toolResult.isError ? (
|
||||||
// Error results - red error box with content
|
// Error results - red error box with content
|
||||||
<div
|
<div
|
||||||
@@ -415,6 +416,9 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
|||||||
{shouldShowAssistantCopyControl && (
|
{shouldShowAssistantCopyControl && (
|
||||||
<MessageCopyControl content={assistantCopyContent} messageType="assistant" />
|
<MessageCopyControl content={assistantCopyContent} messageType="assistant" />
|
||||||
)}
|
)}
|
||||||
|
{shouldShowAssistantCopyControl && (
|
||||||
|
<MessageSpeakControl content={assistantCopyContent} />
|
||||||
|
)}
|
||||||
{!isGrouped && <span>{formattedTime}</span>}
|
{!isGrouped && <span>{formattedTime}</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Volume2, Loader2, Square } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useTts } from '../../hooks/useTts';
|
||||||
|
import { useVoiceAvailable } from '../../hooks/useVoiceAvailable';
|
||||||
|
|
||||||
|
// Tap-to-speak button beside the copy control on assistant messages.
|
||||||
|
// Renders nothing unless the optional voice feature is enabled.
|
||||||
|
const MessageSpeakControl = ({ content }: { content: string }) => {
|
||||||
|
const { t } = useTranslation('chat');
|
||||||
|
const available = useVoiceAvailable();
|
||||||
|
const { state, toggle, error } = useTts(() => content);
|
||||||
|
|
||||||
|
if (!available) return null;
|
||||||
|
|
||||||
|
const title =
|
||||||
|
state === 'playing' ? t('voice.stopSpeaking') : state === 'loading' ? t('voice.loading') : t('voice.speak');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="relative inline-flex">
|
||||||
|
{error && (
|
||||||
|
<span className="absolute bottom-full left-1/2 z-10 mb-1 max-w-[240px] -translate-x-1/2 whitespace-normal rounded bg-red-600 px-2 py-1 text-center text-xs text-white shadow-lg">
|
||||||
|
{error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggle}
|
||||||
|
title={title}
|
||||||
|
aria-label={title}
|
||||||
|
className="inline-flex items-center gap-1 rounded px-1 py-0.5 text-gray-400 transition-colors hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
{state === 'playing' ? (
|
||||||
|
<Square className="h-3.5 w-3.5" />
|
||||||
|
) : state === 'loading' ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Volume2 className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MessageSpeakControl;
|
||||||
147
src/components/chat/view/subcomponents/ToolGroupContainer.tsx
Normal file
147
src/components/chat/view/subcomponents/ToolGroupContainer.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { ChatMessage, ClaudePermissionSuggestion, PermissionGrantResult, Provider } from '../../types/types';
|
||||||
|
import type { Project } from '../../../../types/app';
|
||||||
|
import type { ToolGroupItem } from '../../utils/toolGrouping';
|
||||||
|
import { getToolConfig } from '../../tools';
|
||||||
|
|
||||||
|
import MessageComponent from './MessageComponent';
|
||||||
|
|
||||||
|
type DiffLine = {
|
||||||
|
type: string;
|
||||||
|
content: string;
|
||||||
|
lineNum: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ToolGroupContainerProps {
|
||||||
|
group: ToolGroupItem;
|
||||||
|
prevMessage: ChatMessage | null;
|
||||||
|
createDiff: (oldStr: string, newStr: string) => DiffLine[];
|
||||||
|
getMessageKey: (message: ChatMessage) => string;
|
||||||
|
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
|
||||||
|
onShowSettings?: () => void;
|
||||||
|
onGrantToolPermission?: (suggestion: ClaudePermissionSuggestion) => PermissionGrantResult | null | undefined;
|
||||||
|
autoExpandTools?: boolean;
|
||||||
|
showRawParameters?: boolean;
|
||||||
|
showThinking?: boolean;
|
||||||
|
selectedProject?: Project | null;
|
||||||
|
provider: Provider | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseToolInput(toolInput: unknown): unknown {
|
||||||
|
if (typeof toolInput !== 'string') {
|
||||||
|
return toolInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(toolInput);
|
||||||
|
} catch {
|
||||||
|
return toolInput;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToolInputPreview(message: ChatMessage): string {
|
||||||
|
const config = getToolConfig(message.toolName || 'UnknownTool').input;
|
||||||
|
const parsedInput = parseToolInput(message.toolInput);
|
||||||
|
const title = typeof config.title === 'function' ? config.title(parsedInput) : config.title;
|
||||||
|
const value = config.getValue?.(parsedInput);
|
||||||
|
|
||||||
|
return String(value || title || message.displayText || message.content || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToolGroupIcon(icon: string | undefined, toolName: string): string {
|
||||||
|
if (icon === 'terminal') {
|
||||||
|
return '$';
|
||||||
|
}
|
||||||
|
|
||||||
|
return icon || toolName.slice(0, 1).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ToolGroupContainer({
|
||||||
|
group,
|
||||||
|
prevMessage,
|
||||||
|
createDiff,
|
||||||
|
getMessageKey,
|
||||||
|
onFileOpen,
|
||||||
|
onShowSettings,
|
||||||
|
onGrantToolPermission,
|
||||||
|
autoExpandTools,
|
||||||
|
showRawParameters,
|
||||||
|
showThinking,
|
||||||
|
selectedProject,
|
||||||
|
provider,
|
||||||
|
}: ToolGroupContainerProps) {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const config = getToolConfig(group.toolName).input;
|
||||||
|
const label = config.label || group.toolName;
|
||||||
|
const borderClass = config.colorScheme?.border || 'border-border';
|
||||||
|
const iconClass = config.colorScheme?.icon || 'text-muted-foreground';
|
||||||
|
const icon = getToolGroupIcon(config.icon, group.toolName);
|
||||||
|
|
||||||
|
const preview = useMemo(() => {
|
||||||
|
const visiblePreviews = group.messages
|
||||||
|
.slice(0, 2)
|
||||||
|
.map(getToolInputPreview)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const extraCount = group.messages.length - visiblePreviews.length;
|
||||||
|
const previewText = visiblePreviews.join(', ');
|
||||||
|
|
||||||
|
if (!previewText) {
|
||||||
|
return extraCount > 0 ? `+${extraCount} more` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return extraCount > 0 ? `${previewText}, +${extraCount} more` : previewText;
|
||||||
|
}, [group.messages]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-message tool px-3 sm:px-0" data-message-timestamp={group.timestamp || undefined}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`group flex w-full items-center gap-2 border-l-2 ${borderClass} rounded-r-md bg-muted/25 px-3 py-2 text-left transition-colors hover:bg-muted/40 dark:bg-muted/10 dark:hover:bg-muted/20`}
|
||||||
|
onClick={() => setIsExpanded((current) => !current)}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
className={`h-3.5 w-3.5 flex-shrink-0 text-muted-foreground transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className={`${iconClass} flex h-5 w-5 flex-shrink-0 items-center justify-center rounded bg-background/80 text-xs font-medium`}>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-shrink-0 text-xs font-medium text-foreground">{label}</span>
|
||||||
|
<span className="flex-shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||||
|
x{group.messages.length}
|
||||||
|
</span>
|
||||||
|
{preview && (
|
||||||
|
<>
|
||||||
|
<span className="text-[10px] text-muted-foreground/40">/</span>
|
||||||
|
<span className="min-w-0 truncate font-mono text-xs text-muted-foreground">{preview}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="mt-2 space-y-3 sm:space-y-4">
|
||||||
|
{group.messages.map((message, index) => (
|
||||||
|
<MessageComponent
|
||||||
|
key={getMessageKey(message)}
|
||||||
|
message={message}
|
||||||
|
prevMessage={index > 0 ? group.messages[index - 1] : prevMessage}
|
||||||
|
createDiff={createDiff}
|
||||||
|
onFileOpen={onFileOpen}
|
||||||
|
onShowSettings={onShowSettings}
|
||||||
|
onGrantToolPermission={onGrantToolPermission}
|
||||||
|
autoExpandTools={autoExpandTools}
|
||||||
|
showRawParameters={showRawParameters}
|
||||||
|
showThinking={showThinking}
|
||||||
|
selectedProject={selectedProject}
|
||||||
|
provider={provider}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
src/components/chat/view/subcomponents/VoiceInputButton.tsx
Normal file
46
src/components/chat/view/subcomponents/VoiceInputButton.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Mic, Square, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { PromptInputButton } from '../../../../shared/view/ui';
|
||||||
|
import type { VoiceInputState } from '../../hooks/useVoiceInput';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
state: VoiceInputState;
|
||||||
|
onToggle: () => void;
|
||||||
|
errorMsg?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Push-to-talk mic button (presentational). Recording state and the stop-and-send action
|
||||||
|
// are owned by the composer so the main Send button can drive them too. This button just
|
||||||
|
// starts recording and, while recording, stops and drops the transcript into the input box.
|
||||||
|
export default function VoiceInputButton({ state, onToggle, errorMsg }: Props) {
|
||||||
|
const { t } = useTranslation('chat');
|
||||||
|
|
||||||
|
const icon =
|
||||||
|
state === 'recording' ? (
|
||||||
|
<Square className="text-red-500" />
|
||||||
|
) : state === 'transcribing' ? (
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Mic />
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="relative inline-flex">
|
||||||
|
{errorMsg && (
|
||||||
|
<span className="absolute bottom-full left-1/2 mb-1 -translate-x-1/2 whitespace-nowrap rounded bg-red-600 px-2 py-1 text-xs text-white shadow-lg">
|
||||||
|
{errorMsg}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<PromptInputButton
|
||||||
|
tooltip={{ content: state === 'recording' ? t('voice.stopRecording') : t('voice.input') }}
|
||||||
|
onClick={(e: { preventDefault: () => void }) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onToggle();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</PromptInputButton>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { useTaskMaster } from '../../../contexts/TaskMasterContext';
|
|||||||
import { usePaletteOpsRegister } from '../../../contexts/PaletteOpsContext';
|
import { usePaletteOpsRegister } from '../../../contexts/PaletteOpsContext';
|
||||||
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
|
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
|
||||||
import { useUiPreferences } from '../../../hooks/useUiPreferences';
|
import { useUiPreferences } from '../../../hooks/useUiPreferences';
|
||||||
|
import { useFileOpenResolver } from '../../../hooks/useFileOpenResolver';
|
||||||
import { authenticatedFetch } from '../../../utils/api';
|
import { authenticatedFetch } from '../../../utils/api';
|
||||||
import { useEditorSidebar } from '../../code-editor/hooks/useEditorSidebar';
|
import { useEditorSidebar } from '../../code-editor/hooks/useEditorSidebar';
|
||||||
import EditorSidebar from '../../code-editor/view/EditorSidebar';
|
import EditorSidebar from '../../code-editor/view/EditorSidebar';
|
||||||
@@ -77,6 +78,10 @@ function MainContent({
|
|||||||
isMobile,
|
isMobile,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Resolves bare/partial file references (e.g. links inside chat messages) to
|
||||||
|
// real project files before opening them in the in-app editor.
|
||||||
|
const resolvedFileOpen = useFileOpenResolver(selectedProject, handleFileOpen);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Identify projects by DB `projectId`; the TaskMaster context uses the
|
// Identify projects by DB `projectId`; the TaskMaster context uses the
|
||||||
// same identifier to key its internal maps.
|
// same identifier to key its internal maps.
|
||||||
@@ -121,6 +126,10 @@ function MainContent({
|
|||||||
setActiveTab('files');
|
setActiveTab('files');
|
||||||
handleFileOpen(filePath);
|
handleFileOpen(filePath);
|
||||||
},
|
},
|
||||||
|
// Opens the editor side panel in place, keeping the current tab (e.g. chat).
|
||||||
|
openFileInEditor: (filePath: string) => {
|
||||||
|
resolvedFileOpen(filePath);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ function getTabTitle(activeTab: AppTab, shouldShowTasksTab: boolean, t: (key: st
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (activeTab === 'browser') {
|
if (activeTab === 'browser') {
|
||||||
return 'Browser';
|
return t('tabs.browser');
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'Project';
|
return 'Project';
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
Languages,
|
Languages,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
|
Mic,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { PreferenceToggleItem } from './types';
|
import type { PreferenceToggleItem } from './types';
|
||||||
|
|
||||||
@@ -54,4 +55,9 @@ export const INPUT_SETTING_TOGGLES: PreferenceToggleItem[] = [
|
|||||||
labelKey: 'quickSettings.sendByCtrlEnter',
|
labelKey: 'quickSettings.sendByCtrlEnter',
|
||||||
icon: Languages,
|
icon: Languages,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'voiceEnabled',
|
||||||
|
labelKey: 'quickSettings.voiceEnabled',
|
||||||
|
icon: Mic,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ export type PreferenceToggleKey =
|
|||||||
| 'showRawParameters'
|
| 'showRawParameters'
|
||||||
| 'showThinking'
|
| 'showThinking'
|
||||||
| 'autoScrollToBottom'
|
| 'autoScrollToBottom'
|
||||||
| 'sendByCtrlEnter';
|
| 'sendByCtrlEnter'
|
||||||
|
| 'voiceEnabled';
|
||||||
|
|
||||||
export type QuickSettingsPreferences = Record<PreferenceToggleKey, boolean>;
|
export type QuickSettingsPreferences = Record<PreferenceToggleKey, boolean>;
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export default function QuickSettingsContent({
|
|||||||
onPreferenceChange,
|
onPreferenceChange,
|
||||||
}: QuickSettingsContentProps) {
|
}: QuickSettingsContentProps) {
|
||||||
const { t } = useTranslation('settings');
|
const { t } = useTranslation('settings');
|
||||||
|
const inputSettingToggles = preferences.voiceEnabled
|
||||||
|
? INPUT_SETTING_TOGGLES
|
||||||
|
: INPUT_SETTING_TOGGLES.filter(({ key }) => key !== 'voiceEnabled');
|
||||||
|
|
||||||
const renderToggleRows = (items: PreferenceToggleItem[]) => (
|
const renderToggleRows = (items: PreferenceToggleItem[]) => (
|
||||||
items.map(({ key, labelKey, icon }) => (
|
items.map(({ key, labelKey, icon }) => (
|
||||||
@@ -67,7 +70,7 @@ export default function QuickSettingsContent({
|
|||||||
</QuickSettingsSection>
|
</QuickSettingsSection>
|
||||||
|
|
||||||
<QuickSettingsSection title={t('quickSettings.sections.inputSettings')}>
|
<QuickSettingsSection title={t('quickSettings.sections.inputSettings')}>
|
||||||
{renderToggleRows(INPUT_SETTING_TOGGLES)}
|
{renderToggleRows(inputSettingToggles)}
|
||||||
<p className="ml-3 text-xs text-gray-500 dark:text-gray-400">
|
<p className="ml-3 text-xs text-gray-500 dark:text-gray-400">
|
||||||
{t('quickSettings.sendByCtrlEnterDescription')}
|
{t('quickSettings.sendByCtrlEnterDescription')}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -27,12 +27,14 @@ export default function QuickSettingsPanelView() {
|
|||||||
showThinking: preferences.showThinking,
|
showThinking: preferences.showThinking,
|
||||||
autoScrollToBottom: preferences.autoScrollToBottom,
|
autoScrollToBottom: preferences.autoScrollToBottom,
|
||||||
sendByCtrlEnter: preferences.sendByCtrlEnter,
|
sendByCtrlEnter: preferences.sendByCtrlEnter,
|
||||||
|
voiceEnabled: preferences.voiceEnabled,
|
||||||
}), [
|
}), [
|
||||||
preferences.autoExpandTools,
|
preferences.autoExpandTools,
|
||||||
preferences.autoScrollToBottom,
|
preferences.autoScrollToBottom,
|
||||||
preferences.sendByCtrlEnter,
|
preferences.sendByCtrlEnter,
|
||||||
preferences.showRawParameters,
|
preferences.showRawParameters,
|
||||||
preferences.showThinking,
|
preferences.showThinking,
|
||||||
|
preferences.voiceEnabled,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handlePreferenceChange = useCallback(
|
const handlePreferenceChange = useCallback(
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ const createDefaultNotificationPreferences = (): NotificationPreferencesState =>
|
|||||||
channels: {
|
channels: {
|
||||||
inApp: true,
|
inApp: true,
|
||||||
webPush: false,
|
webPush: false,
|
||||||
|
desktop: false,
|
||||||
sound: true,
|
sound: true,
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
@@ -127,6 +128,7 @@ const normalizeNotificationPreferences = (
|
|||||||
channels: {
|
channels: {
|
||||||
inApp: preferences?.channels?.inApp ?? defaults.channels.inApp,
|
inApp: preferences?.channels?.inApp ?? defaults.channels.inApp,
|
||||||
webPush: preferences?.channels?.webPush ?? defaults.channels.webPush,
|
webPush: preferences?.channels?.webPush ?? defaults.channels.webPush,
|
||||||
|
desktop: preferences?.channels?.desktop ?? defaults.channels.desktop,
|
||||||
sound: preferences?.channels?.sound ?? defaults.channels.sound,
|
sound: preferences?.channels?.sound ?? defaults.channels.sound,
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { Dispatch, SetStateAction } from 'react';
|
|||||||
import type { LLMProvider } from '../../../types/app';
|
import type { LLMProvider } from '../../../types/app';
|
||||||
import type { ProviderAuthStatus } from '../../provider-auth/types';
|
import type { ProviderAuthStatus } from '../../provider-auth/types';
|
||||||
|
|
||||||
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'browser' | 'notifications' | 'plugins' | 'about';
|
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'voice' | 'tasks' | 'browser' | 'notifications' | 'plugins' | 'about';
|
||||||
export type AgentProvider = LLMProvider;
|
export type AgentProvider = LLMProvider;
|
||||||
export type AgentCategory = 'account' | 'permissions' | 'mcp' | 'skills';
|
export type AgentCategory = 'account' | 'permissions' | 'mcp' | 'skills';
|
||||||
export type ProjectSortOrder = 'name' | 'date';
|
export type ProjectSortOrder = 'name' | 'date';
|
||||||
@@ -30,6 +30,7 @@ export type NotificationPreferencesState = {
|
|||||||
channels: {
|
channels: {
|
||||||
inApp: boolean;
|
inApp: boolean;
|
||||||
webPush: boolean;
|
webPush: boolean;
|
||||||
|
desktop: boolean;
|
||||||
sound: boolean;
|
sound: boolean;
|
||||||
};
|
};
|
||||||
events: {
|
events: {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -7,6 +8,7 @@ import SettingsSidebar from '../view/SettingsSidebar';
|
|||||||
import AgentsSettingsTab from '../view/tabs/agents-settings/AgentsSettingsTab';
|
import AgentsSettingsTab from '../view/tabs/agents-settings/AgentsSettingsTab';
|
||||||
import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab';
|
import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab';
|
||||||
import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab';
|
import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab';
|
||||||
|
import VoiceSettingsTab from '../view/tabs/VoiceSettingsTab';
|
||||||
import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab';
|
import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab';
|
||||||
import BrowserUseSettingsTab from '../view/tabs/browser-use-settings/BrowserUseSettingsTab';
|
import BrowserUseSettingsTab from '../view/tabs/browser-use-settings/BrowserUseSettingsTab';
|
||||||
import NotificationsSettingsTab from '../view/tabs/NotificationsSettingsTab';
|
import NotificationsSettingsTab from '../view/tabs/NotificationsSettingsTab';
|
||||||
@@ -17,8 +19,22 @@ import { useSettingsController } from '../hooks/useSettingsController';
|
|||||||
import { useWebPush } from '../../../hooks/useWebPush';
|
import { useWebPush } from '../../../hooks/useWebPush';
|
||||||
import type { SettingsProps } from '../types/types';
|
import type { SettingsProps } from '../types/types';
|
||||||
|
|
||||||
|
type DesktopNotificationsState = {
|
||||||
|
enabled: boolean;
|
||||||
|
supported: boolean;
|
||||||
|
connectedCount?: number;
|
||||||
|
targetCount?: number;
|
||||||
|
lastError?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: SettingsProps) {
|
function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: SettingsProps) {
|
||||||
const { t } = useTranslation('settings');
|
const { t } = useTranslation('settings');
|
||||||
|
const desktopNotificationsBridge = useMemo(() => (
|
||||||
|
typeof window === 'undefined'
|
||||||
|
? null
|
||||||
|
: ((window as any).cloudcliDesktopNotifications || null)
|
||||||
|
), []);
|
||||||
|
const [desktopNotificationsState, setDesktopNotificationsState] = useState<DesktopNotificationsState | null>(null);
|
||||||
const {
|
const {
|
||||||
activeTab,
|
activeTab,
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
@@ -74,6 +90,45 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!desktopNotificationsBridge) return undefined;
|
||||||
|
let mounted = true;
|
||||||
|
desktopNotificationsBridge.getState().then((state: any) => {
|
||||||
|
if (mounted) {
|
||||||
|
setDesktopNotificationsState(state?.desktopNotifications || null);
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
const unsubscribe = desktopNotificationsBridge.onStateUpdated?.((state: any) => {
|
||||||
|
if (mounted) {
|
||||||
|
setDesktopNotificationsState(state?.desktopNotifications || null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
unsubscribe?.();
|
||||||
|
};
|
||||||
|
}, [desktopNotificationsBridge]);
|
||||||
|
|
||||||
|
const handleEnableDesktopNotifications = async () => {
|
||||||
|
if (!desktopNotificationsBridge) return;
|
||||||
|
const state = await desktopNotificationsBridge.update({ enabled: true });
|
||||||
|
setDesktopNotificationsState(state?.desktopNotifications || null);
|
||||||
|
setNotificationPreferences({
|
||||||
|
...notificationPreferences,
|
||||||
|
channels: { ...notificationPreferences.channels, desktop: true },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisableDesktopNotifications = async () => {
|
||||||
|
if (!desktopNotificationsBridge) return;
|
||||||
|
const state = await desktopNotificationsBridge.update({ enabled: false });
|
||||||
|
setDesktopNotificationsState(state?.desktopNotifications || null);
|
||||||
|
setNotificationPreferences({
|
||||||
|
...notificationPreferences,
|
||||||
|
channels: { ...notificationPreferences.channels, desktop: false },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -152,11 +207,17 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
|||||||
isPushLoading={isPushLoading}
|
isPushLoading={isPushLoading}
|
||||||
onEnablePush={handleEnablePush}
|
onEnablePush={handleEnablePush}
|
||||||
onDisablePush={handleDisablePush}
|
onDisablePush={handleDisablePush}
|
||||||
|
isDesktop={Boolean(desktopNotificationsBridge)}
|
||||||
|
desktopNotifications={desktopNotificationsState}
|
||||||
|
onEnableDesktopNotifications={handleEnableDesktopNotifications}
|
||||||
|
onDisableDesktopNotifications={handleDisableDesktopNotifications}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'api' && <CredentialsSettingsTab />}
|
{activeTab === 'api' && <CredentialsSettingsTab />}
|
||||||
|
|
||||||
|
{activeTab === 'voice' && <VoiceSettingsTab />}
|
||||||
|
|
||||||
{activeTab === 'plugins' && <PluginSettingsTab />}
|
{activeTab === 'plugins' && <PluginSettingsTab />}
|
||||||
|
|
||||||
{activeTab === 'about' && <AboutTab />}
|
{activeTab === 'about' && <AboutTab />}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Bell, Bot, GitBranch, Info, Key, ListChecks, MonitorPlay, Palette, Puzzle } from 'lucide-react';
|
import { Bell, Bot, GitBranch, Info, Key, ListChecks, Mic, MonitorPlay, Palette, Puzzle } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { cn } from '../../../lib/utils';
|
import { cn } from '../../../lib/utils';
|
||||||
import { PillBar, Pill } from '../../../shared/view/ui';
|
import { PillBar, Pill } from '../../../shared/view/ui';
|
||||||
import type { SettingsMainTab } from '../types/types';
|
import type { SettingsMainTab } from '../types/types';
|
||||||
@@ -20,6 +21,7 @@ const NAV_ITEMS: NavItem[] = [
|
|||||||
{ id: 'appearance', labelKey: 'mainTabs.appearance', icon: Palette },
|
{ id: 'appearance', labelKey: 'mainTabs.appearance', icon: Palette },
|
||||||
{ id: 'git', labelKey: 'mainTabs.git', icon: GitBranch },
|
{ id: 'git', labelKey: 'mainTabs.git', icon: GitBranch },
|
||||||
{ id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key },
|
{ id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key },
|
||||||
|
{ id: 'voice', labelKey: 'mainTabs.voice', icon: Mic },
|
||||||
{ id: 'tasks', labelKey: 'mainTabs.tasks', icon: ListChecks },
|
{ id: 'tasks', labelKey: 'mainTabs.tasks', icon: ListChecks },
|
||||||
{ id: 'browser', labelKey: 'mainTabs.browser', icon: MonitorPlay },
|
{ id: 'browser', labelKey: 'mainTabs.browser', icon: MonitorPlay },
|
||||||
{ id: 'plugins', labelKey: 'mainTabs.plugins', icon: Puzzle },
|
{ id: 'plugins', labelKey: 'mainTabs.plugins', icon: Puzzle },
|
||||||
|
|||||||
@@ -13,6 +13,16 @@ type NotificationsSettingsTabProps = {
|
|||||||
isPushLoading: boolean;
|
isPushLoading: boolean;
|
||||||
onEnablePush: () => void;
|
onEnablePush: () => void;
|
||||||
onDisablePush: () => void;
|
onDisablePush: () => void;
|
||||||
|
isDesktop?: boolean;
|
||||||
|
desktopNotifications?: {
|
||||||
|
enabled: boolean;
|
||||||
|
supported: boolean;
|
||||||
|
connectedCount?: number;
|
||||||
|
targetCount?: number;
|
||||||
|
lastError?: string | null;
|
||||||
|
} | null;
|
||||||
|
onEnableDesktopNotifications?: () => void;
|
||||||
|
onDisableDesktopNotifications?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function NotificationsSettingsTab({
|
export default function NotificationsSettingsTab({
|
||||||
@@ -23,6 +33,10 @@ export default function NotificationsSettingsTab({
|
|||||||
isPushLoading,
|
isPushLoading,
|
||||||
onEnablePush,
|
onEnablePush,
|
||||||
onDisablePush,
|
onDisablePush,
|
||||||
|
isDesktop = false,
|
||||||
|
desktopNotifications = null,
|
||||||
|
onEnableDesktopNotifications,
|
||||||
|
onDisableDesktopNotifications,
|
||||||
}: NotificationsSettingsTabProps) {
|
}: NotificationsSettingsTabProps) {
|
||||||
const { t } = useTranslation('settings');
|
const { t } = useTranslation('settings');
|
||||||
|
|
||||||
@@ -33,57 +47,107 @@ export default function NotificationsSettingsTab({
|
|||||||
<div className="space-y-6 md:space-y-8">
|
<div className="space-y-6 md:space-y-8">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Bell className="w-5 h-5 text-blue-600" />
|
<Bell className="h-5 w-5 text-blue-600" />
|
||||||
<h3 className="text-lg font-medium text-foreground">{t('notifications.title')}</h3>
|
<h3 className="text-lg font-medium text-foreground">{t('notifications.title')}</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">{t('notifications.description')}</p>
|
<p className="text-sm text-muted-foreground">{t('notifications.description')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 bg-card border border-border rounded-lg p-4">
|
{isDesktop ? (
|
||||||
<h4 className="font-medium text-foreground">{t('notifications.webPush.title')}</h4>
|
<div className="space-y-4 rounded-lg border border-border bg-card p-4">
|
||||||
{!pushSupported ? (
|
<h4 className="font-medium text-foreground">
|
||||||
<p className="text-sm text-muted-foreground">{t('notifications.webPush.unsupported')}</p>
|
{t('notifications.desktop.title', { defaultValue: 'Notify this desktop app' })}
|
||||||
) : pushDenied ? (
|
</h4>
|
||||||
<p className="text-sm text-muted-foreground">{t('notifications.webPush.denied')}</p>
|
{desktopNotifications?.supported === false ? (
|
||||||
) : (
|
<p className="text-sm text-muted-foreground">
|
||||||
<div className="flex items-center gap-3">
|
{t('notifications.desktop.unsupported', { defaultValue: 'Desktop notifications are not supported on this system.' })}
|
||||||
<button
|
</p>
|
||||||
type="button"
|
) : (
|
||||||
disabled={isPushLoading}
|
<div className="space-y-2">
|
||||||
onClick={() => {
|
<div className="flex items-center gap-3">
|
||||||
if (isPushSubscribed) {
|
<button
|
||||||
onDisablePush();
|
type="button"
|
||||||
} else {
|
onClick={() => {
|
||||||
onEnablePush();
|
if (desktopNotifications?.enabled) {
|
||||||
}
|
onDisableDesktopNotifications?.();
|
||||||
}}
|
} else {
|
||||||
className={`inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
|
onEnableDesktopNotifications?.();
|
||||||
isPushSubscribed
|
}
|
||||||
? 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50'
|
}}
|
||||||
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'
|
className={`inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors ${
|
||||||
}`}
|
desktopNotifications?.enabled
|
||||||
>
|
? 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50'
|
||||||
{isPushLoading ? (
|
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
}`}
|
||||||
) : isPushSubscribed ? (
|
>
|
||||||
<BellOff className="w-4 h-4" />
|
{desktopNotifications?.enabled ? (
|
||||||
) : (
|
<BellOff className="h-4 w-4" />
|
||||||
<BellRing className="w-4 h-4" />
|
) : (
|
||||||
|
<BellRing className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{desktopNotifications?.enabled
|
||||||
|
? t('notifications.desktop.disable', { defaultValue: 'Disable desktop notifications' })
|
||||||
|
: t('notifications.desktop.enable', { defaultValue: 'Enable desktop notifications' })}
|
||||||
|
</button>
|
||||||
|
{desktopNotifications?.enabled && (
|
||||||
|
<span className="text-sm text-green-600 dark:text-green-400">
|
||||||
|
{t('notifications.desktop.enabled', { defaultValue: 'Desktop notifications are enabled' })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{desktopNotifications?.lastError && (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">{desktopNotifications.lastError}</p>
|
||||||
)}
|
)}
|
||||||
{isPushLoading
|
</div>
|
||||||
? t('notifications.webPush.loading')
|
)}
|
||||||
: isPushSubscribed
|
</div>
|
||||||
? t('notifications.webPush.disable')
|
) : (
|
||||||
: t('notifications.webPush.enable')}
|
<div className="space-y-4 rounded-lg border border-border bg-card p-4">
|
||||||
</button>
|
<h4 className="font-medium text-foreground">{t('notifications.webPush.title')}</h4>
|
||||||
{isPushSubscribed && (
|
{!pushSupported ? (
|
||||||
<span className="text-sm text-green-600 dark:text-green-400">
|
<p className="text-sm text-muted-foreground">{t('notifications.webPush.unsupported')}</p>
|
||||||
{t('notifications.webPush.enabled')}
|
) : pushDenied ? (
|
||||||
</span>
|
<p className="text-sm text-muted-foreground">{t('notifications.webPush.denied')}</p>
|
||||||
)}
|
) : (
|
||||||
</div>
|
<div className="flex items-center gap-3">
|
||||||
)}
|
<button
|
||||||
</div>
|
type="button"
|
||||||
|
disabled={isPushLoading}
|
||||||
|
onClick={() => {
|
||||||
|
if (isPushSubscribed) {
|
||||||
|
onDisablePush();
|
||||||
|
} else {
|
||||||
|
onEnablePush();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||||
|
isPushSubscribed
|
||||||
|
? 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50'
|
||||||
|
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isPushLoading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : isPushSubscribed ? (
|
||||||
|
<BellOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<BellRing className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{isPushLoading
|
||||||
|
? t('notifications.webPush.loading')
|
||||||
|
: isPushSubscribed
|
||||||
|
? t('notifications.webPush.disable')
|
||||||
|
: t('notifications.webPush.enable')}
|
||||||
|
</button>
|
||||||
|
{isPushSubscribed && (
|
||||||
|
<span className="text-sm text-green-600 dark:text-green-400">
|
||||||
|
{t('notifications.webPush.enabled')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-4 rounded-lg border border-border bg-card p-4">
|
<div className="space-y-4 rounded-lg border border-border bg-card p-4">
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
@@ -133,7 +197,7 @@ export default function NotificationsSettingsTab({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 bg-card border border-border rounded-lg p-4">
|
<div className="space-y-4 rounded-lg border border-border bg-card p-4">
|
||||||
<h4 className="font-medium text-foreground">{t('notifications.events.title')}</h4>
|
<h4 className="font-medium text-foreground">{t('notifications.events.title')}</h4>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||||
@@ -149,7 +213,7 @@ export default function NotificationsSettingsTab({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="w-4 h-4"
|
className="h-4 w-4"
|
||||||
/>
|
/>
|
||||||
{t('notifications.events.actionRequired')}
|
{t('notifications.events.actionRequired')}
|
||||||
</label>
|
</label>
|
||||||
@@ -167,7 +231,7 @@ export default function NotificationsSettingsTab({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="w-4 h-4"
|
className="h-4 w-4"
|
||||||
/>
|
/>
|
||||||
{t('notifications.events.stop')}
|
{t('notifications.events.stop')}
|
||||||
</label>
|
</label>
|
||||||
@@ -185,7 +249,7 @@ export default function NotificationsSettingsTab({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="w-4 h-4"
|
className="h-4 w-4"
|
||||||
/>
|
/>
|
||||||
{t('notifications.events.error')}
|
{t('notifications.events.error')}
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
91
src/components/settings/view/tabs/VoiceSettingsTab.tsx
Normal file
91
src/components/settings/view/tabs/VoiceSettingsTab.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import type { InputHTMLAttributes } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import SettingsSection from '../SettingsSection';
|
||||||
|
import SettingsToggle from '../SettingsToggle';
|
||||||
|
import { useUiPreferences } from '../../../../hooks/useUiPreferences';
|
||||||
|
import { useVoiceConfig } from '../../../../hooks/useVoiceConfig';
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
'w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring';
|
||||||
|
|
||||||
|
function Field({ label, ...props }: { label: string } & InputHTMLAttributes<HTMLInputElement>) {
|
||||||
|
return (
|
||||||
|
<label className="block space-y-1">
|
||||||
|
<span className="text-sm font-medium text-foreground">{label}</span>
|
||||||
|
<input className={inputClass} {...props} />
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VoiceSettingsTab() {
|
||||||
|
const { t } = useTranslation('settings');
|
||||||
|
const { preferences, setPreference } = useUiPreferences();
|
||||||
|
const { config, update } = useVoiceConfig();
|
||||||
|
const voiceEnabled = preferences.voiceEnabled;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<SettingsSection title={t('voiceSettings.title')} description={t('voiceSettings.description')}>
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-border p-3">
|
||||||
|
<div className="pr-3">
|
||||||
|
<div className="text-sm font-medium text-foreground">{t('voiceSettings.enable')}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{t('voiceSettings.enableDescription')}</div>
|
||||||
|
</div>
|
||||||
|
<SettingsToggle
|
||||||
|
checked={voiceEnabled}
|
||||||
|
onChange={(v) => setPreference('voiceEnabled', v)}
|
||||||
|
ariaLabel={t('voiceSettings.enable')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{voiceEnabled && (
|
||||||
|
<SettingsSection title={t('voiceSettings.backendTitle')} description={t('voiceSettings.backendDescription')}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Field
|
||||||
|
label={t('voiceSettings.baseUrl')}
|
||||||
|
placeholder="https://api.openai.com/v1"
|
||||||
|
value={config.baseUrl}
|
||||||
|
onChange={(e) => update({ baseUrl: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={t('voiceSettings.apiKey')}
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="sk-…"
|
||||||
|
value={config.apiKey}
|
||||||
|
onChange={(e) => update({ apiKey: e.target.value })}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||||
|
<Field
|
||||||
|
label={t('voiceSettings.sttModel')}
|
||||||
|
placeholder="whisper-1"
|
||||||
|
value={config.sttModel}
|
||||||
|
onChange={(e) => update({ sttModel: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={t('voiceSettings.ttsModel')}
|
||||||
|
placeholder="tts-1"
|
||||||
|
value={config.ttsModel}
|
||||||
|
onChange={(e) => update({ ttsModel: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={t('voiceSettings.voice')}
|
||||||
|
placeholder="alloy"
|
||||||
|
value={config.ttsVoice}
|
||||||
|
onChange={(e) => update({ ttsVoice: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={t('voiceSettings.format')}
|
||||||
|
placeholder="mp3"
|
||||||
|
value={config.ttsFormat}
|
||||||
|
onChange={(e) => update({ ttsFormat: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('voiceSettings.note')}</p>
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ export default function GitHubStarBadge() {
|
|||||||
>
|
>
|
||||||
<GitHubIcon className="h-3.5 w-3.5" />
|
<GitHubIcon className="h-3.5 w-3.5" />
|
||||||
<Star className="h-3 w-3" />
|
<Star className="h-3 w-3" />
|
||||||
<span className="font-medium">Star</span>
|
<span className="font-normal">Star</span>
|
||||||
{formattedCount && (
|
{formattedCount && (
|
||||||
<span className="border-l border-border/50 pl-1.5 tabular-nums">{formattedCount}</span>
|
<span className="border-l border-border/50 pl-1.5 tabular-nums">{formattedCount}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ function HighlightedSnippet({ snippet, highlights }: { snippet: string; highligh
|
|||||||
parts.push(snippet.slice(cursor));
|
parts.push(snippet.slice(cursor));
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span className="text-xs leading-relaxed text-muted-foreground">
|
<span className="min-w-0 flex-1 break-words text-xs leading-relaxed text-muted-foreground">
|
||||||
{parts}
|
{parts}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -266,7 +266,7 @@ export default function SidebarContent({
|
|||||||
<div key={projectResult.projectName} className="space-y-1">
|
<div key={projectResult.projectName} className="space-y-1">
|
||||||
<div className="flex items-center gap-1.5 px-1 py-1">
|
<div className="flex items-center gap-1.5 px-1 py-1">
|
||||||
<Folder className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
<Folder className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
||||||
<span className="truncate text-xs font-medium text-foreground">
|
<span className="truncate text-xs font-normal text-foreground">
|
||||||
{projectResult.projectDisplayName}
|
{projectResult.projectDisplayName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -286,7 +286,7 @@ export default function SidebarContent({
|
|||||||
>
|
>
|
||||||
<div className="mb-1 flex items-center gap-1.5">
|
<div className="mb-1 flex items-center gap-1.5">
|
||||||
<MessageSquare className="h-3 w-3 flex-shrink-0 text-primary" />
|
<MessageSquare className="h-3 w-3 flex-shrink-0 text-primary" />
|
||||||
<span className="truncate text-xs font-medium text-foreground">
|
<span className="truncate text-xs font-normal text-foreground">
|
||||||
{session.sessionSummary}
|
{session.sessionSummary}
|
||||||
</span>
|
</span>
|
||||||
{session.provider && session.provider !== 'claude' && (
|
{session.provider && session.provider !== 'claude' && (
|
||||||
@@ -298,7 +298,7 @@ export default function SidebarContent({
|
|||||||
<div className="space-y-1 pl-4">
|
<div className="space-y-1 pl-4">
|
||||||
{session.matches.map((match, idx) => (
|
{session.matches.map((match, idx) => (
|
||||||
<div key={idx} className="flex items-start gap-1">
|
<div key={idx} className="flex items-start gap-1">
|
||||||
<span className="mt-0.5 flex-shrink-0 text-[10px] font-medium uppercase text-muted-foreground/60">
|
<span className="mt-0.5 flex-shrink-0 text-[10px] font-normal uppercase text-muted-foreground/60">
|
||||||
{match.role === 'user' ? 'U' : 'A'}
|
{match.role === 'user' ? 'U' : 'A'}
|
||||||
</span>
|
</span>
|
||||||
<HighlightedSnippet
|
<HighlightedSnippet
|
||||||
@@ -336,11 +336,11 @@ export default function SidebarContent({
|
|||||||
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
|
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
|
||||||
<Activity className="h-3.5 w-3.5" />
|
<Activity className="h-3.5 w-3.5" />
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate text-xs font-medium text-foreground">
|
<span className="truncate text-xs font-normal text-foreground">
|
||||||
{t('running.title', 'Running now')}
|
{t('running.title', 'Running now')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[11px] font-medium text-emerald-700 dark:text-emerald-300">
|
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[11px] font-normal text-emerald-700 dark:text-emerald-300">
|
||||||
{runningSessionsCount}
|
{runningSessionsCount}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -395,7 +395,7 @@ export default function SidebarContent({
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Folder className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
<Folder className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||||
<span className="truncate text-sm font-medium text-foreground">
|
<span className="truncate text-sm font-normal text-foreground">
|
||||||
{project.displayName}
|
{project.displayName}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center justify-center rounded-full bg-muted px-1 py-px text-center text-[7px] font-medium uppercase leading-none tracking-[0.02em] text-muted-foreground">
|
<span className="inline-flex items-center justify-center rounded-full bg-muted px-1 py-px text-center text-[7px] font-medium uppercase leading-none tracking-[0.02em] text-muted-foreground">
|
||||||
@@ -448,7 +448,7 @@ export default function SidebarContent({
|
|||||||
<SessionProviderLogo provider={session.__provider} className="h-3.5 w-3.5 flex-shrink-0" />
|
<SessionProviderLogo provider={session.__provider} className="h-3.5 w-3.5 flex-shrink-0" />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="truncate text-xs font-medium text-foreground">
|
<span className="truncate text-xs font-normal text-foreground">
|
||||||
{(typeof session.summary === 'string' && session.summary.trim().length > 0
|
{(typeof session.summary === 'string' && session.summary.trim().length > 0
|
||||||
? session.summary
|
? session.summary
|
||||||
: typeof session.name === 'string' && session.name.trim().length > 0
|
: typeof session.name === 'string' && session.name.trim().length > 0
|
||||||
@@ -484,7 +484,7 @@ export default function SidebarContent({
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Folder className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
<Folder className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||||
<span className="truncate text-sm font-medium text-foreground">
|
<span className="truncate text-sm font-normal text-foreground">
|
||||||
{group.projectDisplayName}
|
{group.projectDisplayName}
|
||||||
</span>
|
</span>
|
||||||
{group.isProjectArchived && (
|
{group.isProjectArchived && (
|
||||||
@@ -513,7 +513,7 @@ export default function SidebarContent({
|
|||||||
<SessionProviderLogo provider={session.provider} className="h-3.5 w-3.5 flex-shrink-0" />
|
<SessionProviderLogo provider={session.provider} className="h-3.5 w-3.5 flex-shrink-0" />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="truncate text-xs font-medium text-foreground">
|
<span className="truncate text-xs font-normal text-foreground">
|
||||||
{session.sessionTitle}
|
{session.sessionTitle}
|
||||||
</span>
|
</span>
|
||||||
{session.lastActivity && (
|
{session.lastActivity && (
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export default function SidebarFooter({
|
|||||||
<span className="absolute -right-0.5 -top-0.5 h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500" />
|
<span className="absolute -right-0.5 -top-0.5 h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<span className="block truncate text-sm font-medium text-blue-600 dark:text-blue-300">
|
<span className="block truncate text-sm font-normal text-blue-600 dark:text-blue-300">
|
||||||
{releaseInfo?.title || `v${latestVersion}`}
|
{releaseInfo?.title || `v${latestVersion}`}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-blue-500/70 dark:text-blue-400/60">
|
<span className="text-[10px] text-blue-500/70 dark:text-blue-400/60">
|
||||||
@@ -91,7 +91,7 @@ export default function SidebarFooter({
|
|||||||
<span className="absolute -right-0.5 -top-0.5 h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500" />
|
<span className="absolute -right-0.5 -top-0.5 h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1 text-left">
|
<div className="min-w-0 flex-1 text-left">
|
||||||
<span className="block truncate text-sm font-medium text-blue-600 dark:text-blue-300">
|
<span className="block truncate text-sm font-normal text-blue-600 dark:text-blue-300">
|
||||||
{releaseInfo?.title || `v${latestVersion}`}
|
{releaseInfo?.title || `v${latestVersion}`}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-blue-500/70 dark:text-blue-400/60">
|
<span className="text-xs text-blue-500/70 dark:text-blue-400/60">
|
||||||
@@ -168,7 +168,7 @@ export default function SidebarFooter({
|
|||||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-background/80">
|
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-background/80">
|
||||||
<Bug className="h-4 w-4 text-muted-foreground" />
|
<Bug className="h-4 w-4 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium text-foreground">{t('actions.reportIssue')}</span>
|
<span className="text-sm font-normal text-foreground">{t('actions.reportIssue')}</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -183,7 +183,7 @@ export default function SidebarFooter({
|
|||||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-background/80">
|
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-background/80">
|
||||||
<DiscordIcon className="h-4 w-4 text-muted-foreground" />
|
<DiscordIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium text-foreground">{t('actions.joinCommunity')}</span>
|
<span className="text-sm font-normal text-foreground">{t('actions.joinCommunity')}</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -196,7 +196,7 @@ export default function SidebarFooter({
|
|||||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-background/80">
|
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-background/80">
|
||||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium text-foreground">{t('actions.settings')}</span>
|
<span className="text-sm font-normal text-foreground">{t('actions.settings')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export default function SidebarHeader({
|
|||||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="truncate text-sm font-semibold tracking-tight text-foreground">{t('app.title')}</h1>
|
<h1 className="truncate text-sm font-normal tracking-tight text-foreground">{t('app.title')}</h1>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ export default function SidebarHeader({
|
|||||||
onClick={() => onSearchModeChange('projects')}
|
onClick={() => onSearchModeChange('projects')}
|
||||||
aria-pressed={searchMode === 'projects'}
|
aria-pressed={searchMode === 'projects'}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
|
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-normal transition-all",
|
||||||
searchMode === 'projects'
|
searchMode === 'projects'
|
||||||
? "bg-background shadow-sm text-foreground"
|
? "bg-background shadow-sm text-foreground"
|
||||||
: "text-muted-foreground hover:text-foreground"
|
: "text-muted-foreground hover:text-foreground"
|
||||||
@@ -151,7 +151,7 @@ export default function SidebarHeader({
|
|||||||
onClick={() => onSearchModeChange('conversations')}
|
onClick={() => onSearchModeChange('conversations')}
|
||||||
aria-pressed={searchMode === 'conversations'}
|
aria-pressed={searchMode === 'conversations'}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
|
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-normal transition-all",
|
||||||
searchMode === 'conversations'
|
searchMode === 'conversations'
|
||||||
? "bg-background shadow-sm text-foreground"
|
? "bg-background shadow-sm text-foreground"
|
||||||
: "text-muted-foreground hover:text-foreground"
|
: "text-muted-foreground hover:text-foreground"
|
||||||
@@ -167,7 +167,7 @@ export default function SidebarHeader({
|
|||||||
aria-label={t('search.runningTooltip', 'Running sessions')}
|
aria-label={t('search.runningTooltip', 'Running sessions')}
|
||||||
title={t('search.runningTooltip', 'Running sessions')}
|
title={t('search.runningTooltip', 'Running sessions')}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
|
"flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-normal transition-all",
|
||||||
searchMode === 'running'
|
searchMode === 'running'
|
||||||
? "bg-background shadow-sm text-foreground ring-1 ring-emerald-500/15"
|
? "bg-background shadow-sm text-foreground ring-1 ring-emerald-500/15"
|
||||||
: "text-muted-foreground hover:text-foreground"
|
: "text-muted-foreground hover:text-foreground"
|
||||||
@@ -190,7 +190,7 @@ export default function SidebarHeader({
|
|||||||
aria-label={t('search.archiveOnlyTooltip', 'Archive only')}
|
aria-label={t('search.archiveOnlyTooltip', 'Archive only')}
|
||||||
title={t('search.archiveOnlyTooltip', 'Archive only')}
|
title={t('search.archiveOnlyTooltip', 'Archive only')}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-center rounded-md px-2.5 py-1.5 text-xs font-medium transition-all",
|
"flex items-center justify-center rounded-md px-2.5 py-1.5 text-xs font-normal transition-all",
|
||||||
searchMode === 'archived'
|
searchMode === 'archived'
|
||||||
? "bg-background shadow-sm text-foreground"
|
? "bg-background shadow-sm text-foreground"
|
||||||
: "text-muted-foreground hover:text-foreground"
|
: "text-muted-foreground hover:text-foreground"
|
||||||
@@ -278,7 +278,7 @@ export default function SidebarHeader({
|
|||||||
onClick={() => onSearchModeChange('projects')}
|
onClick={() => onSearchModeChange('projects')}
|
||||||
aria-pressed={searchMode === 'projects'}
|
aria-pressed={searchMode === 'projects'}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
|
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-normal transition-all",
|
||||||
searchMode === 'projects'
|
searchMode === 'projects'
|
||||||
? "bg-background shadow-sm text-foreground"
|
? "bg-background shadow-sm text-foreground"
|
||||||
: "text-muted-foreground hover:text-foreground"
|
: "text-muted-foreground hover:text-foreground"
|
||||||
@@ -291,7 +291,7 @@ export default function SidebarHeader({
|
|||||||
onClick={() => onSearchModeChange('conversations')}
|
onClick={() => onSearchModeChange('conversations')}
|
||||||
aria-pressed={searchMode === 'conversations'}
|
aria-pressed={searchMode === 'conversations'}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
|
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-normal transition-all",
|
||||||
searchMode === 'conversations'
|
searchMode === 'conversations'
|
||||||
? "bg-background shadow-sm text-foreground"
|
? "bg-background shadow-sm text-foreground"
|
||||||
: "text-muted-foreground hover:text-foreground"
|
: "text-muted-foreground hover:text-foreground"
|
||||||
@@ -307,7 +307,7 @@ export default function SidebarHeader({
|
|||||||
aria-label={t('search.runningTooltip', 'Running sessions')}
|
aria-label={t('search.runningTooltip', 'Running sessions')}
|
||||||
title={t('search.runningTooltip', 'Running sessions')}
|
title={t('search.runningTooltip', 'Running sessions')}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
|
"flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-normal transition-all",
|
||||||
searchMode === 'running'
|
searchMode === 'running'
|
||||||
? "bg-background shadow-sm text-foreground ring-1 ring-emerald-500/15"
|
? "bg-background shadow-sm text-foreground ring-1 ring-emerald-500/15"
|
||||||
: "text-muted-foreground hover:text-foreground"
|
: "text-muted-foreground hover:text-foreground"
|
||||||
@@ -331,7 +331,7 @@ export default function SidebarHeader({
|
|||||||
aria-label={t('search.archiveOnlyTooltip', 'Archive only')}
|
aria-label={t('search.archiveOnlyTooltip', 'Archive only')}
|
||||||
title={t('search.archiveOnlyTooltip', 'Archive only')}
|
title={t('search.archiveOnlyTooltip', 'Archive only')}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-center rounded-md px-2.5 py-1.5 text-xs font-medium transition-all",
|
"flex items-center justify-center rounded-md px-2.5 py-1.5 text-xs font-normal transition-all",
|
||||||
searchMode === 'archived'
|
searchMode === 'archived'
|
||||||
? "bg-background shadow-sm text-foreground"
|
? "bg-background shadow-sm text-foreground"
|
||||||
: "text-muted-foreground hover:text-foreground"
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ export default function SidebarProjectItem({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex min-w-0 flex-1 items-center justify-between">
|
<div className="flex min-w-0 flex-1 items-center justify-between">
|
||||||
<h3 className="truncate text-sm font-medium text-foreground">{project.displayName}</h3>
|
<h3 className="truncate text-sm font-normal text-foreground">{project.displayName}</h3>
|
||||||
{tasksEnabled && (
|
{tasksEnabled && (
|
||||||
<TaskIndicator
|
<TaskIndicator
|
||||||
status={taskStatus}
|
status={taskStatus}
|
||||||
@@ -318,7 +318,7 @@ export default function SidebarProjectItem({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<div className="truncate text-sm font-semibold text-foreground" title={project.displayName}>
|
<div className="truncate text-sm font-normal text-foreground" title={project.displayName}>
|
||||||
{project.displayName}
|
{project.displayName}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react';
|
|||||||
import { Check, Edit2, Loader2, Trash2, X } from 'lucide-react';
|
import { Check, Edit2, Loader2, Trash2, X } from 'lucide-react';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
|
|
||||||
import { Badge, Button, Tooltip } from '../../../../shared/view/ui';
|
import { Badge, Tooltip, buttonVariants } from '../../../../shared/view/ui';
|
||||||
import { cn } from '../../../../lib/utils';
|
import { cn } from '../../../../lib/utils';
|
||||||
import type { Project, ProjectSession, LLMProvider } from '../../../../types/app';
|
import type { Project, ProjectSession, LLMProvider } from '../../../../types/app';
|
||||||
import type { SessionWithProvider } from '../../types/types';
|
import type { SessionWithProvider } from '../../types/types';
|
||||||
@@ -157,7 +157,7 @@ export default function SidebarSessionItem({
|
|||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
|
<div className="min-w-0 flex-1 truncate text-xs font-normal text-foreground">{sessionView.sessionName}</div>
|
||||||
{isProcessing ? (
|
{isProcessing ? (
|
||||||
<span className="ml-auto flex-shrink-0">
|
<span className="ml-auto flex-shrink-0">
|
||||||
<Tooltip content={t('tooltips.processingSessionIndicator', 'Processing session')} position="top">
|
<Tooltip content={t('tooltips.processingSessionIndicator', 'Processing session')} position="top">
|
||||||
@@ -195,9 +195,10 @@ export default function SidebarSessionItem({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
<Button
|
<a
|
||||||
variant="ghost"
|
href={`/session/${session.id}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
buttonVariants({ variant: 'ghost' }),
|
||||||
'h-auto w-full justify-start rounded-md border bg-card p-2 text-left font-normal transition-all duration-150',
|
'h-auto w-full justify-start rounded-md border bg-card p-2 text-left font-normal transition-all duration-150',
|
||||||
isSelected ? 'border-primary/20 bg-primary/5' : 'border-border/30',
|
isSelected ? 'border-primary/20 bg-primary/5' : 'border-border/30',
|
||||||
!isSelected && isProcessing
|
!isSelected && isProcessing
|
||||||
@@ -206,7 +207,13 @@ export default function SidebarSessionItem({
|
|||||||
? 'border-green-500/30 bg-green-50/5 hover:bg-green-50/10 dark:bg-green-900/5 dark:hover:bg-green-900/10'
|
? 'border-green-500/30 bg-green-50/5 hover:bg-green-50/10 dark:bg-green-900/5 dark:hover:bg-green-900/10'
|
||||||
: 'hover:bg-accent/50',
|
: 'hover:bg-accent/50',
|
||||||
)}
|
)}
|
||||||
onClick={() => onSessionSelect(session, project.projectId)}
|
// Left-click keeps in-app navigation; Ctrl/Cmd/middle-click and the
|
||||||
|
// native right-click menu use the href to open a new tab/window.
|
||||||
|
onClick={(event) => {
|
||||||
|
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||||
|
event.preventDefault();
|
||||||
|
onSessionSelect(session, project.projectId);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex w-full min-w-0 items-center gap-2">
|
<div className="flex w-full min-w-0 items-center gap-2">
|
||||||
<div
|
<div
|
||||||
@@ -219,7 +226,7 @@ export default function SidebarSessionItem({
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
|
<div className="min-w-0 flex-1 truncate text-xs font-normal text-foreground">{sessionView.sessionName}</div>
|
||||||
{isProcessing ? (
|
{isProcessing ? (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -249,7 +256,7 @@ export default function SidebarSessionItem({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</a>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={editingContainerRef}
|
ref={editingContainerRef}
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export default function TaskIndicator({
|
|||||||
title={indicatorConfig.title}
|
title={indicatorConfig.title}
|
||||||
>
|
>
|
||||||
<Icon className={sizeClassNames[size]} />
|
<Icon className={sizeClassNames[size]} />
|
||||||
<span className="font-medium">{indicatorConfig.label}</span>
|
<span className="font-normal">{indicatorConfig.label}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import type { MutableRefObject, ReactNode } from 'react';
|
|||||||
|
|
||||||
export type PaletteOps = {
|
export type PaletteOps = {
|
||||||
openFile: (path: string) => void;
|
openFile: (path: string) => void;
|
||||||
|
// Opens a file in the editor side panel without changing the active tab
|
||||||
|
// (used by in-chat file links so they behave like the inline edit view).
|
||||||
|
openFileInEditor: (path: string) => void;
|
||||||
openSettings: (tab?: string) => void;
|
openSettings: (tab?: string) => void;
|
||||||
refreshProjects: () => Promise<void> | void;
|
refreshProjects: () => Promise<void> | void;
|
||||||
};
|
};
|
||||||
@@ -13,6 +16,7 @@ const PaletteOpsContext = createContext<Registry | null>(null);
|
|||||||
|
|
||||||
const defaultOps: PaletteOps = {
|
const defaultOps: PaletteOps = {
|
||||||
openFile: () => undefined,
|
openFile: () => undefined,
|
||||||
|
openFileInEditor: () => undefined,
|
||||||
openSettings: () => undefined,
|
openSettings: () => undefined,
|
||||||
refreshProjects: () => undefined,
|
refreshProjects: () => undefined,
|
||||||
};
|
};
|
||||||
@@ -27,6 +31,8 @@ export function usePaletteOps(): PaletteOps {
|
|||||||
return useMemo<PaletteOps>(
|
return useMemo<PaletteOps>(
|
||||||
() => ({
|
() => ({
|
||||||
openFile: (path) => (ref?.current.openFile ?? defaultOps.openFile)(path),
|
openFile: (path) => (ref?.current.openFile ?? defaultOps.openFile)(path),
|
||||||
|
openFileInEditor: (path) =>
|
||||||
|
(ref?.current.openFileInEditor ?? defaultOps.openFileInEditor)(path),
|
||||||
openSettings: (tab) => (ref?.current.openSettings ?? defaultOps.openSettings)(tab),
|
openSettings: (tab) => (ref?.current.openSettings ?? defaultOps.openSettings)(tab),
|
||||||
refreshProjects: () => (ref?.current.refreshProjects ?? defaultOps.refreshProjects)(),
|
refreshProjects: () => (ref?.current.refreshProjects ?? defaultOps.refreshProjects)(),
|
||||||
}),
|
}),
|
||||||
@@ -36,18 +42,20 @@ export function usePaletteOps(): PaletteOps {
|
|||||||
|
|
||||||
export function usePaletteOpsRegister(partial: Partial<PaletteOps>) {
|
export function usePaletteOpsRegister(partial: Partial<PaletteOps>) {
|
||||||
const ref = useContext(PaletteOpsContext);
|
const ref = useContext(PaletteOpsContext);
|
||||||
const { openFile, openSettings, refreshProjects } = partial;
|
const { openFile, openFileInEditor, openSettings, refreshProjects } = partial;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ref) return undefined;
|
if (!ref) return undefined;
|
||||||
const prev = { ...ref.current };
|
const prev = { ...ref.current };
|
||||||
if (openFile) ref.current.openFile = openFile;
|
if (openFile) ref.current.openFile = openFile;
|
||||||
|
if (openFileInEditor) ref.current.openFileInEditor = openFileInEditor;
|
||||||
if (openSettings) ref.current.openSettings = openSettings;
|
if (openSettings) ref.current.openSettings = openSettings;
|
||||||
if (refreshProjects) ref.current.refreshProjects = refreshProjects;
|
if (refreshProjects) ref.current.refreshProjects = refreshProjects;
|
||||||
return () => {
|
return () => {
|
||||||
if (openFile && ref.current.openFile === openFile) ref.current.openFile = prev.openFile;
|
if (openFile && ref.current.openFile === openFile) ref.current.openFile = prev.openFile;
|
||||||
|
if (openFileInEditor && ref.current.openFileInEditor === openFileInEditor) ref.current.openFileInEditor = prev.openFileInEditor;
|
||||||
if (openSettings && ref.current.openSettings === openSettings) ref.current.openSettings = prev.openSettings;
|
if (openSettings && ref.current.openSettings === openSettings) ref.current.openSettings = prev.openSettings;
|
||||||
if (refreshProjects && ref.current.refreshProjects === refreshProjects) ref.current.refreshProjects = prev.refreshProjects;
|
if (refreshProjects && ref.current.refreshProjects === refreshProjects) ref.current.refreshProjects = prev.refreshProjects;
|
||||||
};
|
};
|
||||||
}, [ref, openFile, openSettings, refreshProjects]);
|
}, [ref, openFile, openFileInEditor, openSettings, refreshProjects]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,4 +91,4 @@ export const ThemeProvider = ({ children }) => {
|
|||||||
{children}
|
{children}
|
||||||
</ThemeContext.Provider>
|
</ThemeContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
108
src/hooks/useFileOpenResolver.ts
Normal file
108
src/hooks/useFileOpenResolver.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
|
||||||
|
import { api } from '../utils/api';
|
||||||
|
import type { Project } from '../types/app';
|
||||||
|
|
||||||
|
type FileNode = {
|
||||||
|
type: 'file' | 'directory';
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
children?: FileNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type FlatFile = {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// `diffInfo` is intentionally `any` so this resolver can wrap editor handlers
|
||||||
|
// that expect a concrete diff payload type as well as generic callers.
|
||||||
|
type OnFileOpen = (filePath: string, diffInfo?: any) => void;
|
||||||
|
|
||||||
|
const normalize = (value: string): string => value.replace(/\\/g, '/');
|
||||||
|
|
||||||
|
const flatten = (nodes: FileNode[], out: FlatFile[]): void => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.type === 'file') {
|
||||||
|
out.push({ name: node.name, path: node.path });
|
||||||
|
} else if (node.children && node.children.length > 0) {
|
||||||
|
flatten(node.children, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// References inside chat messages are often bare basenames (`foo.ts`) or partial
|
||||||
|
// paths (`utils/foo.ts`) rather than full paths, so match by path suffix and
|
||||||
|
// fall back to filename equality.
|
||||||
|
const findBestMatch = (files: FlatFile[], ref: string): string | null => {
|
||||||
|
const target = normalize(ref).replace(/^\.\//, '').replace(/^\/+/, '');
|
||||||
|
if (!target) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const suffixMatch = files.find((file) => {
|
||||||
|
const filePath = normalize(file.path);
|
||||||
|
return filePath === target || filePath.endsWith(`/${target}`);
|
||||||
|
});
|
||||||
|
if (suffixMatch) {
|
||||||
|
return suffixMatch.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = target.split('/').pop() || target;
|
||||||
|
return files.find((file) => file.name === base)?.path ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps an `onFileOpen` handler so a possibly bare/partial file reference is
|
||||||
|
* resolved against the project's file tree (cached per project) before the file
|
||||||
|
* is opened in the in-app editor.
|
||||||
|
*/
|
||||||
|
export function useFileOpenResolver(
|
||||||
|
selectedProject: Project | null | undefined,
|
||||||
|
onFileOpen: OnFileOpen,
|
||||||
|
): OnFileOpen {
|
||||||
|
const projectId = selectedProject?.projectId;
|
||||||
|
const cacheRef = useRef<{ projectId?: string; files: Promise<FlatFile[]> | null }>({
|
||||||
|
projectId: undefined,
|
||||||
|
files: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadFiles = useCallback((): Promise<FlatFile[]> => {
|
||||||
|
if (!projectId) {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}
|
||||||
|
if (cacheRef.current.projectId === projectId && cacheRef.current.files) {
|
||||||
|
return cacheRef.current.files;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filesPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.getFiles(projectId);
|
||||||
|
if (!response.ok) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
const tree: FileNode[] = Array.isArray(data) ? data : [];
|
||||||
|
const flat: FlatFile[] = [];
|
||||||
|
flatten(tree, flat);
|
||||||
|
return flat;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
cacheRef.current = { projectId, files: filesPromise };
|
||||||
|
return filesPromise;
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
return useCallback(
|
||||||
|
(filePath: string, diffInfo?: any) => {
|
||||||
|
const ref = normalize(filePath).trim();
|
||||||
|
void loadFiles().then((files) => {
|
||||||
|
const match = findBestMatch(files, ref);
|
||||||
|
onFileOpen(match ?? filePath, diffInfo);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[loadFiles, onFileOpen],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ type UiPreferences = {
|
|||||||
autoScrollToBottom: boolean;
|
autoScrollToBottom: boolean;
|
||||||
sendByCtrlEnter: boolean;
|
sendByCtrlEnter: boolean;
|
||||||
sidebarVisible: boolean;
|
sidebarVisible: boolean;
|
||||||
|
voiceEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type UiPreferenceKey = keyof UiPreferences;
|
type UiPreferenceKey = keyof UiPreferences;
|
||||||
@@ -39,6 +40,7 @@ const DEFAULTS: UiPreferences = {
|
|||||||
autoScrollToBottom: true,
|
autoScrollToBottom: true,
|
||||||
sendByCtrlEnter: false,
|
sendByCtrlEnter: false,
|
||||||
sidebarVisible: true,
|
sidebarVisible: true,
|
||||||
|
voiceEnabled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const PREFERENCE_KEYS = Object.keys(DEFAULTS) as UiPreferenceKey[];
|
const PREFERENCE_KEYS = Object.keys(DEFAULTS) as UiPreferenceKey[];
|
||||||
|
|||||||
68
src/hooks/useVoiceConfig.ts
Normal file
68
src/hooks/useVoiceConfig.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export type VoiceConfig = {
|
||||||
|
baseUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
sttModel: string;
|
||||||
|
ttsModel: string;
|
||||||
|
ttsVoice: string;
|
||||||
|
ttsFormat: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'voiceConfig';
|
||||||
|
export const VOICE_CONFIG_SYNC_EVENT = 'voice-config:sync';
|
||||||
|
const DEFAULTS: VoiceConfig = { baseUrl: '', apiKey: '', sttModel: '', ttsModel: '', ttsVoice: '', ttsFormat: '' };
|
||||||
|
|
||||||
|
export function readVoiceConfig(): VoiceConfig {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return { ...DEFAULTS };
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return { ...DEFAULTS };
|
||||||
|
const config = { ...DEFAULTS };
|
||||||
|
for (const key of Object.keys(DEFAULTS) as (keyof VoiceConfig)[]) {
|
||||||
|
if (typeof parsed[key] === 'string') config[key] = parsed[key];
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULTS };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Headers the voice proxy reads to target a per-user OpenAI-compatible backend.
|
||||||
|
// Empty fields are omitted so the server's env defaults apply.
|
||||||
|
export function voiceConfigHeaders(): Record<string, string> {
|
||||||
|
if (typeof window === 'undefined') return {};
|
||||||
|
const c = readVoiceConfig();
|
||||||
|
const h: Record<string, string> = {};
|
||||||
|
if (c.apiKey) h['x-voice-api-key'] = c.apiKey;
|
||||||
|
if (c.sttModel) h['x-voice-stt-model'] = c.sttModel;
|
||||||
|
if (c.ttsModel) h['x-voice-tts-model'] = c.ttsModel;
|
||||||
|
if (c.ttsVoice) h['x-voice-tts-voice'] = c.ttsVoice;
|
||||||
|
if (c.ttsFormat.trim()) h['x-voice-tts-format'] = c.ttsFormat.trim();
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVoiceConfig() {
|
||||||
|
const [config, setConfig] = useState<VoiceConfig>(() =>
|
||||||
|
typeof window === 'undefined' ? { ...DEFAULTS } : readVoiceConfig(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const update = (patch: Partial<VoiceConfig>) => {
|
||||||
|
setConfig((prev) => {
|
||||||
|
const next = { ...prev, ...patch };
|
||||||
|
try {
|
||||||
|
const stored: Partial<VoiceConfig> = { ...next };
|
||||||
|
if (next.ttsFormat.trim()) stored.ttsFormat = next.ttsFormat.trim();
|
||||||
|
else delete stored.ttsFormat;
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(stored));
|
||||||
|
window.dispatchEvent(new Event(VOICE_CONFIG_SYNC_EVENT));
|
||||||
|
} catch {
|
||||||
|
/* ignore persistence errors */
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return { config, update };
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { authenticatedFetch } from '../utils/api';
|
import { authenticatedFetch } from '../utils/api';
|
||||||
|
|
||||||
type WebPushState = {
|
type WebPushState = {
|
||||||
@@ -22,7 +23,12 @@ function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
|||||||
|
|
||||||
export function useWebPush(): WebPushState {
|
export function useWebPush(): WebPushState {
|
||||||
const [permission, setPermission] = useState<NotificationPermission | 'unsupported'>(() => {
|
const [permission, setPermission] = useState<NotificationPermission | 'unsupported'>(() => {
|
||||||
if (typeof window === 'undefined' || !('Notification' in window) || !('serviceWorker' in navigator)) {
|
if (
|
||||||
|
typeof window === 'undefined'
|
||||||
|
|| Boolean((window as any).cloudcliDesktopNotifications)
|
||||||
|
|| !('Notification' in window)
|
||||||
|
|| !('serviceWorker' in navigator)
|
||||||
|
) {
|
||||||
return 'unsupported';
|
return 'unsupported';
|
||||||
}
|
}
|
||||||
return Notification.permission;
|
return Notification.permission;
|
||||||
|
|||||||
@@ -23,7 +23,8 @@
|
|||||||
"files": "Dateien",
|
"files": "Dateien",
|
||||||
"git": "Quellcodeverwaltung",
|
"git": "Quellcodeverwaltung",
|
||||||
"tasks": "Aufgaben",
|
"tasks": "Aufgaben",
|
||||||
"browser": "Browser"
|
"browser": "Browser",
|
||||||
|
"computer": "Computer"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Lädt...",
|
"loading": "Lädt...",
|
||||||
|
|||||||
@@ -122,6 +122,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"voice": {
|
||||||
|
"input": "Voice input",
|
||||||
|
"stopRecording": "Stop recording",
|
||||||
|
"transcribing": "Transcribing…",
|
||||||
|
"speak": "Read aloud",
|
||||||
|
"stopSpeaking": "Stop",
|
||||||
|
"loading": "Loading…"
|
||||||
|
},
|
||||||
"input": {
|
"input": {
|
||||||
"placeholder": "Type / for commands, @ for files, or ask {{provider}} anything...",
|
"placeholder": "Type / for commands, @ for files, or ask {{provider}} anything...",
|
||||||
"placeholderDefault": "Type your message...",
|
"placeholderDefault": "Type your message...",
|
||||||
|
|||||||
@@ -23,7 +23,8 @@
|
|||||||
"files": "Files",
|
"files": "Files",
|
||||||
"git": "Source Control",
|
"git": "Source Control",
|
||||||
"tasks": "Tasks",
|
"tasks": "Tasks",
|
||||||
"browser": "Browser"
|
"browser": "Browser",
|
||||||
|
"computer": "Computer"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
|
|||||||
@@ -50,6 +50,21 @@
|
|||||||
"resetToDefaults": "Reset to Defaults",
|
"resetToDefaults": "Reset to Defaults",
|
||||||
"cancelChanges": "Cancel Changes"
|
"cancelChanges": "Cancel Changes"
|
||||||
},
|
},
|
||||||
|
"voiceSettings": {
|
||||||
|
"title": "Voice",
|
||||||
|
"description": "Speech-to-text input and read-aloud, via an OpenAI-compatible audio backend.",
|
||||||
|
"enable": "Enable voice",
|
||||||
|
"enableDescription": "Show the mic button and the read-aloud button on messages.",
|
||||||
|
"backendTitle": "Backend",
|
||||||
|
"backendDescription": "Point at OpenAI, Groq, or a local server (LocalAI, Speaches, Kokoro-FastAPI). Leave blank to use the server default.",
|
||||||
|
"baseUrl": "Base URL",
|
||||||
|
"apiKey": "API key",
|
||||||
|
"sttModel": "Speech-to-text model",
|
||||||
|
"ttsModel": "Text-to-speech model",
|
||||||
|
"voice": "Voice",
|
||||||
|
"format": "Audio format",
|
||||||
|
"note": "A custom base URL is called directly by your browser and must allow browser CORS requests. Leave it blank to use the server-configured backend."
|
||||||
|
},
|
||||||
"quickSettings": {
|
"quickSettings": {
|
||||||
"title": "Quick Settings",
|
"title": "Quick Settings",
|
||||||
"sections": {
|
"sections": {
|
||||||
@@ -64,6 +79,7 @@
|
|||||||
"showThinking": "Show thinking",
|
"showThinking": "Show thinking",
|
||||||
"autoScrollToBottom": "Auto-scroll to bottom",
|
"autoScrollToBottom": "Auto-scroll to bottom",
|
||||||
"sendByCtrlEnter": "Send by Ctrl+Enter",
|
"sendByCtrlEnter": "Send by Ctrl+Enter",
|
||||||
|
"voiceEnabled": "Voice (mic + read aloud)",
|
||||||
"sendByCtrlEnterDescription": "When enabled, pressing Ctrl+Enter will send the message instead of just Enter. This is useful for IME users to avoid accidental sends.",
|
"sendByCtrlEnterDescription": "When enabled, pressing Ctrl+Enter will send the message instead of just Enter. This is useful for IME users to avoid accidental sends.",
|
||||||
"dragHandle": {
|
"dragHandle": {
|
||||||
"dragging": "Dragging handle",
|
"dragging": "Dragging handle",
|
||||||
@@ -94,6 +110,7 @@
|
|||||||
"appearance": "Appearance",
|
"appearance": "Appearance",
|
||||||
"git": "Git",
|
"git": "Git",
|
||||||
"apiTokens": "API & Tokens",
|
"apiTokens": "API & Tokens",
|
||||||
|
"voice": "Voice",
|
||||||
"tasks": "Tasks",
|
"tasks": "Tasks",
|
||||||
"browser": "Browser",
|
"browser": "Browser",
|
||||||
"notifications": "Notifications",
|
"notifications": "Notifications",
|
||||||
@@ -104,14 +121,21 @@
|
|||||||
"title": "Notifications",
|
"title": "Notifications",
|
||||||
"description": "Control which notification events you receive.",
|
"description": "Control which notification events you receive.",
|
||||||
"webPush": {
|
"webPush": {
|
||||||
"title": "Web Push Notifications",
|
"title": "Notify this browser",
|
||||||
"enable": "Enable Push Notifications",
|
"enable": "Enable notifications",
|
||||||
"disable": "Disable Push Notifications",
|
"disable": "Disable notifications",
|
||||||
"enabled": "Push notifications are enabled",
|
"enabled": "Notifications are enabled for this browser",
|
||||||
"loading": "Updating...",
|
"loading": "Updating...",
|
||||||
"unsupported": "Push notifications are not supported in this browser.",
|
"unsupported": "Push notifications are not supported in this browser.",
|
||||||
"denied": "Push notifications are blocked. Please allow them in your browser settings."
|
"denied": "Push notifications are blocked. Please allow them in your browser settings."
|
||||||
},
|
},
|
||||||
|
"desktop": {
|
||||||
|
"title": "Notify this desktop app",
|
||||||
|
"enable": "Enable notifications",
|
||||||
|
"disable": "Disable notifications",
|
||||||
|
"enabled": "Notifications are enabled for this desktop app",
|
||||||
|
"unsupported": "Desktop notifications are not supported on this system."
|
||||||
|
},
|
||||||
"sound": {
|
"sound": {
|
||||||
"title": "Sound",
|
"title": "Sound",
|
||||||
"description": "Play a short tone when a chat run finishes or needs tool approval.",
|
"description": "Play a short tone when a chat run finishes or needs tool approval.",
|
||||||
|
|||||||
@@ -22,7 +22,9 @@
|
|||||||
"shell": "Terminal",
|
"shell": "Terminal",
|
||||||
"files": "Fichiers",
|
"files": "Fichiers",
|
||||||
"git": "Contrôle de source",
|
"git": "Contrôle de source",
|
||||||
"tasks": "Tâches"
|
"tasks": "Tâches",
|
||||||
|
"browser": "Navigateur",
|
||||||
|
"computer": "Ordinateur"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Chargement...",
|
"loading": "Chargement...",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user