mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-04 20:42:57 +08:00
Compare commits
4 Commits
v1.35.1
...
feat/pendi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8298e5df5 | ||
|
|
43d3269b38 | ||
|
|
16be1d0f7b | ||
|
|
63a4869325 |
109
.github/workflows/desktop-macos-branch-build.yml
vendored
109
.github/workflows/desktop-macos-branch-build.yml
vendored
@@ -1,109 +0,0 @@
|
|||||||
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
305
.github/workflows/desktop-release.yml
vendored
@@ -1,305 +0,0 @@
|
|||||||
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/*
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
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
|
|
||||||
14
.github/workflows/release.yml
vendored
14
.github/workflows/release.yml
vendored
@@ -4,21 +4,15 @@ 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:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -26,12 +20,12 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.RELEASE_PAT }}
|
token: ${{ secrets.RELEASE_PAT }}
|
||||||
|
|
||||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
registry-url: https://registry.npmjs.org
|
registry-url: https://registry.npmjs.org
|
||||||
|
|||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -143,11 +143,3 @@ 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
|
|
||||||
|
|||||||
65
CHANGELOG.md
65
CHANGELOG.md
@@ -3,71 +3,6 @@
|
|||||||
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.1](https://github.com/siteboon/claudecodeui/compare/v1.35.0...v1.35.1) (2026-07-01)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* preview video on new tab ([#933](https://github.com/siteboon/claudecodeui/issues/933)) ([2ebe64f](https://github.com/siteboon/claudecodeui/commit/2ebe64f21874f45f6c8747310be874ae7342c61c))
|
|
||||||
* remove obsolete semantic helper release jobs ([1e16f1f](https://github.com/siteboon/claudecodeui/commit/1e16f1f0854e347aa333434638d64f2b167d9a9d))
|
|
||||||
* resolve mobile shell issues ([#923](https://github.com/siteboon/claudecodeui/issues/923)) ([b6cf333](https://github.com/siteboon/claudecodeui/commit/b6cf33308da996f8169580a4b5b74e3c5f38e447))
|
|
||||||
|
|
||||||
### Maintenance
|
|
||||||
|
|
||||||
* remove computer use ([6761f31](https://github.com/siteboon/claudecodeui/commit/6761f31a56fe82d82c7e0c079b4891e7d5a81817))
|
|
||||||
|
|
||||||
## [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
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -59,7 +59,6 @@
|
|||||||
- **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
|
||||||
@@ -74,6 +73,7 @@ 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)**
|
||||||
|
|
||||||
|
|
||||||
### Self-Hosted (Open source)
|
### Self-Hosted (Open source)
|
||||||
|
|
||||||
#### npm
|
#### npm
|
||||||
@@ -105,16 +105,6 @@ npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
|
|||||||
|
|
||||||
Supports Claude Code, Codex, and Gemini CLI. See the [sandbox docs](docker/) for setup and advanced options.
|
Supports Claude Code, Codex, and Gemini CLI. See the [sandbox docs](docker/) for setup and advanced options.
|
||||||
|
|
||||||
### Desktop Companion App
|
|
||||||
|
|
||||||
CloudCLI Desktop is an optional native companion for CloudCLI Cloud and Local CloudCLI. It ships from this repository's GitHub Releases and keeps CloudCLI available from your menu bar or tray.
|
|
||||||
|
|
||||||
- **[macOS](https://cloudcli.ai/download/macos)**
|
|
||||||
- **[Windows](https://cloudcli.ai/download/windows)**
|
|
||||||
- **[Download page](https://cloudcli.ai/download)** · **[GitHub Releases and checksums](https://github.com/siteboon/claudecodeui/releases)**
|
|
||||||
|
|
||||||
Use it to open CloudCLI Cloud environments, switch between local and remote workspaces, and copy mobile/browser URLs. To work locally, choose **Local CloudCLI** in the desktop app; it will use your running local server or start one for you.
|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -129,8 +119,7 @@ CloudCLI UI is the open source UI layer that powers CloudCLI Cloud. You can self
|
|||||||
| **Setup** | `npx @cloudcli-ai/cloudcli` | `npx @cloudcli-ai/cloudcli@latest sandbox ~/project` | No setup required |
|
| **Setup** | `npx @cloudcli-ai/cloudcli` | `npx @cloudcli-ai/cloudcli@latest sandbox ~/project` | No setup required |
|
||||||
| **Isolation** | Runs on your host | Hypervisor-level sandbox (microVM) | Full cloud isolation |
|
| **Isolation** | Runs on your host | Hypervisor-level sandbox (microVM) | Full cloud isolation |
|
||||||
| **Machine needs to stay on** | Yes | Yes | No |
|
| **Machine needs to stay on** | Yes | Yes | No |
|
||||||
| **Mobile access** | Any browser on your network | Any browser on your network | Any device |
|
| **Mobile access** | Any browser on your network | Any browser on your network | Any device, native app coming |
|
||||||
| **Desktop companion** | Optional. Choose Local CloudCLI | Optional. Choose Local CloudCLI | Optional. Opens cloud environments |
|
|
||||||
| **Agents supported** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI |
|
| **Agents supported** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI |
|
||||||
| **File explorer and Git** | Yes | Yes | Yes |
|
| **File explorer and Git** | Yes | Yes | Yes |
|
||||||
| **MCP configuration** | Synced with `~/.claude` | Managed via UI | Managed via UI |
|
| **MCP configuration** | Synced with `~/.claude` | Managed via UI | Managed via UI |
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,260 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,378 +0,0 @@
|
|||||||
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?.();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,766 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,801 +0,0 @@
|
|||||||
* {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,687 +0,0 @@
|
|||||||
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();
|
|
||||||
})();
|
|
||||||
@@ -1,550 +0,0 @@
|
|||||||
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
944
electron/main.js
@@ -1,944 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
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));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
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));
|
|
||||||
@@ -1,277 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
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,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,331 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
12
index.html
12
index.html
@@ -4,17 +4,9 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
<title>CloudCLI UI</title>
|
<title>CloudCLI UI</title>
|
||||||
|
|
||||||
<!-- Fonts: Encode Sans (UI) + Merriweather (chat) -->
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
||||||
<link
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Encode+Sans:wght@400;500;600;700&family=Merriweather:ital,wght@0,400;0,700;1,400;1,700&display=swap"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- PWA Manifest -->
|
<!-- PWA Manifest -->
|
||||||
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
|
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
|
||||||
|
|
||||||
|
|||||||
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.35.1",
|
"version": "1.34.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,12 +34,8 @@
|
|||||||
"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:stage": "node scripts/release/prepare-desktop-app.js",
|
"desktop:pack": "npm run build && electron-builder --dir",
|
||||||
"desktop:pack": "npm run build && npm run desktop:stage && electron-builder --projectDir .desktop-build/desktop-app --dir",
|
"desktop:dist:mac": "npm run build && electron-builder --mac dmg zip",
|
||||||
"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 })\"",
|
||||||
@@ -58,10 +54,9 @@
|
|||||||
"build": {
|
"build": {
|
||||||
"appId": "ai.cloudcli.desktop",
|
"appId": "ai.cloudcli.desktop",
|
||||||
"productName": "CloudCLI",
|
"productName": "CloudCLI",
|
||||||
"asar": false,
|
"artifactName": "CloudCLI-${version}-${arch}.${ext}",
|
||||||
"artifactName": "cloudcli-desktop-${version}-${os}-${arch}.${ext}",
|
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "release/desktop"
|
"output": "release"
|
||||||
},
|
},
|
||||||
"extraMetadata": {
|
"extraMetadata": {
|
||||||
"main": "electron/main.js"
|
"main": "electron/main.js"
|
||||||
@@ -73,8 +68,7 @@
|
|||||||
"dist-server/",
|
"dist-server/",
|
||||||
"shared/",
|
"shared/",
|
||||||
"server/",
|
"server/",
|
||||||
"package.json",
|
"package.json"
|
||||||
"!**/node_modules/@anthropic-ai/claude-agent-sdk-{darwin,linux,win32}-*/**"
|
|
||||||
],
|
],
|
||||||
"protocols": [
|
"protocols": [
|
||||||
{
|
{
|
||||||
@@ -86,10 +80,9 @@
|
|||||||
],
|
],
|
||||||
"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",
|
||||||
@@ -103,16 +96,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"win": {
|
|
||||||
"icon": "electron/assets/logo-windows.ico",
|
|
||||||
"target": [
|
|
||||||
"nsis"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nsis": {
|
|
||||||
"installerIcon": "electron/assets/logo-windows.ico",
|
|
||||||
"uninstallerIcon": "electron/assets/logo-windows.ico"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -147,7 +130,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.141.0",
|
"@openai/codex-sdk": "^0.125.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",
|
||||||
@@ -240,9 +223,5 @@
|
|||||||
"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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,176 +0,0 @@
|
|||||||
#!/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)}`);
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
#!/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,12 +57,10 @@ 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';
|
||||||
@@ -203,8 +201,6 @@ 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);
|
||||||
|
|
||||||
@@ -226,8 +222,6 @@ 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')));
|
||||||
|
|
||||||
@@ -1685,40 +1679,6 @@ 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() {
|
||||||
@@ -1745,9 +1705,6 @@ 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)));
|
||||||
@@ -1781,11 +1738,6 @@ 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.error('No .env file found or error reading it:', e.message);
|
console.log('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,7 +4,6 @@ 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,7 +3,6 @@ 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,
|
||||||
@@ -441,9 +440,6 @@ 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);
|
||||||
|
|||||||
@@ -1,153 +0,0 @@
|
|||||||
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,9 +10,7 @@ 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;
|
||||||
@@ -25,7 +23,6 @@ const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
|
|||||||
channels: {
|
channels: {
|
||||||
inApp: false,
|
inApp: false,
|
||||||
webPush: false,
|
webPush: false,
|
||||||
desktop: false,
|
|
||||||
sound: true,
|
sound: true,
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
@@ -37,20 +34,11 @@ 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: {
|
||||||
@@ -115,3 +103,4 @@ export const notificationPreferencesDb = {
|
|||||||
return notificationPreferencesDb.updateNotificationPreferences(userId, preferences);
|
return notificationPreferencesDb.updateNotificationPreferences(userId, preferences);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -69,23 +69,6 @@ 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,
|
||||||
@@ -161,10 +144,6 @@ ${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.
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
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';
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -1,288 +0,0 @@
|
|||||||
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
|
|
||||||
};
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
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,17 +430,6 @@ 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,7 +3,6 @@ import type {
|
|||||||
ProviderSkill,
|
ProviderSkill,
|
||||||
ProviderSkillCreateInput,
|
ProviderSkillCreateInput,
|
||||||
ProviderSkillListOptions,
|
ProviderSkillListOptions,
|
||||||
ProviderSkillRemoveInput,
|
|
||||||
} from '@/shared/types.js';
|
} from '@/shared/types.js';
|
||||||
|
|
||||||
export const providerSkillsService = {
|
export const providerSkillsService = {
|
||||||
@@ -28,12 +27,4 @@ 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,11 +1,10 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { mkdir, rm, stat, writeFile } from 'node:fs/promises';
|
import { mkdir, rm, 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,
|
||||||
@@ -237,48 +236,6 @@ 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,19 +662,6 @@ 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: [
|
||||||
@@ -714,11 +701,4 @@ 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,7 +6,6 @@ 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 = {
|
||||||
@@ -64,11 +63,6 @@ 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,11 +1,6 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
|
||||||
import {
|
import { apiKeysDb, credentialsDb, notificationPreferencesDb, pushSubscriptionsDb } from '../modules/database/index.js';
|
||||||
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,7 +1,268 @@
|
|||||||
|
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,7 +13,6 @@ import type {
|
|||||||
ProviderMcpServer,
|
ProviderMcpServer,
|
||||||
ProviderSessionActiveModelChange,
|
ProviderSessionActiveModelChange,
|
||||||
ProviderSkillCreateInput,
|
ProviderSkillCreateInput,
|
||||||
ProviderSkillRemoveInput,
|
|
||||||
UpsertProviderMcpServerInput,
|
UpsertProviderMcpServerInput,
|
||||||
} from '@/shared/types.js';
|
} from '@/shared/types.js';
|
||||||
|
|
||||||
@@ -112,10 +111,6 @@ 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,10 +361,6 @@ 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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,224 +0,0 @@
|
|||||||
// 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;
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import { AlertCircle } from 'lucide-react';
|
|
||||||
|
|
||||||
type AuthErrorAlertProps = {
|
type AuthErrorAlertProps = {
|
||||||
errorMessage: string;
|
errorMessage: string;
|
||||||
};
|
};
|
||||||
@@ -10,12 +8,8 @@ export default function AuthErrorAlert({ errorMessage }: AuthErrorAlertProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="rounded-md border border-red-300 bg-red-100 p-3 dark:border-red-800 dark:bg-red-900/20">
|
||||||
role="alert"
|
<p className="text-sm text-red-700 dark:text-red-400">{errorMessage}</p>
|
||||||
className="flex items-start gap-2.5 rounded-xl border border-destructive/30 bg-destructive/10 p-3 text-destructive"
|
|
||||||
>
|
|
||||||
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
|
||||||
<p className="text-sm leading-relaxed">{errorMessage}</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import type { ComponentType } from 'react';
|
|
||||||
import { Eye, EyeOff } from 'lucide-react';
|
|
||||||
|
|
||||||
type AuthInputFieldProps = {
|
type AuthInputFieldProps = {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -12,14 +8,13 @@ type AuthInputFieldProps = {
|
|||||||
type?: 'text' | 'password' | 'email';
|
type?: 'text' | 'password' | 'email';
|
||||||
name?: string;
|
name?: string;
|
||||||
autoComplete?: string;
|
autoComplete?: string;
|
||||||
icon?: ComponentType<{ className?: string }>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A labelled input field for authentication forms.
|
* A labelled input field for authentication forms.
|
||||||
* Renders a `<label>` / `<input>` pair and forwards browser autofill hints
|
* Renders a `<label>` / `<input>` pair and forwards browser autofill hints
|
||||||
* (`name`, `autoComplete`) so that password managers can identify and fill
|
* (`name`, `autoComplete`) so that password managers can identify and fill
|
||||||
* the field correctly. Password fields gain a show/hide visibility toggle.
|
* the field correctly.
|
||||||
*/
|
*/
|
||||||
export default function AuthInputField({
|
export default function AuthInputField({
|
||||||
id,
|
id,
|
||||||
@@ -31,48 +26,24 @@ export default function AuthInputField({
|
|||||||
type = 'text',
|
type = 'text',
|
||||||
name,
|
name,
|
||||||
autoComplete,
|
autoComplete,
|
||||||
icon: Icon,
|
|
||||||
}: AuthInputFieldProps) {
|
}: AuthInputFieldProps) {
|
||||||
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
|
||||||
|
|
||||||
const isPasswordField = type === 'password';
|
|
||||||
const resolvedType = isPasswordField && isPasswordVisible ? 'text' : type;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor={id} className="mb-1.5 block text-sm font-medium text-foreground">
|
<label htmlFor={id} className="mb-1 block text-sm font-medium text-foreground">
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
<div className="group relative">
|
<input
|
||||||
{Icon && (
|
id={id}
|
||||||
<Icon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground transition-colors group-focus-within:text-primary" />
|
type={type}
|
||||||
)}
|
name={name ?? id}
|
||||||
<input
|
autoComplete={autoComplete}
|
||||||
id={id}
|
value={value}
|
||||||
type={resolvedType}
|
onChange={(event) => onChange(event.target.value)}
|
||||||
name={name ?? id}
|
className="w-full rounded-md border border-border bg-background px-3 py-2 text-foreground focus:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
autoComplete={autoComplete}
|
placeholder={placeholder}
|
||||||
value={value}
|
required
|
||||||
onChange={(event) => onChange(event.target.value)}
|
disabled={isDisabled}
|
||||||
className={`w-full rounded-xl border border-border bg-background/60 py-2.5 text-foreground shadow-sm transition-colors placeholder:text-muted-foreground/60 hover:border-foreground/20 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-60 ${
|
/>
|
||||||
Icon ? 'pl-10' : 'pl-3.5'
|
|
||||||
} ${isPasswordField ? 'pr-11' : 'pr-3.5'}`}
|
|
||||||
placeholder={placeholder}
|
|
||||||
required
|
|
||||||
disabled={isDisabled}
|
|
||||||
/>
|
|
||||||
{isPasswordField && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsPasswordVisible((previous) => !previous)}
|
|
||||||
disabled={isDisabled}
|
|
||||||
aria-label={isPasswordVisible ? 'Hide password' : 'Show password'}
|
|
||||||
className="absolute right-2 top-1/2 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{isPasswordVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,30 @@
|
|||||||
import { CLOUDCLI_WORDMARK_FONT_FAMILY } from '../../../constants/branding';
|
import { MessageSquare } from 'lucide-react';
|
||||||
|
|
||||||
const loadingDotAnimationDelays = ['0s', '0.15s', '0.3s'];
|
const loadingDotAnimationDelays = ['0s', '0.1s', '0.2s'];
|
||||||
|
|
||||||
export default function AuthLoadingScreen() {
|
export default function AuthLoadingScreen() {
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background p-4">
|
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||||
<div aria-hidden className="pointer-events-none absolute inset-0">
|
<div className="text-center">
|
||||||
<div className="absolute -top-40 left-1/2 h-[36rem] w-[36rem] -translate-x-1/2 rounded-full bg-primary/10 blur-3xl" />
|
<div className="mb-4 flex justify-center">
|
||||||
</div>
|
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-primary shadow-sm">
|
||||||
|
<MessageSquare className="h-8 w-8 text-primary-foreground" />
|
||||||
<div className="relative text-center" role="status" aria-live="polite">
|
|
||||||
<div className="mb-5 flex justify-center">
|
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary to-primary/80 shadow-lg shadow-primary/25 ring-1 ring-inset ring-white/20">
|
|
||||||
<img src="/logo.svg" alt="CloudCLI" className="h-9 w-9" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1
|
<h1 className="mb-2 text-2xl font-bold text-foreground">CloudCLI</h1>
|
||||||
className="mb-4 text-2xl font-bold tracking-tight text-foreground"
|
|
||||||
style={{ fontFamily: CLOUDCLI_WORDMARK_FONT_FAMILY }}
|
<div className="flex items-center justify-center space-x-2">
|
||||||
>
|
|
||||||
CloudCLI
|
|
||||||
</h1>
|
|
||||||
<p className="sr-only">Loading authentication state…</p>
|
|
||||||
<div aria-hidden className="flex items-center justify-center gap-2">
|
|
||||||
{loadingDotAnimationDelays.map((delay) => (
|
{loadingDotAnimationDelays.map((delay) => (
|
||||||
<div
|
<div
|
||||||
key={delay}
|
key={delay}
|
||||||
className="h-2 w-2 animate-bounce rounded-full bg-primary"
|
className="h-2 w-2 animate-bounce rounded-full bg-blue-500"
|
||||||
style={{ animationDelay: delay }}
|
style={{ animationDelay: delay }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-2 text-muted-foreground">Loading...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
import { MessageSquare } from 'lucide-react';
|
||||||
import { IS_PLATFORM } from '../../../constants/config';
|
import { IS_PLATFORM } from '../../../constants/config';
|
||||||
|
|
||||||
type AuthScreenLayoutProps = {
|
type AuthScreenLayoutProps = {
|
||||||
@@ -17,38 +18,29 @@ export default function AuthScreenLayout({
|
|||||||
logo,
|
logo,
|
||||||
}: AuthScreenLayoutProps) {
|
}: AuthScreenLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<div className="relative h-screen overflow-y-auto bg-background">
|
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||||
{/* Ambient, on-brand backdrop that gives the screen depth without
|
<div className="w-full max-w-md">
|
||||||
competing with the card content. Fixed so it stays put while the
|
<div className="space-y-6 rounded-lg border border-border bg-card p-8 shadow-lg">
|
||||||
form scrolls on short viewports. */}
|
|
||||||
<div aria-hidden className="pointer-events-none fixed inset-0">
|
|
||||||
<div className="absolute -top-40 left-1/2 h-[36rem] w-[36rem] -translate-x-1/2 rounded-full bg-primary/10 blur-3xl" />
|
|
||||||
<div className="absolute -bottom-32 -left-24 h-[26rem] w-[26rem] rounded-full bg-primary/5 blur-3xl" />
|
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(hsl(var(--foreground)/0.04)_1px,transparent_1px)] [background-size:22px_22px] opacity-60" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative mx-auto flex min-h-full w-full max-w-md items-center justify-center p-4 py-8">
|
|
||||||
<div className="w-full rounded-2xl border border-border/70 bg-card/90 p-8 shadow-[0_24px_60px_-20px_hsl(var(--foreground)/0.18)] ring-1 ring-foreground/5 backdrop-blur-xl sm:p-10">
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mb-5 flex justify-center">
|
<div className="mb-4 flex justify-center">
|
||||||
{logo ?? (
|
{logo ?? (
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary to-primary/80 shadow-lg shadow-primary/25 ring-1 ring-inset ring-white/20">
|
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-primary shadow-sm">
|
||||||
<img src="/logo.svg" alt="CloudCLI" className="h-9 w-9" />
|
<MessageSquare className="h-8 w-8 text-primary-foreground" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="font-serif text-3xl font-bold tracking-tight text-foreground">{title}</h1>
|
<h1 className="text-2xl font-bold text-foreground">{title}</h1>
|
||||||
<p className="mx-auto mt-2 max-w-xs text-sm leading-relaxed text-muted-foreground">{description}</p>
|
<p className="mt-2 text-muted-foreground">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8">{children}</div>
|
{children}
|
||||||
|
|
||||||
<div className="mt-6 border-t border-border/60 pt-5 text-center">
|
<div className="text-center">
|
||||||
<p className="text-xs leading-relaxed text-muted-foreground">{footerText}</p>
|
<p className="text-sm text-muted-foreground">{footerText}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!IS_PLATFORM && (
|
{!IS_PLATFORM && (
|
||||||
<div className="mt-4 flex items-center justify-center gap-1.5">
|
<div className="flex items-center justify-center gap-1.5 pt-2">
|
||||||
<svg className="h-3.5 w-3.5 text-muted-foreground/50" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
<svg className="h-3.5 w-3.5 text-muted-foreground/50" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
|
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import type { FormEvent } from 'react';
|
import type { FormEvent } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Loader2, Lock, User } from 'lucide-react';
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import AuthErrorAlert from './AuthErrorAlert';
|
import AuthErrorAlert from './AuthErrorAlert';
|
||||||
import AuthInputField from './AuthInputField';
|
import AuthInputField from './AuthInputField';
|
||||||
@@ -70,7 +69,6 @@ export default function LoginForm() {
|
|||||||
placeholder={t('login.placeholders.username')}
|
placeholder={t('login.placeholders.username')}
|
||||||
isDisabled={isSubmitting}
|
isDisabled={isSubmitting}
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
icon={User}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AuthInputField
|
<AuthInputField
|
||||||
@@ -82,7 +80,6 @@ export default function LoginForm() {
|
|||||||
isDisabled={isSubmitting}
|
isDisabled={isSubmitting}
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
icon={Lock}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AuthErrorAlert errorMessage={errorMessage} />
|
<AuthErrorAlert errorMessage={errorMessage} />
|
||||||
@@ -90,16 +87,9 @@ export default function LoginForm() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="flex w-full items-center justify-center gap-2 rounded-xl bg-primary px-4 py-2.5 font-medium text-primary-foreground shadow-lg shadow-primary/25 transition-all duration-200 hover:brightness-110 hover:shadow-primary/30 focus:outline-none focus:ring-2 focus:ring-primary/40 focus:ring-offset-2 focus:ring-offset-card active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60"
|
className="w-full rounded-md bg-blue-600 px-4 py-2 font-medium text-white transition-colors duration-200 hover:bg-blue-700 disabled:bg-blue-400"
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? t('login.loading') : t('login.submit')}
|
||||||
<>
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
{t('login.loading')}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
t('login.submit')
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</AuthScreenLayout>
|
</AuthScreenLayout>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import type { FormEvent } from 'react';
|
import type { FormEvent } from 'react';
|
||||||
import { Loader2, Lock, ShieldCheck, User } from 'lucide-react';
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import AuthErrorAlert from './AuthErrorAlert';
|
import AuthErrorAlert from './AuthErrorAlert';
|
||||||
import AuthInputField from './AuthInputField';
|
import AuthInputField from './AuthInputField';
|
||||||
@@ -86,6 +85,7 @@ export default function SetupForm() {
|
|||||||
title="Welcome to CloudCLI"
|
title="Welcome to CloudCLI"
|
||||||
description="Set up your account to get started"
|
description="Set up your account to get started"
|
||||||
footerText="This is a single-user system. Only one account can be created."
|
footerText="This is a single-user system. Only one account can be created."
|
||||||
|
logo={<img src="/logo.svg" alt="CloudCLI" className="h-16 w-16" />}
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<AuthInputField
|
<AuthInputField
|
||||||
@@ -94,10 +94,9 @@ export default function SetupForm() {
|
|||||||
label="Username"
|
label="Username"
|
||||||
value={formState.username}
|
value={formState.username}
|
||||||
onChange={(value) => updateField('username', value)}
|
onChange={(value) => updateField('username', value)}
|
||||||
placeholder="Choose a username"
|
placeholder="Enter your username"
|
||||||
isDisabled={isSubmitting}
|
isDisabled={isSubmitting}
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
icon={User}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AuthInputField
|
<AuthInputField
|
||||||
@@ -106,11 +105,10 @@ export default function SetupForm() {
|
|||||||
label="Password"
|
label="Password"
|
||||||
value={formState.password}
|
value={formState.password}
|
||||||
onChange={(value) => updateField('password', value)}
|
onChange={(value) => updateField('password', value)}
|
||||||
placeholder="Create a password"
|
placeholder="Enter your password"
|
||||||
isDisabled={isSubmitting}
|
isDisabled={isSubmitting}
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
icon={Lock}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AuthInputField
|
<AuthInputField
|
||||||
@@ -119,33 +117,20 @@ export default function SetupForm() {
|
|||||||
label="Confirm Password"
|
label="Confirm Password"
|
||||||
value={formState.confirmPassword}
|
value={formState.confirmPassword}
|
||||||
onChange={(value) => updateField('confirmPassword', value)}
|
onChange={(value) => updateField('confirmPassword', value)}
|
||||||
placeholder="Re-enter your password"
|
placeholder="Confirm your password"
|
||||||
isDisabled={isSubmitting}
|
isDisabled={isSubmitting}
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
icon={ShieldCheck}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
||||||
<ShieldCheck className="h-3.5 w-3.5" />
|
|
||||||
At least 3 characters for username, 6 for password.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<AuthErrorAlert errorMessage={errorMessage} />
|
<AuthErrorAlert errorMessage={errorMessage} />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="flex w-full items-center justify-center gap-2 rounded-xl bg-primary px-4 py-2.5 font-medium text-primary-foreground shadow-lg shadow-primary/25 transition-all duration-200 hover:brightness-110 hover:shadow-primary/30 focus:outline-none focus:ring-2 focus:ring-primary/40 focus:ring-offset-2 focus:ring-offset-card active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60"
|
className="w-full rounded-md bg-blue-600 px-4 py-2 font-medium text-white transition-colors duration-200 hover:bg-blue-700 disabled:bg-blue-400"
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? 'Setting up...' : 'Create Account'}
|
||||||
<>
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
Setting up...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'Create Account'
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</AuthScreenLayout>
|
</AuthScreenLayout>
|
||||||
|
|||||||
@@ -204,8 +204,6 @@ 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);
|
||||||
@@ -459,22 +457,6 @@ 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 {
|
||||||
@@ -793,17 +775,6 @@ 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]);
|
||||||
@@ -835,13 +806,13 @@ export function useChatComposerState({
|
|||||||
if (!textareaRef.current) {
|
if (!textareaRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (lastAutosizedInputRef.current === input) {
|
// Re-run when input changes so restored drafts get the same autosize behavior as typed text.
|
||||||
return;
|
textareaRef.current.style.height = 'auto';
|
||||||
}
|
textareaRef.current.style.height = `${Math.max(22, textareaRef.current.scrollHeight)}px`;
|
||||||
// Re-run for restored drafts and programmatic input changes. User typing is
|
const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight);
|
||||||
// already resized in onInput, so this avoids doing the same forced layout twice.
|
const expanded = textareaRef.current.scrollHeight > lineHeight * 2;
|
||||||
resizeTextarea(textareaRef.current);
|
setIsTextareaExpanded(expanded);
|
||||||
}, [input, resizeTextarea]);
|
}, [input]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!textareaRef.current || input.trim()) {
|
if (!textareaRef.current || input.trim()) {
|
||||||
@@ -923,11 +894,15 @@ export function useChatComposerState({
|
|||||||
const handleTextareaInput = useCallback(
|
const handleTextareaInput = useCallback(
|
||||||
(event: FormEvent<HTMLTextAreaElement>) => {
|
(event: FormEvent<HTMLTextAreaElement>) => {
|
||||||
const target = event.currentTarget;
|
const target = event.currentTarget;
|
||||||
resizeTextarea(target);
|
target.style.height = 'auto';
|
||||||
|
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);
|
||||||
},
|
},
|
||||||
[resizeTextarea, setCursorPosition, syncInputOverlayScroll],
|
[setCursorPosition, syncInputOverlayScroll],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleClearInput = useCallback(() => {
|
const handleClearInput = useCallback(() => {
|
||||||
@@ -1038,7 +1013,6 @@ export function useChatComposerState({
|
|||||||
isDragActive,
|
isDragActive,
|
||||||
openImagePicker: open,
|
openImagePicker: open,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
handleVoiceTranscript,
|
|
||||||
handleInputChange,
|
handleInputChange,
|
||||||
handleKeyDown,
|
handleKeyDown,
|
||||||
handlePaste,
|
handlePaste,
|
||||||
|
|||||||
@@ -207,15 +207,6 @@ 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;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface UseChatSessionStateArgs {
|
|||||||
selectedSession: ProjectSession | null;
|
selectedSession: ProjectSession | null;
|
||||||
ws: WebSocket | null;
|
ws: WebSocket | null;
|
||||||
sendMessage: (message: unknown) => void;
|
sendMessage: (message: unknown) => void;
|
||||||
|
autoScrollToBottom?: boolean;
|
||||||
externalMessageUpdate?: number;
|
externalMessageUpdate?: number;
|
||||||
newSessionTrigger?: number;
|
newSessionTrigger?: number;
|
||||||
processingSessions?: SessionActivityMap;
|
processingSessions?: SessionActivityMap;
|
||||||
@@ -95,6 +96,7 @@ export function useChatSessionState({
|
|||||||
selectedSession,
|
selectedSession,
|
||||||
ws,
|
ws,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
|
autoScrollToBottom,
|
||||||
externalMessageUpdate,
|
externalMessageUpdate,
|
||||||
newSessionTrigger,
|
newSessionTrigger,
|
||||||
processingSessions,
|
processingSessions,
|
||||||
@@ -119,7 +121,6 @@ export function useChatSessionState({
|
|||||||
const [viewHiddenCount, setViewHiddenCount] = useState(0);
|
const [viewHiddenCount, setViewHiddenCount] = useState(0);
|
||||||
|
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const wasNearTopRef = useRef(false);
|
|
||||||
const [searchTarget, setSearchTarget] = useState<{ timestamp?: string; uuid?: string; snippet?: string } | null>(null);
|
const [searchTarget, setSearchTarget] = useState<{ timestamp?: string; uuid?: string; snippet?: string } | null>(null);
|
||||||
const searchScrollActiveRef = useRef(false);
|
const searchScrollActiveRef = useRef(false);
|
||||||
const isLoadingSessionRef = useRef(false);
|
const isLoadingSessionRef = useRef(false);
|
||||||
@@ -184,7 +185,6 @@ export function useChatSessionState({
|
|||||||
setShowLoadAllOverlay(false);
|
setShowLoadAllOverlay(false);
|
||||||
setViewHiddenCount(0);
|
setViewHiddenCount(0);
|
||||||
setSearchTarget(null);
|
setSearchTarget(null);
|
||||||
wasNearTopRef.current = false;
|
|
||||||
searchScrollActiveRef.current = false;
|
searchScrollActiveRef.current = false;
|
||||||
topLoadLockRef.current = false;
|
topLoadLockRef.current = false;
|
||||||
pendingScrollRestoreRef.current = null;
|
pendingScrollRestoreRef.current = null;
|
||||||
@@ -336,34 +336,12 @@ export function useChatSessionState({
|
|||||||
const slot = await sessionStore.fetchMore(selectedSession.id, {
|
const slot = await sessionStore.fetchMore(selectedSession.id, {
|
||||||
limit: MESSAGES_PER_PAGE,
|
limit: MESSAGES_PER_PAGE,
|
||||||
});
|
});
|
||||||
if (!slot) return false;
|
if (!slot || slot.serverMessages.length === 0) return false;
|
||||||
if (slot.serverMessages.length === 0) {
|
|
||||||
if (!slot.hasMore) {
|
|
||||||
setHasMoreMessages(false);
|
|
||||||
allMessagesLoadedRef.current = true;
|
|
||||||
setAllMessagesLoaded(true);
|
|
||||||
if (loadAllOverlayTimerRef.current) {
|
|
||||||
clearTimeout(loadAllOverlayTimerRef.current);
|
|
||||||
loadAllOverlayTimerRef.current = null;
|
|
||||||
}
|
|
||||||
setShowLoadAllOverlay(false);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
pendingScrollRestoreRef.current = { height: previousScrollHeight, top: previousScrollTop };
|
pendingScrollRestoreRef.current = { height: previousScrollHeight, top: previousScrollTop };
|
||||||
setHasMoreMessages(slot.hasMore);
|
setHasMoreMessages(slot.hasMore);
|
||||||
setTotalMessages(slot.total);
|
setTotalMessages(slot.total);
|
||||||
setVisibleMessageCount((prev) => prev + MESSAGES_PER_PAGE);
|
setVisibleMessageCount((prev) => prev + MESSAGES_PER_PAGE);
|
||||||
if (!slot.hasMore) {
|
|
||||||
allMessagesLoadedRef.current = true;
|
|
||||||
setAllMessagesLoaded(true);
|
|
||||||
if (loadAllOverlayTimerRef.current) {
|
|
||||||
clearTimeout(loadAllOverlayTimerRef.current);
|
|
||||||
loadAllOverlayTimerRef.current = null;
|
|
||||||
}
|
|
||||||
setShowLoadAllOverlay(false);
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
} finally {
|
} finally {
|
||||||
isLoadingMoreRef.current = false;
|
isLoadingMoreRef.current = false;
|
||||||
@@ -379,25 +357,8 @@ export function useChatSessionState({
|
|||||||
const nearBottom = isNearBottom();
|
const nearBottom = isNearBottom();
|
||||||
setIsUserScrolledUp(!nearBottom);
|
setIsUserScrolledUp(!nearBottom);
|
||||||
|
|
||||||
const scrolledNearTop = container.scrollTop < 100;
|
|
||||||
|
|
||||||
// "Load all" prompt: appear (with fade-in) when the user reaches the top
|
|
||||||
if (scrolledNearTop && hasMoreMessages && !allMessagesLoadedRef.current) {
|
|
||||||
if (!wasNearTopRef.current) {
|
|
||||||
wasNearTopRef.current = true;
|
|
||||||
if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current);
|
|
||||||
|
|
||||||
setShowLoadAllOverlay(true);
|
|
||||||
loadAllOverlayTimerRef.current = setTimeout(() => {
|
|
||||||
setShowLoadAllOverlay(false);
|
|
||||||
loadAllOverlayTimerRef.current = null;
|
|
||||||
}, 2500);
|
|
||||||
}
|
|
||||||
} else if (!scrolledNearTop) {
|
|
||||||
wasNearTopRef.current = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!allMessagesLoadedRef.current) {
|
if (!allMessagesLoadedRef.current) {
|
||||||
|
const scrolledNearTop = container.scrollTop < 100;
|
||||||
if (!scrolledNearTop) { topLoadLockRef.current = false; return; }
|
if (!scrolledNearTop) { topLoadLockRef.current = false; return; }
|
||||||
if (topLoadLockRef.current) {
|
if (topLoadLockRef.current) {
|
||||||
if (container.scrollTop > 20) topLoadLockRef.current = false;
|
if (container.scrollTop > 20) topLoadLockRef.current = false;
|
||||||
@@ -406,7 +367,7 @@ export function useChatSessionState({
|
|||||||
const didLoad = await loadOlderMessages(container);
|
const didLoad = await loadOlderMessages(container);
|
||||||
if (didLoad) topLoadLockRef.current = true;
|
if (didLoad) topLoadLockRef.current = true;
|
||||||
}
|
}
|
||||||
}, [hasMoreMessages, isNearBottom, loadOlderMessages]);
|
}, [isNearBottom, loadOlderMessages]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!pendingScrollRestoreRef.current || !scrollContainerRef.current) return;
|
if (!pendingScrollRestoreRef.current || !scrollContainerRef.current) return;
|
||||||
@@ -425,7 +386,6 @@ export function useChatSessionState({
|
|||||||
}
|
}
|
||||||
topLoadLockRef.current = false;
|
topLoadLockRef.current = false;
|
||||||
pendingScrollRestoreRef.current = null;
|
pendingScrollRestoreRef.current = null;
|
||||||
wasNearTopRef.current = false;
|
|
||||||
setIsUserScrolledUp(false);
|
setIsUserScrolledUp(false);
|
||||||
}, [selectedProject?.projectId, selectedSession?.id]);
|
}, [selectedProject?.projectId, selectedSession?.id]);
|
||||||
|
|
||||||
@@ -532,7 +492,6 @@ export function useChatSessionState({
|
|||||||
setLoadAllJustFinished(false);
|
setLoadAllJustFinished(false);
|
||||||
setShowLoadAllOverlay(false);
|
setShowLoadAllOverlay(false);
|
||||||
setViewHiddenCount(0);
|
setViewHiddenCount(0);
|
||||||
wasNearTopRef.current = false;
|
|
||||||
if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current);
|
if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current);
|
||||||
if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current);
|
if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current);
|
||||||
|
|
||||||
@@ -587,7 +546,7 @@ export function useChatSessionState({
|
|||||||
if (!isProcessing) {
|
if (!isProcessing) {
|
||||||
await sessionStore.refreshFromServer(selectedSession.id);
|
await sessionStore.refreshFromServer(selectedSession.id);
|
||||||
|
|
||||||
if (isNearBottom()) {
|
if (Boolean(autoScrollToBottom) && isNearBottom()) {
|
||||||
setTimeout(() => scrollToBottom(), 200);
|
setTimeout(() => scrollToBottom(), 200);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -598,6 +557,7 @@ export function useChatSessionState({
|
|||||||
|
|
||||||
reloadExternalMessages();
|
reloadExternalMessages();
|
||||||
}, [
|
}, [
|
||||||
|
autoScrollToBottom,
|
||||||
externalMessageUpdate,
|
externalMessageUpdate,
|
||||||
isNearBottom,
|
isNearBottom,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
@@ -729,9 +689,10 @@ export function useChatSessionState({
|
|||||||
}, [chatMessages, visibleMessageCount]);
|
}, [chatMessages, visibleMessageCount]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = scrollContainerRef.current;
|
if (!autoScrollToBottom && scrollContainerRef.current) {
|
||||||
if (!container) return;
|
const container = scrollContainerRef.current;
|
||||||
scrollPositionRef.current = { height: container.scrollHeight, top: container.scrollTop };
|
scrollPositionRef.current = { height: container.scrollHeight, top: container.scrollTop };
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -739,8 +700,8 @@ export function useChatSessionState({
|
|||||||
if (isLoadingMoreRef.current || isLoadingMoreMessages || pendingScrollRestoreRef.current) return;
|
if (isLoadingMoreRef.current || isLoadingMoreMessages || pendingScrollRestoreRef.current) return;
|
||||||
if (searchScrollActiveRef.current) return;
|
if (searchScrollActiveRef.current) return;
|
||||||
|
|
||||||
if (!isUserScrolledUp) {
|
if (autoScrollToBottom) {
|
||||||
setTimeout(() => scrollToBottom(), 50);
|
if (!isUserScrolledUp) setTimeout(() => scrollToBottom(), 50);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -750,7 +711,7 @@ export function useChatSessionState({
|
|||||||
const newHeight = container.scrollHeight;
|
const newHeight = container.scrollHeight;
|
||||||
const heightDiff = newHeight - prevHeight;
|
const heightDiff = newHeight - prevHeight;
|
||||||
if (heightDiff > 0 && prevTop > 0) container.scrollTop = prevTop + heightDiff;
|
if (heightDiff > 0 && prevTop > 0) container.scrollTop = prevTop + heightDiff;
|
||||||
}, [chatMessages.length, isLoadingMoreMessages, isUserScrolledUp, scrollToBottom]);
|
}, [autoScrollToBottom, chatMessages.length, isLoadingMoreMessages, isUserScrolledUp, scrollToBottom]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = scrollContainerRef.current;
|
const container = scrollContainerRef.current;
|
||||||
@@ -759,8 +720,23 @@ export function useChatSessionState({
|
|||||||
return () => container.removeEventListener('scroll', handleScroll);
|
return () => container.removeEventListener('scroll', handleScroll);
|
||||||
}, [handleScroll]);
|
}, [handleScroll]);
|
||||||
|
|
||||||
// "Load all" overlay visibility is driven by scroll-to-top in handleScroll;
|
// "Load all" overlay
|
||||||
// timers are cleared on session change via the reset effect above.
|
const prevLoadingRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const wasLoading = prevLoadingRef.current;
|
||||||
|
prevLoadingRef.current = isLoadingMoreMessages;
|
||||||
|
|
||||||
|
if (wasLoading && !isLoadingMoreMessages && hasMoreMessages) {
|
||||||
|
if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current);
|
||||||
|
setShowLoadAllOverlay(true);
|
||||||
|
loadAllOverlayTimerRef.current = setTimeout(() => setShowLoadAllOverlay(false), 2000);
|
||||||
|
}
|
||||||
|
if (!hasMoreMessages && !isLoadingMoreMessages) {
|
||||||
|
if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current);
|
||||||
|
setShowLoadAllOverlay(false);
|
||||||
|
}
|
||||||
|
return () => { if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current); };
|
||||||
|
}, [isLoadingMoreMessages, hasMoreMessages]);
|
||||||
|
|
||||||
const loadAllMessages = useCallback(async () => {
|
const loadAllMessages = useCallback(async () => {
|
||||||
if (!selectedSession || !selectedProject) return;
|
if (!selectedSession || !selectedProject) return;
|
||||||
@@ -770,10 +746,6 @@ export function useChatSessionState({
|
|||||||
isLoadingMoreRef.current = true;
|
isLoadingMoreRef.current = true;
|
||||||
setIsLoadingAllMessages(true);
|
setIsLoadingAllMessages(true);
|
||||||
setShowLoadAllOverlay(true);
|
setShowLoadAllOverlay(true);
|
||||||
if (loadAllOverlayTimerRef.current) {
|
|
||||||
clearTimeout(loadAllOverlayTimerRef.current);
|
|
||||||
loadAllOverlayTimerRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const container = scrollContainerRef.current;
|
const container = scrollContainerRef.current;
|
||||||
const previousScrollHeight = container ? container.scrollHeight : 0;
|
const previousScrollHeight = container ? container.scrollHeight : 0;
|
||||||
@@ -800,11 +772,7 @@ export function useChatSessionState({
|
|||||||
|
|
||||||
setLoadAllJustFinished(true);
|
setLoadAllJustFinished(true);
|
||||||
if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current);
|
if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current);
|
||||||
loadAllFinishedTimerRef.current = setTimeout(() => {
|
loadAllFinishedTimerRef.current = setTimeout(() => { setLoadAllJustFinished(false); setShowLoadAllOverlay(false); }, 1000);
|
||||||
setLoadAllJustFinished(false);
|
|
||||||
setShowLoadAllOverlay(false);
|
|
||||||
loadAllFinishedTimerRef.current = null;
|
|
||||||
}, 2500);
|
|
||||||
} else {
|
} else {
|
||||||
allMessagesLoadedRef.current = false;
|
allMessagesLoadedRef.current = false;
|
||||||
setShowLoadAllOverlay(false);
|
setShowLoadAllOverlay(false);
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
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, BashCommandDisplay, CollapsibleDisplay, ToolDiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent, SubagentContainer } from './components';
|
import { OneLineDisplay, 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';
|
||||||
@@ -24,6 +24,7 @@ interface ToolRendererProps {
|
|||||||
onFileOpen?: (filePath: string, diffInfo?: any) => void;
|
onFileOpen?: (filePath: string, diffInfo?: any) => void;
|
||||||
createDiff?: (oldStr: string, newStr: string) => DiffLine[];
|
createDiff?: (oldStr: string, newStr: string) => DiffLine[];
|
||||||
selectedProject?: Project | null;
|
selectedProject?: Project | null;
|
||||||
|
autoExpandTools?: boolean;
|
||||||
showRawParameters?: boolean;
|
showRawParameters?: boolean;
|
||||||
rawToolInput?: string;
|
rawToolInput?: string;
|
||||||
isSubagentContainer?: boolean;
|
isSubagentContainer?: boolean;
|
||||||
@@ -79,6 +80,7 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
|
|||||||
onFileOpen,
|
onFileOpen,
|
||||||
createDiff,
|
createDiff,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
|
autoExpandTools = false,
|
||||||
showRawParameters = false,
|
showRawParameters = false,
|
||||||
rawToolInput,
|
rawToolInput,
|
||||||
isSubagentContainer,
|
isSubagentContainer,
|
||||||
@@ -123,39 +125,6 @@ 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; 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);
|
||||||
@@ -197,7 +166,7 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
|
|||||||
<PlanDisplay
|
<PlanDisplay
|
||||||
title={title}
|
title={title}
|
||||||
content={contentProps.content || ''}
|
content={contentProps.content || ''}
|
||||||
defaultOpen={displayConfig.defaultOpen ?? false}
|
defaultOpen={displayConfig.defaultOpen ?? autoExpandTools}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
showRawParameters={mode === 'input' && showRawParameters}
|
showRawParameters={mode === 'input' && showRawParameters}
|
||||||
rawContent={rawToolInput}
|
rawContent={rawToolInput}
|
||||||
@@ -214,7 +183,7 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
|
|||||||
|
|
||||||
const defaultOpen = displayConfig.defaultOpen !== undefined
|
const defaultOpen = displayConfig.defaultOpen !== undefined
|
||||||
? displayConfig.defaultOpen
|
? displayConfig.defaultOpen
|
||||||
: false;
|
: autoExpandTools;
|
||||||
|
|
||||||
const contentProps = displayConfig.getContentProps?.(parsedData, {
|
const contentProps = displayConfig.getContentProps?.(parsedData, {
|
||||||
selectedProject,
|
selectedProject,
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
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,11 +15,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [expandedIdx, setExpandedIdx] = useState<number | null>(null);
|
const [expandedIdx, setExpandedIdx] = useState<number | null>(null);
|
||||||
|
|
||||||
// Tool inputs are runtime data loaded from session transcripts and may be
|
if (!questions || questions.length === 0) {
|
||||||
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,23 +24,11 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`space-y-2 ${className}`}>
|
<div className={`space-y-2 ${className}`}>
|
||||||
{questions.map((rawQuestion, idx) => {
|
{questions.map((q, 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];
|
||||||
// `answer` may be a non-string (or absent) in malformed payloads.
|
const answerLabels = answer ? answer.split(', ') : [];
|
||||||
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
|
||||||
@@ -90,7 +74,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 = !options.some(o => o.label === lbl);
|
const isCustom = !q.options.some(o => o.label === lbl);
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={lbl}
|
key={lbl}
|
||||||
@@ -126,7 +110,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">
|
||||||
{options.map((opt) => {
|
{q.options.map((opt) => {
|
||||||
const wasSelected = answerLabels.includes(opt.label);
|
const wasSelected = answerLabels.includes(opt.label);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -164,7 +148,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{answerLabels.filter(lbl => !options.some(o => o.label === lbl)).map(lbl => (
|
{answerLabels.filter(lbl => !q.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"
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ export const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({
|
|||||||
className={`group flex w-full items-center gap-2.5 rounded-lg border px-3 py-2 text-left transition-all duration-150 ${
|
className={`group flex w-full items-center gap-2.5 rounded-lg border px-3 py-2 text-left transition-all duration-150 ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'border-blue-300 bg-blue-50/80 ring-1 ring-blue-200/50 dark:border-blue-600 dark:bg-blue-900/25 dark:ring-blue-700/30'
|
? 'border-blue-300 bg-blue-50/80 ring-1 ring-blue-200/50 dark:border-blue-600 dark:bg-blue-900/25 dark:ring-blue-700/30'
|
||||||
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50/60 dark:border-gray-700/60 dark:hover:border-gray-600 dark:hover:bg-gray-700/40'
|
: 'dark:hover:bg-gray-750/50 border-gray-200 hover:border-gray-300 hover:bg-gray-50/60 dark:border-gray-700/60 dark:hover:border-gray-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Keyboard hint */}
|
{/* Keyboard hint */}
|
||||||
@@ -277,7 +277,7 @@ export const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({
|
|||||||
className={`group flex w-full items-center gap-2.5 rounded-lg border px-3 py-2 text-left transition-all duration-150 ${
|
className={`group flex w-full items-center gap-2.5 rounded-lg border px-3 py-2 text-left transition-all duration-150 ${
|
||||||
isOtherOn
|
isOtherOn
|
||||||
? 'border-blue-300 bg-blue-50/80 ring-1 ring-blue-200/50 dark:border-blue-600 dark:bg-blue-900/25 dark:ring-blue-700/30'
|
? 'border-blue-300 bg-blue-50/80 ring-1 ring-blue-200/50 dark:border-blue-600 dark:bg-blue-900/25 dark:ring-blue-700/30'
|
||||||
: 'border-dashed border-gray-200 hover:border-gray-300 hover:bg-gray-50/60 dark:border-gray-700/60 dark:hover:border-gray-600 dark:hover:bg-gray-700/40'
|
: 'dark:hover:bg-gray-750/50 border-dashed border-gray-200 hover:border-gray-300 hover:bg-gray-50/60 dark:border-gray-700/60 dark:hover:border-gray-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<kbd className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded font-mono text-[10px] transition-all duration-150 ${
|
<kbd className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded font-mono text-[10px] transition-all duration-150 ${
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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';
|
||||||
|
|||||||
@@ -126,8 +126,10 @@ export interface ChatInterfaceProps {
|
|||||||
onNavigateToSession?: (targetSessionId: string, options?: SessionNavigationOptions) => void;
|
onNavigateToSession?: (targetSessionId: string, options?: SessionNavigationOptions) => void;
|
||||||
onSessionEstablished?: (sessionId: string, context: SessionEstablishedContext) => void;
|
onSessionEstablished?: (sessionId: string, context: SessionEstablishedContext) => void;
|
||||||
onShowSettings?: () => void;
|
onShowSettings?: () => void;
|
||||||
|
autoExpandTools?: boolean;
|
||||||
showRawParameters?: boolean;
|
showRawParameters?: boolean;
|
||||||
showThinking?: boolean;
|
showThinking?: boolean;
|
||||||
|
autoScrollToBottom?: boolean;
|
||||||
sendByCtrlEnter?: boolean;
|
sendByCtrlEnter?: boolean;
|
||||||
externalMessageUpdate?: number;
|
externalMessageUpdate?: number;
|
||||||
newSessionTrigger?: number;
|
newSessionTrigger?: number;
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
import type { ChatMessage } from '../types/types';
|
|
||||||
|
|
||||||
export const TOOL_GROUP_THRESHOLD = 2;
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Messages that render nothing (e.g. reasoning hidden when showThinking is off)
|
|
||||||
// shouldn't split an otherwise-continuous run of the same tool — providers like
|
|
||||||
// Codex interleave hidden reasoning between consecutive tool calls.
|
|
||||||
function rendersNothing(message: ChatMessage, showThinking: boolean): boolean {
|
|
||||||
return Boolean(message.isThinking && !showThinking);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function groupConsecutiveTools(
|
|
||||||
messages: ChatMessage[],
|
|
||||||
showThinking: boolean = true,
|
|
||||||
): 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) {
|
|
||||||
const candidate = messages[nextIndex];
|
|
||||||
|
|
||||||
// Skip invisible interleaved messages so they don't break the run.
|
|
||||||
if (rendersNothing(candidate, showThinking)) {
|
|
||||||
nextIndex += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isGroupableToolMessage(candidate) && candidate.toolName === message.toolName) {
|
|
||||||
run.push(candidate);
|
|
||||||
nextIndex += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ArrowDownIcon } from 'lucide-react';
|
|
||||||
|
|
||||||
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
|
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
|
||||||
import { useWebSocket } from '../../../contexts/WebSocketContext';
|
import { useWebSocket } from '../../../contexts/WebSocketContext';
|
||||||
@@ -31,8 +30,10 @@ function ChatInterface({
|
|||||||
onNavigateToSession,
|
onNavigateToSession,
|
||||||
onSessionEstablished,
|
onSessionEstablished,
|
||||||
onShowSettings,
|
onShowSettings,
|
||||||
|
autoExpandTools,
|
||||||
showRawParameters,
|
showRawParameters,
|
||||||
showThinking,
|
showThinking,
|
||||||
|
autoScrollToBottom,
|
||||||
sendByCtrlEnter,
|
sendByCtrlEnter,
|
||||||
externalMessageUpdate,
|
externalMessageUpdate,
|
||||||
newSessionTrigger,
|
newSessionTrigger,
|
||||||
@@ -123,6 +124,7 @@ function ChatInterface({
|
|||||||
selectedSession,
|
selectedSession,
|
||||||
ws,
|
ws,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
|
autoScrollToBottom,
|
||||||
externalMessageUpdate,
|
externalMessageUpdate,
|
||||||
newSessionTrigger,
|
newSessionTrigger,
|
||||||
processingSessions,
|
processingSessions,
|
||||||
@@ -171,7 +173,6 @@ function ChatInterface({
|
|||||||
isDragActive,
|
isDragActive,
|
||||||
openImagePicker,
|
openImagePicker,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
handleVoiceTranscript,
|
|
||||||
handleInputChange,
|
handleInputChange,
|
||||||
handleKeyDown,
|
handleKeyDown,
|
||||||
handlePaste,
|
handlePaste,
|
||||||
@@ -183,7 +184,7 @@ function ChatInterface({
|
|||||||
handlePermissionDecision,
|
handlePermissionDecision,
|
||||||
handleGrantToolPermission,
|
handleGrantToolPermission,
|
||||||
handleInputFocusChange,
|
handleInputFocusChange,
|
||||||
isInputFocused,
|
isInputFocused: _isInputFocused,
|
||||||
commandModalPayload,
|
commandModalPayload,
|
||||||
closeCommandModal,
|
closeCommandModal,
|
||||||
showCostModal,
|
showCostModal,
|
||||||
@@ -309,7 +310,7 @@ function ChatInterface({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PermissionContext.Provider value={permissionContextValue}>
|
<PermissionContext.Provider value={permissionContextValue}>
|
||||||
<div className="flex h-full min-h-0 flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<ChatMessagesPane
|
<ChatMessagesPane
|
||||||
scrollContainerRef={scrollContainerRef}
|
scrollContainerRef={scrollContainerRef}
|
||||||
onWheel={handleScroll}
|
onWheel={handleScroll}
|
||||||
@@ -354,27 +355,13 @@ function ChatInterface({
|
|||||||
onFileOpen={onFileOpen}
|
onFileOpen={onFileOpen}
|
||||||
onShowSettings={onShowSettings}
|
onShowSettings={onShowSettings}
|
||||||
onGrantToolPermission={handleGrantToolPermission}
|
onGrantToolPermission={handleGrantToolPermission}
|
||||||
|
autoExpandTools={autoExpandTools}
|
||||||
showRawParameters={showRawParameters}
|
showRawParameters={showRawParameters}
|
||||||
showThinking={showThinking}
|
showThinking={showThinking}
|
||||||
selectedProject={selectedProject}
|
selectedProject={selectedProject}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="relative flex-shrink-0">
|
<ChatComposer
|
||||||
{isUserScrolledUp && chatMessages.length > 0 && (
|
|
||||||
<div className="pointer-events-none absolute -top-11 left-0 right-0 z-20 flex justify-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={scrollToBottomAndReset}
|
|
||||||
aria-label={t('input.scrollToBottom', { defaultValue: 'Scroll to bottom' })}
|
|
||||||
className="pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border border-border/50 bg-card text-muted-foreground shadow-sm transition-all duration-200 hover:bg-accent hover:text-foreground"
|
|
||||||
title={t('input.scrollToBottom', { defaultValue: 'Scroll to bottom' })}
|
|
||||||
>
|
|
||||||
<ArrowDownIcon className="h-4 w-4" aria-hidden />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ChatComposer
|
|
||||||
pendingPermissionRequests={pendingPermissionRequests}
|
pendingPermissionRequests={pendingPermissionRequests}
|
||||||
handlePermissionDecision={handlePermissionDecision}
|
handlePermissionDecision={handlePermissionDecision}
|
||||||
handleGrantToolPermission={handleGrantToolPermission}
|
handleGrantToolPermission={handleGrantToolPermission}
|
||||||
@@ -389,6 +376,9 @@ function ChatInterface({
|
|||||||
onToggleCommandMenu={handleToggleCommandMenu}
|
onToggleCommandMenu={handleToggleCommandMenu}
|
||||||
hasInput={Boolean(input.trim())}
|
hasInput={Boolean(input.trim())}
|
||||||
onClearInput={handleClearInput}
|
onClearInput={handleClearInput}
|
||||||
|
isUserScrolledUp={isUserScrolledUp}
|
||||||
|
hasMessages={chatMessages.length > 0}
|
||||||
|
onScrollToBottom={scrollToBottomAndReset}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
isDragActive={isDragActive}
|
isDragActive={isDragActive}
|
||||||
attachedImages={attachedImages}
|
attachedImages={attachedImages}
|
||||||
@@ -416,14 +406,12 @@ 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}
|
||||||
onTextareaPaste={handlePaste}
|
onTextareaPaste={handlePaste}
|
||||||
onTextareaScrollSync={syncInputOverlayScroll}
|
onTextareaScrollSync={syncInputOverlayScroll}
|
||||||
onTextareaInput={handleTextareaInput}
|
onTextareaInput={handleTextareaInput}
|
||||||
isInputFocused={isInputFocused}
|
|
||||||
onInputFocusChange={handleInputFocusChange}
|
onInputFocusChange={handleInputFocusChange}
|
||||||
placeholder={t('input.placeholder', {
|
placeholder={t('input.placeholder', {
|
||||||
provider:
|
provider:
|
||||||
@@ -440,7 +428,6 @@ function ChatInterface({
|
|||||||
isTextareaExpanded={isTextareaExpanded}
|
isTextareaExpanded={isTextareaExpanded}
|
||||||
sendByCtrlEnter={sendByCtrlEnter}
|
sendByCtrlEnter={sendByCtrlEnter}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<QuickSettingsPanel />
|
<QuickSettingsPanel />
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import type { SessionActivity } from '../../../../hooks/useSessionProtection';
|
|||||||
type ActivityIndicatorProps = {
|
type ActivityIndicatorProps = {
|
||||||
activity: SessionActivity | null;
|
activity: SessionActivity | null;
|
||||||
onAbort?: () => void;
|
onAbort?: () => void;
|
||||||
isInputFocused?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const ACTION_KEYS = [
|
const ACTION_KEYS = [
|
||||||
@@ -19,7 +18,6 @@ const ACTION_KEYS = [
|
|||||||
'claudeStatus.actions.reasoning',
|
'claudeStatus.actions.reasoning',
|
||||||
];
|
];
|
||||||
const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
|
const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
|
||||||
const EXIT_ANIMATION_MS = 220;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal response-in-progress indicator, in the spirit of the inline status
|
* Minimal response-in-progress indicator, in the spirit of the inline status
|
||||||
@@ -28,31 +26,11 @@ const EXIT_ANIMATION_MS = 220;
|
|||||||
* session has an entry in the processing map; it disappears the instant that
|
* session has an entry in the processing map; it disappears the instant that
|
||||||
* entry is removed.
|
* entry is removed.
|
||||||
*/
|
*/
|
||||||
export default function ActivityIndicator({ activity, onAbort, isInputFocused = false }: ActivityIndicatorProps) {
|
export default function ActivityIndicator({ activity, onAbort }: ActivityIndicatorProps) {
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
const [renderedActivity, setRenderedActivity] = useState<SessionActivity | null>(activity);
|
const startedAt = activity?.startedAt ?? null;
|
||||||
const [isExiting, setIsExiting] = useState(false);
|
|
||||||
const startedAt = renderedActivity?.startedAt ?? null;
|
|
||||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (activity) {
|
|
||||||
setRenderedActivity(activity);
|
|
||||||
setIsExiting(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!renderedActivity) return;
|
|
||||||
|
|
||||||
setIsExiting(true);
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setRenderedActivity(null);
|
|
||||||
setIsExiting(false);
|
|
||||||
}, EXIT_ANIMATION_MS);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [activity, renderedActivity]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (startedAt === null) return;
|
if (startedAt === null) return;
|
||||||
const update = () => setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1000)));
|
const update = () => setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1000)));
|
||||||
@@ -61,10 +39,10 @@ export default function ActivityIndicator({ activity, onAbort, isInputFocused =
|
|||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [startedAt]);
|
}, [startedAt]);
|
||||||
|
|
||||||
if (!renderedActivity) return null;
|
if (!activity) return null;
|
||||||
|
|
||||||
const actionWords = ACTION_KEYS.map((key, i) => t(key, { defaultValue: DEFAULT_ACTION_WORDS[i] }));
|
const actionWords = ACTION_KEYS.map((key, i) => t(key, { defaultValue: DEFAULT_ACTION_WORDS[i] }));
|
||||||
const label = (renderedActivity.statusText || actionWords[Math.floor(elapsedSeconds / 4) % actionWords.length])
|
const label = (activity.statusText || actionWords[Math.floor(elapsedSeconds / 4) % actionWords.length])
|
||||||
.replace(/\.+$/, '');
|
.replace(/\.+$/, '');
|
||||||
|
|
||||||
const minutes = Math.floor(elapsedSeconds / 60);
|
const minutes = Math.floor(elapsedSeconds / 60);
|
||||||
@@ -72,31 +50,19 @@ export default function ActivityIndicator({ activity, onAbort, isInputFocused =
|
|||||||
const elapsedLabel = minutes < 1
|
const elapsedLabel = minutes < 1
|
||||||
? t('claudeStatus.elapsed.seconds', { count: seconds, defaultValue: '{{count}}s' })
|
? t('claudeStatus.elapsed.seconds', { count: seconds, defaultValue: '{{count}}s' })
|
||||||
: t('claudeStatus.elapsed.minutesSeconds', { minutes, seconds, defaultValue: '{{minutes}}m {{seconds}}s' });
|
: t('claudeStatus.elapsed.minutesSeconds', { minutes, seconds, defaultValue: '{{minutes}}m {{seconds}}s' });
|
||||||
const tabSurfaceClassName = [
|
|
||||||
'chat-activity-tab inline-flex h-8 items-center rounded-b-none rounded-t-lg border border-b-0 bg-card px-3 text-xs transition-all duration-200',
|
|
||||||
isInputFocused
|
|
||||||
? 'border-primary/30 shadow-[0_-1px_2px_hsl(var(--foreground)/0.08),1px_0_2px_hsl(var(--foreground)/0.06),-1px_0_2px_hsl(var(--foreground)/0.06)]'
|
|
||||||
: 'border-border/50 shadow-[0_-1px_1px_hsl(var(--foreground)/0.04),1px_0_1px_hsl(var(--foreground)/0.03),-1px_0_1px_hsl(var(--foreground)/0.03)]',
|
|
||||||
].join(' ');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="animate-in fade-in mb-2 w-full duration-300">
|
||||||
className={`pointer-events-none bg-transparent ${
|
<div className="mx-auto flex max-w-4xl items-center gap-2 px-1">
|
||||||
isExiting ? 'chat-activity-exit' : 'chat-activity-enter'
|
<span className="h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-primary" aria-hidden />
|
||||||
}`}
|
<Shimmer className="text-xs font-medium">{`${label}…`}</Shimmer>
|
||||||
>
|
<span className="text-xs tabular-nums text-muted-foreground/60">{elapsedLabel}</span>
|
||||||
<div className="flex items-end justify-between gap-2">
|
|
||||||
<div className={`${tabSurfaceClassName} gap-2`}>
|
|
||||||
<span className="h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-primary" aria-hidden />
|
|
||||||
<Shimmer className="font-medium">{`${label}…`}</Shimmer>
|
|
||||||
<span className="tabular-nums text-muted-foreground/60">{elapsedLabel}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{renderedActivity.canInterrupt && onAbort && (
|
{activity.canInterrupt && onAbort && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onAbort}
|
onClick={onAbort}
|
||||||
className={`${tabSurfaceClassName} pointer-events-auto gap-1.5 text-muted-foreground hover:bg-card hover:text-destructive`}
|
className="ml-auto flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||||
aria-label={t('claudeStatus.stop', { defaultValue: 'Stop' })}
|
aria-label={t('claudeStatus.stop', { defaultValue: 'Stop' })}
|
||||||
>
|
>
|
||||||
<svg className="h-2.5 w-2.5 fill-current" viewBox="0 0 24 24" aria-hidden>
|
<svg className="h-2.5 w-2.5 fill-current" viewBox="0 0 24 24" aria-hidden>
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
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,
|
||||||
@@ -11,10 +9,8 @@ import type {
|
|||||||
RefObject,
|
RefObject,
|
||||||
TouchEvent,
|
TouchEvent,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { ImageIcon, MessageSquareIcon, XIcon, Loader2 } from 'lucide-react';
|
import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon } 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 {
|
||||||
@@ -31,7 +27,6 @@ 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';
|
||||||
|
|
||||||
@@ -68,6 +63,9 @@ interface ChatComposerProps {
|
|||||||
onToggleCommandMenu: () => void;
|
onToggleCommandMenu: () => void;
|
||||||
hasInput: boolean;
|
hasInput: boolean;
|
||||||
onClearInput: () => void;
|
onClearInput: () => void;
|
||||||
|
isUserScrolledUp: boolean;
|
||||||
|
hasMessages: boolean;
|
||||||
|
onScrollToBottom: () => void;
|
||||||
onSubmit: (event: FormEvent<HTMLFormElement> | MouseEvent<HTMLButtonElement> | TouchEvent<HTMLButtonElement>) => void;
|
onSubmit: (event: FormEvent<HTMLFormElement> | MouseEvent<HTMLButtonElement> | TouchEvent<HTMLButtonElement>) => void;
|
||||||
isDragActive: boolean;
|
isDragActive: boolean;
|
||||||
attachedImages: File[];
|
attachedImages: File[];
|
||||||
@@ -91,14 +89,12 @@ 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;
|
||||||
onTextareaPaste: (event: ClipboardEvent<HTMLTextAreaElement>) => void;
|
onTextareaPaste: (event: ClipboardEvent<HTMLTextAreaElement>) => void;
|
||||||
onTextareaScrollSync: (target: HTMLTextAreaElement) => void;
|
onTextareaScrollSync: (target: HTMLTextAreaElement) => void;
|
||||||
onTextareaInput: (event: FormEvent<HTMLTextAreaElement>) => void;
|
onTextareaInput: (event: FormEvent<HTMLTextAreaElement>) => void;
|
||||||
isInputFocused?: boolean;
|
|
||||||
onInputFocusChange?: (focused: boolean) => void;
|
onInputFocusChange?: (focused: boolean) => void;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
isTextareaExpanded: boolean;
|
isTextareaExpanded: boolean;
|
||||||
@@ -120,6 +116,9 @@ export default function ChatComposer({
|
|||||||
onToggleCommandMenu,
|
onToggleCommandMenu,
|
||||||
hasInput,
|
hasInput,
|
||||||
onClearInput,
|
onClearInput,
|
||||||
|
isUserScrolledUp,
|
||||||
|
hasMessages,
|
||||||
|
onScrollToBottom,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
isDragActive,
|
isDragActive,
|
||||||
attachedImages,
|
attachedImages,
|
||||||
@@ -143,52 +142,24 @@ export default function ChatComposer({
|
|||||||
renderInputWithMentions,
|
renderInputWithMentions,
|
||||||
textareaRef,
|
textareaRef,
|
||||||
input,
|
input,
|
||||||
onVoiceTranscript,
|
|
||||||
onInputChange,
|
onInputChange,
|
||||||
onTextareaClick,
|
onTextareaClick,
|
||||||
onTextareaKeyDown,
|
onTextareaKeyDown,
|
||||||
onTextareaPaste,
|
onTextareaPaste,
|
||||||
onTextareaScrollSync,
|
onTextareaScrollSync,
|
||||||
onTextareaInput,
|
onTextareaInput,
|
||||||
isInputFocused = false,
|
|
||||||
onInputFocusChange,
|
onInputFocusChange,
|
||||||
placeholder,
|
placeholder,
|
||||||
isTextareaExpanded,
|
isTextareaExpanded,
|
||||||
sendByCtrlEnter,
|
sendByCtrlEnter,
|
||||||
}: ChatComposerProps) {
|
}: ChatComposerProps) {
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
const commandMenuPosition = useMemo(() => {
|
const textareaRect = textareaRef.current?.getBoundingClientRect();
|
||||||
if (!isCommandMenuOpen) {
|
const commandMenuPosition = {
|
||||||
return { top: 0, left: 16, bottom: 90 };
|
top: textareaRect ? Math.max(16, textareaRect.top - 316) : 0,
|
||||||
}
|
left: textareaRect ? textareaRect.left : 16,
|
||||||
const textareaRect = textareaRef.current?.getBoundingClientRect();
|
bottom: textareaRect ? window.innerHeight - textareaRect.top + 8 : 90,
|
||||||
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(
|
||||||
@@ -197,18 +168,15 @@ export default function ChatComposer({
|
|||||||
|
|
||||||
// Hide the thinking/status bar while any permission request is pending
|
// Hide the thinking/status bar while any permission request is pending
|
||||||
const hasPendingPermissions = pendingPermissionRequests.length > 0;
|
const hasPendingPermissions = pendingPermissionRequests.length > 0;
|
||||||
const hasActivityIndicator = Boolean(activity && !hasPendingPermissions);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-composer-shell relative flex-shrink-0 px-2 pb-2 pt-0 sm:px-4 sm:pb-4 md:px-4 md:pb-6">
|
<div className="flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6">
|
||||||
{!hasPendingPermissions && (
|
{!hasPendingPermissions && (
|
||||||
<div className="pointer-events-none absolute bottom-full left-1/2 z-10 w-[calc(100%-1rem)] max-w-[54.25rem] -translate-x-1/2 translate-y-px bg-transparent sm:w-[calc(100%-2rem)]">
|
<ActivityIndicator activity={activity} onAbort={onAbortSession} />
|
||||||
<ActivityIndicator activity={activity} onAbort={onAbortSession} isInputFocused={isInputFocused} />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pendingPermissionRequests.length > 0 && (
|
{pendingPermissionRequests.length > 0 && (
|
||||||
<div className="mx-auto mb-3 max-w-[54.25rem]">
|
<div className="mx-auto mb-3 max-w-4xl">
|
||||||
<PermissionRequestsBanner
|
<PermissionRequestsBanner
|
||||||
pendingPermissionRequests={pendingPermissionRequests}
|
pendingPermissionRequests={pendingPermissionRequests}
|
||||||
handlePermissionDecision={handlePermissionDecision}
|
handlePermissionDecision={handlePermissionDecision}
|
||||||
@@ -217,7 +185,19 @@ export default function ChatComposer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!hasQuestionPanel && <div className="relative mx-auto max-w-[54.25rem]">
|
{!hasQuestionPanel && <div className="relative mx-auto max-w-4xl">
|
||||||
|
{isUserScrolledUp && hasMessages && (
|
||||||
|
<div className="absolute -top-10 left-0 right-0 z-10 flex justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onScrollToBottom}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-full border border-border/50 bg-card text-muted-foreground shadow-sm transition-all duration-200 hover:bg-accent hover:text-foreground"
|
||||||
|
title={t('input.scrollToBottom', { defaultValue: 'Scroll to bottom' })}
|
||||||
|
>
|
||||||
|
<ArrowDownIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{showFileDropdown && filteredFiles.length > 0 && (
|
{showFileDropdown && filteredFiles.length > 0 && (
|
||||||
<div className="absolute bottom-full left-0 right-0 z-50 mb-2 max-h-48 overflow-y-auto rounded-xl border border-border/50 bg-card/95 shadow-lg backdrop-blur-md">
|
<div className="absolute bottom-full left-0 right-0 z-50 mb-2 max-h-48 overflow-y-auto rounded-xl border border-border/50 bg-card/95 shadow-lg backdrop-blur-md">
|
||||||
{filteredFiles.map((file, index) => (
|
{filteredFiles.map((file, index) => (
|
||||||
@@ -258,10 +238,7 @@ export default function ChatComposer({
|
|||||||
<PromptInput
|
<PromptInput
|
||||||
onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void}
|
onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void}
|
||||||
status={isLoading ? 'streaming' : 'ready'}
|
status={isLoading ? 'streaming' : 'ready'}
|
||||||
className={[
|
className={isTextareaExpanded ? 'chat-input-expanded' : ''}
|
||||||
isTextareaExpanded ? 'chat-input-expanded' : '',
|
|
||||||
hasActivityIndicator ? 'rounded-t-none' : '',
|
|
||||||
].filter(Boolean).join(' ')}
|
|
||||||
{...getRootProps()}
|
{...getRootProps()}
|
||||||
>
|
>
|
||||||
{isDragActive && (
|
{isDragActive && (
|
||||||
@@ -332,14 +309,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}
|
||||||
className={`inline-flex h-8 items-center rounded-lg border px-2 text-xs font-medium transition-all duration-200 sm:px-2.5 ${
|
className={`rounded-lg border p-2 text-xs font-medium transition-all duration-200 sm:px-2.5 sm:py-1 ${
|
||||||
permissionMode === 'default'
|
permissionMode === 'default'
|
||||||
? 'border-border/60 bg-muted/50 text-muted-foreground hover:bg-muted'
|
? 'border-border/60 bg-muted/50 text-muted-foreground hover:bg-muted'
|
||||||
: permissionMode === 'acceptEdits'
|
: permissionMode === 'acceptEdits'
|
||||||
@@ -414,21 +387,10 @@ 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={
|
onClick={isLoading ? onAbortSession : undefined}
|
||||||
isLoading
|
disabled={!isLoading && !input.trim()}
|
||||||
? 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 { memo, useCallback, useMemo } from 'react';
|
import { useCallback, 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,12 +10,9 @@ 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';
|
|
||||||
import LoadAllMessagesOverlay from './LoadAllMessagesOverlay';
|
|
||||||
|
|
||||||
interface ChatMessagesPaneProps {
|
interface ChatMessagesPaneProps {
|
||||||
scrollContainerRef: RefObject<HTMLDivElement>;
|
scrollContainerRef: RefObject<HTMLDivElement>;
|
||||||
@@ -62,12 +59,13 @@ interface ChatMessagesPaneProps {
|
|||||||
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
|
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
|
||||||
onShowSettings?: () => void;
|
onShowSettings?: () => void;
|
||||||
onGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };
|
onGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };
|
||||||
|
autoExpandTools?: boolean;
|
||||||
showRawParameters?: boolean;
|
showRawParameters?: boolean;
|
||||||
showThinking?: boolean;
|
showThinking?: boolean;
|
||||||
selectedProject: Project;
|
selectedProject: Project;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChatMessagesPane({
|
export default function ChatMessagesPane({
|
||||||
scrollContainerRef,
|
scrollContainerRef,
|
||||||
onWheel,
|
onWheel,
|
||||||
onTouchMove,
|
onTouchMove,
|
||||||
@@ -111,59 +109,47 @@ function ChatMessagesPane({
|
|||||||
onFileOpen,
|
onFileOpen,
|
||||||
onShowSettings,
|
onShowSettings,
|
||||||
onGrantToolPermission,
|
onGrantToolPermission,
|
||||||
|
autoExpandTools,
|
||||||
showRawParameters,
|
showRawParameters,
|
||||||
showThinking,
|
showThinking,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
}: ChatMessagesPaneProps) {
|
}: ChatMessagesPaneProps) {
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
const groupedVisibleMessages = useMemo(
|
const messageKeyMapRef = useRef<WeakMap<ChatMessage, string>>(new WeakMap());
|
||||||
() => groupConsecutiveTools(visibleMessages, Boolean(showThinking)),
|
const allocatedKeysRef = useRef<Set<string>>(new Set());
|
||||||
[visibleMessages, showThinking],
|
const generatedMessageKeyCounterRef = useRef(0);
|
||||||
);
|
|
||||||
|
|
||||||
// Stable, deterministic keys for the messages rendered this pass.
|
// Keep keys stable across prepends so existing MessageComponent instances retain local state.
|
||||||
//
|
const getMessageKey = useCallback((message: ChatMessage) => {
|
||||||
// `normalizedToChatMessages` rebuilds fresh ChatMessage objects on every store
|
const existingKey = messageKeyMapRef.current.get(message);
|
||||||
// update, so caching keys by object identity (or via a cross-render allocation
|
if (existingKey) {
|
||||||
// Set) minted a brand-new key for the *same* logical message on each prepend —
|
return existingKey;
|
||||||
// remounting the whole list, which disconnects the scroll-restore anchor and
|
|
||||||
// reflows heights, jumping the viewport to the bottom. Deriving keys purely
|
|
||||||
// from this render's ordered messages (intrinsic key, disambiguated by
|
|
||||||
// occurrence index on collision) yields the same key for the same message
|
|
||||||
// order, so React preserves existing DOM nodes and component state on prepend.
|
|
||||||
const messageKeyMap = useMemo(() => {
|
|
||||||
const keys = new WeakMap<ChatMessage, string>();
|
|
||||||
const occurrences = new Map<string, number>();
|
|
||||||
const assign = (message: ChatMessage) => {
|
|
||||||
const intrinsicKey = getIntrinsicMessageKey(message) ?? 'message-generated';
|
|
||||||
const seen = occurrences.get(intrinsicKey) ?? 0;
|
|
||||||
occurrences.set(intrinsicKey, seen + 1);
|
|
||||||
keys.set(message, seen === 0 ? intrinsicKey : `${intrinsicKey}__${seen}`);
|
|
||||||
};
|
|
||||||
for (const item of groupedVisibleMessages) {
|
|
||||||
if (isToolGroupItem(item)) {
|
|
||||||
item.messages.forEach(assign);
|
|
||||||
} else {
|
|
||||||
assign(item);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return keys;
|
|
||||||
}, [groupedVisibleMessages]);
|
|
||||||
|
|
||||||
const getMessageKey = useCallback(
|
const intrinsicKey = getIntrinsicMessageKey(message);
|
||||||
(message: ChatMessage) =>
|
let candidateKey = intrinsicKey;
|
||||||
messageKeyMap.get(message) ?? getIntrinsicMessageKey(message) ?? 'message-generated',
|
|
||||||
[messageKeyMap],
|
if (!candidateKey || allocatedKeysRef.current.has(candidateKey)) {
|
||||||
);
|
do {
|
||||||
|
generatedMessageKeyCounterRef.current += 1;
|
||||||
|
candidateKey = intrinsicKey
|
||||||
|
? `${intrinsicKey}-${generatedMessageKeyCounterRef.current}`
|
||||||
|
: `message-generated-${generatedMessageKeyCounterRef.current}`;
|
||||||
|
} while (allocatedKeysRef.current.has(candidateKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
allocatedKeysRef.current.add(candidateKey);
|
||||||
|
messageKeyMapRef.current.set(message, candidateKey);
|
||||||
|
return candidateKey;
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={scrollContainerRef}
|
ref={scrollContainerRef}
|
||||||
onWheel={onWheel}
|
onWheel={onWheel}
|
||||||
onTouchMove={onTouchMove}
|
onTouchMove={onTouchMove}
|
||||||
className="chat-messages-pane relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden py-3 sm:py-4"
|
className="relative flex-1 space-y-3 overflow-y-auto overflow-x-hidden px-0 py-3 sm:space-y-4 sm:p-4"
|
||||||
>
|
>
|
||||||
<div className="mx-auto w-full max-w-[54.25rem] space-y-3 px-4 sm:space-y-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">
|
||||||
<div className="flex items-center justify-center space-x-2">
|
<div className="flex items-center justify-center space-x-2">
|
||||||
@@ -219,13 +205,35 @@ function ChatMessagesPane({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<LoadAllMessagesOverlay
|
{/* Floating "Load all messages" overlay */}
|
||||||
showLoadAllOverlay={showLoadAllOverlay}
|
{(showLoadAllOverlay || isLoadingAllMessages || loadAllJustFinished) && (
|
||||||
isLoadingAllMessages={isLoadingAllMessages}
|
<div className="pointer-events-none sticky top-2 z-20 flex justify-center">
|
||||||
loadAllJustFinished={loadAllJustFinished}
|
{loadAllJustFinished ? (
|
||||||
totalMessages={totalMessages}
|
<div className="flex items-center space-x-2 rounded-full bg-green-600 px-4 py-1.5 text-xs font-medium text-white shadow-lg dark:bg-green-500">
|
||||||
onLoadAllMessages={loadAllMessages}
|
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
/>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
<span>{t('session.messages.allLoaded')}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="pointer-events-auto flex items-center space-x-2 rounded-full bg-blue-600 px-4 py-1.5 text-xs font-medium text-white shadow-lg transition-all duration-200 hover:scale-105 hover:bg-blue-700 disabled:cursor-wait disabled:opacity-75 dark:bg-blue-500 dark:hover:bg-blue-600"
|
||||||
|
onClick={loadAllMessages}
|
||||||
|
disabled={isLoadingAllMessages}
|
||||||
|
>
|
||||||
|
{isLoadingAllMessages && (
|
||||||
|
<div className="h-3 w-3 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{isLoadingAllMessages
|
||||||
|
? t('session.messages.loadingAll')
|
||||||
|
: <>{t('session.messages.loadAll')} {totalMessages > 0 && `(${totalMessages})`}</>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Legacy message count indicator (for non-paginated view) */}
|
{/* Legacy message count indicator (for non-paginated view) */}
|
||||||
{!hasMoreMessages && chatMessages.length > visibleMessageCount && (
|
{!hasMoreMessages && chatMessages.length > visibleMessageCount && (
|
||||||
@@ -244,57 +252,28 @@ function ChatMessagesPane({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(() => {
|
{visibleMessages.map((message, index) => {
|
||||||
let prevMessage: ChatMessage | null = null;
|
const prevMessage = index > 0 ? visibleMessages[index - 1] : null;
|
||||||
|
return (
|
||||||
return groupedVisibleMessages.map((item) => {
|
<MessageComponent
|
||||||
if (isToolGroupItem(item)) {
|
key={getMessageKey(message)}
|
||||||
const groupPrevMessage = prevMessage;
|
message={message}
|
||||||
prevMessage = item.messages[item.messages.length - 1] || prevMessage;
|
prevMessage={prevMessage}
|
||||||
|
createDiff={createDiff}
|
||||||
return (
|
onFileOpen={onFileOpen}
|
||||||
<ToolGroupContainer
|
onShowSettings={onShowSettings}
|
||||||
key={`tool-group-${getMessageKey(item.messages[0])}`}
|
onGrantToolPermission={onGrantToolPermission}
|
||||||
group={item}
|
autoExpandTools={autoExpandTools}
|
||||||
prevMessage={groupPrevMessage}
|
showRawParameters={showRawParameters}
|
||||||
createDiff={createDiff}
|
showThinking={showThinking}
|
||||||
getMessageKey={getMessageKey}
|
selectedProject={selectedProject}
|
||||||
onFileOpen={onFileOpen}
|
provider={provider}
|
||||||
onShowSettings={onShowSettings}
|
/>
|
||||||
onGrantToolPermission={onGrantToolPermission}
|
);
|
||||||
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}
|
|
||||||
showRawParameters={showRawParameters}
|
|
||||||
showThinking={showThinking}
|
|
||||||
selectedProject={selectedProject}
|
|
||||||
provider={provider}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
})()}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(ChatMessagesPane);
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import type { CSSProperties } from 'react';
|
||||||
import type { CSSProperties, ReactElement } from 'react';
|
|
||||||
import {
|
import {
|
||||||
CornerDownLeft,
|
CornerDownLeft,
|
||||||
Folder,
|
Folder,
|
||||||
@@ -78,7 +77,6 @@ const namespaceAccentClasses: Record<string, string> = {
|
|||||||
|
|
||||||
const MENU_EDGE_GAP = 16;
|
const MENU_EDGE_GAP = 16;
|
||||||
const MENU_MAX_HEIGHT = 360;
|
const MENU_MAX_HEIGHT = 360;
|
||||||
const MENU_MIN_HEIGHT = 160;
|
|
||||||
|
|
||||||
const getCommandKey = (command: CommandMenuCommand) =>
|
const getCommandKey = (command: CommandMenuCommand) =>
|
||||||
`${command.name}::${command.namespace || command.type || 'other'}::${command.path || ''}`;
|
`${command.name}::${command.namespace || command.type || 'other'}::${command.path || ''}`;
|
||||||
@@ -94,9 +92,8 @@ const getMenuPosition = (position: { top: number; left: number; bottom?: number
|
|||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return { position: 'fixed', top: '16px', left: '16px' };
|
return { position: 'fixed', top: '16px', left: '16px' };
|
||||||
}
|
}
|
||||||
const maxAnchorBottom = Math.max(MENU_EDGE_GAP, window.innerHeight - MENU_EDGE_GAP - MENU_MIN_HEIGHT);
|
|
||||||
if (window.innerWidth < 640) {
|
if (window.innerWidth < 640) {
|
||||||
const anchorBottom = Math.min(Math.max(MENU_EDGE_GAP, position.bottom ?? 90), maxAnchorBottom);
|
const anchorBottom = Math.max(MENU_EDGE_GAP, position.bottom ?? 90);
|
||||||
return {
|
return {
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
bottom: `${anchorBottom}px`,
|
bottom: `${anchorBottom}px`,
|
||||||
@@ -107,7 +104,7 @@ const getMenuPosition = (position: { top: number; left: number; bottom?: number
|
|||||||
maxHeight: `min(54vh, calc(100vh - ${anchorBottom}px - ${MENU_EDGE_GAP}px))`,
|
maxHeight: `min(54vh, calc(100vh - ${anchorBottom}px - ${MENU_EDGE_GAP}px))`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const anchorBottom = Math.min(Math.max(MENU_EDGE_GAP, position.bottom ?? 90), maxAnchorBottom);
|
const anchorBottom = Math.max(MENU_EDGE_GAP, position.bottom ?? 90);
|
||||||
const clampedLeft = Math.max(
|
const clampedLeft = Math.max(
|
||||||
MENU_EDGE_GAP,
|
MENU_EDGE_GAP,
|
||||||
Math.min(position.left, window.innerWidth - 440 - MENU_EDGE_GAP),
|
Math.min(position.left, window.innerWidth - 440 - MENU_EDGE_GAP),
|
||||||
@@ -219,14 +216,12 @@ export default function CommandMenu({
|
|||||||
: ['builtin', 'skill', 'project', 'user', 'other'];
|
: ['builtin', 'skill', 'project', 'user', 'other'];
|
||||||
const extraNamespaces = Object.keys(groupedCommands).filter((namespace) => !preferredOrder.includes(namespace));
|
const extraNamespaces = Object.keys(groupedCommands).filter((namespace) => !preferredOrder.includes(namespace));
|
||||||
const orderedNamespaces = [...preferredOrder, ...extraNamespaces].filter((namespace) => groupedCommands[namespace]);
|
const orderedNamespaces = [...preferredOrder, ...extraNamespaces].filter((namespace) => groupedCommands[namespace]);
|
||||||
const renderInPortal = (node: ReactElement) =>
|
|
||||||
typeof document === 'undefined' ? node : createPortal(node, document.body);
|
|
||||||
|
|
||||||
if (commands.length === 0) {
|
if (commands.length === 0) {
|
||||||
return renderInPortal(
|
return (
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
className="command-menu command-menu-empty border border-border bg-popover/95 text-sm text-muted-foreground"
|
className="command-menu command-menu-empty border border-gray-200 bg-white/95 text-sm text-gray-500 dark:border-gray-700/80 dark:bg-gray-900/95 dark:text-gray-400"
|
||||||
style={{
|
style={{
|
||||||
...menuBaseStyle,
|
...menuBaseStyle,
|
||||||
...menuPosition,
|
...menuPosition,
|
||||||
@@ -242,20 +237,20 @@ export default function CommandMenu({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return renderInPortal(
|
return (
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
role="listbox"
|
role="listbox"
|
||||||
aria-label="Available commands"
|
aria-label="Available commands"
|
||||||
className="command-menu border border-border bg-popover/95 text-popover-foreground"
|
className="command-menu border border-gray-200/90 bg-white/95 text-gray-900 dark:border-slate-700/80 dark:bg-slate-950/95 dark:text-slate-100"
|
||||||
style={{ ...menuBaseStyle, ...menuPosition, opacity: 1, transform: 'translateY(0)' }}
|
style={{ ...menuBaseStyle, ...menuPosition, opacity: 1, transform: 'translateY(0)' }}
|
||||||
>
|
>
|
||||||
{orderedNamespaces.map((namespace) => (
|
{orderedNamespaces.map((namespace) => (
|
||||||
<div key={namespace} className="command-group">
|
<div key={namespace} className="command-group">
|
||||||
{orderedNamespaces.length > 1 && (
|
{orderedNamespaces.length > 1 && (
|
||||||
<div className="flex items-center justify-between px-2 pb-1.5 pt-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
<div className="flex items-center justify-between px-2 pb-1.5 pt-2 text-[10px] font-semibold uppercase tracking-wide text-gray-500 dark:text-slate-400">
|
||||||
<span>{namespaceLabels[namespace] || namespace}</span>
|
<span>{namespaceLabels[namespace] || namespace}</span>
|
||||||
<span className="rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
<span className="rounded border border-gray-200 bg-gray-50 px-1.5 py-0.5 text-[10px] text-gray-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400">
|
||||||
{(groupedCommands[namespace] || []).length}
|
{(groupedCommands[namespace] || []).length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -273,15 +268,15 @@ export default function CommandMenu({
|
|||||||
aria-selected={isSelected}
|
aria-selected={isSelected}
|
||||||
className={`command-item group relative mb-1 flex cursor-pointer items-start gap-2 rounded-md border px-2.5 py-2 transition-all ${
|
className={`command-item group relative mb-1 flex cursor-pointer items-start gap-2 rounded-md border px-2.5 py-2 transition-all ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'border-primary/30 bg-primary/10 shadow-sm'
|
? 'border-sky-200 bg-sky-50 shadow-sm dark:border-cyan-400/30 dark:bg-cyan-400/10'
|
||||||
: 'border-transparent bg-transparent hover:border-border hover:bg-accent'
|
: 'border-transparent bg-transparent hover:border-gray-200 hover:bg-gray-50/90 dark:hover:border-slate-700 dark:hover:bg-slate-900/80'
|
||||||
}`}
|
}`}
|
||||||
onMouseEnter={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, true)}
|
onMouseEnter={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, true)}
|
||||||
onClick={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, false)}
|
onClick={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, false)}
|
||||||
onMouseDown={(event) => event.preventDefault()}
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
>
|
>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<span className="absolute bottom-1.5 left-1.5 top-1.5 w-0.5 rounded-full bg-primary" />
|
<span className="absolute bottom-1.5 left-1.5 top-1.5 w-0.5 rounded-full bg-sky-500 dark:bg-cyan-300" />
|
||||||
)}
|
)}
|
||||||
<span className={`mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md border ${accentClass}`}>
|
<span className={`mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md border ${accentClass}`}>
|
||||||
<NamespaceIcon aria-hidden="true" size={14} strokeWidth={2.2} />
|
<NamespaceIcon aria-hidden="true" size={14} strokeWidth={2.2} />
|
||||||
@@ -289,20 +284,20 @@ export default function CommandMenu({
|
|||||||
<div className="min-w-0 flex-1 pr-1">
|
<div className="min-w-0 flex-1 pr-1">
|
||||||
<div className={`flex min-w-0 items-center gap-2 ${command.description ? 'mb-1' : 'mb-0'}`}>
|
<div className={`flex min-w-0 items-center gap-2 ${command.description ? 'mb-1' : 'mb-0'}`}>
|
||||||
<span
|
<span
|
||||||
className="min-w-0 truncate font-mono text-[13px] font-semibold text-foreground"
|
className="min-w-0 truncate font-mono text-[13px] font-semibold text-gray-950 dark:text-slate-50"
|
||||||
title={command.name}
|
title={command.name}
|
||||||
>
|
>
|
||||||
{command.name}
|
{command.name}
|
||||||
</span>
|
</span>
|
||||||
{command.metadata?.type && (
|
{command.metadata?.type && (
|
||||||
<span className="command-metadata-badge shrink-0 rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground shadow-sm">
|
<span className="command-metadata-badge shrink-0 rounded border border-gray-200 bg-white px-1.5 py-0.5 text-[10px] font-medium text-gray-500 shadow-sm dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300">
|
||||||
{command.metadata.type}
|
{command.metadata.type}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{command.description && (
|
{command.description && (
|
||||||
<div
|
<div
|
||||||
className="truncate whitespace-nowrap text-[12px] leading-4 text-muted-foreground"
|
className="truncate whitespace-nowrap text-[12px] leading-4 text-gray-500 dark:text-slate-400"
|
||||||
title={command.description}
|
title={command.description}
|
||||||
>
|
>
|
||||||
{command.description}
|
{command.description}
|
||||||
@@ -310,7 +305,7 @@ export default function CommandMenu({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<span className="mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded border border-primary/30 bg-card text-primary shadow-sm">
|
<span className="mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded border border-sky-200 bg-white text-sky-600 shadow-sm dark:border-cyan-400/30 dark:bg-slate-950 dark:text-cyan-200">
|
||||||
<CornerDownLeft aria-hidden="true" size={13} strokeWidth={2.2} />
|
<CornerDownLeft aria-hidden="true" size={13} strokeWidth={2.2} />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { useMemo, useState } from 'react';
|
|||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
|
Check,
|
||||||
CircleHelp,
|
CircleHelp,
|
||||||
|
Clipboard,
|
||||||
Coins,
|
Coins,
|
||||||
Cpu,
|
Cpu,
|
||||||
Gauge,
|
Gauge,
|
||||||
@@ -57,6 +59,19 @@ 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',
|
||||||
@@ -231,6 +246,7 @@ function HelpContent({ data }: { data: HelpCommandData }) {
|
|||||||
function ModelsContent({
|
function ModelsContent({
|
||||||
data,
|
data,
|
||||||
providerModelCatalog,
|
providerModelCatalog,
|
||||||
|
providerModelCacheCatalog,
|
||||||
providerModelsRefreshing,
|
providerModelsRefreshing,
|
||||||
onHardRefreshProviderModels,
|
onHardRefreshProviderModels,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
@@ -238,12 +254,14 @@ 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);
|
||||||
@@ -251,6 +269,7 @@ 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;
|
||||||
@@ -263,6 +282,7 @@ 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();
|
||||||
@@ -276,8 +296,18 @@ 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);
|
||||||
@@ -300,106 +330,162 @@ function ModelsContent({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col gap-3">
|
<div className="flex h-full min-h-0 flex-col gap-2.5">
|
||||||
{/* Compact context bar: active model + refresh, no clutter */}
|
<div className="rounded-2xl border border-border/70 bg-muted/20 p-2.5">
|
||||||
<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="grid gap-2.5 lg:grid-cols-[minmax(0,1.55fr)_minmax(12rem,0.7fr)_minmax(15rem,0.9fr)] lg:items-start">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
Active model · {providerLabel}
|
<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">
|
||||||
</p>
|
{providerLabel}
|
||||||
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
</Badge>
|
||||||
<span className="break-all font-mono text-sm font-semibold text-foreground">{currentModel}</span>
|
<Badge variant="secondary" className="rounded-lg px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-foreground">
|
||||||
{pendingSessionModel && pendingSessionModel !== currentModel && (
|
{availableOptions.length} models
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-[0.14em] text-emerald-500 dark:text-emerald-400">
|
</Badge>
|
||||||
→ {pendingSessionModel} next
|
</div>
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</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>
|
|
||||||
|
|
||||||
{showSearch && (
|
<div className="mt-2 rounded-xl border border-primary/15 bg-primary/[0.06] px-3 py-2">
|
||||||
<SearchField value={query} onChange={setQuery} placeholder={`Search ${providerLabel} models...`} />
|
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-primary">Active Model</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>
|
||||||
|
|
||||||
{filteredOptions.length > 0 ? (
|
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-1">
|
||||||
<div className="scrollbar-thin -mr-1 min-h-0 flex-1 overflow-y-auto pr-1">
|
<div className="rounded-xl border border-border/60 bg-background/55 px-2.5 py-1.5">
|
||||||
<div className="grid gap-2 md:grid-cols-2">
|
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-foreground/80">Default</p>
|
||||||
{filteredOptions.map((option, index) => {
|
<p className="mt-1 break-all font-mono text-[11px] font-medium text-foreground">{defaultModel}</p>
|
||||||
const isCurrent = option.value === currentModel;
|
</div>
|
||||||
const isPendingSelection = option.value === pendingSessionModel;
|
<div className="rounded-xl border border-border/60 bg-background/55 px-2.5 py-1.5">
|
||||||
const isChanging = option.value === changingModel;
|
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-foreground/80">Updated</p>
|
||||||
return (
|
<p className="mt-1 text-[11px] font-medium text-foreground">{formatUpdatedAt(currentCache?.updatedAt)}</p>
|
||||||
<button
|
</div>
|
||||||
key={option.value}
|
</div>
|
||||||
type="button"
|
|
||||||
onClick={() => handleSelectModel(option.value)}
|
<div className="rounded-xl border border-border/60 bg-background/55 p-2.5">
|
||||||
disabled={Boolean(changingModel)}
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
aria-label={`Select model ${option.value}`}
|
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-foreground/80">
|
||||||
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 ${
|
Catalog Refresh
|
||||||
isCurrent
|
</p>
|
||||||
? 'border-primary/45 bg-primary/10'
|
<Badge variant="secondary" className="rounded-md px-1.5 py-0 text-[9px] uppercase tracking-[0.14em]">
|
||||||
: isPendingSelection
|
All providers
|
||||||
? 'border-emerald-500/35 bg-emerald-500/10'
|
</Badge>
|
||||||
: 'border-border/70 bg-background/80 hover:border-primary/30 hover:bg-background'
|
</div>
|
||||||
}`}
|
<p className="mt-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||||
style={{ animationDelay: `${Math.min(index * 14, 180)}ms` }}
|
Model lists are cached for 3 days. Refresh after CLI, auth, or config changes,
|
||||||
>
|
or when a new model is missing.
|
||||||
<span className="flex items-center justify-between gap-2">
|
</p>
|
||||||
<span className="break-all font-mono text-sm font-semibold text-foreground">{option.value}</span>
|
<Button
|
||||||
{isCurrent ? (
|
type="button"
|
||||||
<BadgeCheck className="h-4 w-4 shrink-0 text-primary" />
|
variant="outline"
|
||||||
) : isChanging ? (
|
size="sm"
|
||||||
<RefreshCw className="h-4 w-4 shrink-0 animate-spin text-primary" />
|
onClick={onHardRefreshProviderModels}
|
||||||
) : null}
|
disabled={providerModelsRefreshing}
|
||||||
</span>
|
className="mt-2 h-8 w-full rounded-xl px-3"
|
||||||
{option.label && option.label !== option.value && (
|
>
|
||||||
<span className="mt-1 text-xs font-medium text-foreground/85">{option.label}</span>
|
<RefreshCw className={providerModelsRefreshing ? 'animate-spin' : ''} />
|
||||||
)}
|
{providerModelsRefreshing ? 'Refreshing catalogs...' : 'Refresh from providers'}
|
||||||
{option.description && (
|
</Button>
|
||||||
<span className="mt-1 text-xs leading-5 text-muted-foreground">{option.description}</span>
|
|
||||||
)}
|
|
||||||
{isCurrent && (
|
|
||||||
<span className="mt-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-primary">Current selection</span>
|
|
||||||
)}
|
|
||||||
{isPendingSelection && !isCurrent && (
|
|
||||||
<span className="mt-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-500 dark:text-emerald-400">
|
|
||||||
Applies next response
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</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 */}
|
<div className="mt-2 border-t border-border/50 pt-1.5 text-[11px] text-muted-foreground">
|
||||||
<p className="shrink-0 text-[11px] leading-4 text-muted-foreground">
|
{hasConcreteSessionId
|
||||||
{selectionNotice ? (
|
? 'Selecting a model stores a session override and applies it on the next response for this session.'
|
||||||
<span className="text-foreground">{selectionNotice}</span>
|
: 'Selecting a model updates the default model used for new turns in this provider.'}
|
||||||
) : hasConcreteSessionId ? (
|
{selectionNotice && <span className="ml-2 text-foreground">{selectionNotice}</span>}
|
||||||
'Your choice applies to this session on the next response.'
|
</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">
|
||||||
|
<div className="mb-2.5 grid gap-2 sm:grid-cols-[1fr_auto] sm:items-center">
|
||||||
|
<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 ? (
|
||||||
|
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto pr-1">
|
||||||
|
<div className="grid gap-2 md:grid-cols-2">
|
||||||
|
{filteredOptions.map((option, index) => {
|
||||||
|
const isCurrent = option.value === currentModel;
|
||||||
|
const wasCopied = copiedModel === option.value;
|
||||||
|
const isPendingSelection = option.value === pendingSessionModel;
|
||||||
|
const isChanging = option.value === changingModel;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={option.value}
|
||||||
|
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 ${
|
||||||
|
isCurrent
|
||||||
|
? 'border-primary/45 bg-primary/10'
|
||||||
|
: isPendingSelection
|
||||||
|
? 'border-emerald-500/35 bg-emerald-500/10'
|
||||||
|
: 'border-border/70 bg-background/80 hover:border-primary/30 hover:bg-background'
|
||||||
|
}`}
|
||||||
|
style={{ animationDelay: `${Math.min(index * 14, 180)}ms` }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelectModel(option.value)}
|
||||||
|
disabled={Boolean(changingModel)}
|
||||||
|
className="min-w-0 flex-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
aria-label={`Use model ${option.value}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="break-all font-mono text-sm font-semibold text-foreground">{option.value}</span>
|
||||||
|
{isCurrent && <BadgeCheck className="h-4 w-4 shrink-0 text-primary" />}
|
||||||
|
</span>
|
||||||
|
{option.label && option.label !== option.value && (
|
||||||
|
<span className="mt-1 block text-xs text-muted-foreground">{option.label}</span>
|
||||||
|
)}
|
||||||
|
{option.description && (
|
||||||
|
<span className="mt-1 block 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>}
|
||||||
|
{isPendingSelection && !isCurrent && (
|
||||||
|
<span className="mt-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-400">
|
||||||
|
Next response selection
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isChanging && (
|
||||||
|
<span className="mt-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-primary">
|
||||||
|
Applying...
|
||||||
|
</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>
|
||||||
) : (
|
) : (
|
||||||
'Your choice becomes the default model for new turns.'
|
<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>
|
||||||
)}
|
)}
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -520,6 +606,7 @@ export default function CommandResultModal({
|
|||||||
payload,
|
payload,
|
||||||
onClose,
|
onClose,
|
||||||
providerModelCatalog,
|
providerModelCatalog,
|
||||||
|
providerModelCacheCatalog,
|
||||||
providerModelsRefreshing,
|
providerModelsRefreshing,
|
||||||
onHardRefreshProviderModels,
|
onHardRefreshProviderModels,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
@@ -537,9 +624,9 @@ export default function CommandResultModal({
|
|||||||
icon: CircleHelp,
|
icon: CircleHelp,
|
||||||
},
|
},
|
||||||
models: {
|
models: {
|
||||||
eyebrow: 'Model selection',
|
eyebrow: 'Model inventory',
|
||||||
title: 'Choose a Model',
|
title: 'Available Models',
|
||||||
subtitle: 'Pick the model this provider should use.',
|
subtitle: 'Browse, search, and copy model IDs for the active provider.',
|
||||||
icon: Cpu,
|
icon: Cpu,
|
||||||
},
|
},
|
||||||
cost: {
|
cost: {
|
||||||
@@ -565,41 +652,46 @@ export default function CommandResultModal({
|
|||||||
<DialogTitle>{activeMeta?.title || 'Command Result'}</DialogTitle>
|
<DialogTitle>{activeMeta?.title || 'Command Result'}</DialogTitle>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`flex shrink-0 items-start justify-between gap-3 border-b border-border bg-popover ${
|
className={`relative shrink-0 overflow-hidden border-b border-border/70 bg-gradient-to-br from-primary/15 via-background to-muted/40 ${
|
||||||
isModelsModal ? 'px-4 py-3 sm:px-5 sm:py-4' : 'px-4 py-4 sm:px-6 sm:py-5'
|
isModelsModal ? 'px-4 pb-3 pt-3 sm:px-5 sm:pb-4 sm:pt-4' : 'px-4 pb-4 pt-4 sm:px-6 sm:pb-5 sm:pt-5'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="pointer-events-none absolute -left-20 -top-24 h-56 w-56 rounded-full bg-primary/20 blur-3xl" />
|
||||||
<div
|
<div className="pointer-events-none absolute right-0 top-0 h-full w-1/2 bg-[radial-gradient(circle_at_top_right,hsl(var(--primary)/0.16),transparent_58%)]" />
|
||||||
className={`flex shrink-0 items-center justify-center rounded-xl border border-border bg-muted text-foreground ${
|
|
||||||
isModelsModal ? 'h-9 w-9' : 'h-10 w-10'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<HeaderIcon className={isModelsModal ? 'h-4 w-4' : 'h-5 w-5'} />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
|
||||||
{activeMeta?.eyebrow}
|
|
||||||
</p>
|
|
||||||
<p className="mt-0.5 text-lg font-semibold tracking-tight text-foreground sm:text-xl">
|
|
||||||
{activeMeta?.title}
|
|
||||||
</p>
|
|
||||||
<p className="mt-0.5 max-w-2xl text-sm leading-5 text-muted-foreground">
|
|
||||||
{activeMeta?.subtitle}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
<div className="relative flex items-start justify-between gap-3">
|
||||||
type="button"
|
<div className="flex min-w-0 items-start gap-3 sm:items-center">
|
||||||
variant="ghost"
|
<div
|
||||||
size="icon"
|
className={`rounded-2xl border border-primary/30 bg-primary/10 text-primary shadow-sm ${
|
||||||
onClick={onClose}
|
isModelsModal ? 'p-2.5' : 'p-3'
|
||||||
className="h-8 w-8 shrink-0 rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground"
|
}`}
|
||||||
aria-label="Close command result modal"
|
>
|
||||||
>
|
<HeaderIcon className={isModelsModal ? 'h-4 w-4' : 'h-5 w-5'} />
|
||||||
<X className="h-4 w-4" />
|
</div>
|
||||||
</Button>
|
<div className="min-w-0">
|
||||||
|
<p className="text-[12px] font-bold uppercase tracking-[0.22em] text-primary">
|
||||||
|
{activeMeta?.eyebrow}
|
||||||
|
</p>
|
||||||
|
<p className={`mt-1 font-semibold tracking-tight text-foreground ${isModelsModal ? 'text-xl sm:text-2xl' : 'text-xl sm:text-2xl'}`}>
|
||||||
|
{activeMeta?.title}
|
||||||
|
</p>
|
||||||
|
<p className={`mt-1 max-w-2xl ${isModelsModal ? 'text-sm leading-5 text-foreground/75' : 'text-sm leading-5 text-muted-foreground'}`}>
|
||||||
|
{activeMeta?.subtitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-9 w-9 shrink-0 rounded-xl text-muted-foreground hover:bg-background/70 hover:text-foreground"
|
||||||
|
aria-label="Close command result modal"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="settings-content-enter min-h-0 flex-1 overflow-hidden px-4 py-4 sm:px-6 sm:py-5">
|
<div className="settings-content-enter min-h-0 flex-1 overflow-hidden px-4 py-4 sm:px-6 sm:py-5">
|
||||||
@@ -608,6 +700,7 @@ 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}
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
const loadAllOverlayAnimationStyle = `
|
|
||||||
@keyframes loadAllOverlayAutoFade {
|
|
||||||
0%, 80% { opacity: 1; }
|
|
||||||
100% { opacity: 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.load-all-overlay-auto-fade {
|
|
||||||
animation: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
interface LoadAllMessagesOverlayProps {
|
|
||||||
showLoadAllOverlay: boolean;
|
|
||||||
isLoadingAllMessages: boolean;
|
|
||||||
loadAllJustFinished: boolean;
|
|
||||||
totalMessages: number;
|
|
||||||
onLoadAllMessages: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LoadAllMessagesOverlay({
|
|
||||||
showLoadAllOverlay,
|
|
||||||
isLoadingAllMessages,
|
|
||||||
loadAllJustFinished,
|
|
||||||
totalMessages,
|
|
||||||
onLoadAllMessages,
|
|
||||||
}: LoadAllMessagesOverlayProps) {
|
|
||||||
const { t } = useTranslation('chat');
|
|
||||||
|
|
||||||
if (!showLoadAllOverlay && !isLoadingAllMessages && !loadAllJustFinished) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`pointer-events-none sticky top-2 z-20 flex justify-center ${!isLoadingAllMessages ? 'load-all-overlay-auto-fade' : ''}`}
|
|
||||||
style={!isLoadingAllMessages ? { animation: 'loadAllOverlayAutoFade 2500ms ease forwards' } : undefined}
|
|
||||||
>
|
|
||||||
<style>{loadAllOverlayAnimationStyle}</style>
|
|
||||||
{loadAllJustFinished ? (
|
|
||||||
<div className="flex items-center space-x-2 rounded-full bg-green-600 px-4 py-1.5 text-xs font-medium text-white shadow-lg dark:bg-green-500">
|
|
||||||
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
<span>{t('session.messages.allLoaded')}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="pointer-events-auto flex items-center space-x-2 rounded-full bg-blue-600 px-4 py-1.5 text-xs font-medium text-white shadow-lg transition-all duration-200 hover:scale-105 hover:bg-blue-700 disabled:cursor-wait disabled:opacity-75 dark:bg-blue-500 dark:hover:bg-blue-600"
|
|
||||||
onClick={onLoadAllMessages}
|
|
||||||
disabled={isLoadingAllMessages}
|
|
||||||
>
|
|
||||||
{isLoadingAllMessages && (
|
|
||||||
<div className="h-3 w-3 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
|
||||||
)}
|
|
||||||
<span>
|
|
||||||
{isLoadingAllMessages
|
|
||||||
? t('session.messages.loadingAll')
|
|
||||||
: <>{t('session.messages.loadAll')} {totalMessages > 0 && `(${totalMessages})`}</>}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,53 +4,16 @@ import remarkGfm from 'remark-gfm';
|
|||||||
import remarkMath from 'remark-math';
|
import remarkMath from 'remark-math';
|
||||||
import rehypeKatex from 'rehype-katex';
|
import rehypeKatex from 'rehype-katex';
|
||||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
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';
|
|
||||||
import { useTheme } from '../../../../contexts/ThemeContext';
|
|
||||||
|
|
||||||
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;
|
||||||
@@ -60,7 +23,6 @@ type CodeBlockProps = {
|
|||||||
|
|
||||||
const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockProps) => {
|
const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockProps) => {
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
const { isDarkMode } = useTheme();
|
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const raw = Array.isArray(children) ? children.join('') : String(children ?? '');
|
const raw = Array.isArray(children) ? children.join('') : String(children ?? '');
|
||||||
const looksMultiline = /[\r\n]/.test(raw);
|
const looksMultiline = /[\r\n]/.test(raw);
|
||||||
@@ -98,7 +60,7 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="absolute right-2 top-2 z-10 rounded-md border border-border bg-card/90 px-2 py-1 text-xs text-foreground/80 opacity-0 transition-opacity hover:bg-muted focus:opacity-100 active:opacity-100 group-hover:opacity-100"
|
className="absolute right-2 top-2 z-10 rounded-md border border-gray-600 bg-gray-700/80 px-2 py-1 text-xs text-white opacity-0 transition-opacity hover:bg-gray-700 focus:opacity-100 active:opacity-100 group-hover:opacity-100"
|
||||||
title={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
|
title={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
|
||||||
aria-label={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
|
aria-label={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
|
||||||
>
|
>
|
||||||
@@ -134,20 +96,17 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
|
|||||||
|
|
||||||
<SyntaxHighlighter
|
<SyntaxHighlighter
|
||||||
language={language}
|
language={language}
|
||||||
style={isDarkMode ? oneDark : oneLight}
|
style={oneDark}
|
||||||
customStyle={{
|
customStyle={{
|
||||||
margin: 0,
|
margin: 0,
|
||||||
borderRadius: '0.75rem',
|
borderRadius: '0.5rem',
|
||||||
fontSize: '0.875rem',
|
fontSize: '0.875rem',
|
||||||
padding: language && language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',
|
padding: language && language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',
|
||||||
// ChatGPT-style soft grey block in light mode; keep oneDark's own bg in dark.
|
|
||||||
...(isDarkMode ? {} : { background: 'hsl(var(--muted))' }),
|
|
||||||
}}
|
}}
|
||||||
codeTagProps={{
|
codeTagProps={{
|
||||||
style: {
|
style: {
|
||||||
fontFamily:
|
fontFamily:
|
||||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||||
...(isDarkMode ? {} : { background: 'transparent' }),
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -159,15 +118,16 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
|
|||||||
|
|
||||||
const markdownComponents = {
|
const markdownComponents = {
|
||||||
code: CodeBlock,
|
code: CodeBlock,
|
||||||
// CodeBlock renders its own syntax-highlighted <pre>; this passthrough stops
|
|
||||||
// react-markdown (and Tailwind Typography) from wrapping it in a second,
|
|
||||||
// dark-themed <pre> shell that would frame the block.
|
|
||||||
pre: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
|
||||||
blockquote: ({ children }: { children?: React.ReactNode }) => (
|
blockquote: ({ children }: { children?: React.ReactNode }) => (
|
||||||
<blockquote className="my-2 border-l-4 border-gray-300 pl-4 italic text-gray-600 dark:border-gray-600 dark:text-gray-400">
|
<blockquote className="my-2 border-l-4 border-gray-300 pl-4 italic text-gray-600 dark:border-gray-600 dark:text-gray-400">
|
||||||
{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">
|
||||||
@@ -187,50 +147,10 @@ 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={components as any}>
|
<ReactMarkdown remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} components={markdownComponents as any}>
|
||||||
{content}
|
{content}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useMemo, useRef } from 'react';
|
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
||||||
@@ -15,7 +15,6 @@ 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;
|
||||||
@@ -30,6 +29,7 @@ type MessageComponentProps = {
|
|||||||
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
|
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
|
||||||
onShowSettings?: () => void;
|
onShowSettings?: () => void;
|
||||||
onGrantToolPermission?: (suggestion: ClaudePermissionSuggestion) => PermissionGrantResult | null | undefined;
|
onGrantToolPermission?: (suggestion: ClaudePermissionSuggestion) => PermissionGrantResult | null | undefined;
|
||||||
|
autoExpandTools?: boolean;
|
||||||
showRawParameters?: boolean;
|
showRawParameters?: boolean;
|
||||||
showThinking?: boolean;
|
showThinking?: boolean;
|
||||||
selectedProject?: Project | null;
|
selectedProject?: Project | null;
|
||||||
@@ -44,7 +44,7 @@ type InteractiveOption = {
|
|||||||
|
|
||||||
const COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']);
|
const COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']);
|
||||||
|
|
||||||
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
|
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
const isGrouped = prevMessage && prevMessage.type === message.type &&
|
const isGrouped = prevMessage && prevMessage.type === message.type &&
|
||||||
((prevMessage.type === 'assistant') ||
|
((prevMessage.type === 'assistant') ||
|
||||||
@@ -52,6 +52,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
|
|||||||
(prevMessage.type === 'tool') ||
|
(prevMessage.type === 'tool') ||
|
||||||
(prevMessage.type === 'error'));
|
(prevMessage.type === 'error'));
|
||||||
const messageRef = useRef<HTMLDivElement | null>(null);
|
const messageRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const userCopyContent = String(message.content || '');
|
const userCopyContent = String(message.content || '');
|
||||||
const formattedMessageContent = useMemo(
|
const formattedMessageContent = useMemo(
|
||||||
() => formatUsageLimitText(String(message.content || '')),
|
() => formatUsageLimitText(String(message.content || '')),
|
||||||
@@ -70,6 +71,32 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
|
|||||||
!message.isThinking;
|
!message.isThinking;
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const node = messageRef.current;
|
||||||
|
if (!autoExpandTools || !node || !message.isToolUse) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting && !isExpanded) {
|
||||||
|
setIsExpanded(true);
|
||||||
|
const details = node.querySelectorAll<HTMLDetailsElement>('details');
|
||||||
|
details.forEach((detail) => {
|
||||||
|
detail.open = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 }
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(node);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.unobserve(node);
|
||||||
|
};
|
||||||
|
}, [autoExpandTools, isExpanded, message.isToolUse]);
|
||||||
|
|
||||||
const formattedTime = useMemo(() => new Date(message.timestamp).toLocaleTimeString(), [message.timestamp]);
|
const formattedTime = useMemo(() => new Date(message.timestamp).toLocaleTimeString(), [message.timestamp]);
|
||||||
const shouldHideThinkingMessage = Boolean(message.isThinking && !showThinking);
|
const shouldHideThinkingMessage = Boolean(message.isThinking && !showThinking);
|
||||||
|
|
||||||
@@ -87,7 +114,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
|
|||||||
/* User message bubble on the right */
|
/* User message bubble on the right */
|
||||||
<div className="flex w-full items-end space-x-0 sm:w-auto sm:max-w-[85%] sm:space-x-3 md:max-w-md lg:max-w-lg xl:max-w-xl">
|
<div className="flex w-full items-end space-x-0 sm:w-auto sm:max-w-[85%] sm:space-x-3 md:max-w-md lg:max-w-lg xl:max-w-xl">
|
||||||
<div className="group flex-1 rounded-2xl rounded-br-md bg-blue-600 px-3 py-2 text-white shadow-sm sm:flex-initial sm:px-4">
|
<div className="group flex-1 rounded-2xl rounded-br-md bg-blue-600 px-3 py-2 text-white shadow-sm sm:flex-initial sm:px-4">
|
||||||
<div dir="auto" className="whitespace-pre-wrap break-words font-serif text-sm">
|
<div dir="auto" className="whitespace-pre-wrap break-words text-sm">
|
||||||
{message.content}
|
{message.content}
|
||||||
</div>
|
</div>
|
||||||
{message.images && message.images.length > 0 && (
|
{message.images && message.images.length > 0 && (
|
||||||
@@ -138,7 +165,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
|
|||||||
🔧
|
🔧
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full p-1 text-sm text-foreground">
|
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full p-1 text-sm text-white">
|
||||||
<SessionProviderLogo provider={provider} className="h-full w-full" />
|
<SessionProviderLogo provider={provider} className="h-full w-full" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -166,7 +193,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
|
|||||||
<>
|
<>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<Markdown className="prose prose-sm max-w-none font-serif dark:prose-invert">
|
<Markdown className="prose prose-sm max-w-none dark:prose-invert">
|
||||||
{String(message.displayText || '')}
|
{String(message.displayText || '')}
|
||||||
</Markdown>
|
</Markdown>
|
||||||
</div>
|
</div>
|
||||||
@@ -182,6 +209,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
|
|||||||
onFileOpen={onFileOpen}
|
onFileOpen={onFileOpen}
|
||||||
createDiff={createDiff}
|
createDiff={createDiff}
|
||||||
selectedProject={selectedProject}
|
selectedProject={selectedProject}
|
||||||
|
autoExpandTools={autoExpandTools}
|
||||||
showRawParameters={showRawParameters}
|
showRawParameters={showRawParameters}
|
||||||
rawToolInput={typeof message.toolInput === 'string' ? message.toolInput : undefined}
|
rawToolInput={typeof message.toolInput === 'string' ? message.toolInput : undefined}
|
||||||
isSubagentContainer={message.isSubagentContainer}
|
isSubagentContainer={message.isSubagentContainer}
|
||||||
@@ -189,8 +217,8 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tool Result Section — Bash renders its output inside the command row above. */}
|
{/* Tool Result Section */}
|
||||||
{message.toolResult && message.toolName !== 'Bash' && !shouldHideToolResult(message.toolName || 'UnknownTool', message.toolResult) && (
|
{message.toolResult && !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
|
||||||
@@ -204,7 +232,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
|
|||||||
<span className="text-xs font-medium text-red-700 dark:text-red-300">{t('messageTypes.error')}</span>
|
<span className="text-xs font-medium text-red-700 dark:text-red-300">{t('messageTypes.error')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative text-sm text-red-900 dark:text-red-100">
|
<div className="relative text-sm text-red-900 dark:text-red-100">
|
||||||
<Markdown className="prose prose-sm prose-red max-w-none font-serif dark:prose-invert">
|
<Markdown className="prose prose-sm prose-red max-w-none dark:prose-invert">
|
||||||
{String(message.toolResult.content || '')}
|
{String(message.toolResult.content || '')}
|
||||||
</Markdown>
|
</Markdown>
|
||||||
</div>
|
</div>
|
||||||
@@ -221,6 +249,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
|
|||||||
onFileOpen={onFileOpen}
|
onFileOpen={onFileOpen}
|
||||||
createDiff={createDiff}
|
createDiff={createDiff}
|
||||||
selectedProject={selectedProject}
|
selectedProject={selectedProject}
|
||||||
|
autoExpandTools={autoExpandTools}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -312,7 +341,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
|
|||||||
<Reasoning defaultOpen={false}>
|
<Reasoning defaultOpen={false}>
|
||||||
<ReasoningTrigger />
|
<ReasoningTrigger />
|
||||||
<ReasoningContent>
|
<ReasoningContent>
|
||||||
<Markdown className="prose prose-sm prose-gray max-w-none font-serif dark:prose-invert">
|
<Markdown className="prose prose-sm prose-gray max-w-none dark:prose-invert">
|
||||||
{message.content}
|
{message.content}
|
||||||
</Markdown>
|
</Markdown>
|
||||||
<div className="mt-3 flex items-center text-[11px]">
|
<div className="mt-3 flex items-center text-[11px]">
|
||||||
@@ -347,15 +376,15 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="my-2">
|
<div className="my-2">
|
||||||
<div className="mb-2 flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="mb-2 flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="font-medium">{t('json.response')}</span>
|
<span className="font-medium">{t('json.response')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-hidden rounded-lg border border-border bg-muted">
|
<div className="overflow-hidden rounded-lg border border-gray-600/30 bg-gray-800 dark:border-gray-700 dark:bg-gray-900">
|
||||||
<pre className="overflow-x-auto p-4">
|
<pre className="overflow-x-auto p-4">
|
||||||
<code className="block whitespace-pre font-mono text-sm text-foreground">
|
<code className="block whitespace-pre font-mono text-sm text-gray-100 dark:text-gray-200">
|
||||||
{formatted}
|
{formatted}
|
||||||
</code>
|
</code>
|
||||||
</pre>
|
</pre>
|
||||||
@@ -369,7 +398,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
|
|||||||
|
|
||||||
// Normal rendering for non-JSON content
|
// Normal rendering for non-JSON content
|
||||||
return message.type === 'assistant' ? (
|
return message.type === 'assistant' ? (
|
||||||
<Markdown className="prose prose-sm prose-gray max-w-none font-serif dark:prose-invert">
|
<Markdown className="prose prose-sm prose-gray max-w-none dark:prose-invert">
|
||||||
{content}
|
{content}
|
||||||
</Markdown>
|
</Markdown>
|
||||||
) : (
|
) : (
|
||||||
@@ -386,9 +415,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
|
|||||||
{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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { CSSProperties } from 'react';
|
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { copyTextToClipboard } from '../../../../utils/clipboard';
|
import { copyTextToClipboard } from '../../../../utils/clipboard';
|
||||||
|
|
||||||
@@ -51,32 +49,9 @@ const MessageCopyControl = ({
|
|||||||
const [selectedFormat, setSelectedFormat] = useState<CopyFormat>(defaultFormat);
|
const [selectedFormat, setSelectedFormat] = useState<CopyFormat>(defaultFormat);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
const [menuStyle, setMenuStyle] = useState<CSSProperties>({});
|
|
||||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
|
||||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const copyFeedbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const copyFeedbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
// The dropdown is rendered in a portal so it escapes the chat message's
|
|
||||||
// `contain: paint` box (which would otherwise clip it). Anchor it to the
|
|
||||||
// trigger, flipping above when there isn't room below.
|
|
||||||
const openDropdown = () => {
|
|
||||||
const rect = triggerRef.current?.getBoundingClientRect();
|
|
||||||
if (rect) {
|
|
||||||
const ESTIMATED_MENU_HEIGHT = 84;
|
|
||||||
const openUp = rect.bottom + ESTIMATED_MENU_HEIGHT + 8 > window.innerHeight;
|
|
||||||
setMenuStyle({
|
|
||||||
position: 'fixed',
|
|
||||||
right: Math.max(8, window.innerWidth - rect.right),
|
|
||||||
zIndex: 1000,
|
|
||||||
...(openUp
|
|
||||||
? { bottom: window.innerHeight - rect.top + 4 }
|
|
||||||
: { top: rect.bottom + 4 }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setIsDropdownOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyFormatOptions: CopyFormatOption[] = useMemo(
|
const copyFormatOptions: CopyFormatOption[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -108,28 +83,18 @@ const MessageCopyControl = ({
|
|||||||
}, [defaultFormat]);
|
}, [defaultFormat]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDropdownOpen) return;
|
// Close the dropdown when clicking anywhere outside this control.
|
||||||
|
|
||||||
// Close when clicking outside both the control and the portaled menu.
|
|
||||||
const closeOnOutsideClick = (event: MouseEvent) => {
|
const closeOnOutsideClick = (event: MouseEvent) => {
|
||||||
|
if (!isDropdownOpen) return;
|
||||||
const target = event.target as Node;
|
const target = event.target as Node;
|
||||||
if (dropdownRef.current?.contains(target) || menuRef.current?.contains(target)) {
|
if (dropdownRef.current && !dropdownRef.current.contains(target)) {
|
||||||
return;
|
setIsDropdownOpen(false);
|
||||||
}
|
}
|
||||||
setIsDropdownOpen(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// The menu is fixed-positioned; close it if the page scrolls so it can't
|
|
||||||
// detach from the trigger.
|
|
||||||
const closeOnScroll = () => setIsDropdownOpen(false);
|
|
||||||
|
|
||||||
window.addEventListener('mousedown', closeOnOutsideClick);
|
window.addEventListener('mousedown', closeOnOutsideClick);
|
||||||
window.addEventListener('scroll', closeOnScroll, true);
|
|
||||||
window.addEventListener('resize', closeOnScroll);
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('mousedown', closeOnOutsideClick);
|
window.removeEventListener('mousedown', closeOnOutsideClick);
|
||||||
window.removeEventListener('scroll', closeOnScroll, true);
|
|
||||||
window.removeEventListener('resize', closeOnScroll);
|
|
||||||
};
|
};
|
||||||
}, [isDropdownOpen]);
|
}, [isDropdownOpen]);
|
||||||
|
|
||||||
@@ -205,9 +170,8 @@ const MessageCopyControl = ({
|
|||||||
{canSelectCopyFormat && (
|
{canSelectCopyFormat && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => (isDropdownOpen ? setIsDropdownOpen(false) : openDropdown())}
|
onClick={() => setIsDropdownOpen((prev) => !prev)}
|
||||||
className={`rounded px-1 py-0.5 transition-colors ${toneClass}`}
|
className={`rounded px-1 py-0.5 transition-colors ${toneClass}`}
|
||||||
aria-label={t('copyMessage.selectFormat', { defaultValue: 'Select copy format' })}
|
aria-label={t('copyMessage.selectFormat', { defaultValue: 'Select copy format' })}
|
||||||
title={t('copyMessage.selectFormat', { defaultValue: 'Select copy format' })}
|
title={t('copyMessage.selectFormat', { defaultValue: 'Select copy format' })}
|
||||||
@@ -222,12 +186,8 @@ const MessageCopyControl = ({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isDropdownOpen && createPortal(
|
{isDropdownOpen && (
|
||||||
<div
|
<div className="absolute left-auto top-full z-30 mt-1 min-w-36 rounded-md border border-gray-200 bg-white p-1 shadow-lg dark:border-gray-700 dark:bg-gray-900">
|
||||||
ref={menuRef}
|
|
||||||
style={menuStyle}
|
|
||||||
className="min-w-36 rounded-md border border-border bg-popover p-1 shadow-lg"
|
|
||||||
>
|
|
||||||
{copyFormatOptions.map((option) => {
|
{copyFormatOptions.map((option) => {
|
||||||
const isSelected = option.format === selectedFormat;
|
const isSelected = option.format === selectedFormat;
|
||||||
return (
|
return (
|
||||||
@@ -236,16 +196,15 @@ const MessageCopyControl = ({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleFormatChange(option.format)}
|
onClick={() => handleFormatChange(option.format)}
|
||||||
className={`block w-full rounded px-2 py-1.5 text-left transition-colors ${isSelected
|
className={`block w-full rounded px-2 py-1.5 text-left transition-colors ${isSelected
|
||||||
? 'bg-accent text-foreground'
|
? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-100'
|
||||||
: 'text-foreground hover:bg-accent'
|
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800/60'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="block text-xs font-medium">{option.label}</span>
|
<span className="block text-xs font-medium">{option.label}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>,
|
</div>
|
||||||
document.body,
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -186,7 +186,7 @@ export default function ProviderSelectionEmptyState({
|
|||||||
if (!selectedSession && !currentSessionId) {
|
if (!selectedSession && !currentSessionId) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center px-4">
|
<div className="flex h-full items-center justify-center px-4">
|
||||||
<div className="w-full max-w-[34.25rem]">
|
<div className="w-full max-w-md">
|
||||||
<div className="mb-8 text-center">
|
<div className="mb-8 text-center">
|
||||||
<h2 className="text-lg font-semibold tracking-tight text-foreground sm:text-xl">
|
<h2 className="text-lg font-semibold tracking-tight text-foreground sm:text-xl">
|
||||||
{t("providerSelection.title")}
|
{t("providerSelection.title")}
|
||||||
@@ -352,7 +352,7 @@ export default function ProviderSelectionEmptyState({
|
|||||||
if (selectedSession) {
|
if (selectedSession) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<div className="max-w-[34.25rem] px-6 text-center">
|
<div className="max-w-md px-6 text-center">
|
||||||
<p className="mb-1.5 text-lg font-semibold text-foreground">
|
<p className="mb-1.5 text-lg font-semibold text-foreground">
|
||||||
{t("session.continue.title")}
|
{t("session.continue.title")}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default function TokenUsageSummary({ usage, onClick }: TokenUsageSummaryP
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-border/70 bg-background/70 px-2 text-xs text-muted-foreground shadow-sm transition-colors hover:border-primary/25 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 sm:gap-2 sm:px-2.5"
|
className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-border/70 bg-background/70 px-2 text-xs text-muted-foreground shadow-sm transition-colors hover:border-primary/25 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 sm:gap-2 sm:px-2.5"
|
||||||
title={`${usedTokens.toLocaleString()} tokens used`}
|
title={`${usedTokens.toLocaleString()} tokens used`}
|
||||||
aria-label="Show token usage"
|
aria-label="Show token usage"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
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;
|
|
||||||
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,
|
|
||||||
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}
|
|
||||||
showRawParameters={showRawParameters}
|
|
||||||
showThinking={showThinking}
|
|
||||||
selectedProject={selectedProject}
|
|
||||||
provider={provider}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export const CODE_EDITOR_STORAGE_KEYS = {
|
export const CODE_EDITOR_STORAGE_KEYS = {
|
||||||
|
theme: 'codeEditorTheme',
|
||||||
wordWrap: 'codeEditorWordWrap',
|
wordWrap: 'codeEditorWordWrap',
|
||||||
showMinimap: 'codeEditorShowMinimap',
|
showMinimap: 'codeEditorShowMinimap',
|
||||||
lineNumbers: 'codeEditorLineNumbers',
|
lineNumbers: 'codeEditorLineNumbers',
|
||||||
@@ -6,6 +7,7 @@ export const CODE_EDITOR_STORAGE_KEYS = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const CODE_EDITOR_DEFAULTS = {
|
export const CODE_EDITOR_DEFAULTS = {
|
||||||
|
isDarkMode: true,
|
||||||
wordWrap: false,
|
wordWrap: false,
|
||||||
minimapEnabled: true,
|
minimapEnabled: true,
|
||||||
showLineNumbers: true,
|
showLineNumbers: true,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useState } from 'react';
|
|||||||
import { api } from '../../../utils/api';
|
import { api } from '../../../utils/api';
|
||||||
import type { CodeEditorFile } from '../types/types';
|
import type { CodeEditorFile } from '../types/types';
|
||||||
import { isBinaryFile } from '../utils/binaryFile';
|
import { isBinaryFile } from '../utils/binaryFile';
|
||||||
import { getPreviewKind } from '../utils/previewableFile';
|
|
||||||
|
|
||||||
type UseCodeEditorDocumentParams = {
|
type UseCodeEditorDocumentParams = {
|
||||||
file: CodeEditorFile;
|
file: CodeEditorFile;
|
||||||
@@ -24,9 +23,6 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
|||||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
const [isBinary, setIsBinary] = useState(false);
|
const [isBinary, setIsBinary] = useState(false);
|
||||||
// Some binaries (images, PDFs, audio, video) can be rendered natively, so the
|
|
||||||
// editor shows an inline preview instead of the generic binary placeholder.
|
|
||||||
const previewKind = getPreviewKind(file.name);
|
|
||||||
// `fileProjectId` is the DB primary key passed down from the editor sidebar;
|
// `fileProjectId` is the DB primary key passed down from the editor sidebar;
|
||||||
// the fallback to `projectPath` preserves older callers that didn't yet
|
// the fallback to `projectPath` preserves older callers that didn't yet
|
||||||
// propagate the identifier.
|
// propagate the identifier.
|
||||||
@@ -42,19 +38,8 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setIsBinary(false);
|
setIsBinary(false);
|
||||||
|
|
||||||
// Natively previewable media (image/pdf/audio/video) is rendered by
|
|
||||||
// CodeEditorMediaPreview, so there is nothing to read as text here.
|
|
||||||
// Clear any buffer left over from a previously opened text file so a
|
|
||||||
// stray save can't write stale content over the binary file.
|
|
||||||
if (getPreviewKind(file.name)) {
|
|
||||||
setContent('');
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if file is binary by extension
|
// Check if file is binary by extension
|
||||||
if (isBinaryFile(file.name)) {
|
if (isBinaryFile(file.name)) {
|
||||||
setContent('');
|
|
||||||
setIsBinary(true);
|
setIsBinary(true);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
@@ -91,12 +76,6 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
|||||||
}, [file.diffInfo, file.name, fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectId]);
|
}, [file.diffInfo, file.name, fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectId]);
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
// Preview-only and binary files have no editable text buffer; never write
|
|
||||||
// them back (e.g. via Cmd/Ctrl+S) or we'd corrupt the file on disk.
|
|
||||||
if (previewKind || isBinaryFile(fileName)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
|
|
||||||
@@ -130,7 +109,7 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
|||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [content, filePath, fileProjectId, previewKind, fileName]);
|
}, [content, filePath, fileProjectId]);
|
||||||
|
|
||||||
const handleDownload = useCallback(() => {
|
const handleDownload = useCallback(() => {
|
||||||
const blob = new Blob([content], { type: 'text/plain' });
|
const blob = new Blob([content], { type: 'text/plain' });
|
||||||
@@ -155,8 +134,6 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
|||||||
saveSuccess,
|
saveSuccess,
|
||||||
saveError,
|
saveError,
|
||||||
isBinary,
|
isBinary,
|
||||||
previewKind,
|
|
||||||
fileProjectId,
|
|
||||||
handleSave,
|
handleSave,
|
||||||
handleDownload,
|
handleDownload,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ import {
|
|||||||
CODE_EDITOR_STORAGE_KEYS,
|
CODE_EDITOR_STORAGE_KEYS,
|
||||||
} from '../constants/settings';
|
} from '../constants/settings';
|
||||||
|
|
||||||
|
const readTheme = () => {
|
||||||
|
const savedTheme = localStorage.getItem(CODE_EDITOR_STORAGE_KEYS.theme);
|
||||||
|
if (!savedTheme) {
|
||||||
|
return CODE_EDITOR_DEFAULTS.isDarkMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
return savedTheme === 'dark';
|
||||||
|
};
|
||||||
|
|
||||||
const readBoolean = (storageKey: string, defaultValue: boolean, falseValue = 'false') => {
|
const readBoolean = (storageKey: string, defaultValue: boolean, falseValue = 'false') => {
|
||||||
const value = localStorage.getItem(storageKey);
|
const value = localStorage.getItem(storageKey);
|
||||||
if (value === null) {
|
if (value === null) {
|
||||||
@@ -24,6 +33,7 @@ const readFontSize = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const useCodeEditorSettings = () => {
|
export const useCodeEditorSettings = () => {
|
||||||
|
const [isDarkMode, setIsDarkMode] = useState(readTheme);
|
||||||
const [wordWrap, setWordWrap] = useState(readWordWrap);
|
const [wordWrap, setWordWrap] = useState(readWordWrap);
|
||||||
const [minimapEnabled, setMinimapEnabled] = useState(() => (
|
const [minimapEnabled, setMinimapEnabled] = useState(() => (
|
||||||
readBoolean(CODE_EDITOR_STORAGE_KEYS.showMinimap, CODE_EDITOR_DEFAULTS.minimapEnabled)
|
readBoolean(CODE_EDITOR_STORAGE_KEYS.showMinimap, CODE_EDITOR_DEFAULTS.minimapEnabled)
|
||||||
@@ -33,13 +43,18 @@ export const useCodeEditorSettings = () => {
|
|||||||
));
|
));
|
||||||
const [fontSize, setFontSize] = useState(readFontSize);
|
const [fontSize, setFontSize] = useState(readFontSize);
|
||||||
|
|
||||||
// Keep legacy behavior where the editor writes wrap settings directly.
|
// Keep legacy behavior where the editor writes theme and wrap settings directly.
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(CODE_EDITOR_STORAGE_KEYS.theme, isDarkMode ? 'dark' : 'light');
|
||||||
|
}, [isDarkMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem(CODE_EDITOR_STORAGE_KEYS.wordWrap, String(wordWrap));
|
localStorage.setItem(CODE_EDITOR_STORAGE_KEYS.wordWrap, String(wordWrap));
|
||||||
}, [wordWrap]);
|
}, [wordWrap]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const refreshFromStorage = () => {
|
const refreshFromStorage = () => {
|
||||||
|
setIsDarkMode(readTheme());
|
||||||
setWordWrap(readWordWrap());
|
setWordWrap(readWordWrap());
|
||||||
setMinimapEnabled(readBoolean(CODE_EDITOR_STORAGE_KEYS.showMinimap, CODE_EDITOR_DEFAULTS.minimapEnabled));
|
setMinimapEnabled(readBoolean(CODE_EDITOR_STORAGE_KEYS.showMinimap, CODE_EDITOR_DEFAULTS.minimapEnabled));
|
||||||
setShowLineNumbers(readBoolean(CODE_EDITOR_STORAGE_KEYS.lineNumbers, CODE_EDITOR_DEFAULTS.showLineNumbers));
|
setShowLineNumbers(readBoolean(CODE_EDITOR_STORAGE_KEYS.lineNumbers, CODE_EDITOR_DEFAULTS.showLineNumbers));
|
||||||
@@ -56,6 +71,8 @@ export const useCodeEditorSettings = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
isDarkMode,
|
||||||
|
setIsDarkMode,
|
||||||
wordWrap,
|
wordWrap,
|
||||||
setWordWrap,
|
setWordWrap,
|
||||||
minimapEnabled,
|
minimapEnabled,
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
// Some binary files can't be edited as text, but the browser can still render
|
|
||||||
// them natively (images, PDFs, audio, video). For those we show an inline
|
|
||||||
// preview instead of the generic "binary file" placeholder. Anything not listed
|
|
||||||
// here (zip, exe, avi, mkv, fonts, ...) falls through to the binary message.
|
|
||||||
|
|
||||||
export type PreviewKind = 'image' | 'pdf' | 'video' | 'audio';
|
|
||||||
|
|
||||||
// Single source of truth: every extension the browser can preview, mapped to the
|
|
||||||
// MIME type we apply when the server response has a missing/generic Content-Type.
|
|
||||||
// The preview kind is derived from the MIME type so the two never drift apart.
|
|
||||||
// Formats browsers generally can't play (avi, mkv, flv, wmv) are intentionally
|
|
||||||
// absent and keep the binary fallback.
|
|
||||||
const EXTENSION_MIME: Record<string, string> = {
|
|
||||||
// Images
|
|
||||||
png: 'image/png',
|
|
||||||
jpg: 'image/jpeg',
|
|
||||||
jpeg: 'image/jpeg',
|
|
||||||
gif: 'image/gif',
|
|
||||||
svg: 'image/svg+xml',
|
|
||||||
webp: 'image/webp',
|
|
||||||
ico: 'image/x-icon',
|
|
||||||
bmp: 'image/bmp',
|
|
||||||
avif: 'image/avif',
|
|
||||||
apng: 'image/apng',
|
|
||||||
// PDF
|
|
||||||
pdf: 'application/pdf',
|
|
||||||
// Video
|
|
||||||
mp4: 'video/mp4',
|
|
||||||
webm: 'video/webm',
|
|
||||||
ogv: 'video/ogg',
|
|
||||||
mov: 'video/quicktime',
|
|
||||||
m4v: 'video/x-m4v',
|
|
||||||
// Audio
|
|
||||||
mp3: 'audio/mpeg',
|
|
||||||
wav: 'audio/wav',
|
|
||||||
m4a: 'audio/mp4',
|
|
||||||
aac: 'audio/aac',
|
|
||||||
flac: 'audio/flac',
|
|
||||||
opus: 'audio/opus',
|
|
||||||
oga: 'audio/ogg',
|
|
||||||
ogg: 'audio/ogg',
|
|
||||||
weba: 'audio/webm',
|
|
||||||
};
|
|
||||||
|
|
||||||
const extensionOf = (filename: string): string => filename.split('.').pop()?.toLowerCase() ?? '';
|
|
||||||
|
|
||||||
const kindForMime = (mime: string): PreviewKind | null => {
|
|
||||||
if (mime === 'application/pdf') return 'pdf';
|
|
||||||
if (mime.startsWith('image/')) return 'image';
|
|
||||||
if (mime.startsWith('video/')) return 'video';
|
|
||||||
if (mime.startsWith('audio/')) return 'audio';
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getPreviewKind = (filename: string): PreviewKind | null => {
|
|
||||||
const mime = EXTENSION_MIME[extensionOf(filename)];
|
|
||||||
return mime ? kindForMime(mime) : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// MIME type to fall back to when the server returns no/generic Content-Type.
|
|
||||||
// Returns undefined for non-previewable extensions.
|
|
||||||
export const getPreviewMimeType = (filename: string): string | undefined =>
|
|
||||||
EXTENSION_MIME[extensionOf(filename)];
|
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
import { EditorView } from '@codemirror/view';
|
import { EditorView } from '@codemirror/view';
|
||||||
import { unifiedMergeView } from '@codemirror/merge';
|
import { unifiedMergeView } from '@codemirror/merge';
|
||||||
import type { Extension } from '@codemirror/state';
|
import type { Extension } from '@codemirror/state';
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
|
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
|
||||||
import { useTheme } from '../../../contexts/ThemeContext';
|
|
||||||
import { useCodeEditorDocument } from '../hooks/useCodeEditorDocument';
|
import { useCodeEditorDocument } from '../hooks/useCodeEditorDocument';
|
||||||
import { useCodeEditorSettings } from '../hooks/useCodeEditorSettings';
|
import { useCodeEditorSettings } from '../hooks/useCodeEditorSettings';
|
||||||
import { useEditorKeyboardShortcuts } from '../hooks/useEditorKeyboardShortcuts';
|
import { useEditorKeyboardShortcuts } from '../hooks/useEditorKeyboardShortcuts';
|
||||||
@@ -13,13 +11,11 @@ import type { CodeEditorFile } from '../types/types';
|
|||||||
import { createMinimapExtension, createScrollToFirstChunkExtension, getLanguageExtensions } from '../utils/editorExtensions';
|
import { createMinimapExtension, createScrollToFirstChunkExtension, getLanguageExtensions } from '../utils/editorExtensions';
|
||||||
import { getEditorStyles } from '../utils/editorStyles';
|
import { getEditorStyles } from '../utils/editorStyles';
|
||||||
import { createEditorToolbarPanelExtension } from '../utils/editorToolbarPanel';
|
import { createEditorToolbarPanelExtension } from '../utils/editorToolbarPanel';
|
||||||
|
|
||||||
import CodeEditorFooter from './subcomponents/CodeEditorFooter';
|
import CodeEditorFooter from './subcomponents/CodeEditorFooter';
|
||||||
import CodeEditorHeader from './subcomponents/CodeEditorHeader';
|
import CodeEditorHeader from './subcomponents/CodeEditorHeader';
|
||||||
import CodeEditorLoadingState from './subcomponents/CodeEditorLoadingState';
|
import CodeEditorLoadingState from './subcomponents/CodeEditorLoadingState';
|
||||||
import CodeEditorSurface from './subcomponents/CodeEditorSurface';
|
import CodeEditorSurface from './subcomponents/CodeEditorSurface';
|
||||||
import CodeEditorBinaryFile from './subcomponents/CodeEditorBinaryFile';
|
import CodeEditorBinaryFile from './subcomponents/CodeEditorBinaryFile';
|
||||||
import CodeEditorMediaPreview from './subcomponents/CodeEditorMediaPreview';
|
|
||||||
|
|
||||||
type CodeEditorProps = {
|
type CodeEditorProps = {
|
||||||
file: CodeEditorFile;
|
file: CodeEditorFile;
|
||||||
@@ -46,10 +42,8 @@ export default function CodeEditor({
|
|||||||
const [showDiff, setShowDiff] = useState(Boolean(file.diffInfo));
|
const [showDiff, setShowDiff] = useState(Boolean(file.diffInfo));
|
||||||
const [markdownPreview, setMarkdownPreview] = useState(false);
|
const [markdownPreview, setMarkdownPreview] = useState(false);
|
||||||
|
|
||||||
// The code editor follows the app-wide theme; it has no theme of its own.
|
|
||||||
const { isDarkMode } = useTheme();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
isDarkMode,
|
||||||
wordWrap,
|
wordWrap,
|
||||||
minimapEnabled,
|
minimapEnabled,
|
||||||
showLineNumbers,
|
showLineNumbers,
|
||||||
@@ -64,8 +58,6 @@ export default function CodeEditor({
|
|||||||
saveSuccess,
|
saveSuccess,
|
||||||
saveError,
|
saveError,
|
||||||
isBinary,
|
isBinary,
|
||||||
previewKind,
|
|
||||||
fileProjectId,
|
|
||||||
handleSave,
|
handleSave,
|
||||||
handleDownload,
|
handleDownload,
|
||||||
} = useCodeEditorDocument({
|
} = useCodeEditorDocument({
|
||||||
@@ -78,29 +70,6 @@ export default function CodeEditor({
|
|||||||
return extension === 'md' || extension === 'markdown';
|
return extension === 'md' || extension === 'markdown';
|
||||||
}, [file.name]);
|
}, [file.name]);
|
||||||
|
|
||||||
const isHtmlPreviewFile = useMemo(() => {
|
|
||||||
const extension = file.name.split('.').pop()?.toLowerCase();
|
|
||||||
return extension === 'html' || extension === 'htm';
|
|
||||||
}, [file.name]);
|
|
||||||
|
|
||||||
const openHtmlPreview = useCallback(() => {
|
|
||||||
const previewWindow = window.open('', '_blank');
|
|
||||||
if (!previewWindow) return;
|
|
||||||
|
|
||||||
previewWindow.opener = null;
|
|
||||||
previewWindow.document.title = file.name;
|
|
||||||
previewWindow.document.body.style.margin = '0';
|
|
||||||
|
|
||||||
const iframe = previewWindow.document.createElement('iframe');
|
|
||||||
iframe.title = file.name;
|
|
||||||
iframe.sandbox.add('allow-forms', 'allow-modals', 'allow-popups', 'allow-scripts');
|
|
||||||
iframe.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;border:0;background:white';
|
|
||||||
|
|
||||||
iframe.srcdoc = content;
|
|
||||||
|
|
||||||
previewWindow.document.body.appendChild(iframe);
|
|
||||||
}, [content, file.name]);
|
|
||||||
|
|
||||||
const minimapExtension = useMemo(
|
const minimapExtension = useMemo(
|
||||||
() => (
|
() => (
|
||||||
createMinimapExtension({
|
createMinimapExtension({
|
||||||
@@ -193,30 +162,6 @@ export default function CodeEditor({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Natively previewable media (image/pdf/audio/video) is rendered inline
|
|
||||||
// instead of showing the generic "cannot be displayed" placeholder.
|
|
||||||
if (previewKind) {
|
|
||||||
return (
|
|
||||||
<CodeEditorMediaPreview
|
|
||||||
file={file}
|
|
||||||
kind={previewKind}
|
|
||||||
projectId={fileProjectId}
|
|
||||||
isSidebar={isSidebar}
|
|
||||||
isFullscreen={isFullscreen}
|
|
||||||
onClose={onClose}
|
|
||||||
onToggleFullscreen={() => setIsFullscreen((previous) => !previous)}
|
|
||||||
labels={{
|
|
||||||
loading: t('filePreview.loading', 'Loading preview...'),
|
|
||||||
error: t('filePreview.error', 'Unable to display this file.'),
|
|
||||||
openInNewTab: t('filePreview.openInNewTab', 'Open in new tab'),
|
|
||||||
fullscreen: t('actions.fullscreen', 'Fullscreen'),
|
|
||||||
exitFullscreen: t('actions.exitFullscreen', 'Exit fullscreen'),
|
|
||||||
close: t('actions.close', 'Close'),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Binary file display
|
// Binary file display
|
||||||
if (isBinary) {
|
if (isBinary) {
|
||||||
return (
|
return (
|
||||||
@@ -252,12 +197,10 @@ export default function CodeEditor({
|
|||||||
isSidebar={isSidebar}
|
isSidebar={isSidebar}
|
||||||
isFullscreen={isFullscreen}
|
isFullscreen={isFullscreen}
|
||||||
isMarkdownFile={isMarkdownFile}
|
isMarkdownFile={isMarkdownFile}
|
||||||
isHtmlPreviewFile={isHtmlPreviewFile}
|
|
||||||
markdownPreview={markdownPreview}
|
markdownPreview={markdownPreview}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
saveSuccess={saveSuccess}
|
saveSuccess={saveSuccess}
|
||||||
onToggleMarkdownPreview={() => setMarkdownPreview((previous) => !previous)}
|
onToggleMarkdownPreview={() => setMarkdownPreview((previous) => !previous)}
|
||||||
onOpenHtmlPreview={openHtmlPreview}
|
|
||||||
onOpenSettings={() => paletteOps.openSettings('appearance')}
|
onOpenSettings={() => paletteOps.openSettings('appearance')}
|
||||||
onDownload={handleDownload}
|
onDownload={handleDownload}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
@@ -267,7 +210,6 @@ export default function CodeEditor({
|
|||||||
showingChanges: t('header.showingChanges'),
|
showingChanges: t('header.showingChanges'),
|
||||||
editMarkdown: t('actions.editMarkdown'),
|
editMarkdown: t('actions.editMarkdown'),
|
||||||
previewMarkdown: t('actions.previewMarkdown'),
|
previewMarkdown: t('actions.previewMarkdown'),
|
||||||
previewHtml: t('actions.previewHtml', 'Open HTML preview in new tab'),
|
|
||||||
settings: t('toolbar.settings'),
|
settings: t('toolbar.settings'),
|
||||||
download: t('actions.download'),
|
download: t('actions.download'),
|
||||||
save: t('actions.save'),
|
save: t('actions.save'),
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Code2, Download, Eye, Maximize2, Minimize2, Save, Settings as SettingsIcon, X } from 'lucide-react';
|
import { Code2, Download, Eye, Maximize2, Minimize2, Save, Settings as SettingsIcon, X } from 'lucide-react';
|
||||||
|
|
||||||
import type { CodeEditorFile } from '../../types/types';
|
import type { CodeEditorFile } from '../../types/types';
|
||||||
|
|
||||||
type CodeEditorHeaderProps = {
|
type CodeEditorHeaderProps = {
|
||||||
@@ -7,12 +6,10 @@ type CodeEditorHeaderProps = {
|
|||||||
isSidebar: boolean;
|
isSidebar: boolean;
|
||||||
isFullscreen: boolean;
|
isFullscreen: boolean;
|
||||||
isMarkdownFile: boolean;
|
isMarkdownFile: boolean;
|
||||||
isHtmlPreviewFile: boolean;
|
|
||||||
markdownPreview: boolean;
|
markdownPreview: boolean;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
saveSuccess: boolean;
|
saveSuccess: boolean;
|
||||||
onToggleMarkdownPreview: () => void;
|
onToggleMarkdownPreview: () => void;
|
||||||
onOpenHtmlPreview: () => void;
|
|
||||||
onOpenSettings: () => void;
|
onOpenSettings: () => void;
|
||||||
onDownload: () => void;
|
onDownload: () => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
@@ -22,7 +19,6 @@ type CodeEditorHeaderProps = {
|
|||||||
showingChanges: string;
|
showingChanges: string;
|
||||||
editMarkdown: string;
|
editMarkdown: string;
|
||||||
previewMarkdown: string;
|
previewMarkdown: string;
|
||||||
previewHtml: string;
|
|
||||||
settings: string;
|
settings: string;
|
||||||
download: string;
|
download: string;
|
||||||
save: string;
|
save: string;
|
||||||
@@ -39,12 +35,10 @@ export default function CodeEditorHeader({
|
|||||||
isSidebar,
|
isSidebar,
|
||||||
isFullscreen,
|
isFullscreen,
|
||||||
isMarkdownFile,
|
isMarkdownFile,
|
||||||
isHtmlPreviewFile,
|
|
||||||
markdownPreview,
|
markdownPreview,
|
||||||
saving,
|
saving,
|
||||||
saveSuccess,
|
saveSuccess,
|
||||||
onToggleMarkdownPreview,
|
onToggleMarkdownPreview,
|
||||||
onOpenHtmlPreview,
|
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
onDownload,
|
onDownload,
|
||||||
onSave,
|
onSave,
|
||||||
@@ -88,17 +82,6 @@ export default function CodeEditorHeader({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isHtmlPreviewFile && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onOpenHtmlPreview}
|
|
||||||
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
|
||||||
title={labels.previewHtml}
|
|
||||||
>
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onOpenSettings}
|
onClick={onOpenSettings}
|
||||||
|
|||||||
@@ -1,289 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { authenticatedFetch } from '../../../../utils/api';
|
|
||||||
import type { CodeEditorFile } from '../../types/types';
|
|
||||||
import { getPreviewMimeType, type PreviewKind } from '../../utils/previewableFile';
|
|
||||||
|
|
||||||
type CodeEditorMediaPreviewProps = {
|
|
||||||
file: CodeEditorFile;
|
|
||||||
kind: PreviewKind;
|
|
||||||
// DB projectId used to build the raw-content URL; falls back to projectPath
|
|
||||||
// for older callers, mirroring useCodeEditorDocument.
|
|
||||||
projectId?: string;
|
|
||||||
isSidebar: boolean;
|
|
||||||
isFullscreen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onToggleFullscreen: () => void;
|
|
||||||
labels: {
|
|
||||||
loading: string;
|
|
||||||
error: string;
|
|
||||||
openInNewTab: string;
|
|
||||||
fullscreen: string;
|
|
||||||
exitFullscreen: string;
|
|
||||||
close: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Reject a "PDF" whose bytes aren't actually a PDF before handing it to the
|
|
||||||
// same-origin iframe, so a mislabeled HTML/SVG file can't run in the app origin.
|
|
||||||
const PDF_HEADER_SCAN_BYTES = 1024;
|
|
||||||
|
|
||||||
const looksLikePdf = async (blob: Blob): Promise<boolean> => {
|
|
||||||
const header = await blob.slice(0, PDF_HEADER_SCAN_BYTES).arrayBuffer();
|
|
||||||
// PDFs must contain the "%PDF-" marker at the very start of the file.
|
|
||||||
return new TextDecoder('latin1').decode(header).includes('%PDF-');
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function CodeEditorMediaPreview({
|
|
||||||
file,
|
|
||||||
kind,
|
|
||||||
projectId,
|
|
||||||
isSidebar,
|
|
||||||
isFullscreen,
|
|
||||||
onClose,
|
|
||||||
onToggleFullscreen,
|
|
||||||
labels,
|
|
||||||
}: CodeEditorMediaPreviewProps) {
|
|
||||||
const [url, setUrl] = useState<string | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
// Identifies which file the current `url` was loaded for. Rendering is gated on
|
|
||||||
// this so a blob from a previously-opened file can never show under the new
|
|
||||||
// file (the editor reuses this component instance across files).
|
|
||||||
const [loadedKey, setLoadedKey] = useState<string | null>(null);
|
|
||||||
const sourceKey = `${projectId ?? ''}:${file.path}:${kind}`;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!projectId) {
|
|
||||||
setUrl(null);
|
|
||||||
setLoadedKey(null);
|
|
||||||
setError(labels.error);
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let objectUrl: string | null = null;
|
|
||||||
const controller = new AbortController();
|
|
||||||
|
|
||||||
const loadMedia = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
setUrl(null);
|
|
||||||
|
|
||||||
// The content endpoint requires the auth header, so we fetch the bytes
|
|
||||||
// ourselves and hand the media element a blob URL instead of a bare src.
|
|
||||||
// Fetching a blob (rather than streaming) also lets <video>/<audio> seek.
|
|
||||||
const contentUrl = `/api/projects/${projectId}/files/content?path=${encodeURIComponent(file.path)}`;
|
|
||||||
const response = await authenticatedFetch(contentUrl, { signal: controller.signal });
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Request failed with status ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
|
|
||||||
// Pick the MIME type to expose to the browser. Preserve a valid
|
|
||||||
// Content-Type from the server, but supply an extension-specific
|
|
||||||
// default when it is missing or generic (application/octet-stream),
|
|
||||||
// otherwise formats like webm/ogg/flac/svg won't render.
|
|
||||||
const fallbackMime = getPreviewMimeType(file.name);
|
|
||||||
const isGenericType = !blob.type || blob.type === 'application/octet-stream';
|
|
||||||
const isMislabeledVideo = kind === 'video' && Boolean(fallbackMime) && !blob.type.startsWith('video/');
|
|
||||||
let outType = isGenericType || isMislabeledVideo ? (fallbackMime ?? blob.type) : blob.type;
|
|
||||||
|
|
||||||
if (kind === 'pdf') {
|
|
||||||
// The PDF renders in a same-origin <iframe>, so verify the bytes are
|
|
||||||
// really a PDF and pin the type to application/pdf. That forces the
|
|
||||||
// browser's PDF handler and prevents a mislabeled HTML/SVG file from
|
|
||||||
// executing scripts in the app's origin.
|
|
||||||
if (!(await looksLikePdf(blob))) {
|
|
||||||
throw new Error('File is not a valid PDF');
|
|
||||||
}
|
|
||||||
outType = 'application/pdf';
|
|
||||||
}
|
|
||||||
|
|
||||||
const typed = outType && outType !== blob.type ? new Blob([blob], { type: outType }) : blob;
|
|
||||||
objectUrl = URL.createObjectURL(typed);
|
|
||||||
|
|
||||||
// The cleanup may have already run (deps changed during an await), in
|
|
||||||
// which case it revoked nothing because objectUrl was still null. Don't
|
|
||||||
// publish a URL the cleanup will never revoke — drop it ourselves.
|
|
||||||
if (controller.signal.aborted) {
|
|
||||||
URL.revokeObjectURL(objectUrl);
|
|
||||||
objectUrl = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setUrl(objectUrl);
|
|
||||||
setLoadedKey(sourceKey);
|
|
||||||
} catch (loadError: unknown) {
|
|
||||||
if (loadError instanceof Error && loadError.name === 'AbortError') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.error('Error loading preview:', loadError);
|
|
||||||
setError(labels.error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadMedia();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
controller.abort();
|
|
||||||
if (objectUrl) {
|
|
||||||
URL.revokeObjectURL(objectUrl);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [file.path, file.name, projectId, kind, sourceKey, labels.error]);
|
|
||||||
|
|
||||||
// Only expose the blob once it matches the file currently being shown, so a
|
|
||||||
// stale URL from the previous file is never rendered during a switch.
|
|
||||||
const currentUrl = url && loadedKey === sourceKey ? url : null;
|
|
||||||
|
|
||||||
// SVGs render safely inline via <img> (scripts don't execute there), but the
|
|
||||||
// open-in-new-tab link is a top-level navigation. A blob URL inherits the
|
|
||||||
// app's origin, so a user-controlled SVG with an embedded <script> would run
|
|
||||||
// as same-origin script. Withhold the new-tab action for SVGs.
|
|
||||||
const isSvg = getPreviewMimeType(file.name) === 'image/svg+xml';
|
|
||||||
const canOpenInNewTab = Boolean(currentUrl) && !isSvg;
|
|
||||||
|
|
||||||
const renderMedia = () => {
|
|
||||||
if (!currentUrl) return null;
|
|
||||||
switch (kind) {
|
|
||||||
case 'image':
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
src={currentUrl}
|
|
||||||
alt={file.name}
|
|
||||||
className="max-h-full max-w-full object-contain"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'pdf':
|
|
||||||
// Not sandboxed on purpose: the browser's built-in PDF viewer refuses to
|
|
||||||
// load inside a sandboxed frame (any `sandbox` value yields a broken
|
|
||||||
// viewer). Script execution is instead prevented upstream by validating
|
|
||||||
// the PDF magic bytes and pinning the blob's MIME type to application/pdf.
|
|
||||||
return <iframe src={currentUrl} title={file.name} className="h-full w-full border-0 bg-white" />;
|
|
||||||
case 'video':
|
|
||||||
return (
|
|
||||||
<video src={currentUrl} controls className="max-h-full max-w-full" autoPlay={false}>
|
|
||||||
{labels.error}
|
|
||||||
</video>
|
|
||||||
);
|
|
||||||
case 'audio':
|
|
||||||
return (
|
|
||||||
<div className="flex w-full max-w-xl flex-col items-center gap-4 px-6">
|
|
||||||
<p className="max-w-full truncate text-sm text-muted-foreground">{file.name}</p>
|
|
||||||
<audio src={currentUrl} controls className="w-full">
|
|
||||||
{labels.error}
|
|
||||||
</audio>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const previewBody = (
|
|
||||||
<div className="relative flex h-full w-full flex-col items-center justify-center bg-muted/30 p-2">
|
|
||||||
{loading && (
|
|
||||||
<div className="text-sm text-muted-foreground">{labels.loading}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && currentUrl && renderMedia()}
|
|
||||||
|
|
||||||
{!loading && !currentUrl && (
|
|
||||||
<div className="flex flex-col items-center gap-3 p-8 text-center text-muted-foreground">
|
|
||||||
<p className="text-sm">{error || labels.error}</p>
|
|
||||||
<p className="break-all text-xs">{file.path}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const headerActions = (
|
|
||||||
<div className="flex shrink-0 items-center gap-0.5">
|
|
||||||
{canOpenInNewTab && currentUrl && (
|
|
||||||
<a
|
|
||||||
href={currentUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
|
||||||
aria-label={labels.openInNewTab}
|
|
||||||
title={labels.openInNewTab}
|
|
||||||
>
|
|
||||||
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{!isSidebar && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onToggleFullscreen}
|
|
||||||
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
|
||||||
aria-label={isFullscreen ? labels.exitFullscreen : labels.fullscreen}
|
|
||||||
title={isFullscreen ? labels.exitFullscreen : labels.fullscreen}
|
|
||||||
>
|
|
||||||
{isFullscreen ? (
|
|
||||||
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 9V4.5M9 9H4.5M9 9L3.5 3.5M9 15v4.5M9 15H4.5M9 15l-5.5 5.5M15 9h4.5M15 9V4.5M15 9l5.5-5.5M15 15h4.5M15 15v4.5m0-4.5l5.5 5.5" />
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
|
||||||
aria-label={labels.close}
|
|
||||||
title={labels.close}
|
|
||||||
>
|
|
||||||
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const header = (
|
|
||||||
<div className="flex flex-shrink-0 items-center justify-between border-b border-border px-3 py-1.5">
|
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
||||||
<h3 className="truncate text-sm font-medium text-gray-900 dark:text-white">{file.name}</h3>
|
|
||||||
</div>
|
|
||||||
{headerActions}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isSidebar) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full flex-col bg-background">
|
|
||||||
{header}
|
|
||||||
{previewBody}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const containerClassName = isFullscreen
|
|
||||||
? 'fixed inset-0 z-[9999] bg-background flex flex-col'
|
|
||||||
: 'fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center md:p-4';
|
|
||||||
|
|
||||||
const innerClassName = isFullscreen
|
|
||||||
? 'bg-background flex flex-col w-full h-full'
|
|
||||||
: 'bg-background shadow-2xl flex flex-col w-full h-full md:rounded-lg md:shadow-2xl md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={containerClassName}>
|
|
||||||
<div className={innerClassName}>
|
|
||||||
{header}
|
|
||||||
{previewBody}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { ComponentProps } from 'react';
|
import type { ComponentProps } from 'react';
|
||||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
import { oneDark as prismOneDark, oneLight as prismOneLight } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
import { oneDark as prismOneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||||
import { copyTextToClipboard } from '../../../../../utils/clipboard';
|
import { copyTextToClipboard } from '../../../../../utils/clipboard';
|
||||||
import { useTheme } from '../../../../../contexts/ThemeContext';
|
|
||||||
|
|
||||||
type MarkdownCodeBlockProps = {
|
type MarkdownCodeBlockProps = {
|
||||||
inline?: boolean;
|
inline?: boolean;
|
||||||
@@ -17,7 +16,6 @@ export default function MarkdownCodeBlock({
|
|||||||
node: _node,
|
node: _node,
|
||||||
...props
|
...props
|
||||||
}: MarkdownCodeBlockProps) {
|
}: MarkdownCodeBlockProps) {
|
||||||
const { isDarkMode } = useTheme();
|
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const rawContent = Array.isArray(children) ? children.join('') : String(children ?? '');
|
const rawContent = Array.isArray(children) ? children.join('') : String(children ?? '');
|
||||||
const looksMultiline = /[\r\n]/.test(rawContent);
|
const looksMultiline = /[\r\n]/.test(rawContent);
|
||||||
@@ -52,22 +50,20 @@ export default function MarkdownCodeBlock({
|
|||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
className="absolute right-2 top-2 z-10 rounded-md border border-border bg-card/90 px-2 py-1 text-xs text-foreground/80 opacity-0 transition-opacity hover:bg-muted group-hover:opacity-100"
|
className="absolute right-2 top-2 z-10 rounded-md border border-gray-600 bg-gray-700/80 px-2 py-1 text-xs text-white opacity-0 transition-opacity hover:bg-gray-700 group-hover:opacity-100"
|
||||||
>
|
>
|
||||||
{copied ? 'Copied!' : 'Copy'}
|
{copied ? 'Copied!' : 'Copy'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<SyntaxHighlighter
|
<SyntaxHighlighter
|
||||||
language={language}
|
language={language}
|
||||||
style={isDarkMode ? prismOneDark : prismOneLight}
|
style={prismOneDark}
|
||||||
customStyle={{
|
customStyle={{
|
||||||
margin: 0,
|
margin: 0,
|
||||||
borderRadius: '0.75rem',
|
borderRadius: '0.5rem',
|
||||||
fontSize: '0.875rem',
|
fontSize: '0.875rem',
|
||||||
padding: language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',
|
padding: language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',
|
||||||
...(isDarkMode ? {} : { background: 'hsl(var(--muted))' }),
|
|
||||||
}}
|
}}
|
||||||
codeTagProps={{ style: isDarkMode ? {} : { background: 'transparent' } }}
|
|
||||||
>
|
>
|
||||||
{rawContent}
|
{rawContent}
|
||||||
</SyntaxHighlighter>
|
</SyntaxHighlighter>
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ type MarkdownPreviewProps = {
|
|||||||
|
|
||||||
const markdownPreviewComponents: Components = {
|
const markdownPreviewComponents: Components = {
|
||||||
code: MarkdownCodeBlock,
|
code: MarkdownCodeBlock,
|
||||||
// MarkdownCodeBlock renders its own highlighted <pre>; passthrough prevents a
|
|
||||||
// second Typography-styled <pre> shell from framing it.
|
|
||||||
pre: ({ children }) => <>{children}</>,
|
|
||||||
blockquote: ({ children }) => (
|
blockquote: ({ children }) => (
|
||||||
<blockquote className="my-2 border-l-4 border-gray-300 pl-4 italic text-gray-600 dark:border-gray-600 dark:text-gray-400">
|
<blockquote className="my-2 border-l-4 border-gray-300 pl-4 italic text-gray-600 dark:border-gray-600 dark:text-gray-400">
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ export default function GitPanelHeader({
|
|||||||
<button
|
<button
|
||||||
onClick={requestPublishConfirmation}
|
onClick={requestPublishConfirmation}
|
||||||
disabled={anyPending}
|
disabled={anyPending}
|
||||||
className="flex items-center gap-1 rounded-lg bg-primary px-2.5 py-1 text-sm text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
className="flex items-center gap-1 rounded-lg bg-purple-600 px-2.5 py-1 text-sm text-white transition-colors hover:bg-purple-700 disabled:opacity-50"
|
||||||
title={`Publish "${currentBranch}" to ${remoteName}`}
|
title={`Publish "${currentBranch}" to ${remoteName}`}
|
||||||
>
|
>
|
||||||
<Upload className={`h-3 w-3 ${isPublishing ? 'animate-pulse' : ''}`} />
|
<Upload className={`h-3 w-3 ${isPublishing ? 'animate-pulse' : ''}`} />
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ 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';
|
||||||
@@ -54,7 +53,7 @@ function MainContent({
|
|||||||
newSessionTrigger,
|
newSessionTrigger,
|
||||||
}: MainContentProps) {
|
}: MainContentProps) {
|
||||||
const { preferences } = useUiPreferences();
|
const { preferences } = useUiPreferences();
|
||||||
const { showRawParameters, showThinking, sendByCtrlEnter } = preferences;
|
const { autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter } = preferences;
|
||||||
|
|
||||||
const { currentProject, setCurrentProject } = useTaskMaster() as TaskMasterContextValue;
|
const { currentProject, setCurrentProject } = useTaskMaster() as TaskMasterContextValue;
|
||||||
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings() as TasksSettingsContextValue;
|
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings() as TasksSettingsContextValue;
|
||||||
@@ -78,10 +77,6 @@ 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.
|
||||||
@@ -126,10 +121,6 @@ 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) {
|
||||||
@@ -170,8 +161,10 @@ function MainContent({
|
|||||||
onNavigateToSession={onNavigateToSession}
|
onNavigateToSession={onNavigateToSession}
|
||||||
onSessionEstablished={onSessionEstablished}
|
onSessionEstablished={onSessionEstablished}
|
||||||
onShowSettings={onShowSettings}
|
onShowSettings={onShowSettings}
|
||||||
|
autoExpandTools={autoExpandTools}
|
||||||
showRawParameters={showRawParameters}
|
showRawParameters={showRawParameters}
|
||||||
showThinking={showThinking}
|
showThinking={showThinking}
|
||||||
|
autoScrollToBottom={autoScrollToBottom}
|
||||||
sendByCtrlEnter={sendByCtrlEnter}
|
sendByCtrlEnter={sendByCtrlEnter}
|
||||||
externalMessageUpdate={externalMessageUpdate}
|
externalMessageUpdate={externalMessageUpdate}
|
||||||
newSessionTrigger={newSessionTrigger}
|
newSessionTrigger={newSessionTrigger}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ function getTabTitle(activeTab: AppTab, shouldShowTasksTab: boolean, t: (key: st
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (activeTab === 'browser') {
|
if (activeTab === 'browser') {
|
||||||
return t('tabs.browser');
|
return 'Browser';
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'Project';
|
return 'Project';
|
||||||
@@ -70,7 +70,7 @@ export default function MainContentTitle({
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{activeTab === 'chat' && selectedSession ? (
|
{activeTab === 'chat' && selectedSession ? (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 title={getSessionTitle(selectedSession)} className="truncate text-sm font-semibold leading-tight text-foreground">
|
<h2 className="scrollbar-hide overflow-x-auto whitespace-nowrap text-sm font-semibold leading-tight text-foreground">
|
||||||
{getSessionTitle(selectedSession)}
|
{getSessionTitle(selectedSession)}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="truncate text-[11px] leading-tight text-muted-foreground">{selectedProject.displayName}</div>
|
<div className="truncate text-[11px] leading-tight text-muted-foreground">{selectedProject.displayName}</div>
|
||||||
|
|||||||
@@ -29,11 +29,11 @@ export const MCP_GLOBAL_SUPPORTED_SCOPES: McpScope[] = ['user', 'project'];
|
|||||||
export const MCP_GLOBAL_SUPPORTED_TRANSPORTS: McpTransport[] = ['stdio', 'http'];
|
export const MCP_GLOBAL_SUPPORTED_TRANSPORTS: McpTransport[] = ['stdio', 'http'];
|
||||||
|
|
||||||
export const MCP_PROVIDER_BUTTON_CLASSES: Record<McpProvider, string> = {
|
export const MCP_PROVIDER_BUTTON_CLASSES: Record<McpProvider, string> = {
|
||||||
claude: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
claude: 'bg-purple-600 text-white hover:bg-purple-700',
|
||||||
cursor: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
cursor: 'bg-purple-600 text-white hover:bg-purple-700',
|
||||||
codex: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
codex: 'bg-gray-800 text-white hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600',
|
||||||
gemini: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
gemini: 'bg-blue-600 text-white hover:bg-blue-700',
|
||||||
opencode: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
opencode: 'bg-zinc-900 text-white hover:bg-zinc-800 dark:bg-zinc-700 dark:hover:bg-zinc-600',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MCP_SUPPORTS_WORKING_DIRECTORY: Record<McpProvider, boolean> = {
|
export const MCP_SUPPORTS_WORKING_DIRECTORY: Record<McpProvider, boolean> = {
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export default function McpServers({ selectedProvider, currentProjects }: McpSer
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Server className="h-5 w-5 text-primary" />
|
<Server className="h-5 w-5 text-purple-500" />
|
||||||
<h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
|
<h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">{description}</p>
|
<p className="text-sm text-muted-foreground">{description}</p>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user