Compare commits

...

12 Commits

Author SHA1 Message Date
viper151
d8dfb2cbb6 chore(release): v1.35.1 2026-07-01 15:39:39 +00:00
Simos Mikelatos
1e16f1f085 fix: remove obsolete semantic helper release jobs 2026-07-01 15:33:37 +00:00
Haile
7eb7348d50 Feat/design improvements and minor bug fixes (#939)
* fix(shell): hide prompt options on desktop

* fix(chat): group continuous same-tool runs more consistently

Consecutive tool calls (Edit, Read, Grep, etc.) grouped inconsistently:

- The group threshold was 3, so a run of only 2 calls stayed ungrouped
  while a run of 3 collapsed — making two back-to-back edits look
  different from three.
- A run was broken by any interleaved message, including ones that render
  nothing (reasoning hidden when showThinking is off). Providers like
  Codex interleave hidden reasoning between tool calls, so visually
  continuous edits intermittently failed to group.

Lower TOOL_GROUP_THRESHOLD to 2 and skip non-rendered messages when
extending a run, so any 2+ consecutive same-tool calls collapse reliably.
ChatMessagesPane now passes showThinking into groupConsecutiveTools.

* fix(chat): stabilize message scroll controls

* fix: update command menu positioning

* fix(chat): refine load all overlay behavior

* fix(chat): hide load all prompt after final page

* fix(chat): remove auto scroll quick setting

* fix(chat): unify messages and composer into centered column

Constrain both ChatMessagesPane content and ChatComposer to the same
max-w-3xl centered column. Previously only
the composer had a max-width, causing messages to fill the full width
while the input stayed narrow, making them visually misaligned with
large empty gutters on either side.

* style(ui): rework light/dark theme to make it visually consistent

Rework the color system around warm neutrals and route hardcoded
surfaces through theme tokens for consistency.

- Theme tokens (index.css, ThemeContext): warm cream light mode and
  neutral charcoal dark mode, replacing the pure-white/blue-tinted
  palette; update PWA theme-color meta
- Code blocks: soft grey background in light mode via
  oneLight/oneDark, and drop the Tailwind Typography <pre> shell that
  framed the highlighter in a dark box
- Dropdowns/panels: convert CommandMenu, Quick Settings, and the JSON
  response block from hardcoded gray/slate to popover/muted/border
  tokens
- Git panel: Publish button purple -> primary blue
- Composer: drop top padding so the input sits flush with the thread

* fix: use app theme for code editor

* style(chat): unify composer toolbar heights and declutter slash-command modal

- Composer: give the permission-mode and token-usage buttons a fixed
  h-8 so every bottom-toolbar control shares one height
- CommandResultModal: replace the blue gradient header (gradient fill,
  glow blobs, blue eyebrow + icon chip) with a clean neutral header on
  popover/muted tokens

* fix(chat): header ellipsis, Codex logo on light theme, portal copy menu

- MainContentTitle: truncate the session title with an ellipsis instead
  of horizontal-scrolling it
- MessageComponent: use text-foreground for the provider logo chip so the
  currentColor Codex/OpenAI mark is visible on the light theme
- MessageCopyControl: render the copy-format dropdown in a portal so it
  escapes the chat message's `contain: paint` clip box; anchor it to the
  trigger, flip above near the viewport bottom, close on scroll/resize

* style(mcp): remove purple accents and portal the server form modal

- Replace the purple provider-button colors, heading icon, and form
  submit button with the primary token (no purple in the MCP UI)
- Portal the add/edit MCP server modal to document.body so its fixed
  overlay covers the full viewport, fixing the white band at the top
  caused by the Settings dialog's transformed tab content becoming the
  containing block

* style(ui): use Merriweather serif for chat text and Encode Sans for the rest of the UI

* fix: align activity indicator with composer input width

Wrap ActivityIndicator in the same mx-auto max-w-3xl container as the
text input so the "Analyzing…" label and Stop button stay within the
input's boundaries instead of spanning the full window width.

* style: improve thinking and stop button placements

* style(auth): modernize login, setup, and onboarding screens

* fix(chat): correct invalid dark-mode hover on AskUserQuestion options

* fix: remove unnecessary auto expand tools

* fix: resolve coderabbit comments

* fix(chat): widen chat layout and sidebar titles

* fix(branding): update CloudCLI wordmark styling

---------

Co-authored-by: Simos Mikelatos <simosmik@gmail.com>
2026-07-01 13:57:03 +02:00
Simos Mikelatos
44aecbab68 Clarify desktop companion in README 2026-07-01 11:29:14 +00:00
Simos Mikelatos
18e98a780d Point desktop downloads to CloudCLI 2026-07-01 11:17:27 +00:00
Haile
2ebe64f218 fix: preview video on new tab (#933) 2026-06-29 15:36:31 +02:00
Haile
b6cf33308d fix: resolve mobile shell issues (#923) 2026-06-29 14:19:01 +02:00
Simos Mikelatos
6761f31a56 chore: remove computer use 2026-06-29 10:31:11 +00:00
viper151
35da5d090d chore(release): v1.35.0 2026-06-29 10:07:59 +00:00
Simos Mikelatos
d882f80b6d Consolidate desktop release workflow 2026-06-29 09:40:28 +00:00
Haile
053f244d14 Chat & sidebar UX improvements (#929) 2026-06-29 09:37:24 +00:00
Simos Mikelatos
97c9b67bfc feat: add Electron desktop app 2026-06-29 09:37:21 +00:00
160 changed files with 12060 additions and 1438 deletions

View File

@@ -0,0 +1,109 @@
name: Desktop macOS Branch Build
on:
workflow_dispatch:
push:
branches:
- electron-app
jobs:
build-macos:
name: Build macOS desktop artifact
runs-on: macos-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 0
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Typecheck
run: npm run typecheck
- name: Resolve artifact metadata
id: artifact
run: |
SAFE_REF="$(printf '%s' "${GITHUB_REF_NAME}" | tr -c 'A-Za-z0-9._-' '-')"
echo "name=CloudCLI-macOS-${SAFE_REF}-${GITHUB_RUN_NUMBER}" >> "$GITHUB_OUTPUT"
echo "server_bundle_tag=cloudcli-local-server-${SAFE_REF}" >> "$GITHUB_OUTPUT"
- name: Configure branch server bundle source
run: printf '{"releaseTag":"%s"}\n' "${{ steps.artifact.outputs.server_bundle_tag }}" > electron/server-bundle-config.json
- name: Verify signing secrets are configured
run: |
test -n "$CSC_LINK"
test -n "$CSC_KEY_PASSWORD"
test -n "$APPLE_ID"
test -n "$APPLE_APP_SPECIFIC_PASSWORD"
test -n "$APPLE_TEAM_ID"
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Build signed and notarized macOS artifacts
run: npm run desktop:dist:mac -- --publish never
env:
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Build branch server bundle
run: node scripts/release/build-server-bundle.js
- name: Verify branch server runtime artifacts
run: |
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz' -print -quit)"
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz.sha256' -print -quit)"
- name: Publish branch server bundle
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: ${{ steps.artifact.outputs.server_bundle_tag }}
name: CloudCLI Desktop Local Runtime (${{ github.ref_name }})
body: |
This prerelease is used by CloudCLI Desktop branch builds to run Local mode.
To test this branch, download the desktop app from this workflow run's artifacts. When you open Local CloudCLI, the desktop app automatically downloads the matching runtime from this prerelease.
You do not need to download these runtime files manually.
prerelease: true
fail_on_unmatched_files: false
overwrite_files: true
files: |
release/local-server/*
- name: Verify macOS artifacts
run: |
test -n "$(find release/desktop -maxdepth 1 -name '*.dmg' -print -quit)"
shasum -a 256 release/desktop/*.dmg > release/SHASUMS256.txt
cat release/SHASUMS256.txt
- name: Upload branch build artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: ${{ steps.artifact.outputs.name }}
path: |
release/desktop/*.dmg
release/SHASUMS256.txt
if-no-files-found: error
retention-days: 14

305
.github/workflows/desktop-release.yml vendored Normal file
View File

@@ -0,0 +1,305 @@
name: Desktop Release
on:
workflow_dispatch:
inputs:
tag:
description: "Release tag to create or update (defaults to v<package version>)"
required: false
type: string
release_name:
description: 'Release name (defaults to "CloudCLI Desktop <tag>")'
required: false
type: string
prerelease:
description: "Mark the GitHub release as a prerelease"
required: true
default: false
type: boolean
jobs:
resolve-release:
name: Resolve release metadata
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
tag: ${{ steps.release.outputs.tag }}
release_name: ${{ steps.release.outputs.release_name }}
server_bundle_tag: ${{ steps.release.outputs.server_bundle_tag }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Resolve release metadata
id: release
env:
TAG_INPUT: ${{ inputs.tag }}
RELEASE_NAME_INPUT: ${{ inputs.release_name }}
run: |
VERSION="$(node -p "require('./package.json').version")"
TAG="$TAG_INPUT"
if [ -z "$TAG" ]; then
TAG="v${VERSION}"
fi
TAG="$(printf '%s' "$TAG" | tr -d '\r\n' | sed 's/[^A-Za-z0-9._-]/-/g')"
if [ -z "$TAG" ]; then
echo "Resolved release tag is empty after normalization." >&2
exit 1
fi
RELEASE_NAME="$RELEASE_NAME_INPUT"
if [ -z "$RELEASE_NAME" ]; then
RELEASE_NAME="CloudCLI Desktop ${TAG}"
fi
RELEASE_NAME_DELIMITER="release_name_${GITHUB_RUN_ID}_${GITHUB_RUN_ATTEMPT}"
{
echo "tag=$TAG"
echo "release_name<<$RELEASE_NAME_DELIMITER"
printf '%s\n' "$RELEASE_NAME"
echo "$RELEASE_NAME_DELIMITER"
echo "server_bundle_tag=cloudcli-local-server-${TAG}"
} >> "$GITHUB_OUTPUT"
build-macos:
name: Build signed macOS desktop app
needs: resolve-release
runs-on: macos-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 0
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Typecheck
run: npm run typecheck
- name: Configure release server bundle source
env:
SERVER_BUNDLE_TAG: ${{ needs.resolve-release.outputs.server_bundle_tag }}
run: printf '{"releaseTag":"%s"}\n' "$SERVER_BUNDLE_TAG" > electron/server-bundle-config.json
- name: Verify macOS signing secrets are configured
run: |
test -n "$CSC_LINK"
test -n "$CSC_KEY_PASSWORD"
test -n "$APPLE_ID"
test -n "$APPLE_APP_SPECIFIC_PASSWORD"
test -n "$APPLE_TEAM_ID"
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Build signed and notarized macOS artifacts
run: npm run desktop:dist:mac -- --publish never
env:
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Build macOS local server bundle
run: node scripts/release/build-server-bundle.js
- name: Stage macOS release assets
run: |
mkdir -p desktop-release-assets server-release-assets
test -n "$(find release/desktop -maxdepth 1 -name '*.dmg' -print -quit)"
shasum -a 256 release/desktop/*.dmg > desktop-release-assets/SHASUMS256-macos.txt
cp release/desktop/*.dmg desktop-release-assets/
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz' -print -quit)"
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz.sha256' -print -quit)"
cp release/local-server/* server-release-assets/
- name: Upload macOS desktop assets
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: desktop-release-macos
path: desktop-release-assets/*
if-no-files-found: error
- name: Upload macOS server assets
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: server-release-macos
path: server-release-assets/*
if-no-files-found: error
build-windows:
name: Build Windows desktop app
needs: resolve-release
runs-on: windows-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 0
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Typecheck
run: npm run typecheck
- name: Configure release server bundle source
shell: bash
env:
SERVER_BUNDLE_TAG: ${{ needs.resolve-release.outputs.server_bundle_tag }}
run: printf '{"releaseTag":"%s"}\n' "$SERVER_BUNDLE_TAG" > electron/server-bundle-config.json
- name: Check Windows signing secrets
id: windows-signing
shell: bash
env:
WINDOWS_CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK }}
WINDOWS_CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD }}
run: |
if [ -n "$WINDOWS_CSC_LINK" ] && [ -n "$WINDOWS_CSC_KEY_PASSWORD" ]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
fi
- name: Build signed Windows artifacts
if: steps.windows-signing.outputs.enabled == 'true'
run: npm run desktop:dist:win -- --publish never
env:
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD }}
- name: Build unsigned Windows artifacts
if: steps.windows-signing.outputs.enabled != 'true'
run: npm run desktop:dist:win -- --publish never
env:
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
CSC_IDENTITY_AUTO_DISCOVERY: "false"
- name: Build Windows local server bundle
run: node scripts/release/build-server-bundle.js
- name: Stage Windows release assets
shell: bash
run: |
mkdir -p desktop-release-assets server-release-assets
test -n "$(find release/desktop -maxdepth 1 -name '*.exe' -print -quit)"
sha256sum release/desktop/*.exe > desktop-release-assets/SHASUMS256-windows.txt
cp release/desktop/*.exe desktop-release-assets/
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz' -print -quit)"
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz.sha256' -print -quit)"
cp release/local-server/* server-release-assets/
- name: Upload Windows desktop assets
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: desktop-release-windows
path: desktop-release-assets/*
if-no-files-found: error
- name: Upload Windows server assets
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: server-release-windows
path: server-release-assets/*
if-no-files-found: error
publish:
name: Publish desktop release
needs:
- resolve-release
- build-macos
- build-windows
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download desktop assets
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
pattern: desktop-release-*
path: release/desktop
merge-multiple: true
- name: Download server assets
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
pattern: server-release-*
path: release/local-server
merge-multiple: true
- name: Verify release assets
run: |
test -n "$(find release/desktop -maxdepth 1 -name '*.dmg' -print -quit)"
test -n "$(find release/desktop -maxdepth 1 -name '*.exe' -print -quit)"
test -f release/desktop/SHASUMS256-macos.txt
test -f release/desktop/SHASUMS256-windows.txt
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz' -print -quit)"
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz.sha256' -print -quit)"
find release -maxdepth 2 -type f -print | sort
- name: Publish local server runtime assets
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: ${{ needs.resolve-release.outputs.server_bundle_tag }}
target_commitish: ${{ github.sha }}
name: CloudCLI Local Server Runtime (${{ needs.resolve-release.outputs.tag }})
body: |
This prerelease contains the Local mode runtime for CloudCLI Desktop.
Download CloudCLI Desktop from the main ${{ needs.resolve-release.outputs.tag }} release. When you open Local CloudCLI, the desktop app automatically downloads the matching runtime from this prerelease.
You do not need to download these runtime files manually.
prerelease: true
fail_on_unmatched_files: false
overwrite_files: true
files: |
release/local-server/*
- name: Publish GitHub release assets
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: ${{ needs.resolve-release.outputs.tag }}
target_commitish: ${{ github.sha }}
name: ${{ needs.resolve-release.outputs.release_name }}
body: |
Download the CloudCLI Desktop installer for your platform.
The local server runtime used by local mode is installed automatically by the desktop app. You do not need to download any server bundle manually.
prerelease: ${{ inputs.prerelease }}
fail_on_unmatched_files: false
overwrite_files: true
files: |
release/desktop/*

View File

@@ -0,0 +1,95 @@
name: Desktop Windows Branch Build
on:
workflow_dispatch:
push:
branches:
- electron-app
jobs:
build-windows:
name: Build unsigned Windows desktop artifact
runs-on: windows-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 0
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Typecheck
run: npm run typecheck
- name: Resolve artifact metadata
id: artifact
shell: bash
run: |
SAFE_REF="$(printf '%s' "${GITHUB_REF_NAME}" | tr -c 'A-Za-z0-9._-' '-')"
echo "name=CloudCLI-windows-${SAFE_REF}-${GITHUB_RUN_NUMBER}" >> "$GITHUB_OUTPUT"
echo "server_bundle_tag=cloudcli-local-server-${SAFE_REF}" >> "$GITHUB_OUTPUT"
- name: Configure branch server bundle source
shell: bash
run: printf '{"releaseTag":"%s"}\n' "${{ steps.artifact.outputs.server_bundle_tag }}" > electron/server-bundle-config.json
- name: Build unsigned Windows artifacts
run: npm run desktop:dist:win -- --publish never
env:
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
CSC_IDENTITY_AUTO_DISCOVERY: "false"
- name: Build branch server bundle
run: node scripts/release/build-server-bundle.js
- name: Verify branch server runtime artifacts
shell: bash
run: |
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz' -print -quit)"
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz.sha256' -print -quit)"
- name: Publish branch server bundle
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: ${{ steps.artifact.outputs.server_bundle_tag }}
name: CloudCLI Desktop Local Runtime (${{ github.ref_name }})
body: |
This prerelease is used by CloudCLI Desktop branch builds to run Local mode.
To test this branch, download the desktop app from this workflow run's artifacts. When you open Local CloudCLI, the desktop app automatically downloads the matching runtime from this prerelease.
You do not need to download these runtime files manually.
prerelease: true
fail_on_unmatched_files: false
overwrite_files: true
files: |
release/local-server/*
- name: Verify Windows artifacts
shell: bash
run: |
test -n "$(find release/desktop -maxdepth 1 -name '*.exe' -print -quit)"
sha256sum release/desktop/*.exe > release/SHASUMS256.txt
cat release/SHASUMS256.txt
- name: Upload branch build artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: ${{ steps.artifact.outputs.name }}
path: |
release/desktop/*.exe
release/SHASUMS256.txt
if-no-files-found: error
retention-days: 14

View File

@@ -4,15 +4,21 @@ on:
workflow_dispatch:
inputs:
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
default: 'patch'
default: "patch"
type: string
release_name:
description: 'Custom release name (optional, defaults to "CloudCLI UI vX.Y.Z")'
required: false
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:
release:
runs-on: ubuntu-latest
@@ -20,12 +26,12 @@ jobs:
contents: write
id-token: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 0
token: ${{ secrets.RELEASE_PAT }}
- uses: actions/setup-node@v6
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 22
registry-url: https://registry.npmjs.org

8
.gitignore vendored
View File

@@ -143,3 +143,11 @@ tasks/
# Git 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

View File

@@ -3,6 +3,71 @@
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)
### New Features

View File

@@ -59,6 +59,7 @@
- **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
- **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
- **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
@@ -73,7 +74,6 @@ The fastest way to get started — no local setup required. Get a fully managed,
**[Get started with CloudCLI Cloud](https://cloudcli.ai)**
### Self-Hosted (Open source)
#### npm
@@ -105,6 +105,16 @@ 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.
### 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.
---
@@ -119,7 +129,8 @@ 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 |
| **Isolation** | Runs on your host | Hypervisor-level sandbox (microVM) | Full cloud isolation |
| **Machine needs to stay on** | Yes | Yes | No |
| **Mobile access** | Any browser on your network | Any browser on your network | Any device, native app coming |
| **Mobile access** | Any browser on your network | Any browser on your network | Any device |
| **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 |
| **File explorer and Git** | Yes | Yes | Yes |
| **MCP configuration** | Synced with `~/.claude` | Managed via UI | Managed via UI |

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

260
electron/cloud.js Normal file
View File

@@ -0,0 +1,260 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { safeStorage } from 'electron';
const CLOUD_API_TIMEOUT_MS = 15000;
function encryptSecret(secret) {
if (!safeStorage.isEncryptionAvailable()) {
return { encrypted: false, value: secret };
}
return {
encrypted: true,
value: safeStorage.encryptString(secret).toString('base64'),
};
}
function decryptSecret(record) {
if (!record?.value) return null;
if (!record.encrypted) return record.value;
try {
return safeStorage.decryptString(Buffer.from(record.value, 'base64'));
} catch {
return null;
}
}
export class CloudController {
constructor({ storePath, controlPlaneUrl, callbackUrl, onChange }) {
this.storePath = storePath;
this.controlPlaneUrl = controlPlaneUrl;
this.callbackUrl = callbackUrl;
this.onChange = onChange;
this.cloudAccount = null;
this.cloudEnvironments = [];
this.authState = 'logged_out';
}
getAccount() {
return this.cloudAccount;
}
getAuthState() {
return this.authState;
}
getEnvironments() {
return this.cloudEnvironments;
}
getEnvironmentUrl(environment) {
return environment.access_url || `https://${environment.subdomain}.cloudcli.ai`;
}
async getEnvironmentLaunchUrl(environment) {
if (!environment?.id) {
return this.getEnvironmentUrl(environment);
}
const data = await this.cloudApi(`/api/v1/environments/${encodeURIComponent(environment.id)}/launch`, {
method: 'POST',
});
return data.launch_url || data.environment_url || this.getEnvironmentUrl(environment);
}
findEnvironment(environmentId) {
return this.cloudEnvironments.find((item) => item.id === environmentId) || null;
}
async loadCloudAccount() {
try {
const raw = await fs.readFile(this.storePath, 'utf8');
const stored = JSON.parse(raw);
const apiKey = decryptSecret(stored.apiKey);
this.cloudAccount = {
deviceId: stored.deviceId || crypto.randomUUID(),
email: stored.email || null,
apiKey: apiKey || null,
};
this.authState = apiKey ? 'connected' : (stored.email ? 'expired' : 'logged_out');
return this.cloudAccount;
} catch {
this.cloudAccount = {
deviceId: crypto.randomUUID(),
email: null,
apiKey: null,
};
this.authState = 'logged_out';
return this.cloudAccount;
}
}
async saveCloudAccount(account) {
const payload = {
deviceId: account.deviceId || crypto.randomUUID(),
email: account.email || null,
apiKey: account.apiKey ? encryptSecret(account.apiKey) : null,
};
await fs.mkdir(path.dirname(this.storePath), { recursive: true });
await fs.writeFile(this.storePath, JSON.stringify(payload, null, 2), 'utf8');
this.cloudAccount = {
deviceId: payload.deviceId,
email: payload.email,
apiKey: account.apiKey || null,
};
this.authState = account.apiKey ? 'connected' : 'logged_out';
this.onChange?.();
return this.cloudAccount;
}
async clearCloudAccount() {
this.cloudAccount = {
deviceId: crypto.randomUUID(),
email: null,
apiKey: null,
};
this.cloudEnvironments = [];
this.authState = 'logged_out';
await fs.rm(this.storePath, { force: true });
this.onChange?.();
}
async invalidateCloudAccount() {
this.cloudEnvironments = [];
if (!this.cloudAccount) {
this.cloudAccount = {
deviceId: crypto.randomUUID(),
email: null,
apiKey: null,
};
} else {
this.cloudAccount = {
...this.cloudAccount,
apiKey: null,
};
}
this.authState = this.cloudAccount.email ? 'expired' : 'logged_out';
const payload = {
deviceId: this.cloudAccount.deviceId,
email: this.cloudAccount.email || null,
apiKey: null,
};
await fs.mkdir(path.dirname(this.storePath), { recursive: true });
await fs.writeFile(this.storePath, JSON.stringify(payload, null, 2), 'utf8');
this.onChange?.();
}
async cloudApi(pathname, options = {}) {
if (!this.cloudAccount?.apiKey) {
throw new Error('Connect your CloudCLI account first.');
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), CLOUD_API_TIMEOUT_MS);
let response;
try {
response = await fetch(`${this.controlPlaneUrl}${pathname}`, {
...options,
signal: options.signal || controller.signal,
headers: {
'Content-Type': 'application/json',
'X-API-Key': this.cloudAccount.apiKey,
...(options.headers || {}),
},
});
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(`CloudCLI API request timed out after ${Math.round(CLOUD_API_TIMEOUT_MS / 1000)} seconds.`);
}
throw error;
} finally {
clearTimeout(timeout);
}
const body = await response.json().catch(() => ({}));
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
await this.invalidateCloudAccount();
}
throw new Error(body.error || `CloudCLI API request failed: ${response.status}`);
}
return body;
}
async refreshCloudEnvironments() {
if (!this.cloudAccount?.apiKey) {
this.cloudEnvironments = [];
this.onChange?.();
return [];
}
const data = await this.cloudApi('/api/v1/environments');
this.cloudEnvironments = data.environments || [];
this.onChange?.();
return this.cloudEnvironments;
}
async startEnvironment(environment) {
await this.cloudApi(`/api/v1/environments/${encodeURIComponent(environment.id)}/start`, {
method: 'POST',
});
}
async stopEnvironment(environment) {
await this.cloudApi(`/api/v1/environments/${encodeURIComponent(environment.id)}/stop`, {
method: 'POST',
});
}
async getEnvironmentCredentials(environment) {
return this.cloudApi(`/api/v1/environments/${encodeURIComponent(environment.id)}/credentials`);
}
async startEnvironmentAndWait(environment, timeoutMs) {
await this.startEnvironment(environment);
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const environments = await this.refreshCloudEnvironments();
const current = environments.find((env) => env.id === environment.id);
if (current?.status === 'running') {
return current;
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
throw new Error(`${environment.name} did not become ready in time.`);
}
buildConnectUrl() {
if (!this.cloudAccount?.deviceId) {
this.cloudAccount = {
deviceId: crypto.randomUUID(),
email: null,
apiKey: null,
};
}
const connectUrl = new URL('/auth/app-connect', this.controlPlaneUrl);
connectUrl.searchParams.set('device_id', this.cloudAccount.deviceId);
connectUrl.searchParams.set('callback_url', this.callbackUrl);
connectUrl.searchParams.set('app_surface', 'cloudcli_desktop');
connectUrl.searchParams.set('client_platform', 'desktop');
return connectUrl.toString();
}
async saveFromCallback({ apiKey, email }) {
await this.saveCloudAccount({
deviceId: this.cloudAccount?.deviceId || crypto.randomUUID(),
email,
apiKey,
});
return this.cloudAccount;
}
}

View File

@@ -0,0 +1,378 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { Notification } from 'electron';
import WebSocket from 'ws';
const RECONNECT_MIN_MS = 1000;
const RECONNECT_MAX_MS = 30000;
const TARGET_REGISTER_TIMEOUT_MS = 8000;
function toNotificationsWsUrl(httpUrl) {
try {
const parsed = new URL(httpUrl);
parsed.protocol = parsed.protocol === 'http:' ? 'ws:' : 'wss:';
parsed.pathname = '/desktop-notifications';
parsed.search = '';
parsed.hash = '';
return parsed.toString();
} catch {
return null;
}
}
function readJsonMessage(raw) {
try {
return JSON.parse(String(raw));
} catch {
return null;
}
}
async function requestJson(url, { method = 'POST', body = null, headers = {} } = {}) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TARGET_REGISTER_TIMEOUT_MS);
try {
const response = await fetch(url, {
method,
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
...headers,
},
...(body == null ? {} : { body: JSON.stringify(body) }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.error || `Request failed with status ${response.status}`);
}
return payload;
} finally {
clearTimeout(timeout);
}
}
export class DesktopNotificationsController {
constructor({
settingsPath,
appVersion,
appName,
getDeviceId,
getAccountEmail,
getRunningEnvironmentUrls,
getApiKey,
getAuthToken,
getIconPath,
openNotificationTarget,
onChange,
}) {
this.settingsPath = settingsPath;
this.appVersion = appVersion;
this.appName = appName;
this.getDeviceId = getDeviceId;
this.getAccountEmail = getAccountEmail;
this.getRunningEnvironmentUrls = getRunningEnvironmentUrls;
this.getApiKey = getApiKey;
this.getAuthToken = getAuthToken;
this.getIconPath = getIconPath;
this.openNotificationTarget = openNotificationTarget;
this.onChange = onChange;
this.settings = { enabled: false };
this.connections = new Map();
this.lastEvent = null;
this.lastError = null;
}
getState() {
const connectedTargets = [];
for (const [url, connection] of this.connections.entries()) {
if (connection.ws?.readyState === WebSocket.OPEN) {
connectedTargets.push(url);
}
}
return {
enabled: this.settings.enabled,
supported: Notification.isSupported(),
targetCount: this.connections.size,
connectedCount: connectedTargets.length,
connectedTargets,
lastEvent: this.lastEvent,
lastError: this.lastError,
};
}
async loadSettings() {
try {
const raw = await fs.readFile(this.settingsPath, 'utf8');
const stored = JSON.parse(raw);
this.settings = { enabled: Boolean(stored.enabled) };
} catch {
this.settings = { enabled: false };
}
return this.settings;
}
async saveSettings(next) {
const enabled = Boolean(next?.enabled);
if (!enabled && this.settings.enabled) {
await this.disableCurrentTargets();
}
this.settings = { enabled };
await fs.mkdir(path.dirname(this.settingsPath), { recursive: true });
await fs.writeFile(this.settingsPath, JSON.stringify(this.settings, null, 2), 'utf8');
await this.sync();
this.onChange?.();
return this.settings;
}
async sync() {
if (!this.settings.enabled) {
this.stop();
this.lastEvent = 'disabled';
this.onChange?.();
return;
}
if (!Notification.isSupported()) {
this.stop();
this.lastEvent = 'unsupported';
this.lastError = 'Native notifications are not supported on this system.';
this.onChange?.();
return;
}
const deviceId = this.getDeviceId?.();
if (!deviceId) {
this.stop();
this.lastEvent = 'missing-device';
this.lastError = 'Connect a CloudCLI account before enabling desktop notifications.';
this.onChange?.();
return;
}
const targets = (this.getRunningEnvironmentUrls?.() || [])
.map((httpUrl) => ({
httpUrl,
wsUrl: toNotificationsWsUrl(httpUrl),
}))
.filter((target) => target.wsUrl);
const nextWsUrls = new Set(targets.map((target) => target.wsUrl));
for (const [wsUrl, connection] of this.connections.entries()) {
if (!nextWsUrls.has(wsUrl)) {
this.closeConnection(connection);
this.connections.delete(wsUrl);
}
}
for (const target of targets) {
if (!this.connections.has(target.wsUrl)) {
void this.connect(target).catch((error) => {
this.lastEvent = 'connect-error';
this.lastError = error instanceof Error ? error.message : String(error);
this.onChange?.();
});
}
}
this.lastEvent = targets.length ? 'sync' : 'no-targets';
this.onChange?.();
}
async connect(target, attempt = 0) {
const existing = this.connections.get(target.wsUrl);
if (existing?.ws && [WebSocket.CONNECTING, WebSocket.OPEN].includes(existing.ws.readyState)) {
return;
}
const connection = {
...target,
ws: null,
reconnectTimer: null,
closed: false,
attempt,
};
this.connections.set(target.wsUrl, connection);
const headers = await this.getTargetAuthHeaders(target.httpUrl);
if (connection.closed || this.connections.get(target.wsUrl) !== connection) {
return;
}
const ws = new WebSocket(target.wsUrl, { headers: Object.keys(headers).length ? headers : undefined });
connection.ws = ws;
ws.on('open', async () => {
try {
await this.registerTarget(target.httpUrl);
ws.send(JSON.stringify({
type: 'register',
deviceId: this.getDeviceId?.(),
label: this.getAccountEmail?.() || this.appName,
platform: process.platform,
appVersion: this.appVersion,
}));
connection.attempt = 0;
this.lastEvent = 'connected';
this.lastError = null;
this.onChange?.();
} catch (error) {
this.lastEvent = 'register-error';
this.lastError = error instanceof Error ? error.message : String(error);
this.onChange?.();
try { ws.close(); } catch {}
}
});
ws.on('message', (raw) => this.handleMessage(target, ws, raw));
ws.on('close', () => this.scheduleReconnect(target.wsUrl));
ws.on('error', (error) => {
this.lastEvent = 'socket-error';
this.lastError = error instanceof Error ? error.message : String(error);
this.onChange?.();
});
}
async registerTarget(httpUrl) {
const url = new URL('/api/notifications/endpoints/current', httpUrl).toString();
await requestJson(url, {
method: 'POST',
headers: await this.getTargetAuthHeaders(httpUrl),
body: {
channel: 'desktop',
endpointId: this.getDeviceId?.(),
label: this.getAccountEmail?.() || this.appName,
metadata: {
platform: process.platform,
appVersion: this.appVersion,
},
enabled: true,
},
});
}
async disableCurrentTargets() {
const deviceId = this.getDeviceId?.();
if (!deviceId) return;
const targets = new Set([
...[...this.connections.values()].map((connection) => connection.httpUrl).filter(Boolean),
...(this.getRunningEnvironmentUrls?.() || []),
]);
const results = await Promise.allSettled([...targets].map(async (httpUrl) => {
const url = new URL(`/api/notifications/endpoints/desktop/${encodeURIComponent(deviceId)}`, httpUrl).toString();
await requestJson(url, {
method: 'PATCH',
headers: await this.getTargetAuthHeaders(httpUrl),
body: { enabled: false },
});
}));
const rejected = results.find((result) => result.status === 'rejected');
if (rejected) {
this.lastEvent = 'disable-endpoint-error';
this.lastError = rejected.reason instanceof Error ? rejected.reason.message : String(rejected.reason);
}
}
async getTargetAuthHeaders(httpUrl) {
const headers = {};
const apiKey = this.getApiKey?.();
if (apiKey) {
headers['X-API-Key'] = apiKey;
}
const authToken = await Promise.resolve(this.getAuthToken?.(httpUrl)).catch(() => null);
if (authToken) {
headers.Authorization = `Bearer ${authToken}`;
}
return headers;
}
handleMessage(target, ws, raw) {
const message = readJsonMessage(raw);
if (!message || message.type !== 'notification' || !message.payload) {
return;
}
const shown = this.showNativeNotification(target, message.payload);
if (shown && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'notification_ack',
id: message.id || message.payload?.data?.tag || null,
action: 'shown',
}));
}
}
showNativeNotification(target, payload) {
if (!Notification.isSupported()) return false;
const notification = new Notification({
title: payload.title || this.appName,
body: payload.body || '',
icon: this.getIconPath?.(),
silent: false,
});
notification.on('click', () => {
void this.openNotificationTarget?.({
environmentUrl: target.httpUrl,
sessionId: payload.data?.sessionId || null,
provider: payload.data?.provider || null,
}).catch((error) => {
this.lastEvent = 'click-error';
this.lastError = error instanceof Error ? error.message : String(error);
this.onChange?.();
});
});
notification.show();
this.lastEvent = 'notification-shown';
this.lastError = null;
this.onChange?.();
return true;
}
scheduleReconnect(wsUrl) {
const connection = this.connections.get(wsUrl);
if (!connection || connection.closed || !this.settings.enabled) {
return;
}
const attempt = connection.attempt + 1;
connection.attempt = attempt;
const delay = Math.min(RECONNECT_MAX_MS, RECONNECT_MIN_MS * (2 ** Math.min(attempt, 5)));
connection.reconnectTimer = setTimeout(() => {
if (!this.connections.has(wsUrl) || !this.settings.enabled) return;
void this.connect({
httpUrl: connection.httpUrl,
wsUrl: connection.wsUrl,
}, attempt).catch((error) => {
this.lastEvent = 'connect-error';
this.lastError = error instanceof Error ? error.message : String(error);
this.onChange?.();
});
}, delay);
this.lastEvent = 'reconnecting';
this.onChange?.();
}
closeConnection(connection) {
connection.closed = true;
if (connection.reconnectTimer) {
clearTimeout(connection.reconnectTimer);
connection.reconnectTimer = null;
}
try { connection.ws?.close(); } catch {}
}
stop() {
for (const connection of this.connections.values()) {
this.closeConnection(connection);
}
this.connections.clear();
this.onChange?.();
}
}

766
electron/desktopWindow.js Normal file
View File

@@ -0,0 +1,766 @@
import { BrowserWindow, Menu, Tray, clipboard, nativeImage, nativeTheme, session, webContents as electronWebContents } from 'electron';
import { ViewHost } from './viewHost.js';
const TITLEBAR_HEIGHT = 44;
const AUTH_TOKEN_STORAGE_KEY = 'auth-token';
function isAllowedPermissionOrigin(sourceUrl, controlPlaneUrl) {
try {
const source = new URL(sourceUrl);
if ((source.hostname === '127.0.0.1' || source.hostname === 'localhost') && source.protocol === 'http:') {
return true;
}
if (source.protocol !== 'https:') {
return false;
}
const controlPlane = new URL(controlPlaneUrl);
return source.origin === controlPlane.origin || source.hostname.endsWith('.cloudcli.ai');
} catch {
return false;
}
}
function getWebContentsProcessId(contents) {
return {
osProcessId: typeof contents.getOSProcessId === 'function' ? contents.getOSProcessId() : null,
processId: typeof contents.getProcessId === 'function' ? contents.getProcessId() : null,
};
}
export class DesktopWindowManager {
constructor({
appName,
getWindowIconPath,
getLauncherPath,
getPreloadPath,
openExternalUrl,
getDesktopState,
getDisplayTargetName,
getRemoteEnvironmentMenuItems,
getCloudState,
getLocalState,
actions,
tabs,
}) {
this.appName = appName;
this.getWindowIconPath = getWindowIconPath;
this.getLauncherPath = getLauncherPath;
this.getPreloadPath = getPreloadPath;
this.openExternalUrl = openExternalUrl;
this.getDesktopState = getDesktopState;
this.getDisplayTargetName = getDisplayTargetName;
this.getRemoteEnvironmentMenuItems = getRemoteEnvironmentMenuItems;
this.getCloudState = getCloudState;
this.getLocalState = getLocalState;
this.actions = actions;
this.tabs = tabs;
this.mainWindow = null;
this.settingsWindow = null;
this.tray = null;
this.launcherLoaded = false;
this.viewHost = new ViewHost({
appName: this.appName,
getMainWindow: () => this.mainWindow,
getContentViewBounds: () => this.getContentViewBounds(),
getPreloadPath: this.getPreloadPath,
openExternalUrl: this.openExternalUrl,
showError: this.actions.showError,
});
}
getMainWindow() {
return this.mainWindow;
}
getTrayImage() {
const image = nativeImage.createFromPath(this.getWindowIconPath());
return image.resize({ width: 18, height: 18 });
}
getContentViewBounds() {
if (!this.mainWindow) return { x: 0, y: TITLEBAR_HEIGHT, width: 0, height: 0 };
const [width, height] = this.mainWindow.getContentSize();
return {
x: 0,
y: TITLEBAR_HEIGHT,
width,
height: Math.max(0, height - TITLEBAR_HEIGHT),
};
}
detachActiveContentView() {
this.viewHost.detachAll();
}
async showTabPlaceholder(target, message) {
const tabId = this.tabs.getTabIdForTarget(target);
await this.viewHost.showTabPlaceholder(tabId, target, message);
}
async showLocalStartupTarget(target, logs) {
const tabId = this.tabs.getTabIdForTarget(target);
await this.viewHost.showLocalStartupTarget(tabId, target, logs);
}
async showContentTarget(target) {
const tabId = this.tabs.getTabIdForTarget(target);
await this.viewHost.showContentTarget(tabId, target);
}
destroyTabView(tabId) {
this.viewHost.destroyTabView(tabId);
}
emitDesktopState() {
const state = this.getDesktopState();
if (this.mainWindow && !this.mainWindow.webContents.isDestroyed()) {
this.mainWindow.webContents.send('cloudcli-desktop:state-updated', state);
}
if (this.settingsWindow && !this.settingsWindow.webContents.isDestroyed()) {
this.settingsWindow.webContents.send('cloudcli-desktop:state-updated', state);
}
}
emitLauncherCommand(command) {
if (!this.mainWindow || this.mainWindow.webContents.isDestroyed()) return;
this.mainWindow.webContents.send('cloudcli-desktop:launcher-command', command);
}
emitSettingsCommand(command) {
if (!this.settingsWindow || this.settingsWindow.webContents.isDestroyed()) return;
this.settingsWindow.webContents.send('cloudcli-desktop:launcher-command', command);
}
syncSettingsWindowBounds() {
if (!this.mainWindow || !this.settingsWindow || this.settingsWindow.isDestroyed()) return;
this.settingsWindow.setBounds(this.mainWindow.getBounds());
}
async ensureSettingsWindow(sheet = 'desktop-settings') {
if (!this.mainWindow) return null;
if (this.settingsWindow && !this.settingsWindow.isDestroyed()) {
this.syncSettingsWindowBounds();
this.emitSettingsCommand({ type: 'open-sheet', sheet });
this.settingsWindow.focus();
return this.settingsWindow;
}
this.settingsWindow = new BrowserWindow({
parent: this.mainWindow,
show: false,
frame: false,
transparent: true,
hasShadow: false,
resizable: false,
minimizable: false,
maximizable: false,
fullscreenable: false,
movable: false,
skipTaskbar: true,
backgroundColor: '#00000000',
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
preload: this.getPreloadPath(),
},
});
this.syncSettingsWindowBounds();
this.viewHost.configureChildWebContents(this.settingsWindow.webContents);
this.settingsWindow.once('ready-to-show', () => this.settingsWindow?.show());
this.settingsWindow.on('closed', () => {
this.settingsWindow = null;
});
await this.settingsWindow.loadFile(this.getLauncherPath(), {
query: { modal: '1', sheet },
});
return this.settingsWindow;
}
closeSettingsWindow() {
if (!this.settingsWindow || this.settingsWindow.isDestroyed()) return;
this.settingsWindow.close();
}
async showTarget(target, { trackTab = true } = {}) {
if (!this.mainWindow) return;
if (trackTab) {
this.tabs.upsertTarget(target);
}
this.actions.setActiveTarget(target);
this.buildAppMenu();
this.mainWindow.setTitle(`${this.appName} - ${target.name}`);
const finalUrl = await this.showContentTarget(target);
this.emitDesktopState();
return finalUrl;
}
async showLauncher() {
if (!this.mainWindow) return;
const target = { kind: 'launcher', name: this.appName, url: null };
this.tabs.upsertTarget(target);
this.actions.setActiveTarget(target);
this.detachActiveContentView();
this.buildAppMenu();
this.mainWindow.setTitle(this.appName);
this.mainWindow.webContents.focus();
if (!this.launcherLoaded) {
await this.mainWindow.loadFile(this.getLauncherPath());
this.launcherLoaded = true;
} else {
this.emitDesktopState();
}
}
async switchDesktopTab(tabId) {
const tab = this.tabs.activate(tabId);
if (!tab || !this.mainWindow) return this.getDesktopState();
if (tab.id === 'home' || tab.kind === 'launcher') {
await this.showLauncher();
return this.getDesktopState();
}
if (!tab.target?.url) {
throw new Error('This tab does not have a target URL.');
}
await this.showTarget(tab.target, { trackTab: false });
return this.getDesktopState();
}
async reloadActiveTab() {
const activeTab = this.tabs.getActiveTab();
if (!activeTab || activeTab.id === 'home' || activeTab.kind === 'launcher') {
this.emitDesktopState();
return this.getDesktopState();
}
const reloaded = this.viewHost.reloadTab(activeTab.id);
if (!reloaded && activeTab.target?.url) {
await this.showTarget(activeTab.target, { trackTab: false });
}
this.emitDesktopState();
return this.getDesktopState();
}
async navigateActiveView(url) {
const navigated = await this.viewHost.navigateActiveView(url);
this.emitDesktopState();
return navigated;
}
async readAuthTokenForTarget(url) {
return this.viewHost.readLocalStorageValueForOrigin(url, AUTH_TOKEN_STORAGE_KEY);
}
openActiveTabDevTools() {
if (this.viewHost.openActiveViewDevTools()) return;
void this.actions.showError('No active BrowserView', new Error('Switch to a non-launcher tab before opening active tab DevTools.'));
}
reloadActiveBrowserViewForDiagnostics() {
if (this.viewHost.reloadActiveView()) return;
void this.actions.showError('No active BrowserView', new Error('Switch to a non-launcher tab before reloading the active BrowserView.'));
}
detachActiveBrowserViewForDiagnostics() {
if (this.viewHost.detachActiveView()) return;
void this.actions.showError('No active BrowserView', new Error('Switch to a non-launcher tab before detaching the active BrowserView.'));
}
copyWebContentsDiagnostics() {
const tabViewDiagnostics = this.viewHost.getTabViewDiagnostics();
const tabViewByContentsId = new Map(
tabViewDiagnostics
.filter((item) => item.webContentsId != null)
.map((item) => [item.webContentsId, item])
);
const rows = electronWebContents.getAllWebContents().map((contents) => {
const destroyed = contents.isDestroyed();
const processIds = destroyed ? { osProcessId: null, processId: null } : getWebContentsProcessId(contents);
const tabView = tabViewByContentsId.get(contents.id);
let owner = 'unknown';
if (this.mainWindow?.webContents?.id === contents.id) {
owner = 'main-window';
} else if (this.settingsWindow?.webContents?.id === contents.id) {
owner = 'settings-window';
} else if (tabView) {
owner = `browser-view:${tabView.tabId}`;
}
return {
id: contents.id,
owner,
osProcessId: processIds.osProcessId,
processId: processIds.processId,
url: destroyed ? null : contents.getURL(),
title: destroyed ? null : contents.getTitle(),
destroyed,
focused: destroyed || typeof contents.isFocused !== 'function' ? false : contents.isFocused(),
attached: tabView ? tabView.attached : null,
active: tabView ? tabView.active : null,
};
});
const activeTab = this.tabs.getActiveTab();
const diagnostics = {
generatedAt: new Date().toISOString(),
activeTabId: this.tabs.activeTabId,
activeTab: activeTab
? {
id: activeTab.id,
title: activeTab.title,
kind: activeTab.kind,
targetUrl: activeTab.target?.url || null,
}
: null,
tabViews: tabViewDiagnostics,
webContents: rows,
};
clipboard.writeText(JSON.stringify(diagnostics, null, 2));
}
async closeDesktopTab(tabId) {
const tab = this.tabs.remove(tabId);
if (!tab) return this.getDesktopState();
this.destroyTabView(tabId);
if (this.tabs.activeTabId === 'home') {
await this.showLauncher();
} else {
this.emitDesktopState();
}
return this.getDesktopState();
}
buildEnvironmentActionsSubmenu(environment) {
const items = [];
const statusSuffix = environment.status === 'running' ? '' : ` (${environment.status})`;
items.push({
label: 'Open Environment',
click: () => void this.actions.openEnvironmentInDesktop(environment)
.catch((error) => this.actions.showError(`Could not open ${environment.name || environment.subdomain}${statusSuffix}`, error)),
});
items.push({
label: 'Open in Browser',
click: () => void this.actions.openEnvironmentInBrowser(environment)
.catch((error) => this.actions.showError('Could not open environment in browser', error)),
});
items.push({
label: 'Open in VS Code',
click: () => void this.actions.openEnvironmentInIde(environment, 'vscode')
.catch((error) => this.actions.showError('Could not open environment in VS Code', error)),
});
items.push({
label: 'Open in Cursor',
click: () => void this.actions.openEnvironmentInIde(environment, 'cursor')
.catch((error) => this.actions.showError('Could not open environment in Cursor', error)),
});
items.push({
label: 'Open SSH Terminal',
click: () => void this.actions.openEnvironmentInSsh(environment)
.catch((error) => this.actions.showError('Could not open SSH terminal', error)),
});
items.push({
label: 'Copy Mobile/Web URL',
click: () => this.actions.copyText(this.actions.getEnvironmentUrl(environment)),
});
if (environment.status !== 'running') {
items.unshift({
label: environment.status === 'paused' ? 'Resume' : 'Start',
click: () => void this.actions.startEnvironment(environment)
.catch((error) => this.actions.showError('Could not start environment', error)),
});
}
if (environment.status === 'running') {
items.push({
label: 'Stop',
click: () => void this.actions.stopEnvironment(environment)
.catch((error) => this.actions.showError('Could not stop environment', error)),
});
}
return items;
}
buildTrayEnvironmentSection() {
const cloudState = this.getCloudState();
if (!cloudState.account?.apiKey) {
return [
{
label: cloudState.account?.email ? `Reconnect ${cloudState.account.email}` : 'Login',
click: () => void this.actions.connectCloudAccount()
.catch((error) => this.actions.showError('Could not connect CloudCLI account', error)),
},
];
}
if (!cloudState.environments.length) {
return [{ label: 'No environments found', enabled: false }];
}
return cloudState.environments.map((environment) => ({
label: `${environment.name || environment.subdomain} - ${environment.status}`,
submenu: this.buildEnvironmentActionsSubmenu(environment),
}));
}
buildAppMenu() {
if (!this.mainWindow) return;
const cloudState = this.getCloudState();
const localState = this.getLocalState();
const remoteItems = this.getRemoteEnvironmentMenuItems();
const cloudAccountLabel = cloudState.account?.apiKey
? (cloudState.account?.email ? `Connected: ${cloudState.account.email}` : 'CloudCLI Connected')
: (cloudState.account?.email ? `Reconnect: ${cloudState.account.email}` : 'Connect CloudCLI Account...');
const template = [
{
label: this.appName,
submenu: [
{ label: `About ${this.appName}`, role: 'about' },
{ type: 'separator' },
{
label: 'Show Launcher',
accelerator: 'CmdOrCtrl+Shift+L',
click: () => void this.showLauncher().catch((error) => this.actions.showError('Could not show launcher', error)),
},
{
label: 'Switch Environment',
accelerator: 'CmdOrCtrl+Shift+E',
click: () => void this.actions.showEnvironmentPicker().catch((error) => this.actions.showError('Could not switch environment', error)),
},
{
label: 'Diagnostics',
submenu: [
{
label: 'Copy Diagnostics',
click: () => void this.actions.copyDiagnostics(),
},
],
},
{ type: 'separator' },
{
label: process.platform === 'darwin' ? `Hide ${this.appName}` : 'Hide',
role: 'hide',
visible: process.platform === 'darwin',
},
{ label: 'Hide Others', role: 'hideOthers', visible: process.platform === 'darwin' },
{ label: 'Show All', role: 'unhide', visible: process.platform === 'darwin' },
{ type: 'separator', visible: process.platform === 'darwin' },
{ label: `Quit ${this.appName}`, accelerator: 'CmdOrCtrl+Q', role: 'quit' },
],
},
{
label: 'Environment',
submenu: [
{
label: 'Show Launcher',
accelerator: 'CmdOrCtrl+Shift+L',
click: () => void this.showLauncher().catch((error) => this.actions.showError('Could not show launcher', error)),
},
{
label: 'Switch Environment',
accelerator: 'CmdOrCtrl+Shift+E',
click: () => void this.actions.showEnvironmentPicker().catch((error) => this.actions.showError('Could not switch environment', error)),
},
{ type: 'separator' },
{
label: 'Open Local CloudCLI',
accelerator: 'CmdOrCtrl+L',
click: () => void this.actions.openLocalInDesktop().catch((error) => this.actions.showError('Could not open local CloudCLI', error)),
},
{
label: 'Open Local Web UI in Browser',
accelerator: 'CmdOrCtrl+Shift+W',
click: () => void this.actions.openLocalWebUi().catch((error) => this.actions.showError('Could not open local web UI', error)),
},
{
label: 'Copy Local Web URL',
accelerator: 'CmdOrCtrl+Shift+U',
click: () => void this.actions.copyLocalWebUrl().catch((error) => this.actions.showError('Could not copy local web URL', error)),
},
{ type: 'separator' },
{
label: 'Keep Local Server Running After Quit',
type: 'checkbox',
checked: localState.desktopSettings.keepLocalServerRunning,
click: (menuItem) => void this.actions.updateDesktopSetting('keepLocalServerRunning', menuItem.checked)
.catch((error) => this.actions.showError('Could not update desktop setting', error)),
},
{
label: 'Allow LAN Access to Local Server',
type: 'checkbox',
checked: localState.desktopSettings.exposeLocalServerOnNetwork,
click: (menuItem) => void this.actions.updateDesktopSetting('exposeLocalServerOnNetwork', menuItem.checked)
.catch((error) => this.actions.showError('Could not update desktop setting', error)),
},
],
},
{
label: 'Cloud',
submenu: [
{
label: cloudAccountLabel,
accelerator: 'CmdOrCtrl+Shift+C',
click: () => void this.actions.connectCloudAccount().catch((error) => this.actions.showError('Could not connect CloudCLI account', error)),
},
{
label: 'Refresh Cloud Environments',
click: () => void this.actions.refreshCloudEnvironments().catch((error) => this.actions.showError('Could not load CloudCLI environments', error)),
enabled: Boolean(cloudState.account?.apiKey),
},
{
label: 'Logout CloudCLI Account',
click: () => void this.actions.clearCloudAccount().catch((error) => this.actions.showError('Could not logout', error)),
enabled: Boolean(cloudState.account?.apiKey),
},
{ type: 'separator' },
{
label: 'Remote Environments',
submenu: remoteItems,
},
],
},
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'selectAll' },
],
},
{
label: 'View',
submenu: [
{ role: 'reload' },
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{
label: 'Open Active Tab DevTools',
click: () => this.openActiveTabDevTools(),
},
{
label: 'Copy WebContents Diagnostics',
click: () => this.copyWebContentsDiagnostics(),
},
{
label: 'Reload Active BrowserView',
click: () => this.reloadActiveBrowserViewForDiagnostics(),
},
{
label: 'Detach Active BrowserView',
click: () => this.detachActiveBrowserViewForDiagnostics(),
},
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
},
{
label: 'Window',
submenu: [
{ role: 'minimize' },
{ role: 'zoom' },
...(process.platform === 'darwin' ? [{ type: 'separator' }, { role: 'front' }] : []),
],
},
{
label: 'Help',
submenu: [
{
label: 'Open cloudcli.ai',
click: () => void this.actions.openCloudDashboard(),
},
{
label: 'Copy Diagnostics',
click: () => void this.actions.copyDiagnostics(),
},
],
},
];
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
this.buildTrayMenu();
}
buildTrayMenu() {
if (!this.tray) return;
const cloudState = this.getCloudState();
const localState = this.getLocalState();
const template = [
{
label: 'Local',
submenu: [
{
label: localState.localServerRunning ? 'Open Local in CloudCLI' : 'Start Local in CloudCLI',
click: () => void this.actions.openLocalInDesktop().catch((error) => this.actions.showError('Could not open local CloudCLI', error)),
},
{
label: 'Open Local in Browser',
click: () => void this.actions.openLocalWebUi().catch((error) => this.actions.showError('Could not open local web UI', error)),
},
{
label: 'Copy Local URL',
click: () => void this.actions.copyLocalWebUrl().catch((error) => this.actions.showError('Could not copy local web URL', error)),
},
],
},
{
label: 'Cloud Environments',
submenu: this.buildTrayEnvironmentSection(),
},
{ type: 'separator' },
{
label: cloudState.account?.email ? `Connected: ${cloudState.account.email}` : 'Login',
click: () => void this.actions.connectCloudAccount().catch((error) => this.actions.showError('Could not connect CloudCLI account', error)),
},
{
label: 'Logout CloudCLI Account',
click: () => void this.actions.clearCloudAccount().catch((error) => this.actions.showError('Could not logout', error)),
enabled: Boolean(cloudState.account?.apiKey),
},
{ type: 'separator' },
{
label: `Quit ${this.appName}`,
role: 'quit',
},
];
this.tray.setToolTip(`${this.appName}${this.actions.getActiveTarget()?.name ? ` - ${this.actions.getActiveTarget().name}` : ''}`);
this.tray.setContextMenu(Menu.buildFromTemplate(template));
}
async showDesktopSettings() {
if (!this.mainWindow) return this.getDesktopState();
await this.ensureSettingsWindow('desktop-settings');
return this.getDesktopState();
}
async showLocalSettings() {
if (!this.mainWindow) return this.getDesktopState();
await this.ensureSettingsWindow('local-settings');
return this.getDesktopState();
}
async showActiveEnvironmentActionsMenu() {
if (!this.mainWindow) return this.getDesktopState();
const activeTarget = this.actions.getActiveTarget();
if (activeTarget?.kind !== 'remote') return this.getDesktopState();
const environment = this.getCloudState().environments.find((item) => item.id === activeTarget.id);
if (!environment) return this.getDesktopState();
const menu = Menu.buildFromTemplate(this.buildEnvironmentActionsSubmenu(environment));
menu.popup({ window: this.mainWindow });
return this.getDesktopState();
}
async showEnvironmentActionsMenu(environmentId) {
if (!this.mainWindow) return this.getDesktopState();
const environment = this.getCloudState().environments.find((item) => item.id === environmentId);
if (!environment) return this.getDesktopState();
const menu = Menu.buildFromTemplate(this.buildEnvironmentActionsSubmenu(environment));
menu.popup({ window: this.mainWindow });
return this.getDesktopState();
}
configurePermissions() {
const isAllowedPermission = (webContents, permission) => {
const sourceUrl = webContents.getURL();
const allowedPermissions = new Set(['clipboard-read', 'media', 'notifications']);
return isAllowedPermissionOrigin(sourceUrl, this.getCloudState().controlPlaneUrl) && allowedPermissions.has(permission);
};
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => {
callback(isAllowedPermission(webContents, permission));
});
session.defaultSession.setPermissionCheckHandler((webContents, permission) => {
if (!webContents) return false;
return isAllowedPermission(webContents, permission);
});
}
createTray() {
if (this.tray) return;
this.tray = new Tray(this.getTrayImage());
this.tray.on('click', () => {
if (!this.mainWindow) return;
if (this.mainWindow.isVisible()) {
this.mainWindow.focus();
} else {
this.mainWindow.show();
}
});
this.buildTrayMenu();
}
async createWindow() {
this.mainWindow = new BrowserWindow({
width: 1440,
height: 960,
minWidth: 1024,
minHeight: 720,
show: false,
backgroundColor: '#0f172a',
title: this.appName,
icon: this.getWindowIconPath(),
titleBarStyle: 'hidden',
...(process.platform === 'darwin'
? { trafficLightPosition: { x: 18, y: 14 } }
: {
titleBarOverlay: {
color: nativeTheme.shouldUseDarkColors ? '#111111' : '#f7f8fa',
symbolColor: nativeTheme.shouldUseDarkColors ? '#a1a1a1' : '#5b6470',
height: 44,
},
}),
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
preload: this.getPreloadPath(),
},
});
this.mainWindow.once('ready-to-show', () => {
this.mainWindow?.show();
});
this.mainWindow.webContents.setWindowOpenHandler(({ url }) => {
void this.openExternalUrl(url).catch((error) => this.actions.showError('Could not open external link', error));
return { action: 'deny' };
});
this.mainWindow.on('resize', () => {
this.viewHost.resizeActiveView();
this.syncSettingsWindowBounds();
});
this.mainWindow.on('move', () => {
this.syncSettingsWindowBounds();
});
this.mainWindow.on('closed', () => {
this.viewHost.clear();
this.settingsWindow = null;
this.mainWindow = null;
this.launcherLoaded = false;
});
this.buildAppMenu();
await this.showLauncher();
}
}

View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' data:; connect-src *; img-src 'self' data:" />
<title>CloudCLI Desktop</title>
<link rel="stylesheet" href="./launcher.css" />
</head>
<body>
<div id="app"></div>
<script src="./launcher.js"></script>
</body>
</html>

View File

@@ -0,0 +1,801 @@
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
}
html.cc-modal-window,
body.cc-modal-window {
background: transparent;
}
:root {
--bg: #111315;
--s1: #171a1d;
--s2: #1e2328;
--s3: #262d34;
--b-subtle: #28303a;
--b: #313b46;
--b-strong: #42505f;
--tx: #f5f7fa;
--tx2: #adb8c5;
--tx3: #7f8b98;
--brand: #0a66d9;
--brand-2: #5fa5ff;
--brand-faint: rgba(10, 102, 217, 0.14);
--ok: #2aa775;
--warn: #d48b20;
--err: #d65252;
--tab-hover-bg: rgba(255, 255, 255, 0.08);
--tab-active-bg: rgba(255, 255, 255, 0.14);
--mono: "SF Mono", "Geist Mono", "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, sans-serif;
color-scheme: dark;
}
:root[data-theme="light"] {
--bg: #f3f5f8;
--s1: #ffffff;
--s2: #f7f9fb;
--s3: #edf1f5;
--b-subtle: #e5eaf0;
--b: #d8dee6;
--b-strong: #c3ccd6;
--tx: #11151a;
--tx2: #566171;
--tx3: #7f8b98;
--brand: #0a66d9;
--brand-2: #0f5fc6;
--brand-faint: rgba(10, 102, 217, 0.09);
--ok: #1f8e61;
--warn: #b67515;
--err: #c24747;
--tab-hover-bg: rgba(15, 23, 42, 0.05);
--tab-active-bg: rgba(15, 23, 42, 0.08);
color-scheme: light;
}
body {
background: var(--bg);
color: var(--tx);
font-family: var(--sans);
font-size: 14px;
-webkit-font-smoothing: antialiased;
overflow: hidden;
user-select: none;
}
input {
font: inherit;
user-select: text;
}
button {
font: inherit;
color: inherit;
cursor: pointer;
border: 0;
background: none;
}
#app {
height: 100vh;
display: flex;
flex-direction: column;
min-height: 0;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: var(--b);
border-radius: 6px;
border: 2px solid transparent;
background-clip: content-box;
}
svg {
display: block;
}
.mono {
font-family: var(--mono);
}
.lbl {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 1.1px;
text-transform: uppercase;
color: var(--tx3);
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--tx3);
flex: 0 0 auto;
display: inline-block;
}
.titlebar {
-webkit-app-region: drag;
display: flex;
align-items: center;
gap: 12px;
height: 44px;
padding: 0 12px;
border-bottom: 1px solid var(--b-subtle);
background: color-mix(in srgb, var(--s1) 90%, transparent);
flex: 0 0 auto;
}
.titlebar button,
.titlebar input,
.titlebar .no-drag {
-webkit-app-region: no-drag;
}
.brand {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.brand .mk {
width: 22px;
height: 22px;
display: block;
flex: 0 0 auto;
object-fit: contain;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
min-width: 0;
height: 32px;
padding: 0 13px;
border-radius: 9px;
border: 1px solid var(--b);
background: var(--s1);
color: var(--tx);
font-weight: 500;
transition: border-color 0.12s, background 0.12s, filter 0.12s;
}
.btn:hover {
border-color: var(--b-strong);
background: var(--s2);
}
.btn.pri {
background: var(--brand);
border-color: var(--brand);
color: #fff;
}
.btn.pri:hover {
filter: brightness(1.05);
}
.btn.sm {
height: 28px;
padding: 0 10px;
font-size: 12px;
}
.btn:disabled {
opacity: 0.55;
cursor: default;
}
.icon-btn {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 9px;
border: 1px solid transparent;
color: var(--tx2);
}
.icon-btn:hover {
background: var(--s2);
border-color: var(--b);
color: var(--tx);
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 22px;
padding: 0 9px;
border-radius: 999px;
font-size: 11px;
background: var(--s2);
color: var(--tx2);
border: 1px solid var(--b-subtle);
white-space: nowrap;
}
.badge.ok {
color: var(--ok);
}
.badge.warn {
color: var(--warn);
}
.badge.idle {
color: var(--tx3);
}
.cc-body {
flex: 1;
min-height: 0;
overflow: auto;
position: relative;
}
.statusbar {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 12px;
height: 27px;
padding: 0 12px;
border-top: 1px solid var(--b-subtle);
background: var(--s1);
font-size: 11px;
color: var(--tx2);
font-family: var(--mono);
}
.statusbar .sep {
opacity: 0.4;
}
.status-msg.progress {
color: var(--brand-2);
}
.status-msg.error {
color: var(--err);
}
.cc-overlay {
position: fixed;
inset: 0;
background: rgba(6, 8, 11, 0.28);
display: none;
z-index: 50;
align-items: center;
justify-content: center;
padding: 24px;
}
.cc-overlay.open {
display: flex;
}
.cc-sheet {
width: 620px;
max-width: min(92vw, 620px);
max-height: min(720px, 82vh);
overflow: hidden;
display: flex;
flex-direction: column;
border-radius: 14px;
border: 1px solid var(--b);
background: color-mix(in srgb, var(--s1) 98%, transparent);
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.34);
}
.cc-sheet-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 20px 20px 18px;
border-bottom: 1px solid var(--b-subtle);
}
.cc-sheet-copy {
min-width: 0;
}
.cc-sheet-title {
font-size: 20px;
font-weight: 600;
line-height: 1.2;
}
.cc-sheet-subtitle {
margin-top: 6px;
color: var(--tx2);
line-height: 1.45;
}
.cc-sheet-close {
flex: 0 0 auto;
}
.cc-sheet-body {
overflow: auto;
padding: 16px 20px 20px;
display: flex;
flex-direction: column;
gap: 14px;
}
.cc-sheet-footer {
padding: 14px 20px 18px;
border-top: 1px solid var(--b-subtle);
}
.cc-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.cc-section-head {
display: flex;
flex-direction: column;
gap: 2px;
}
.cc-section-title {
font-size: 14px;
font-weight: 600;
color: var(--tx);
}
.cc-section-body {
display: flex;
flex-direction: column;
gap: 10px;
}
.cc-surface {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
border: 1px solid var(--b-subtle);
border-radius: 14px;
background: linear-gradient(180deg, color-mix(in srgb, var(--s2) 86%, transparent), var(--s1));
}
.cc-row2 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.cc-meta {
color: var(--tx2);
font-size: 12px;
line-height: 1.45;
}
.cc-toggle,
.cc-choice {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
gap: 12px;
align-items: start;
color: var(--tx2);
font-size: 13px;
line-height: 1.45;
}
.cc-toggle input,
.cc-choice input {
width: 16px;
height: 16px;
margin-top: 2px;
accent-color: var(--brand);
}
.cc-toggle b,
.cc-choice b {
color: var(--tx);
font-weight: 600;
}
.cc-choice-group {
gap: 12px;
}
.cc-permissions {
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid var(--b-subtle);
border-radius: 12px;
background: var(--s1);
}
.cc-note {
color: var(--tx2);
font-size: 12px;
line-height: 1.45;
}
.cc-permission-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding-top: 10px;
border-top: 1px solid var(--b-subtle);
}
.cc-permission-title {
color: var(--tx);
font-size: 13px;
font-weight: 600;
}
.cc-permission-detail {
margin-top: 2px;
color: var(--tx2);
font-size: 12px;
line-height: 1.4;
}
.cc-permission-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.cc-kv {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 2px 0;
color: var(--tx2);
}
.cc-kv span:last-child {
color: var(--tx);
font-weight: 500;
}
.cc-actions-inline {
display: flex;
gap: 8px;
flex-wrap: wrap;
padding-top: 4px;
}
.cc-status-badge {
display: inline-flex;
align-items: center;
align-self: flex-start;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid var(--b-subtle);
background: var(--s2);
font-size: 12px;
font-weight: 600;
}
.cc-status-badge.ok {
color: var(--ok);
}
.cc-status-badge.warn {
color: var(--warn);
}
.cc-status-badge.idle {
color: var(--tx3);
}
.v-sidebar {
display: grid;
grid-template-columns: 248px 1fr;
overflow: hidden;
}
.sb {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px 12px;
border-right: 1px solid var(--b-subtle);
background: var(--s1);
overflow: auto;
}
.sb-grp {
display: flex;
flex-direction: column;
gap: 3px;
}
.sb-grp .lbl {
padding: 6px 8px;
}
.sb-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 10px;
color: var(--tx2);
text-align: left;
}
.sb-item > span:nth-child(2) {
flex: 1;
}
.sb-item .sb-meta {
font-size: 11px;
color: var(--tx3);
font-family: var(--mono);
}
.sb-item:hover {
background: var(--s2);
}
.sb-item.active {
background: var(--brand-faint);
color: var(--tx);
}
.sb-item.active svg {
color: var(--brand-2);
}
.sb-main {
overflow: auto;
padding: 24px;
min-width: 0;
}
.pane-h {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.pane-title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.pane-sub {
margin: 4px 0 0;
color: var(--tx2);
font-size: 13px;
}
.card {
border: 1px solid var(--b);
border-radius: 14px;
background: color-mix(in srgb, var(--s1) 94%, transparent);
padding: 18px;
display: flex;
flex-direction: column;
gap: 16px;
max-width: 620px;
margin-bottom: 12px;
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.08);
}
.card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.card-tools {
display: flex;
align-items: center;
gap: 8px;
}
.card-t {
font-size: 15px;
font-weight: 600;
}
.card-sub {
margin-top: 4px;
color: var(--tx2);
font-size: 13px;
line-height: 1.45;
}
.card-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.env {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
padding: 12px 14px;
border: 1px solid var(--b);
border-radius: 12px;
background: var(--s1);
margin-bottom: 8px;
}
.env:hover {
border-color: var(--b-strong);
}
.env-i {
flex: 1;
min-width: 0;
}
.env-n {
font-weight: 500;
}
.env-u {
font-size: 12px;
color: var(--tx3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.env-tags {
display: flex;
gap: 6px;
}
.tag {
font-family: var(--mono);
font-size: 11px;
color: var(--tx2);
background: var(--s2);
border: 1px solid var(--b-subtle);
border-radius: 999px;
padding: 2px 7px;
white-space: nowrap;
}
.empty {
border: 1px dashed var(--b);
border-radius: 12px;
padding: 28px;
text-align: center;
color: var(--tx2);
max-width: 560px;
}
body.mac .titlebar {
padding-left: 92px;
padding-right: 12px;
}
body.win .titlebar {
padding-right: 150px;
}
.titlebar .brand {
margin-right: 6px;
}
.tb-tabs {
display: flex;
align-items: center;
gap: 5px;
min-width: 0;
overflow: hidden;
}
.tb-tab {
display: inline-flex;
align-items: center;
gap: 10px;
min-width: 112px;
max-width: 232px;
flex: 0 0 auto;
height: 30px;
padding: 0 7px 0 12px;
border: 1px solid transparent;
border-radius: 8px;
color: var(--tx2);
font-size: 12px;
background: transparent;
transition: background 0.12s, color 0.12s;
}
.tb-tab:hover {
background: var(--tab-hover-bg);
}
.tb-tab.active {
background: var(--tab-active-bg);
color: var(--tx);
}
.tb-tab span:first-child {
flex: 1;
min-width: 0;
max-width: 20ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tb-close {
display: grid;
width: 20px;
height: 20px;
margin-left: 8px;
place-items: center;
border-radius: 6px;
color: var(--tx3);
font-size: 14px;
line-height: 1;
flex: 0 0 auto;
}
.tb-close:hover {
background: var(--tab-hover-bg);
color: var(--tx);
}
.tb-action {
flex: 0 0 auto;
}
@media (max-width: 760px) {
.v-sidebar {
grid-template-columns: 1fr;
}
.sb {
flex-direction: row;
align-items: center;
overflow: auto;
}
.env-tags {
display: none;
}
.cc-sheet {
max-width: 100%;
}
.cc-row2 {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,687 @@
window.__APP_VERSION__ = '1.34.0';
window.__MOCK_STATE__ = {
account: { connected: true, email: 'you@cloudcli.ai' },
activeTarget: { kind: 'launcher', name: 'Launcher', url: null },
cloudLoading: false,
desktopSettings: { keepLocalServerRunning: false, exposeLocalServerOnNetwork: false, themeMode: 'system' },
localWebUrl: 'http://localhost:3001',
shareableWebUrl: 'http://localhost:3001',
localServerRunning: false,
localStartupLogs: [],
environments: [
{ id: 'env-api', name: 'api-gateway', subdomain: 'api-gateway', access_url: 'https://api-gateway.cloudcli.ai', status: 'running', region: 'fra1', agent: 'Claude Code' },
{ id: 'env-web', name: 'web-frontend', subdomain: 'web-frontend', access_url: 'https://web-frontend.cloudcli.ai', status: 'stopped', region: 'sfo1', agent: 'Codex' },
{ id: 'env-data', name: 'data-pipeline', subdomain: 'data-pipeline', access_url: 'https://data-pipeline.cloudcli.ai', status: 'stopped', region: 'fra1', agent: 'Cursor' },
{ id: 'env-ml', name: 'ml-trainer', subdomain: 'ml-trainer', access_url: 'https://ml-trainer.cloudcli.ai', status: 'paused', region: 'iad1', agent: 'Gemini' },
],
};
(function cloudCliLauncher() {
var MOCK = window.__MOCK_STATE__ || {};
var VERSION = window.__APP_VERSION__ || '';
var LOGO_URL = new URL('../../public/logo-32.png', window.location.href).toString();
var SEARCH = new URLSearchParams(window.location.search || '');
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
var mockState = clone(MOCK);
var mockBridge = {
getState: function () { return Promise.resolve(clone(mockState)); },
openLocal: function () {
mockState.localServerRunning = true;
mockState.activeTarget = { kind: 'local', name: 'Local CloudCLI', url: mockState.localWebUrl };
return Promise.resolve(clone(mockState));
},
openLocalWebUi: function () {
mockState.localServerRunning = true;
return Promise.resolve(clone(mockState));
},
copyLocalWebUrl: function () { return Promise.resolve(clone(mockState)); },
connectCloud: function () {
mockState.account = { connected: true, email: 'you@cloudcli.ai' };
return Promise.resolve(clone(mockState));
},
disconnectCloud: function () {
mockState.account = { connected: false, email: null };
mockState.environments = [];
mockState.tabs = (mockState.tabs || []).filter(function (tab) { return tab.kind !== 'remote'; });
mockState.activeTabId = 'home';
mockState.activeTarget = { kind: 'launcher', name: 'Launcher', url: null };
return Promise.resolve(clone(mockState));
},
refreshEnvironments: function () { return Promise.resolve(clone(mockState)); },
refreshActiveTab: function () { return Promise.resolve(clone(mockState)); },
copyDiagnostics: function () { return Promise.resolve(clone(mockState)); },
showEnvironmentPicker: function () { return Promise.resolve(clone(mockState)); },
showLauncher: function () { return Promise.resolve(clone(mockState)); },
showLocalSettings: function () { return Promise.resolve(clone(mockState)); },
showDesktopSettings: function () { return Promise.resolve(clone(mockState)); },
closeSettingsWindow: function () { return Promise.resolve(clone(mockState)); },
showActiveEnvironmentActionsMenu: function () { return Promise.resolve(clone(mockState)); },
openCloudDashboard: function () { return Promise.resolve(clone(mockState)); },
runActiveEnvironmentAction: function () { return Promise.resolve(clone(mockState)); },
switchTab: function (id) { mockState.activeTabId = id; return Promise.resolve(clone(mockState)); },
closeTab: function (id) {
mockState.tabs = (mockState.tabs || []).filter(function (tab) { return tab.id === 'home' || tab.id !== id; });
if (mockState.activeTabId === id) mockState.activeTabId = 'home';
return Promise.resolve(clone(mockState));
},
updateSetting: function (key, value) {
mockState.desktopSettings = mockState.desktopSettings || {};
mockState.desktopSettings[key] = key === 'themeMode' ? value : !!value;
return Promise.resolve(clone(mockState));
},
openEnvironment: function (id) {
var env = (mockState.environments || []).filter(function (item) { return item.id === id; })[0];
if (env) {
env.status = 'starting';
setTimeout(function () {
env.status = 'running';
mockState.activeTarget = { kind: 'remote', id: id, name: env.name, url: env.access_url };
}, 1700);
}
return Promise.resolve(clone(mockState));
},
};
var bridge = window.cloudcliDesktop || mockBridge;
var ICONS = {
terminal: '<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>',
cloud: '<path d="M17.5 19a4.5 4.5 0 0 0 .5-8.97A6 6 0 0 0 6.34 9 4 4 0 0 0 7 19z"/>',
refresh: '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>',
settings: '<line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/>',
gear: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 .6l-.03.08a2 2 0 1 1-3.94 0L10 20a1.7 1.7 0 0 0-1-.6 1.7 1.7 0 0 0-1.88.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-.6-1l-.08-.03a2 2 0 1 1 0-3.94L4 10a1.7 1.7 0 0 0 .6-1 1.7 1.7 0 0 0-.34-1.88l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-.6l.03-.08a2 2 0 1 1 3.94 0L14 4a1.7 1.7 0 0 0 1 .6 1.7 1.7 0 0 0 1.88-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9c.2.36.4.7.6 1l.08.03a2 2 0 1 1 0 3.94L20 14a1.7 1.7 0 0 0-.6 1z"/>',
play: '<polygon points="6 4 20 12 6 20 6 4"/>',
arrow: '<line x1="7" y1="17" x2="17" y2="7"/><polyline points="8 7 17 7 17 16"/>',
copy: '<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
cloudPlus: '<path d="M17.5 19a4.5 4.5 0 0 0 .5-8.97A6 6 0 0 0 6.34 9 4 4 0 0 0 7 19z"/><line x1="12" y1="9" x2="12" y2="15"/><line x1="9" y1="12" x2="15" y2="12"/>',
monitor: '<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>',
phone: '<rect x="7" y="2" width="10" height="20" rx="2"/><line x1="11" y1="18" x2="13" y2="18"/>',
x: '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
logOut: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>',
};
var FILLED = { play: true };
function icon(name, size) {
size = size || 16;
return '<svg width="' + size + '" height="' + size + '" viewBox="0 0 24 24" fill="' + (FILLED[name] ? 'currentColor' : 'none') + '" stroke="' + (FILLED[name] ? 'none' : 'currentColor') + '" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">' + (ICONS[name] || '') + '</svg>';
}
function esc(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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">&times;</span>' : '') +
'</button>';
}).join('');
}
CC.titlebar = function (state) {
var conn = connected(state);
var activeTab = (state.tabs || []).filter(function (tab) { return tab.active; })[0] || null;
var activeEnvironmentId = state.activeTarget && state.activeTarget.kind === 'remote' ? state.activeTarget.id : null;
if (!activeEnvironmentId && activeTab && /^remote:/.test(activeTab.id || '')) {
activeEnvironmentId = activeTab.id.replace(/^remote:/, '');
}
var activeRefreshable = (state.activeTarget && (state.activeTarget.kind === 'remote' || state.activeTarget.kind === 'local')) ||
(activeTab && activeTab.id !== 'home');
var envActions = activeEnvironmentId ? '<button class="btn sm tb-action no-drag" data-cc-action="env-row-menu" data-cc-environment-id="' + esc(activeEnvironmentId) + '" title="Open environment actions">Open environment in...</button>' : '';
var refreshAction = activeRefreshable ? '<button class="icon-btn tb-action no-drag" data-cc-action="refresh-tab" title="Refresh tab">' + icon('refresh', 16) + '</button>' : '';
var logoutAction = (conn || authState(state) === 'expired') ? '<button class="icon-btn tb-action no-drag" data-cc-action="logout" title="Logout">' + icon('logOut', 16) + '</button>' : '';
return '<div class="titlebar">' +
'<div class="brand"><img class="mk" src="' + esc(LOGO_URL) + '" alt=""><span>CloudCLI</span></div>' +
'<div class="tb-tabs no-drag">' + renderTabs(state) + '</div>' +
'<span style="flex:1"></span>' +
refreshAction +
envActions +
'<button class="btn sm tb-action no-drag" data-cc-action="connect" title="' + esc(authState(state) === 'expired' ? 'Reconnect your CloudCLI account' : accountLabel(state)) + '"><span class="dot" style="background:' + (conn ? 'var(--ok)' : (authState(state) === 'expired' ? 'var(--warn)' : 'var(--tx3)')) + '"></span>' + esc(accountLabel(state)) + '</button>' +
logoutAction +
'<button class="icon-btn tb-action no-drag" data-cc-action="settings-toggle" title="Settings">' + icon('settings', 16) + '</button>' +
'</div>';
};
CC.statusbar = function (state) {
var status = CC._status || {};
var running = !!state.localServerRunning;
return '<div class="statusbar">' +
'<span><span class="dot" style="width:7px;height:7px;background:' + (running ? 'var(--ok)' : 'var(--tx3)') + '"></span> local ' + (running ? 'running · ' + esc(localUrl(state)) : 'idle') + '</span>' +
'<span class="sep">·</span><span>' + esc(envCount(state)) + '</span>' +
'<span class="sep">·</span><span>' + (authState(state) === 'expired' ? 'session expired' : (connected(state) ? esc(accountLabel(state)) : 'not connected')) + '</span>' +
'<span style="flex:1"></span>' +
(status.msg ? '<span class="status-msg ' + esc(status.tone) + '">' + esc(status.msg) + '</span><span class="sep">·</span>' : '') +
'<span>v' + esc(VERSION) + '</span>' +
'</div>';
};
CC.renderSheet = function (title, subtitle, sections, footer) {
overlay.innerHTML =
'<div class="cc-sheet cc-modal">' +
'<div class="cc-sheet-header">' +
'<div class="cc-sheet-copy"><div class="cc-sheet-title">' + esc(title) + '</div><div class="cc-sheet-subtitle">' + esc(subtitle || '') + '</div></div>' +
'<button class="icon-btn cc-sheet-close" data-cc-action="settings-close" title="Close">' + icon('x', 16) + '</button>' +
'</div>' +
'<div class="cc-sheet-body">' + sections.join('') + '</div>' +
(footer ? '<div class="cc-sheet-footer">' + footer + '</div>' : '') +
'</div>';
};
CC.renderSection = function (eyebrow, title, body) {
return '<section class="cc-section">' +
'<div class="cc-section-head">' +
'<div class="lbl">' + esc(eyebrow) + '</div>' +
'<div class="cc-section-title">' + esc(title) + '</div>' +
'</div>' +
'<div class="cc-section-body">' + body + '</div>' +
'</section>';
};
CC.renderRadioOption = function (name, value, checked, title, description) {
return '<label class="cc-choice">' +
'<input type="radio" name="' + esc(name) + '" value="' + esc(value) + '"' + (checked ? ' checked' : '') + '>' +
'<span><b>' + esc(title) + '</b><br>' + esc(description) + '</span>' +
'</label>';
};
CC.openSheet = function (sheet, options) {
options = options || {};
if (sheet === 'desktop-settings') {
CC.renderDesktopSettings();
} else {
CC.renderLocalSettings();
}
CC.ui.openSheet = sheet;
overlay.classList.add('open');
if (typeof options.scrollTop === 'number') {
var body = overlay.querySelector('.cc-sheet-body');
if (body) body.scrollTop = options.scrollTop;
}
};
CC.closeSheet = function () {
if (CC.modalMode && bridge.closeSettingsWindow) {
CC.ui.openSheet = null;
return bridge.closeSettingsWindow();
}
CC.ui.openSheet = null;
overlay.classList.remove('open');
};
CC.buildLocalServerSection = function (state, options) {
options = options || {};
var settings = state.desktopSettings || {};
var url = localUrl(state) || 'starts on demand';
var body = '<div class="cc-surface">' +
'<div class="cc-meta mono">' + esc(url) + '</div>' +
'<div class="cc-row2"><button class="btn sm" data-cc-action="open-web">' + icon('arrow', 14) + 'Open in browser</button><button class="btn sm" data-cc-action="copy-web">' + icon('copy', 14) + 'Copy URL</button></div>';
if (options.includePreferences) {
body +=
'<label class="cc-toggle"><input type="checkbox" data-cc-setting="keepLocalServerRunning"' + (settings.keepLocalServerRunning ? ' checked' : '') + '><span><b>Keep server running</b><br>Leave Local CloudCLI available after you quit the app.</span></label>' +
'<label class="cc-toggle"><input type="checkbox" data-cc-setting="exposeLocalServerOnNetwork"' + (settings.exposeLocalServerOnNetwork ? ' checked' : '') + '><span><b>Allow LAN access</b><br>Use the copied URL from another device on this network.</span></label>';
}
body += '</div>';
return CC.renderSection(
options.eyebrow || 'LOCAL SERVER',
options.title || 'Run Local CloudCLI on this machine',
body
);
};
CC.buildThemeSection = function (state) {
var settings = state.desktopSettings || {};
return CC.renderSection('APPEARANCE', 'Desktop theme', '' +
'<div class="cc-surface cc-choice-group">' +
CC.renderRadioOption('desktop-theme', 'system', settings.themeMode === 'system', 'System', 'Follow the operating system appearance.') +
CC.renderRadioOption('desktop-theme', 'light', settings.themeMode === 'light', 'Light', 'Use the light interface appearance.') +
CC.renderRadioOption('desktop-theme', 'dark', settings.themeMode === 'dark', 'Dark', 'Use the dark interface appearance.') +
'</div>'
);
};
CC.renderLocalSettings = function () {
var state = CC.state || {};
var sections = [
CC.buildLocalServerSection(state, { includePreferences: false }),
CC.renderSection('PREFERENCES', 'How the local service behaves', '' +
'<div class="cc-surface">' +
'<label class="cc-toggle"><input type="checkbox" data-cc-setting="keepLocalServerRunning"' + ((state.desktopSettings || {}).keepLocalServerRunning ? ' checked' : '') + '><span><b>Keep server running</b><br>Leave Local CloudCLI available after you quit the app.</span></label>' +
'<label class="cc-toggle"><input type="checkbox" data-cc-setting="exposeLocalServerOnNetwork"' + ((state.desktopSettings || {}).exposeLocalServerOnNetwork ? ' checked' : '') + '><span><b>Allow LAN access</b><br>Use the copied URL from another device on this network.</span></label>' +
'</div>'
),
];
CC.renderSheet('Local Settings', 'Manage how Local CloudCLI runs on this computer.', sections);
};
CC.renderDesktopSettings = function () {
var sections = [
CC.buildThemeSection(CC.state || {}),
];
CC.renderSheet('Desktop Settings', 'Manage the desktop app appearance.', sections);
};
CC.render = function (state) {
state = state || CC.state;
var titlebar = (CC._reg.titlebar || CC.titlebar)(state);
var statusbar = (CC._reg.statusbar || CC.statusbar)(state);
var body = CC._reg.renderBody ? CC._reg.renderBody(state) : '';
if (CC.modalMode) {
app.innerHTML = '';
} else {
app.innerHTML = titlebar + '<div class="cc-body ' + (CC._reg.bodyClass || '') + '">' + body + '</div>' + statusbar;
}
if (CC._reg.afterRender) CC._reg.afterRender(state);
};
function wireEvents() {
if (CC._wired) return;
CC._wired = true;
document.addEventListener('click', function (event) {
if (CC._reg.onClick && CC._reg.onClick(event)) return;
var closeTab = event.target.closest('[data-cc-close-tab]');
if (closeTab) {
event.stopPropagation();
CC.run('Closing tab...', function () { return bridge.closeTab(closeTab.getAttribute('data-cc-close-tab')); });
return;
}
var tab = event.target.closest('[data-cc-tab]');
if (tab) {
CC.run('Switching tab...', function () { return bridge.switchTab(tab.getAttribute('data-cc-tab')); });
return;
}
var action = event.target.closest('[data-cc-action]');
if (action) {
CC.act(action.getAttribute('data-cc-action'), action);
return;
}
var env = event.target.closest('[data-cc-env]');
if (env) {
CC.openEnv(env.getAttribute('data-cc-env'));
return;
}
if (overlay.classList.contains('open') && !event.target.closest('.cc-sheet')) {
CC.closeSheet();
}
});
document.addEventListener('change', function (event) {
var setting = event.target.closest('[data-cc-setting]');
if (setting) {
CC.act('set-setting', {
key: setting.getAttribute('data-cc-setting'),
value: setting.checked,
});
return;
}
var theme = event.target.closest('[name="desktop-theme"]');
if (theme) {
CC.act('set-theme-mode', { value: theme.value });
return;
}
});
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape' && overlay.classList.contains('open')) {
CC.closeSheet();
return;
}
if ((event.metaKey || event.ctrlKey) && event.key === ',') {
event.preventDefault();
CC.act('settings-toggle');
return;
}
if (overlay.classList.contains('open')) return;
if (CC._reg.onKey) CC._reg.onKey(event, CC.state);
});
}
function boot() {
app = document.getElementById('app');
overlay = document.createElement('div');
overlay.id = 'cc-overlay';
overlay.className = 'cc-overlay';
document.body.appendChild(overlay);
var isMac = /Mac/i.test(navigator.platform) || /Mac OS X/i.test(navigator.userAgent);
var isWin = /Win/i.test(navigator.platform);
CC.platform = isMac ? 'mac' : (isWin ? 'win' : 'linux');
document.body.classList.add(CC.platform);
CC.ui.initialSheet = SEARCH.get('sheet') || 'desktop-settings';
if (CC.modalMode) {
document.documentElement.classList.add('cc-modal-window');
document.body.classList.add('cc-modal-window');
}
wireEvents();
if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () {
CC.applyTheme(CC.state);
});
}
if (bridge.onStateUpdated) {
bridge.onStateUpdated(function (state) { CC.setState(state); });
}
if (bridge.onLauncherCommand) {
bridge.onLauncherCommand(function (command) {
if (command && command.type === 'open-sheet') {
CC.ui.initialSheet = command.sheet || CC.ui.initialSheet || 'desktop-settings';
CC.openSheet(command.sheet);
}
});
}
CC.refresh().catch(function (error) {
CC._status = { msg: errMsg(error), tone: 'error' };
CC.render(CC.state);
});
}
CC.register = function (registry) {
CC._reg = registry || {};
};
CC.start = function () {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
};
})();
(function sidebarApp() {
var CC = window.CC;
function navItem(id, iconName, label, meta, selected) {
return '<button class="sb-item' + (selected === id ? ' active' : '') + '" data-cc-nav="' + id + '">' +
CC.icon(iconName, 16) + '<span>' + label + '</span><span class="sb-meta">' + CC.esc(meta) + '</span></button>';
}
function localPane(state) {
return '<div class="pane-h"><div><h2 class="pane-title">Local servers</h2><p class="pane-sub">Manage Local CloudCLI on this machine. No account required.</p></div></div>' +
'<div class="card"><div class="card-head"><div><div class="card-t">Local server</div><div class="card-sub mono">' + CC.esc(CC.localUrl(state) || 'Starts on demand') + '</div></div><div class="card-tools"><span class="dot" style="background:' + (state.localServerRunning ? 'var(--ok)' : 'var(--tx3)') + '"></span><button class="icon-btn" data-cc-action="local-settings-toggle" title="Local settings">' + CC.icon('gear', 16) + '</button></div></div>' +
'<div class="card-actions"><button class="btn pri" data-cc-action="local">' + CC.icon('play', 15) + 'Open Local CloudCLI</button><button class="btn" data-cc-action="open-web">' + CC.icon('arrow', 14) + 'Open in browser</button><button class="btn" data-cc-action="copy-web">' + CC.icon('copy', 14) + 'Copy URL</button></div></div>';
}
function envRow(environment) {
var meta = CC.statusMeta(environment.status);
var tags = (environment.agent ? '<span class="tag">' + CC.esc(environment.agent) + '</span>' : '') + (environment.region ? '<span class="tag">' + CC.esc(environment.region) + '</span>' : '');
return '<div class="env" data-cc-env="' + environment.id + '"><span class="dot" style="background:' + meta.dot + '"></span>' +
'<div class="env-i"><div class="env-n">' + CC.esc(environment.name || environment.subdomain) + '</div><div class="env-u mono">' + CC.esc(environment.access_url || '') + '</div></div>' +
'<div class="env-tags">' + tags + '</div>' +
'<span class="badge ' + meta.cls + '">' + meta.label + '</span>' +
'<button class="btn sm" data-cc-action="env-row-menu" data-cc-environment-id="' + environment.id + '">Open environment in...</button>' +
'<button class="btn sm ' + (environment.status === 'running' ? 'pri' : '') + '">' + CC.icon(meta.busy ? 'refresh' : (environment.status === 'running' ? 'arrow' : 'play'), 14) + meta.open + '</button></div>';
}
function cloudPane(state) {
var header = '<div class="pane-h"><div><h2 class="pane-title">Environments</h2><p class="pane-sub">' + CC.esc(CC.envCount(state)) + '</p></div><button class="btn sm" data-cc-action="dashboard">' + CC.icon('arrow', 14) + 'Dashboard</button></div>';
if (CC.authState(state) === 'expired') {
return header + '<div class="empty">Your CloudCLI session expired.<div style="margin-top:14px"><button class="btn pri" data-cc-action="connect">' + CC.icon('cloudPlus', 15) + 'Reconnect account</button></div></div>';
}
if (!CC.connected(state)) {
return header + '<div class="empty">Connect your CloudCLI account to list hosted environments.<div style="margin-top:14px"><button class="btn pri" data-cc-action="connect">' + CC.icon('cloudPlus', 15) + 'Connect account</button></div></div>';
}
if (state.cloudLoading && !(state.environments || []).length) {
return header + '<div class="empty">Loading your CloudCLI environments...</div>';
}
var list = (state.environments || []).map(envRow).join('');
if (!list) list = '<div class="empty">No hosted environments yet.</div>';
return header + list;
}
function renderBody(state) {
var section = CC.ui.section || ((CC.connected(state) || CC.authState(state) === 'expired') ? 'cloud' : 'local');
CC.ui.section = section;
var nav = '<div class="sb"><div class="sb-grp"><div class="lbl">Launcher</div>' +
navItem('local', 'terminal', 'Local servers', state.localServerRunning ? 'on' : 'idle', section) +
navItem('cloud', 'cloud', 'Cloud environments', (state.environments || []).length, section) +
'</div></div>';
return nav + '<div class="sb-main">' + (section === 'local' ? localPane(state) : cloudPane(state)) + '</div>';
}
function onClick(event) {
var nav = event.target.closest('[data-cc-nav]');
if (!nav) return false;
CC.ui.section = nav.getAttribute('data-cc-nav');
CC.render(CC.state);
return true;
}
CC.register({
bodyClass: 'v-sidebar',
renderBody: renderBody,
onClick: onClick,
});
CC.start();
})();

550
electron/localServer.js Normal file
View File

@@ -0,0 +1,550 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import http from 'node:http';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { ServerInstaller } from './serverInstaller.js';
const DEFAULT_PORT = 3001;
const HOST = '127.0.0.1';
const DISPLAY_HOST = 'localhost';
const HEALTH_TIMEOUT_MS = 1000;
const SERVER_START_TIMEOUT_MS = 30000;
const MAX_STARTUP_LOG_LINES = 300;
const SERVER_MARKER_PATH = path.join(os.homedir(), '.cloudcli', 'local-server.json');
const LOCAL_SERVER_URL_ENV_KEYS = [
'CLOUDCLI_DESKTOP_LOCAL_SERVER_URL',
'CLOUDCLI_LOCAL_SERVER_URL',
'ELECTRON_LOCAL_SERVER_URL',
];
const LOCAL_SERVER_PORT_ENV_KEYS = [
'CLOUDCLI_DESKTOP_LOCAL_SERVER_PORT',
'CLOUDCLI_SERVER_PORT',
'SERVER_PORT',
'PORT',
];
function requestJson(url, timeoutMs = HEALTH_TIMEOUT_MS) {
return new Promise((resolve) => {
const req = http.get(url, { timeout: timeoutMs }, (res) => {
let body = '';
res.setEncoding('utf8');
res.on('data', (chunk) => {
body += chunk;
});
res.on('end', () => {
try {
resolve({
ok: res.statusCode >= 200 && res.statusCode < 300,
json: JSON.parse(body),
});
} catch {
resolve({ ok: false, json: null });
}
});
});
req.on('timeout', () => {
req.destroy();
resolve({ ok: false, json: null });
});
req.on('error', () => resolve({ ok: false, json: null }));
});
}
async function isCloudCliServer(baseUrl) {
const response = await requestJson(`${baseUrl}/health`);
return response.ok
&& response.json?.status === 'ok'
&& typeof response.json?.installMode === 'string';
}
function isPortAvailable(port, host = HOST) {
return new Promise((resolve) => {
const server = net.createServer();
server.once('error', () => resolve(false));
server.once('listening', () => {
server.close(() => resolve(true));
});
server.listen(port, host);
});
}
function getFreePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.once('listening', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : DEFAULT_PORT;
server.close(() => resolve(port));
});
server.listen(0, HOST);
});
}
async function chooseServerPort(host) {
if (await isPortAvailable(DEFAULT_PORT, host)) {
return DEFAULT_PORT;
}
return getFreePort();
}
function getDesktopPath() {
const currentPath = process.env.PATH || '';
const commonPaths = process.platform === 'win32'
? []
: ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin'];
return [...commonPaths, currentPath].filter(Boolean).join(path.delimiter);
}
function getNodeRuntime(usePackagedElectronRuntime) {
if (process.env.ELECTRON_NODE_PATH) {
return { command: process.env.ELECTRON_NODE_PATH, env: {}, label: 'ELECTRON_NODE_PATH' };
}
if (usePackagedElectronRuntime && process.versions.electron) {
return {
command: process.execPath,
env: { ELECTRON_RUN_AS_NODE: '1' },
label: `Electron ${process.versions.electron} Node ${process.versions.node}`,
};
}
if (process.env.npm_node_execpath) {
return { command: process.env.npm_node_execpath, env: {}, label: 'npm_node_execpath' };
}
return { command: 'node', env: {}, label: 'PATH node' };
}
function stripTrailingSlash(value) {
return value.endsWith('/') ? value.slice(0, -1) : value;
}
function addCandidateUrl(urls, rawUrl) {
if (!rawUrl) return;
try {
const parsed = new URL(String(rawUrl));
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return;
parsed.hash = '';
parsed.search = '';
const normalized = stripTrailingSlash(parsed.toString());
if (!urls.includes(normalized)) urls.push(normalized);
} catch {
// Ignore invalid user-provided discovery values.
}
}
function addCandidatePort(urls, rawPort) {
const port = Number.parseInt(String(rawPort || ''), 10);
if (!Number.isInteger(port) || port < 1 || port > 65535) return;
addCandidateUrl(urls, `http://${HOST}:${port}`);
}
function getPortFromUrl(baseUrl) {
try {
const parsed = new URL(baseUrl);
if (parsed.port) return Number.parseInt(parsed.port, 10);
return parsed.protocol === 'https:' ? 443 : 80;
} catch {
return null;
}
}
function getDisplayUrl(baseUrl) {
try {
const parsed = new URL(baseUrl);
if (parsed.hostname === HOST) {
parsed.hostname = DISPLAY_HOST;
}
return stripTrailingSlash(parsed.toString());
} catch {
return baseUrl;
}
}
async function pathExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async function readServerBundleConfig(appRoot) {
try {
const raw = await fs.readFile(path.join(appRoot, 'electron', 'server-bundle-config.json'), 'utf8');
const config = JSON.parse(raw);
return {
releaseTag: typeof config.releaseTag === 'string' && config.releaseTag.trim()
? config.releaseTag.trim()
: '',
};
} catch {
return { releaseTag: '' };
}
}
function getServerCwd(appRoot, serverEntry) {
const normalizedEntry = path.resolve(serverEntry);
const bundledEntry = path.resolve(appRoot, 'dist-server', 'server', 'index.js');
if (normalizedEntry === bundledEntry) {
return appRoot;
}
// Installed server entries are laid out as <root>/dist-server/server/index.js.
return path.resolve(path.dirname(normalizedEntry), '..', '..');
}
async function readServerMarkerUrl() {
try {
const raw = await fs.readFile(SERVER_MARKER_PATH, 'utf8');
const marker = JSON.parse(raw);
return marker.url || (marker.port ? `http://${marker.host || HOST}:${marker.port}` : null);
} catch {
return null;
}
}
async function getExistingServerCandidateUrls(defaultUrl) {
const urls = [];
for (const key of LOCAL_SERVER_URL_ENV_KEYS) {
addCandidateUrl(urls, process.env[key]);
}
addCandidateUrl(urls, await readServerMarkerUrl());
for (const key of LOCAL_SERVER_PORT_ENV_KEYS) {
addCandidatePort(urls, process.env[key]);
}
addCandidateUrl(urls, defaultUrl);
return urls;
}
async function waitForCloudCliServer(baseUrl, timeoutMs) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (await isCloudCliServer(baseUrl)) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, 300));
}
return false;
}
export class LocalServerController {
constructor({ appRoot, settingsPath, isPackaged = false, appVersion, onChange }) {
this.appRoot = appRoot;
this.settingsPath = settingsPath;
this.isPackaged = isPackaged;
this.appVersion = appVersion;
this.onChange = onChange;
this.localServerUrl = null;
this.localServerPort = null;
this.ownedServerProcess = null;
this.startupLogs = [];
this.desktopSettings = {
keepLocalServerRunning: false,
exposeLocalServerOnNetwork: false,
themeMode: 'system',
};
}
getSettings() {
return this.desktopSettings;
}
getLocalServerUrl() {
return this.localServerUrl;
}
getHealthCheckUrl() {
if (!this.localServerPort) return this.localServerUrl;
return `http://${HOST}:${this.localServerPort}`;
}
appendStartupLog(line) {
const text = String(line || '').trimEnd();
if (!text) return;
const timestamp = new Date().toLocaleTimeString();
this.startupLogs.push(`[${timestamp}] ${text}`);
if (this.startupLogs.length > MAX_STARTUP_LOG_LINES) {
this.startupLogs.splice(0, this.startupLogs.length - MAX_STARTUP_LOG_LINES);
}
this.onChange?.();
}
getStartupLogs() {
return [...this.startupLogs];
}
getPendingTarget() {
return {
kind: 'local',
name: 'Local CloudCLI',
url: this.localServerUrl || `http://${DISPLAY_HOST}:${this.localServerPort || DEFAULT_PORT}`,
};
}
getLanAddress() {
const interfaces = os.networkInterfaces();
for (const entries of Object.values(interfaces)) {
for (const entry of entries || []) {
if (entry.family === 'IPv4' && !entry.internal) {
return entry.address;
}
}
}
return null;
}
getShareableWebUrl() {
if (!this.localServerUrl || !this.localServerPort) return null;
if (this.desktopSettings.exposeLocalServerOnNetwork) {
const lanAddress = this.getLanAddress();
if (lanAddress) {
return `http://${lanAddress}:${this.localServerPort}`;
}
}
return this.getLocalServerUrl();
}
getServerBindHost() {
return this.desktopSettings.exposeLocalServerOnNetwork ? '0.0.0.0' : HOST;
}
async loadDesktopSettings() {
try {
const raw = await fs.readFile(this.settingsPath, 'utf8');
const stored = JSON.parse(raw);
this.desktopSettings = {
keepLocalServerRunning: Boolean(stored.keepLocalServerRunning),
exposeLocalServerOnNetwork: Boolean(stored.exposeLocalServerOnNetwork),
themeMode: stored.themeMode === 'light' || stored.themeMode === 'dark' ? stored.themeMode : 'system',
};
} catch {
this.desktopSettings = {
keepLocalServerRunning: false,
exposeLocalServerOnNetwork: false,
themeMode: 'system',
};
}
}
async saveDesktopSettings(nextSettings = this.desktopSettings) {
this.desktopSettings = {
keepLocalServerRunning: Boolean(nextSettings.keepLocalServerRunning),
exposeLocalServerOnNetwork: Boolean(nextSettings.exposeLocalServerOnNetwork),
themeMode: nextSettings.themeMode === 'light' || nextSettings.themeMode === 'dark' ? nextSettings.themeMode : 'system',
};
await fs.mkdir(path.dirname(this.settingsPath), { recursive: true });
await fs.writeFile(this.settingsPath, JSON.stringify(this.desktopSettings, null, 2), 'utf8');
this.onChange?.();
}
async updateDesktopSetting(key, value) {
if (!Object.prototype.hasOwnProperty.call(this.desktopSettings, key)) {
throw new Error(`Unknown desktop setting: ${key}`);
}
const wasExposeSetting = key === 'exposeLocalServerOnNetwork';
const wasLocalRunning = Boolean(this.localServerUrl);
const nextValue = key === 'themeMode' ? value : Boolean(value);
await this.saveDesktopSettings({ ...this.desktopSettings, [key]: nextValue });
return {
desktopSettings: this.desktopSettings,
requiresRestartNotice: wasExposeSetting && wasLocalRunning,
};
}
/** Resolves the local server entry, installing the matching runtime if needed. */
async resolveServerEntry() {
if (process.env.ELECTRON_SERVER_ENTRY) {
return process.env.ELECTRON_SERVER_ENTRY;
}
const bundledEntry = path.join(this.appRoot, 'dist-server', 'server', 'index.js');
if (process.env.CLOUDCLI_USE_INSTALLED_SERVER !== '1' && await pathExists(bundledEntry)) {
return bundledEntry;
}
if (!this.appVersion) {
throw new Error('Cannot install local server: app version is unknown.');
}
const bundleConfig = await readServerBundleConfig(this.appRoot);
const installer = new ServerInstaller({
version: this.appVersion,
bundleReleaseTag: bundleConfig.releaseTag,
onLog: (line) => this.appendStartupLog(line),
});
return installer.ensureInstalled();
}
startBundledServer(port, serverEntry) {
const bindHost = this.getServerBindHost();
const runtime = getNodeRuntime(this.isPackaged);
const serverCwd = getServerCwd(this.appRoot, serverEntry);
const command = `${runtime.command} ${serverEntry}`;
this.appendStartupLog(`$ ${command}`);
this.appendStartupLog(`runtime: ${runtime.label}`);
this.appendStartupLog(`cwd: ${serverCwd}`);
this.appendStartupLog(`HOST=${bindHost} SERVER_PORT=${port} NODE_ENV=production`);
this.ownedServerProcess = spawn(runtime.command, [serverEntry], {
cwd: serverCwd,
detached: true,
env: {
...process.env,
...runtime.env,
HOST: bindHost,
SERVER_PORT: String(port),
NODE_ENV: 'production',
PATH: getDesktopPath(),
},
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
this.ownedServerProcess.once('error', (error) => {
this.appendStartupLog(`failed to start process: ${error.message}`);
this.ownedServerProcess = null;
});
this.ownedServerProcess.stdout?.on('data', (chunk) => {
for (const line of String(chunk).split(/\r?\n/)) {
this.appendStartupLog(line);
}
});
this.ownedServerProcess.stderr?.on('data', (chunk) => {
for (const line of String(chunk).split(/\r?\n/)) {
this.appendStartupLog(`stderr: ${line}`);
}
});
this.ownedServerProcess.once('exit', (code, signal) => {
this.appendStartupLog(`process exited with code ${code ?? 'null'} and signal ${signal ?? 'null'}`);
if (this.ownedServerProcess) {
console.error(`CloudCLI desktop server exited with code ${code ?? 'null'} and signal ${signal ?? 'null'}`);
}
this.ownedServerProcess = null;
});
}
async resolveLocalServerUrl() {
const defaultUrl = `http://${HOST}:${DEFAULT_PORT}`;
const defaultDisplayUrl = `http://${DISPLAY_HOST}:${DEFAULT_PORT}`;
const devUrl = process.env.ELECTRON_DEV_URL;
const forceOwnServer = process.env.ELECTRON_FORCE_OWN_SERVER === '1';
if (devUrl) {
const ready = await waitForCloudCliServer(defaultUrl, SERVER_START_TIMEOUT_MS);
if (!ready) {
throw new Error(`Development backend did not become ready at ${defaultDisplayUrl}`);
}
this.localServerPort = DEFAULT_PORT;
return devUrl;
}
if (!forceOwnServer) {
const candidateUrls = await getExistingServerCandidateUrls(defaultUrl);
for (const candidateUrl of candidateUrls) {
if (await isCloudCliServer(candidateUrl)) {
const displayUrl = getDisplayUrl(candidateUrl);
this.localServerPort = getPortFromUrl(candidateUrl);
this.appendStartupLog(`Using existing Local CloudCLI at ${displayUrl}`);
return displayUrl;
}
}
}
const serverEntry = await this.resolveServerEntry();
const port = await chooseServerPort(this.getServerBindHost());
const serverUrl = `http://${HOST}:${port}`;
const displayUrl = `http://${DISPLAY_HOST}:${port}`;
this.localServerPort = port;
this.startBundledServer(port, serverEntry);
const ready = await waitForCloudCliServer(serverUrl, SERVER_START_TIMEOUT_MS);
if (!ready) {
const recentLogs = this.getStartupLogs().slice(-20).join('\n');
await this.shutdownOwnedServer();
this.localServerPort = null;
throw new Error([
`Bundled backend did not become ready at ${displayUrl}.`,
recentLogs ? `Recent startup output:\n${recentLogs}` : 'No startup output was captured.',
].join('\n\n'));
}
this.appendStartupLog(`Local CloudCLI ready at ${displayUrl}`);
this.localServerUrl = displayUrl;
return displayUrl;
}
async ensureLocalServer() {
if (!this.localServerUrl) {
this.localServerUrl = await this.resolveLocalServerUrl();
}
return this.localServerUrl;
}
async getResolvedTarget() {
await this.ensureLocalServer();
return {
kind: 'local',
name: 'Local CloudCLI',
url: this.localServerUrl,
};
}
async loadLocalTarget() {
return {
pendingTarget: this.getPendingTarget(),
target: await this.getResolvedTarget(),
};
}
hasOwnedServer() {
return Boolean(this.ownedServerProcess);
}
detachOwnedServer() {
if (!this.ownedServerProcess) return;
this.ownedServerProcess.unref();
this.ownedServerProcess = null;
}
async shutdownOwnedServer() {
if (!this.ownedServerProcess) return;
const child = this.ownedServerProcess;
this.ownedServerProcess = null;
child.kill('SIGTERM');
await new Promise((resolve) => {
const timeout = setTimeout(resolve, 3000);
child.once('exit', () => {
clearTimeout(timeout);
resolve();
});
});
}
}
export { DEFAULT_PORT, HOST };

944
electron/main.js Normal file
View File

@@ -0,0 +1,944 @@
import { app, BrowserWindow, clipboard, dialog, ipcMain, session, shell } from 'electron';
import { spawn } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { CloudController } from './cloud.js';
import { DesktopWindowManager } from './desktopWindow.js';
import { DesktopNotificationsController } from './desktopNotifications.js';
import { LocalServerController } from './localServer.js';
import { TabsController } from './tabs.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const APP_NAME = 'CloudCLI';
const APP_USER_MODEL_ID = 'ai.cloudcli.desktop';
const CALLBACK_PROTOCOL = 'cloudcli';
const CALLBACK_URL = `${CALLBACK_PROTOCOL}://auth/callback`;
const CLOUDCLI_CONTROL_PLANE_URL = process.env.CLOUDCLI_CONTROL_PLANE_URL || 'https://cloudcli.ai';
const REMOTE_START_TIMEOUT_MS = 30000;
const AUTH_CALLBACK_TTL_MS = 10 * 60 * 1000;
const tabs = new TabsController();
if (process.platform === 'win32') {
app.setAppUserModelId(APP_USER_MODEL_ID);
}
let activeTarget = { kind: 'launcher', name: APP_NAME, url: null };
let desktopWindow = null;
let localServer = null;
let cloud = null;
let desktopNotifications = null;
let isQuitting = false;
let isRefreshingCloud = false;
let pendingCloudConnectStartedAt = 0;
function getAppRoot() {
return app.isPackaged ? app.getAppPath() : path.resolve(__dirname, '..');
}
function getLauncherPath() {
return path.join(__dirname, 'launcher', 'index.html');
}
function getPreloadPath() {
return path.join(__dirname, 'preload.cjs');
}
function getWindowIconPath() {
if (process.platform === 'darwin') {
return path.join(getAppRoot(), 'electron', 'assets', 'logo-macos.png');
}
return path.join(getAppRoot(), 'public', 'logo-512.png');
}
function getStorePath() {
return path.join(app.getPath('userData'), 'cloud-account.json');
}
function getSettingsPath() {
return path.join(app.getPath('userData'), 'desktop-settings.json');
}
function getDesktopNotificationsSettingsPath() {
return path.join(app.getPath('userData'), 'desktop-notifications-settings.json');
}
function getRunningEnvironmentUrls() {
return cloud.getEnvironments()
.filter((environment) => environment.status === 'running')
.map((environment) => cloud.getEnvironmentUrl(environment))
.filter(Boolean);
}
function getDisplayTargetName() {
return activeTarget?.name || APP_NAME;
}
function getCloudState() {
return {
account: cloud.getAccount(),
environments: cloud.getEnvironments(),
controlPlaneUrl: CLOUDCLI_CONTROL_PLANE_URL,
};
}
function getLocalState() {
return {
desktopSettings: localServer.getSettings(),
localServerRunning: Boolean(localServer.getLocalServerUrl()),
localWebUrl: localServer.getLocalServerUrl(),
shareableWebUrl: localServer.getShareableWebUrl(),
};
}
function serializeEnvironment(environment) {
return {
id: environment.id,
name: environment.name,
subdomain: environment.subdomain,
access_url: cloud.getEnvironmentUrl(environment),
status: environment.status,
created_at: environment.created_at,
github_url: environment.github_url || null,
region: environment.region || null,
agent: environment.agent || null,
};
}
function getDesktopState() {
const cloudAccount = cloud.getAccount();
const localState = getLocalState();
const authState = cloud.getAuthState();
return {
account: {
connected: authState === 'connected',
email: cloudAccount?.email || null,
authState,
requiresReconnect: authState === 'expired',
},
activeTarget,
desktopSettings: localState.desktopSettings,
localWebUrl: localState.localWebUrl,
shareableWebUrl: localState.shareableWebUrl,
localServerRunning: localState.localServerRunning,
localStartupLogs: localServer.getStartupLogs(),
cloudLoading: isRefreshingCloud,
tabs: tabs.getSerializableTabs(),
activeTabId: tabs.activeTabId,
environments: cloud.getEnvironments().map(serializeEnvironment),
desktopNotifications: desktopNotifications?.getState() || { enabled: false, supported: false, connectedCount: 0, targetCount: 0 },
};
}
async function openExternalUrl(url) {
if (String(url).startsWith(CALLBACK_PROTOCOL + "://")) {
await handleDeepLink(url);
return;
}
await shell.openExternal(url);
}
async function showError(title, error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`${title}: ${message}`);
await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
type: 'error',
title,
message: title,
detail: message,
});
}
function isExpectedNavigationAbort(error) {
const message = error instanceof Error ? error.message : String(error);
return error?.code === 'ERR_ABORTED' || message.includes('ERR_ABORTED') || message.includes('(-3)');
}
function syncDesktopState() {
if (!desktopWindow) return;
desktopWindow.buildAppMenu();
desktopWindow.emitDesktopState();
if (activeTarget?.kind === 'local' && !localServer?.getLocalServerUrl()) {
void desktopWindow.showLocalStartupTarget(localServer.getPendingTarget(), localServer.getStartupLogs())
.catch((error) => {
if (isExpectedNavigationAbort(error)) return;
void showError('Could not update local startup log', error);
});
}
}
function setActiveTarget(target) {
activeTarget = target;
}
function getEnvironmentTarget(environment) {
return {
kind: 'remote',
id: environment.id,
name: environment.name || environment.subdomain,
url: cloud.getEnvironmentUrl(environment),
};
}
async function getEnvironmentLaunchTarget(environment) {
const environmentUrl = cloud.getEnvironmentUrl(environment);
return {
...getEnvironmentTarget(environment),
url: environmentUrl,
loadUrl: await cloud.getEnvironmentLaunchUrl(environment),
};
}
async function hasCloudWebSession() {
const cookies = await session.defaultSession.cookies.get({});
return cookies.some((cookie) => {
const cookieDomain = String(cookie.domain || '');
return cookieDomain.includes('cloudcli.ai')
&& /-auth-token(?:\.\d+)?$/.test(cookie.name)
&& Boolean(cookie.value);
});
}
function isCloudAuthRedirect(url) {
if (!url) return false;
try {
const parsed = new URL(url);
const controlPlane = new URL(CLOUDCLI_CONTROL_PLANE_URL);
return parsed.origin === controlPlane.origin
&& (parsed.pathname === '/login' || parsed.pathname.startsWith('/auth/'));
} catch {
return false;
}
}
function getDiagnosticsText() {
const cloudAccount = cloud.getAccount();
const localState = getLocalState();
return JSON.stringify({
app: APP_NAME,
version: app.getVersion(),
electron: process.versions.electron,
node: process.versions.node,
platform: process.platform,
arch: process.arch,
appPath: getAppRoot(),
userDataPath: app.getPath('userData'),
activeTarget,
localServerUrl: localState.localWebUrl,
localServerPort: localServer.localServerPort,
localWebUrl: localState.localWebUrl,
shareableWebUrl: localState.shareableWebUrl,
desktopSettings: localState.desktopSettings,
cloudConnected: Boolean(cloudAccount?.apiKey),
cloudEmail: cloudAccount?.email || null,
cloudEnvironmentCount: cloud.getEnvironments().length,
cloudRunningEnvironmentCount: getRunningEnvironmentUrls().length,
cloudAuthState: cloud.getAuthState(),
cloudAccountPath: getStorePath(),
controlPlaneUrl: CLOUDCLI_CONTROL_PLANE_URL,
}, null, 2);
}
async function copyDiagnostics() {
clipboard.writeText(getDiagnosticsText());
await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
type: 'info',
title: 'Diagnostics copied',
message: 'CloudCLI desktop diagnostics were copied to the clipboard.',
});
}
async function refreshCloudEnvironments({ showErrors = false } = {}) {
isRefreshingCloud = true;
syncDesktopState();
try {
return await cloud.refreshCloudEnvironments();
} catch (error) {
const authState = cloud.getAuthState();
if (authState === 'expired') {
const expiredError = new Error('Your CloudCLI session expired. Reconnect your account.');
if (showErrors) {
await showError('CloudCLI login required', expiredError);
return [];
}
throw expiredError;
}
if (showErrors) {
await showError('Could not load CloudCLI environments', error);
return [];
}
throw error;
} finally {
isRefreshingCloud = false;
void desktopNotifications?.sync().catch((error) => console.error('[DesktopNotifications] sync failed:', error?.message || error));
syncDesktopState();
}
}
async function connectCloudAccount() {
const connectUrl = cloud.buildConnectUrl();
pendingCloudConnectStartedAt = Date.now();
clipboard.writeText(connectUrl);
await openExternalUrl(connectUrl);
return connectUrl;
}
async function handleDeepLink(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
return;
}
if (parsed.protocol !== `${CALLBACK_PROTOCOL}:` || parsed.hostname !== 'auth') {
return;
}
if (!pendingCloudConnectStartedAt || Date.now() - pendingCloudConnectStartedAt > AUTH_CALLBACK_TTL_MS) {
await showError('CloudCLI account connection failed', new Error('No recent CloudCLI account connection was started from this app.'));
return;
}
const apiKey = parsed.searchParams.get('api_key');
if (!apiKey) {
await showError('CloudCLI account connection failed', new Error('The callback did not include an API key.'));
return;
}
await cloud.saveFromCallback({
apiKey,
email: parsed.searchParams.get('email'),
});
pendingCloudConnectStartedAt = 0;
await refreshCloudEnvironments({ showErrors: true });
dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
type: 'info',
title: 'CloudCLI account connected',
message: cloud.getAccount()?.email ? `Connected as ${cloud.getAccount().email}.` : 'CloudCLI account connected.',
}).catch(() => {});
}
async function copyLocalWebUrl() {
await localServer.ensureLocalServer();
const shareableUrl = localServer.getShareableWebUrl();
const localUrl = localServer.getLocalServerUrl();
if (!shareableUrl) {
throw new Error('Local CloudCLI URL is not available yet.');
}
clipboard.writeText(shareableUrl);
const isLanUrl = shareableUrl !== localUrl;
await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
type: 'info',
title: 'Web URL copied',
message: isLanUrl ? 'LAN web URL copied.' : 'Local web URL copied.',
detail: isLanUrl
? `${shareableUrl}\n\nUse this URL from another device on the same network.`
: `${shareableUrl}\n\nThis URL works on this computer. Enable LAN access before starting Local CloudCLI to copy a phone-accessible URL.`,
});
return getDesktopState();
}
async function openLocalWebUi() {
await localServer.ensureLocalServer();
const url = localServer.getShareableWebUrl() || localServer.getLocalServerUrl();
if (!url) {
throw new Error('Local CloudCLI URL is not available yet.');
}
await openExternalUrl(url);
return getDesktopState();
}
async function updateDesktopSetting(key, value) {
const result = await localServer.updateDesktopSetting(key, value);
syncDesktopState();
if (result.requiresRestartNotice) {
await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
type: 'info',
title: 'Restart local server to apply',
message: 'LAN access changes apply the next time the local server starts.',
detail: 'Quit CloudCLI and stop the local server, then open Local CloudCLI again.',
});
}
return getDesktopState();
}
async function showEnvironmentPicker() {
let environments = cloud.getEnvironments();
let refreshError = null;
if (cloud.getAccount()?.apiKey) {
try {
environments = await refreshCloudEnvironments({ showErrors: false });
} catch (error) {
refreshError = error;
console.warn('[Cloud] Could not refresh environments before showing picker:', error?.message || error);
}
}
const choices = ['Local CloudCLI', ...environments.map((environment) => {
const status = environment.status === 'running' ? '' : ` (${environment.status})`;
return `${environment.name || environment.subdomain}${status}`;
})];
const response = await dialog.showMessageBox(desktopWindow?.getMainWindow(), {
type: 'question',
buttons: [...choices, 'Cancel'],
defaultId: 0,
cancelId: choices.length,
title: 'Switch CloudCLI Environment',
message: 'Choose where this desktop window should connect.',
detail: refreshError ? `Cloud environments could not be refreshed. Showing cached environments.\n\n${refreshError.message || refreshError}` : undefined,
});
if (response.response === choices.length) return getDesktopState();
if (response.response === 0) return openLocalInDesktop();
return openEnvironmentInDesktop(environments[response.response - 1]);
}
async function startEnvironment(environment) {
await cloud.startEnvironmentAndWait(environment, REMOTE_START_TIMEOUT_MS);
await refreshCloudEnvironments({ showErrors: true });
return getDesktopState();
}
async function stopEnvironment(environment) {
await cloud.stopEnvironment(environment);
await refreshCloudEnvironments({ showErrors: true });
return getDesktopState();
}
async function openEnvironmentInBrowser(environment) {
await openExternalUrl(await cloud.getEnvironmentLaunchUrl(environment));
return getDesktopState();
}
function getProjectFolder(environment) {
return String(environment.name || environment.subdomain || 'workspace').replace(/[^a-zA-Z0-9-]/g, '');
}
function getSshTarget(credentials) {
if (credentials.ssh_command) {
const parts = String(credentials.ssh_command).split(/\s+/);
if (parts.length >= 2) return parts[1];
}
return `${credentials.username}@ssh.cloudcli.ai`;
}
function getSshHost(credentials) {
const target = getSshTarget(credentials);
const atIndex = target.indexOf('@');
return atIndex >= 0 ? target.slice(atIndex + 1) : 'ssh.cloudcli.ai';
}
function getSafeSshUsername(credentials) {
const username = String(credentials.username || '');
if (!/^[a-zA-Z0-9._-]+$/.test(username)) {
throw new Error('Cloud environment returned an invalid SSH username.');
}
return username;
}
function getSafeSshHost(credentials) {
const host = getSshHost(credentials);
if (!/^[a-zA-Z0-9.-]+$/.test(host)) {
throw new Error('Cloud environment returned an invalid SSH host.');
}
return host;
}
function shellQuote(value) {
return `'${String(value).replace(/'/g, `'\\''`)}'`;
}
async function getEnvironmentCredentials(environment) {
const credentials = await cloud.getEnvironmentCredentials(environment);
if (credentials.password) {
clipboard.writeText(credentials.password);
}
return credentials;
}
async function openEnvironmentInIde(environment, ide) {
const credentials = await getEnvironmentCredentials(environment);
const scheme = ide === 'cursor' ? 'cursor' : 'vscode';
const remoteUri = `${scheme}://vscode-remote/ssh-remote+${getSafeSshUsername(credentials)}@${getSafeSshHost(credentials)}/workspace/${getProjectFolder(environment)}?windowId=_blank`;
await shell.openExternal(remoteUri);
return getDesktopState();
}
async function openEnvironmentInSsh(environment) {
const credentials = await getEnvironmentCredentials(environment);
const remoteCommand = `cd /workspace/${getProjectFolder(environment)} && exec $SHELL -l`;
const sshCommand = `ssh -t ${shellQuote(getSshTarget(credentials))} ${shellQuote(remoteCommand)}`;
if (process.platform === 'darwin') {
const escaped = sshCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
spawn('osascript', ['-e', `tell application "Terminal" to do script "${escaped}"`], {
detached: true,
stdio: 'ignore',
}).unref();
} else {
clipboard.writeText(sshCommand);
await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
type: 'info',
title: 'SSH command copied',
message: 'The SSH command was copied to the clipboard.',
detail: sshCommand,
});
}
return getDesktopState();
}
async function copyEnvironmentMobileUrl(environment) {
const url = cloud.getEnvironmentUrl(environment);
clipboard.writeText(url);
await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
type: 'info',
title: 'Environment URL copied',
message: 'Use this URL from your mobile browser.',
detail: url,
});
return getDesktopState();
}
async function openCloudDashboard() {
await openExternalUrl(CLOUDCLI_CONTROL_PLANE_URL);
return getDesktopState();
}
function getActiveRemoteEnvironment() {
if (activeTarget?.kind !== 'remote') return null;
return cloud.findEnvironment(activeTarget.id);
}
async function runActiveEnvironmentAction(action) {
const environment = getActiveRemoteEnvironment();
if (!environment) {
throw new Error('Open a cloud environment first.');
}
switch (action) {
case 'web':
return openEnvironmentInBrowser(environment);
case 'vscode':
return openEnvironmentInIde(environment, 'vscode');
case 'cursor':
return openEnvironmentInIde(environment, 'cursor');
case 'ssh':
return openEnvironmentInSsh(environment);
case 'mobile':
return copyEnvironmentMobileUrl(environment);
default:
throw new Error(`Unknown environment action: ${action}`);
}
}
async function openLocalInDesktop() {
const existingTab = tabs.getTab('local');
if (existingTab && localServer.getLocalServerUrl()) {
await desktopWindow.showTarget(await localServer.getResolvedTarget());
return getDesktopState();
}
const pendingTarget = localServer.getPendingTarget();
tabs.upsertTarget(pendingTarget);
setActiveTarget(pendingTarget);
await desktopWindow.showLocalStartupTarget(pendingTarget, localServer.getStartupLogs());
desktopWindow.emitDesktopState();
const target = await localServer.getResolvedTarget();
await desktopWindow.showTarget(target);
return getDesktopState();
}
async function openEnvironmentInDesktop(environment) {
const pendingTarget = getEnvironmentTarget(environment);
const tabId = tabs.getTabIdForTarget(pendingTarget);
const hadTab = Boolean(tabs.getTab(tabId));
const previousTabId = tabs.activeTabId;
if (!hadTab) {
await desktopWindow.showTabPlaceholder(
pendingTarget,
`${environment.status === 'running' ? 'Opening' : 'Starting'} ${pendingTarget.name}...`,
);
tabs.upsertTarget(pendingTarget);
desktopWindow.emitDesktopState();
}
let nextEnvironment = environment;
if (environment.status !== 'running') {
const response = await dialog.showMessageBox(desktopWindow?.getMainWindow(), {
type: 'question',
buttons: ['Start Environment', 'Cancel'],
defaultId: 0,
cancelId: 1,
title: 'Start environment?',
message: `${pendingTarget.name} is ${environment.status}.`,
detail: 'CloudCLI can start it before opening the remote app.',
});
if (response.response !== 0) {
if (!hadTab) {
tabs.remove(tabId);
desktopWindow.destroyTabView(tabId);
if (previousTabId && previousTabId !== tabId) {
await desktopWindow.switchDesktopTab(previousTabId);
} else {
await desktopWindow.showLauncher();
}
}
return getDesktopState();
}
if (hadTab) {
await desktopWindow.showTabPlaceholder(pendingTarget, `Starting ${pendingTarget.name}...`);
tabs.upsertTarget(pendingTarget);
desktopWindow.emitDesktopState();
}
nextEnvironment = await cloud.startEnvironmentAndWait(environment, REMOTE_START_TIMEOUT_MS);
}
let target = getEnvironmentTarget(nextEnvironment);
if (!(await hasCloudWebSession())) {
target = await getEnvironmentLaunchTarget(nextEnvironment);
}
const usedBootstrap = Boolean(target.loadUrl);
const finalUrl = await desktopWindow.showTarget(target);
if (!usedBootstrap && isCloudAuthRedirect(finalUrl)) {
const bootstrapTarget = await getEnvironmentLaunchTarget(nextEnvironment);
bootstrapTarget.forceLoad = true;
await desktopWindow.showTarget(bootstrapTarget);
}
return getDesktopState();
}
function findEnvironmentByUrl(environmentUrl) {
const targetOrigin = (() => {
try {
return new URL(environmentUrl).origin;
} catch {
return null;
}
})();
if (!targetOrigin) return null;
return cloud.getEnvironments().find((environment) => {
try {
return new URL(cloud.getEnvironmentUrl(environment)).origin === targetOrigin;
} catch {
return false;
}
}) || null;
}
async function openNotificationTarget({ environmentUrl, sessionId = null }) {
const window = desktopWindow?.getMainWindow();
if (window) {
if (window.isMinimized()) window.restore();
window.show();
window.focus();
}
const environment = findEnvironmentByUrl(environmentUrl);
if (environment) {
await openEnvironmentInDesktop(environment);
} else {
const parsed = new URL(environmentUrl);
await desktopWindow.showTarget({
kind: 'remote',
name: parsed.hostname,
url: parsed.origin,
});
}
const targetUrl = new URL(sessionId ? `/session/${encodeURIComponent(sessionId)}` : '/', environmentUrl).toString();
await desktopWindow.navigateActiveView(targetUrl);
return getDesktopState();
}
async function getEnvironmentAuthToken(environmentUrl) {
return (await desktopWindow?.readAuthTokenForTarget(environmentUrl)) || null;
}
async function clearCloudAccount() {
await cloud.clearCloudAccount();
desktopNotifications?.stop();
const removedTabs = tabs.removeByKind('remote');
for (const tab of removedTabs) {
desktopWindow?.destroyTabView(tab.id);
}
if (activeTarget?.kind === 'remote') {
await desktopWindow?.showLauncher();
} else {
syncDesktopState();
}
return getDesktopState();
}
function getRemoteEnvironmentMenuItems() {
const cloudAccount = cloud.getAccount();
const environments = cloud.getEnvironments();
if (!cloudAccount?.apiKey) {
return [{ label: 'Connect CloudCLI Account...', click: () => void connectCloudAccount() }];
}
if (!environments.length) {
return [{ label: 'No environments found', enabled: false }];
}
return environments.map((environment) => ({
label: `${environment.name || environment.subdomain}${environment.status === 'running' ? '' : ` (${environment.status})`}`,
click: () => void openEnvironmentInDesktop(environment)
.catch((error) => showError('Could not open environment', error)),
}));
}
function registerProtocolHandler() {
const appEntry = path.join(getAppRoot(), 'electron', 'main.js');
if (process.defaultApp && process.argv.length >= 2) {
app.setAsDefaultProtocolClient(CALLBACK_PROTOCOL, process.execPath, [appEntry]);
} else {
app.setAsDefaultProtocolClient(CALLBACK_PROTOCOL);
}
}
function registerIpcHandlers() {
ipcMain.handle('cloudcli-desktop:connect-cloud', async () => ({
...getDesktopState(),
connectUrl: await connectCloudAccount(),
}));
ipcMain.handle('cloudcli-desktop:copy-diagnostics', async () => {
await copyDiagnostics();
return getDesktopState();
});
ipcMain.handle('cloudcli-desktop:copy-local-web-url', async () => copyLocalWebUrl());
ipcMain.handle('cloudcli-desktop:get-state', () => getDesktopState());
ipcMain.handle('cloudcli-desktop:open-cloud-dashboard', async () => openCloudDashboard());
ipcMain.handle('cloudcli-desktop:run-active-environment-action', async (_event, action) => runActiveEnvironmentAction(action));
ipcMain.handle('cloudcli-desktop:open-environment', async (_event, environmentId) => {
const environment = cloud.findEnvironment(environmentId);
if (!environment) {
throw new Error('Environment not found. Refresh and try again.');
}
return openEnvironmentInDesktop(environment);
});
ipcMain.handle('cloudcli-desktop:open-local', async () => openLocalInDesktop());
ipcMain.handle('cloudcli-desktop:open-local-web-ui', async () => openLocalWebUi());
ipcMain.handle('cloudcli-desktop:refresh-environments', async () => {
await refreshCloudEnvironments({ showErrors: true });
return getDesktopState();
});
ipcMain.handle('cloudcli-desktop:disconnect-cloud', async () => clearCloudAccount());
ipcMain.handle('cloudcli-desktop:reload-active-tab', async () => desktopWindow.reloadActiveTab());
ipcMain.handle('cloudcli-desktop:show-environment-picker', async () => showEnvironmentPicker());
ipcMain.handle('cloudcli-desktop:show-launcher', async () => {
await desktopWindow.showLauncher();
return getDesktopState();
});
ipcMain.handle('cloudcli-desktop:update-desktop-notifications', async (_event, settings) => {
await desktopNotifications?.saveSettings(settings);
return getDesktopState();
});
ipcMain.handle('cloudcli-desktop:show-desktop-settings', async () => desktopWindow.showDesktopSettings());
ipcMain.handle('cloudcli-desktop:show-local-settings', async () => desktopWindow.showLocalSettings());
ipcMain.handle('cloudcli-desktop:close-settings-window', async () => {
desktopWindow.closeSettingsWindow();
return getDesktopState();
});
ipcMain.handle('cloudcli-desktop:show-active-environment-actions-menu', async () => desktopWindow.showActiveEnvironmentActionsMenu());
ipcMain.handle('cloudcli-desktop:show-environment-actions-menu', async (_event, environmentId) => desktopWindow.showEnvironmentActionsMenu(environmentId));
ipcMain.handle('cloudcli-desktop:switch-tab', async (_event, tabId) => desktopWindow.switchDesktopTab(tabId));
ipcMain.handle('cloudcli-desktop:close-tab', async (_event, tabId) => desktopWindow.closeDesktopTab(tabId));
ipcMain.handle('cloudcli-desktop:update-setting', async (_event, key, value) => updateDesktopSetting(key, value));
}
function registerAppEvents() {
app.on('open-url', (event, url) => {
event.preventDefault();
void handleDeepLink(url);
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
if (desktopWindow) {
void desktopWindow.createWindow();
} else {
void createDesktopWindow();
}
return;
}
const window = desktopWindow?.getMainWindow();
if (window) {
window.show();
window.focus();
}
});
app.on('before-quit', () => {
desktopNotifications?.stop();
});
app.on('before-quit', (event) => {
if (isQuitting || !localServer?.hasOwnedServer()) return;
if (localServer.getSettings().keepLocalServerRunning) {
localServer.detachOwnedServer();
return;
}
event.preventDefault();
isQuitting = true;
void localServer.shutdownOwnedServer().finally(() => app.quit());
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
}
async function createDesktopWindow() {
desktopWindow = new DesktopWindowManager({
appName: APP_NAME,
getWindowIconPath,
getLauncherPath,
getPreloadPath,
openExternalUrl,
getDesktopState,
getDisplayTargetName,
getRemoteEnvironmentMenuItems,
getCloudState,
getLocalState,
tabs,
actions: {
copyDiagnostics,
copyText: (text) => clipboard.writeText(text),
clearCloudAccount,
connectCloudAccount,
getActiveTarget: () => activeTarget,
getEnvironmentUrl: (environment) => cloud.getEnvironmentUrl(environment),
openEnvironmentInBrowser,
openEnvironmentInDesktop,
openEnvironmentInIde,
openEnvironmentInSsh,
openLocalInDesktop,
openLocalWebUi,
openCloudDashboard,
refreshCloudEnvironments: () => refreshCloudEnvironments({ showErrors: true }),
setActiveTarget,
showEnvironmentPicker,
showError,
startEnvironment,
stopEnvironment,
updateDesktopSetting,
copyLocalWebUrl,
openNotificationTarget,
},
});
desktopWindow.createTray();
desktopWindow.configurePermissions();
await desktopWindow.createWindow();
}
function registerSingleInstance() {
const gotSingleInstanceLock = app.requestSingleInstanceLock();
if (!gotSingleInstanceLock) {
app.quit();
return false;
}
app.on('second-instance', (_event, argv) => {
const deepLink = argv.find((arg) => arg.startsWith(`${CALLBACK_PROTOCOL}://`));
if (deepLink) {
void handleDeepLink(deepLink);
}
const window = desktopWindow?.getMainWindow();
if (window) {
if (window.isMinimized()) window.restore();
window.show();
window.focus();
}
});
return true;
}
async function bootstrap() {
app.name = APP_NAME;
app.setName(APP_NAME);
process.title = APP_NAME;
await app.whenReady();
app.setName(APP_NAME);
app.setAboutPanelOptions({
applicationName: APP_NAME,
applicationVersion: app.getVersion(),
copyright: 'CloudCLI',
});
localServer = new LocalServerController({
appRoot: getAppRoot(),
settingsPath: getSettingsPath(),
isPackaged: app.isPackaged,
appVersion: app.getVersion(),
onChange: syncDesktopState,
});
cloud = new CloudController({
storePath: getStorePath(),
controlPlaneUrl: CLOUDCLI_CONTROL_PLANE_URL,
callbackUrl: CALLBACK_URL,
onChange: syncDesktopState,
});
desktopNotifications = new DesktopNotificationsController({
settingsPath: getDesktopNotificationsSettingsPath(),
appVersion: app.getVersion(),
appName: APP_NAME,
getDeviceId: () => cloud.getAccount()?.deviceId || '',
getAccountEmail: () => cloud.getAccount()?.email || null,
getRunningEnvironmentUrls,
getApiKey: () => cloud.getAccount()?.apiKey || '',
getAuthToken: getEnvironmentAuthToken,
getIconPath: getWindowIconPath,
openNotificationTarget,
onChange: syncDesktopState,
});
await localServer.loadDesktopSettings();
await cloud.loadCloudAccount();
await desktopNotifications.loadSettings();
registerProtocolHandler();
registerIpcHandlers();
registerAppEvents();
await createDesktopWindow();
void refreshCloudEnvironments({ showErrors: false });
}
if (registerSingleInstance()) {
bootstrap().catch(async (error) => {
await showError('CloudCLI failed to start', error);
app.quit();
});
}

60
electron/preload.cjs Normal file
View File

@@ -0,0 +1,60 @@
const { contextBridge, ipcRenderer } = require('electron');
function isCloudCliAppOrigin(location) {
if (location.protocol === 'file:') return true;
if (location.protocol === 'http:') {
return location.hostname === '127.0.0.1' || location.hostname === 'localhost';
}
return location.protocol === 'https:' && (
location.hostname === 'cloudcli.ai' || location.hostname.endsWith('.cloudcli.ai')
);
}
function onDesktopStateUpdated(callback) {
const listener = (_event, state) => callback(state);
ipcRenderer.on('cloudcli-desktop:state-updated', listener);
return () => {
ipcRenderer.removeListener('cloudcli-desktop:state-updated', listener);
};
}
if (isCloudCliAppOrigin(window.location)) {
contextBridge.exposeInMainWorld('cloudcliDesktopNotifications', {
getState: () => ipcRenderer.invoke('cloudcli-desktop:get-state'),
update: (settings) => ipcRenderer.invoke('cloudcli-desktop:update-desktop-notifications', settings),
onStateUpdated: onDesktopStateUpdated,
});
}
if (window.location.protocol === 'file:') {
contextBridge.exposeInMainWorld('cloudcliDesktop', {
connectCloud: () => ipcRenderer.invoke('cloudcli-desktop:connect-cloud'),
disconnectCloud: () => ipcRenderer.invoke('cloudcli-desktop:disconnect-cloud'),
copyDiagnostics: () => ipcRenderer.invoke('cloudcli-desktop:copy-diagnostics'),
copyLocalWebUrl: () => ipcRenderer.invoke('cloudcli-desktop:copy-local-web-url'),
getState: () => ipcRenderer.invoke('cloudcli-desktop:get-state'),
openCloudDashboard: () => ipcRenderer.invoke('cloudcli-desktop:open-cloud-dashboard'),
openEnvironment: (environmentId) => ipcRenderer.invoke('cloudcli-desktop:open-environment', environmentId),
runActiveEnvironmentAction: (action) => ipcRenderer.invoke('cloudcli-desktop:run-active-environment-action', action),
openLocal: () => ipcRenderer.invoke('cloudcli-desktop:open-local'),
openLocalWebUi: () => ipcRenderer.invoke('cloudcli-desktop:open-local-web-ui'),
refreshEnvironments: () => ipcRenderer.invoke('cloudcli-desktop:refresh-environments'),
refreshActiveTab: () => ipcRenderer.invoke('cloudcli-desktop:reload-active-tab'),
showEnvironmentPicker: () => ipcRenderer.invoke('cloudcli-desktop:show-environment-picker'),
showLauncher: () => ipcRenderer.invoke('cloudcli-desktop:show-launcher'),
showLocalSettings: () => ipcRenderer.invoke('cloudcli-desktop:show-local-settings'),
showDesktopSettings: () => ipcRenderer.invoke('cloudcli-desktop:show-desktop-settings'),
closeSettingsWindow: () => ipcRenderer.invoke('cloudcli-desktop:close-settings-window'),
showActiveEnvironmentActionsMenu: () => ipcRenderer.invoke('cloudcli-desktop:show-active-environment-actions-menu'),
showEnvironmentActionsMenu: (environmentId) => ipcRenderer.invoke('cloudcli-desktop:show-environment-actions-menu', environmentId),
switchTab: (tabId) => ipcRenderer.invoke('cloudcli-desktop:switch-tab', tabId),
closeTab: (tabId) => ipcRenderer.invoke('cloudcli-desktop:close-tab', tabId),
updateSetting: (key, value) => ipcRenderer.invoke('cloudcli-desktop:update-setting', key, value),
onStateUpdated: onDesktopStateUpdated,
onLauncherCommand: (callback) => {
ipcRenderer.on('cloudcli-desktop:launcher-command', (_event, command) => callback(command));
},
});
}

View File

@@ -0,0 +1,62 @@
import fs from 'node:fs/promises';
import sharp from 'sharp';
const size = 1024;
const assetsDir = 'electron/assets';
const iconPath = 'electron/assets/logo-macos.png';
const icnsPath = 'electron/assets/logo-macos.icns';
function renderSvg(entrySize) {
const scale = entrySize / 32;
return `
<svg xmlns="http://www.w3.org/2000/svg" width="${entrySize}" height="${entrySize}" viewBox="0 0 ${entrySize} ${entrySize}">
<rect width="${entrySize}" height="${entrySize}" fill="#2563eb"/>
<path
d="M${8 * scale} ${9 * scale}C${8 * scale} ${8.44772 * scale} ${8.44772 * scale} ${8 * scale} ${9 * scale} ${8 * scale}H${23 * scale}C${23.5523 * scale} ${8 * scale} ${24 * scale} ${8.44772 * scale} ${24 * scale} ${9 * scale}V${18 * scale}C${24 * scale} ${18.5523 * scale} ${23.5523 * scale} ${19 * scale} ${23 * scale} ${19 * scale}H${12 * scale}L${8 * scale} ${23 * scale}V${9 * scale}Z"
stroke="white"
stroke-width="${2 * scale}"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
</svg>`;
}
async function renderPng(entrySize) {
return sharp(Buffer.from(renderSvg(entrySize)))
.png()
.toBuffer();
}
await fs.mkdir(assetsDir, { recursive: true });
await fs.writeFile(iconPath, await renderPng(size));
const icnsEntries = [
['icp4', 16],
['icp5', 32],
['icp6', 64],
['ic07', 128],
['ic08', 256],
['ic09', 512],
['ic10', 1024],
['ic11', 32],
['ic12', 64],
['ic13', 256],
['ic14', 512],
];
const blocks = await Promise.all(icnsEntries.map(async ([type, entrySize]) => {
const png = await renderPng(entrySize);
const block = Buffer.alloc(8 + png.length);
block.write(type, 0, 4, 'ascii');
block.writeUInt32BE(block.length, 4);
png.copy(block, 8);
return block;
}));
const totalLength = 8 + blocks.reduce((sum, block) => sum + block.length, 0);
const header = Buffer.alloc(8);
header.write('icns', 0, 4, 'ascii');
header.writeUInt32BE(totalLength, 4);
await fs.writeFile(icnsPath, Buffer.concat([header, ...blocks], totalLength));

277
electron/serverInstaller.js Normal file
View File

@@ -0,0 +1,277 @@
import { spawn } from 'node:child_process';
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import { createReadStream, createWriteStream } from 'node:fs';
import https from 'node:https';
import os from 'node:os';
import path from 'node:path';
/**
* Installs the versioned local server runtime used by CloudCLI Desktop.
*
* Server bundles are cached under:
* ~/.cloudcli/server/<version>/dist-server/server/index.js
*/
const DEFAULT_INSTALL_ROOT = path.join(os.homedir(), '.cloudcli', 'server');
const DEFAULT_BUNDLE_BASE_URL = 'https://github.com/siteboon/claudecodeui/releases/download';
const MAX_REDIRECTS = 5;
const LOCAL_DOWNLOAD_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
function mapArch(arch = process.arch) {
return arch === 'arm64' ? 'arm64' : 'x64';
}
function mapPlatform(platform = process.platform) {
if (platform === 'darwin') return 'mac';
if (platform === 'win32') return 'win';
return 'linux';
}
export class ServerInstaller {
constructor({
version,
platform = process.platform,
arch = process.arch,
installRoot = process.env.CLOUDCLI_SERVER_DIR || DEFAULT_INSTALL_ROOT,
bundleBaseUrl = process.env.CLOUDCLI_SERVER_BUNDLE_URL || DEFAULT_BUNDLE_BASE_URL,
bundleReleaseTag = process.env.CLOUDCLI_SERVER_BUNDLE_RELEASE_TAG || '',
onLog,
} = {}) {
if (!version) throw new Error('ServerInstaller requires the app version');
this.version = version;
this.platform = mapPlatform(platform);
this.arch = mapArch(arch);
this.installRoot = installRoot;
this.bundleBaseUrl = bundleBaseUrl.replace(/\/+$/, '');
this.bundleReleaseTag = bundleReleaseTag || `v${this.version}`;
this.onLog = typeof onLog === 'function' ? onLog : () => {};
}
/** Directory the current version's server is (or will be) installed in. */
getVersionDir() {
return path.join(this.installRoot, this.version);
}
/** Absolute path to the server entry once installed. */
getServerEntry() {
return path.join(this.getVersionDir(), 'dist-server', 'server', 'index.js');
}
getBundleName() {
return `cloudcli-local-server-${this.version}-${this.platform}-${this.arch}.tar.gz`;
}
getBundleUrl() {
const url = new URL(`${this.bundleBaseUrl}/${this.bundleReleaseTag}/${this.getBundleName()}`);
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && LOCAL_DOWNLOAD_HOSTS.has(url.hostname))) {
throw new Error(`Refusing unsupported server bundle URL: ${url.toString()}`);
}
return url.toString();
}
log(line) {
this.onLog(String(line));
}
async isInstalled() {
try {
const marker = JSON.parse(
await fs.readFile(path.join(this.getVersionDir(), '.installed.json'), 'utf8'),
);
if (marker.version !== this.version) return false;
await fs.access(this.getServerEntry());
return true;
} catch {
return false;
}
}
/**
* Ensures the server for this version is installed, downloading + extracting
* it if needed. Returns the resolved server entry path.
*/
async ensureInstalled() {
if (await this.isInstalled()) {
this.log(`Local server ${this.version} already installed.`);
return this.getServerEntry();
}
const versionDir = this.getVersionDir();
const tmpDir = path.join(this.installRoot, `.tmp-${this.version}-${process.pid}`);
const archivePath = path.join(tmpDir, this.getBundleName());
await fs.mkdir(tmpDir, { recursive: true });
try {
const url = this.getBundleUrl();
this.log(`Downloading local server bundle…`);
this.log(url);
await this.#download(url, archivePath);
await this.#verifyChecksum(url, archivePath);
this.log('Extracting local server…');
await fs.rm(versionDir, { recursive: true, force: true });
await fs.mkdir(versionDir, { recursive: true });
await this.#validateArchive(archivePath);
await this.#extract(archivePath, versionDir);
const entry = this.getServerEntry();
await fs.access(entry);
await fs.writeFile(
path.join(versionDir, '.installed.json'),
JSON.stringify({ version: this.version, installedAt: new Date().toISOString() }, null, 2),
'utf8',
);
this.log(`Local server ${this.version} installed.`);
return entry;
} catch (error) {
await fs.rm(versionDir, { recursive: true, force: true }).catch(() => {});
throw new Error(`Failed to install local server: ${error.message}`);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}
#download(url, destPath, redirectsLeft = MAX_REDIRECTS) {
return new Promise((resolve, reject) => {
const req = https.get(url, (res) => {
const { statusCode, headers } = res;
if (statusCode >= 300 && statusCode < 400 && headers.location) {
res.resume();
if (redirectsLeft <= 0) {
reject(new Error('Too many redirects'));
return;
}
const next = new URL(headers.location, url).toString();
resolve(this.#download(next, destPath, redirectsLeft - 1));
return;
}
if (statusCode !== 200) {
res.resume();
reject(new Error(`Download failed with HTTP ${statusCode}`));
return;
}
const total = Number(headers['content-length']) || 0;
let received = 0;
let lastPct = -1;
const out = createWriteStream(destPath);
res.on('data', (chunk) => {
received += chunk.length;
if (total) {
const pct = Math.floor((received / total) * 100);
if (pct !== lastPct && pct % 10 === 0) {
lastPct = pct;
this.log(`Downloading… ${pct}%`);
}
}
});
res.pipe(out);
out.on('finish', () => out.close(resolve));
out.on('error', reject);
res.on('error', reject);
});
req.on('error', reject);
});
}
async #verifyChecksum(url, archivePath) {
let expected;
try {
expected = (await this.#fetchText(`${url}.sha256`)).trim().split(/\s+/)[0];
} catch (error) {
throw new Error(`Could not verify server bundle checksum: ${error.message}`);
}
const actual = await this.#sha256(archivePath);
if (expected.toLowerCase() !== actual.toLowerCase()) {
throw new Error('Checksum mismatch — refusing to install');
}
this.log('Checksum verified.');
}
#fetchText(url, redirectsLeft = MAX_REDIRECTS) {
return new Promise((resolve, reject) => {
https
.get(url, (res) => {
const { statusCode, headers } = res;
if (statusCode >= 300 && statusCode < 400 && headers.location) {
res.resume();
if (redirectsLeft <= 0) return reject(new Error('Too many redirects'));
return resolve(this.#fetchText(new URL(headers.location, url).toString(), redirectsLeft - 1));
}
if (statusCode !== 200) {
res.resume();
return reject(new Error(`HTTP ${statusCode}`));
}
let body = '';
res.setEncoding('utf8');
res.on('data', (c) => (body += c));
res.on('end', () => resolve(body));
res.on('error', reject);
})
.on('error', reject);
});
}
#sha256(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = createReadStream(filePath);
stream.on('data', (c) => hash.update(c));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
#extract(archivePath, destDir) {
return new Promise((resolve, reject) => {
const child = spawn('tar', ['-xzf', archivePath, '-C', destDir], {
stdio: ['ignore', 'ignore', 'pipe'],
windowsHide: true,
});
let stderr = '';
child.stderr?.on('data', (c) => (stderr += c));
child.once('error', reject);
child.once('exit', (code) => {
if (code === 0) resolve();
else reject(new Error(`tar exited with code ${code}: ${stderr.trim()}`));
});
});
}
#validateArchive(archivePath) {
return new Promise((resolve, reject) => {
const child = spawn('tar', ['-tzf', archivePath], {
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
let stdout = '';
let stderr = '';
child.stdout?.on('data', (c) => { stdout += c; });
child.stderr?.on('data', (c) => { stderr += c; });
child.once('error', reject);
child.once('exit', (code) => {
if (code !== 0) {
reject(new Error(`tar list exited with code ${code}: ${stderr.trim()}`));
return;
}
for (const entry of stdout.split(/\r?\n/).filter(Boolean)) {
const normalized = entry.replace(/\\/g, '/');
if (
path.isAbsolute(normalized)
|| /^[a-zA-Z]:\//.test(normalized)
|| normalized.split('/').includes('..')
) {
reject(new Error(`Refusing unsafe archive entry: ${entry}`));
return;
}
}
resolve();
});
});
}
}

87
electron/tabs.js Normal file
View File

@@ -0,0 +1,87 @@
export class TabsController {
constructor() {
this.activeTabId = 'home';
this.tabs = [
{
id: 'home',
title: 'Launcher',
kind: 'launcher',
closable: false,
},
];
}
getTabIdForTarget(target) {
if (target.kind === 'launcher') return 'home';
if (target.kind === 'remote' && target.id) return `remote:${target.id}`;
return target.kind;
}
upsertTarget(target) {
const tabId = this.getTabIdForTarget(target);
const existingTab = this.tabs.find((tab) => tab.id === tabId);
const nextTab = {
id: tabId,
title: target.kind === 'launcher' ? 'Launcher' : target.name,
kind: target.kind,
target,
closable: tabId !== 'home',
};
if (existingTab) {
Object.assign(existingTab, nextTab);
} else {
this.tabs.push(nextTab);
}
this.activeTabId = tabId;
return nextTab;
}
activate(tabId) {
const tab = this.tabs.find((item) => item.id === tabId);
if (!tab) return null;
this.activeTabId = tab.id;
return tab;
}
remove(tabId) {
const tab = this.tabs.find((item) => item.id === tabId);
if (!tab || !tab.closable) return null;
this.tabs = this.tabs.filter((item) => item.id !== tabId);
if (this.activeTabId === tabId) {
this.activeTabId = 'home';
}
return tab;
}
removeByKind(kind) {
const removed = this.tabs.filter((tab) => tab.kind === kind && tab.closable);
if (!removed.length) return [];
const removedIds = new Set(removed.map((tab) => tab.id));
this.tabs = this.tabs.filter((tab) => !removedIds.has(tab.id));
if (removedIds.has(this.activeTabId)) {
this.activeTabId = 'home';
}
return removed;
}
getActiveTab() {
return this.getTab(this.activeTabId);
}
getTab(tabId) {
return this.tabs.find((item) => item.id === tabId) || null;
}
getSerializableTabs() {
return this.tabs.map((tab) => ({
id: tab.id,
title: tab.title,
kind: tab.kind,
closable: tab.closable,
active: tab.id === this.activeTabId,
}));
}
}

331
electron/viewHost.js Normal file
View File

@@ -0,0 +1,331 @@
import { BrowserView } from 'electron';
const TARGET_LOAD_TIMEOUT_MS = 20000;
function escapeHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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;
}
}

View File

@@ -4,9 +4,17 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<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" />
<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" />
<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 -->
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />

1488
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@cloudcli-ai/cloudcli",
"version": "1.34.0",
"version": "1.35.1",
"productName": "CloudCLI",
"description": "A web-based UI for Claude Code CLI",
"type": "module",
@@ -34,8 +34,12 @@
"client": "vite",
"desktop": "electron electron/main.js",
"desktop:dev": "cross-env ELECTRON_DEV_URL=http://127.0.0.1:5173 electron electron/main.js",
"desktop:pack": "npm run build && electron-builder --dir",
"desktop:dist:mac": "npm run build && electron-builder --mac dmg zip",
"desktop:stage": "node scripts/release/prepare-desktop-app.js",
"desktop:pack": "npm run build && npm run desktop:stage && electron-builder --projectDir .desktop-build/desktop-app --dir",
"desktop:dist:mac": "npm run build && npm run desktop:stage && electron-builder --projectDir .desktop-build/desktop-app --mac dmg",
"desktop:dist:win": "npm run build && npm run desktop:stage && electron-builder --projectDir .desktop-build/desktop-app --win nsis",
"server:bundle": "npm run build && node scripts/release/build-server-bundle.js",
"desktop:icon:mac": "node electron/scripts/generate-macos-icon.js",
"build": "npm run build:client && npm run build:server",
"build:client": "vite build",
"prebuild:server": "node -e \"require('node:fs').rmSync('dist-server', { recursive: true, force: true })\"",
@@ -54,9 +58,10 @@
"build": {
"appId": "ai.cloudcli.desktop",
"productName": "CloudCLI",
"artifactName": "CloudCLI-${version}-${arch}.${ext}",
"asar": false,
"artifactName": "cloudcli-desktop-${version}-${os}-${arch}.${ext}",
"directories": {
"output": "release"
"output": "release/desktop"
},
"extraMetadata": {
"main": "electron/main.js"
@@ -68,7 +73,8 @@
"dist-server/",
"shared/",
"server/",
"package.json"
"package.json",
"!**/node_modules/@anthropic-ai/claude-agent-sdk-{darwin,linux,win32}-*/**"
],
"protocols": [
{
@@ -80,9 +86,10 @@
],
"mac": {
"category": "public.app-category.developer-tools",
"icon": "electron/assets/logo-macos.icns",
"notarize": true,
"target": [
"dmg",
"zip"
"dmg"
],
"extendInfo": {
"CFBundleName": "CloudCLI",
@@ -96,6 +103,16 @@
}
]
}
},
"win": {
"icon": "electron/assets/logo-windows.ico",
"target": [
"nsis"
]
},
"nsis": {
"installerIcon": "electron/assets/logo-windows.ico",
"uninstallerIcon": "electron/assets/logo-windows.ico"
}
},
"keywords": [
@@ -130,7 +147,7 @@
"@codemirror/theme-one-dark": "^6.1.2",
"@iarna/toml": "^2.2.5",
"@octokit/rest": "^22.0.0",
"@openai/codex-sdk": "^0.125.0",
"@openai/codex-sdk": "^0.141.0",
"@replit/codemirror-minimap": "^0.5.2",
"@tailwindcss/typography": "^0.5.16",
"@uiw/react-codemirror": "^4.23.13",
@@ -223,5 +240,9 @@
"lint-staged": {
"src/**/*.{ts,tsx,js,jsx}": "eslint",
"server/**/*.{js,ts}": "eslint"
},
"optionalDependencies": {
"@nut-tree-fork/nut-js": "^4.2.6",
"screenshot-desktop": "^1.15.4"
}
}

View File

@@ -0,0 +1,176 @@
#!/usr/bin/env node
import crypto from 'node:crypto';
import { createReadStream, readFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, '..', '..');
const packageJson = JSON.parse(
await fs.readFile(path.join(rootDir, 'package.json'), 'utf8'),
);
function getElectronVersion() {
try {
return JSON.parse(
readFileSync(path.join(rootDir, 'node_modules', 'electron', 'package.json'), 'utf8'),
).version;
} catch {
try {
return JSON.parse(
readFileSync(path.join(rootDir, 'package-lock.json'), 'utf8'),
).packages['node_modules/electron'].version;
} catch {
throw new Error('Could not resolve an exact Electron version for server native rebuild.');
}
}
}
function mapArch(arch = process.arch) {
return arch === 'arm64' ? 'arm64' : 'x64';
}
function mapPlatform(platform = process.platform) {
if (platform === 'darwin') return 'mac';
if (platform === 'win32') return 'win';
return 'linux';
}
function run(command, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
stdio: 'inherit',
shell: process.platform === 'win32',
...options,
});
child.once('error', reject);
child.once('exit', (code) => {
if (code === 0) resolve();
else reject(new Error(`${command} ${args.join(' ')} exited with code ${code}`));
});
});
}
async function pathExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async function copyRequired(stageDir, relativePath) {
const from = path.join(rootDir, relativePath);
if (!(await pathExists(from))) {
throw new Error(`Required server bundle input is missing: ${relativePath}`);
}
await fs.cp(from, path.join(stageDir, relativePath), { recursive: true });
}
async function copyIfExists(stageDir, relativePath) {
const from = path.join(rootDir, relativePath);
if (!(await pathExists(from))) return;
await fs.cp(from, path.join(stageDir, relativePath), { recursive: true });
}
async function writeServerPackageJson(stageDir) {
const stagedPackageJson = {
...packageJson,
scripts: {
...(packageJson.scripts || {}),
},
};
// The bundle stage is not a git checkout with dev dependencies, so lifecycle
// scripts such as Husky prepare must not run there. Dependency install scripts
// still run; native modules need them before the Electron ABI rebuild below.
delete stagedPackageJson.scripts.postinstall;
delete stagedPackageJson.scripts.prepare;
delete stagedPackageJson.scripts.prepublishOnly;
await fs.writeFile(
path.join(stageDir, 'package.json'),
`${JSON.stringify(stagedPackageJson, null, 2)}\n`,
'utf8',
);
}
function sha256(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = createReadStream(filePath);
stream.on('data', (chunk) => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
const platform = mapPlatform(process.env.CLOUDCLI_BUNDLE_PLATFORM || process.platform);
const arch = mapArch(process.env.CLOUDCLI_BUNDLE_ARCH || process.arch);
const version = packageJson.version;
const bundleName = `cloudcli-local-server-${version}-${platform}-${arch}.tar.gz`;
const bundleRoot = path.join(rootDir, 'release', 'local-server');
const stageDir = path.join(bundleRoot, `.stage-${version}-${platform}-${arch}`);
const archivePath = path.join(bundleRoot, bundleName);
await fs.rm(stageDir, { recursive: true, force: true });
await fs.mkdir(stageDir, { recursive: true });
await fs.mkdir(bundleRoot, { recursive: true });
await copyRequired(stageDir, 'dist');
await copyRequired(stageDir, 'dist-server');
await copyRequired(stageDir, 'public');
await copyRequired(stageDir, 'shared');
await copyRequired(stageDir, 'package-lock.json');
await copyIfExists(stageDir, 'scripts/fix-node-pty.js');
await writeServerPackageJson(stageDir);
console.log('Installing production server dependencies into bundle stage...');
await run('npm', ['ci', '--omit=dev'], {
cwd: stageDir,
env: {
...process.env,
npm_config_audit: 'false',
npm_config_fund: 'false',
},
});
const electronVersion = getElectronVersion();
const electronRebuild = process.platform === 'win32'
? path.join(rootDir, 'node_modules', '.bin', 'electron-rebuild.cmd')
: path.join(rootDir, 'node_modules', '.bin', 'electron-rebuild');
console.log(`Rebuilding native server dependencies for Electron ${electronVersion} (${arch})...`);
await run(electronRebuild, ['--version', electronVersion, '--module-dir', stageDir, '--arch', arch, '--force'], {
cwd: rootDir,
env: {
...process.env,
npm_config_audit: 'false',
npm_config_fund: 'false',
},
});
if (await pathExists(path.join(stageDir, 'scripts', 'fix-node-pty.js'))) {
await run(process.execPath, ['scripts/fix-node-pty.js'], { cwd: stageDir });
}
await fs.writeFile(
path.join(stageDir, '.installed.json'),
JSON.stringify({ version, platform, arch, builtAt: new Date().toISOString() }, null, 2),
'utf8',
);
await fs.rm(archivePath, { force: true });
const tarArgs = process.platform === 'win32'
? ['-czf', archivePath, '-C', stageDir, '.']
: ['-czf', archivePath, '-C', stageDir, '.'];
await run('tar', tarArgs);
const digest = await sha256(archivePath);
const checksumPath = `${archivePath}.sha256`;
await fs.writeFile(checksumPath, `${digest} ${bundleName}\n`, 'utf8');
await fs.rm(stageDir, { recursive: true, force: true });
const size = (await fs.stat(archivePath)).size / 1024 / 1024;
console.log(`Wrote ${path.relative(rootDir, archivePath)} (${size.toFixed(1)} MB)`);
console.log(`Wrote ${path.relative(rootDir, checksumPath)}`);

View File

@@ -0,0 +1,152 @@
#!/usr/bin/env node
import { readFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, '..', '..');
const stageDir = path.join(rootDir, '.desktop-build', 'desktop-app');
const packageJson = JSON.parse(
await fs.readFile(path.join(rootDir, 'package.json'), 'utf8'),
);
function getElectronVersion() {
try {
return JSON.parse(
readFileSync(path.join(rootDir, 'node_modules', 'electron', 'package.json'), 'utf8'),
).version;
} catch {
try {
return JSON.parse(
readFileSync(path.join(rootDir, 'package-lock.json'), 'utf8'),
).packages['node_modules/electron'].version;
} catch {
throw new Error('Could not resolve an exact Electron version for desktop packaging.');
}
}
}
async function pathExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async function copyRequired(relativePath) {
const from = path.join(rootDir, relativePath);
const to = path.join(stageDir, relativePath);
if (!(await pathExists(from))) {
throw new Error(`Required desktop build input is missing: ${relativePath}`);
}
await fs.cp(from, to, { recursive: true });
}
async function copyIfExists(relativePath) {
const from = path.join(rootDir, relativePath);
if (!(await pathExists(from))) return false;
await fs.cp(from, path.join(stageDir, relativePath), { recursive: true });
return true;
}
async function copyNodeModule(packageName) {
const parts = packageName.split('/');
const source = path.join(rootDir, 'node_modules', ...parts);
if (!(await pathExists(source))) return false;
const target = path.join(stageDir, 'node_modules', ...parts);
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.cp(source, target, { recursive: true });
return true;
}
function buildDesktopPackageJson(copiedOptionalDependencies) {
return {
name: `${packageJson.name}-desktop`,
version: packageJson.version,
productName: packageJson.productName,
description: `${packageJson.productName} desktop shell`,
author: packageJson.author,
license: packageJson.license,
type: 'module',
main: 'electron/main.js',
dependencies: {
ws: packageJson.dependencies.ws,
},
optionalDependencies: copiedOptionalDependencies,
build: {
appId: packageJson.build.appId,
productName: packageJson.build.productName,
asar: packageJson.build.asar,
artifactName: packageJson.build.artifactName,
electronVersion: getElectronVersion(),
directories: {
output: '../../release/desktop',
},
extraMetadata: {
main: 'electron/main.js',
},
files: [
'electron/**',
'public/**',
'dist/**',
'dist-server/**',
'node_modules/**',
'package.json',
],
protocols: packageJson.build.protocols,
mac: packageJson.build.mac,
win: packageJson.build.win,
nsis: packageJson.build.nsis,
},
};
}
await fs.rm(stageDir, { recursive: true, force: true });
await fs.mkdir(stageDir, { recursive: true });
await copyRequired('electron');
await copyRequired('dist');
await copyRequired('public');
const copiedRuntimeDependencies = [];
if (await copyNodeModule('ws')) {
copiedRuntimeDependencies.push('ws');
} else {
throw new Error('Required desktop dependency is missing from node_modules: ws');
}
const copiedOptionalDependencies = {};
for (const [name, version] of Object.entries(packageJson.optionalDependencies || {})) {
if (await copyNodeModule(name)) {
copiedOptionalDependencies[name] = version;
}
}
for (const name of [
'@nut-tree-fork/default-clipboard-provider',
'@nut-tree-fork/libnut',
'@nut-tree-fork/provider-interfaces',
'@nut-tree-fork/shared',
'jimp',
'node-abort-controller',
'temp',
]) {
await copyNodeModule(name);
}
await fs.writeFile(
path.join(stageDir, 'package.json'),
`${JSON.stringify(buildDesktopPackageJson(copiedOptionalDependencies), null, 2)}\n`,
'utf8',
);
console.log(`Prepared thin desktop app at ${path.relative(rootDir, stageDir)}`);
console.log(`Runtime dependencies: ${copiedRuntimeDependencies.join(', ')}`);
if (Object.keys(copiedOptionalDependencies).length) {
console.log(`Optional dependencies: ${Object.keys(copiedOptionalDependencies).join(', ')}`);
}

View File

@@ -57,6 +57,7 @@ import commandsRoutes from './routes/commands.js';
import settingsRoutes from './routes/settings.js';
import agentRoutes from './routes/agent.js';
import projectModuleRoutes from './modules/projects/projects.routes.js';
import notificationRoutes from './modules/notifications/notifications.routes.js';
import userRoutes from './routes/user.js';
import geminiRoutes from './routes/gemini.js';
import pluginsRoutes from './routes/plugins.js';
@@ -202,6 +203,8 @@ app.use('/api/commands', authenticateToken, commandsRoutes);
// Settings API Routes (protected)
app.use('/api/settings', authenticateToken, settingsRoutes);
app.use('/api/notifications', authenticateToken, notificationRoutes);
// User API Routes (protected)
app.use('/api/user', authenticateToken, userRoutes);
@@ -1682,6 +1685,40 @@ const SERVER_PORT = process.env.SERVER_PORT || 3001;
const HOST = process.env.HOST || '0.0.0.0';
const DISPLAY_HOST = getConnectableHost(HOST);
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
async function startServer() {
@@ -1708,6 +1745,9 @@ async function startServer() {
server.listen(SERVER_PORT, HOST, async () => {
const appInstallPath = APP_ROOT;
await writeLocalServerMarker().catch((error) => {
console.warn('[WARN] Could not write local server marker:', error.message);
});
console.log('');
console.log(c.dim('═'.repeat(63)));
@@ -1741,6 +1781,11 @@ async function startServer() {
} catch (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.on('SIGTERM', () => void shutdownRuntimeServices());

View File

@@ -22,7 +22,7 @@ try {
}
});
} catch (e) {
console.log('No .env file found or error reading it:', e.message);
console.error('No .env file found or error reading it:', e.message);
}
// Keep the default database in a stable user-level location so rebuilding dist-server

View File

@@ -4,6 +4,7 @@ export { apiKeysDb } from '@/modules/database/repositories/api-keys.js';
export { appConfigDb } from '@/modules/database/repositories/app-config.js';
export { credentialsDb } from '@/modules/database/repositories/credentials.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 { projectsDb } from '@/modules/database/repositories/projects.db.js';
export { pushSubscriptionsDb } from '@/modules/database/repositories/push-subscriptions.js';

View File

@@ -3,6 +3,7 @@ import { Database } from 'better-sqlite3';
import {
APP_CONFIG_TABLE_SCHEMA_SQL,
LAST_SCANNED_AT_SQL,
NOTIFICATION_CHANNEL_ENDPOINTS_TABLE_SCHEMA_SQL,
PROJECTS_TABLE_SCHEMA_SQL,
PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL,
SESSIONS_TABLE_SCHEMA_SQL,
@@ -440,6 +441,9 @@ export const runMigrations = (db: Database) => {
db.exec(VAPID_KEYS_TABLE_SCHEMA_SQL);
db.exec(PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL);
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);
rebuildProjectsTableWithPrimaryKeySchema(db);

View File

@@ -0,0 +1,153 @@
import { getConnection } from '@/modules/database/connection.js';
type NotificationChannelEndpointRow = {
id: number;
user_id: number;
channel: string;
endpoint_id: string;
label: string | null;
metadata_json: string | null;
enabled: number;
last_seen_at: string;
created_at: string;
updated_at: string;
};
type UpsertNotificationChannelEndpointInput = {
userId: number;
channel: string;
endpointId: string;
label?: string | null;
metadata?: Record<string, unknown> | null;
enabled?: boolean;
};
function normalizeRequiredText(value: unknown): string {
if (typeof value !== 'string') return '';
return value.trim();
}
function normalizeNullableText(value: unknown): string | null {
if (typeof value !== 'string') return null;
const normalized = value.trim();
return normalized || null;
}
function serializeMetadata(metadata: Record<string, unknown> | null | undefined): string | null {
if (!metadata || typeof metadata !== 'object') return null;
return JSON.stringify(metadata);
}
function parseMetadata(metadataJson: string | null): Record<string, unknown> {
if (!metadataJson) return {};
try {
const parsed = JSON.parse(metadataJson);
return parsed && typeof parsed === 'object' ? parsed as Record<string, unknown> : {};
} catch {
return {};
}
}
export const notificationChannelEndpointsDb = {
upsertEndpoint(input: UpsertNotificationChannelEndpointInput): NotificationChannelEndpointRow {
const channel = normalizeRequiredText(input.channel);
const endpointId = normalizeRequiredText(input.endpointId);
if (!channel) throw new Error('channel is required');
if (!endpointId) throw new Error('endpointId is required');
const enabled = input.enabled === false ? 0 : 1;
const db = getConnection();
db.prepare(
`INSERT INTO notification_channel_endpoints (
user_id,
channel,
endpoint_id,
label,
metadata_json,
enabled,
last_seen_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT(user_id, channel, endpoint_id) DO UPDATE SET
label = excluded.label,
metadata_json = excluded.metadata_json,
enabled = excluded.enabled,
last_seen_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP`
).run(
input.userId,
channel,
endpointId,
normalizeNullableText(input.label),
serializeMetadata(input.metadata),
enabled
);
return notificationChannelEndpointsDb.getEndpoint(input.userId, channel, endpointId)!;
},
getEndpoint(userId: number, channel: string, endpointId: string): NotificationChannelEndpointRow | null {
const db = getConnection();
const row = db.prepare(
`SELECT id, user_id, channel, endpoint_id, label, metadata_json, enabled, last_seen_at, created_at, updated_at
FROM notification_channel_endpoints
WHERE user_id = ? AND channel = ? AND endpoint_id = ?`
).get(
userId,
normalizeRequiredText(channel),
normalizeRequiredText(endpointId)
) as NotificationChannelEndpointRow | undefined;
return row || null;
},
getEndpoints(userId: number, channel: string): NotificationChannelEndpointRow[] {
const db = getConnection();
return db.prepare(
`SELECT id, user_id, channel, endpoint_id, label, metadata_json, enabled, last_seen_at, created_at, updated_at
FROM notification_channel_endpoints
WHERE user_id = ? AND channel = ?
ORDER BY last_seen_at DESC`
).all(userId, normalizeRequiredText(channel)) as NotificationChannelEndpointRow[];
},
getEnabledEndpoints(userId: number, channel: string): NotificationChannelEndpointRow[] {
const db = getConnection();
return db.prepare(
`SELECT id, user_id, channel, endpoint_id, label, metadata_json, enabled, last_seen_at, created_at, updated_at
FROM notification_channel_endpoints
WHERE user_id = ? AND channel = ? AND enabled = 1
ORDER BY last_seen_at DESC`
).all(userId, normalizeRequiredText(channel)) as NotificationChannelEndpointRow[];
},
setEndpointEnabled(userId: number, channel: string, endpointId: string, enabled: boolean): boolean {
const db = getConnection();
const result = db.prepare(
`UPDATE notification_channel_endpoints
SET enabled = ?, updated_at = CURRENT_TIMESTAMP
WHERE user_id = ? AND channel = ? AND endpoint_id = ?`
).run(enabled ? 1 : 0, userId, normalizeRequiredText(channel), normalizeRequiredText(endpointId));
return result.changes > 0;
},
touchEndpoint(userId: number, channel: string, endpointId: string): boolean {
const db = getConnection();
const result = db.prepare(
`UPDATE notification_channel_endpoints
SET last_seen_at = CURRENT_TIMESTAMP
WHERE user_id = ? AND channel = ? AND endpoint_id = ?`
).run(userId, normalizeRequiredText(channel), normalizeRequiredText(endpointId));
return result.changes > 0;
},
removeEndpoint(userId: number, channel: string, endpointId: string): boolean {
const db = getConnection();
const result = db.prepare(
'DELETE FROM notification_channel_endpoints WHERE user_id = ? AND channel = ? AND endpoint_id = ?'
).run(userId, normalizeRequiredText(channel), normalizeRequiredText(endpointId));
return result.changes > 0;
},
parseMetadata,
};

View File

@@ -10,7 +10,9 @@ type NotificationPreferences = {
channels: {
inApp: boolean;
webPush: boolean;
desktop: boolean;
sound: boolean;
[key: string]: boolean;
};
events: {
actionRequired: boolean;
@@ -23,6 +25,7 @@ const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
channels: {
inApp: false,
webPush: false,
desktop: false,
sound: true,
},
events: {
@@ -34,11 +37,20 @@ const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
function normalizeNotificationPreferences(value: unknown): NotificationPreferences {
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 {
channels: {
...extraChannels,
inApp: source.channels?.inApp === true,
webPush: source.channels?.webPush === true,
desktop: source.channels?.desktop === true,
sound: source.channels?.sound !== false,
},
events: {
@@ -103,4 +115,3 @@ export const notificationPreferencesDb = {
return notificationPreferencesDb.updateNotificationPreferences(userId, preferences);
},
};

View File

@@ -69,6 +69,23 @@ CREATE TABLE IF NOT EXISTS push_subscriptions (
);
`;
export const NOTIFICATION_CHANNEL_ENDPOINTS_TABLE_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS notification_channel_endpoints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
channel TEXT NOT NULL,
endpoint_id TEXT NOT NULL,
label TEXT,
metadata_json TEXT,
enabled BOOLEAN DEFAULT 1,
last_seen_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, channel, endpoint_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`;
export const PROJECTS_TABLE_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS projects (
project_id TEXT PRIMARY KEY NOT NULL,
@@ -144,6 +161,10 @@ ${VAPID_KEYS_TABLE_SCHEMA_SQL}
${PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL}
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}
-- 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.

View File

@@ -0,0 +1,13 @@
export {
buildNotificationPayload,
createNotificationEvent,
notifyUserIfEnabled,
notifyRunFailed,
notifyRunStopped,
} from '@/modules/notifications/services/notification-orchestrator.service.js';
export {
registerDesktopNotificationClient,
sendDesktopNotification,
unregisterDesktopNotificationClient,
} from '@/modules/notifications/services/desktop-notification-clients.service.js';
export { handleDesktopNotificationsConnection } from '@/modules/notifications/websocket/desktop-notifications-websocket.service.js';

View File

@@ -0,0 +1,127 @@
import express from 'express';
import { notificationChannelEndpointsDb, notificationPreferencesDb } from '@/modules/database/index.js';
const router = express.Router();
function readText(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function sanitizeEndpoint(endpoint: any) {
return {
id: endpoint.id,
channel: endpoint.channel,
endpointId: endpoint.endpoint_id,
label: endpoint.label,
metadata: notificationChannelEndpointsDb.parseMetadata(endpoint.metadata_json),
enabled: Boolean(endpoint.enabled),
lastSeenAt: endpoint.last_seen_at,
createdAt: endpoint.created_at,
updatedAt: endpoint.updated_at,
};
}
function readUserId(req: express.Request): number {
const userId = Number((req as any).user?.id);
if (!Number.isInteger(userId) || userId <= 0) {
throw new Error('Authenticated user is missing');
}
return userId;
}
function updateChannelPreference(userId: number, channel: string): unknown {
const currentPrefs = notificationPreferencesDb.getPreferences(userId);
const hasEnabledEndpoint = notificationChannelEndpointsDb.getEnabledEndpoints(userId, channel).length > 0;
return notificationPreferencesDb.updatePreferences(userId, {
...currentPrefs,
channels: { ...currentPrefs.channels, [channel]: hasEnabledEndpoint },
});
}
router.get('/endpoints', (req, res) => {
try {
const channel = readText(req.query.channel);
if (!channel) {
return res.status(400).json({ error: 'channel is required' });
}
const userId = readUserId(req);
const endpoints = notificationChannelEndpointsDb
.getEndpoints(userId, channel)
.map(sanitizeEndpoint);
return res.json({ success: true, endpoints });
} catch (error) {
console.error('Error fetching notification endpoints:', error);
return res.status(500).json({ error: 'Failed to fetch notification endpoints' });
}
});
router.post('/endpoints/current', (req, res) => {
try {
const { channel, endpointId, label, metadata = {}, enabled = true } = req.body || {};
const normalizedChannel = readText(channel);
const normalizedEndpointId = readText(endpointId);
if (!normalizedChannel || !normalizedEndpointId) {
return res.status(400).json({ error: 'channel and endpointId are required' });
}
const userId = readUserId(req);
const endpoint = notificationChannelEndpointsDb.upsertEndpoint({
userId,
channel: normalizedChannel,
endpointId: normalizedEndpointId,
label,
metadata: metadata && typeof metadata === 'object' ? metadata : {},
enabled: enabled !== false,
});
const preferences = updateChannelPreference(userId, normalizedChannel);
return res.json({ success: true, endpoint: sanitizeEndpoint(endpoint), preferences });
} catch (error) {
console.error('Error registering notification endpoint:', error);
return res.status(500).json({ error: 'Failed to register notification endpoint' });
}
});
router.patch('/endpoints/:channel/:endpointId', (req, res) => {
try {
const { channel, endpointId } = req.params;
const { enabled } = req.body || {};
if (typeof enabled !== 'boolean') {
return res.status(400).json({ error: 'enabled must be a boolean' });
}
const userId = readUserId(req);
const updated = notificationChannelEndpointsDb.setEndpointEnabled(userId, channel, endpointId, enabled);
if (!updated) {
return res.status(404).json({ error: 'Notification endpoint not found' });
}
const endpoint = notificationChannelEndpointsDb.getEndpoint(userId, channel, endpointId);
const preferences = updateChannelPreference(userId, channel);
return res.json({ success: true, endpoint: endpoint ? sanitizeEndpoint(endpoint) : null, preferences });
} catch (error) {
console.error('Error updating notification endpoint:', error);
return res.status(500).json({ error: 'Failed to update notification endpoint' });
}
});
router.delete('/endpoints/:channel/:endpointId', (req, res) => {
try {
const { channel, endpointId } = req.params;
const userId = readUserId(req);
const removed = notificationChannelEndpointsDb.removeEndpoint(userId, channel, endpointId);
if (!removed) {
return res.status(404).json({ error: 'Notification endpoint not found' });
}
const preferences = updateChannelPreference(userId, channel);
return res.json({ success: true, preferences });
} catch (error) {
console.error('Error removing notification endpoint:', error);
return res.status(500).json({ error: 'Failed to remove notification endpoint' });
}
});
export default router;

View File

@@ -0,0 +1,124 @@
import type { WebSocket } from 'ws';
import { notificationChannelEndpointsDb } from '@/modules/database/index.js';
const DESKTOP_CHANNEL = 'desktop';
const clientsByUserId = new Map<number, Map<string, WebSocket>>();
const clientBySocket = new WeakMap<WebSocket, { userId: number; endpointId: string }>();
function normalizeUserId(userId: unknown): number | null {
const numeric = Number(userId);
return Number.isInteger(numeric) && numeric > 0 ? numeric : null;
}
function normalizeEndpointId(endpointId: unknown): string {
if (typeof endpointId !== 'string') return '';
return endpointId.trim();
}
function getUserClients(userId: unknown, create = false): Map<string, WebSocket> | null {
const normalizedUserId = normalizeUserId(userId);
if (!normalizedUserId) return null;
let clients = clientsByUserId.get(normalizedUserId);
if (!clients && create) {
clients = new Map();
clientsByUserId.set(normalizedUserId, clients);
}
return clients || null;
}
export function registerDesktopNotificationClient({
userId,
deviceId,
label = null,
platform = null,
appVersion = null,
ws,
}: {
userId: number;
deviceId: string;
label?: string | null;
platform?: string | null;
appVersion?: string | null;
ws: WebSocket;
}) {
const normalizedUserId = normalizeUserId(userId);
const endpointId = normalizeEndpointId(deviceId);
if (!normalizedUserId || !endpointId) {
return false;
}
const endpoint = notificationChannelEndpointsDb.upsertEndpoint({
userId: normalizedUserId,
channel: DESKTOP_CHANNEL,
endpointId,
label,
metadata: { platform, appVersion },
enabled: true,
});
const clients = getUserClients(normalizedUserId, true)!;
const previous = clients.get(endpointId);
if (previous && previous !== ws && previous.readyState === previous.OPEN) {
previous.close(4000, 'Device reconnected');
}
clients.set(endpointId, ws);
clientBySocket.set(ws, { userId: normalizedUserId, endpointId });
return endpoint;
}
export function unregisterDesktopNotificationClient(ws: WebSocket): void {
const registration = clientBySocket.get(ws);
if (!registration) return;
const clients = getUserClients(registration.userId);
if (clients?.get(registration.endpointId) === ws) {
clients.delete(registration.endpointId);
if (clients.size === 0) {
clientsByUserId.delete(registration.userId);
}
}
clientBySocket.delete(ws);
}
export function sendDesktopNotification(userId: unknown, payload: unknown): { attempted: number; sent: number } {
const normalizedUserId = normalizeUserId(userId);
if (!normalizedUserId) return { attempted: 0, sent: 0 };
const clients = getUserClients(normalizedUserId);
if (!clients?.size) return { attempted: 0, sent: 0 };
const enabledEndpointIds = new Set(
notificationChannelEndpointsDb
.getEnabledEndpoints(normalizedUserId, DESKTOP_CHANNEL)
.map((endpoint) => endpoint.endpoint_id)
);
const message = JSON.stringify({
type: 'notification',
id: typeof (payload as any)?.data?.tag === 'string' ? (payload as any).data.tag : `${Date.now()}`,
payload,
});
let attempted = 0;
let sent = 0;
for (const [endpointId, ws] of clients.entries()) {
if (!enabledEndpointIds.has(endpointId)) continue;
attempted += 1;
if (ws.readyState !== ws.OPEN) {
unregisterDesktopNotificationClient(ws);
continue;
}
try {
ws.send(message);
notificationChannelEndpointsDb.touchEndpoint(normalizedUserId, DESKTOP_CHANNEL, endpointId);
sent += 1;
} catch {
unregisterDesktopNotificationClient(ws);
}
}
return { attempted, sent };
}

View File

@@ -0,0 +1,288 @@
import webPush from 'web-push';
import { notificationPreferencesDb, pushSubscriptionsDb, sessionsDb } from '@/modules/database/index.js';
import { sendDesktopNotification as sendDesktopNotificationToClients } from '@/modules/notifications/services/desktop-notification-clients.service.js';
const KIND_TO_PREF_KEY = {
action_required: 'actionRequired',
stop: 'stop',
error: 'error'
};
const PROVIDER_LABELS = {
claude: 'Claude',
cursor: 'Cursor',
codex: 'Codex',
gemini: 'Gemini',
system: 'System'
};
const recentEventKeys = new Map();
const DEDUPE_WINDOW_MS = 20000;
const cleanupOldEventKeys = () => {
const now = Date.now();
for (const [key, timestamp] of recentEventKeys.entries()) {
if (now - timestamp > DEDUPE_WINDOW_MS) {
recentEventKeys.delete(key);
}
}
};
function isNotificationEventEnabled(preferences, event) {
const prefEventKey = KIND_TO_PREF_KEY[event.kind];
const eventEnabled = prefEventKey ? Boolean(preferences?.events?.[prefEventKey]) : true;
return eventEnabled;
}
function isDuplicate(event) {
cleanupOldEventKeys();
const key = event.dedupeKey || `${event.provider}:${event.kind || 'info'}:${event.code || 'generic'}:${event.sessionId || 'none'}`;
if (recentEventKeys.has(key)) {
return true;
}
recentEventKeys.set(key, Date.now());
return false;
}
function createNotificationEvent({
provider,
sessionId = null,
kind = 'info',
code = 'generic.info',
meta = {},
severity = 'info',
dedupeKey = null,
requiresUserAction = false
}) {
return {
provider,
sessionId,
kind,
code,
meta,
severity,
requiresUserAction,
dedupeKey,
createdAt: new Date().toISOString()
};
}
function normalizeErrorMessage(error) {
if (typeof error === 'string') {
return error;
}
if (error && typeof error.message === 'string') {
return error.message;
}
if (error == null) {
return 'Unknown error';
}
return String(error);
}
function normalizeSessionName(sessionName) {
if (typeof sessionName !== 'string') {
return null;
}
const normalized = sessionName.replace(/\s+/g, ' ').trim();
if (!normalized) {
return null;
}
return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized;
}
function rowMatchesProvider(row, provider) {
return row && (!provider || row.provider === provider);
}
function resolveSessionRow(sessionId, provider) {
if (!sessionId) {
return null;
}
const appSessionRow = sessionsDb.getSessionById(sessionId);
if (rowMatchesProvider(appSessionRow, provider)) {
return appSessionRow;
}
const providerSessionRow = sessionsDb.getSessionByProviderSessionId(sessionId);
if (rowMatchesProvider(providerSessionRow, provider)) {
return providerSessionRow;
}
return null;
}
function normalizeNotificationSession(event) {
if (!event?.sessionId || !event.provider || event.provider === 'system') {
return event;
}
const row = resolveSessionRow(event.sessionId, event.provider);
if (!row || row.session_id === event.sessionId) {
return event;
}
return {
...event,
sessionId: row.session_id
};
}
function resolveSessionName(event) {
const explicitSessionName = normalizeSessionName(event.meta?.sessionName);
if (explicitSessionName) {
return explicitSessionName;
}
if (!event.sessionId || !event.provider) {
return null;
}
return normalizeSessionName(sessionsDb.getSessionName(event.sessionId, event.provider));
}
function buildNotificationPayload(event) {
const normalizedEvent = normalizeNotificationSession(event);
const CODE_MAP = {
'permission.required': normalizedEvent.meta?.toolName
? `Action Required: Tool "${normalizedEvent.meta.toolName}" needs approval`
: 'Action Required: A tool needs your approval',
'run.stopped': normalizedEvent.meta?.stopReason || 'Run Stopped: The run has stopped',
'run.failed': normalizedEvent.meta?.error ? `Run Failed: ${normalizedEvent.meta.error}` : 'Run Failed: The run encountered an error',
'agent.notification': normalizedEvent.meta?.message ? String(normalizedEvent.meta.message) : 'You have a new notification',
'push.enabled': 'Push notifications are now enabled!'
};
const providerLabel = PROVIDER_LABELS[normalizedEvent.provider] || 'Assistant';
const sessionName = resolveSessionName(normalizedEvent);
const message = CODE_MAP[normalizedEvent.code] || 'You have a new notification';
return {
title: sessionName || 'CloudCLI',
body: `${providerLabel}: ${message}`,
data: {
sessionId: normalizedEvent.sessionId || null,
code: normalizedEvent.code,
provider: normalizedEvent.provider || null,
sessionName,
tag: `${normalizedEvent.provider || 'assistant'}:${normalizedEvent.sessionId || 'none'}:${normalizedEvent.code}`
}
};
}
function sendWebPushPayload(userId, payload) {
const subscriptions = pushSubscriptionsDb.getSubscriptions(userId);
if (!subscriptions.length) return Promise.resolve();
const serializedPayload = JSON.stringify(payload);
return Promise.allSettled(
subscriptions.map((sub) =>
webPush.sendNotification(
{
endpoint: sub.endpoint,
keys: {
p256dh: sub.keys_p256dh,
auth: sub.keys_auth
}
},
serializedPayload
)
)
).then((results) => {
results.forEach((result, index) => {
if (result.status === 'rejected') {
const statusCode = result.reason?.statusCode;
if (statusCode === 410 || statusCode === 404) {
pushSubscriptionsDb.removeSubscription(subscriptions[index].endpoint);
}
}
});
});
}
const notificationChannels = [
{
id: 'webPush',
// TODO: Web push still uses push_subscriptions. Do not remove that table until
// browser push subscriptions are migrated into notification_channel_endpoints.
isEnabled: (preferences) => Boolean(preferences?.channels?.webPush),
send: ({ userId, payload }) => sendWebPushPayload(userId, payload)
},
{
id: 'desktop',
isEnabled: (preferences) => Boolean(preferences?.channels?.desktop),
send: ({ userId, payload }) => sendDesktopNotificationToClients(userId, payload)
}
];
function notifyUserIfEnabled({ userId, event }) {
if (!userId || !event) {
return;
}
const normalizedEvent = normalizeNotificationSession(event);
const preferences = notificationPreferencesDb.getPreferences(userId);
if (!isNotificationEventEnabled(preferences, normalizedEvent)) {
return;
}
if (isDuplicate(normalizedEvent)) {
return;
}
const payload = buildNotificationPayload(normalizedEvent);
for (const channel of notificationChannels) {
if (!channel.isEnabled(preferences)) {
continue;
}
Promise.resolve(channel.send({ userId, event: normalizedEvent, payload })).catch((err) => {
console.error(`Notification channel "${channel.id}" send error:`, err);
});
}
}
function notifyRunStopped({ userId, provider, sessionId = null, stopReason = 'completed', sessionName = null }) {
notifyUserIfEnabled({
userId,
event: createNotificationEvent({
provider,
sessionId,
kind: 'stop',
code: 'run.stopped',
meta: { stopReason, sessionName },
severity: 'info',
dedupeKey: `${provider}:run:stop:${sessionId || 'none'}:${stopReason}`
})
});
}
function notifyRunFailed({ userId, provider, sessionId = null, error, sessionName = null }) {
const errorMessage = normalizeErrorMessage(error);
notifyUserIfEnabled({
userId,
event: createNotificationEvent({
provider,
sessionId,
kind: 'error',
code: 'run.failed',
meta: { error: errorMessage, sessionName },
severity: 'error',
dedupeKey: `${provider}:run:error:${sessionId || 'none'}:${errorMessage}`
})
});
}
export {
buildNotificationPayload,
createNotificationEvent,
notifyUserIfEnabled,
notifyRunStopped,
notifyRunFailed
};

View File

@@ -0,0 +1,109 @@
import type { WebSocket } from 'ws';
import {
registerDesktopNotificationClient,
unregisterDesktopNotificationClient,
} from '@/modules/notifications/services/desktop-notification-clients.service.js';
import type { AuthenticatedWebSocketRequest } from '@/shared/types.js';
import { parseIncomingJsonObject } from '@/shared/utils.js';
type DesktopNotificationRegisterMessage = {
type?: unknown;
kind?: unknown;
deviceId?: unknown;
label?: unknown;
platform?: unknown;
appVersion?: unknown;
};
function readRequestUserId(request: AuthenticatedWebSocketRequest): number | null {
const user = request.user;
const rawUserId = typeof user?.id === 'number' || typeof user?.id === 'string'
? user.id
: typeof user?.userId === 'number' || typeof user?.userId === 'string'
? user.userId
: null;
const numericUserId = Number(rawUserId);
return Number.isInteger(numericUserId) && numericUserId > 0 ? numericUserId : null;
}
function readOptionalString(value: unknown): string | null {
if (typeof value !== 'string') return null;
const normalized = value.trim();
return normalized || null;
}
function sendJson(ws: WebSocket, payload: unknown): void {
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify(payload));
}
}
export function handleDesktopNotificationsConnection(
ws: WebSocket,
request: AuthenticatedWebSocketRequest
): void {
const userId = readRequestUserId(request);
if (!userId) {
ws.close(1008, 'Missing authenticated user');
return;
}
let registered = false;
ws.on('message', (rawMessage) => {
const data = parseIncomingJsonObject(rawMessage) as DesktopNotificationRegisterMessage | null;
if (!data) {
return;
}
const type = typeof data.type === 'string' ? data.type : typeof data.kind === 'string' ? data.kind : '';
if (type === 'notification_ack') {
return;
}
if (type !== 'register' || registered) {
return;
}
const deviceId = readOptionalString(data.deviceId);
if (!deviceId) {
sendJson(ws, {
type: 'error',
code: 'DEVICE_ID_REQUIRED',
message: 'Desktop notification registration requires deviceId.',
});
ws.close(1008, 'Missing deviceId');
return;
}
const device = registerDesktopNotificationClient({
userId,
deviceId,
label: readOptionalString(data.label),
platform: readOptionalString(data.platform),
appVersion: readOptionalString(data.appVersion),
ws,
});
if (!device) {
ws.close(1011, 'Registration failed');
return;
}
registered = true;
sendJson(ws, {
type: 'registered',
deviceId: device.endpoint_id,
enabled: Boolean(device.enabled),
});
});
ws.on('close', () => {
unregisterDesktopNotificationClient(ws);
});
ws.on('error', () => {
unregisterDesktopNotificationClient(ws);
});
}

View File

@@ -430,6 +430,17 @@ router.post(
}),
);
router.delete(
'/:provider/skills/:directoryName',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const result = await providerSkillsService.removeProviderSkill(provider, {
directoryName: readPathParam(req.params.directoryName, 'directoryName'),
});
res.json(createApiSuccessResponse(result));
}),
);
// ----------------- MCP routes -----------------
router.get(
'/:provider/mcp/servers',

View File

@@ -3,6 +3,7 @@ import type {
ProviderSkill,
ProviderSkillCreateInput,
ProviderSkillListOptions,
ProviderSkillRemoveInput,
} from '@/shared/types.js';
export const providerSkillsService = {
@@ -27,4 +28,12 @@ export const providerSkillsService = {
const provider = providerRegistry.resolveProvider(providerName);
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);
},
};

View File

@@ -1,10 +1,11 @@
import path from 'node:path';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { mkdir, rm, stat, writeFile } from 'node:fs/promises';
import type { IProviderSkills } from '@/shared/interfaces.js';
import type {
LLMProvider,
ProviderSkillCreateInput,
ProviderSkillRemoveInput,
ProviderSkill,
ProviderSkillListOptions,
ProviderSkillSource,
@@ -236,6 +237,48 @@ export abstract class SkillsProvider implements IProviderSkills {
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 async getGlobalSkillSource(): Promise<ProviderSkillSource | null> {

View File

@@ -662,6 +662,19 @@ test('providerSkillsService adds global skills for claude, codex, gemini, and cu
const listedCursorSkills = await providerSkillsService.listProviderSkills('cursor');
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(
providerSkillsService.addProviderSkills('codex', {
entries: [
@@ -701,4 +714,11 @@ test('providerSkillsService rejects managed skill creation for opencode', { conc
}),
/does not support managed global skills/i,
);
await assert.rejects(
providerSkillsService.removeProviderSkill('opencode', {
directoryName: 'opencode-global-dir',
}),
/does not support managed global skills/i,
);
});

View File

@@ -6,6 +6,7 @@ import { handleChatConnection } from '@/modules/websocket/services/chat-websocke
import { verifyWebSocketClient } from '@/modules/websocket/services/websocket-auth.service.js';
import { handlePluginWsProxy } from '@/modules/websocket/services/plugin-websocket-proxy.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';
type WebSocketServerDependencies = {
@@ -63,6 +64,11 @@ export function createWebSocketServer(
return;
}
if (pathname === '/desktop-notifications') {
handleDesktopNotificationsConnection(ws, incomingRequest);
return;
}
if (pathname.startsWith('/plugin-ws/')) {
handlePluginWsProxy(ws, pathname, dependencies.getPluginPort);
return;

View File

@@ -1,6 +1,11 @@
import express from 'express';
import { apiKeysDb, credentialsDb, notificationPreferencesDb, pushSubscriptionsDb } from '../modules/database/index.js';
import {
apiKeysDb,
credentialsDb,
notificationPreferencesDb,
pushSubscriptionsDb,
} from '../modules/database/index.js';
import { getPublicKey } from '../services/vapid-keys.js';
import { createNotificationEvent, notifyUserIfEnabled } from '../services/notification-orchestrator.js';

View File

@@ -1,268 +1,7 @@
import webPush from 'web-push';
import { notificationPreferencesDb, pushSubscriptionsDb, sessionsDb } from '../modules/database/index.js';
const KIND_TO_PREF_KEY = {
action_required: 'actionRequired',
stop: 'stop',
error: 'error'
};
const PROVIDER_LABELS = {
claude: 'Claude',
cursor: 'Cursor',
codex: 'Codex',
gemini: 'Gemini',
system: 'System'
};
const recentEventKeys = new Map();
const DEDUPE_WINDOW_MS = 20000;
const cleanupOldEventKeys = () => {
const now = Date.now();
for (const [key, timestamp] of recentEventKeys.entries()) {
if (now - timestamp > DEDUPE_WINDOW_MS) {
recentEventKeys.delete(key);
}
}
};
function shouldSendPush(preferences, event) {
const webPushEnabled = Boolean(preferences?.channels?.webPush);
const prefEventKey = KIND_TO_PREF_KEY[event.kind];
const eventEnabled = prefEventKey ? Boolean(preferences?.events?.[prefEventKey]) : true;
return webPushEnabled && eventEnabled;
}
function isDuplicate(event) {
cleanupOldEventKeys();
const key = event.dedupeKey || `${event.provider}:${event.kind || 'info'}:${event.code || 'generic'}:${event.sessionId || 'none'}`;
if (recentEventKeys.has(key)) {
return true;
}
recentEventKeys.set(key, Date.now());
return false;
}
function createNotificationEvent({
provider,
sessionId = null,
kind = 'info',
code = 'generic.info',
meta = {},
severity = 'info',
dedupeKey = null,
requiresUserAction = false
}) {
return {
provider,
sessionId,
kind,
code,
meta,
severity,
requiresUserAction,
dedupeKey,
createdAt: new Date().toISOString()
};
}
function normalizeErrorMessage(error) {
if (typeof error === 'string') {
return error;
}
if (error && typeof error.message === 'string') {
return error.message;
}
if (error == null) {
return 'Unknown error';
}
return String(error);
}
function normalizeSessionName(sessionName) {
if (typeof sessionName !== 'string') {
return null;
}
const normalized = sessionName.replace(/\s+/g, ' ').trim();
if (!normalized) {
return null;
}
return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized;
}
function rowMatchesProvider(row, provider) {
return row && (!provider || row.provider === provider);
}
function resolveSessionRow(sessionId, provider) {
if (!sessionId) {
return null;
}
const appSessionRow = sessionsDb.getSessionById(sessionId);
if (rowMatchesProvider(appSessionRow, provider)) {
return appSessionRow;
}
const providerSessionRow = sessionsDb.getSessionByProviderSessionId(sessionId);
if (rowMatchesProvider(providerSessionRow, provider)) {
return providerSessionRow;
}
return null;
}
function normalizeNotificationSession(event) {
if (!event?.sessionId || !event.provider || event.provider === 'system') {
return event;
}
const row = resolveSessionRow(event.sessionId, event.provider);
if (!row || row.session_id === event.sessionId) {
return event;
}
return {
...event,
sessionId: row.session_id
};
}
function resolveSessionName(event) {
const explicitSessionName = normalizeSessionName(event.meta?.sessionName);
if (explicitSessionName) {
return explicitSessionName;
}
if (!event.sessionId || !event.provider) {
return null;
}
return normalizeSessionName(sessionsDb.getSessionName(event.sessionId, event.provider));
}
function buildPushBody(event) {
const normalizedEvent = normalizeNotificationSession(event);
const CODE_MAP = {
'permission.required': normalizedEvent.meta?.toolName
? `Action Required: Tool "${normalizedEvent.meta.toolName}" needs approval`
: 'Action Required: A tool needs your approval',
'run.stopped': normalizedEvent.meta?.stopReason || 'Run Stopped: The run has stopped',
'run.failed': normalizedEvent.meta?.error ? `Run Failed: ${normalizedEvent.meta.error}` : 'Run Failed: The run encountered an error',
'agent.notification': normalizedEvent.meta?.message ? String(normalizedEvent.meta.message) : 'You have a new notification',
'push.enabled': 'Push notifications are now enabled!'
};
const providerLabel = PROVIDER_LABELS[normalizedEvent.provider] || 'Assistant';
const sessionName = resolveSessionName(normalizedEvent);
const message = CODE_MAP[normalizedEvent.code] || 'You have a new notification';
return {
title: sessionName || 'CloudCLI',
body: `${providerLabel}: ${message}`,
data: {
sessionId: normalizedEvent.sessionId || null,
code: normalizedEvent.code,
provider: normalizedEvent.provider || null,
sessionName,
tag: `${normalizedEvent.provider || 'assistant'}:${normalizedEvent.sessionId || 'none'}:${normalizedEvent.code}`
}
};
}
async function sendWebPush(userId, event) {
const subscriptions = pushSubscriptionsDb.getSubscriptions(userId);
if (!subscriptions.length) return;
const payload = JSON.stringify(buildPushBody(event));
const results = await Promise.allSettled(
subscriptions.map((sub) =>
webPush.sendNotification(
{
endpoint: sub.endpoint,
keys: {
p256dh: sub.keys_p256dh,
auth: sub.keys_auth
}
},
payload
)
)
);
// Clean up gone subscriptions (410 Gone or 404)
results.forEach((result, index) => {
if (result.status === 'rejected') {
const statusCode = result.reason?.statusCode;
if (statusCode === 410 || statusCode === 404) {
pushSubscriptionsDb.removeSubscription(subscriptions[index].endpoint);
}
}
});
}
function notifyUserIfEnabled({ userId, event }) {
if (!userId || !event) {
return;
}
const normalizedEvent = normalizeNotificationSession(event);
const preferences = notificationPreferencesDb.getPreferences(userId);
if (!shouldSendPush(preferences, normalizedEvent)) {
return;
}
if (isDuplicate(normalizedEvent)) {
return;
}
sendWebPush(userId, normalizedEvent).catch((err) => {
console.error('Web push send error:', err);
});
}
function notifyRunStopped({ userId, provider, sessionId = null, stopReason = 'completed', sessionName = null }) {
notifyUserIfEnabled({
userId,
event: createNotificationEvent({
provider,
sessionId,
kind: 'stop',
code: 'run.stopped',
meta: { stopReason, sessionName },
severity: 'info',
dedupeKey: `${provider}:run:stop:${sessionId || 'none'}:${stopReason}`
})
});
}
function notifyRunFailed({ userId, provider, sessionId = null, error, sessionName = null }) {
const errorMessage = normalizeErrorMessage(error);
notifyUserIfEnabled({
userId,
event: createNotificationEvent({
provider,
sessionId,
kind: 'error',
code: 'run.failed',
meta: { error: errorMessage, sessionName },
severity: 'error',
dedupeKey: `${provider}:run:error:${sessionId || 'none'}:${errorMessage}`
})
});
}
export {
buildNotificationPayload,
createNotificationEvent,
notifyUserIfEnabled,
notifyRunStopped,
notifyRunFailed
};
notifyRunFailed,
} from '../modules/notifications/services/notification-orchestrator.service.js';

View File

@@ -13,9 +13,9 @@ import {
pushSubscriptionsDb,
sessionsDb,
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) {
const previousDatabasePath = process.env.DATABASE_PATH;

View File

@@ -13,6 +13,7 @@ import type {
ProviderMcpServer,
ProviderSessionActiveModelChange,
ProviderSkillCreateInput,
ProviderSkillRemoveInput,
UpsertProviderMcpServerInput,
} from '@/shared/types.js';
@@ -111,6 +112,10 @@ export interface IProviderSkills {
* records that were written.
*/
addSkills(input: ProviderSkillCreateInput): Promise<ProviderSkill[]>;
removeSkill(
input: ProviderSkillRemoveInput,
): Promise<{ removed: boolean; provider: LLMProvider; directoryName: string }>;
}
// ---------------------------

View File

@@ -361,6 +361,10 @@ export type ProviderSkillCreateInput = {
entries: ProviderSkillCreateEntry[];
};
export type ProviderSkillRemoveInput = {
directoryName: string;
};
/**
* Normalized skill record returned by provider skill adapters.
*

View File

@@ -1,3 +1,5 @@
import { AlertCircle } from 'lucide-react';
type AuthErrorAlertProps = {
errorMessage: string;
};
@@ -8,8 +10,12 @@ export default function AuthErrorAlert({ errorMessage }: AuthErrorAlertProps) {
}
return (
<div className="rounded-md border border-red-300 bg-red-100 p-3 dark:border-red-800 dark:bg-red-900/20">
<p className="text-sm text-red-700 dark:text-red-400">{errorMessage}</p>
<div
role="alert"
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>
);
}

View File

@@ -1,3 +1,7 @@
import { useState } from 'react';
import type { ComponentType } from 'react';
import { Eye, EyeOff } from 'lucide-react';
type AuthInputFieldProps = {
id: string;
label: string;
@@ -8,13 +12,14 @@ type AuthInputFieldProps = {
type?: 'text' | 'password' | 'email';
name?: string;
autoComplete?: string;
icon?: ComponentType<{ className?: string }>;
};
/**
* A labelled input field for authentication forms.
* Renders a `<label>` / `<input>` pair and forwards browser autofill hints
* (`name`, `autoComplete`) so that password managers can identify and fill
* the field correctly.
* the field correctly. Password fields gain a show/hide visibility toggle.
*/
export default function AuthInputField({
id,
@@ -26,24 +31,48 @@ export default function AuthInputField({
type = 'text',
name,
autoComplete,
icon: Icon,
}: AuthInputFieldProps) {
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const isPasswordField = type === 'password';
const resolvedType = isPasswordField && isPasswordVisible ? 'text' : type;
return (
<div>
<label htmlFor={id} className="mb-1 block text-sm font-medium text-foreground">
<label htmlFor={id} className="mb-1.5 block text-sm font-medium text-foreground">
{label}
</label>
<input
id={id}
type={type}
name={name ?? id}
autoComplete={autoComplete}
value={value}
onChange={(event) => onChange(event.target.value)}
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"
placeholder={placeholder}
required
disabled={isDisabled}
/>
<div className="group relative">
{Icon && (
<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" />
)}
<input
id={id}
type={resolvedType}
name={name ?? id}
autoComplete={autoComplete}
value={value}
onChange={(event) => onChange(event.target.value)}
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>
);
}

View File

@@ -1,30 +1,37 @@
import { MessageSquare } from 'lucide-react';
import { CLOUDCLI_WORDMARK_FONT_FAMILY } from '../../../constants/branding';
const loadingDotAnimationDelays = ['0s', '0.1s', '0.2s'];
const loadingDotAnimationDelays = ['0s', '0.15s', '0.3s'];
export default function AuthLoadingScreen() {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="text-center">
<div className="mb-4 flex justify-center">
<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 flex min-h-screen items-center justify-center overflow-hidden bg-background p-4">
<div aria-hidden className="pointer-events-none absolute 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>
<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>
<h1 className="mb-2 text-2xl font-bold text-foreground">CloudCLI</h1>
<div className="flex items-center justify-center space-x-2">
<h1
className="mb-4 text-2xl font-bold tracking-tight text-foreground"
style={{ fontFamily: CLOUDCLI_WORDMARK_FONT_FAMILY }}
>
CloudCLI
</h1>
<p className="sr-only">Loading authentication state</p>
<div aria-hidden className="flex items-center justify-center gap-2">
{loadingDotAnimationDelays.map((delay) => (
<div
key={delay}
className="h-2 w-2 animate-bounce rounded-full bg-blue-500"
className="h-2 w-2 animate-bounce rounded-full bg-primary"
style={{ animationDelay: delay }}
/>
))}
</div>
<p className="mt-2 text-muted-foreground">Loading...</p>
</div>
</div>
);

View File

@@ -1,5 +1,4 @@
import type { ReactNode } from 'react';
import { MessageSquare } from 'lucide-react';
import { IS_PLATFORM } from '../../../constants/config';
type AuthScreenLayoutProps = {
@@ -18,29 +17,38 @@ export default function AuthScreenLayout({
logo,
}: AuthScreenLayoutProps) {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-md">
<div className="space-y-6 rounded-lg border border-border bg-card p-8 shadow-lg">
<div className="relative h-screen overflow-y-auto bg-background">
{/* Ambient, on-brand backdrop that gives the screen depth without
competing with the card content. Fixed so it stays put while the
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="mb-4 flex justify-center">
<div className="mb-5 flex justify-center">
{logo ?? (
<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="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>
<h1 className="text-2xl font-bold text-foreground">{title}</h1>
<p className="mt-2 text-muted-foreground">{description}</p>
<h1 className="font-serif text-3xl font-bold tracking-tight text-foreground">{title}</h1>
<p className="mx-auto mt-2 max-w-xs text-sm leading-relaxed text-muted-foreground">{description}</p>
</div>
{children}
<div className="mt-8">{children}</div>
<div className="text-center">
<p className="text-sm text-muted-foreground">{footerText}</p>
<div className="mt-6 border-t border-border/60 pt-5 text-center">
<p className="text-xs leading-relaxed text-muted-foreground">{footerText}</p>
</div>
{!IS_PLATFORM && (
<div className="flex items-center justify-center gap-1.5 pt-2">
<div className="mt-4 flex items-center justify-center gap-1.5">
<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" />
</svg>

View File

@@ -1,6 +1,7 @@
import { useCallback, useState } from 'react';
import type { FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2, Lock, User } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import AuthErrorAlert from './AuthErrorAlert';
import AuthInputField from './AuthInputField';
@@ -69,6 +70,7 @@ export default function LoginForm() {
placeholder={t('login.placeholders.username')}
isDisabled={isSubmitting}
autoComplete="username"
icon={User}
/>
<AuthInputField
@@ -80,6 +82,7 @@ export default function LoginForm() {
isDisabled={isSubmitting}
type="password"
autoComplete="current-password"
icon={Lock}
/>
<AuthErrorAlert errorMessage={errorMessage} />
@@ -87,9 +90,16 @@ export default function LoginForm() {
<button
type="submit"
disabled={isSubmitting}
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"
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"
>
{isSubmitting ? t('login.loading') : t('login.submit')}
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{t('login.loading')}
</>
) : (
t('login.submit')
)}
</button>
</form>
</AuthScreenLayout>

View File

@@ -1,5 +1,6 @@
import { useCallback, useState } from 'react';
import type { FormEvent } from 'react';
import { Loader2, Lock, ShieldCheck, User } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import AuthErrorAlert from './AuthErrorAlert';
import AuthInputField from './AuthInputField';
@@ -85,7 +86,6 @@ export default function SetupForm() {
title="Welcome to CloudCLI"
description="Set up your account to get started"
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">
<AuthInputField
@@ -94,9 +94,10 @@ export default function SetupForm() {
label="Username"
value={formState.username}
onChange={(value) => updateField('username', value)}
placeholder="Enter your username"
placeholder="Choose a username"
isDisabled={isSubmitting}
autoComplete="username"
icon={User}
/>
<AuthInputField
@@ -105,10 +106,11 @@ export default function SetupForm() {
label="Password"
value={formState.password}
onChange={(value) => updateField('password', value)}
placeholder="Enter your password"
placeholder="Create a password"
isDisabled={isSubmitting}
type="password"
autoComplete="new-password"
icon={Lock}
/>
<AuthInputField
@@ -117,20 +119,33 @@ export default function SetupForm() {
label="Confirm Password"
value={formState.confirmPassword}
onChange={(value) => updateField('confirmPassword', value)}
placeholder="Confirm your password"
placeholder="Re-enter your password"
isDisabled={isSubmitting}
type="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} />
<button
type="submit"
disabled={isSubmitting}
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"
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"
>
{isSubmitting ? 'Setting up...' : 'Create Account'}
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Setting up...
</>
) : (
'Create Account'
)}
</button>
</form>
</AuthScreenLayout>

View File

@@ -204,6 +204,8 @@ export function useChatComposerState({
const textareaRef = useRef<HTMLTextAreaElement>(null);
const inputHighlightRef = useRef<HTMLDivElement>(null);
const textareaLineHeightRef = useRef<number | null>(null);
const lastAutosizedInputRef = useRef<string | null>(null);
const handleSubmitRef = useRef<
((event: FormEvent<HTMLFormElement> | MouseEvent | TouchEvent | KeyboardEvent<HTMLTextAreaElement>) => Promise<void>) | null
>(null);
@@ -457,6 +459,22 @@ export function useChatComposerState({
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 validFiles = files.filter((file) => {
try {
@@ -817,13 +835,13 @@ export function useChatComposerState({
if (!textareaRef.current) {
return;
}
// Re-run when input changes so restored drafts get the same autosize behavior as typed text.
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${Math.max(22, textareaRef.current.scrollHeight)}px`;
const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight);
const expanded = textareaRef.current.scrollHeight > lineHeight * 2;
setIsTextareaExpanded(expanded);
}, [input]);
if (lastAutosizedInputRef.current === input) {
return;
}
// Re-run for restored drafts and programmatic input changes. User typing is
// already resized in onInput, so this avoids doing the same forced layout twice.
resizeTextarea(textareaRef.current);
}, [input, resizeTextarea]);
useEffect(() => {
if (!textareaRef.current || input.trim()) {
@@ -905,15 +923,11 @@ export function useChatComposerState({
const handleTextareaInput = useCallback(
(event: FormEvent<HTMLTextAreaElement>) => {
const target = event.currentTarget;
target.style.height = 'auto';
target.style.height = `${Math.max(22, target.scrollHeight)}px`;
resizeTextarea(target);
setCursorPosition(target.selectionStart);
syncInputOverlayScroll(target);
const lineHeight = parseInt(window.getComputedStyle(target).lineHeight);
setIsTextareaExpanded(target.scrollHeight > lineHeight * 2);
},
[setCursorPosition, syncInputOverlayScroll],
[resizeTextarea, setCursorPosition, syncInputOverlayScroll],
);
const handleClearInput = useCallback(() => {

View File

@@ -207,6 +207,15 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
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 || '');
if (!content.trim()) {
break;

View File

@@ -18,7 +18,6 @@ interface UseChatSessionStateArgs {
selectedSession: ProjectSession | null;
ws: WebSocket | null;
sendMessage: (message: unknown) => void;
autoScrollToBottom?: boolean;
externalMessageUpdate?: number;
newSessionTrigger?: number;
processingSessions?: SessionActivityMap;
@@ -96,7 +95,6 @@ export function useChatSessionState({
selectedSession,
ws,
sendMessage,
autoScrollToBottom,
externalMessageUpdate,
newSessionTrigger,
processingSessions,
@@ -121,6 +119,7 @@ export function useChatSessionState({
const [viewHiddenCount, setViewHiddenCount] = useState(0);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const wasNearTopRef = useRef(false);
const [searchTarget, setSearchTarget] = useState<{ timestamp?: string; uuid?: string; snippet?: string } | null>(null);
const searchScrollActiveRef = useRef(false);
const isLoadingSessionRef = useRef(false);
@@ -185,6 +184,7 @@ export function useChatSessionState({
setShowLoadAllOverlay(false);
setViewHiddenCount(0);
setSearchTarget(null);
wasNearTopRef.current = false;
searchScrollActiveRef.current = false;
topLoadLockRef.current = false;
pendingScrollRestoreRef.current = null;
@@ -336,12 +336,34 @@ export function useChatSessionState({
const slot = await sessionStore.fetchMore(selectedSession.id, {
limit: MESSAGES_PER_PAGE,
});
if (!slot || slot.serverMessages.length === 0) return false;
if (!slot) 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 };
setHasMoreMessages(slot.hasMore);
setTotalMessages(slot.total);
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;
} finally {
isLoadingMoreRef.current = false;
@@ -357,8 +379,25 @@ export function useChatSessionState({
const nearBottom = isNearBottom();
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) {
const scrolledNearTop = container.scrollTop < 100;
if (!scrolledNearTop) { topLoadLockRef.current = false; return; }
if (topLoadLockRef.current) {
if (container.scrollTop > 20) topLoadLockRef.current = false;
@@ -367,7 +406,7 @@ export function useChatSessionState({
const didLoad = await loadOlderMessages(container);
if (didLoad) topLoadLockRef.current = true;
}
}, [isNearBottom, loadOlderMessages]);
}, [hasMoreMessages, isNearBottom, loadOlderMessages]);
useLayoutEffect(() => {
if (!pendingScrollRestoreRef.current || !scrollContainerRef.current) return;
@@ -386,6 +425,7 @@ export function useChatSessionState({
}
topLoadLockRef.current = false;
pendingScrollRestoreRef.current = null;
wasNearTopRef.current = false;
setIsUserScrolledUp(false);
}, [selectedProject?.projectId, selectedSession?.id]);
@@ -492,6 +532,7 @@ export function useChatSessionState({
setLoadAllJustFinished(false);
setShowLoadAllOverlay(false);
setViewHiddenCount(0);
wasNearTopRef.current = false;
if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current);
if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current);
@@ -546,7 +587,7 @@ export function useChatSessionState({
if (!isProcessing) {
await sessionStore.refreshFromServer(selectedSession.id);
if (Boolean(autoScrollToBottom) && isNearBottom()) {
if (isNearBottom()) {
setTimeout(() => scrollToBottom(), 200);
}
}
@@ -557,7 +598,6 @@ export function useChatSessionState({
reloadExternalMessages();
}, [
autoScrollToBottom,
externalMessageUpdate,
isNearBottom,
scrollToBottom,
@@ -689,10 +729,9 @@ export function useChatSessionState({
}, [chatMessages, visibleMessageCount]);
useEffect(() => {
if (!autoScrollToBottom && scrollContainerRef.current) {
const container = scrollContainerRef.current;
scrollPositionRef.current = { height: container.scrollHeight, top: container.scrollTop };
}
const container = scrollContainerRef.current;
if (!container) return;
scrollPositionRef.current = { height: container.scrollHeight, top: container.scrollTop };
});
useEffect(() => {
@@ -700,8 +739,8 @@ export function useChatSessionState({
if (isLoadingMoreRef.current || isLoadingMoreMessages || pendingScrollRestoreRef.current) return;
if (searchScrollActiveRef.current) return;
if (autoScrollToBottom) {
if (!isUserScrolledUp) setTimeout(() => scrollToBottom(), 50);
if (!isUserScrolledUp) {
setTimeout(() => scrollToBottom(), 50);
return;
}
@@ -711,7 +750,7 @@ export function useChatSessionState({
const newHeight = container.scrollHeight;
const heightDiff = newHeight - prevHeight;
if (heightDiff > 0 && prevTop > 0) container.scrollTop = prevTop + heightDiff;
}, [autoScrollToBottom, chatMessages.length, isLoadingMoreMessages, isUserScrolledUp, scrollToBottom]);
}, [chatMessages.length, isLoadingMoreMessages, isUserScrolledUp, scrollToBottom]);
useEffect(() => {
const container = scrollContainerRef.current;
@@ -720,23 +759,8 @@ export function useChatSessionState({
return () => container.removeEventListener('scroll', handleScroll);
}, [handleScroll]);
// "Load all" overlay
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]);
// "Load all" overlay visibility is driven by scroll-to-top in handleScroll;
// timers are cleared on session change via the reset effect above.
const loadAllMessages = useCallback(async () => {
if (!selectedSession || !selectedProject) return;
@@ -746,6 +770,10 @@ export function useChatSessionState({
isLoadingMoreRef.current = true;
setIsLoadingAllMessages(true);
setShowLoadAllOverlay(true);
if (loadAllOverlayTimerRef.current) {
clearTimeout(loadAllOverlayTimerRef.current);
loadAllOverlayTimerRef.current = null;
}
const container = scrollContainerRef.current;
const previousScrollHeight = container ? container.scrollHeight : 0;
@@ -772,7 +800,11 @@ export function useChatSessionState({
setLoadAllJustFinished(true);
if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current);
loadAllFinishedTimerRef.current = setTimeout(() => { setLoadAllJustFinished(false); setShowLoadAllOverlay(false); }, 1000);
loadAllFinishedTimerRef.current = setTimeout(() => {
setLoadAllJustFinished(false);
setShowLoadAllOverlay(false);
loadAllFinishedTimerRef.current = null;
}, 2500);
} else {
allMessagesLoadedRef.current = false;
setShowLoadAllOverlay(false);

View File

@@ -4,7 +4,7 @@ import type { Project } from '../../../types/app';
import type { SubagentChildTool } from '../types/types';
import { getToolConfig } from './configs/toolConfigs';
import { OneLineDisplay, CollapsibleDisplay, ToolDiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent, SubagentContainer } from './components';
import { OneLineDisplay, BashCommandDisplay, CollapsibleDisplay, ToolDiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent, SubagentContainer } from './components';
import { PlanDisplay } from './components/PlanDisplay';
import { ToolStatusBadge } from './components/ToolStatusBadge';
import type { ToolStatus } from './components/ToolStatusBadge';
@@ -24,7 +24,6 @@ interface ToolRendererProps {
onFileOpen?: (filePath: string, diffInfo?: any) => void;
createDiff?: (oldStr: string, newStr: string) => DiffLine[];
selectedProject?: Project | null;
autoExpandTools?: boolean;
showRawParameters?: boolean;
rawToolInput?: string;
isSubagentContainer?: boolean;
@@ -80,7 +79,6 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
onFileOpen,
createDiff,
selectedProject,
autoExpandTools = false,
showRawParameters = false,
rawToolInput,
isSubagentContainer,
@@ -125,6 +123,39 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
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') {
const value = displayConfig.getValue?.(parsedData) || '';
const secondary = displayConfig.getSecondary?.(parsedData);
@@ -166,7 +197,7 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
<PlanDisplay
title={title}
content={contentProps.content || ''}
defaultOpen={displayConfig.defaultOpen ?? autoExpandTools}
defaultOpen={displayConfig.defaultOpen ?? false}
isStreaming={isStreaming}
showRawParameters={mode === 'input' && showRawParameters}
rawContent={rawToolInput}
@@ -183,7 +214,7 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
const defaultOpen = displayConfig.defaultOpen !== undefined
? displayConfig.defaultOpen
: autoExpandTools;
: false;
const contentProps = displayConfig.getContentProps?.(parsedData, {
selectedProject,

View File

@@ -0,0 +1,156 @@
import React, { useEffect, useRef, useState } from 'react';
import { ChevronRight, Copy, Check } from 'lucide-react';
import { cn } from '../../../../lib/utils';
import { copyTextToClipboard } from '../../../../utils/clipboard';
import { ToolStatusBadge } from './ToolStatusBadge';
import type { ToolStatus } from './ToolStatusBadge';
interface BashCommandDisplayProps {
command: string;
description?: string;
/** Combined stdout/stderr from the tool result (empty while running). */
output?: string;
isError?: boolean;
status?: ToolStatus;
defaultOpen?: boolean;
}
/**
* Codex-in-VSCode style command row: a compact, single-line command with a
* chevron on the left. When the command produced output, the row becomes a
* dropdown that expands to reveal the output inline. Theme-integrated surfaces
* keep it clean in both light and dark mode; consecutive commands stack tightly
* into a clean list.
*/
export const BashCommandDisplay: React.FC<BashCommandDisplayProps> = ({
command,
description,
output,
isError = false,
status,
defaultOpen = false,
}) => {
const trimmedOutput = (output || '').replace(/\s+$/, '');
const hasOutput = trimmedOutput.length > 0;
const outputLineCount = hasOutput ? trimmedOutput.split('\n').length : 0;
const isRunning = status === 'running';
const [open, setOpen] = useState(false);
const [copied, setCopied] = useState(false);
// Output (and errors) often arrive after this component first mounts, so apply
// the auto-open intent once when there is finally something to show. After that
// the user is in control of the toggle.
const autoAppliedRef = useRef(false);
useEffect(() => {
if (!autoAppliedRef.current && hasOutput && (defaultOpen || isError)) {
autoAppliedRef.current = true;
setOpen(true);
}
}, [hasOutput, defaultOpen, isError]);
const toggle = () => {
if (hasOutput) {
setOpen((prev) => !prev);
}
};
const handleCopy = async (event: React.MouseEvent) => {
event.stopPropagation();
const didCopy = await copyTextToClipboard(command);
if (!didCopy) return;
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div
className={cn(
'group/cmd overflow-hidden rounded-lg border bg-muted/40 backdrop-blur-sm transition-all duration-200',
isError ? 'border-red-500/30' : 'border-border/60',
hasOutput && !open && 'hover:border-border hover:bg-muted/60',
open && 'bg-muted/50 shadow-sm',
)}
>
{/* Command header — clickable when there is output to expand */}
<div
role={hasOutput ? 'button' : undefined}
tabIndex={hasOutput ? 0 : undefined}
aria-expanded={hasOutput ? open : undefined}
onClick={toggle}
onKeyDown={(event) => {
if (hasOutput && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault();
toggle();
}
}}
className={cn(
'flex items-center gap-2 px-2.5 py-1.5 outline-none',
hasOutput && 'cursor-pointer focus-visible:ring-1 focus-visible:ring-ring',
)}
>
<ChevronRight
className={cn(
'h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/70 transition-transform duration-200',
open && 'rotate-90',
!hasOutput && 'opacity-0',
)}
/>
<span className="flex-shrink-0 select-none font-mono text-xs font-semibold text-emerald-500 dark:text-emerald-400">
$
</span>
<code
className={cn(
'min-w-0 flex-1 font-mono text-xs text-foreground',
open ? 'whitespace-pre-wrap break-all' : 'truncate',
)}
>
{command}
</code>
{isRunning && (
<span className="h-2.5 w-2.5 flex-shrink-0 animate-spin rounded-full border-[1.5px] border-muted-foreground/30 border-t-emerald-400" />
)}
{status && status !== 'running' && <ToolStatusBadge status={status} className="flex-shrink-0" />}
{!open && hasOutput && !isRunning && (
<span className="flex-shrink-0 text-[10px] tabular-nums text-muted-foreground/70 transition-opacity group-hover/cmd:opacity-0">
{outputLineCount} {outputLineCount === 1 ? 'line' : 'lines'}
</span>
)}
<button
onClick={handleCopy}
onKeyDown={(event) => event.stopPropagation()}
className="flex-shrink-0 rounded p-0.5 text-muted-foreground/60 opacity-0 transition-all hover:bg-foreground/10 hover:text-foreground focus:opacity-100 group-hover/cmd:opacity-100"
title="Copy command"
aria-label="Copy command"
>
{copied ? <Check className="h-3.5 w-3.5 text-emerald-500" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</div>
{description && !open && (
<div className="truncate px-2.5 pb-1.5 pl-[2.4rem] text-[11px] italic text-muted-foreground/70">
{description}
</div>
)}
{/* Expanded output */}
{open && hasOutput && (
<div className="settings-content-enter border-t border-border/50 bg-background/50">
{description && (
<div className="px-3 pt-2 text-[11px] italic text-muted-foreground/70">{description}</div>
)}
<pre
className={cn(
'max-h-80 overflow-auto whitespace-pre-wrap break-all px-3 py-2 font-mono text-xs leading-relaxed',
isError ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground',
)}
>
{trimmedOutput}
</pre>
</div>
)}
</div>
);
};

View File

@@ -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 ${
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'
: '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'
: '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'
}`}
>
{/* 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 ${
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'
: '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'
: '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'
}`}
>
<kbd className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded font-mono text-[10px] transition-all duration-150 ${

View File

@@ -1,6 +1,7 @@
export { CollapsibleSection } from './CollapsibleSection';
export { ToolDiffViewer } from './ToolDiffViewer';
export { OneLineDisplay } from './OneLineDisplay';
export { BashCommandDisplay } from './BashCommandDisplay';
export { CollapsibleDisplay } from './CollapsibleDisplay';
export { SubagentContainer } from './SubagentContainer';
export * from './ContentRenderers';

View File

@@ -126,10 +126,8 @@ export interface ChatInterfaceProps {
onNavigateToSession?: (targetSessionId: string, options?: SessionNavigationOptions) => void;
onSessionEstablished?: (sessionId: string, context: SessionEstablishedContext) => void;
onShowSettings?: () => void;
autoExpandTools?: boolean;
showRawParameters?: boolean;
showThinking?: boolean;
autoScrollToBottom?: boolean;
sendByCtrlEnter?: boolean;
externalMessageUpdate?: number;
newSessionTrigger?: number;

View File

@@ -0,0 +1,81 @@
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;
}

View File

@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { ArrowDownIcon } from 'lucide-react';
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
import { useWebSocket } from '../../../contexts/WebSocketContext';
@@ -30,10 +31,8 @@ function ChatInterface({
onNavigateToSession,
onSessionEstablished,
onShowSettings,
autoExpandTools,
showRawParameters,
showThinking,
autoScrollToBottom,
sendByCtrlEnter,
externalMessageUpdate,
newSessionTrigger,
@@ -124,7 +123,6 @@ function ChatInterface({
selectedSession,
ws,
sendMessage,
autoScrollToBottom,
externalMessageUpdate,
newSessionTrigger,
processingSessions,
@@ -185,7 +183,7 @@ function ChatInterface({
handlePermissionDecision,
handleGrantToolPermission,
handleInputFocusChange,
isInputFocused: _isInputFocused,
isInputFocused,
commandModalPayload,
closeCommandModal,
showCostModal,
@@ -311,7 +309,7 @@ function ChatInterface({
return (
<PermissionContext.Provider value={permissionContextValue}>
<div className="flex h-full flex-col">
<div className="flex h-full min-h-0 flex-col">
<ChatMessagesPane
scrollContainerRef={scrollContainerRef}
onWheel={handleScroll}
@@ -356,13 +354,27 @@ function ChatInterface({
onFileOpen={onFileOpen}
onShowSettings={onShowSettings}
onGrantToolPermission={handleGrantToolPermission}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
showThinking={showThinking}
selectedProject={selectedProject}
/>
<ChatComposer
<div className="relative flex-shrink-0">
{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}
handlePermissionDecision={handlePermissionDecision}
handleGrantToolPermission={handleGrantToolPermission}
@@ -377,9 +389,6 @@ function ChatInterface({
onToggleCommandMenu={handleToggleCommandMenu}
hasInput={Boolean(input.trim())}
onClearInput={handleClearInput}
isUserScrolledUp={isUserScrolledUp}
hasMessages={chatMessages.length > 0}
onScrollToBottom={scrollToBottomAndReset}
onSubmit={handleSubmit}
isDragActive={isDragActive}
attachedImages={attachedImages}
@@ -414,6 +423,7 @@ function ChatInterface({
onTextareaPaste={handlePaste}
onTextareaScrollSync={syncInputOverlayScroll}
onTextareaInput={handleTextareaInput}
isInputFocused={isInputFocused}
onInputFocusChange={handleInputFocusChange}
placeholder={t('input.placeholder', {
provider:
@@ -430,6 +440,7 @@ function ChatInterface({
isTextareaExpanded={isTextareaExpanded}
sendByCtrlEnter={sendByCtrlEnter}
/>
</div>
</div>
<QuickSettingsPanel />

View File

@@ -7,6 +7,7 @@ import type { SessionActivity } from '../../../../hooks/useSessionProtection';
type ActivityIndicatorProps = {
activity: SessionActivity | null;
onAbort?: () => void;
isInputFocused?: boolean;
};
const ACTION_KEYS = [
@@ -18,6 +19,7 @@ const ACTION_KEYS = [
'claudeStatus.actions.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
@@ -26,11 +28,31 @@ const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working',
* session has an entry in the processing map; it disappears the instant that
* entry is removed.
*/
export default function ActivityIndicator({ activity, onAbort }: ActivityIndicatorProps) {
export default function ActivityIndicator({ activity, onAbort, isInputFocused = false }: ActivityIndicatorProps) {
const { t } = useTranslation('chat');
const startedAt = activity?.startedAt ?? null;
const [renderedActivity, setRenderedActivity] = useState<SessionActivity | null>(activity);
const [isExiting, setIsExiting] = useState(false);
const startedAt = renderedActivity?.startedAt ?? null;
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(() => {
if (startedAt === null) return;
const update = () => setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1000)));
@@ -39,10 +61,10 @@ export default function ActivityIndicator({ activity, onAbort }: ActivityIndicat
return () => clearInterval(timer);
}, [startedAt]);
if (!activity) return null;
if (!renderedActivity) return null;
const actionWords = ACTION_KEYS.map((key, i) => t(key, { defaultValue: DEFAULT_ACTION_WORDS[i] }));
const label = (activity.statusText || actionWords[Math.floor(elapsedSeconds / 4) % actionWords.length])
const label = (renderedActivity.statusText || actionWords[Math.floor(elapsedSeconds / 4) % actionWords.length])
.replace(/\.+$/, '');
const minutes = Math.floor(elapsedSeconds / 60);
@@ -50,19 +72,31 @@ export default function ActivityIndicator({ activity, onAbort }: ActivityIndicat
const elapsedLabel = minutes < 1
? t('claudeStatus.elapsed.seconds', { count: seconds, defaultValue: '{{count}}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 (
<div className="animate-in fade-in mb-2 w-full duration-300">
<div className="mx-auto flex max-w-4xl items-center gap-2 px-1">
<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={`pointer-events-none bg-transparent ${
isExiting ? 'chat-activity-exit' : 'chat-activity-enter'
}`}
>
<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>
{activity.canInterrupt && onAbort && (
{renderedActivity.canInterrupt && onAbort && (
<button
type="button"
onClick={onAbort}
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"
className={`${tabSurfaceClassName} pointer-events-auto gap-1.5 text-muted-foreground hover:bg-card hover:text-destructive`}
aria-label={t('claudeStatus.stop', { defaultValue: 'Stop' })}
>
<svg className="h-2.5 w-2.5 fill-current" viewBox="0 0 24 24" aria-hidden>

View File

@@ -1,4 +1,5 @@
import { useTranslation } from 'react-i18next';
import { useMemo } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type {
ChangeEvent,
@@ -10,7 +11,7 @@ import type {
RefObject,
TouchEvent,
} from 'react';
import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon, Loader2 } from 'lucide-react';
import { ImageIcon, MessageSquareIcon, XIcon, Loader2 } from 'lucide-react';
import { useVoiceInput } from '../../hooks/useVoiceInput';
import { useVoiceAvailable } from '../../hooks/useVoiceAvailable';
@@ -67,9 +68,6 @@ interface ChatComposerProps {
onToggleCommandMenu: () => void;
hasInput: boolean;
onClearInput: () => void;
isUserScrolledUp: boolean;
hasMessages: boolean;
onScrollToBottom: () => void;
onSubmit: (event: FormEvent<HTMLFormElement> | MouseEvent<HTMLButtonElement> | TouchEvent<HTMLButtonElement>) => void;
isDragActive: boolean;
attachedImages: File[];
@@ -100,6 +98,7 @@ interface ChatComposerProps {
onTextareaPaste: (event: ClipboardEvent<HTMLTextAreaElement>) => void;
onTextareaScrollSync: (target: HTMLTextAreaElement) => void;
onTextareaInput: (event: FormEvent<HTMLTextAreaElement>) => void;
isInputFocused?: boolean;
onInputFocusChange?: (focused: boolean) => void;
placeholder: string;
isTextareaExpanded: boolean;
@@ -121,9 +120,6 @@ export default function ChatComposer({
onToggleCommandMenu,
hasInput,
onClearInput,
isUserScrolledUp,
hasMessages,
onScrollToBottom,
onSubmit,
isDragActive,
attachedImages,
@@ -154,12 +150,24 @@ export default function ChatComposer({
onTextareaPaste,
onTextareaScrollSync,
onTextareaInput,
isInputFocused = false,
onInputFocusChange,
placeholder,
isTextareaExpanded,
sendByCtrlEnter,
}: ChatComposerProps) {
const { t } = useTranslation('chat');
const commandMenuPosition = useMemo(() => {
if (!isCommandMenuOpen) {
return { top: 0, left: 16, bottom: 90 };
}
const textareaRect = textareaRef.current?.getBoundingClientRect();
return {
top: textareaRect ? Math.max(16, textareaRect.top - 316) : 0,
left: textareaRect ? textareaRect.left : 16,
bottom: textareaRect ? window.innerHeight - textareaRect.top + 8 : 90,
};
}, [input, isCommandMenuOpen, textareaRef]);
// Voice state is hosted here (not in the mic button) so the main Send button can stop
// recording and send the transcript in one tap, the way the mic button drops it in the box.
@@ -182,13 +190,6 @@ export default function ChatComposer({
const isRecording = voiceState === 'recording';
const isTranscribing = voiceState === 'transcribing';
const textareaRect = textareaRef.current?.getBoundingClientRect();
const commandMenuPosition = {
top: textareaRect ? Math.max(16, textareaRect.top - 316) : 0,
left: textareaRect ? textareaRect.left : 16,
bottom: textareaRect ? window.innerHeight - textareaRect.top + 8 : 90,
};
// Detect if the AskUserQuestion interactive panel is active
const hasQuestionPanel = pendingPermissionRequests.some(
(r) => r.toolName === 'AskUserQuestion'
@@ -196,15 +197,18 @@ export default function ChatComposer({
// Hide the thinking/status bar while any permission request is pending
const hasPendingPermissions = pendingPermissionRequests.length > 0;
const hasActivityIndicator = Boolean(activity && !hasPendingPermissions);
return (
<div className="flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6">
<div className="chat-composer-shell relative flex-shrink-0 px-2 pb-2 pt-0 sm:px-4 sm:pb-4 md:px-4 md:pb-6">
{!hasPendingPermissions && (
<ActivityIndicator activity={activity} onAbort={onAbortSession} />
<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} isInputFocused={isInputFocused} />
</div>
)}
{pendingPermissionRequests.length > 0 && (
<div className="mx-auto mb-3 max-w-4xl">
<div className="mx-auto mb-3 max-w-[54.25rem]">
<PermissionRequestsBanner
pendingPermissionRequests={pendingPermissionRequests}
handlePermissionDecision={handlePermissionDecision}
@@ -213,19 +217,7 @@ export default function ChatComposer({
</div>
)}
{!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>
)}
{!hasQuestionPanel && <div className="relative mx-auto max-w-[54.25rem]">
{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">
{filteredFiles.map((file, index) => (
@@ -266,7 +258,10 @@ export default function ChatComposer({
<PromptInput
onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void}
status={isLoading ? 'streaming' : 'ready'}
className={isTextareaExpanded ? 'chat-input-expanded' : ''}
className={[
isTextareaExpanded ? 'chat-input-expanded' : '',
hasActivityIndicator ? 'rounded-t-none' : '',
].filter(Boolean).join(' ')}
{...getRootProps()}
>
{isDragActive && (
@@ -344,7 +339,7 @@ export default function ChatComposer({
<button
type="button"
onClick={onModeSwitch}
className={`rounded-lg border p-2 text-xs font-medium transition-all duration-200 sm:px-2.5 sm:py-1 ${
className={`inline-flex h-8 items-center rounded-lg border px-2 text-xs font-medium transition-all duration-200 sm:px-2.5 ${
permissionMode === 'default'
? 'border-border/60 bg-muted/50 text-muted-foreground hover:bg-muted'
: permissionMode === 'acceptEdits'

View File

@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next';
import { useCallback, useRef } from 'react';
import { memo, useCallback, useMemo } from 'react';
import type { Dispatch, RefObject, SetStateAction } from 'react';
import type { ChatMessage } from '../../types/types';
@@ -10,9 +10,12 @@ import type {
ProviderModelsDefinition,
} from '../../../../types/app';
import { getIntrinsicMessageKey } from '../../utils/messageKeys';
import { groupConsecutiveTools, isToolGroupItem } from '../../utils/toolGrouping';
import MessageComponent from './MessageComponent';
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
import ToolGroupContainer from './ToolGroupContainer';
import LoadAllMessagesOverlay from './LoadAllMessagesOverlay';
interface ChatMessagesPaneProps {
scrollContainerRef: RefObject<HTMLDivElement>;
@@ -59,13 +62,12 @@ interface ChatMessagesPaneProps {
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
onShowSettings?: () => void;
onGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };
autoExpandTools?: boolean;
showRawParameters?: boolean;
showThinking?: boolean;
selectedProject: Project;
}
export default function ChatMessagesPane({
function ChatMessagesPane({
scrollContainerRef,
onWheel,
onTouchMove,
@@ -109,47 +111,59 @@ export default function ChatMessagesPane({
onFileOpen,
onShowSettings,
onGrantToolPermission,
autoExpandTools,
showRawParameters,
showThinking,
selectedProject,
}: ChatMessagesPaneProps) {
const { t } = useTranslation('chat');
const messageKeyMapRef = useRef<WeakMap<ChatMessage, string>>(new WeakMap());
const allocatedKeysRef = useRef<Set<string>>(new Set());
const generatedMessageKeyCounterRef = useRef(0);
const groupedVisibleMessages = useMemo(
() => groupConsecutiveTools(visibleMessages, Boolean(showThinking)),
[visibleMessages, showThinking],
);
// Keep keys stable across prepends so existing MessageComponent instances retain local state.
const getMessageKey = useCallback((message: ChatMessage) => {
const existingKey = messageKeyMapRef.current.get(message);
if (existingKey) {
return existingKey;
// Stable, deterministic keys for the messages rendered this pass.
//
// `normalizedToChatMessages` rebuilds fresh ChatMessage objects on every store
// update, so caching keys by object identity (or via a cross-render allocation
// Set) minted a brand-new key for the *same* logical message on each prepend —
// 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 intrinsicKey = getIntrinsicMessageKey(message);
let candidateKey = intrinsicKey;
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;
}, []);
const getMessageKey = useCallback(
(message: ChatMessage) =>
messageKeyMap.get(message) ?? getIntrinsicMessageKey(message) ?? 'message-generated',
[messageKeyMap],
);
return (
<div
ref={scrollContainerRef}
onWheel={onWheel}
onTouchMove={onTouchMove}
className="relative flex-1 space-y-3 overflow-y-auto overflow-x-hidden px-0 py-3 sm:space-y-4 sm:p-4"
className="chat-messages-pane relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden py-3 sm:py-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 ? (
<div className="mt-8 text-center text-gray-500 dark:text-gray-400">
<div className="flex items-center justify-center space-x-2">
@@ -205,35 +219,13 @@ export default function ChatMessagesPane({
</div>
)}
{/* Floating "Load all messages" overlay */}
{(showLoadAllOverlay || isLoadingAllMessages || loadAllJustFinished) && (
<div className="pointer-events-none sticky top-2 z-20 flex justify-center">
{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={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>
)}
<LoadAllMessagesOverlay
showLoadAllOverlay={showLoadAllOverlay}
isLoadingAllMessages={isLoadingAllMessages}
loadAllJustFinished={loadAllJustFinished}
totalMessages={totalMessages}
onLoadAllMessages={loadAllMessages}
/>
{/* Legacy message count indicator (for non-paginated view) */}
{!hasMoreMessages && chatMessages.length > visibleMessageCount && (
@@ -252,28 +244,57 @@ export default function ChatMessagesPane({
</div>
)}
{visibleMessages.map((message, index) => {
const prevMessage = index > 0 ? visibleMessages[index - 1] : null;
return (
<MessageComponent
key={getMessageKey(message)}
message={message}
prevMessage={prevMessage}
createDiff={createDiff}
onFileOpen={onFileOpen}
onShowSettings={onShowSettings}
onGrantToolPermission={onGrantToolPermission}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
showThinking={showThinking}
selectedProject={selectedProject}
provider={provider}
/>
);
})}
{(() => {
let prevMessage: ChatMessage | null = null;
return groupedVisibleMessages.map((item) => {
if (isToolGroupItem(item)) {
const groupPrevMessage = prevMessage;
prevMessage = item.messages[item.messages.length - 1] || prevMessage;
return (
<ToolGroupContainer
key={`tool-group-${getMessageKey(item.messages[0])}`}
group={item}
prevMessage={groupPrevMessage}
createDiff={createDiff}
getMessageKey={getMessageKey}
onFileOpen={onFileOpen}
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>
);
}
export default memo(ChatMessagesPane);

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react';
import type { CSSProperties } from 'react';
import { createPortal } from 'react-dom';
import type { CSSProperties, ReactElement } from 'react';
import {
CornerDownLeft,
Folder,
@@ -77,6 +78,7 @@ const namespaceAccentClasses: Record<string, string> = {
const MENU_EDGE_GAP = 16;
const MENU_MAX_HEIGHT = 360;
const MENU_MIN_HEIGHT = 160;
const getCommandKey = (command: CommandMenuCommand) =>
`${command.name}::${command.namespace || command.type || 'other'}::${command.path || ''}`;
@@ -92,8 +94,9 @@ const getMenuPosition = (position: { top: number; left: number; bottom?: number
if (typeof window === 'undefined') {
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) {
const anchorBottom = Math.max(MENU_EDGE_GAP, position.bottom ?? 90);
const anchorBottom = Math.min(Math.max(MENU_EDGE_GAP, position.bottom ?? 90), maxAnchorBottom);
return {
position: 'fixed',
bottom: `${anchorBottom}px`,
@@ -104,7 +107,7 @@ const getMenuPosition = (position: { top: number; left: number; bottom?: number
maxHeight: `min(54vh, calc(100vh - ${anchorBottom}px - ${MENU_EDGE_GAP}px))`,
};
}
const anchorBottom = Math.max(MENU_EDGE_GAP, position.bottom ?? 90);
const anchorBottom = Math.min(Math.max(MENU_EDGE_GAP, position.bottom ?? 90), maxAnchorBottom);
const clampedLeft = Math.max(
MENU_EDGE_GAP,
Math.min(position.left, window.innerWidth - 440 - MENU_EDGE_GAP),
@@ -216,12 +219,14 @@ export default function CommandMenu({
: ['builtin', 'skill', 'project', 'user', 'other'];
const extraNamespaces = Object.keys(groupedCommands).filter((namespace) => !preferredOrder.includes(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) {
return (
return renderInPortal(
<div
ref={menuRef}
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"
className="command-menu command-menu-empty border border-border bg-popover/95 text-sm text-muted-foreground"
style={{
...menuBaseStyle,
...menuPosition,
@@ -237,20 +242,20 @@ export default function CommandMenu({
);
}
return (
return renderInPortal(
<div
ref={menuRef}
role="listbox"
aria-label="Available commands"
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"
className="command-menu border border-border bg-popover/95 text-popover-foreground"
style={{ ...menuBaseStyle, ...menuPosition, opacity: 1, transform: 'translateY(0)' }}
>
{orderedNamespaces.map((namespace) => (
<div key={namespace} className="command-group">
{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-gray-500 dark:text-slate-400">
<div className="flex items-center justify-between px-2 pb-1.5 pt-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
<span>{namespaceLabels[namespace] || namespace}</span>
<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">
<span className="rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
{(groupedCommands[namespace] || []).length}
</span>
</div>
@@ -268,15 +273,15 @@ export default function CommandMenu({
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 ${
isSelected
? 'border-sky-200 bg-sky-50 shadow-sm dark:border-cyan-400/30 dark:bg-cyan-400/10'
: 'border-transparent bg-transparent hover:border-gray-200 hover:bg-gray-50/90 dark:hover:border-slate-700 dark:hover:bg-slate-900/80'
? 'border-primary/30 bg-primary/10 shadow-sm'
: 'border-transparent bg-transparent hover:border-border hover:bg-accent'
}`}
onMouseEnter={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, true)}
onClick={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, false)}
onMouseDown={(event) => event.preventDefault()}
>
{isSelected && (
<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="absolute bottom-1.5 left-1.5 top-1.5 w-0.5 rounded-full bg-primary" />
)}
<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} />
@@ -284,20 +289,20 @@ export default function CommandMenu({
<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'}`}>
<span
className="min-w-0 truncate font-mono text-[13px] font-semibold text-gray-950 dark:text-slate-50"
className="min-w-0 truncate font-mono text-[13px] font-semibold text-foreground"
title={command.name}
>
{command.name}
</span>
{command.metadata?.type && (
<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">
<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">
{command.metadata.type}
</span>
)}
</div>
{command.description && (
<div
className="truncate whitespace-nowrap text-[12px] leading-4 text-gray-500 dark:text-slate-400"
className="truncate whitespace-nowrap text-[12px] leading-4 text-muted-foreground"
title={command.description}
>
{command.description}
@@ -305,7 +310,7 @@ export default function CommandMenu({
)}
</div>
{isSelected && (
<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">
<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">
<CornerDownLeft aria-hidden="true" size={13} strokeWidth={2.2} />
</span>
)}

View File

@@ -2,9 +2,7 @@ import { useMemo, useState } from 'react';
import {
Activity,
BadgeCheck,
Check,
CircleHelp,
Clipboard,
Coins,
Cpu,
Gauge,
@@ -59,19 +57,6 @@ type ModelOption = {
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> = {
claude: 'Claude',
cursor: 'Cursor',
@@ -246,7 +231,6 @@ function HelpContent({ data }: { data: HelpCommandData }) {
function ModelsContent({
data,
providerModelCatalog,
providerModelCacheCatalog,
providerModelsRefreshing,
onHardRefreshProviderModels,
currentSessionId,
@@ -254,14 +238,12 @@ function ModelsContent({
}: {
data: ModelCommandData;
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
providerModelCacheCatalog: Partial<Record<LLMProvider, ProviderModelsCacheInfo>>;
providerModelsRefreshing: boolean;
onHardRefreshProviderModels: () => void;
currentSessionId: string | null;
onSelectProviderModel: CommandResultModalProps['onSelectProviderModel'];
}) {
const [query, setQuery] = useState('');
const [copiedModel, setCopiedModel] = useState<string | null>(null);
const [changingModel, setChangingModel] = useState<string | null>(null);
const [pendingSessionModel, setPendingSessionModel] = useState<string | null>(null);
const [selectionNotice, setSelectionNotice] = useState<string | null>(null);
@@ -269,7 +251,6 @@ function ModelsContent({
const currentModel = data?.current?.model || 'Unknown';
const providerLabel = data?.current?.providerLabel || getProviderLabel(currentProvider);
const liveDefinition = providerModelCatalog[currentProvider];
const currentCache = providerModelCacheCatalog[currentProvider] ?? data?.cache;
const availableOptions = useMemo<ModelOption[]>(() => {
if (liveDefinition?.OPTIONS && liveDefinition.OPTIONS.length > 0) {
return liveDefinition.OPTIONS;
@@ -282,7 +263,6 @@ function ModelsContent({
const availableModels = Array.isArray(data?.availableModels) ? data.availableModels : [];
return availableModels.map((model) => ({ value: model, label: model }));
}, [data, liveDefinition]);
const defaultModel = liveDefinition?.DEFAULT || data?.defaultModel || currentModel;
const filteredOptions = useMemo(() => {
const normalized = query.trim().toLowerCase();
@@ -296,18 +276,8 @@ function ModelsContent({
});
}, [availableOptions, query]);
const activeOption = availableOptions.find((option) => option.value === currentModel);
const hasConcreteSessionId = typeof currentSessionId === 'string' && currentSessionId.trim().length > 0;
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 showSearch = availableOptions.length > 6;
const handleSelectModel = async (model: string) => {
setChangingModel(model);
@@ -330,162 +300,106 @@ function ModelsContent({
};
return (
<div className="flex h-full min-h-0 flex-col gap-2.5">
<div className="rounded-2xl border border-border/70 bg-muted/20 p-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="flex flex-wrap items-center gap-2">
<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">
{providerLabel}
</Badge>
<Badge variant="secondary" className="rounded-lg px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-foreground">
{availableOptions.length} models
</Badge>
</div>
<div className="mt-2 rounded-xl border border-primary/15 bg-primary/[0.06] px-3 py-2">
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-primary">Active Model</p>
<p className="mt-1 break-all font-mono text-[0.98rem] font-semibold leading-5 text-foreground sm:text-[1.05rem]">
{currentModel}
</p>
{activeOption?.label && activeOption.label !== currentModel && (
<p className="mt-1 text-[11px] font-medium text-foreground/85">{activeOption.label}</p>
)}
{activeOption?.description && (
<p className="mt-0.5 line-clamp-1 text-[11px] text-muted-foreground">{activeOption.description}</p>
)}
{pendingSessionModel && pendingSessionModel !== currentModel && (
<p className="mt-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-primary">
Next response: {pendingSessionModel}
</p>
)}
</div>
</div>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-1">
<div className="rounded-xl border border-border/60 bg-background/55 px-2.5 py-1.5">
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-foreground/80">Default</p>
<p className="mt-1 break-all font-mono text-[11px] font-medium text-foreground">{defaultModel}</p>
</div>
<div className="rounded-xl border border-border/60 bg-background/55 px-2.5 py-1.5">
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-foreground/80">Updated</p>
<p className="mt-1 text-[11px] font-medium text-foreground">{formatUpdatedAt(currentCache?.updatedAt)}</p>
</div>
</div>
<div className="rounded-xl border border-border/60 bg-background/55 p-2.5">
<div className="flex flex-wrap items-center gap-1.5">
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-foreground/80">
Catalog Refresh
</p>
<Badge variant="secondary" className="rounded-md px-1.5 py-0 text-[9px] uppercase tracking-[0.14em]">
All providers
</Badge>
</div>
<p className="mt-1.5 text-[11px] leading-4 text-muted-foreground">
Model lists are cached for 3 days. Refresh after CLI, auth, or config changes,
or when a new model is missing.
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={onHardRefreshProviderModels}
disabled={providerModelsRefreshing}
className="mt-2 h-8 w-full rounded-xl px-3"
>
<RefreshCw className={providerModelsRefreshing ? 'animate-spin' : ''} />
{providerModelsRefreshing ? 'Refreshing catalogs...' : 'Refresh from providers'}
</Button>
</div>
</div>
<div className="mt-2 border-t border-border/50 pt-1.5 text-[11px] text-muted-foreground">
{hasConcreteSessionId
? 'Selecting a model stores a session override and applies it on the next response for this session.'
: 'Selecting a model updates the default model used for new turns in this provider.'}
{selectionNotice && <span className="ml-2 text-foreground">{selectionNotice}</span>}
<div className="flex h-full min-h-0 flex-col gap-3">
{/* Compact context bar: active model + refresh, no clutter */}
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border/70 bg-muted/20 px-3.5 py-2.5">
<div className="min-w-0">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Active model · {providerLabel}
</p>
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5">
<span className="break-all font-mono text-sm font-semibold text-foreground">{currentModel}</span>
{pendingSessionModel && pendingSessionModel !== currentModel && (
<span className="text-[11px] font-semibold uppercase tracking-[0.14em] text-emerald-500 dark:text-emerald-400">
{pendingSessionModel} next
</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>
<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>
{showSearch && (
<SearchField value={query} onChange={setQuery} placeholder={`Search ${providerLabel} models...`} />
)}
{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>
{filteredOptions.length > 0 ? (
<div className="scrollbar-thin -mr-1 min-h-0 flex-1 overflow-y-auto pr-1">
<div className="grid gap-2 md:grid-cols-2">
{filteredOptions.map((option, index) => {
const isCurrent = option.value === currentModel;
const isPendingSelection = option.value === pendingSessionModel;
const isChanging = option.value === changingModel;
return (
<button
key={option.value}
type="button"
onClick={() => handleSelectModel(option.value)}
disabled={Boolean(changingModel)}
aria-label={`Select model ${option.value}`}
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 ${
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` }}
>
<span className="flex items-center justify-between 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" />
) : isChanging ? (
<RefreshCw className="h-4 w-4 shrink-0 animate-spin text-primary" />
) : null}
</span>
{option.label && option.label !== option.value && (
<span className="mt-1 text-xs font-medium text-foreground/85">{option.label}</span>
)}
{option.description && (
<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 className="rounded-2xl border border-dashed border-border bg-background/60 px-4 py-10 text-center text-sm text-muted-foreground">
No models match that search.
</div>
)}
{/* Single quiet line of guidance / feedback */}
<p className="shrink-0 text-[11px] leading-4 text-muted-foreground">
{selectionNotice ? (
<span className="text-foreground">{selectionNotice}</span>
) : hasConcreteSessionId ? (
'Your choice applies to this session on the next response.'
) : (
<div className="rounded-2xl border border-dashed border-border bg-background/60 px-4 py-10 text-center text-sm text-muted-foreground">
No models match that search.
</div>
'Your choice becomes the default model for new turns.'
)}
</div>
</p>
</div>
);
}
@@ -606,7 +520,6 @@ export default function CommandResultModal({
payload,
onClose,
providerModelCatalog,
providerModelCacheCatalog,
providerModelsRefreshing,
onHardRefreshProviderModels,
currentSessionId,
@@ -624,9 +537,9 @@ export default function CommandResultModal({
icon: CircleHelp,
},
models: {
eyebrow: 'Model inventory',
title: 'Available Models',
subtitle: 'Browse, search, and copy model IDs for the active provider.',
eyebrow: 'Model selection',
title: 'Choose a Model',
subtitle: 'Pick the model this provider should use.',
icon: Cpu,
},
cost: {
@@ -652,46 +565,41 @@ export default function CommandResultModal({
<DialogTitle>{activeMeta?.title || 'Command Result'}</DialogTitle>
<div
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 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'
className={`flex shrink-0 items-start justify-between gap-3 border-b border-border bg-popover ${
isModelsModal ? 'px-4 py-3 sm:px-5 sm:py-4' : 'px-4 py-4 sm:px-6 sm:py-5'
}`}
>
<div className="pointer-events-none absolute -left-20 -top-24 h-56 w-56 rounded-full bg-primary/20 blur-3xl" />
<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%)]" />
<div className="relative flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3 sm:items-center">
<div
className={`rounded-2xl border border-primary/30 bg-primary/10 text-primary shadow-sm ${
isModelsModal ? 'p-2.5' : 'p-3'
}`}
>
<HeaderIcon className={isModelsModal ? 'h-4 w-4' : 'h-5 w-5'} />
</div>
<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"
<div className="flex min-w-0 items-center gap-3">
<div
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'
}`}
>
<X className="h-4 w-4" />
</Button>
<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
type="button"
variant="ghost"
size="icon"
onClick={onClose}
className="h-8 w-8 shrink-0 rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Close command result modal"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="settings-content-enter min-h-0 flex-1 overflow-hidden px-4 py-4 sm:px-6 sm:py-5">
@@ -700,7 +608,6 @@ export default function CommandResultModal({
<ModelsContent
data={payload.data as ModelCommandData}
providerModelCatalog={providerModelCatalog}
providerModelCacheCatalog={providerModelCacheCatalog}
providerModelsRefreshing={providerModelsRefreshing}
onHardRefreshProviderModels={onHardRefreshProviderModels}
currentSessionId={currentSessionId}

View File

@@ -0,0 +1,68 @@
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>
);
}

View File

@@ -4,16 +4,53 @@ import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { useTranslation } from 'react-i18next';
import { normalizeInlineCodeFences } from '../../utils/chatFormatting';
import { copyTextToClipboard } from '../../../../utils/clipboard';
import { usePaletteOps } from '../../../../contexts/PaletteOpsContext';
import { useTheme } from '../../../../contexts/ThemeContext';
type MarkdownProps = {
children: React.ReactNode;
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 = {
node?: any;
inline?: boolean;
@@ -23,6 +60,7 @@ type CodeBlockProps = {
const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockProps) => {
const { t } = useTranslation('chat');
const { isDarkMode } = useTheme();
const [copied, setCopied] = useState(false);
const raw = Array.isArray(children) ? children.join('') : String(children ?? '');
const looksMultiline = /[\r\n]/.test(raw);
@@ -60,7 +98,7 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
}
})
}
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"
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"
title={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
aria-label={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
>
@@ -96,17 +134,20 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
<SyntaxHighlighter
language={language}
style={oneDark}
style={isDarkMode ? oneDark : oneLight}
customStyle={{
margin: 0,
borderRadius: '0.5rem',
borderRadius: '0.75rem',
fontSize: '0.875rem',
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={{
style: {
fontFamily:
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
...(isDarkMode ? {} : { background: 'transparent' }),
},
}}
>
@@ -118,16 +159,15 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
const markdownComponents = {
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 className="my-2 border-l-4 border-gray-300 pl-4 italic text-gray-600 dark:border-gray-600 dark:text-gray-400">
{children}
</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>,
table: ({ children }: { children?: React.ReactNode }) => (
<div className="my-2 overflow-x-auto">
@@ -147,10 +187,50 @@ export function Markdown({ children, className }: MarkdownProps) {
const content = normalizeInlineCodeFences(String(children ?? ''));
const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []);
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 (
<div className={className}>
<ReactMarkdown remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} components={markdownComponents as any}>
<ReactMarkdown remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} components={components as any}>
{content}
</ReactMarkdown>
</div>

View File

@@ -1,4 +1,4 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { memo, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
@@ -30,7 +30,6 @@ type MessageComponentProps = {
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
onShowSettings?: () => void;
onGrantToolPermission?: (suggestion: ClaudePermissionSuggestion) => PermissionGrantResult | null | undefined;
autoExpandTools?: boolean;
showRawParameters?: boolean;
showThinking?: boolean;
selectedProject?: Project | null;
@@ -45,7 +44,7 @@ type InteractiveOption = {
const COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']);
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
const { t } = useTranslation('chat');
const isGrouped = prevMessage && prevMessage.type === message.type &&
((prevMessage.type === 'assistant') ||
@@ -53,7 +52,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
(prevMessage.type === 'tool') ||
(prevMessage.type === 'error'));
const messageRef = useRef<HTMLDivElement | null>(null);
const [isExpanded, setIsExpanded] = useState(false);
const userCopyContent = String(message.content || '');
const formattedMessageContent = useMemo(
() => formatUsageLimitText(String(message.content || '')),
@@ -72,32 +70,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
!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 shouldHideThinkingMessage = Boolean(message.isThinking && !showThinking);
@@ -115,7 +87,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
/* 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="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 text-sm">
<div dir="auto" className="whitespace-pre-wrap break-words font-serif text-sm">
{message.content}
</div>
{message.images && message.images.length > 0 && (
@@ -166,7 +138,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
🔧
</div>
) : (
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full p-1 text-sm text-white">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full p-1 text-sm text-foreground">
<SessionProviderLogo provider={provider} className="h-full w-full" />
</div>
)}
@@ -194,7 +166,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
<>
<div className="flex flex-col">
<div className="flex flex-col">
<Markdown className="prose prose-sm max-w-none dark:prose-invert">
<Markdown className="prose prose-sm max-w-none font-serif dark:prose-invert">
{String(message.displayText || '')}
</Markdown>
</div>
@@ -210,7 +182,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
onFileOpen={onFileOpen}
createDiff={createDiff}
selectedProject={selectedProject}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
rawToolInput={typeof message.toolInput === 'string' ? message.toolInput : undefined}
isSubagentContainer={message.isSubagentContainer}
@@ -218,8 +189,8 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
/>
)}
{/* Tool Result Section */}
{message.toolResult && !shouldHideToolResult(message.toolName || 'UnknownTool', message.toolResult) && (
{/* Tool Result Section — Bash renders its output inside the command row above. */}
{message.toolResult && message.toolName !== 'Bash' && !shouldHideToolResult(message.toolName || 'UnknownTool', message.toolResult) && (
message.toolResult.isError ? (
// Error results - red error box with content
<div
@@ -233,7 +204,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
<span className="text-xs font-medium text-red-700 dark:text-red-300">{t('messageTypes.error')}</span>
</div>
<div className="relative text-sm text-red-900 dark:text-red-100">
<Markdown className="prose prose-sm prose-red max-w-none dark:prose-invert">
<Markdown className="prose prose-sm prose-red max-w-none font-serif dark:prose-invert">
{String(message.toolResult.content || '')}
</Markdown>
</div>
@@ -250,7 +221,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
onFileOpen={onFileOpen}
createDiff={createDiff}
selectedProject={selectedProject}
autoExpandTools={autoExpandTools}
/>
</div>
)
@@ -342,7 +312,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
<Reasoning defaultOpen={false}>
<ReasoningTrigger />
<ReasoningContent>
<Markdown className="prose prose-sm prose-gray max-w-none dark:prose-invert">
<Markdown className="prose prose-sm prose-gray max-w-none font-serif dark:prose-invert">
{message.content}
</Markdown>
<div className="mt-3 flex items-center text-[11px]">
@@ -377,15 +347,15 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
return (
<div className="my-2">
<div className="mb-2 flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<div className="mb-2 flex items-center gap-2 text-sm text-muted-foreground">
<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" />
</svg>
<span className="font-medium">{t('json.response')}</span>
</div>
<div className="overflow-hidden rounded-lg border border-gray-600/30 bg-gray-800 dark:border-gray-700 dark:bg-gray-900">
<div className="overflow-hidden rounded-lg border border-border bg-muted">
<pre className="overflow-x-auto p-4">
<code className="block whitespace-pre font-mono text-sm text-gray-100 dark:text-gray-200">
<code className="block whitespace-pre font-mono text-sm text-foreground">
{formatted}
</code>
</pre>
@@ -399,7 +369,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
// Normal rendering for non-JSON content
return message.type === 'assistant' ? (
<Markdown className="prose prose-sm prose-gray max-w-none dark:prose-invert">
<Markdown className="prose prose-sm prose-gray max-w-none font-serif dark:prose-invert">
{content}
</Markdown>
) : (

View File

@@ -1,4 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { CSSProperties } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { copyTextToClipboard } from '../../../../utils/clipboard';
@@ -49,9 +51,32 @@ const MessageCopyControl = ({
const [selectedFormat, setSelectedFormat] = useState<CopyFormat>(defaultFormat);
const [copied, setCopied] = useState(false);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [menuStyle, setMenuStyle] = useState<CSSProperties>({});
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);
// 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(
() => [
{
@@ -83,18 +108,28 @@ const MessageCopyControl = ({
}, [defaultFormat]);
useEffect(() => {
// Close the dropdown when clicking anywhere outside this control.
if (!isDropdownOpen) return;
// Close when clicking outside both the control and the portaled menu.
const closeOnOutsideClick = (event: MouseEvent) => {
if (!isDropdownOpen) return;
const target = event.target as Node;
if (dropdownRef.current && !dropdownRef.current.contains(target)) {
setIsDropdownOpen(false);
if (dropdownRef.current?.contains(target) || menuRef.current?.contains(target)) {
return;
}
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('scroll', closeOnScroll, true);
window.addEventListener('resize', closeOnScroll);
return () => {
window.removeEventListener('mousedown', closeOnOutsideClick);
window.removeEventListener('scroll', closeOnScroll, true);
window.removeEventListener('resize', closeOnScroll);
};
}, [isDropdownOpen]);
@@ -170,8 +205,9 @@ const MessageCopyControl = ({
{canSelectCopyFormat && (
<>
<button
ref={triggerRef}
type="button"
onClick={() => setIsDropdownOpen((prev) => !prev)}
onClick={() => (isDropdownOpen ? setIsDropdownOpen(false) : openDropdown())}
className={`rounded px-1 py-0.5 transition-colors ${toneClass}`}
aria-label={t('copyMessage.selectFormat', { defaultValue: 'Select copy format' })}
title={t('copyMessage.selectFormat', { defaultValue: 'Select copy format' })}
@@ -186,8 +222,12 @@ const MessageCopyControl = ({
</svg>
</button>
{isDropdownOpen && (
<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">
{isDropdownOpen && createPortal(
<div
ref={menuRef}
style={menuStyle}
className="min-w-36 rounded-md border border-border bg-popover p-1 shadow-lg"
>
{copyFormatOptions.map((option) => {
const isSelected = option.format === selectedFormat;
return (
@@ -196,15 +236,16 @@ const MessageCopyControl = ({
type="button"
onClick={() => handleFormatChange(option.format)}
className={`block w-full rounded px-2 py-1.5 text-left transition-colors ${isSelected
? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-100'
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800/60'
? 'bg-accent text-foreground'
: 'text-foreground hover:bg-accent'
}`}
>
<span className="block text-xs font-medium">{option.label}</span>
</button>
);
})}
</div>
</div>,
document.body,
)}
</>
)}

View File

@@ -186,7 +186,7 @@ export default function ProviderSelectionEmptyState({
if (!selectedSession && !currentSessionId) {
return (
<div className="flex h-full items-center justify-center px-4">
<div className="w-full max-w-md">
<div className="w-full max-w-[34.25rem]">
<div className="mb-8 text-center">
<h2 className="text-lg font-semibold tracking-tight text-foreground sm:text-xl">
{t("providerSelection.title")}
@@ -352,7 +352,7 @@ export default function ProviderSelectionEmptyState({
if (selectedSession) {
return (
<div className="flex h-full items-center justify-center">
<div className="max-w-md px-6 text-center">
<div className="max-w-[34.25rem] px-6 text-center">
<p className="mb-1.5 text-lg font-semibold text-foreground">
{t("session.continue.title")}
</p>

View File

@@ -43,7 +43,7 @@ export default function TokenUsageSummary({ usage, onClick }: TokenUsageSummaryP
<button
type="button"
onClick={onClick}
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"
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"
title={`${usedTokens.toLocaleString()} tokens used`}
aria-label="Show token usage"
>

View File

@@ -0,0 +1,144 @@
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>
);
}

View File

@@ -1,5 +1,4 @@
export const CODE_EDITOR_STORAGE_KEYS = {
theme: 'codeEditorTheme',
wordWrap: 'codeEditorWordWrap',
showMinimap: 'codeEditorShowMinimap',
lineNumbers: 'codeEditorLineNumbers',
@@ -7,7 +6,6 @@ export const CODE_EDITOR_STORAGE_KEYS = {
} as const;
export const CODE_EDITOR_DEFAULTS = {
isDarkMode: true,
wordWrap: false,
minimapEnabled: true,
showLineNumbers: true,

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { api } from '../../../utils/api';
import type { CodeEditorFile } from '../types/types';
import { isBinaryFile } from '../utils/binaryFile';
import { getPreviewKind } from '../utils/previewableFile';
type UseCodeEditorDocumentParams = {
file: CodeEditorFile;
@@ -23,6 +24,9 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
const [saveSuccess, setSaveSuccess] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
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;
// the fallback to `projectPath` preserves older callers that didn't yet
// propagate the identifier.
@@ -38,8 +42,19 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
setLoading(true);
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
if (isBinaryFile(file.name)) {
setContent('');
setIsBinary(true);
setLoading(false);
return;
@@ -76,6 +91,12 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
}, [file.diffInfo, file.name, fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectId]);
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);
setSaveError(null);
@@ -109,7 +130,7 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
} finally {
setSaving(false);
}
}, [content, filePath, fileProjectId]);
}, [content, filePath, fileProjectId, previewKind, fileName]);
const handleDownload = useCallback(() => {
const blob = new Blob([content], { type: 'text/plain' });
@@ -134,6 +155,8 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
saveSuccess,
saveError,
isBinary,
previewKind,
fileProjectId,
handleSave,
handleDownload,
};

View File

@@ -5,15 +5,6 @@ import {
CODE_EDITOR_STORAGE_KEYS,
} 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 value = localStorage.getItem(storageKey);
if (value === null) {
@@ -33,7 +24,6 @@ const readFontSize = () => {
};
export const useCodeEditorSettings = () => {
const [isDarkMode, setIsDarkMode] = useState(readTheme);
const [wordWrap, setWordWrap] = useState(readWordWrap);
const [minimapEnabled, setMinimapEnabled] = useState(() => (
readBoolean(CODE_EDITOR_STORAGE_KEYS.showMinimap, CODE_EDITOR_DEFAULTS.minimapEnabled)
@@ -43,18 +33,13 @@ export const useCodeEditorSettings = () => {
));
const [fontSize, setFontSize] = useState(readFontSize);
// Keep legacy behavior where the editor writes theme and wrap settings directly.
useEffect(() => {
localStorage.setItem(CODE_EDITOR_STORAGE_KEYS.theme, isDarkMode ? 'dark' : 'light');
}, [isDarkMode]);
// Keep legacy behavior where the editor writes wrap settings directly.
useEffect(() => {
localStorage.setItem(CODE_EDITOR_STORAGE_KEYS.wordWrap, String(wordWrap));
}, [wordWrap]);
useEffect(() => {
const refreshFromStorage = () => {
setIsDarkMode(readTheme());
setWordWrap(readWordWrap());
setMinimapEnabled(readBoolean(CODE_EDITOR_STORAGE_KEYS.showMinimap, CODE_EDITOR_DEFAULTS.minimapEnabled));
setShowLineNumbers(readBoolean(CODE_EDITOR_STORAGE_KEYS.lineNumbers, CODE_EDITOR_DEFAULTS.showLineNumbers));
@@ -71,8 +56,6 @@ export const useCodeEditorSettings = () => {
}, []);
return {
isDarkMode,
setIsDarkMode,
wordWrap,
setWordWrap,
minimapEnabled,

View File

@@ -0,0 +1,63 @@
// 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)];

View File

@@ -1,9 +1,11 @@
import { EditorView } from '@codemirror/view';
import { unifiedMergeView } from '@codemirror/merge';
import type { Extension } from '@codemirror/state';
import { useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
import { useTheme } from '../../../contexts/ThemeContext';
import { useCodeEditorDocument } from '../hooks/useCodeEditorDocument';
import { useCodeEditorSettings } from '../hooks/useCodeEditorSettings';
import { useEditorKeyboardShortcuts } from '../hooks/useEditorKeyboardShortcuts';
@@ -11,11 +13,13 @@ import type { CodeEditorFile } from '../types/types';
import { createMinimapExtension, createScrollToFirstChunkExtension, getLanguageExtensions } from '../utils/editorExtensions';
import { getEditorStyles } from '../utils/editorStyles';
import { createEditorToolbarPanelExtension } from '../utils/editorToolbarPanel';
import CodeEditorFooter from './subcomponents/CodeEditorFooter';
import CodeEditorHeader from './subcomponents/CodeEditorHeader';
import CodeEditorLoadingState from './subcomponents/CodeEditorLoadingState';
import CodeEditorSurface from './subcomponents/CodeEditorSurface';
import CodeEditorBinaryFile from './subcomponents/CodeEditorBinaryFile';
import CodeEditorMediaPreview from './subcomponents/CodeEditorMediaPreview';
type CodeEditorProps = {
file: CodeEditorFile;
@@ -42,8 +46,10 @@ export default function CodeEditor({
const [showDiff, setShowDiff] = useState(Boolean(file.diffInfo));
const [markdownPreview, setMarkdownPreview] = useState(false);
// The code editor follows the app-wide theme; it has no theme of its own.
const { isDarkMode } = useTheme();
const {
isDarkMode,
wordWrap,
minimapEnabled,
showLineNumbers,
@@ -58,6 +64,8 @@ export default function CodeEditor({
saveSuccess,
saveError,
isBinary,
previewKind,
fileProjectId,
handleSave,
handleDownload,
} = useCodeEditorDocument({
@@ -70,6 +78,29 @@ export default function CodeEditor({
return extension === 'md' || extension === 'markdown';
}, [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(
() => (
createMinimapExtension({
@@ -162,6 +193,30 @@ 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
if (isBinary) {
return (
@@ -197,10 +252,12 @@ export default function CodeEditor({
isSidebar={isSidebar}
isFullscreen={isFullscreen}
isMarkdownFile={isMarkdownFile}
isHtmlPreviewFile={isHtmlPreviewFile}
markdownPreview={markdownPreview}
saving={saving}
saveSuccess={saveSuccess}
onToggleMarkdownPreview={() => setMarkdownPreview((previous) => !previous)}
onOpenHtmlPreview={openHtmlPreview}
onOpenSettings={() => paletteOps.openSettings('appearance')}
onDownload={handleDownload}
onSave={handleSave}
@@ -210,6 +267,7 @@ export default function CodeEditor({
showingChanges: t('header.showingChanges'),
editMarkdown: t('actions.editMarkdown'),
previewMarkdown: t('actions.previewMarkdown'),
previewHtml: t('actions.previewHtml', 'Open HTML preview in new tab'),
settings: t('toolbar.settings'),
download: t('actions.download'),
save: t('actions.save'),

View File

@@ -1,4 +1,5 @@
import { Code2, Download, Eye, Maximize2, Minimize2, Save, Settings as SettingsIcon, X } from 'lucide-react';
import type { CodeEditorFile } from '../../types/types';
type CodeEditorHeaderProps = {
@@ -6,10 +7,12 @@ type CodeEditorHeaderProps = {
isSidebar: boolean;
isFullscreen: boolean;
isMarkdownFile: boolean;
isHtmlPreviewFile: boolean;
markdownPreview: boolean;
saving: boolean;
saveSuccess: boolean;
onToggleMarkdownPreview: () => void;
onOpenHtmlPreview: () => void;
onOpenSettings: () => void;
onDownload: () => void;
onSave: () => void;
@@ -19,6 +22,7 @@ type CodeEditorHeaderProps = {
showingChanges: string;
editMarkdown: string;
previewMarkdown: string;
previewHtml: string;
settings: string;
download: string;
save: string;
@@ -35,10 +39,12 @@ export default function CodeEditorHeader({
isSidebar,
isFullscreen,
isMarkdownFile,
isHtmlPreviewFile,
markdownPreview,
saving,
saveSuccess,
onToggleMarkdownPreview,
onOpenHtmlPreview,
onOpenSettings,
onDownload,
onSave,
@@ -82,6 +88,17 @@ export default function CodeEditorHeader({
</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
type="button"
onClick={onOpenSettings}

View File

@@ -0,0 +1,289 @@
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>
);
}

View File

@@ -1,8 +1,9 @@
import { useState } from 'react';
import type { ComponentProps } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark as prismOneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { oneDark as prismOneDark, oneLight as prismOneLight } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { copyTextToClipboard } from '../../../../../utils/clipboard';
import { useTheme } from '../../../../../contexts/ThemeContext';
type MarkdownCodeBlockProps = {
inline?: boolean;
@@ -16,6 +17,7 @@ export default function MarkdownCodeBlock({
node: _node,
...props
}: MarkdownCodeBlockProps) {
const { isDarkMode } = useTheme();
const [copied, setCopied] = useState(false);
const rawContent = Array.isArray(children) ? children.join('') : String(children ?? '');
const looksMultiline = /[\r\n]/.test(rawContent);
@@ -50,20 +52,22 @@ export default function MarkdownCodeBlock({
setTimeout(() => setCopied(false), 2000);
}
})}
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"
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"
>
{copied ? 'Copied!' : 'Copy'}
</button>
<SyntaxHighlighter
language={language}
style={prismOneDark}
style={isDarkMode ? prismOneDark : prismOneLight}
customStyle={{
margin: 0,
borderRadius: '0.5rem',
borderRadius: '0.75rem',
fontSize: '0.875rem',
padding: language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',
...(isDarkMode ? {} : { background: 'hsl(var(--muted))' }),
}}
codeTagProps={{ style: isDarkMode ? {} : { background: 'transparent' } }}
>
{rawContent}
</SyntaxHighlighter>

View File

@@ -12,6 +12,9 @@ type MarkdownPreviewProps = {
const markdownPreviewComponents: Components = {
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 className="my-2 border-l-4 border-gray-300 pl-4 italic text-gray-600 dark:border-gray-600 dark:text-gray-400">
{children}

View File

@@ -189,7 +189,7 @@ export default function GitPanelHeader({
<button
onClick={requestPublishConfirmation}
disabled={anyPending}
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"
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"
title={`Publish "${currentBranch}" to ${remoteName}`}
>
<Upload className={`h-3 w-3 ${isPublishing ? 'animate-pulse' : ''}`} />

View File

@@ -11,6 +11,7 @@ import { useTaskMaster } from '../../../contexts/TaskMasterContext';
import { usePaletteOpsRegister } from '../../../contexts/PaletteOpsContext';
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
import { useUiPreferences } from '../../../hooks/useUiPreferences';
import { useFileOpenResolver } from '../../../hooks/useFileOpenResolver';
import { authenticatedFetch } from '../../../utils/api';
import { useEditorSidebar } from '../../code-editor/hooks/useEditorSidebar';
import EditorSidebar from '../../code-editor/view/EditorSidebar';
@@ -53,7 +54,7 @@ function MainContent({
newSessionTrigger,
}: MainContentProps) {
const { preferences } = useUiPreferences();
const { autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter } = preferences;
const { showRawParameters, showThinking, sendByCtrlEnter } = preferences;
const { currentProject, setCurrentProject } = useTaskMaster() as TaskMasterContextValue;
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings() as TasksSettingsContextValue;
@@ -77,6 +78,10 @@ function MainContent({
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(() => {
// Identify projects by DB `projectId`; the TaskMaster context uses the
// same identifier to key its internal maps.
@@ -121,6 +126,10 @@ function MainContent({
setActiveTab('files');
handleFileOpen(filePath);
},
// Opens the editor side panel in place, keeping the current tab (e.g. chat).
openFileInEditor: (filePath: string) => {
resolvedFileOpen(filePath);
},
});
if (isLoading) {
@@ -161,10 +170,8 @@ function MainContent({
onNavigateToSession={onNavigateToSession}
onSessionEstablished={onSessionEstablished}
onShowSettings={onShowSettings}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
showThinking={showThinking}
autoScrollToBottom={autoScrollToBottom}
sendByCtrlEnter={sendByCtrlEnter}
externalMessageUpdate={externalMessageUpdate}
newSessionTrigger={newSessionTrigger}

View File

@@ -29,7 +29,7 @@ function getTabTitle(activeTab: AppTab, shouldShowTasksTab: boolean, t: (key: st
}
if (activeTab === 'browser') {
return 'Browser';
return t('tabs.browser');
}
return 'Project';
@@ -70,7 +70,7 @@ export default function MainContentTitle({
<div className="min-w-0 flex-1">
{activeTab === 'chat' && selectedSession ? (
<div className="min-w-0">
<h2 className="scrollbar-hide overflow-x-auto whitespace-nowrap text-sm font-semibold leading-tight text-foreground">
<h2 title={getSessionTitle(selectedSession)} className="truncate text-sm font-semibold leading-tight text-foreground">
{getSessionTitle(selectedSession)}
</h2>
<div className="truncate text-[11px] leading-tight text-muted-foreground">{selectedProject.displayName}</div>

View File

@@ -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_PROVIDER_BUTTON_CLASSES: Record<McpProvider, string> = {
claude: 'bg-purple-600 text-white hover:bg-purple-700',
cursor: 'bg-purple-600 text-white hover:bg-purple-700',
codex: 'bg-gray-800 text-white hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600',
gemini: 'bg-blue-600 text-white hover:bg-blue-700',
opencode: 'bg-zinc-900 text-white hover:bg-zinc-800 dark:bg-zinc-700 dark:hover:bg-zinc-600',
claude: 'bg-primary text-primary-foreground hover:bg-primary/90',
cursor: 'bg-primary text-primary-foreground hover:bg-primary/90',
codex: 'bg-primary text-primary-foreground hover:bg-primary/90',
gemini: 'bg-primary text-primary-foreground hover:bg-primary/90',
opencode: 'bg-primary text-primary-foreground hover:bg-primary/90',
};
export const MCP_SUPPORTS_WORKING_DIRECTORY: Record<McpProvider, boolean> = {

View File

@@ -135,7 +135,7 @@ export default function McpServers({ selectedProvider, currentProjects }: McpSer
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-purple-500" />
<Server className="h-5 w-5 text-primary" />
<h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
</div>
<p className="text-sm text-muted-foreground">{description}</p>

View File

@@ -1,4 +1,5 @@
import { FolderOpen, Globe, X } from 'lucide-react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { Button, Input } from '../../../../shared/view/ui';
@@ -119,8 +120,8 @@ export default function McpServerFormModal({
const supportsWorkingDirectory = !isGlobalMode && MCP_SUPPORTS_WORKING_DIRECTORY[provider];
const showCodexOnlyFields = provider === 'codex' && !isGlobalMode;
return (
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-black/50 p-4">
return createPortal(
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50 p-4">
<div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-lg border border-border bg-background">
<div className="flex items-center justify-between border-b border-border p-4">
<h3 className="text-lg font-medium text-foreground">{modalTitle}</h3>
@@ -418,7 +419,7 @@ export default function McpServerFormModal({
<Button
type="submit"
disabled={isSubmitting || !canSubmit}
className="bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-50"
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{isSubmitting
? t('mcpForm.actions.saving')
@@ -429,6 +430,7 @@ export default function McpServerFormModal({
</div>
</form>
</div>
</div>
</div>,
document.body,
);
}

View File

@@ -148,11 +148,18 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
return (
<>
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-2xl">
<div className="relative h-screen overflow-y-auto bg-background">
<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-2xl items-center justify-center p-4">
<div className="w-full py-6">
<OnboardingStepProgress currentStep={currentStep} />
<div className="rounded-lg border border-border bg-card p-8 shadow-lg">
<div className="rounded-2xl border border-border/70 bg-card/90 p-6 shadow-[0_24px_60px_-20px_hsl(var(--foreground)/0.18)] ring-1 ring-foreground/5 backdrop-blur-xl">
{currentStep === 0 ? (
<GitConfigurationStep
gitName={gitName}
@@ -168,13 +175,16 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
/>
)}
{errorMessage && (
<div className="mt-6 rounded-lg border border-red-300 bg-red-100 p-4 dark:border-red-800 dark:bg-red-900/20">
<p className="text-sm text-red-700 dark:text-red-400">{errorMessage}</p>
</div>
)}
{errorMessage && (
<div
role="alert"
className="mt-5 rounded-xl border border-destructive/30 bg-destructive/10 p-3.5"
>
<p className="text-sm text-destructive">{errorMessage}</p>
</div>
)}
<div className="mt-8 flex items-center justify-between border-t border-border pt-6">
<div className="mt-6 flex items-center justify-between border-t border-border pt-5">
<button
onClick={handlePreviousStep}
disabled={currentStep === 0 || isSubmitting}
@@ -189,7 +199,7 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
<button
onClick={handleNextStep}
disabled={!isCurrentStepValid || isSubmitting}
className="flex items-center gap-2 rounded-lg bg-blue-600 px-6 py-3 font-medium text-white transition-colors duration-200 hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-blue-400"
className="flex items-center gap-2 rounded-xl bg-primary px-6 py-2.5 font-medium text-primary-foreground shadow-lg shadow-primary/25 transition-all duration-200 hover:brightness-110 active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none"
>
{isSubmitting ? (
<>
@@ -207,7 +217,7 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
<button
onClick={handleFinish}
disabled={isSubmitting}
className="flex items-center gap-2 rounded-lg bg-green-600 px-6 py-3 font-medium text-white transition-colors duration-200 hover:bg-green-700 disabled:cursor-not-allowed disabled:bg-green-400"
className="flex items-center gap-2 rounded-xl bg-emerald-600 px-6 py-2.5 font-medium text-white shadow-lg shadow-emerald-600/25 transition-all duration-200 hover:bg-emerald-700 active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none"
>
{isSubmitting ? (
<>
@@ -225,6 +235,7 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -31,26 +31,26 @@ export default function AgentConnectionCard({
: status.error || 'Not connected';
return (
<div className={`rounded-lg border p-4 transition-colors ${containerClassName}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`flex h-10 w-10 items-center justify-center rounded-full ${iconContainerClassName}`}>
<div className={`rounded-xl border px-3 py-2.5 transition-colors ${containerClassName}`}>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<div className={`flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full ${iconContainerClassName}`}>
<SessionProviderLogo provider={provider} className="h-5 w-5" />
</div>
<div>
<div className="flex items-center gap-2 font-medium text-foreground">
<div className="min-w-0">
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
{title}
{status.authenticated && <Check className="h-4 w-4 text-green-500" />}
{status.authenticated && <Check className="h-3.5 w-3.5 flex-shrink-0 text-emerald-500" />}
</div>
<div className="text-xs text-muted-foreground">{statusText}</div>
<div className="truncate text-xs text-muted-foreground" title={statusText}>{statusText}</div>
</div>
</div>
{!status.authenticated && !status.loading && (
<button
onClick={onLogin}
className={`${loginButtonClassName} rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors`}
className={`${loginButtonClassName} flex-shrink-0 rounded-lg px-4 py-1.5 text-sm font-medium text-white transition-colors`}
>
Login
</button>

View File

@@ -51,15 +51,15 @@ export default function AgentConnectionsStep({
onOpenProviderLogin,
}: AgentConnectionsStepProps) {
return (
<div className="space-y-6">
<div className="mb-6 text-center">
<h2 className="mb-2 text-2xl font-bold text-foreground">Connect Your AI Agents</h2>
<p className="text-muted-foreground">
<div className="space-y-4">
<div className="text-center">
<h2 className="font-serif text-xl font-bold tracking-tight text-foreground">Connect Your AI Agents</h2>
<p className="mx-auto mt-1 max-w-sm text-sm leading-relaxed text-muted-foreground">
Login to one or more AI coding assistants. All are optional.
</p>
</div>
<div className="space-y-3">
<div className="-mr-1 max-h-[38vh] space-y-2 overflow-y-auto pr-1">
{providerCards.map((providerCard) => (
<AgentConnectionCard
key={providerCard.provider}
@@ -74,9 +74,7 @@ export default function AgentConnectionsStep({
))}
</div>
<div className="pt-2 text-center text-sm text-muted-foreground">
<p>You can configure these later in Settings.</p>
</div>
<p className="text-center text-xs text-muted-foreground">You can configure these later in Settings.</p>
</div>
);
}

View File

@@ -16,13 +16,13 @@ export default function GitConfigurationStep({
onGitEmailChange,
}: GitConfigurationStepProps) {
return (
<div className="space-y-6">
<div className="mb-8 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30">
<GitBranch className="h-8 w-8 text-blue-600 dark:text-blue-400" />
<div className="space-y-5">
<div className="text-center">
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/10 ring-1 ring-inset ring-primary/20">
<GitBranch className="h-7 w-7 text-primary" />
</div>
<h2 className="mb-2 text-2xl font-bold text-foreground">Git Configuration</h2>
<p className="text-muted-foreground">
<h2 className="font-serif text-xl font-bold tracking-tight text-foreground">Git Configuration</h2>
<p className="mx-auto mt-1 max-w-sm text-sm leading-relaxed text-muted-foreground">
Configure your git identity to ensure proper attribution for commits.
</p>
</div>
@@ -38,7 +38,7 @@ export default function GitConfigurationStep({
id="gitName"
value={gitName}
onChange={(event) => onGitNameChange(event.target.value)}
className="w-full rounded-lg border border-border bg-background px-4 py-3 text-foreground focus:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full rounded-xl border border-border bg-background/60 px-4 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"
placeholder="John Doe"
required
disabled={isSubmitting}
@@ -56,7 +56,7 @@ export default function GitConfigurationStep({
id="gitEmail"
value={gitEmail}
onChange={(event) => onGitEmailChange(event.target.value)}
className="w-full rounded-lg border border-border bg-background px-4 py-3 text-foreground focus:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full rounded-xl border border-border bg-background/60 px-4 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"
placeholder="john@example.com"
required
disabled={isSubmitting}

View File

@@ -11,7 +11,7 @@ const onboardingSteps = [
export default function OnboardingStepProgress({ currentStep }: OnboardingStepProgressProps) {
return (
<div className="mb-8">
<div className="mb-5">
<div className="flex items-center justify-between">
{onboardingSteps.map((step, index) => {
const isCompleted = index < currentStep;
@@ -22,18 +22,18 @@ export default function OnboardingStepProgress({ currentStep }: OnboardingStepPr
<div key={step.title} className="contents">
<div className="flex flex-1 flex-col items-center">
<div
className={`flex h-12 w-12 items-center justify-center rounded-full border-2 transition-colors duration-200 ${
className={`flex h-10 w-10 items-center justify-center rounded-full border-2 transition-all duration-200 ${
isCompleted
? 'border-green-500 bg-green-500 text-white'
? 'border-emerald-500 bg-emerald-500 text-white shadow-lg shadow-emerald-500/25'
: isActive
? 'border-blue-600 bg-blue-600 text-white'
: 'border-border bg-background text-muted-foreground'
? 'border-primary bg-primary text-primary-foreground shadow-lg shadow-primary/25'
: 'border-border bg-card text-muted-foreground'
}`}
>
{isCompleted ? <Check className="h-6 w-6" /> : <Icon className="h-6 w-6" />}
{isCompleted ? <Check className="h-5 w-5" /> : <Icon className="h-5 w-5" />}
</div>
<div className="mt-2 text-center">
<div className="mt-1.5 text-center">
<p className={`text-sm font-medium ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}>
{step.title}
</p>
@@ -42,7 +42,7 @@ export default function OnboardingStepProgress({ currentStep }: OnboardingStepPr
</div>
{index < onboardingSteps.length - 1 && (
<div className={`mx-2 h-0.5 flex-1 transition-colors duration-200 ${isCompleted ? 'bg-green-500' : 'bg-border'}`} />
<div className={`mx-2 h-0.5 flex-1 transition-colors duration-200 ${isCompleted ? 'bg-emerald-500' : 'bg-border'}`} />
)}
</div>
);

View File

@@ -1,11 +1,10 @@
import {
ArrowDown,
Brain,
Eye,
Languages,
Maximize2,
Mic,
} from 'lucide-react';
import type { PreferenceToggleItem } from './types';
export const HANDLE_POSITION_STORAGE_KEY = 'quickSettingsHandlePosition';
@@ -16,7 +15,7 @@ export const HANDLE_POSITION_MAX = 90;
export const DRAG_THRESHOLD_PX = 5;
export const SETTING_ROW_CLASS =
'flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600';
'flex items-center justify-between p-3 rounded-lg bg-muted/60 hover:bg-accent transition-colors border border-transparent hover:border-border';
export const TOGGLE_ROW_CLASS = `${SETTING_ROW_CLASS} cursor-pointer`;
@@ -24,11 +23,6 @@ export const CHECKBOX_CLASS =
'h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600';
export const TOOL_DISPLAY_TOGGLES: PreferenceToggleItem[] = [
{
key: 'autoExpandTools',
labelKey: 'quickSettings.autoExpandTools',
icon: Maximize2,
},
{
key: 'showRawParameters',
labelKey: 'quickSettings.showRawParameters',
@@ -41,14 +35,6 @@ export const TOOL_DISPLAY_TOGGLES: PreferenceToggleItem[] = [
},
];
export const VIEW_OPTION_TOGGLES: PreferenceToggleItem[] = [
{
key: 'autoScrollToBottom',
labelKey: 'quickSettings.autoScrollToBottom',
icon: ArrowDown,
},
];
export const INPUT_SETTING_TOGGLES: PreferenceToggleItem[] = [
{
key: 'sendByCtrlEnter',

View File

@@ -2,10 +2,8 @@ import type { CSSProperties } from 'react';
import type { LucideIcon } from 'lucide-react';
export type PreferenceToggleKey =
| 'autoExpandTools'
| 'showRawParameters'
| 'showThinking'
| 'autoScrollToBottom'
| 'sendByCtrlEnter'
| 'voiceEnabled';

Some files were not shown because too many files have changed in this diff Show More