mirror of
https://github.com/cline/cline.git
synced 2026-09-14 19:39:22 +08:00
Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1adce5e56d | ||
|
|
73583ea178 | ||
|
|
21a0141b86 | ||
|
|
ecb71ba7cc | ||
|
|
f5224abdf5 | ||
|
|
85484abf7a | ||
|
|
0b0e2fbab8 | ||
|
|
1e2e8fe81b | ||
|
|
78c6724c6a | ||
|
|
f2a895cf86 | ||
|
|
1585999251 | ||
|
|
2c556a4c94 | ||
|
|
9a80fa04c2 | ||
|
|
22a1fa2c84 | ||
|
|
57d364ffc2 | ||
|
|
0912f34286 | ||
|
|
bdb216c110 | ||
|
|
c92d4e7553 | ||
|
|
b919a7e86c | ||
|
|
eefbe9fb18 | ||
|
|
402b9994d8 | ||
|
|
fce1b97512 | ||
|
|
e7b0cec8e1 | ||
|
|
353ddc10f4 | ||
|
|
cabeb61036 | ||
|
|
cc29955c2d | ||
|
|
c2faf38d72 | ||
|
|
4dab17769c | ||
|
|
396032cd3b | ||
|
|
f33ab3a872 | ||
|
|
2ca8364ffc | ||
|
|
2ef81be703 | ||
|
|
359445ae0c | ||
|
|
d9e2e9c76b | ||
|
|
d859a86a6f | ||
|
|
0b7b9c1b3d | ||
|
|
557d725690 | ||
|
|
7274d8badc | ||
|
|
d1837366c0 | ||
|
|
c380daf4a3 | ||
|
|
c564045d81 | ||
|
|
9a5e1751b2 |
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.cline/skills/publish-desktop
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.cline/skills/publish-desktop
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
name: publish-desktop
|
||||
description: Use when preparing, tagging, and publishing a Cline Code desktop app (apps/examples/desktop-app) release. Guides changelog drafting, version bumps in package.json + tauri.conf.json, desktop-vX.Y.Z tags, and the desktop-publish GitHub workflow that builds, signs, notarizes, and updates the auto-update feed.
|
||||
---
|
||||
|
||||
# Desktop App Release
|
||||
|
||||
Use this skill when the user asks to release the desktop app, publish Cline Code, bump the desktop version, create a `desktop-vX.Y.Z` tag, or trigger the desktop publish workflow.
|
||||
|
||||
> Working directory: run every command below from the repository root.
|
||||
|
||||
Desktop releases are macOS-only today (signed + notarized DMG for Apple Silicon and Intel) and are built entirely in GitHub Actions — there is no local publish path. Installed apps discover new releases automatically through the Tauri updater, so publishing a release is what ships the update to every existing user.
|
||||
|
||||
## Release contract
|
||||
|
||||
- Version sources (must match each other and the tag): `apps/examples/desktop-app/package.json` and `apps/examples/desktop-app/src-tauri/tauri.conf.json`. (`src-tauri/Cargo.toml` has its own version but `tauri.conf.json` overrides it; no need to touch it.)
|
||||
- Release tag: `desktop-vX.Y.Z`, where `X.Y.Z` matches both version files.
|
||||
- Release prep includes approved release notes, the version bumps, and an `apps/examples/desktop-app/CHANGELOG.md` update.
|
||||
- Publish path: `.github/workflows/desktop-publish.yml` (workflow_dispatch, requires the tag to exist, point at the checked-out commit, and be reachable from `origin/main`).
|
||||
- The workflow creates the `desktop-vX.Y.Z` GitHub release (DMGs + updater artifacts + `latest.json`) and refreshes the rolling `desktop-latest` release, which is the static auto-update feed every installed app polls. Never delete the `desktop-latest` release or tag.
|
||||
- The changelog's top `## X.Y.Z` section is extracted verbatim into the GitHub release body, the Slack announcement, and the updater manifest notes.
|
||||
- Always ask before pushing commits or tags.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Gather context.
|
||||
|
||||
```sh
|
||||
git status --short --branch
|
||||
git fetch origin --tags
|
||||
git tag --list 'desktop-v*' --sort=-v:refname | head -10
|
||||
node -p "require('./apps/examples/desktop-app/package.json').version"
|
||||
node -p "require('./apps/examples/desktop-app/src-tauri/tauri.conf.json').version"
|
||||
```
|
||||
|
||||
If there is no `desktop-v*` tag yet, this is the first release; use the desktop app's first commit as the baseline and say the baseline is inferred.
|
||||
|
||||
2. Collect release commits.
|
||||
|
||||
```sh
|
||||
git log <last-desktop-tag>..HEAD --oneline --no-merges -- apps/examples/desktop-app sdk/packages .github/workflows/desktop-publish.yml
|
||||
```
|
||||
|
||||
The sidecar bundles `@cline/core` and friends from the monorepo, so SDK changes ship inside the desktop app too. Fold user-visible SDK changes (providers, models, behavior fixes) into the notes; skip purely internal ones.
|
||||
|
||||
3. Draft user-facing release notes.
|
||||
|
||||
Flat bullet list, user-facing language. Present the draft and wait for approval before editing files.
|
||||
|
||||
4. Decide the version bump.
|
||||
|
||||
Ask whether this is patch, minor, major, or an explicit version. Do not guess if the user has not made it clear.
|
||||
|
||||
5. Update release files.
|
||||
|
||||
- `apps/examples/desktop-app/package.json` → new version
|
||||
- `apps/examples/desktop-app/src-tauri/tauri.conf.json` → same version
|
||||
- Prepend `## X.Y.Z` (no date) to `apps/examples/desktop-app/CHANGELOG.md` with the approved notes.
|
||||
|
||||
6. Verify before committing.
|
||||
|
||||
```sh
|
||||
bun -F @cline/code typecheck
|
||||
bun test apps/examples/desktop-app/scripts/generate-update-manifest.test.ts
|
||||
```
|
||||
|
||||
The full desktop bundle can only be built on macOS; the workflow's build job is the real verification. For extra local confidence on a Mac checkout, `bun run package:desktop:mac --allow-unsigned-mac` from the app directory.
|
||||
|
||||
7. Commit release changes.
|
||||
|
||||
```sh
|
||||
git add apps/examples/desktop-app/package.json apps/examples/desktop-app/src-tauri/tauri.conf.json apps/examples/desktop-app/CHANGELOG.md
|
||||
git commit -m "chore(desktop): release vX.Y.Z"
|
||||
```
|
||||
|
||||
Ask before pushing the release commit, then before creating and pushing the tag:
|
||||
|
||||
```sh
|
||||
git push origin HEAD
|
||||
git tag -a desktop-vX.Y.Z -m "Desktop vX.Y.Z"
|
||||
git push origin refs/tags/desktop-vX.Y.Z
|
||||
```
|
||||
|
||||
8. Publish.
|
||||
|
||||
The release commit must be on `main` and the tag pushed first.
|
||||
|
||||
```sh
|
||||
gh workflow run desktop-publish.yml -f git_tag=desktop-vX.Y.Z -f confirm_publish=publish
|
||||
gh run list --workflow=desktop-publish.yml --limit=1 --json url,status,conclusion,createdAt --jq '.[0]'
|
||||
```
|
||||
|
||||
The workflow builds both architectures in parallel (aarch64 native, x86_64 cross-compiled), signs with the Developer ID certificate, notarizes with the App Store Connect API key, signs updater artifacts with the Tauri updater key, creates the GitHub release, refreshes `desktop-latest/latest.json`, and posts to Slack. Notarization typically adds 2–10 minutes.
|
||||
|
||||
If the workflow fails on missing credentials, see "Repo secrets (one-time setup)" below.
|
||||
|
||||
9. Verify the update feed after the run succeeds.
|
||||
|
||||
```sh
|
||||
curl -sL https://github.com/cline/cline/releases/download/desktop-latest/latest.json | head -30
|
||||
```
|
||||
|
||||
The `version` field must be the new release and both `darwin-aarch64` and `darwin-x86_64` URLs must point at the new `desktop-vX.Y.Z` assets. Installed apps pick the update up on next launch or within 2 hours.
|
||||
|
||||
10. Final response.
|
||||
|
||||
Report: version, tag, changelog updated, commit hash, what was pushed, workflow URL, and the feed verification result.
|
||||
|
||||
## Repo secrets (one-time setup)
|
||||
|
||||
The workflow needs these repository secrets. The Apple ones come from the same
|
||||
Apple Developer account used for manual signing (see the app README's "macOS
|
||||
signing & notarization" section for how to obtain them):
|
||||
|
||||
| Secret | Value |
|
||||
| --- | --- |
|
||||
| `APPLE_CERTIFICATE` | Base64 of the **Developer ID Application** identity exported from Keychain Access as `.p12` (must include the private key): `base64 -i certificate.p12 \| pbcopy` |
|
||||
| `APPLE_CERTIFICATE_PASSWORD` | The password chosen when exporting the `.p12` |
|
||||
| `APPLE_SIGNING_IDENTITY` | `Developer ID Application: <Team Name> (<TEAMID>)` — from `security find-identity -v -p codesigning` |
|
||||
| `APPLE_API_KEY` | App Store Connect API **Key ID** (notarization) |
|
||||
| `APPLE_API_KEY_CONTENT` | Contents of the `AuthKey_<KEYID>.p8` file |
|
||||
| `APPLE_API_ISSUER` | App Store Connect **Issuer ID** (UUID from Users and Access → Integrations) |
|
||||
| `TAURI_SIGNING_PRIVATE_KEY` | Contents of the Tauri updater private key (`tauri signer generate`). If this key is ever lost, shipped apps can no longer verify updates — guard it. |
|
||||
| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Password for that key |
|
||||
|
||||
The Slack + telemetry secrets (`SLACK_RELEASE_BOT_TOKEN`, `TELEMETRY_SERVICE_API_KEY`,
|
||||
OTEL settings) are shared with the CLI publish workflow and already configured.
|
||||
@@ -0,0 +1,158 @@
|
||||
---
|
||||
name: publish-ui
|
||||
description: Prepare, validate, and publish standalone @cline/ui npm releases. Use when bumping the UI package version, publishing latest or next through ui-publish.yml, checking UI release readiness, or completing the one-time npm trusted-publishing bootstrap.
|
||||
---
|
||||
|
||||
# Publish UI
|
||||
|
||||
Release `@cline/ui` independently from the Cline SDK runtime packages.
|
||||
|
||||
## Release contract
|
||||
|
||||
- Version source: `sdk/packages/ui/package.json`.
|
||||
- Workflow: `.github/workflows/ui-publish.yml`.
|
||||
- The package keeps `internal: true` only to stay out of the SDK's shared
|
||||
version/publish scripts. It is still a public npm package because
|
||||
`private: false` and `publishConfig.access: public` control npm publication.
|
||||
- `latest` is the production channel. `next` is an opt-in preview channel.
|
||||
- Use prerelease versions such as `0.2.0-next.0` for `next`; do not publish a
|
||||
version intended for `latest` under the preview tag because npm versions
|
||||
cannot be republished.
|
||||
- There is no UI Git tag, GitHub release, schedule, or Slack announcement.
|
||||
- The workflow runs only by manual dispatch. Every release attempt runs the UI
|
||||
quality checks before publishing and requires `confirm_publish=publish` from
|
||||
`main`.
|
||||
- The publish job and npm trust relationship use the protected `Publish`
|
||||
environment.
|
||||
- Every npm publication needs a new semver version; npm versions are immutable.
|
||||
- Always ask before pushing commits, triggering the publish workflow, changing
|
||||
npm trust settings, or running a local publish command.
|
||||
|
||||
## Normal release
|
||||
|
||||
1. Inspect the branch, current version, npm state, and UI changes.
|
||||
|
||||
```sh
|
||||
git status --short --branch
|
||||
node -p "require('./sdk/packages/ui/package.json').version"
|
||||
npm view @cline/ui dist-tags versions --json
|
||||
git log --oneline --no-merges -- \
|
||||
sdk/packages/ui apps/examples/desktop-app/webview/components/views/chat \
|
||||
.github/workflows/ui-publish.yml
|
||||
```
|
||||
|
||||
2. Ask for the npm channel and version together. For `latest`, ask for patch,
|
||||
minor, major, or an explicit version. For `next`, require an explicit
|
||||
prerelease version such as `0.2.0-next.0`. Do not guess. Update only
|
||||
`sdk/packages/ui/package.json` and its workspace version in `bun.lock`. Do
|
||||
not run the SDK version command.
|
||||
|
||||
3. Validate the release candidate.
|
||||
|
||||
```sh
|
||||
bun install --filter @cline/ui --filter @cline/code --frozen-lockfile
|
||||
bun -F @cline/ui typecheck
|
||||
bun -F @cline/ui test
|
||||
bun -F @cline/ui test:package
|
||||
bun -F @cline/ui build-storybook
|
||||
bun -F @cline/code test:chat-ui
|
||||
```
|
||||
|
||||
The packed-package test installs the tarball with Bun/React 19 and with
|
||||
npm/Node/React 18.
|
||||
Inspect `bun pm pack --dry-run` when the exported file set changed.
|
||||
|
||||
4. Commit the version bump separately from feature work. Ask before pushing.
|
||||
|
||||
```sh
|
||||
git add sdk/packages/ui/package.json bun.lock
|
||||
git commit -m "chore(ui): release vX.Y.Z"
|
||||
git push origin HEAD
|
||||
```
|
||||
|
||||
5. After the release commit reaches `main`, restate the selected npm tag and ask
|
||||
for explicit publish approval. Then trigger and watch the standalone
|
||||
workflow:
|
||||
|
||||
```sh
|
||||
run_url=$(gh workflow run ui-publish.yml --ref main \
|
||||
-f npm_tag=latest \
|
||||
-f confirm_publish=publish)
|
||||
test -n "$run_url"
|
||||
run_id=${run_url##*/}
|
||||
gh run watch "$run_id" --exit-status
|
||||
```
|
||||
|
||||
Use `npm_tag=next` only for a deliberate preview. Do not report success until
|
||||
the workflow succeeds and npm shows the exact version under the selected tag.
|
||||
|
||||
```sh
|
||||
npm view @cline/ui dist-tags versions --json
|
||||
```
|
||||
|
||||
## One-time npm bootstrap
|
||||
|
||||
Use this only while `npm view @cline/ui` returns `E404`. npm requires the
|
||||
package to exist before its GitHub trusted publisher can be configured.
|
||||
|
||||
1. Merge the package and `ui-publish.yml` to `main`. Start from a clean,
|
||||
reviewed `main` checkout. Verify authentication, account 2FA, and write
|
||||
access to the `@cline` npm organization. The `npm trust` command in step 4
|
||||
requires npm CLI 11.15 or newer; the automated trusted-publishing workflow
|
||||
itself enforces npm 11.5.1 or newer.
|
||||
|
||||
```sh
|
||||
npm --version
|
||||
npm whoami
|
||||
npm view @cline/ui version
|
||||
```
|
||||
|
||||
If npm is older than 11.15, ask before upgrading with
|
||||
`npm install -g npm@^11.15.0`.
|
||||
|
||||
2. Run the normal release validation in step 3 above. Then build, pack, test,
|
||||
and inspect the exact initial tarball. Record the absolute archive path
|
||||
printed by the final command.
|
||||
|
||||
```sh
|
||||
bun -F @cline/ui build
|
||||
pack_dir=$(mktemp -d)
|
||||
(cd sdk/packages/ui && bun pm pack --ignore-scripts --destination "$pack_dir" --quiet)
|
||||
tarball=$(find "$pack_dir" -maxdepth 1 -name '*.tgz' -print -quit)
|
||||
test -n "$tarball"
|
||||
bun sdk/packages/ui/scripts/smoke-package.ts "$tarball"
|
||||
tar -tzf "$tarball"
|
||||
printf 'Bootstrap archive: %s\n' "$tarball"
|
||||
```
|
||||
|
||||
3. Ask for explicit approval, then publish the initial version publicly under
|
||||
`latest`:
|
||||
|
||||
```sh
|
||||
npm publish /absolute/path/from-step-2.tgz --access public --tag latest
|
||||
```
|
||||
|
||||
4. Ask separately before configuring the standalone workflow as the trusted
|
||||
publisher:
|
||||
|
||||
```sh
|
||||
npm trust github @cline/ui \
|
||||
--repo cline/cline \
|
||||
--file ui-publish.yml \
|
||||
--env Publish \
|
||||
--allow-publish
|
||||
```
|
||||
|
||||
5. Verify both package state and trust. Every later release uses the workflow;
|
||||
do not add a long-lived npm token.
|
||||
|
||||
```sh
|
||||
npm view @cline/ui dist-tags versions --json
|
||||
npm trust list @cline/ui
|
||||
```
|
||||
|
||||
## Final report
|
||||
|
||||
Report the version and npm tag, release commit, whether anything was pushed,
|
||||
workflow URL or bootstrap result, npm verification, and tests/builds run. If
|
||||
the package still returns `E404`, state that bootstrap remains required.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Publish UI"
|
||||
short_description: "Prepare and publish the Cline UI package"
|
||||
default_prompt: "Use $publish-ui to prepare and publish a new @cline/ui npm release."
|
||||
@@ -0,0 +1,310 @@
|
||||
name: desktop-publish
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
git_tag:
|
||||
description: "Existing release tag to publish, for example desktop-v0.1.0"
|
||||
required: true
|
||||
type: string
|
||||
confirm_publish:
|
||||
description: 'Type "publish" to confirm the desktop release.'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: .
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate release tag
|
||||
if: |
|
||||
github.repository == 'cline/cline' &&
|
||||
github.event.inputs.confirm_publish == 'publish' &&
|
||||
!endsWith(github.actor, '[bot]')
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.git_tag }}
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Validate release tag
|
||||
id: version
|
||||
env:
|
||||
TAG: ${{ github.event.inputs.git_tag }}
|
||||
run: |
|
||||
if ! printf "%s\n" "$TAG" | grep -Eq '^desktop-v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
|
||||
echo "git_tag must look like desktop-vX.Y.Z, got: ${TAG}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION="${TAG#desktop-v}"
|
||||
PACKAGE_VERSION=$(node -p "require('./apps/examples/desktop-app/package.json').version")
|
||||
TAURI_VERSION=$(node -p "require('./apps/examples/desktop-app/src-tauri/tauri.conf.json').version")
|
||||
|
||||
if [ "$PACKAGE_VERSION" != "$VERSION" ]; then
|
||||
echo "apps/examples/desktop-app/package.json version ${PACKAGE_VERSION} does not match ${TAG}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$TAURI_VERSION" != "$VERSION" ]; then
|
||||
echo "apps/examples/desktop-app/src-tauri/tauri.conf.json version ${TAURI_VERSION} does not match ${TAG}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TAG_COMMIT=$(git rev-parse "${TAG}^{commit}")
|
||||
HEAD_COMMIT=$(git rev-parse HEAD)
|
||||
if [ "$TAG_COMMIT" != "$HEAD_COMMIT" ]; then
|
||||
echo "${TAG} does not point at the checked out commit"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch origin +main:refs/remotes/origin/main
|
||||
if ! git merge-base --is-ancestor "$HEAD_COMMIT" origin/main; then
|
||||
echo "${TAG} is not reachable from origin/main"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
name: Build macOS (${{ matrix.arch }})
|
||||
needs: validate
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 90
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
include:
|
||||
- target: aarch64-apple-darwin
|
||||
arch: aarch64
|
||||
- target: x86_64-apple-darwin
|
||||
arch: x86_64
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.tag }}
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Cache Rust build
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: apps/examples/desktop-app/src-tauri
|
||||
key: ${{ matrix.target }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build SDK packages
|
||||
run: bun run build:sdk
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
|
||||
- name: Write App Store Connect API key
|
||||
env:
|
||||
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }}
|
||||
run: |
|
||||
if [ -z "$APPLE_API_KEY_CONTENT" ]; then
|
||||
echo "APPLE_API_KEY_CONTENT secret is not configured"
|
||||
exit 1
|
||||
fi
|
||||
printf "%s" "$APPLE_API_KEY_CONTENT" > "$RUNNER_TEMP/AuthKey.p8"
|
||||
|
||||
- name: Build, sign, and notarize desktop bundle
|
||||
working-directory: apps/examples/desktop-app
|
||||
run: bunx tauri build --target ${{ matrix.target }} --config src-tauri/tauri.release.conf.json
|
||||
env:
|
||||
# Developer ID signing (Tauri imports the cert into a temp keychain)
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
# Notarization via App Store Connect API key. Tauri reads the Key ID
|
||||
# from APPLE_API_KEY; APPLE_API_KEY_ID alone silently skips notarization.
|
||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||
APPLE_API_KEY_PATH: ${{ runner.temp }}/AuthKey.p8
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
# Updater artifact signing (minisign keypair, independent of Apple)
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
- name: Collect artifacts
|
||||
working-directory: apps/examples/desktop-app
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
ARCH: ${{ matrix.arch }}
|
||||
TARGET: ${{ matrix.target }}
|
||||
run: |
|
||||
BUNDLE_DIR="src-tauri/target/${TARGET}/release/bundle"
|
||||
OUT="dist/publish"
|
||||
mkdir -p "$OUT"
|
||||
|
||||
DMG=$(find "$BUNDLE_DIR/dmg" -name '*.dmg' -print -quit)
|
||||
if [ -z "$DMG" ]; then
|
||||
echo "no DMG produced under $BUNDLE_DIR/dmg"
|
||||
exit 1
|
||||
fi
|
||||
cp "$DMG" "$OUT/Cline-Code_${VERSION}_${ARCH}.dmg"
|
||||
|
||||
TARBALL=$(find "$BUNDLE_DIR/macos" -name '*.app.tar.gz' -print -quit)
|
||||
if [ -z "$TARBALL" ] || [ ! -f "${TARBALL}.sig" ]; then
|
||||
echo "updater artifact or signature missing under $BUNDLE_DIR/macos"
|
||||
exit 1
|
||||
fi
|
||||
cp "$TARBALL" "$OUT/Cline-Code_${VERSION}_${ARCH}.app.tar.gz"
|
||||
cp "${TARBALL}.sig" "$OUT/Cline-Code_${VERSION}_${ARCH}.app.tar.gz.sig"
|
||||
|
||||
ls -lh "$OUT"
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-${{ matrix.arch }}
|
||||
path: apps/examples/desktop-app/dist/publish/*
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: Create GitHub release
|
||||
needs: [validate, build]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.tag }}
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist/desktop
|
||||
merge-multiple: true
|
||||
|
||||
- name: Get Changelog Entry
|
||||
id: changelog
|
||||
run: |
|
||||
# Grab content between the first "## " header and the next one
|
||||
CONTENT=$(awk '/^## [0-9]/{if(found) exit; found=1; next} found{print}' apps/examples/desktop-app/CHANGELOG.md)
|
||||
echo "content<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
printf "%s\n" "$CONTENT" > "$RUNNER_TEMP/release-notes.md"
|
||||
|
||||
- name: Generate updater manifest
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
TAG: ${{ needs.validate.outputs.tag }}
|
||||
run: |
|
||||
bun apps/examples/desktop-app/scripts/generate-update-manifest.ts \
|
||||
--version "$VERSION" \
|
||||
--tag "$TAG" \
|
||||
--dir dist/desktop \
|
||||
--out dist/desktop/latest.json \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--notes-file "$RUNNER_TEMP/release-notes.md"
|
||||
cat dist/desktop/latest.json
|
||||
|
||||
- name: Get Previous Desktop Tag
|
||||
id: prev_tag
|
||||
env:
|
||||
CURRENT_TAG: ${{ needs.validate.outputs.tag }}
|
||||
run: |
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 --match 'desktop-v*' "$CURRENT_TAG^" 2>/dev/null || echo "")
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ needs.validate.outputs.tag }}
|
||||
name: "Desktop v${{ needs.validate.outputs.version }}"
|
||||
# The repo-wide "latest" release stays owned by CLI releases; the
|
||||
# desktop auto-update feed is the rolling desktop-latest release.
|
||||
make_latest: "false"
|
||||
files: dist/desktop/*
|
||||
body: |
|
||||
${{ steps.changelog.outputs.content }}
|
||||
|
||||
${{ steps.prev_tag.outputs.prev_tag != '' && format('**Full Changelog**: https://github.com/{0}/compare/{1}...{2}', github.repository, steps.prev_tag.outputs.prev_tag, needs.validate.outputs.tag) || '' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Update auto-update feed (desktop-latest)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if ! gh release view desktop-latest >/dev/null 2>&1; then
|
||||
gh release create desktop-latest \
|
||||
--title "Cline Code desktop (auto-update feed)" \
|
||||
--notes "Rolling release backing the desktop app auto-updater. The latest.json asset points at the newest desktop-vX.Y.Z release. Do not delete." \
|
||||
--latest=false \
|
||||
--target "$(git rev-parse HEAD)"
|
||||
fi
|
||||
gh release upload desktop-latest dist/desktop/latest.json --clobber
|
||||
|
||||
- name: Summary
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
TAG: ${{ needs.validate.outputs.tag }}
|
||||
run: |
|
||||
echo "Published Cline Code desktop v${VERSION}"
|
||||
echo "Release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${TAG}"
|
||||
echo "Auto-update feed refreshed: https://github.com/${GITHUB_REPOSITORY}/releases/download/desktop-latest/latest.json"
|
||||
|
||||
- name: Post release to Slack
|
||||
uses: slackapi/slack-github-action@v3.0.1
|
||||
with:
|
||||
method: chat.postMessage
|
||||
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: "C0APVKGGZFC"
|
||||
text: "Cline Code desktop v${{ needs.validate.outputs.version }}"
|
||||
blocks:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: "Cline Code desktop v${{ needs.validate.outputs.version }}"
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
text: "<https://github.com/${{ github.repository }}/releases/tag/${{ needs.validate.outputs.tag }}|Download DMG> — installed apps auto-update on next launch${{ steps.prev_tag.outputs.prev_tag != '' && format(' | Full Changelog: https://github.com/{0}/compare/{1}...{2}', github.repository, steps.prev_tag.outputs.prev_tag, needs.validate.outputs.tag) || '' }}"
|
||||
@@ -0,0 +1,145 @@
|
||||
name: ui-publish
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
npm_tag:
|
||||
description: "npm distribution tag"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- next
|
||||
- latest
|
||||
default: next
|
||||
confirm_publish:
|
||||
description: 'Type "publish" to publish @cline/ui to npm'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
name: UI quality and package checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24.x"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --filter @cline/ui --filter @cline/code --frozen-lockfile
|
||||
|
||||
- name: Typecheck UI
|
||||
run: bun -F @cline/ui typecheck
|
||||
|
||||
- name: Test UI
|
||||
run: bun -F @cline/ui test
|
||||
|
||||
- name: Build Storybook
|
||||
run: bun -F @cline/ui build-storybook
|
||||
|
||||
- name: Build UI package
|
||||
run: bun -F @cline/ui build
|
||||
|
||||
- name: Test desktop chat integration
|
||||
run: bun -F @cline/code test:chat-ui
|
||||
|
||||
- name: Pack publish artifact
|
||||
id: pack
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pack_dir="$RUNNER_TEMP/ui-npm-pack"
|
||||
mkdir -p "$pack_dir"
|
||||
cd sdk/packages/ui
|
||||
bun pm pack --ignore-scripts --destination "$pack_dir" --quiet
|
||||
archive=$(find "$pack_dir" -maxdepth 1 -name '*.tgz' -print -quit)
|
||||
test -n "$archive"
|
||||
echo "archive=$archive" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Test packed package
|
||||
env:
|
||||
UI_PACKAGE_ARCHIVE: ${{ steps.pack.outputs.archive }}
|
||||
run: bun sdk/packages/ui/scripts/smoke-package.ts "$UI_PACKAGE_ARCHIVE"
|
||||
|
||||
- name: Upload publish artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ui-npm-package
|
||||
path: ${{ runner.temp }}/ui-npm-pack/*.tgz
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
publish:
|
||||
name: Publish @cline/ui
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' &&
|
||||
github.repository == 'cline/cline' &&
|
||||
github.ref == 'refs/heads/main' &&
|
||||
inputs.confirm_publish == 'publish' &&
|
||||
!endsWith(github.actor, '[bot]')
|
||||
needs: quality
|
||||
runs-on: ubuntu-latest
|
||||
environment: Publish
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24.x"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Download publish artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ui-npm-package
|
||||
path: ${{ runner.temp }}/ui-npm-pack
|
||||
|
||||
- name: Verify publish tooling
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm_version=$(npm --version)
|
||||
echo "npm ${npm_version}"
|
||||
node -e 'const [major, minor, patch] = process.argv[1].split(".").map(Number); if (major < 11 || (major === 11 && (minor < 5 || (minor === 5 && patch < 1)))) { console.error("npm 11.5.1 or newer is required for trusted publishing"); process.exit(1); }' "$npm_version"
|
||||
|
||||
- name: Publish package
|
||||
shell: bash
|
||||
env:
|
||||
NPM_CONFIG_PROVENANCE: "true"
|
||||
NPM_TAG: ${{ inputs.npm_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
archive=$(find "$RUNNER_TEMP/ui-npm-pack" -maxdepth 1 -name '*.tgz' -print -quit)
|
||||
if [ -z "$archive" ]; then
|
||||
echo "UI package archive was not downloaded"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version=$(tar -xOf "$archive" package/package.json | node -e 'let input=""; process.stdin.on("data", chunk => input += chunk); process.stdin.on("end", () => process.stdout.write(JSON.parse(input).version))')
|
||||
if npm view "@cline/ui@${version}" version >/dev/null 2>&1; then
|
||||
echo "@cline/ui@${version} already exists; bump sdk/packages/ui/package.json before publishing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
npm publish "$archive" --tag "$NPM_TAG" --access public
|
||||
echo "Published @cline/ui@${version} with npm tag '${NPM_TAG}'"
|
||||
@@ -1,5 +1,23 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.46
|
||||
|
||||
- Fixed out-of-credits detection so the CLI reliably recognizes the Cline API's real `insufficient_credits` (402) error and shows the "add credits" card instead of a generic error
|
||||
|
||||
## 3.0.45
|
||||
|
||||
- Smaller install: the Claude Code and Codex providers are now optional and loaded on demand, cutting `npm i -g cline` from ~640MB to ~285MB (from SDK v0.0.65)
|
||||
- Kimi K3 is now available as a ClinePass model (from SDK v0.0.65)
|
||||
- Runs now retry once after refreshing expired OAuth credentials (from SDK v0.0.65)
|
||||
- Team runs: the spawn tool is no longer exposed to teammates, and errored teammate runs now report as failed instead of completed (from SDK v0.0.65)
|
||||
- Hub status output now includes version numbers
|
||||
- Updated the bundled model catalog (from SDK v0.0.65)
|
||||
|
||||
## 3.0.44
|
||||
|
||||
- Improved max output token handling across providers (gateway routing, OpenAI vendor, and reasoning models) (from SDK v0.0.64)
|
||||
- Frontmatter and configuration files that start with a UTF-8 byte order mark (e.g. saved by Windows editors) now parse correctly (from SDK v0.0.64)
|
||||
|
||||
## 3.0.43
|
||||
|
||||
- The CLI now automatically trusts your operating system's certificate store, so it works behind corporate proxies and TLS-inspecting firewalls without manually setting `NODE_EXTRA_CA_CERTS` (fixes "unable to get local issuer certificate" errors, including Windows intermediate CA stores)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.43",
|
||||
"version": "3.0.46",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
@@ -78,19 +78,19 @@
|
||||
"@cline/cline-hub": "workspace:*",
|
||||
"@gramio/format": "^0.7.0",
|
||||
"@opentui-ui/dialog": "^0.1.2",
|
||||
"@opentui/core": "0.1.102",
|
||||
"@opentui/react": "0.1.102",
|
||||
"@opentui/core": "0.4.3",
|
||||
"@opentui/react": "0.4.3",
|
||||
"chat": "^4.23.0",
|
||||
"commander": "^14.0.3",
|
||||
"fzf": "^0.5.2",
|
||||
"marked": "^15.0.12",
|
||||
"open": "^10.2.0",
|
||||
"opentui-spinner": "^0.0.6",
|
||||
"opentui-spinner": "^0.0.7",
|
||||
"pino": "^10.3.1",
|
||||
"posthog-node": "^5.8.0",
|
||||
"react": "19.2.4",
|
||||
"react-devtools-core": "^7.0.1",
|
||||
"react-reconciler": "0.32.0",
|
||||
"react-reconciler": "0.33.0",
|
||||
"yaml": "^2.8.2",
|
||||
"nanoid": "^5.1.7",
|
||||
"zod": "^4.1.11"
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { version as cliVersion } from "../../package.json";
|
||||
import { getCliBuildInfo } from "../utils/common";
|
||||
|
||||
const {
|
||||
@@ -174,6 +175,40 @@ describe("runDoctorCommand", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("reports CLI and running hub Core versions", async () => {
|
||||
const cwd = "/workspace";
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
port: 25463,
|
||||
pid: 50174,
|
||||
coreVersion: "0.0.63",
|
||||
});
|
||||
mockProbeHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
port: 25463,
|
||||
pid: 50174,
|
||||
coreVersion: "0.0.64",
|
||||
});
|
||||
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
|
||||
|
||||
const output: string[] = [];
|
||||
const code = await runDoctorCommand(
|
||||
{ cwd, json: true },
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(JSON.parse(output[0] || "")).toMatchObject({
|
||||
cliVersion,
|
||||
coreVersion: "0.0.64",
|
||||
});
|
||||
});
|
||||
|
||||
it("doctor --fix clears wedged hub startup artifacts when no server is actually running", async () => {
|
||||
const cwd = mkdtempSync(path.join(os.tmpdir(), "doctor-hub-fix-"));
|
||||
tempDirs.push(cwd);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import open from "open";
|
||||
import { version as cliVersion } from "../../package.json";
|
||||
import { isProcessRunning } from "../connectors/common";
|
||||
import {
|
||||
type ActiveConnectorRecord,
|
||||
@@ -49,6 +50,8 @@ type SpawnedProcessRecord = {
|
||||
|
||||
type DoctorStatus = {
|
||||
cwd: string;
|
||||
cliVersion: string;
|
||||
coreVersion?: string;
|
||||
hubUrl?: string;
|
||||
hubHealthy: boolean;
|
||||
hubPid?: number;
|
||||
@@ -337,6 +340,8 @@ async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
|
||||
];
|
||||
return {
|
||||
cwd,
|
||||
cliVersion,
|
||||
coreVersion: health?.coreVersion ?? discovery?.coreVersion,
|
||||
hubUrl: current?.url,
|
||||
hubHealthy: !!health?.url,
|
||||
hubPid: current?.pid,
|
||||
@@ -419,6 +424,8 @@ export async function runDoctorCommand(
|
||||
io.writeln(JSON.stringify(before));
|
||||
return 0;
|
||||
}
|
||||
writeln(`cli version ${c.dim}${before.cliVersion}${c.reset}`);
|
||||
writeln(`core version ${c.dim}${before.coreVersion ?? "n/a"}${c.reset}`);
|
||||
writeln(`hub url ${c.dim}${before.hubUrl ?? "none"}${c.reset}`);
|
||||
writeln(
|
||||
`hub healthy ${c.dim}${before.hubHealthy ? "yes" : "no"}${before.hubPid ? ` (pid=${before.hubPid})` : ""}${c.reset}`,
|
||||
|
||||
@@ -34,6 +34,7 @@ vi.mock("@cline/core", () => ({
|
||||
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
|
||||
}));
|
||||
|
||||
import { version as cliVersion } from "../../package.json";
|
||||
import { createHubCommand } from "./hub";
|
||||
|
||||
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
|
||||
@@ -63,6 +64,7 @@ describe("createHubCommand", () => {
|
||||
port: 25463,
|
||||
pid: 50174,
|
||||
startedAt: "2026-01-01T00:00:00.000Z",
|
||||
coreVersion: "0.0.62",
|
||||
});
|
||||
|
||||
const output: string[] = [];
|
||||
@@ -88,6 +90,8 @@ describe("createHubCommand", () => {
|
||||
pid: 50174,
|
||||
startedAt: "2026-01-01T00:00:00.000Z",
|
||||
uptime: "1m 5s",
|
||||
cliVersion,
|
||||
coreVersion: "0.0.62",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "@cline/core";
|
||||
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import { version as cliVersion } from "../../package.json";
|
||||
|
||||
interface HubCommandIo {
|
||||
writeln: (text?: string) => void;
|
||||
@@ -134,6 +135,8 @@ export function createHubCommand(
|
||||
pid: health?.pid,
|
||||
startedAt: health?.startedAt,
|
||||
uptime,
|
||||
cliVersion,
|
||||
coreVersion: health?.coreVersion ?? discovery?.coreVersion,
|
||||
}),
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -67,6 +67,62 @@ describe("createConnectorRuntimeTurnStream", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps streaming when tool status delivery fails", async () => {
|
||||
let handlers: StreamHandlers | undefined;
|
||||
const log = vi.fn();
|
||||
const statusError = new Error("message_not_found");
|
||||
const client = {
|
||||
streamEvents: (_request: unknown, callbacks: StreamHandlers) => {
|
||||
handlers = callbacks;
|
||||
return () => {};
|
||||
},
|
||||
sendRuntimeSession: async () => {
|
||||
handlers?.onEvent({
|
||||
eventType: "runtime.chat.tool_call_start",
|
||||
payload: { toolName: "run_commands" },
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
handlers?.onEvent({
|
||||
eventType: "runtime.chat.text_delta",
|
||||
payload: { text: "Final response" },
|
||||
});
|
||||
return {
|
||||
result: {
|
||||
text: "Final response",
|
||||
finishReason: "stop",
|
||||
iterations: 1,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const chunks: string[] = [];
|
||||
for await (const chunk of createConnectorRuntimeTurnStream({
|
||||
client: client as never,
|
||||
sessionId: "session-1",
|
||||
request: { config: {} as never, prompt: "hi" },
|
||||
clientId: "client-1",
|
||||
logger: { core: { log } } as unknown as CliLoggerAdapter,
|
||||
transport: "slack",
|
||||
conversationId: "thread-1",
|
||||
onToolStatus: async () => {
|
||||
throw statusError;
|
||||
},
|
||||
})) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
expect(chunks.join("")).toBe("Final response");
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
"Connector tool status delivery failed",
|
||||
expect.objectContaining({
|
||||
severity: "warn",
|
||||
transport: "slack",
|
||||
error: statusError,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("treats queued runtime turns as non-error completion", async () => {
|
||||
const log = vi.fn();
|
||||
const client = {
|
||||
|
||||
@@ -169,7 +169,17 @@ export function createConnectorRuntimeTurnStream(input: {
|
||||
return;
|
||||
}
|
||||
lastStatusMessage = message;
|
||||
await input.onToolStatus?.(message);
|
||||
try {
|
||||
await input.onToolStatus?.(message);
|
||||
} catch (error) {
|
||||
input.logger.core.log("Connector tool status delivery failed", {
|
||||
severity: "warn",
|
||||
transport: input.transport,
|
||||
conversationId: input.conversationId,
|
||||
sessionId: input.sessionId,
|
||||
error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const stopStreaming = input.client.streamEvents(
|
||||
|
||||
@@ -309,3 +309,62 @@ describe("loadIndividualSubscriptionPlans", () => {
|
||||
expect(result).toEqual(plans);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isClineAccountCreditsErrorMessage", () => {
|
||||
it("matches the raw insufficient_credits JSON payload from the Cline API 402", async () => {
|
||||
const { isClineAccountCreditsErrorMessage } = await import(
|
||||
"./cline-account"
|
||||
);
|
||||
expect(
|
||||
isClineAccountCreditsErrorMessage(
|
||||
'{"code":"insufficient_credits","current_balance":-0.14,"message":"Not enough credits available"}',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches the insufficient_credits payload wrapped in an error prefix", async () => {
|
||||
const { isClineAccountCreditsErrorMessage } = await import(
|
||||
"./cline-account"
|
||||
);
|
||||
expect(
|
||||
isClineAccountCreditsErrorMessage(
|
||||
'Error: {"code":"insufficient_credits","current_balance":0,"message":"Not enough credits available"}',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches the plain human-readable Cline API message", async () => {
|
||||
const { isClineAccountCreditsErrorMessage } = await import(
|
||||
"./cline-account"
|
||||
);
|
||||
expect(
|
||||
isClineAccountCreditsErrorMessage("Not enough credits available"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches the legacy insufficient balance phrasing", async () => {
|
||||
const { isClineAccountCreditsErrorMessage } = await import(
|
||||
"./cline-account"
|
||||
);
|
||||
expect(
|
||||
isClineAccountCreditsErrorMessage(
|
||||
"Insufficient balance. Your Cline credits balance is $0.00.",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match unrelated errors", async () => {
|
||||
const { isClineAccountCreditsErrorMessage } = await import(
|
||||
"./cline-account"
|
||||
);
|
||||
expect(isClineAccountCreditsErrorMessage("Payment Required")).toBe(false);
|
||||
expect(
|
||||
isClineAccountCreditsErrorMessage(
|
||||
"Your credit balance is too low to access the Anthropic API.",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isClineAccountCreditsErrorMessage("insufficient balance on gateway"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,9 +51,16 @@ export function isClineAccountAuthErrorMessage(message: string): boolean {
|
||||
|
||||
export function isClineAccountCreditsErrorMessage(message: string): boolean {
|
||||
const normalized = message.trim().toLowerCase();
|
||||
// The Cline API's 402 response carries `code: "insufficient_credits"` and
|
||||
// the message "Not enough credits available". Depending on how much of the
|
||||
// payload survives error extraction, the CLI may see the raw JSON blob or
|
||||
// just the human-readable message, so match both. The
|
||||
// "insufficient balance" pair is an older backend phrasing kept for safety.
|
||||
return (
|
||||
normalized.includes("insufficient balance") &&
|
||||
normalized.includes("cline credits balance")
|
||||
normalized.includes("insufficient_credits") ||
|
||||
normalized.includes("not enough credits") ||
|
||||
(normalized.includes("insufficient balance") &&
|
||||
normalized.includes("cline credits balance"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"media-chrome": "^4.18.1",
|
||||
"mermaid": "^11.15.0",
|
||||
"mermaid": "11.16.0",
|
||||
"motion": "^12.38.0",
|
||||
"nanoid": "^5.1.7",
|
||||
"next-themes": "^0.4.6",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Cline Code Desktop Changelog
|
||||
|
||||
## 0.0.3
|
||||
|
||||
- The reasoning section in the chat transcript now reads simply "Thinking" — dropped the redundant status text and brain icon.
|
||||
|
||||
## 0.0.2
|
||||
|
||||
- First public release of Cline Code for macOS: a desktop app for running and inspecting Cline agent sessions, signed and notarized for Apple Silicon and Intel.
|
||||
- Automatic updates: the app checks on launch and every 2 hours, downloads new versions in the background, and prompts for a one-click restart. Ignored updates apply on the next launch.
|
||||
- Download the DMG once from GitHub Releases — every future release arrives automatically.
|
||||
@@ -25,7 +25,21 @@ Tailwind adapter and shared base styles without depending on the desktop
|
||||
runtime. See [`webview/styles/README.md`](./webview/styles/README.md) for the
|
||||
desktop integration notes.
|
||||
|
||||
## Shareable Desktop Packages
|
||||
## Releases & Auto-Updates
|
||||
|
||||
Releases are built, signed, notarized, and published by the `desktop-publish`
|
||||
GitHub workflow. The step-by-step flow (version bumps, changelog, tag, repo
|
||||
secrets) lives in the `publish-desktop` skill
|
||||
(`.cline/skills/publish-desktop/SKILL.md`).
|
||||
|
||||
Installed apps auto-update via the Tauri updater: they poll the rolling
|
||||
`desktop-latest` release's `latest.json` on launch and every 2 hours, install
|
||||
updates in the background, and prompt for a restart. Two things must never be
|
||||
lost: the `desktop-latest` release/tag (its feed URL is baked into shipped
|
||||
apps) and the updater private key (`TAURI_SIGNING_PRIVATE_KEY` — without it,
|
||||
shipped apps can't verify new updates).
|
||||
|
||||
## Shareable Desktop Packages (manual fallback)
|
||||
|
||||
Tauri desktop bundles are OS-specific, so build each package on the target OS:
|
||||
|
||||
@@ -108,6 +122,21 @@ Desktop transport envelope:
|
||||
- `<sessionId>.hooks.jsonl` is observability/debug telemetry and should not be required for normal history replay/export flows.
|
||||
- Full v1 schema for the persisted messages file, including failure/retry semantics and golden fixtures, is documented in [`packages/core/docs/messages-contract-v1.md`](../../../sdk/packages/core/docs/messages-contract-v1.md).
|
||||
|
||||
## Sidecar observability
|
||||
|
||||
The desktop sidecar sends SDK telemetry through the same configured OpenTelemetry
|
||||
pipeline used by the CLI and writes structured runtime logs to
|
||||
`~/.cline/data/logs/code.log` by default. Telemetry continues to honor the global
|
||||
opt-out setting exposed in the desktop settings UI. The sidecar truncates stale
|
||||
logs and rotates the active file before it exceeds 50 MiB.
|
||||
|
||||
Logging can be configured with the same environment variables as the CLI:
|
||||
|
||||
- `CLINE_LOG_ENABLED=0` disables file logging.
|
||||
- `CLINE_LOG_LEVEL` sets the Pino level (for example, `debug` or `warn`).
|
||||
- `CLINE_LOG_PATH` overrides the log destination.
|
||||
- `CLINE_LOG_NAME` overrides the logger name.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If live updates stall, verify the desktop backend websocket is connected and `chat_event` messages are arriving.
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
{
|
||||
"name": "@cline/code",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:ui": "bun -F @cline/ui build",
|
||||
"predev:web": "bun run build:ui",
|
||||
"dev:web": "next dev webview -p 3125 --turbo",
|
||||
"dev:sidecar": "bun run sidecar/index.ts",
|
||||
"dev": "tauri dev",
|
||||
"prebuild": "bun run build:ui",
|
||||
"build": "bun run bun.mts",
|
||||
"build:sidecar": "mkdir -p dist/sidecar && bun build ./sidecar/index.ts --outfile ./dist/sidecar/index.js --target bun",
|
||||
"build:sidecar:bin": "bun run scripts/build-sidecar-bin.ts",
|
||||
@@ -16,7 +19,10 @@
|
||||
"package:desktop:windows": "bun run scripts/package-desktop.ts --platform windows",
|
||||
"package:desktop:linux": "bun run scripts/package-desktop.ts --platform linux",
|
||||
"start": "next start webview",
|
||||
"pretypecheck": "bun run build:ui",
|
||||
"typecheck": "tsc -p tsconfig.dev.json --noEmit",
|
||||
"pretest:chat-ui": "bun run build:ui",
|
||||
"test:chat-ui": "vitest run webview/components/views/chat/chat-messages.test.tsx --config vitest.config.ts",
|
||||
"clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -70,6 +76,7 @@
|
||||
"lucide-react": "^0.564.0",
|
||||
"next": "16.2.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"pino": "^10.3.1",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.4",
|
||||
"react-day-picker": "9.13.2",
|
||||
|
||||
@@ -17,13 +17,33 @@ const resolveTargetTriple = async (): Promise<string> => {
|
||||
return host;
|
||||
};
|
||||
|
||||
// Bun cross-compiles --compile binaries, so a CI runner can produce the
|
||||
// sidecar for a different architecture than its own (e.g. the x86_64 macOS
|
||||
// bundle from an arm64 runner). Without an explicit --target, bun always
|
||||
// emits a host-arch binary even when Tauri is building for another triple.
|
||||
const resolveBunCompileTarget = (targetTriple: string): string | undefined => {
|
||||
if (targetTriple.startsWith("aarch64-apple-darwin"))
|
||||
return "bun-darwin-arm64";
|
||||
if (targetTriple.startsWith("x86_64-apple-darwin")) return "bun-darwin-x64";
|
||||
if (targetTriple.startsWith("x86_64-pc-windows")) return "bun-windows-x64";
|
||||
if (targetTriple.startsWith("x86_64-unknown-linux")) return "bun-linux-x64";
|
||||
if (targetTriple.startsWith("aarch64-unknown-linux"))
|
||||
return "bun-linux-arm64";
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
const targetTriple = await resolveTargetTriple();
|
||||
const extension = targetTriple.includes("windows") ? ".exe" : "";
|
||||
const outfile = `./src-tauri/bin/code-sidecar-${targetTriple}${extension}`;
|
||||
const bunTarget = resolveBunCompileTarget(targetTriple);
|
||||
|
||||
await $`mkdir -p src-tauri/bin`;
|
||||
await $`bun build ./sidecar/index.ts --compile --outfile ${outfile}`;
|
||||
if (bunTarget) {
|
||||
await $`bun build ./sidecar/index.ts --compile --target=${bunTarget} --outfile ${outfile}`;
|
||||
} else {
|
||||
await $`bun build ./sidecar/index.ts --compile --outfile ${outfile}`;
|
||||
}
|
||||
};
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { buildUpdateManifest } from "./generate-update-manifest";
|
||||
|
||||
const makeArtifactDir = (): string => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "update-manifest-"));
|
||||
writeFileSync(path.join(dir, "Cline-Code_0.1.0_aarch64.app.tar.gz"), "tar");
|
||||
writeFileSync(
|
||||
path.join(dir, "Cline-Code_0.1.0_aarch64.app.tar.gz.sig"),
|
||||
"sig-aarch64\n",
|
||||
);
|
||||
writeFileSync(path.join(dir, "Cline-Code_0.1.0_x86_64.app.tar.gz"), "tar");
|
||||
writeFileSync(
|
||||
path.join(dir, "Cline-Code_0.1.0_x86_64.app.tar.gz.sig"),
|
||||
"sig-x86_64\n",
|
||||
);
|
||||
writeFileSync(path.join(dir, "Cline-Code_0.1.0_aarch64.dmg"), "dmg");
|
||||
return dir;
|
||||
};
|
||||
|
||||
describe("buildUpdateManifest", () => {
|
||||
test("maps updater artifacts to darwin platform entries", () => {
|
||||
const dir = makeArtifactDir();
|
||||
const manifest = buildUpdateManifest({
|
||||
version: "0.1.0",
|
||||
tag: "desktop-v0.1.0",
|
||||
dir,
|
||||
repo: "cline/cline",
|
||||
notes: "notes",
|
||||
pubDate: "2026-07-21T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(manifest.version).toBe("0.1.0");
|
||||
expect(manifest.platforms["darwin-aarch64"]).toEqual({
|
||||
signature: "sig-aarch64",
|
||||
url: "https://github.com/cline/cline/releases/download/desktop-v0.1.0/Cline-Code_0.1.0_aarch64.app.tar.gz",
|
||||
});
|
||||
expect(manifest.platforms["darwin-x86_64"]).toEqual({
|
||||
signature: "sig-x86_64",
|
||||
url: "https://github.com/cline/cline/releases/download/desktop-v0.1.0/Cline-Code_0.1.0_x86_64.app.tar.gz",
|
||||
});
|
||||
// The DMG is a first-install artifact, not an updater artifact.
|
||||
expect(Object.keys(manifest.platforms)).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("throws when a signature file is missing", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "update-manifest-"));
|
||||
writeFileSync(path.join(dir, "Cline-Code_0.1.0_aarch64.app.tar.gz"), "tar");
|
||||
expect(() =>
|
||||
buildUpdateManifest({
|
||||
version: "0.1.0",
|
||||
tag: "desktop-v0.1.0",
|
||||
dir,
|
||||
repo: "cline/cline",
|
||||
notes: "notes",
|
||||
pubDate: "2026-07-21T00:00:00.000Z",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("throws when no updater artifacts exist", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "update-manifest-"));
|
||||
writeFileSync(path.join(dir, "Cline-Code_0.1.0_aarch64.dmg"), "dmg");
|
||||
expect(() =>
|
||||
buildUpdateManifest({
|
||||
version: "0.1.0",
|
||||
tag: "desktop-v0.1.0",
|
||||
dir,
|
||||
repo: "cline/cline",
|
||||
notes: "notes",
|
||||
pubDate: "2026-07-21T00:00:00.000Z",
|
||||
}),
|
||||
).toThrow(/no updater artifacts/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
// Generates the Tauri updater manifest (latest.json) from the updater
|
||||
// artifacts produced by the desktop-publish workflow. The manifest is uploaded
|
||||
// to the rolling `desktop-latest` GitHub release, which is the static endpoint
|
||||
// configured in src-tauri/tauri.conf.json; its platform URLs point back at the
|
||||
// immutable per-version release assets.
|
||||
//
|
||||
// Usage:
|
||||
// bun scripts/generate-update-manifest.ts \
|
||||
// --version 0.1.0 --tag desktop-v0.1.0 --dir dist/desktop \
|
||||
// --out dist/desktop/latest.json [--repo cline/cline] [--notes-file notes.md]
|
||||
|
||||
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
type UpdaterPlatformEntry = {
|
||||
signature: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type UpdateManifest = {
|
||||
version: string;
|
||||
notes: string;
|
||||
pub_date: string;
|
||||
platforms: Record<string, UpdaterPlatformEntry>;
|
||||
};
|
||||
|
||||
// Maps the arch token embedded in artifact file names (see the "Collect
|
||||
// artifacts" workflow step) to the platform keys the Tauri updater requests.
|
||||
const PLATFORM_KEY_BY_ARCH_SUFFIX: Record<string, string> = {
|
||||
aarch64: "darwin-aarch64",
|
||||
x86_64: "darwin-x86_64",
|
||||
};
|
||||
|
||||
const getArgValue = (args: string[], name: string): string | undefined => {
|
||||
const index = args.indexOf(name);
|
||||
if (index >= 0 && args[index + 1] && !args[index + 1].startsWith("--")) {
|
||||
return args[index + 1];
|
||||
}
|
||||
const prefix = `${name}=`;
|
||||
const inline = args.find((arg) => arg.startsWith(prefix));
|
||||
return inline?.slice(prefix.length);
|
||||
};
|
||||
|
||||
const archOfUpdaterArtifact = (fileName: string): string | undefined => {
|
||||
if (!fileName.endsWith(".app.tar.gz")) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.keys(PLATFORM_KEY_BY_ARCH_SUFFIX).find((arch) =>
|
||||
fileName.includes(`_${arch}`),
|
||||
);
|
||||
};
|
||||
|
||||
export const buildUpdateManifest = (options: {
|
||||
version: string;
|
||||
tag: string;
|
||||
dir: string;
|
||||
repo: string;
|
||||
notes: string;
|
||||
pubDate: string;
|
||||
}): UpdateManifest => {
|
||||
const platforms: Record<string, UpdaterPlatformEntry> = {};
|
||||
|
||||
for (const fileName of readdirSync(options.dir).sort()) {
|
||||
const arch = archOfUpdaterArtifact(fileName);
|
||||
if (!arch) {
|
||||
continue;
|
||||
}
|
||||
const signaturePath = path.join(options.dir, `${fileName}.sig`);
|
||||
const signature = readFileSync(signaturePath, "utf8").trim();
|
||||
if (!signature) {
|
||||
throw new Error(`empty updater signature at ${signaturePath}`);
|
||||
}
|
||||
platforms[PLATFORM_KEY_BY_ARCH_SUFFIX[arch]] = {
|
||||
signature,
|
||||
url: `https://github.com/${options.repo}/releases/download/${options.tag}/${encodeURIComponent(fileName)}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (Object.keys(platforms).length === 0) {
|
||||
throw new Error(
|
||||
`no updater artifacts (*.app.tar.gz with a known arch suffix) found in ${options.dir}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
version: options.version,
|
||||
notes: options.notes,
|
||||
pub_date: options.pubDate,
|
||||
platforms,
|
||||
};
|
||||
};
|
||||
|
||||
const main = () => {
|
||||
const args = process.argv.slice(2);
|
||||
const version = getArgValue(args, "--version");
|
||||
const tag = getArgValue(args, "--tag");
|
||||
const dir = getArgValue(args, "--dir");
|
||||
const out = getArgValue(args, "--out");
|
||||
const repo =
|
||||
getArgValue(args, "--repo") ??
|
||||
process.env.GITHUB_REPOSITORY ??
|
||||
"cline/cline";
|
||||
const notesFile = getArgValue(args, "--notes-file");
|
||||
|
||||
if (!version || !tag || !dir || !out) {
|
||||
throw new Error(
|
||||
"usage: generate-update-manifest.ts --version X.Y.Z --tag desktop-vX.Y.Z --dir <artifact dir> --out <latest.json> [--repo owner/repo] [--notes-file <file>]",
|
||||
);
|
||||
}
|
||||
|
||||
const notes = notesFile
|
||||
? readFileSync(notesFile, "utf8").trim()
|
||||
: `Cline Code v${version}`;
|
||||
|
||||
const manifest = buildUpdateManifest({
|
||||
version,
|
||||
tag,
|
||||
dir,
|
||||
repo,
|
||||
notes,
|
||||
pubDate: new Date().toISOString(),
|
||||
});
|
||||
|
||||
writeFileSync(out, `${JSON.stringify(manifest, null, "\t")}\n`);
|
||||
console.log(`wrote ${out}`);
|
||||
for (const [platform, entry] of Object.entries(manifest.platforms)) {
|
||||
console.log(`- ${platform}: ${entry.url}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (import.meta.main) {
|
||||
main();
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
import { rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildSessionConnectionUpdate,
|
||||
consumeWorkspaceMetadata,
|
||||
handleChatSessionCommand,
|
||||
hasProviderChanged,
|
||||
mergeSessionConfig,
|
||||
prewarmWorkspaceMetadata,
|
||||
shouldUpdateSessionConnection,
|
||||
WORKSPACE_METADATA_PREWARM_TTL_MS,
|
||||
@@ -87,6 +92,42 @@ describe("shouldUpdateSessionConnection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasProviderChanged", () => {
|
||||
it("distinguishes provider switches from model switches", () => {
|
||||
expect(
|
||||
hasProviderChanged(
|
||||
{ provider: "cline", model: "anthropic/claude-sonnet-4.6" },
|
||||
{ provider: "openai-codex", model: "gpt-5.3-codex" },
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasProviderChanged(
|
||||
{ provider: "cline", model: "anthropic/claude-sonnet-4.6" },
|
||||
{ provider: "cline", model: "openai/gpt-5.3-codex" },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("honors a providerId-only update when the stored config uses provider", () => {
|
||||
const current = {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
};
|
||||
const update = {
|
||||
providerId: "openai-codex",
|
||||
modelId: "gpt-5.3-codex",
|
||||
};
|
||||
|
||||
expect(hasProviderChanged(current, update)).toBe(true);
|
||||
expect(mergeSessionConfig(current, update)).toMatchObject({
|
||||
provider: "openai-codex",
|
||||
providerId: "openai-codex",
|
||||
model: "gpt-5.3-codex",
|
||||
modelId: "gpt-5.3-codex",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("first-send connection updates", () => {
|
||||
const baseConfig = {
|
||||
provider: "cline",
|
||||
@@ -105,7 +146,14 @@ describe("first-send connection updates", () => {
|
||||
finishReason: "completed",
|
||||
messages: [],
|
||||
}));
|
||||
const readMessages = vi.fn(async () => [
|
||||
{ role: "user", content: "first prompt" },
|
||||
{ role: "assistant", content: "first response" },
|
||||
]);
|
||||
const readSessionCompactionState = vi.fn(async () => undefined);
|
||||
const stop = vi.fn(async () => undefined);
|
||||
const sessionId = "session-connection-test";
|
||||
const start = vi.fn(async (_input?: unknown) => ({ sessionId }));
|
||||
const ctx = {
|
||||
liveSessions: new Map([
|
||||
[
|
||||
@@ -121,9 +169,29 @@ describe("first-send connection updates", () => {
|
||||
},
|
||||
],
|
||||
]),
|
||||
sessionManager: { send, updateSessionConnection },
|
||||
streamIndices: new Map(),
|
||||
wsClients: new Set(),
|
||||
sessionManager: {
|
||||
readMessages,
|
||||
readSessionCompactionState,
|
||||
send,
|
||||
start,
|
||||
stop,
|
||||
updateSessionConnection,
|
||||
pendingPrompts: {
|
||||
list: vi.fn(async () => []),
|
||||
},
|
||||
},
|
||||
} as unknown as SidecarContext;
|
||||
return { ctx, send, sessionId, updateSessionConnection };
|
||||
return {
|
||||
ctx,
|
||||
readMessages,
|
||||
send,
|
||||
sessionId,
|
||||
start,
|
||||
stop,
|
||||
updateSessionConnection,
|
||||
};
|
||||
}
|
||||
|
||||
it("skips an identical update for a locally-created session", async () => {
|
||||
@@ -158,6 +226,265 @@ describe("first-send connection updates", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rebuilds the same session with its transcript before a provider switch", async () => {
|
||||
const {
|
||||
ctx,
|
||||
readMessages,
|
||||
send,
|
||||
sessionId,
|
||||
start,
|
||||
stop,
|
||||
updateSessionConnection,
|
||||
} = createContext();
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "continue with Codex",
|
||||
config: {
|
||||
...baseConfig,
|
||||
provider: "openai-codex",
|
||||
model: "gpt-5.3-codex",
|
||||
},
|
||||
});
|
||||
|
||||
expect(readMessages).toHaveBeenCalledWith(sessionId);
|
||||
expect(stop).toHaveBeenCalledWith(sessionId);
|
||||
expect(start).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
providerId: "openai-codex",
|
||||
modelId: "gpt-5.3-codex",
|
||||
sessionId,
|
||||
}),
|
||||
initialMessages: [
|
||||
{ role: "user", content: "first prompt" },
|
||||
{ role: "assistant", content: "first response" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(updateSessionConnection).toHaveBeenCalledWith(sessionId, {
|
||||
providerId: "openai-codex",
|
||||
modelId: "gpt-5.3-codex",
|
||||
thinking: true,
|
||||
reasoningEffort: "high",
|
||||
thinkingBudgetTokens: null,
|
||||
});
|
||||
expect(start.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
send.mock.invocationCallOrder[0] ?? 0,
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks a concurrent send throughout provider-switch preparation", async () => {
|
||||
let resolveMessages:
|
||||
| ((messages: Array<{ role: string; content: string }>) => void)
|
||||
| undefined;
|
||||
const messages = new Promise<Array<{ role: string; content: string }>>(
|
||||
(resolve) => {
|
||||
resolveMessages = resolve;
|
||||
},
|
||||
);
|
||||
const { ctx, readMessages, sessionId } = createContext();
|
||||
readMessages.mockImplementationOnce(async () => await messages);
|
||||
|
||||
const switching = handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "continue with Codex",
|
||||
config: {
|
||||
...baseConfig,
|
||||
provider: "openai-codex",
|
||||
model: "gpt-5.3-codex",
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => expect(readMessages).toHaveBeenCalledOnce());
|
||||
|
||||
await expect(
|
||||
handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "racing prompt",
|
||||
config: { ...baseConfig },
|
||||
}),
|
||||
).rejects.toThrow("A provider switch is already in progress");
|
||||
|
||||
resolveMessages?.([
|
||||
{ role: "user", content: "first prompt" },
|
||||
{ role: "assistant", content: "first response" },
|
||||
]);
|
||||
await switching;
|
||||
});
|
||||
|
||||
it.each([
|
||||
"queue",
|
||||
"steer",
|
||||
] as const)("locks provider-switch preparation for explicit %s delivery", async (delivery) => {
|
||||
let resolveMessages:
|
||||
| ((messages: Array<{ role: string; content: string }>) => void)
|
||||
| undefined;
|
||||
const messages = new Promise<Array<{ role: string; content: string }>>(
|
||||
(resolve) => {
|
||||
resolveMessages = resolve;
|
||||
},
|
||||
);
|
||||
const { ctx, readMessages, sessionId } = createContext();
|
||||
readMessages.mockImplementationOnce(async () => await messages);
|
||||
|
||||
const switching = handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "queue this for Codex",
|
||||
delivery,
|
||||
config: {
|
||||
...baseConfig,
|
||||
provider: "openai-codex",
|
||||
model: "gpt-5.3-codex",
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => expect(readMessages).toHaveBeenCalledOnce());
|
||||
|
||||
await expect(
|
||||
handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "racing prompt",
|
||||
config: { ...baseConfig },
|
||||
}),
|
||||
).rejects.toThrow("A provider switch is already in progress");
|
||||
|
||||
resolveMessages?.([
|
||||
{ role: "user", content: "first prompt" },
|
||||
{ role: "assistant", content: "first response" },
|
||||
]);
|
||||
await switching;
|
||||
});
|
||||
|
||||
it("restores the previous provider runtime when replacement startup fails", async () => {
|
||||
const { ctx, send, sessionId, start, stop } = createContext();
|
||||
const previousKanbanDataDir = process.env.CLINE_KANBAN_DATA_DIR;
|
||||
const testKanbanDataDir = join(
|
||||
tmpdir(),
|
||||
`cline-provider-rollback-${process.pid}`,
|
||||
);
|
||||
process.env.CLINE_KANBAN_DATA_DIR = testKanbanDataDir;
|
||||
start
|
||||
.mockRejectedValueOnce(new Error("Codex bootstrap failed"))
|
||||
.mockResolvedValueOnce({ sessionId });
|
||||
|
||||
try {
|
||||
const result = (await handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "continue with Codex",
|
||||
config: {
|
||||
...baseConfig,
|
||||
provider: "openai-codex",
|
||||
model: "gpt-5.3-codex",
|
||||
},
|
||||
})) as { result?: { finishReason?: string; text?: string } };
|
||||
|
||||
expect(stop).toHaveBeenCalledOnce();
|
||||
expect(start).toHaveBeenCalledTimes(2);
|
||||
expect(start.mock.calls[1]?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
providerId: "cline",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
sessionId,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
expect(result.result).toEqual({
|
||||
finishReason: "error",
|
||||
text: "Codex bootstrap failed",
|
||||
});
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "continue with Cline",
|
||||
config: { ...baseConfig },
|
||||
});
|
||||
expect(send).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
if (previousKanbanDataDir === undefined) {
|
||||
delete process.env.CLINE_KANBAN_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_KANBAN_DATA_DIR = previousKanbanDataDir;
|
||||
}
|
||||
rmSync(testKanbanDataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("restores the previous provider when replacement label sync fails", async () => {
|
||||
const { ctx, send, sessionId, start, stop, updateSessionConnection } =
|
||||
createContext();
|
||||
const previousKanbanDataDir = process.env.CLINE_KANBAN_DATA_DIR;
|
||||
const testKanbanDataDir = join(
|
||||
tmpdir(),
|
||||
`cline-provider-label-rollback-${process.pid}`,
|
||||
);
|
||||
process.env.CLINE_KANBAN_DATA_DIR = testKanbanDataDir;
|
||||
try {
|
||||
updateSessionConnection
|
||||
.mockRejectedValueOnce(new Error("manifest write failed"))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
const result = (await handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "continue with Codex",
|
||||
config: {
|
||||
...baseConfig,
|
||||
provider: "openai-codex",
|
||||
model: "gpt-5.3-codex",
|
||||
},
|
||||
})) as { result?: { finishReason?: string; text?: string } };
|
||||
|
||||
expect(stop).toHaveBeenCalledTimes(2);
|
||||
expect(start).toHaveBeenCalledTimes(2);
|
||||
expect(start.mock.calls[1]?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
providerId: "cline",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
sessionId,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(updateSessionConnection).toHaveBeenNthCalledWith(2, sessionId, {
|
||||
providerId: "cline",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
thinking: true,
|
||||
reasoningEffort: "high",
|
||||
thinkingBudgetTokens: null,
|
||||
});
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
expect(result.result).toEqual({
|
||||
finishReason: "error",
|
||||
text: "manifest write failed",
|
||||
});
|
||||
expect(ctx.liveSessions.get(sessionId)?.config).toEqual(baseConfig);
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "continue with Cline",
|
||||
config: { ...baseConfig },
|
||||
});
|
||||
expect(send).toHaveBeenCalledOnce();
|
||||
expect(start).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
if (previousKanbanDataDir === undefined) {
|
||||
delete process.env.CLINE_KANBAN_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_KANBAN_DATA_DIR = previousKanbanDataDir;
|
||||
}
|
||||
rmSync(testKanbanDataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("refreshes hub-attached sessions even when the cached config matches", async () => {
|
||||
const { ctx, sessionId, updateSessionConnection } = createContext({
|
||||
attachedViaHub: true,
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
buildWorkspaceMetadata,
|
||||
type ClineCore,
|
||||
type CoreSessionConfig,
|
||||
createSessionCompactionState,
|
||||
projectSessionCompactionState,
|
||||
type SessionCompactionState,
|
||||
type SessionPendingPrompt,
|
||||
SessionSource,
|
||||
splitCoreSessionConfig,
|
||||
@@ -212,6 +215,9 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
|
||||
modelId: config.model ?? config.modelId ?? "",
|
||||
mode: config.mode ?? "act",
|
||||
apiKey: config.apiKey ?? config.api_key ?? "",
|
||||
baseUrl: config.baseUrl,
|
||||
headers: config.headers,
|
||||
providerConfig: config.providerConfig,
|
||||
workspaceRoot: config.workspaceRoot ?? config.workspace_root ?? "",
|
||||
cwd: config.cwd ?? config.workspaceRoot ?? config.workspace_root ?? "",
|
||||
systemPrompt: config.systemPrompt ?? config.system_prompt ?? "",
|
||||
@@ -293,6 +299,54 @@ export function shouldUpdateSessionConnection(
|
||||
);
|
||||
}
|
||||
|
||||
function readAliasedString(
|
||||
config: JsonRecord,
|
||||
primaryKey: string,
|
||||
aliasKey: string,
|
||||
): string | undefined {
|
||||
for (const key of [primaryKey, aliasKey]) {
|
||||
if (!Object.hasOwn(config, key)) continue;
|
||||
const value = String(config[key] ?? "").trim();
|
||||
return value || undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function mergeSessionConfig(
|
||||
currentConfig: JsonRecord,
|
||||
updates: JsonRecord,
|
||||
): JsonRecord {
|
||||
const providerId =
|
||||
readAliasedString(updates, "provider", "providerId") ??
|
||||
readAliasedString(currentConfig, "provider", "providerId");
|
||||
const modelId =
|
||||
readAliasedString(updates, "model", "modelId") ??
|
||||
readAliasedString(currentConfig, "model", "modelId");
|
||||
return {
|
||||
...currentConfig,
|
||||
...updates,
|
||||
...(providerId ? { provider: providerId, providerId } : {}),
|
||||
...(modelId ? { model: modelId, modelId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function hasProviderChanged(
|
||||
currentConfig: JsonRecord,
|
||||
nextConfig: JsonRecord,
|
||||
): boolean {
|
||||
const currentProviderId = readAliasedString(
|
||||
currentConfig,
|
||||
"provider",
|
||||
"providerId",
|
||||
);
|
||||
const nextProviderId = readAliasedString(
|
||||
nextConfig,
|
||||
"provider",
|
||||
"providerId",
|
||||
);
|
||||
return nextProviderId !== undefined && currentProviderId !== nextProviderId;
|
||||
}
|
||||
|
||||
async function resolveSystemPrompt(config: JsonRecord): Promise<string> {
|
||||
const cwd = String(
|
||||
config.cwd ?? config.workspaceRoot ?? config.workspace_root ?? "",
|
||||
@@ -412,9 +466,10 @@ async function handleStart(
|
||||
// the frontend call the separate "send" action to dispatch the prompt.
|
||||
// This avoids a double-execution bug where start() would run the turn AND
|
||||
// the subsequent manager.send() fire-and-forget would run it again.
|
||||
console.error(
|
||||
`[sidecar:handleStart] calling manager.start provider=${coreConfig.providerId} model=${coreConfig.modelId}`,
|
||||
);
|
||||
ctx.logger?.log("Starting desktop chat session", {
|
||||
providerId: String(coreConfig.providerId ?? ""),
|
||||
modelId: String(coreConfig.modelId ?? ""),
|
||||
});
|
||||
const startResult = await manager.start({
|
||||
...splitCoreSessionConfig(coreConfig as unknown as CoreSessionConfig),
|
||||
source: SessionSource.DESKTOP,
|
||||
@@ -425,7 +480,7 @@ async function handleStart(
|
||||
toolPolicies: resolveToolPolicies(request.config),
|
||||
});
|
||||
const sessionId = startResult.sessionId;
|
||||
console.error(`[sidecar:handleStart] session started sessionId=${sessionId}`);
|
||||
ctx.logger?.log("Desktop chat session started", { sessionId });
|
||||
const session = createLiveSession(request.config, {
|
||||
messages: initialMessages,
|
||||
prompt: initialMessages
|
||||
@@ -510,6 +565,114 @@ async function handleAttach(
|
||||
};
|
||||
}
|
||||
|
||||
async function startRebuiltSession(
|
||||
manager: ClineCore,
|
||||
sessionId: string,
|
||||
config: JsonRecord,
|
||||
systemPrompt: string,
|
||||
messages: Message[],
|
||||
compactionState: SessionCompactionState | undefined,
|
||||
): Promise<void> {
|
||||
const projectedMessages = compactionState
|
||||
? projectSessionCompactionState(compactionState, messages)
|
||||
: undefined;
|
||||
const restarted = await manager.start({
|
||||
...splitCoreSessionConfig(
|
||||
buildCoreSessionConfig({
|
||||
...config,
|
||||
sessionId,
|
||||
systemPrompt,
|
||||
}) as unknown as CoreSessionConfig,
|
||||
),
|
||||
source: SessionSource.DESKTOP,
|
||||
interactive: true,
|
||||
initialMessages: messages,
|
||||
...(projectedMessages
|
||||
? {
|
||||
initialCompactionState: createSessionCompactionState({
|
||||
sourceMessages: messages,
|
||||
compactedMessages: projectedMessages,
|
||||
systemPrompt: compactionState?.system_prompt,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
toolPolicies: resolveToolPolicies(config),
|
||||
});
|
||||
if (restarted.sessionId !== sessionId) {
|
||||
throw new Error(
|
||||
`Provider switch changed session id from ${sessionId} to ${restarted.sessionId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function rebuildSessionForProviderChange(
|
||||
ctx: SidecarContext,
|
||||
manager: ClineCore,
|
||||
sessionId: string,
|
||||
previousConfig: JsonRecord,
|
||||
nextConfig: JsonRecord,
|
||||
): Promise<void> {
|
||||
const [messages, compactionState, previousSystemPrompt, nextSystemPrompt] =
|
||||
await Promise.all([
|
||||
manager.readMessages(sessionId),
|
||||
manager.readSessionCompactionState(sessionId).catch((error) => {
|
||||
ctx.logger?.log?.("Failed to read desktop session compaction state", {
|
||||
sessionId,
|
||||
error,
|
||||
severity: "warn",
|
||||
});
|
||||
return undefined;
|
||||
}),
|
||||
resolveSystemPrompt(previousConfig),
|
||||
resolveSystemPrompt(nextConfig),
|
||||
]);
|
||||
|
||||
await manager.stop(sessionId);
|
||||
let replacementStarted = false;
|
||||
try {
|
||||
await startRebuiltSession(
|
||||
manager,
|
||||
sessionId,
|
||||
nextConfig,
|
||||
nextSystemPrompt,
|
||||
messages,
|
||||
compactionState,
|
||||
);
|
||||
replacementStarted = true;
|
||||
// Reusing a session id preserves its existing manifest. Treat refreshing
|
||||
// its connection label as part of the replacement transaction so a
|
||||
// persistence failure cannot leave runtime and cached state diverged.
|
||||
await manager.updateSessionConnection(
|
||||
sessionId,
|
||||
buildSessionConnectionUpdate(nextConfig),
|
||||
);
|
||||
} catch (replacementError) {
|
||||
try {
|
||||
if (replacementStarted) {
|
||||
await manager.stop(sessionId);
|
||||
}
|
||||
await startRebuiltSession(
|
||||
manager,
|
||||
sessionId,
|
||||
previousConfig,
|
||||
previousSystemPrompt,
|
||||
messages,
|
||||
compactionState,
|
||||
);
|
||||
await manager.updateSessionConnection(
|
||||
sessionId,
|
||||
buildSessionConnectionUpdate(previousConfig),
|
||||
);
|
||||
} catch (rollbackError) {
|
||||
throw new AggregateError(
|
||||
[replacementError, rollbackError],
|
||||
"Provider switch and rollback both failed",
|
||||
);
|
||||
}
|
||||
throw replacementError;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSend(
|
||||
ctx: SidecarContext,
|
||||
request: ChatSessionCommandRequest,
|
||||
@@ -520,70 +683,101 @@ async function handleSend(
|
||||
if (!prompt) throw new Error("prompt is required");
|
||||
const manager = getSessionManager(ctx);
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (request.config) {
|
||||
if (
|
||||
!session ||
|
||||
session.attachedViaHub ||
|
||||
shouldUpdateSessionConnection(session.config, request.config)
|
||||
) {
|
||||
const connectionUpdate = buildSessionConnectionUpdate(request.config);
|
||||
await manager.updateSessionConnection(sessionId, connectionUpdate);
|
||||
}
|
||||
if (session) {
|
||||
session.config = { ...session.config, ...request.config };
|
||||
}
|
||||
if (session?.transitioningProvider) {
|
||||
throw new Error("A provider switch is already in progress");
|
||||
}
|
||||
|
||||
// Determine effective delivery mode.
|
||||
// When the session is busy and no explicit delivery was requested, queue it
|
||||
// via Core so that Core's own pending-prompts mechanism handles draining.
|
||||
// This avoids a sidecar-only local queue that never calls manager.send().
|
||||
let delivery = request.delivery;
|
||||
if (!delivery && session?.busy) {
|
||||
delivery = "queue";
|
||||
}
|
||||
|
||||
if (delivery === "queue") {
|
||||
if (session) {
|
||||
session.prompt = prompt;
|
||||
}
|
||||
// Delegate queuing to Core — it will drain the prompt once the current
|
||||
// turn finishes and emit pending_prompts / pending_prompt_submitted events.
|
||||
await manager.send({
|
||||
sessionId,
|
||||
prompt,
|
||||
delivery: "queue",
|
||||
userImages: request.attachments?.userImages,
|
||||
});
|
||||
const prompts = await manager.pendingPrompts.list({ sessionId });
|
||||
return {
|
||||
sessionId,
|
||||
ok: true,
|
||||
queued: true,
|
||||
promptsInQueue: applyPendingPrompts(ctx, sessionId, prompts),
|
||||
};
|
||||
const nextConfig = request.config
|
||||
? mergeSessionConfig(session?.config ?? {}, request.config)
|
||||
: undefined;
|
||||
const providerChanged = Boolean(
|
||||
session &&
|
||||
request.config &&
|
||||
hasProviderChanged(session.config, request.config),
|
||||
);
|
||||
if (providerChanged && session?.busy) {
|
||||
throw new Error("Cannot switch providers while a turn is running");
|
||||
}
|
||||
|
||||
const ownsBusyState = Boolean(
|
||||
session && delivery !== "queue" && delivery !== "steer",
|
||||
);
|
||||
if (session) {
|
||||
session.prompt = prompt;
|
||||
session.busy = true;
|
||||
session.status = "running";
|
||||
if (ownsBusyState) {
|
||||
session.prompt = prompt;
|
||||
session.busy = true;
|
||||
session.status = "running";
|
||||
}
|
||||
if (providerChanged) {
|
||||
session.transitioningProvider = true;
|
||||
}
|
||||
}
|
||||
try {
|
||||
console.error(
|
||||
`[sidecar:handleSend] calling manager.send sessionId=${sessionId} prompt=${prompt.slice(0, 80)}`,
|
||||
);
|
||||
if (request.config && nextConfig) {
|
||||
if (providerChanged && session) {
|
||||
await rebuildSessionForProviderChange(
|
||||
ctx,
|
||||
manager,
|
||||
sessionId,
|
||||
session.config,
|
||||
nextConfig,
|
||||
);
|
||||
} else if (
|
||||
!session ||
|
||||
session.attachedViaHub ||
|
||||
shouldUpdateSessionConnection(session.config, nextConfig)
|
||||
) {
|
||||
await manager.updateSessionConnection(
|
||||
sessionId,
|
||||
buildSessionConnectionUpdate(nextConfig),
|
||||
);
|
||||
}
|
||||
if (session) {
|
||||
session.config = nextConfig;
|
||||
if (providerChanged) {
|
||||
session.attachedViaHub = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (delivery === "queue") {
|
||||
if (session) {
|
||||
session.prompt = prompt;
|
||||
}
|
||||
await manager.send({
|
||||
sessionId,
|
||||
prompt,
|
||||
delivery: "queue",
|
||||
userImages: request.attachments?.userImages,
|
||||
});
|
||||
const prompts = await manager.pendingPrompts.list({ sessionId });
|
||||
return {
|
||||
sessionId,
|
||||
ok: true,
|
||||
queued: true,
|
||||
promptsInQueue: applyPendingPrompts(ctx, sessionId, prompts),
|
||||
};
|
||||
}
|
||||
|
||||
ctx.logger?.debug("Sending desktop chat prompt", {
|
||||
sessionId,
|
||||
promptLength: prompt.length,
|
||||
delivery,
|
||||
});
|
||||
const result = await manager.send({
|
||||
sessionId,
|
||||
prompt,
|
||||
delivery,
|
||||
userImages: request.attachments?.userImages,
|
||||
});
|
||||
console.error(
|
||||
`[sidecar:handleSend] manager.send resolved sessionId=${sessionId} finishReason=${result?.finishReason} textLen=${result?.text?.length ?? 0}`,
|
||||
);
|
||||
if (session) {
|
||||
session.busy = false;
|
||||
ctx.logger?.log("Desktop chat prompt completed", {
|
||||
sessionId,
|
||||
finishReason: result?.finishReason,
|
||||
textLength: result?.text?.length ?? 0,
|
||||
});
|
||||
if (session && ownsBusyState) {
|
||||
session.status = "idle";
|
||||
if (result?.messages) session.messages = result.messages as unknown[];
|
||||
}
|
||||
@@ -602,11 +796,8 @@ async function handleSend(
|
||||
: undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[sidecar:handleSend] manager.send THREW sessionId=${sessionId} error=${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
if (session) {
|
||||
session.busy = false;
|
||||
ctx.logger?.error?.("Desktop chat prompt failed", { sessionId, error });
|
||||
if (session && ownsBusyState) {
|
||||
session.status = "error";
|
||||
}
|
||||
emitChunk(
|
||||
@@ -626,6 +817,15 @@ async function handleSend(
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
if (session) {
|
||||
if (ownsBusyState) {
|
||||
session.busy = false;
|
||||
}
|
||||
if (providerChanged) {
|
||||
session.transitioningProvider = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
rmSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { basename, dirname, extname, join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, dirname, extname, isAbsolute, join } from "node:path";
|
||||
import type {
|
||||
ClineAccountActionRequest,
|
||||
ProviderCapability,
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
markLocalProviderEnabled,
|
||||
normalizeOAuthProvider,
|
||||
ProviderSettingsManager,
|
||||
RuntimeOAuthTokenManager,
|
||||
readGlobalSettings,
|
||||
resolveLocalClineAuthToken,
|
||||
resolvePluginConfigSearchPaths,
|
||||
@@ -51,6 +53,7 @@ import {
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
|
||||
import packageJson from "../package.json";
|
||||
import {
|
||||
connectorChannelsPayload,
|
||||
startConnectorChannel,
|
||||
@@ -82,6 +85,51 @@ import type {
|
||||
SidecarContext,
|
||||
} from "./types";
|
||||
|
||||
function openUrlInDefaultBrowser(url: string): Promise<void> {
|
||||
const platform = process.platform;
|
||||
// On Windows the URL must not pass through cmd.exe: `cmd /c start <url>`
|
||||
// re-parses metacharacters (&, ^, |) that are valid inside http(s) URLs,
|
||||
// turning a crafted URL into command execution. rundll32 hands the URL
|
||||
// straight to the protocol handler with no shell parsing.
|
||||
const spawned =
|
||||
platform === "darwin"
|
||||
? spawn("open", [url], { stdio: "ignore", detached: true })
|
||||
: platform === "win32"
|
||||
? spawn("rundll32", ["url.dll,FileProtocolHandler", url], {
|
||||
stdio: "ignore",
|
||||
detached: true,
|
||||
})
|
||||
: spawn("xdg-open", [url], {
|
||||
stdio: "ignore",
|
||||
detached: true,
|
||||
});
|
||||
// A missing opener binary emits an async "error" event; without a listener
|
||||
// it becomes an uncaught exception that kills the sidecar. Launchers hand
|
||||
// off to the browser and exit quickly, so a fast non-zero exit means the
|
||||
// handoff failed (xdg-open exits 3 when no handler is available; rundll32
|
||||
// exits 0 even on failure, so Windows stays best-effort). If the launcher
|
||||
// is still running after the grace window, assume the handoff worked
|
||||
// rather than blocking on a launcher that lingers.
|
||||
return new Promise((resolve, reject) => {
|
||||
const graceTimer = setTimeout(resolve, 2_000);
|
||||
spawned.once("spawn", () => {
|
||||
spawned.unref();
|
||||
});
|
||||
spawned.once("error", (error) => {
|
||||
clearTimeout(graceTimer);
|
||||
reject(new Error(`could not open browser: ${error.message}`));
|
||||
});
|
||||
spawned.once("exit", (code) => {
|
||||
clearTimeout(graceTimer);
|
||||
if (code === 0 || code === null) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`browser opener exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function readProviderSettingsUpdate(
|
||||
args: Record<string, unknown> | undefined,
|
||||
): Partial<Omit<SaveProviderSettingsActionRequest, "action" | "providerId">> {
|
||||
@@ -109,8 +157,13 @@ function readMcpServersResponse(): JsonRecord {
|
||||
record.transport && typeof record.transport === "object"
|
||||
? (record.transport as JsonRecord)
|
||||
: undefined;
|
||||
const rawTransportType =
|
||||
transport?.type ?? record.transportType ?? record.type;
|
||||
const transportType = String(
|
||||
transport?.type ?? record.transportType ?? record.type ?? "stdio",
|
||||
rawTransportType ??
|
||||
(typeof transport?.url === "string" || typeof record.url === "string"
|
||||
? "sse"
|
||||
: "stdio"),
|
||||
).trim();
|
||||
return {
|
||||
name,
|
||||
@@ -157,6 +210,31 @@ function readMcpServersResponse(): JsonRecord {
|
||||
return { settingsPath, hasSettingsFile: true, servers: entries };
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport type + URL a server record actually points at, tolerating both
|
||||
* the nested `transport` shape and legacy flat fields (mirrors
|
||||
* readMcpServersResponse).
|
||||
*/
|
||||
function mcpTransportIdentity(record: JsonRecord): string {
|
||||
const transport =
|
||||
record.transport && typeof record.transport === "object"
|
||||
? (record.transport as JsonRecord)
|
||||
: undefined;
|
||||
const url =
|
||||
typeof transport?.url === "string"
|
||||
? transport.url
|
||||
: typeof record.url === "string"
|
||||
? record.url
|
||||
: "";
|
||||
// Core's config-loader defaults a URL-based legacy record with no explicit
|
||||
// type to SSE and maps the legacy "http" alias to streamableHttp; mirror
|
||||
// both so an unchanged endpoint keeps the same identity.
|
||||
const rawType = transport?.type ?? record.transportType ?? record.type;
|
||||
const type = String(rawType ?? (url ? "sse" : "stdio")).trim();
|
||||
const normalizedType = type === "http" ? "streamableHttp" : type;
|
||||
return `${normalizedType}\u0000${url}`;
|
||||
}
|
||||
|
||||
function writeMcpServersMap(servers: JsonRecord): void {
|
||||
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
|
||||
settings.mcpServers = servers;
|
||||
@@ -185,6 +263,31 @@ function removePathIfExists(
|
||||
return true;
|
||||
}
|
||||
|
||||
// Cline access tokens expire between app launches, so account requests must
|
||||
// resolve through the refresh-aware OAuth manager instead of reading the
|
||||
// persisted token directly. A single shared instance keeps concurrent account
|
||||
// requests single-flight; the refresh token is single-use, so parallel
|
||||
// refreshes would invalidate each other.
|
||||
let clineOAuthTokenManager: RuntimeOAuthTokenManager | undefined;
|
||||
|
||||
async function resolveFreshClineAuthToken(
|
||||
manager: ProviderSettingsManager,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
clineOAuthTokenManager ??= new RuntimeOAuthTokenManager();
|
||||
const resolution = await clineOAuthTokenManager.resolveProviderApiKey({
|
||||
providerId: "cline",
|
||||
});
|
||||
if (resolution?.apiKey) {
|
||||
return resolution.apiKey;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the persisted token; the account request surfaces the
|
||||
// auth failure to the caller.
|
||||
}
|
||||
return resolveLocalClineAuthToken(manager.getProviderSettings("cline"));
|
||||
}
|
||||
|
||||
async function listSessionsFromSidecarManager(
|
||||
ctx: SidecarContext,
|
||||
limit: number,
|
||||
@@ -406,17 +509,57 @@ async function handleRoutineScheduleCommand(
|
||||
};
|
||||
try {
|
||||
if (command === "list_routine_schedules") {
|
||||
const [schedules, activeExecutions, upcomingRuns] = await Promise.all([
|
||||
clientCommand("schedule.list", {
|
||||
limit: toPositiveInt(args?.limit) ?? 200,
|
||||
}),
|
||||
clientCommand("schedule.active"),
|
||||
clientCommand("schedule.upcoming", { limit: 30 }),
|
||||
]);
|
||||
const [schedules, activeExecutions, upcomingRuns, lastExecutions] =
|
||||
await Promise.all([
|
||||
clientCommand("schedule.list", {
|
||||
limit: toPositiveInt(args?.limit) ?? 200,
|
||||
}),
|
||||
clientCommand("schedule.active"),
|
||||
clientCommand("schedule.upcoming", { limit: 30 }),
|
||||
clientCommand("schedule.list_executions", { limit: 50 }),
|
||||
]);
|
||||
const scheduleRecords = (schedules.schedules ?? []) as JsonRecord[];
|
||||
const executionRecords = (lastExecutions.executions ??
|
||||
[]) as JsonRecord[];
|
||||
// The bulk query returns the newest executions across ALL schedules,
|
||||
// so a few chatty schedules can evict everyone else's latest run.
|
||||
// Backfill the latest execution for schedules that have run
|
||||
// (lastRunAt set) but fell out of that window.
|
||||
const covered = new Set<string>();
|
||||
for (const execution of executionRecords) {
|
||||
if (typeof execution.scheduleId === "string") {
|
||||
covered.add(execution.scheduleId);
|
||||
}
|
||||
}
|
||||
const missing = scheduleRecords.filter(
|
||||
(schedule) =>
|
||||
typeof schedule.scheduleId === "string" &&
|
||||
schedule.lastRunAt != null &&
|
||||
!covered.has(schedule.scheduleId),
|
||||
);
|
||||
const concurrency = 8;
|
||||
for (let index = 0; index < missing.length; index += concurrency) {
|
||||
const chunk = missing.slice(index, index + concurrency);
|
||||
const replies = await Promise.all(
|
||||
chunk.map((schedule) =>
|
||||
clientCommand("schedule.list_executions", {
|
||||
scheduleId: schedule.scheduleId,
|
||||
limit: 1,
|
||||
}).catch(() => undefined),
|
||||
),
|
||||
);
|
||||
for (const reply of replies) {
|
||||
const executions = (reply?.executions ?? []) as JsonRecord[];
|
||||
if (executions[0]) {
|
||||
executionRecords.push(executions[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
schedules: schedules.schedules ?? [],
|
||||
schedules: scheduleRecords,
|
||||
activeExecutions: activeExecutions.executions ?? [],
|
||||
upcomingRuns: upcomingRuns.runs ?? [],
|
||||
lastExecutions: executionRecords,
|
||||
};
|
||||
}
|
||||
if (command === "create_routine_schedule") {
|
||||
@@ -465,7 +608,13 @@ async function handleRoutineScheduleCommand(
|
||||
return { schedule: reply.schedule ?? null };
|
||||
}
|
||||
if (command === "trigger_routine_schedule") {
|
||||
const reply = await clientCommand("schedule.trigger", { scheduleId });
|
||||
// wait: false queues the run and returns immediately; the default
|
||||
// path blocks until the whole agent run finishes, which outlives the
|
||||
// webview's request timeout.
|
||||
const reply = await clientCommand("schedule.trigger", {
|
||||
scheduleId,
|
||||
wait: false,
|
||||
});
|
||||
return { execution: reply.execution ?? null };
|
||||
}
|
||||
if (command === "delete_routine_schedule") {
|
||||
@@ -718,9 +867,176 @@ function openFileInEditor(filePath: string): void {
|
||||
const cmdArgs =
|
||||
platform === "win32" ? ["/c", "start", "", filePath] : [filePath];
|
||||
const child = spawn(cmd, cmdArgs, { stdio: "ignore", detached: true });
|
||||
// An unhandled child error event would crash the sidecar process.
|
||||
child.once("error", () => {});
|
||||
child.unref();
|
||||
}
|
||||
|
||||
// The macOS app shell launches the sidecar with a minimal GUI PATH
|
||||
// (/usr/bin:/bin:...), so editor CLIs installed under /usr/local/bin or
|
||||
// /opt/homebrew/bin are often not resolvable. `macApps` lets `open -a`
|
||||
// find the app bundle regardless of PATH.
|
||||
interface CodeEditorDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
cli: string;
|
||||
macApps: string[];
|
||||
}
|
||||
|
||||
// Order doubles as the auto-open preference when no editor is requested.
|
||||
const CODE_EDITOR_CATALOG: readonly CodeEditorDefinition[] = [
|
||||
{
|
||||
id: "vscode",
|
||||
label: "VS Code",
|
||||
cli: "code",
|
||||
macApps: ["Visual Studio Code"],
|
||||
},
|
||||
{ id: "cursor", label: "Cursor", cli: "cursor", macApps: ["Cursor"] },
|
||||
{ id: "windsurf", label: "Windsurf", cli: "windsurf", macApps: ["Windsurf"] },
|
||||
{ id: "zed", label: "Zed", cli: "zed", macApps: ["Zed"] },
|
||||
{
|
||||
id: "vscode-insiders",
|
||||
label: "VS Code Insiders",
|
||||
cli: "code-insiders",
|
||||
macApps: ["Visual Studio Code - Insiders"],
|
||||
},
|
||||
{
|
||||
id: "sublime",
|
||||
label: "Sublime Text",
|
||||
cli: "subl",
|
||||
macApps: ["Sublime Text"],
|
||||
},
|
||||
{
|
||||
id: "intellijidea",
|
||||
label: "IntelliJ IDEA",
|
||||
cli: "idea",
|
||||
macApps: ["IntelliJ IDEA", "IntelliJ IDEA CE"],
|
||||
},
|
||||
{ id: "xcode", label: "Xcode", cli: "xed", macApps: ["Xcode"] },
|
||||
];
|
||||
|
||||
function findExecutableOnPath(name: string): string | null {
|
||||
try {
|
||||
const locator = process.platform === "win32" ? "where" : "which";
|
||||
const stdout = execFileSync(locator, [name], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
return stdout.split("\n")[0]?.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// cmd.exe re-parses metacharacters inside arguments even when quoted (the
|
||||
// reason Node refuses to spawn .cmd files without a shell), so strings that
|
||||
// could smuggle a second command must never reach it.
|
||||
const WINDOWS_CMD_UNSAFE_PATTERN = /[&|^<>%!"\r\n]/;
|
||||
|
||||
function isMacAppInstalled(app: string): boolean {
|
||||
return (
|
||||
existsSync(`/Applications/${app}.app`) ||
|
||||
existsSync(join(homedir(), "Applications", `${app}.app`))
|
||||
);
|
||||
}
|
||||
|
||||
/** Editors the current machine can actually launch, in catalog order. */
|
||||
function listAvailableCodeEditors(): Array<{ id: string; label: string }> {
|
||||
return CODE_EDITOR_CATALOG.filter(
|
||||
(editor) =>
|
||||
findExecutableOnPath(editor.cli) !== null ||
|
||||
(process.platform === "darwin" && editor.macApps.some(isMacAppInstalled)),
|
||||
).map(({ id, label }) => ({ id, label }));
|
||||
}
|
||||
|
||||
/** Launches `executable filePath` detached; false if the CLI is unusable. */
|
||||
function launchEditorCli(executable: string, filePath: string): boolean {
|
||||
// Windows `where` resolves editor CLIs to .cmd/.bat shims, which
|
||||
// spawn() cannot launch directly — route those through cmd.exe.
|
||||
const isWindowsShim =
|
||||
process.platform === "win32" && /\.(cmd|bat)$/i.test(executable);
|
||||
if (isWindowsShim && WINDOWS_CMD_UNSAFE_PATTERN.test(executable)) {
|
||||
return false;
|
||||
}
|
||||
const child = isWindowsShim
|
||||
? spawn("cmd", ["/c", executable, filePath], {
|
||||
stdio: "ignore",
|
||||
detached: true,
|
||||
})
|
||||
: spawn(executable, [filePath], {
|
||||
stdio: "ignore",
|
||||
detached: true,
|
||||
});
|
||||
// Spawn failures surface as async error events; without a listener
|
||||
// they crash the sidecar. Fall back to the OS default opener so the
|
||||
// click still opens the file.
|
||||
child.once("error", () => {
|
||||
openFileInEditor(filePath);
|
||||
});
|
||||
child.unref();
|
||||
return true;
|
||||
}
|
||||
|
||||
function launchMacApp(app: string, filePath: string): boolean {
|
||||
try {
|
||||
execFileSync("open", ["-a", app, filePath], {
|
||||
stdio: ["ignore", "ignore", "ignore"],
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the launcher that handled the file, for logging/UI feedback. */
|
||||
function openFileInCodeEditor(filePath: string, editorId?: string): string {
|
||||
if (
|
||||
process.platform === "win32" &&
|
||||
WINDOWS_CMD_UNSAFE_PATTERN.test(filePath)
|
||||
) {
|
||||
throw new Error(
|
||||
"File path contains characters that cannot be passed safely to the Windows shell",
|
||||
);
|
||||
}
|
||||
if (editorId && editorId !== "default") {
|
||||
const editor = CODE_EDITOR_CATALOG.find((entry) => entry.id === editorId);
|
||||
if (!editor) {
|
||||
throw new Error(`Unknown editor: ${editorId}`);
|
||||
}
|
||||
const executable = findExecutableOnPath(editor.cli);
|
||||
if (executable && launchEditorCli(executable, filePath)) {
|
||||
return editor.label;
|
||||
}
|
||||
if (
|
||||
process.platform === "darwin" &&
|
||||
editor.macApps.some((app) => launchMacApp(app, filePath))
|
||||
) {
|
||||
return editor.label;
|
||||
}
|
||||
throw new Error(`${editor.label} is not available on this machine`);
|
||||
}
|
||||
if (!editorId) {
|
||||
for (const editor of CODE_EDITOR_CATALOG) {
|
||||
const executable = findExecutableOnPath(editor.cli);
|
||||
if (executable && launchEditorCli(executable, filePath)) {
|
||||
return editor.cli;
|
||||
}
|
||||
}
|
||||
if (process.platform === "darwin") {
|
||||
for (const editor of CODE_EDITOR_CATALOG) {
|
||||
const app = editor.macApps.find((candidate) =>
|
||||
launchMacApp(candidate, filePath),
|
||||
);
|
||||
if (app) {
|
||||
return app;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
openFileInEditor(filePath);
|
||||
return "system default";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main command router
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -757,7 +1073,13 @@ export async function handleCommand(
|
||||
|
||||
// ── Process context ───────────────────────────────────────────────
|
||||
if (command === "get_process_context") {
|
||||
return { workspaceRoot: ctx.workspaceRoot, cwd: ctx.workspaceRoot };
|
||||
return {
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
homeDir: homedir(),
|
||||
platform: process.platform,
|
||||
appVersion: packageJson.version,
|
||||
};
|
||||
}
|
||||
if (command === "get_chat_ws_endpoint") {
|
||||
return "";
|
||||
@@ -842,9 +1164,7 @@ export async function handleCommand(
|
||||
if (command === "delete_chat_session" || command === "delete_cli_session") {
|
||||
const sessionId = String(args?.sessionId ?? args?.session_id ?? "").trim();
|
||||
if (!sessionId) throw new Error("session id is required");
|
||||
console.error(
|
||||
`[sidecar:delete] request command=${command} sessionId=${sessionId}`,
|
||||
);
|
||||
ctx.logger?.log("Deleting desktop chat session", { command, sessionId });
|
||||
const store = new SqliteSessionStore();
|
||||
const row = store.get(sessionId);
|
||||
const manifest = readSessionManifest(sessionId);
|
||||
@@ -922,14 +1242,16 @@ export async function handleCommand(
|
||||
}
|
||||
}
|
||||
if (!deleted && deleteError) {
|
||||
console.error(
|
||||
`[sidecar:delete] failed sessionId=${sessionId} error=${deleteError.message}`,
|
||||
);
|
||||
ctx.logger?.error?.("Failed to delete desktop chat session", {
|
||||
sessionId,
|
||||
error: deleteError,
|
||||
});
|
||||
throw deleteError;
|
||||
}
|
||||
console.error(
|
||||
`[sidecar:delete] result sessionId=${sessionId} deleted=${deleted}`,
|
||||
);
|
||||
ctx.logger?.log("Desktop chat session delete completed", {
|
||||
sessionId,
|
||||
deleted,
|
||||
});
|
||||
if (deleted) {
|
||||
broadcastEvent(ctx, "session_deleted", {
|
||||
sessionId,
|
||||
@@ -945,6 +1267,22 @@ export async function handleCommand(
|
||||
return await searchWorkspaceFiles(ctx, args);
|
||||
}
|
||||
|
||||
// ── External links ─────────────────────────────────────────────────
|
||||
if (command === "open_external_url") {
|
||||
const rawUrl = String(args?.url ?? "").trim();
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(rawUrl);
|
||||
} catch {
|
||||
throw new Error(`invalid url: ${rawUrl}`);
|
||||
}
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
||||
throw new Error("only http(s) urls can be opened externally");
|
||||
}
|
||||
await openUrlInDefaultBrowser(parsed.toString());
|
||||
return { opened: true };
|
||||
}
|
||||
|
||||
// ── Cline account ──────────────────────────────────────────────────
|
||||
if (command === "cline_account") {
|
||||
const operation = String(args?.operation ?? "").trim();
|
||||
@@ -954,7 +1292,7 @@ export async function handleCommand(
|
||||
const accountService = new ClineAccountService({
|
||||
apiBaseUrl:
|
||||
settings?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
|
||||
getAuthToken: async () => resolveLocalClineAuthToken(settings),
|
||||
getAuthToken: async () => resolveFreshClineAuthToken(manager),
|
||||
});
|
||||
return await executeClineAccountAction(
|
||||
args as ClineAccountActionRequest,
|
||||
@@ -1030,20 +1368,11 @@ export async function handleCommand(
|
||||
manager,
|
||||
providerId,
|
||||
(url) => {
|
||||
const platform = process.platform;
|
||||
const spawned =
|
||||
platform === "darwin"
|
||||
? spawn("open", [url], { stdio: "ignore", detached: true })
|
||||
: platform === "win32"
|
||||
? spawn("cmd", ["/c", "start", "", url], {
|
||||
stdio: "ignore",
|
||||
detached: true,
|
||||
})
|
||||
: spawn("xdg-open", [url], {
|
||||
stdio: "ignore",
|
||||
detached: true,
|
||||
});
|
||||
spawned.unref();
|
||||
// The OAuth helper's openUrl callback is fire-and-forget; surface
|
||||
// opener failures in the log instead of an unhandled rejection.
|
||||
openUrlInDefaultBrowser(url).catch((error) => {
|
||||
console.warn(`[sidecar] ${error instanceof Error ? error.message : error}`);
|
||||
});
|
||||
},
|
||||
);
|
||||
if (saved.provider !== providerId) {
|
||||
@@ -1146,10 +1475,31 @@ export async function handleCommand(
|
||||
updateMcpSettingsFileSync(path, (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
// Preserve machine-managed fields the editor dialog doesn't expose:
|
||||
// oauth tokens for remote servers and plugin-ownership metadata.
|
||||
const sourceName =
|
||||
previousName && servers[previousName] ? previousName : name;
|
||||
const existing = servers[sourceName];
|
||||
const upserted = { ...next };
|
||||
if (existing && typeof existing === "object") {
|
||||
const record = existing as JsonRecord;
|
||||
if (upserted.metadata === undefined && record.metadata !== undefined) {
|
||||
upserted.metadata = record.metadata;
|
||||
}
|
||||
// OAuth tokens were issued for a specific endpoint; carrying them
|
||||
// onto an edited transport or URL would send the old server's
|
||||
// credentials to a different endpoint.
|
||||
if (
|
||||
record.oauth !== undefined &&
|
||||
mcpTransportIdentity(record) === mcpTransportIdentity(upserted)
|
||||
) {
|
||||
upserted.oauth = record.oauth;
|
||||
}
|
||||
}
|
||||
if (previousName && previousName !== name) {
|
||||
delete servers[previousName];
|
||||
}
|
||||
servers[name] = next;
|
||||
servers[name] = upserted;
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
return readMcpServersResponse();
|
||||
@@ -1282,6 +1632,27 @@ export async function handleCommand(
|
||||
openFileInEditor(path);
|
||||
return path;
|
||||
}
|
||||
if (command === "list_available_editors") {
|
||||
return listAvailableCodeEditors();
|
||||
}
|
||||
if (command === "open_file_in_editor") {
|
||||
const rawPath = String(args?.path ?? "").trim();
|
||||
if (!rawPath) throw new Error("path is required");
|
||||
const baseDir =
|
||||
typeof args?.cwd === "string" && args.cwd.trim()
|
||||
? args.cwd.trim()
|
||||
: ctx.workspaceRoot;
|
||||
const filePath = isAbsolute(rawPath) ? rawPath : join(baseDir, rawPath);
|
||||
if (!existsSync(filePath)) {
|
||||
throw new Error(`File not found: ${filePath}`);
|
||||
}
|
||||
const requestedEditor =
|
||||
typeof args?.editor === "string" && args.editor.trim()
|
||||
? args.editor.trim()
|
||||
: undefined;
|
||||
const editor = openFileInCodeEditor(filePath, requestedEditor);
|
||||
return { path: filePath, editor };
|
||||
}
|
||||
|
||||
throw new Error(`unsupported desktop command: ${command}`);
|
||||
}
|
||||
|
||||
@@ -118,6 +118,35 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("wires the desktop logger and telemetry through the client and embedded hub", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
);
|
||||
const logger = {
|
||||
debug: vi.fn(),
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const telemetry = { capture: vi.fn() };
|
||||
const ctx = createSidecarContext("/workspace/project", {
|
||||
logger,
|
||||
telemetry: telemetry as never,
|
||||
});
|
||||
|
||||
await initializeSessionManager(ctx);
|
||||
|
||||
expect(startHubWebSocketServerMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ logger, telemetry }),
|
||||
);
|
||||
expect(createCoreMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
clientName: "cline-code",
|
||||
logger,
|
||||
telemetry,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves askQuestion through the websocket request/response protocol", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
|
||||
@@ -4,12 +4,14 @@ import { homedir } from "node:os";
|
||||
import { dirname } from "node:path";
|
||||
import {
|
||||
type AgentToolContext,
|
||||
type BasicLogger,
|
||||
ClineCore,
|
||||
createLocalHubScheduleRuntimeHandlers,
|
||||
type CoreSessionEvent,
|
||||
createLocalHubScheduleRuntimeHandlers,
|
||||
type ITelemetryService,
|
||||
NodeHubClient,
|
||||
resolveHubOwnerContext,
|
||||
type RuntimeCapabilities,
|
||||
resolveHubOwnerContext,
|
||||
setHomeDirIfUnset,
|
||||
startHubWebSocketServer,
|
||||
type ToolApprovalRequest,
|
||||
@@ -380,7 +382,13 @@ function handleCoreSessionEvent(
|
||||
// Context factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createSidecarContext(workspaceRoot: string): SidecarContext {
|
||||
export function createSidecarContext(
|
||||
workspaceRoot: string,
|
||||
observability: {
|
||||
logger?: BasicLogger;
|
||||
telemetry?: ITelemetryService;
|
||||
} = {},
|
||||
): SidecarContext {
|
||||
return {
|
||||
liveSessions: new Map(),
|
||||
streamIndices: new Map(),
|
||||
@@ -391,6 +399,8 @@ export function createSidecarContext(workspaceRoot: string): SidecarContext {
|
||||
hubClient: null,
|
||||
hubServer: null,
|
||||
workspaceRoot,
|
||||
logger: observability.logger,
|
||||
telemetry: observability.telemetry,
|
||||
unsubscribeSessionEvents: null,
|
||||
};
|
||||
}
|
||||
@@ -698,10 +708,15 @@ export async function initializeSessionManager(
|
||||
`code-sidecar:${process.pid}:${randomUUID()}`,
|
||||
),
|
||||
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
|
||||
logger: ctx.logger,
|
||||
telemetry: ctx.telemetry,
|
||||
});
|
||||
const sessionManager = await ClineCore.create({
|
||||
clientName: "cline-code",
|
||||
backendMode: "hub",
|
||||
capabilities: createSidecarRuntimeCapabilities(ctx),
|
||||
logger: ctx.logger,
|
||||
telemetry: ctx.telemetry,
|
||||
hub: {
|
||||
endpoint: hubServer.url,
|
||||
authToken: hubServer.authToken,
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { homedir } from "node:os";
|
||||
import { setHomeDirIfUnset } from "@cline/core";
|
||||
import { prewarmWorkspaceMetadata } from "./chat-session";
|
||||
import {
|
||||
createSidecarContext,
|
||||
disposeSidecarContext,
|
||||
initializeSessionManager,
|
||||
} from "./context";
|
||||
import { createDesktopObservability } from "./observability";
|
||||
import { resolveWorkspaceRoot } from "./paths";
|
||||
import { startServer } from "./server";
|
||||
import { BunRuntime, SIDECAR_HOST, SIDECAR_MODE, SIDECAR_PORT } from "./types";
|
||||
|
||||
const SHUTDOWN_TIMEOUT_MS = 5_000;
|
||||
let activeObservability:
|
||||
| ReturnType<typeof createDesktopObservability>
|
||||
| undefined;
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -33,18 +39,36 @@ async function main() {
|
||||
}
|
||||
|
||||
const workspaceRoot = resolveWorkspaceRoot(process.cwd());
|
||||
const ctx = createSidecarContext(workspaceRoot);
|
||||
setHomeDirIfUnset(homedir());
|
||||
const observability = createDesktopObservability();
|
||||
activeObservability = observability;
|
||||
const ctx = createSidecarContext(workspaceRoot, observability);
|
||||
observability.logger.log("Desktop sidecar starting", {
|
||||
workspaceRoot,
|
||||
pid: process.pid,
|
||||
});
|
||||
|
||||
prewarmWorkspaceMetadata(workspaceRoot);
|
||||
await initializeSessionManager(ctx);
|
||||
|
||||
let shuttingDown = false;
|
||||
let handlingFatalError = false;
|
||||
const shutdown = async (reason = "code_sidecar_shutdown"): Promise<void> => {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
shuttingDown = true;
|
||||
await withTimeout(disposeSidecarContext(ctx, reason), SHUTDOWN_TIMEOUT_MS);
|
||||
observability.logger.log("Desktop sidecar shutting down", { reason });
|
||||
await withTimeout(
|
||||
(async () => {
|
||||
try {
|
||||
await disposeSidecarContext(ctx, reason);
|
||||
} finally {
|
||||
await observability.dispose();
|
||||
}
|
||||
})(),
|
||||
SHUTDOWN_TIMEOUT_MS,
|
||||
);
|
||||
};
|
||||
|
||||
const shutdownAndExit = (signal: string): void => {
|
||||
@@ -55,11 +79,32 @@ async function main() {
|
||||
|
||||
process.once("SIGINT", () => shutdownAndExit("SIGINT"));
|
||||
process.once("SIGTERM", () => shutdownAndExit("SIGTERM"));
|
||||
const handleFatalError = (kind: string, error: unknown): void => {
|
||||
if (handlingFatalError) {
|
||||
process.exit(1);
|
||||
}
|
||||
handlingFatalError = true;
|
||||
observability.logger.error?.("Desktop sidecar process error", {
|
||||
kind,
|
||||
error,
|
||||
});
|
||||
void shutdown(`code_sidecar_${kind}`).finally(() => process.exit(1));
|
||||
};
|
||||
process.on("uncaughtException", (error) => {
|
||||
handleFatalError("uncaught_exception", error);
|
||||
});
|
||||
process.on("unhandledRejection", (error) => {
|
||||
handleFatalError("unhandled_rejection", error);
|
||||
});
|
||||
process.once("beforeExit", () => {
|
||||
void shutdown("code_sidecar_before_exit");
|
||||
});
|
||||
|
||||
const { port } = startServer(ctx, SIDECAR_PORT, shutdown);
|
||||
observability.logger.log("Desktop sidecar ready", {
|
||||
port,
|
||||
mode: SIDECAR_MODE,
|
||||
});
|
||||
|
||||
// A wildcard bind isn't a dialable address; advertise loopback instead.
|
||||
const dialHost = SIDECAR_HOST === "0.0.0.0" ? "127.0.0.1" : SIDECAR_HOST;
|
||||
@@ -76,8 +121,12 @@ async function main() {
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
main().catch(async (error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
activeObservability?.logger.error?.("Desktop sidecar process failed", {
|
||||
error,
|
||||
});
|
||||
await activeObservability?.dispose();
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
truncateSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createDesktopLoggerAdapter, DESKTOP_LOG_MAX_BYTES } from "./logging";
|
||||
|
||||
const originalEnv = {
|
||||
CLINE_LOG_ENABLED: process.env.CLINE_LOG_ENABLED,
|
||||
CLINE_LOG_LEVEL: process.env.CLINE_LOG_LEVEL,
|
||||
CLINE_LOG_NAME: process.env.CLINE_LOG_NAME,
|
||||
CLINE_LOG_PATH: process.env.CLINE_LOG_PATH,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
for (const [key, value] of Object.entries(originalEnv)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
describe("desktop sidecar logging", () => {
|
||||
it("writes structured SDK logs to the configured file", () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "cline-code-logging-"));
|
||||
const destination = join(directory, "sidecar.log");
|
||||
process.env.CLINE_LOG_PATH = destination;
|
||||
process.env.CLINE_LOG_LEVEL = "debug";
|
||||
delete process.env.CLINE_LOG_ENABLED;
|
||||
|
||||
try {
|
||||
const adapter = createDesktopLoggerAdapter();
|
||||
adapter.core.debug("desktop runtime event", { sessionId: "session-1" });
|
||||
adapter.dispose();
|
||||
|
||||
const contents = readFileSync(destination, "utf8");
|
||||
expect(contents).toContain("desktop runtime event");
|
||||
expect(contents).toContain('"sessionId":"session-1"');
|
||||
expect(adapter.runtimeConfig.name).toBe("cline-code.sidecar");
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("warns once before falling back to stderr when the log file cannot open", () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "cline-code-fallback-"));
|
||||
process.env.CLINE_LOG_PATH = directory;
|
||||
delete process.env.CLINE_LOG_ENABLED;
|
||||
const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true);
|
||||
|
||||
try {
|
||||
const adapter = createDesktopLoggerAdapter();
|
||||
adapter.dispose();
|
||||
|
||||
expect(stderr).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Unable to open log file"),
|
||||
);
|
||||
expect(stderr).toHaveBeenCalledWith(
|
||||
expect.stringContaining("falling back to stderr"),
|
||||
);
|
||||
} finally {
|
||||
stderr.mockRestore();
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rotates the active log before a write exceeds the size limit", () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "cline-code-rotation-"));
|
||||
const destination = join(directory, "sidecar.log");
|
||||
process.env.CLINE_LOG_PATH = destination;
|
||||
delete process.env.CLINE_LOG_ENABLED;
|
||||
|
||||
try {
|
||||
mkdirSync(directory, { recursive: true });
|
||||
writeFileSync(destination, "");
|
||||
truncateSync(destination, DESKTOP_LOG_MAX_BYTES - 1);
|
||||
const adapter = createDesktopLoggerAdapter();
|
||||
adapter.core.log("rotate before writing this entry");
|
||||
adapter.dispose();
|
||||
|
||||
expect(statSync(destination).size).toBeLessThan(1_024);
|
||||
expect(readFileSync(destination, "utf8")).toContain(
|
||||
"rotate before writing this entry",
|
||||
);
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
import {
|
||||
closeSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
statSync,
|
||||
truncateSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import {
|
||||
type BasicLogger,
|
||||
type RuntimeLoggerConfig,
|
||||
resolveClineDataDir,
|
||||
} from "@cline/core";
|
||||
import pino, {
|
||||
type DestinationStream,
|
||||
type LevelWithSilent,
|
||||
type Logger as PinoLogger,
|
||||
} from "pino";
|
||||
|
||||
const LOG_MAX_AGE_MS = 2 * 24 * 60 * 60 * 1_000;
|
||||
export const DESKTOP_LOG_MAX_BYTES = 50 * 1024 * 1024;
|
||||
const LOG_LEVELS: ReadonlySet<LevelWithSilent> = new Set([
|
||||
"trace",
|
||||
"debug",
|
||||
"info",
|
||||
"warn",
|
||||
"error",
|
||||
"fatal",
|
||||
"silent",
|
||||
]);
|
||||
|
||||
export interface DesktopLoggerAdapter {
|
||||
readonly core: BasicLogger;
|
||||
readonly runtimeConfig: Required<RuntimeLoggerConfig>;
|
||||
flush(): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
function resolveLogLevel(value: string | undefined): LevelWithSilent {
|
||||
const candidate = value?.trim().toLowerCase() as LevelWithSilent | undefined;
|
||||
return candidate && LOG_LEVELS.has(candidate) ? candidate : "info";
|
||||
}
|
||||
|
||||
function resolveRuntimeConfig(): Required<RuntimeLoggerConfig> {
|
||||
const enabledValue = process.env.CLINE_LOG_ENABLED?.trim().toLowerCase();
|
||||
return {
|
||||
enabled: enabledValue !== "0" && enabledValue !== "false",
|
||||
level: resolveLogLevel(process.env.CLINE_LOG_LEVEL),
|
||||
destination:
|
||||
process.env.CLINE_LOG_PATH?.trim() ||
|
||||
join(resolveClineDataDir(), "logs", "code.log"),
|
||||
name: process.env.CLINE_LOG_NAME?.trim() || "cline-code.sidecar",
|
||||
bindings: {},
|
||||
};
|
||||
}
|
||||
|
||||
type ManagedDestination = DestinationStream & {
|
||||
flushSync(): void;
|
||||
end(): void;
|
||||
};
|
||||
|
||||
type DestinationResult =
|
||||
| { destination: ManagedDestination; error?: never }
|
||||
| { destination?: never; error: unknown };
|
||||
|
||||
function createDestination(path: string): DestinationResult {
|
||||
try {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const fd = openSync(path, "a");
|
||||
closeSync(fd);
|
||||
const initialStats = statSync(path);
|
||||
if (
|
||||
Date.now() - initialStats.mtimeMs >= LOG_MAX_AGE_MS ||
|
||||
initialStats.size >= DESKTOP_LOG_MAX_BYTES
|
||||
) {
|
||||
truncateSync(path, 0);
|
||||
}
|
||||
const rawDestination = pino.destination({
|
||||
dest: path,
|
||||
mkdir: true,
|
||||
sync: true,
|
||||
});
|
||||
const rawFlushSync = rawDestination.flushSync.bind(rawDestination);
|
||||
let currentSize = statSync(path).size;
|
||||
const destination: ManagedDestination = {
|
||||
write(message: string) {
|
||||
const messageSize = Buffer.byteLength(message);
|
||||
if (currentSize + messageSize > DESKTOP_LOG_MAX_BYTES) {
|
||||
try {
|
||||
rawFlushSync();
|
||||
truncateSync(path, 0);
|
||||
currentSize = 0;
|
||||
} catch {
|
||||
// Rotation is best-effort; preserve the log entry if it fails.
|
||||
}
|
||||
}
|
||||
rawDestination.write(message);
|
||||
currentSize += messageSize;
|
||||
},
|
||||
flushSync() {
|
||||
try {
|
||||
rawFlushSync();
|
||||
} catch {
|
||||
// The synchronous stream may already be closed during teardown.
|
||||
}
|
||||
},
|
||||
end() {
|
||||
rawDestination.end();
|
||||
},
|
||||
};
|
||||
return { destination };
|
||||
} catch (error) {
|
||||
return { error };
|
||||
}
|
||||
}
|
||||
|
||||
function writeDestinationFallbackWarning(path: string, error: unknown): void {
|
||||
try {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
process.stderr.write(
|
||||
`[cline-code] Unable to open log file ${path}; falling back to stderr (${message})\n`,
|
||||
);
|
||||
} catch {
|
||||
// The fallback warning must never prevent sidecar startup.
|
||||
}
|
||||
}
|
||||
|
||||
function flushDestination(destination: ManagedDestination | undefined): void {
|
||||
if (!destination) return;
|
||||
try {
|
||||
destination.flushSync();
|
||||
} catch {
|
||||
// Logging is best-effort during shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
function closeDestination(destination: ManagedDestination | undefined): void {
|
||||
if (!destination) return;
|
||||
try {
|
||||
destination.end();
|
||||
} catch {
|
||||
// Logging is best-effort during shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
function createFallbackDestination(): DestinationStream {
|
||||
return {
|
||||
write(message: string) {
|
||||
process.stderr.write(message);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
The adapter intentionally owns the destination lifecycle. Pino receives a
|
||||
synchronous stream so telemetry and fatal-process logs can be flushed before
|
||||
the sidecar exits.
|
||||
*/
|
||||
function createPinoLogger(
|
||||
runtimeConfig: Required<RuntimeLoggerConfig>,
|
||||
destination: ManagedDestination | undefined,
|
||||
): PinoLogger {
|
||||
return pino(
|
||||
{
|
||||
name: runtimeConfig.name,
|
||||
level: runtimeConfig.enabled ? runtimeConfig.level : "silent",
|
||||
enabled: runtimeConfig.enabled,
|
||||
timestamp: pino.stdTimeFunctions.isoTime,
|
||||
},
|
||||
destination ?? createFallbackDestination(),
|
||||
).child({ component: "sidecar" });
|
||||
}
|
||||
|
||||
function flushLogger(logger: PinoLogger): void {
|
||||
try {
|
||||
logger.flush?.();
|
||||
} catch {
|
||||
// Logging is best-effort during shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
function toFields(
|
||||
metadata?: Record<string, unknown>,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!metadata) return undefined;
|
||||
const { error, ...rest } = metadata;
|
||||
const fields = error === undefined ? rest : { ...rest, err: error };
|
||||
return Object.keys(fields).length > 0 ? fields : undefined;
|
||||
}
|
||||
|
||||
function createCoreLogger(logger: PinoLogger): BasicLogger {
|
||||
return {
|
||||
debug(message, metadata) {
|
||||
const fields = toFields(metadata);
|
||||
fields ? logger.debug(fields, message) : logger.debug(message);
|
||||
},
|
||||
log(message, metadata) {
|
||||
const fields = toFields(metadata);
|
||||
const write =
|
||||
metadata?.severity === "error"
|
||||
? logger.error
|
||||
: metadata?.severity === "warn"
|
||||
? logger.warn
|
||||
: logger.info;
|
||||
fields
|
||||
? write.call(logger, fields, message)
|
||||
: write.call(logger, message);
|
||||
},
|
||||
error(message, metadata) {
|
||||
const fields = toFields(metadata);
|
||||
fields ? logger.error(fields, message) : logger.error(message);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createDesktopLoggerAdapter(): DesktopLoggerAdapter {
|
||||
const runtimeConfig = resolveRuntimeConfig();
|
||||
const destinationResult = runtimeConfig.enabled
|
||||
? createDestination(runtimeConfig.destination)
|
||||
: undefined;
|
||||
const destination = destinationResult?.destination;
|
||||
if (destinationResult?.error !== undefined) {
|
||||
writeDestinationFallbackWarning(
|
||||
runtimeConfig.destination,
|
||||
destinationResult.error,
|
||||
);
|
||||
}
|
||||
const logger = createPinoLogger(runtimeConfig, destination);
|
||||
let disposed = false;
|
||||
const flush = () => {
|
||||
flushDestination(destination);
|
||||
flushLogger(logger);
|
||||
};
|
||||
return {
|
||||
core: createCoreLogger(logger),
|
||||
runtimeConfig,
|
||||
flush,
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
flush();
|
||||
closeDestination(destination);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
captureExtensionActivated: vi.fn(),
|
||||
createClineTelemetryServiceConfig: vi.fn((config: unknown) => config),
|
||||
createConfiguredTelemetryHandle: vi.fn(),
|
||||
disposeTelemetry: vi.fn(async () => {}),
|
||||
disposeLogger: vi.fn(),
|
||||
identifyAccount: vi.fn(),
|
||||
setSdkLogger: vi.fn(),
|
||||
}));
|
||||
|
||||
const logger = {
|
||||
debug: vi.fn(),
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const telemetry = { capture: vi.fn() };
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@cline/core")>("@cline/core");
|
||||
return {
|
||||
...actual,
|
||||
captureExtensionActivated: mocks.captureExtensionActivated,
|
||||
createClineTelemetryServiceConfig: mocks.createClineTelemetryServiceConfig,
|
||||
createConfiguredTelemetryHandle: mocks.createConfiguredTelemetryHandle,
|
||||
identifyAccount: mocks.identifyAccount,
|
||||
ProviderSettingsManager: class {
|
||||
getProviderSettings() {
|
||||
return { auth: { accountId: "account-1" } };
|
||||
}
|
||||
},
|
||||
setSdkLogger: mocks.setSdkLogger,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./logging", () => ({
|
||||
createDesktopLoggerAdapter: () => ({
|
||||
core: logger,
|
||||
dispose: mocks.disposeLogger,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("desktop observability", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.createConfiguredTelemetryHandle.mockReturnValue({
|
||||
telemetry,
|
||||
dispose: mocks.disposeTelemetry,
|
||||
});
|
||||
});
|
||||
|
||||
it("configures desktop telemetry, identity, activation, and lifecycle", async () => {
|
||||
const { createDesktopObservability } = await import("./observability");
|
||||
const observability = createDesktopObservability();
|
||||
|
||||
expect(mocks.createClineTelemetryServiceConfig).toHaveBeenCalledWith({
|
||||
metadata: expect.objectContaining({
|
||||
cline_type: "desktop",
|
||||
platform: "Cline Code",
|
||||
}),
|
||||
});
|
||||
expect(mocks.createConfiguredTelemetryHandle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ logger }),
|
||||
);
|
||||
expect(mocks.identifyAccount).toHaveBeenCalledWith(telemetry, {
|
||||
id: "account-1",
|
||||
provider: "cline",
|
||||
});
|
||||
expect(mocks.captureExtensionActivated).toHaveBeenCalledWith(telemetry);
|
||||
expect(mocks.setSdkLogger).toHaveBeenCalledWith(logger);
|
||||
|
||||
await observability.dispose();
|
||||
await observability.dispose();
|
||||
|
||||
expect(mocks.disposeTelemetry).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.setSdkLogger).toHaveBeenLastCalledWith(undefined);
|
||||
expect(mocks.disposeLogger).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import * as os from "node:os";
|
||||
import {
|
||||
captureExtensionActivated,
|
||||
createClineTelemetryServiceConfig,
|
||||
createConfiguredTelemetryHandle,
|
||||
type ITelemetryService,
|
||||
identifyAccount,
|
||||
ProviderSettingsManager,
|
||||
setSdkLogger,
|
||||
} from "@cline/core";
|
||||
import { version } from "../package.json";
|
||||
import {
|
||||
createDesktopLoggerAdapter,
|
||||
type DesktopLoggerAdapter,
|
||||
} from "./logging";
|
||||
|
||||
export interface DesktopObservability {
|
||||
readonly logger: DesktopLoggerAdapter["core"];
|
||||
readonly telemetry: ITelemetryService;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
export function createDesktopObservability(): DesktopObservability {
|
||||
const loggerAdapter = createDesktopLoggerAdapter();
|
||||
const logger = loggerAdapter.core;
|
||||
setSdkLogger(logger);
|
||||
|
||||
const telemetryHandle = createConfiguredTelemetryHandle({
|
||||
...createClineTelemetryServiceConfig({
|
||||
metadata: {
|
||||
extension_version: version,
|
||||
cline_type: "desktop",
|
||||
platform: "Cline Code",
|
||||
platform_version: process.version,
|
||||
os_type: os.platform(),
|
||||
os_version: os.version(),
|
||||
},
|
||||
}),
|
||||
logger,
|
||||
});
|
||||
const telemetry = telemetryHandle.telemetry;
|
||||
const auth = new ProviderSettingsManager().getProviderSettings("cline")?.auth;
|
||||
if (auth?.accountId) {
|
||||
identifyAccount(telemetry, {
|
||||
id: auth.accountId,
|
||||
provider: "cline",
|
||||
});
|
||||
}
|
||||
captureExtensionActivated(telemetry);
|
||||
|
||||
let disposed = false;
|
||||
return {
|
||||
logger,
|
||||
telemetry,
|
||||
async dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
await telemetryHandle.dispose();
|
||||
setSdkLogger(undefined);
|
||||
loggerAdapter.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -143,7 +143,7 @@ export function startServer(
|
||||
}
|
||||
|
||||
export function createFetchHandler(
|
||||
_ctx: SidecarContext,
|
||||
ctx: SidecarContext,
|
||||
onShutdown?: (reason?: string) => Promise<void>,
|
||||
) {
|
||||
return async (req: Request, server: SidecarServer) => {
|
||||
@@ -199,11 +199,7 @@ export function createFetchHandler(
|
||||
queueMicrotask(() => {
|
||||
void onShutdown?.("code_sidecar_shutdown_endpoint")
|
||||
.catch((error) => {
|
||||
process.stderr.write(
|
||||
`sidecar shutdown failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}\n`,
|
||||
);
|
||||
ctx.logger?.error?.("Desktop sidecar shutdown failed", { error });
|
||||
})
|
||||
.finally(() => process.exit(0));
|
||||
});
|
||||
|
||||
@@ -58,6 +58,7 @@ export function discoverChatSessions(
|
||||
prompt,
|
||||
messages: session.messages,
|
||||
});
|
||||
const persistedMetadata = store.get(sessionId)?.metadata;
|
||||
out.push({
|
||||
sessionId,
|
||||
status: session.status,
|
||||
@@ -68,7 +69,10 @@ export function discoverChatSessions(
|
||||
prompt,
|
||||
startedAt: String(session.startedAt),
|
||||
endedAt: session.endedAt ? String(session.endedAt) : undefined,
|
||||
metadata: { title: resolvedTitle },
|
||||
metadata: {
|
||||
...(persistedMetadata ?? {}),
|
||||
title: resolvedTitle,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type {
|
||||
AgentToolContext,
|
||||
BasicLogger,
|
||||
ClineCore,
|
||||
HubServer,
|
||||
ITelemetryService,
|
||||
NodeHubClient,
|
||||
ToolApprovalResult,
|
||||
} from "@cline/core";
|
||||
@@ -51,6 +53,7 @@ export type LiveSession = {
|
||||
startedAt: number;
|
||||
endedAt?: number;
|
||||
status: string;
|
||||
transitioningProvider?: boolean;
|
||||
prompt?: string;
|
||||
title?: string;
|
||||
attachedViaHub?: boolean;
|
||||
@@ -106,6 +109,8 @@ export type SidecarContext = {
|
||||
hubClient: NodeHubClient | null;
|
||||
hubServer: HubServer | null;
|
||||
workspaceRoot: string;
|
||||
logger?: BasicLogger;
|
||||
telemetry?: ITelemetryService;
|
||||
unsubscribeSessionEvents: (() => void) | null;
|
||||
};
|
||||
export type BunRuntimeApi = {
|
||||
|
||||
@@ -10,6 +10,8 @@ tauri-build = { version = "2.0.0", features = [] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri = { version = "2.11.1", features = [] }
|
||||
tauri-plugin-updater = "2"
|
||||
tokio = { version = "1", features = ["time"] }
|
||||
rfd = "0.15"
|
||||
|
||||
[features]
|
||||
|
||||
@@ -9,6 +9,10 @@ use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use tauri::{Manager, RunEvent, State};
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
|
||||
const UPDATE_INITIAL_DELAY: Duration = Duration::from_secs(10);
|
||||
const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(2 * 60 * 60);
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppContext {
|
||||
@@ -16,6 +20,108 @@ struct AppContext {
|
||||
workspace_root: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct UpdateStatus {
|
||||
state: String,
|
||||
version: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for UpdateStatus {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
state: "idle".to_string(),
|
||||
version: None,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct UpdateState {
|
||||
status: Mutex<UpdateStatus>,
|
||||
}
|
||||
|
||||
impl UpdateState {
|
||||
fn set(&self, state: &str, version: Option<String>, error: Option<String>) {
|
||||
if let Ok(mut guard) = self.status.lock() {
|
||||
*guard = UpdateStatus {
|
||||
state: state.to_string(),
|
||||
version,
|
||||
error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot(&self) -> UpdateStatus {
|
||||
self.status
|
||||
.lock()
|
||||
.map(|guard| guard.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn ready_version(&self) -> Option<String> {
|
||||
self.status.lock().ok().and_then(|guard| {
|
||||
if guard.state == "ready" {
|
||||
guard.version.clone()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_and_install_update(app: &tauri::AppHandle, state: &UpdateState) {
|
||||
// An update that already finished downloading only needs a restart; keep
|
||||
// reporting "ready" instead of flipping back to transient states unless a
|
||||
// newer version shows up.
|
||||
let ready_version = state.ready_version();
|
||||
if ready_version.is_none() {
|
||||
state.set("checking", None, None);
|
||||
}
|
||||
|
||||
let updater = match app.updater() {
|
||||
Ok(updater) => updater,
|
||||
Err(error) => {
|
||||
state.set("error", None, Some(error.to_string()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match updater.check().await {
|
||||
Ok(Some(update)) => {
|
||||
let version = update.version.clone();
|
||||
if ready_version.as_deref() == Some(version.as_str()) {
|
||||
return;
|
||||
}
|
||||
state.set("downloading", Some(version.clone()), None);
|
||||
match update.download_and_install(|_, _| {}, || {}).await {
|
||||
Ok(()) => state.set("ready", Some(version), None),
|
||||
Err(error) => state.set("error", Some(version), Some(error.to_string())),
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
if ready_version.is_none() {
|
||||
state.set("idle", None, None);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
if ready_version.is_none() {
|
||||
state.set("error", None, Some(error.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_update_loop(app: tauri::AppHandle, state: Arc<UpdateState>) {
|
||||
tokio::time::sleep(UPDATE_INITIAL_DELAY).await;
|
||||
loop {
|
||||
check_and_install_update(&app, &state).await;
|
||||
tokio::time::sleep(UPDATE_CHECK_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DesktopBackendState {
|
||||
ws_endpoint: Mutex<Option<String>>,
|
||||
@@ -44,7 +150,11 @@ impl DesktopBackendState {
|
||||
|
||||
if let Ok(mut process_guard) = self.process.lock() {
|
||||
if let Some(child) = process_guard.as_mut() {
|
||||
for _ in 0..30 {
|
||||
// The sidecar bounds its own graceful shutdown with
|
||||
// SHUTDOWN_TIMEOUT_MS (5s in sidecar/index.ts) and then exits
|
||||
// itself; wait past that window before escalating to kill so
|
||||
// an active session can finish persisting.
|
||||
for _ in 0..70 {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => break,
|
||||
Ok(None) => thread::sleep(Duration::from_millis(100)),
|
||||
@@ -452,6 +562,22 @@ fn pick_workspace_directory(initial_path: Option<String>) -> Option<String> {
|
||||
.map(|path| path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_update_status(update_state: State<'_, Arc<UpdateState>>) -> UpdateStatus {
|
||||
update_state.snapshot()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn restart_to_apply_update(
|
||||
app: tauri::AppHandle,
|
||||
backend_state: State<'_, Arc<DesktopBackendState>>,
|
||||
) {
|
||||
// restart() never returns, so the run-loop Exit handler does not get a
|
||||
// chance to stop the sidecar; shut it down explicitly first.
|
||||
backend_state.stop();
|
||||
app.restart();
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_mcp_settings_file() -> Result<String, String> {
|
||||
let settings_path = resolve_mcp_settings_path()?;
|
||||
@@ -485,14 +611,25 @@ fn main() {
|
||||
};
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.manage(desktop_backend)
|
||||
.manage(app_context)
|
||||
.manage(Arc::new(UpdateState::default()))
|
||||
.setup(|app| {
|
||||
let app_context = app.state::<AppContext>().inner().clone();
|
||||
let backend_state = app.state::<Arc<DesktopBackendState>>().inner().clone();
|
||||
if let Err(error) = ensure_desktop_backend_started(&backend_state, &app_context) {
|
||||
eprintln!("[desktop-backend] startup failed: {error}");
|
||||
}
|
||||
// Dev builds are not installed app bundles, so there is nothing the
|
||||
// updater could meaningfully check or replace.
|
||||
if !cfg!(debug_assertions) {
|
||||
let update_state = app.state::<Arc<UpdateState>>().inner().clone();
|
||||
let app_handle = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
run_update_loop(app_handle, update_state).await;
|
||||
});
|
||||
}
|
||||
thread::spawn(move || loop {
|
||||
thread::sleep(Duration::from_secs(5));
|
||||
if backend_state.is_shutting_down() {
|
||||
@@ -507,7 +644,9 @@ fn main() {
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_desktop_backend_endpoint,
|
||||
pick_workspace_directory,
|
||||
open_mcp_settings_file
|
||||
open_mcp_settings_file,
|
||||
get_update_status,
|
||||
restart_to_apply_update
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri app")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Cline Code",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.3",
|
||||
"identifier": "bot.cline.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web",
|
||||
@@ -9,6 +9,14 @@
|
||||
"beforeBuildCommand": "bun run build",
|
||||
"frontendDist": "../webview/out"
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IENEMTJDNzk2RUExQUY3RDEKUldUUjl4cnFsc2NTelYxNzlFR1NkWnI0VTM1V0hvQXRyOW0xV2c0bFhkL3dhdkdpNGhNRW1MQXEK",
|
||||
"endpoints": [
|
||||
"https://github.com/cline/cline/releases/download/desktop-latest/latest.json"
|
||||
]
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"bundle": {
|
||||
"createUpdaterArtifacts": true
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "@cline/ui/theme/index.css";
|
||||
@import "@cline/ui/components/agent-chat.css";
|
||||
|
||||
@source "../../node_modules/streamdown/dist";
|
||||
|
||||
@@ -19,6 +20,60 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Hero heading cycling verb (components/views/chat/welcome-chat.tsx) */
|
||||
@keyframes hero-word-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(0.42em);
|
||||
filter: blur(6px);
|
||||
}
|
||||
60% {
|
||||
filter: blur(0);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
.hero-word-char {
|
||||
display: inline-block;
|
||||
white-space: pre;
|
||||
/* Solid fallback so the word is never invisible if text clipping is unsupported. */
|
||||
color: var(--brand-violet);
|
||||
animation: hero-word-in 0.5s cubic-bezier(0.2, 0.65, 0.3, 1) both;
|
||||
}
|
||||
|
||||
/*
|
||||
* Gradient fill per character. The clip lives on each animated span (not a
|
||||
* shared parent) because WebKit — used by the Tauri webview on macOS — drops
|
||||
* the parent's background when a child paints on its own transform/filter
|
||||
* layer, which would leave the animating letters blank. The -webkit- prefixes
|
||||
* are required by WebKit; @supports keeps the solid fallback above otherwise.
|
||||
*/
|
||||
@supports ((-webkit-background-clip: text) or (background-clip: text)) {
|
||||
.hero-word-char {
|
||||
background-image: linear-gradient(
|
||||
135deg,
|
||||
var(--brand-periwinkle),
|
||||
var(--brand-violet) 55%,
|
||||
var(--brand-magenta)
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hero-word-char {
|
||||
/* biome-ignore lint/complexity/noImportantStyles: reduced motion must override inline animation delay */
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Aurora background (components/ui/aurora-bg.tsx) */
|
||||
@keyframes aurora-drift {
|
||||
0% {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Analytics } from "@vercel/analytics/next";
|
||||
import type { Metadata } from "next";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -33,6 +34,7 @@ export default function RootLayout({
|
||||
<html className="h-full" lang="en">
|
||||
<body className="h-full min-h-screen font-sans antialiased">
|
||||
{children}
|
||||
<Toaster />
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -29,13 +29,16 @@ import {
|
||||
type SettingsSection,
|
||||
SettingsView,
|
||||
} from "@/components/views/settings/settings-view";
|
||||
import { AccountProvider } from "@/contexts/account-context";
|
||||
import { WorkspaceProvider } from "@/contexts/workspace-context";
|
||||
import type { PromptInQueue } from "@/hooks/chat-session/types";
|
||||
import { useAppUpdate } from "@/hooks/use-app-update";
|
||||
import { useChatSession } from "@/hooks/use-chat-session";
|
||||
import { useSessionHistory } from "@/hooks/use-session-history";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import type { ChatSessionConfig } from "@/lib/chat-schema";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import { syncDesktopWindowTitle } from "@/lib/desktop-window-title";
|
||||
import {
|
||||
getSessionMetadataTitle,
|
||||
type SessionHistoryItem,
|
||||
@@ -43,6 +46,7 @@ import {
|
||||
} from "@/lib/session-history";
|
||||
import { syncHubTheme, watchSystemHubTheme } from "@/lib/theme";
|
||||
import {
|
||||
filterWorkspacePaths,
|
||||
mergeWorkspacePaths,
|
||||
normalizeWorkspacePath,
|
||||
readWorkspaceSelectionFromWindow,
|
||||
@@ -81,11 +85,17 @@ export default function Home() {
|
||||
() => threads[0]?.id,
|
||||
);
|
||||
|
||||
useAppUpdate();
|
||||
|
||||
useEffect(() => {
|
||||
syncHubTheme();
|
||||
return watchSystemHubTheme();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void syncDesktopWindowTitle();
|
||||
}, []);
|
||||
|
||||
const handleNewThread = useCallback(() => {
|
||||
const id = makeThreadId();
|
||||
setThreads((prev) => [...prev, { id }]);
|
||||
@@ -219,63 +229,68 @@ export default function Home() {
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<div className="flex h-screen w-full overflow-hidden bg-background text-foreground">
|
||||
<Sidebar className="border-r border-sidebar-border" collapsible="icon">
|
||||
<AgentSidebar
|
||||
activeSessionId={activeHistorySessionId}
|
||||
isHomeActive={
|
||||
view === "chat" &&
|
||||
!activeThread?.historySession &&
|
||||
!activeThread?.hasStarted
|
||||
}
|
||||
onHome={handleHome}
|
||||
onNewThread={handleNewThread}
|
||||
onSettingsSectionChange={setSettingsSection}
|
||||
sessionHistory={sessionHistory}
|
||||
setView={setView}
|
||||
settingsSection={settingsSection}
|
||||
view={view}
|
||||
/>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
|
||||
<SidebarTrigger className="absolute left-3 top-3 z-40 md:hidden" />
|
||||
{view === "sessions" ? (
|
||||
<SessionsView
|
||||
<AccountProvider>
|
||||
<SidebarProvider>
|
||||
<div className="flex h-screen w-full overflow-hidden bg-background text-foreground">
|
||||
<Sidebar
|
||||
className="border-r border-sidebar-border"
|
||||
collapsible="icon"
|
||||
>
|
||||
<AgentSidebar
|
||||
activeSessionId={activeHistorySessionId}
|
||||
history={sessionHistory}
|
||||
isHomeActive={
|
||||
view === "chat" &&
|
||||
!activeThread?.historySession &&
|
||||
!activeThread?.hasStarted
|
||||
}
|
||||
onHome={handleHome}
|
||||
onNewThread={handleNewThread}
|
||||
onSettingsSectionChange={setSettingsSection}
|
||||
sessionHistory={sessionHistory}
|
||||
setView={setView}
|
||||
settingsSection={settingsSection}
|
||||
view={view}
|
||||
/>
|
||||
) : activeThread ? (
|
||||
<div
|
||||
aria-hidden={view === "settings" ? true : undefined}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
inert={view === "settings" ? true : undefined}
|
||||
>
|
||||
<ChatThreadPane
|
||||
key={activeThread.id}
|
||||
historySession={activeThread.historySession}
|
||||
knownWorkspacePaths={historyWorkspacePaths}
|
||||
onUpdateSessionMetadata={handleUpdateSessionMetadata}
|
||||
threadId={activeThread.id}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
onNewThread={handleNewThread}
|
||||
onOpenSession={handleOpenSession}
|
||||
onThreadStarted={handleThreadStarted}
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
|
||||
<SidebarTrigger className="absolute left-3 top-3 z-40 md:hidden" />
|
||||
{view === "sessions" ? (
|
||||
<SessionsView
|
||||
activeSessionId={activeHistorySessionId}
|
||||
history={sessionHistory}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{view === "settings" ? (
|
||||
<div className="absolute inset-0 z-30 bg-background text-foreground">
|
||||
<SettingsView
|
||||
onNavigateSection={setSettingsSection}
|
||||
section={settingsSection}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
) : activeThread ? (
|
||||
<div
|
||||
aria-hidden={view === "settings" ? true : undefined}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
inert={view === "settings" ? true : undefined}
|
||||
>
|
||||
<ChatThreadPane
|
||||
key={activeThread.id}
|
||||
historySession={activeThread.historySession}
|
||||
knownWorkspacePaths={historyWorkspacePaths}
|
||||
onUpdateSessionMetadata={handleUpdateSessionMetadata}
|
||||
threadId={activeThread.id}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
onNewThread={handleNewThread}
|
||||
onOpenSession={handleOpenSession}
|
||||
onThreadStarted={handleThreadStarted}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{view === "settings" ? (
|
||||
<div className="absolute inset-0 z-30 bg-background text-foreground">
|
||||
<SettingsView
|
||||
onNavigateSection={setSettingsSection}
|
||||
section={settingsSection}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</AccountProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -345,10 +360,14 @@ function ChatThreadPane({
|
||||
Record<string, { apiKey: string }>
|
||||
>({});
|
||||
const [providersLoaded, setProvidersLoaded] = useState(false);
|
||||
// History paths lead each merge: they are ordered by session recency, so
|
||||
// stored or stale entries only append after them.
|
||||
const [workspaces, setWorkspaces] = useState<string[]>(() =>
|
||||
mergeWorkspacePaths(
|
||||
readWorkspaceSelectionFromWindow().workspaces,
|
||||
knownWorkspacePaths,
|
||||
filterWorkspacePaths(
|
||||
mergeWorkspacePaths(
|
||||
knownWorkspacePaths,
|
||||
readWorkspaceSelectionFromWindow().workspaces,
|
||||
),
|
||||
),
|
||||
);
|
||||
const [workspacesLoaded, setWorkspacesLoaded] = useState(false);
|
||||
@@ -366,7 +385,9 @@ function ChatThreadPane({
|
||||
|
||||
useEffect(() => {
|
||||
setWorkspaces((current) => {
|
||||
const merged = mergeWorkspacePaths(current, knownWorkspacePaths);
|
||||
const merged = filterWorkspacePaths(
|
||||
mergeWorkspacePaths(knownWorkspacePaths, current),
|
||||
);
|
||||
return current.length === merged.length &&
|
||||
current.every((workspace, index) => workspace === merged[index])
|
||||
? current
|
||||
@@ -508,7 +529,12 @@ function ChatThreadPane({
|
||||
workspaceRef.current.cwd ||
|
||||
""
|
||||
).trim();
|
||||
return mergeWorkspacePaths(knownWorkspacePaths, [preferred, current]);
|
||||
// The active workspace can be an excluded path (restored session,
|
||||
// process cwd fallback); it renders via its own registration in the
|
||||
// selector and welcome screen instead of joining the catalog.
|
||||
return filterWorkspacePaths(
|
||||
mergeWorkspacePaths(knownWorkspacePaths, [preferred, current]),
|
||||
);
|
||||
},
|
||||
[knownWorkspacePaths],
|
||||
);
|
||||
@@ -518,7 +544,7 @@ function ChatThreadPane({
|
||||
try {
|
||||
const results = await listWorkspaces(preferredWorkspace);
|
||||
setWorkspaces((current) => {
|
||||
const merged = mergeWorkspacePaths(current, results);
|
||||
const merged = mergeWorkspacePaths(results, current);
|
||||
return current.length === merged.length &&
|
||||
current.every((workspace, index) => workspace === merged[index])
|
||||
? current
|
||||
@@ -562,7 +588,9 @@ function ChatThreadPane({
|
||||
workspaceRoot: nextWorkspace,
|
||||
cwd: nextWorkspace,
|
||||
}));
|
||||
setWorkspaces((prev) => mergeWorkspacePaths(prev, [nextWorkspace]));
|
||||
setWorkspaces((prev) =>
|
||||
filterWorkspacePaths(mergeWorkspacePaths(prev, [nextWorkspace])),
|
||||
);
|
||||
|
||||
// Fire git branch + workspace list refresh in the background
|
||||
desktopClient
|
||||
@@ -1079,6 +1107,7 @@ function ChatThreadPane({
|
||||
body={
|
||||
showDiffView ? (
|
||||
<DiffView
|
||||
cwd={config.cwd || config.workspaceRoot}
|
||||
fileDiffs={fileDiffs}
|
||||
onClose={() => setShowDiffView(false)}
|
||||
/>
|
||||
@@ -1104,7 +1133,10 @@ function ChatThreadPane({
|
||||
)
|
||||
}
|
||||
composer={composer}
|
||||
gitBranch={gitBranch}
|
||||
onListGitBranches={listGitBranches}
|
||||
onStartChat={setPromptInput}
|
||||
onSwitchGitBranch={switchGitBranch}
|
||||
quickActions={[]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,13 +3,21 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AgentSidebar } from "@/components/agent-sidebar";
|
||||
import {
|
||||
AgentSidebar,
|
||||
getSessionOverviewItems,
|
||||
getSessionOverviewTitle,
|
||||
} from "@/components/agent-sidebar";
|
||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { AccountProvider } from "@/contexts/account-context";
|
||||
import type {
|
||||
SessionThread,
|
||||
UseSessionHistoryResult,
|
||||
} from "@/hooks/use-session-history";
|
||||
|
||||
const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() }));
|
||||
vi.mock("@/lib/desktop-client", () => ({ desktopClient: { invoke } }));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
@@ -78,6 +86,9 @@ function sessionIsVisible(title: string): boolean {
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
window.localStorage.clear();
|
||||
invoke.mockReset();
|
||||
invoke.mockRejectedValue(new Error("No Cline account auth token found"));
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => ({
|
||||
@@ -101,6 +112,42 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("AgentSidebar session organization", () => {
|
||||
it("builds the hover overview with branch and secondary metadata last", () => {
|
||||
const thread = {
|
||||
...makeThread("cline", 5),
|
||||
gitBranch: "bee/session-overview",
|
||||
inputTokens: 3_000_000,
|
||||
outputTokens: 9_000,
|
||||
totalCostUsd: 3.06,
|
||||
};
|
||||
|
||||
expect(getSessionOverviewItems(thread)).toEqual([
|
||||
["Workspace", "cline", "/projects/cline"],
|
||||
["Git branch", "bee/session-overview"],
|
||||
["Provider", "cline"],
|
||||
["Model", "test-model"],
|
||||
["Tokens", "3009k"],
|
||||
["Cost", "$3.06"],
|
||||
["ID", "cline-5"],
|
||||
["Updated", "5m"],
|
||||
]);
|
||||
expect(getSessionOverviewItems(makeThread("cline", 5))).not.toContainEqual([
|
||||
"Git branch",
|
||||
expect.anything(),
|
||||
]);
|
||||
expect(
|
||||
getSessionOverviewItems(thread).some(([label]) => label === "Status"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("shows the full first line of the session title", () => {
|
||||
const firstLine =
|
||||
"This is a complete session title that is intentionally longer than seventy characters for the hover overview";
|
||||
expect(getSessionOverviewTitle(`${firstLine}\nSecond line`)).toBe(
|
||||
firstLine,
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults to time and keeps project expansion scoped to one project", async () => {
|
||||
const threads = [
|
||||
...Array.from({ length: 12 }, (_, index) =>
|
||||
@@ -125,8 +172,10 @@ describe("AgentSidebar session organization", () => {
|
||||
isHomeActive
|
||||
onHome={vi.fn()}
|
||||
onNewThread={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={sessionHistory}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
/>
|
||||
</SidebarProvider>,
|
||||
@@ -173,4 +222,154 @@ describe("AgentSidebar session organization", () => {
|
||||
await click(buttonWithText("Load older projects"));
|
||||
expect(loadOlderSessions).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("shows the signed-in account and active organization in the footer", async () => {
|
||||
invoke.mockResolvedValue({
|
||||
id: "user-1",
|
||||
email: "beatrix@cline.bot",
|
||||
displayName: "Beatrix",
|
||||
photoUrl: "",
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-01T00:00:00Z",
|
||||
organizations: [
|
||||
{
|
||||
active: true,
|
||||
memberId: "member-1",
|
||||
name: "Cline Bot Inc",
|
||||
organizationId: "org-1",
|
||||
roles: ["admin"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AccountProvider>
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
isHomeActive
|
||||
onHome={vi.fn()}
|
||||
onNewThread={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory([], vi.fn())}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
/>
|
||||
</SidebarProvider>
|
||||
</AccountProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(container.textContent).toContain("Beatrix");
|
||||
expect(container.textContent).toContain("Cline Bot Inc");
|
||||
});
|
||||
expect(container.textContent).not.toContain("Cline Desktop");
|
||||
expect(container.textContent).not.toContain("Local");
|
||||
});
|
||||
|
||||
it("opens the Account settings section when the footer account row is clicked", async () => {
|
||||
const setView = vi.fn();
|
||||
const onSettingsSectionChange = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AccountProvider>
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
isHomeActive
|
||||
onHome={vi.fn()}
|
||||
onNewThread={vi.fn()}
|
||||
onSettingsSectionChange={onSettingsSectionChange}
|
||||
sessionHistory={makeSessionHistory([], vi.fn())}
|
||||
setView={setView}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
/>
|
||||
</SidebarProvider>
|
||||
</AccountProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
const accountButton = container.querySelector(
|
||||
'[aria-label="Account settings"]',
|
||||
);
|
||||
expect(accountButton).not.toBeNull();
|
||||
await click(accountButton as Element);
|
||||
|
||||
expect(onSettingsSectionChange).toHaveBeenCalledWith("Account");
|
||||
expect(setView).toHaveBeenCalledWith("settings");
|
||||
});
|
||||
|
||||
it("shows the desktop app version in a popover when the Cline logo is clicked", async () => {
|
||||
const onHome = vi.fn();
|
||||
invoke.mockImplementation(async (command: string) => {
|
||||
if (command === "get_process_context") {
|
||||
return { appVersion: "1.2.3" };
|
||||
}
|
||||
throw new Error("No Cline account auth token found");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AccountProvider>
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
isHomeActive
|
||||
onHome={onHome}
|
||||
onNewThread={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory([], vi.fn())}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
/>
|
||||
</SidebarProvider>
|
||||
</AccountProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
const logoButton = container.querySelector('[aria-label="Cline home"]');
|
||||
expect(logoButton).not.toBeNull();
|
||||
expect(document.body.textContent).not.toContain("Version 1.2.3");
|
||||
|
||||
await click(logoButton as Element);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.textContent).toContain("Version 1.2.3");
|
||||
});
|
||||
expect(onHome).toHaveBeenCalled();
|
||||
expect(invoke).toHaveBeenCalledWith("get_process_context");
|
||||
});
|
||||
|
||||
it("falls back to a signed-out footer without account data", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AccountProvider>
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
isHomeActive
|
||||
onHome={vi.fn()}
|
||||
onNewThread={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory([], vi.fn())}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
/>
|
||||
</SidebarProvider>
|
||||
</AccountProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(container.textContent).toContain("Cline Desktop");
|
||||
});
|
||||
expect(container.textContent).not.toContain("Local");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Activity,
|
||||
ArrowDownUp,
|
||||
Blocks,
|
||||
Bot,
|
||||
ChevronDown,
|
||||
CircleUserRound,
|
||||
Clock3,
|
||||
Code,
|
||||
FileText,
|
||||
Filter,
|
||||
FolderTree,
|
||||
GitFork,
|
||||
Home,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
PanelLeftOpen,
|
||||
Pencil,
|
||||
Pin,
|
||||
Plug,
|
||||
Plus,
|
||||
Radio,
|
||||
Search,
|
||||
Server,
|
||||
Settings,
|
||||
SlidersHorizontal,
|
||||
Store,
|
||||
Trash2,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
type ReactNode,
|
||||
@@ -64,18 +66,26 @@ import {
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { normalizeTitle } from "@/components/utils";
|
||||
import {
|
||||
CUSTOMIZATION_SECTIONS,
|
||||
SETTINGS_SECTIONS,
|
||||
type SettingsSection,
|
||||
} from "@/components/views/settings/settings-view";
|
||||
import { useAccount } from "@/contexts/account-context";
|
||||
import type {
|
||||
SessionThread,
|
||||
UseSessionHistoryResult,
|
||||
} from "@/hooks/use-session-history";
|
||||
import { formatCostUsd, formatTokenCount } from "@/hooks/use-session-history";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import {
|
||||
groupThreadsByProject,
|
||||
INITIAL_VISIBLE_THREAD_COUNT,
|
||||
@@ -92,12 +102,16 @@ type SidebarSortMode = "time" | "project";
|
||||
const SETTINGS_SECTION_ICONS = {
|
||||
General: SlidersHorizontal,
|
||||
Models: Bot,
|
||||
"MCP Servers": Server,
|
||||
"MCP Marketplace": Store,
|
||||
Customizations: Blocks,
|
||||
Channels: Radio,
|
||||
Schedules: Clock3,
|
||||
Account: CircleUserRound,
|
||||
Plugins: Plug,
|
||||
Skills: Activity,
|
||||
MCP: Server,
|
||||
Hooks: Code,
|
||||
Rules: FileText,
|
||||
Agents: Bot,
|
||||
Tools: Wrench,
|
||||
} satisfies Record<SettingsSection, typeof Settings>;
|
||||
|
||||
function SettingsSectionNavigation({
|
||||
@@ -109,6 +123,30 @@ function SettingsSectionNavigation({
|
||||
collapsed: boolean;
|
||||
onSelect: (section: SettingsSection) => void;
|
||||
}) {
|
||||
const renderSectionButton = (section: SettingsSection) => {
|
||||
const Icon = SETTINGS_SECTION_ICONS[section];
|
||||
return (
|
||||
<Button
|
||||
aria-current={activeSection === section ? "page" : undefined}
|
||||
aria-label={section}
|
||||
className={cn(
|
||||
"min-w-0 justify-start",
|
||||
activeSection === section &&
|
||||
"bg-sidebar-accent text-sidebar-accent-foreground",
|
||||
collapsed && "mx-auto size-9 justify-center px-0",
|
||||
)}
|
||||
key={section}
|
||||
onClick={() => onSelect(section)}
|
||||
title={section}
|
||||
type="button"
|
||||
variant="sidebarItem"
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
{!collapsed ? <span className="truncate">{section}</span> : null}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Settings sections"
|
||||
@@ -122,29 +160,15 @@ function SettingsSectionNavigation({
|
||||
Settings
|
||||
</p>
|
||||
) : null}
|
||||
{SETTINGS_SECTIONS.map((section) => {
|
||||
const Icon = SETTINGS_SECTION_ICONS[section];
|
||||
return (
|
||||
<Button
|
||||
aria-current={activeSection === section ? "page" : undefined}
|
||||
aria-label={section}
|
||||
className={cn(
|
||||
"min-w-0 justify-start",
|
||||
activeSection === section &&
|
||||
"bg-sidebar-accent text-sidebar-accent-foreground",
|
||||
collapsed && "mx-auto size-9 justify-center px-0",
|
||||
)}
|
||||
key={section}
|
||||
onClick={() => onSelect(section)}
|
||||
title={section}
|
||||
type="button"
|
||||
variant="sidebarItem"
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
{!collapsed ? <span className="truncate">{section}</span> : null}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{SETTINGS_SECTIONS.map(renderSectionButton)}
|
||||
{!collapsed ? (
|
||||
<p className="px-2 pb-2 pt-4 text-sm font-medium text-muted-foreground">
|
||||
Customizations
|
||||
</p>
|
||||
) : (
|
||||
<div className="my-2 h-px w-6 shrink-0 bg-sidebar-border" />
|
||||
)}
|
||||
{CUSTOMIZATION_SECTIONS.map(renderSectionButton)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -172,6 +196,14 @@ export function AgentSidebar({
|
||||
}) {
|
||||
const { isMobile, setOpen, setOpenMobile, state } = useSidebar();
|
||||
const isCollapsed = !isMobile && state === "collapsed";
|
||||
const { user, activeOrganization } = useAccount();
|
||||
const { displayName, email } = user || {};
|
||||
const username = displayName?.split(" ")?.[0] || email?.split("@")?.[0];
|
||||
const accountName = username?.trim() || "Cline Desktop";
|
||||
const accountScope = user
|
||||
? (activeOrganization?.name ?? "Personal")
|
||||
: undefined;
|
||||
const accountInitial = accountName.charAt(0).toUpperCase();
|
||||
const {
|
||||
deleteThread: deleteHistoryThread,
|
||||
forkThread: forkHistoryThread,
|
||||
@@ -205,6 +237,26 @@ export function AgentSidebar({
|
||||
const [projectVisibleCounts, setProjectVisibleCounts] = useState<
|
||||
Record<string, number>
|
||||
>({});
|
||||
const [appVersion, setAppVersion] = useState<string | null>(null);
|
||||
|
||||
const loadAppVersion = useCallback(async () => {
|
||||
try {
|
||||
const context = await desktopClient.invoke<{ appVersion?: unknown }>(
|
||||
"get_process_context",
|
||||
);
|
||||
const version =
|
||||
typeof context?.appVersion === "string"
|
||||
? context.appVersion.trim()
|
||||
: "";
|
||||
setAppVersion(version || null);
|
||||
} catch {
|
||||
// Leave the version hidden; an older sidecar build has no appVersion.
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadAppVersion();
|
||||
}, [loadAppVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCollapsed && searchOpen) {
|
||||
@@ -446,14 +498,31 @@ export function AgentSidebar({
|
||||
isCollapsed && "justify-center px-0",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
aria-label="Cline home"
|
||||
className="rounded-md p-1 text-sidebar-foreground transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
|
||||
onClick={openHome}
|
||||
type="button"
|
||||
<Popover
|
||||
onOpenChange={(open) => {
|
||||
if (open && !appVersion) {
|
||||
void loadAppVersion();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ClineLogo className="h-6 w-6" />
|
||||
</button>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
aria-label="Cline home"
|
||||
className="flex items-center gap-2 rounded-md p-1 text-sidebar-foreground transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
|
||||
type="button"
|
||||
onClick={openHome}
|
||||
title="Home"
|
||||
>
|
||||
<ClineLogo className="h-6 w-6" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-52 p-3" side="bottom">
|
||||
<p className="text-sm font-medium">Cline Code</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{appVersion ? `Version ${appVersion}` : "Version unavailable"}
|
||||
</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className={cn("shrink-0 px-3", isCollapsed && "px-1.5")}>
|
||||
@@ -465,13 +534,13 @@ export function AgentSidebar({
|
||||
"bg-sidebar-accent text-sidebar-accent-foreground",
|
||||
isCollapsed && "mx-auto size-9 justify-center px-0",
|
||||
)}
|
||||
aria-label="Home"
|
||||
aria-label="New Session"
|
||||
onClick={openHome}
|
||||
title="Home"
|
||||
title="New Session"
|
||||
variant="sidebarItem"
|
||||
>
|
||||
<Home className="size-4" />
|
||||
{!isCollapsed ? "Home" : null}
|
||||
<Plus className="size-4" />
|
||||
{!isCollapsed ? "New Session" : null}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -542,17 +611,6 @@ export function AgentSidebar({
|
||||
</Button>
|
||||
{sortMenu}
|
||||
{filterMenu}
|
||||
<Button
|
||||
aria-label="New session"
|
||||
className="m-0! size-8 p-0! text-muted-foreground hover:text-sidebar-foreground"
|
||||
onClick={openNewThread}
|
||||
size="icon"
|
||||
title="New session"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{searchOpen ? (
|
||||
@@ -679,36 +737,47 @@ export function AgentSidebar({
|
||||
)}
|
||||
|
||||
<div className="shrink-0 border-t border-sidebar-border/70 px-2 py-3">
|
||||
<Button
|
||||
aria-label="Settings"
|
||||
type="button"
|
||||
variant="sidebarItem"
|
||||
className={cn(
|
||||
"min-w-0 justify-start",
|
||||
view === "settings" &&
|
||||
"bg-sidebar-accent text-sidebar-accent-foreground",
|
||||
isCollapsed && "mx-auto size-9 justify-center px-0",
|
||||
)}
|
||||
onClick={openSettings}
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
{!isCollapsed ? "Settings" : null}
|
||||
</Button>
|
||||
{view !== "settings" && (
|
||||
<Button
|
||||
aria-label="Settings"
|
||||
type="button"
|
||||
variant="sidebarItem"
|
||||
className={cn(
|
||||
"min-w-0 justify-start",
|
||||
isCollapsed && "mx-auto size-9 justify-center px-0",
|
||||
)}
|
||||
onClick={openSettings}
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
{!isCollapsed ? "Settings" : null}
|
||||
</Button>
|
||||
)}
|
||||
{!isCollapsed ? (
|
||||
<div className="mt-2 flex items-center gap-2 rounded-md px-3 py-2 text-sidebar-foreground">
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-primary text-[11px] font-semibold text-primary-foreground">
|
||||
C
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<button
|
||||
aria-label="Account settings"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sidebar-foreground transition-colors hover:bg-sidebar-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
||||
view === "settings" &&
|
||||
settingsSection === "Account" &&
|
||||
"bg-sidebar-accent text-sidebar-accent-foreground",
|
||||
)}
|
||||
onClick={() => openSettingsSection("Account")}
|
||||
title={user?.email || undefined}
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0 flex gap-2 items-center">
|
||||
<span className="flex size-4 shrink-0 items-center justify-center rounded-full bg-primary text-[11px] font-semibold text-primary-foreground">
|
||||
{accountInitial}
|
||||
</span>
|
||||
<span className="block truncate text-sm font-medium">
|
||||
Cline Desktop
|
||||
</span>
|
||||
<span className="block text-[11px] text-muted-foreground">
|
||||
Local
|
||||
{accountName}
|
||||
<span className="pl-1 truncate text-[11px] text-muted-foreground">
|
||||
{accountScope}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -823,11 +892,9 @@ function ThreadItem({
|
||||
pendingAction: "rename" | "fork" | "delete" | null;
|
||||
unread: boolean;
|
||||
}) {
|
||||
const tokenLabel = formatTokenCount(thread.inputTokens, thread.outputTokens);
|
||||
const costLabel = formatCostUsd(thread.totalCostUsd);
|
||||
const title = normalizeTitle(thread.title);
|
||||
const overviewTitle = getSessionOverviewTitle(thread.title);
|
||||
const pending = pendingAction !== null;
|
||||
const workspacePath = thread.workspacePath || thread.codebase;
|
||||
const statusDotClass = pending
|
||||
? "bg-yellow-400"
|
||||
: thread.status === "running"
|
||||
@@ -835,20 +902,7 @@ function ThreadItem({
|
||||
: unread
|
||||
? "bg-blue-500"
|
||||
: "";
|
||||
const infoItems: Array<[string, string | null | undefined, string?]> = [
|
||||
["ID", thread.id],
|
||||
[
|
||||
"Workspace",
|
||||
workspaceDisplayName(workspacePath),
|
||||
workspacePath || undefined,
|
||||
],
|
||||
["Status", thread.status],
|
||||
["Updated", thread.time],
|
||||
["Provider", thread.provider],
|
||||
["Model", thread.model],
|
||||
["Tokens", tokenLabel],
|
||||
["Cost", costLabel],
|
||||
].filter((item): item is [string, string, string?] => Boolean(item[1]));
|
||||
const infoItems = getSessionOverviewItems(thread);
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
@@ -881,7 +935,7 @@ function ThreadItem({
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"group grid h-8 w-full max-w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-2 overflow-hidden rounded-md px-2 text-left text-sm font-normal transition-colors",
|
||||
"group grid h-8 w-full max-w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-1 overflow-hidden rounded-md px-2 text-left text-sm font-normal transition-colors",
|
||||
isActive
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground/80 hover:bg-sidebar-accent/50",
|
||||
@@ -915,7 +969,9 @@ function ThreadItem({
|
||||
sideOffset={8}
|
||||
>
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="truncate text-sm font-medium">{title}</div>
|
||||
<div className="wrap-break-word text-sm font-medium">
|
||||
{overviewTitle}
|
||||
</div>
|
||||
<div className="grid grid-cols-[72px_minmax(0,1fr)] gap-x-2 gap-y-1 text-xs">
|
||||
{infoItems.map(([label, value, fullValue]) => (
|
||||
<div className="contents" key={label}>
|
||||
@@ -942,6 +998,34 @@ function ThreadItem({
|
||||
);
|
||||
}
|
||||
|
||||
export function getSessionOverviewTitle(title: string): string {
|
||||
const firstLine = title.split(/\r?\n/, 1)[0] ?? "";
|
||||
return normalizeTitle(firstLine);
|
||||
}
|
||||
|
||||
export function getSessionOverviewItems(
|
||||
thread: SessionThread,
|
||||
): Array<[string, string, string?]> {
|
||||
const workspacePath = thread.workspacePath || thread.codebase;
|
||||
const items: Array<[string, string | null | undefined, string?]> = [
|
||||
[
|
||||
"Workspace",
|
||||
workspaceDisplayName(workspacePath),
|
||||
workspacePath || undefined,
|
||||
],
|
||||
["Git branch", thread.gitBranch],
|
||||
["Provider", thread.provider],
|
||||
["Model", thread.model],
|
||||
["Tokens", formatTokenCount(thread.inputTokens, thread.outputTokens)],
|
||||
["Cost", formatCostUsd(thread.totalCostUsd)],
|
||||
["ID", thread.id],
|
||||
["Updated", thread.time],
|
||||
];
|
||||
return items.filter((item): item is [string, string, string?] =>
|
||||
Boolean(item[1]),
|
||||
);
|
||||
}
|
||||
|
||||
function EditableSessionTitle({
|
||||
value,
|
||||
disabled,
|
||||
|
||||
@@ -313,8 +313,8 @@ function Sidebar({
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||
side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
? "left-0 group-data-[collapsible=offcanvas]:-left-(--sidebar-width)"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:-right-(--sidebar-width)",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
|
||||
@@ -13,14 +13,6 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -42,6 +34,7 @@ import {
|
||||
loadProviderModels,
|
||||
} from "@/lib/provider-model-catalog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SearchableSelect } from "./searchable-select";
|
||||
import { WorkspaceSelector } from "./workspace-selector";
|
||||
|
||||
type ActiveMention = {
|
||||
@@ -1045,7 +1038,7 @@ export function ChatInputBar({
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
/>
|
||||
<div className="flex shrink-0 items-center rounded-md bg-muted p-0.5 max-[560px]:col-start-2 max-[560px]:row-start-1">
|
||||
<div className="hidden flex shrink-0 items-center rounded-md bg-muted p-0.5 max-[560px]:col-start-2 max-[560px]:row-start-1">
|
||||
<button
|
||||
aria-pressed={mode === "plan"}
|
||||
className={cn(
|
||||
@@ -1087,7 +1080,6 @@ export function ChatInputBar({
|
||||
}
|
||||
onProviderChange={onProviderChange}
|
||||
provider={provider}
|
||||
variant={variant}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
@@ -1097,7 +1089,7 @@ export function ChatInputBar({
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label="Thinking level"
|
||||
className="h-7 min-w-[5.75rem] gap-1.5 border-0 bg-muted px-2 text-[11px] shadow-none data-[size=sm]:h-7 max-[560px]:col-span-2 max-[560px]:col-start-1 max-[560px]:row-start-2"
|
||||
className="h-7 gap-1.5 border-0 bg-muted px-2 text-[11px] shadow-none data-[size=sm]:h-7 [&>svg:last-child]:hidden max-[560px]:col-span-2 max-[560px]:col-start-1 max-[560px]:row-start-2"
|
||||
size="sm"
|
||||
title={
|
||||
modelSupportsReasoning === false
|
||||
@@ -1128,7 +1120,7 @@ export function ChatInputBar({
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex min-w-0 shrink-0 items-center gap-2 max-[560px]:contents">
|
||||
<div className="max-w-48 overflow-visible max-[720px]:max-w-36 max-[560px]:col-start-3 max-[560px]:row-start-2">
|
||||
<div className="hidden max-w-48 overflow-visible max-[720px]:max-w-36 max-[560px]:col-start-3 max-[560px]:row-start-2">
|
||||
<WorkspaceSelector
|
||||
currentBranch={gitBranch}
|
||||
onListGitBranches={onListGitBranches}
|
||||
@@ -1181,7 +1173,6 @@ function ModelSelector({
|
||||
provider,
|
||||
model,
|
||||
isBusy,
|
||||
variant,
|
||||
onProviderChange,
|
||||
onModelChange,
|
||||
onModelSupportsReasoningChange,
|
||||
@@ -1189,7 +1180,6 @@ function ModelSelector({
|
||||
provider: string;
|
||||
model: string;
|
||||
isBusy: boolean;
|
||||
variant: "conversation" | "welcome";
|
||||
onProviderChange: (provider: string) => void;
|
||||
onModelChange: (model: string) => void;
|
||||
onModelSupportsReasoningChange: (supportsReasoning: boolean | null) => void;
|
||||
@@ -1416,13 +1406,13 @@ function ModelSelector({
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-1 text-[11px]">
|
||||
<Combobox
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-0.5 text-[11px]">
|
||||
<SearchableSelect
|
||||
ariaLabel="Provider"
|
||||
disabled={isBusy || providers.length === 0}
|
||||
emptyLabel="No providers found."
|
||||
items={providers}
|
||||
onValueChange={(value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
onSelect={(value) => {
|
||||
onProviderChange(value);
|
||||
const rememberedModel = lastSelection.lastModelByProvider[value];
|
||||
const providerModelIds = visibleProviderModels[value] ?? [];
|
||||
@@ -1439,63 +1429,23 @@ function ModelSelector({
|
||||
onModelChange(firstModel);
|
||||
}
|
||||
}}
|
||||
placeholder="Provider"
|
||||
searchPlaceholder="Search providers"
|
||||
triggerClassName="max-w-28 text-[11px]"
|
||||
value={resolvedProvider}
|
||||
>
|
||||
<ComboboxInput
|
||||
aria-label="Provider"
|
||||
className={cn(
|
||||
"h-7 text-[11px] max-[560px]:w-20",
|
||||
variant === "welcome" && "w-24 border-0 bg-transparent shadow-none",
|
||||
)}
|
||||
disabled={isBusy || providers.length === 0}
|
||||
readOnly
|
||||
showClear={false}
|
||||
showTrigger
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No providers found.</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(item) => (
|
||||
<ComboboxItem className="text-[11px]" key={item} value={item}>
|
||||
{item}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
|
||||
<Combobox
|
||||
/>
|
||||
<span className="text-muted-foreground/50">/</span>
|
||||
<SearchableSelect
|
||||
ariaLabel="Model"
|
||||
disabled={isBusy || modelsForProvider.length === 0}
|
||||
emptyLabel="No models found."
|
||||
items={modelsForProvider}
|
||||
onValueChange={(value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
onModelChange(value);
|
||||
}}
|
||||
onSelect={(value) => onModelChange(value)}
|
||||
placeholder="Model"
|
||||
searchPlaceholder="Search models"
|
||||
triggerClassName="max-w-52 text-[11px]"
|
||||
value={resolvedModel}
|
||||
>
|
||||
<ComboboxInput
|
||||
aria-label="Model"
|
||||
className={cn(
|
||||
"h-7 text-[11px] max-[560px]:w-32",
|
||||
variant === "welcome" && "w-52 border-0 bg-transparent shadow-none",
|
||||
)}
|
||||
disabled={isBusy || modelsForProvider.length === 0}
|
||||
readOnly
|
||||
showClear={false}
|
||||
showTrigger
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No models found.</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(item) => (
|
||||
<ComboboxItem className="text-[11px]" key={item} value={item}>
|
||||
{item}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,10 @@ afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function renderMessages(messages: ChatMessage[]) {
|
||||
async function renderMessages(
|
||||
messages: ChatMessage[],
|
||||
overrides: Partial<Parameters<typeof ChatMessages>[0]> = {},
|
||||
) {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ChatMessages
|
||||
@@ -37,6 +40,7 @@ async function renderMessages(messages: ChatMessage[]) {
|
||||
pendingToolApprovals={[]}
|
||||
sessionId="session-1"
|
||||
status="completed"
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
@@ -90,4 +94,251 @@ describe("ChatMessages tool disclosures", () => {
|
||||
"workspace selector",
|
||||
);
|
||||
});
|
||||
|
||||
it("groups consecutive tool calls and combines matching activity totals", async () => {
|
||||
const tools: ChatMessage[] = [
|
||||
{
|
||||
id: "read",
|
||||
sessionId: "session-1",
|
||||
role: "tool",
|
||||
content: JSON.stringify({
|
||||
toolName: "read_files",
|
||||
input: { paths: ["one.ts", "two.ts"] },
|
||||
result: {},
|
||||
}),
|
||||
createdAt: 1,
|
||||
},
|
||||
...["one.ts", "two.ts", "three.ts", "four.ts"].map(
|
||||
(path, index): ChatMessage => ({
|
||||
id: `edit-${index}`,
|
||||
sessionId: "session-1",
|
||||
role: "tool",
|
||||
content: JSON.stringify({
|
||||
toolName: "editor",
|
||||
input: { path, old_text: "before", new_text: "after" },
|
||||
result: {},
|
||||
}),
|
||||
createdAt: index + 2,
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
await renderMessages(tools);
|
||||
|
||||
expect(container.textContent).toContain("Read 2 files. Edited 4 files");
|
||||
expect(container.textContent?.match(/Read 2 files/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("preserves interleaved tool activity order", async () => {
|
||||
const read = (
|
||||
id: string,
|
||||
path: string,
|
||||
createdAt: number,
|
||||
): ChatMessage => ({
|
||||
id,
|
||||
sessionId: "session-1",
|
||||
role: "tool",
|
||||
content: JSON.stringify({
|
||||
toolName: "read_files",
|
||||
input: { paths: [path] },
|
||||
result: {},
|
||||
}),
|
||||
createdAt,
|
||||
});
|
||||
|
||||
await renderMessages([
|
||||
read("read-before", "before.ts", 1),
|
||||
{
|
||||
id: "edit",
|
||||
sessionId: "session-1",
|
||||
role: "tool",
|
||||
content: JSON.stringify({
|
||||
toolName: "editor",
|
||||
input: {
|
||||
path: "change.ts",
|
||||
old_text: "before",
|
||||
new_text: "after",
|
||||
},
|
||||
result: {},
|
||||
}),
|
||||
createdAt: 2,
|
||||
},
|
||||
read("read-after", "after.ts", 3),
|
||||
]);
|
||||
|
||||
expect(container.textContent).toContain(
|
||||
"Read 1 file. Edited 1 file. Read 1 file",
|
||||
);
|
||||
});
|
||||
|
||||
it("starts a new tool group after non-tool content", async () => {
|
||||
const tool = (id: string, createdAt: number): ChatMessage => ({
|
||||
id,
|
||||
sessionId: "session-1",
|
||||
role: "tool",
|
||||
content: JSON.stringify({
|
||||
toolName: "read_files",
|
||||
input: { paths: [`${id}.ts`] },
|
||||
result: {},
|
||||
}),
|
||||
createdAt,
|
||||
});
|
||||
|
||||
await renderMessages([
|
||||
tool("first", 1),
|
||||
{
|
||||
id: "assistant",
|
||||
sessionId: "session-1",
|
||||
role: "assistant",
|
||||
content: "Between tools",
|
||||
createdAt: 2,
|
||||
},
|
||||
tool("second", 3),
|
||||
]);
|
||||
|
||||
expect(container.textContent?.match(/Read 1 file/g)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("normalizes payload-backed configured subagent names", async () => {
|
||||
await renderMessages([
|
||||
{
|
||||
id: "commands",
|
||||
sessionId: "session-1",
|
||||
role: "tool",
|
||||
content: JSON.stringify({
|
||||
toolName: "run_commands",
|
||||
input: { commands: ["bun test", "bun run typecheck"] },
|
||||
result: {},
|
||||
}),
|
||||
createdAt: 1,
|
||||
},
|
||||
...[2, 3, 4].map(
|
||||
(createdAt): ChatMessage => ({
|
||||
id: `configured-subagent-${createdAt}`,
|
||||
sessionId: "session-1",
|
||||
role: "tool",
|
||||
content: JSON.stringify({
|
||||
toolName: "subagent_subagent",
|
||||
input: { prompt: "Investigate" },
|
||||
result: { text: "Done" },
|
||||
}),
|
||||
createdAt,
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(container.textContent).toContain(
|
||||
"Ran 2 commands. spawn_agent. spawn_agent. spawn_agent",
|
||||
);
|
||||
expect(container.textContent).not.toContain("subagent_subagent");
|
||||
});
|
||||
|
||||
it("does not render assistant actions without text content", async () => {
|
||||
await renderMessages([
|
||||
{
|
||||
id: "reasoning-only",
|
||||
sessionId: "session-1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "Internal reasoning",
|
||||
createdAt: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
container.querySelector('button[aria-label="Copy assistant message"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatMessages thinking indicator", () => {
|
||||
const userMessage: ChatMessage = {
|
||||
id: "user-1",
|
||||
sessionId: "session-1",
|
||||
role: "user",
|
||||
content: "Hello",
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
it("shows while starting", async () => {
|
||||
await renderMessages([userMessage], { status: "starting" });
|
||||
expect(container.textContent).toContain("Thinking...");
|
||||
});
|
||||
|
||||
it("keeps showing while running until the first assistant output arrives", async () => {
|
||||
await renderMessages([userMessage], { status: "running" });
|
||||
expect(container.textContent).toContain("Thinking...");
|
||||
});
|
||||
|
||||
it("ignores trailing status messages when deciding to show", async () => {
|
||||
await renderMessages(
|
||||
[
|
||||
userMessage,
|
||||
{
|
||||
id: "status-1",
|
||||
sessionId: "session-1",
|
||||
role: "status",
|
||||
content: "Session started: session-1",
|
||||
createdAt: 2,
|
||||
},
|
||||
],
|
||||
{ status: "running" },
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("Thinking...");
|
||||
});
|
||||
|
||||
it("hides once assistant output is streaming", async () => {
|
||||
await renderMessages(
|
||||
[
|
||||
userMessage,
|
||||
{
|
||||
id: "assistant-1",
|
||||
sessionId: "session-1",
|
||||
role: "assistant",
|
||||
content: "Working on it",
|
||||
createdAt: 2,
|
||||
},
|
||||
],
|
||||
{ status: "running", streamingMessageId: "assistant-1" },
|
||||
);
|
||||
|
||||
expect(container.textContent).not.toContain("Thinking...");
|
||||
});
|
||||
|
||||
it("hides while a tool runs", async () => {
|
||||
await renderMessages(
|
||||
[
|
||||
userMessage,
|
||||
{
|
||||
id: "tool-1",
|
||||
sessionId: "session-1",
|
||||
role: "tool",
|
||||
content: "not-json",
|
||||
createdAt: 2,
|
||||
meta: { toolName: "search" },
|
||||
},
|
||||
],
|
||||
{ status: "running" },
|
||||
);
|
||||
|
||||
expect(container.textContent).not.toContain("Thinking...");
|
||||
});
|
||||
|
||||
it("hides while a tool approval is pending", async () => {
|
||||
await renderMessages([userMessage], {
|
||||
status: "running",
|
||||
pendingToolApprovals: [
|
||||
{
|
||||
requestId: "req-1",
|
||||
sessionId: "session-1",
|
||||
createdAt: new Date(1).toISOString(),
|
||||
toolCallId: "call-1",
|
||||
toolName: "execute_command",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(container.textContent).not.toContain("Thinking...");
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,182 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SessionFileDiff } from "@/lib/session-diff";
|
||||
import { DiffView } from "./diff-view";
|
||||
|
||||
const { invokeMock } = vi.hoisted(() => ({
|
||||
invokeMock: vi.fn(async (command: string) =>
|
||||
command === "list_available_editors"
|
||||
? [{ id: "vscode", label: "VS Code" }]
|
||||
: { path: "/repo/docs/a.mdx", editor: "VS Code" },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/desktop-client", () => ({
|
||||
desktopClient: { invoke: invokeMock },
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let writeText: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
// jsdom lacks the layout/pointer APIs the Radix dropdown menu touches.
|
||||
if (!("ResizeObserver" in globalThis)) {
|
||||
Object.assign(globalThis, {
|
||||
ResizeObserver: class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
});
|
||||
}
|
||||
Element.prototype.scrollIntoView ??= () => {};
|
||||
Element.prototype.hasPointerCapture ??= () => false;
|
||||
Element.prototype.setPointerCapture ??= () => {};
|
||||
Element.prototype.releasePointerCapture ??= () => {};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
invokeMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function click(element: Element): Promise<void> {
|
||||
await act(async () => {
|
||||
element.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true, cancelable: true }),
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
// Radix dropdown triggers open on pointerdown, not click.
|
||||
async function pointerDown(element: Element): Promise<void> {
|
||||
await act(async () => {
|
||||
element.dispatchEvent(
|
||||
new MouseEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
}),
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function buttonWithLabel(label: string): HTMLButtonElement {
|
||||
const button = container.querySelector<HTMLButtonElement>(
|
||||
`button[aria-label="${label}"]`,
|
||||
);
|
||||
expect(button).not.toBeNull();
|
||||
return button as HTMLButtonElement;
|
||||
}
|
||||
|
||||
// Menu items render in a portal attached to document.body.
|
||||
function menuItems(): HTMLElement[] {
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
||||
);
|
||||
}
|
||||
|
||||
const FILE_DIFF: SessionFileDiff = {
|
||||
path: "docs/a.mdx",
|
||||
additions: 2,
|
||||
deletions: 1,
|
||||
hunks: [],
|
||||
};
|
||||
|
||||
describe("DiffView file actions", () => {
|
||||
it("copies the cwd-resolved absolute file path", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<DiffView
|
||||
cwd="/Users/renee/cline"
|
||||
fileDiffs={[FILE_DIFF]}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await click(buttonWithLabel("Copy file path for docs/a.mdx"));
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith("/Users/renee/cline/docs/a.mdx");
|
||||
});
|
||||
|
||||
it("opens the file in a chosen editor through the desktop backend", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<DiffView
|
||||
cwd="/Users/renee/cline"
|
||||
fileDiffs={[FILE_DIFF]}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await pointerDown(buttonWithLabel("Open docs/a.mdx in editor"));
|
||||
|
||||
const labels = menuItems().map((item) => item.textContent);
|
||||
expect(labels).toEqual(["VS Code", "System default"]);
|
||||
|
||||
const vscodeItem = menuItems().find(
|
||||
(item) => item.textContent === "VS Code",
|
||||
);
|
||||
await click(vscodeItem as Element);
|
||||
|
||||
expect(invokeMock).toHaveBeenCalledWith("open_file_in_editor", {
|
||||
path: "docs/a.mdx",
|
||||
cwd: "/Users/renee/cline",
|
||||
editor: "vscode",
|
||||
});
|
||||
});
|
||||
|
||||
it("still offers the system default opener when editor detection fails", async () => {
|
||||
invokeMock.mockImplementation(async (command: string) => {
|
||||
if (command === "list_available_editors") {
|
||||
throw new Error("unsupported desktop command");
|
||||
}
|
||||
return { path: "/repo/docs/a.mdx", editor: "system default" };
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(<DiffView fileDiffs={[FILE_DIFF]} onClose={vi.fn()} />);
|
||||
});
|
||||
|
||||
await pointerDown(buttonWithLabel("Open docs/a.mdx in editor"));
|
||||
|
||||
const labels = menuItems().map((item) => item.textContent);
|
||||
expect(labels).toEqual(["System default"]);
|
||||
|
||||
await click(menuItems()[0] as Element);
|
||||
|
||||
expect(invokeMock).toHaveBeenCalledWith("open_file_in_editor", {
|
||||
path: "docs/a.mdx",
|
||||
editor: "default",
|
||||
});
|
||||
});
|
||||
|
||||
it("copies the path as-is when no cwd is available", async () => {
|
||||
await act(async () => {
|
||||
root.render(<DiffView fileDiffs={[FILE_DIFF]} onClose={vi.fn()} />);
|
||||
});
|
||||
|
||||
await click(buttonWithLabel("Copy file path for docs/a.mdx"));
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith("docs/a.mdx");
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronRight, Minus, Plus, X } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
AppWindow,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Minus,
|
||||
Plus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import type { SessionFileDiff } from "@/lib/session-diff";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resolveWorkspaceFilePath } from "@/lib/workspace-paths";
|
||||
import { EditorIcon } from "./editor-icons";
|
||||
|
||||
type DiffViewProps = {
|
||||
fileDiffs: SessionFileDiff[];
|
||||
cwd?: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function DiffView({ fileDiffs, onClose }: DiffViewProps) {
|
||||
type EditorOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export function DiffView({ fileDiffs, cwd, onClose }: DiffViewProps) {
|
||||
const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(new Set());
|
||||
const [editors, setEditors] = useState<EditorOption[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
desktopClient
|
||||
.invoke<EditorOption[]>("list_available_editors")
|
||||
.then((list) => {
|
||||
if (!cancelled && Array.isArray(list)) setEditors(list);
|
||||
})
|
||||
.catch(() => {
|
||||
// Older sidecars don't support the command; the menu still
|
||||
// offers the system default opener.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const _totals = useMemo(
|
||||
() =>
|
||||
@@ -40,8 +85,8 @@ export function DiffView({ fileDiffs, onClose }: DiffViewProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex h-10 items-center justify-between border-b border-border bg-card px-4">
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden">
|
||||
<div className="flex h-10 shrink-0 items-center justify-between border-b border-border bg-card px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
Uncommitted changes
|
||||
@@ -64,7 +109,7 @@ export function DiffView({ fileDiffs, onClose }: DiffViewProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
{fileDiffs.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center px-4 py-16 text-sm text-muted-foreground">
|
||||
No file changes in this session yet.
|
||||
@@ -74,6 +119,8 @@ export function DiffView({ fileDiffs, onClose }: DiffViewProps) {
|
||||
{fileDiffs.map((file) => (
|
||||
<DiffFileSection
|
||||
collapsed={collapsedFiles.has(file.path)}
|
||||
cwd={cwd}
|
||||
editors={editors}
|
||||
file={file}
|
||||
key={file.path}
|
||||
onToggle={() => toggleFileCollapse(file.path)}
|
||||
@@ -89,34 +136,147 @@ export function DiffView({ fileDiffs, onClose }: DiffViewProps) {
|
||||
function DiffFileSection({
|
||||
file,
|
||||
collapsed,
|
||||
cwd,
|
||||
editors,
|
||||
onToggle,
|
||||
}: {
|
||||
file: SessionFileDiff;
|
||||
collapsed: boolean;
|
||||
cwd?: string;
|
||||
editors: EditorOption[];
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [opening, setOpening] = useState(false);
|
||||
const copyResetTimerRef = useRef<number | null>(null);
|
||||
const resolvedPath = resolveWorkspaceFilePath(file.path, cwd);
|
||||
|
||||
const handleCopyPath = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(resolvedPath);
|
||||
setCopied(true);
|
||||
if (copyResetTimerRef.current !== null) {
|
||||
window.clearTimeout(copyResetTimerRef.current);
|
||||
}
|
||||
copyResetTimerRef.current = window.setTimeout(() => {
|
||||
setCopied(false);
|
||||
copyResetTimerRef.current = null;
|
||||
}, 1600);
|
||||
} catch {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Copy failed",
|
||||
description: "The file path could not be copied to the clipboard.",
|
||||
});
|
||||
}
|
||||
}, [resolvedPath]);
|
||||
|
||||
const handleOpenInEditor = useCallback(
|
||||
async (editor?: string) => {
|
||||
setOpening(true);
|
||||
try {
|
||||
await desktopClient.invoke("open_file_in_editor", {
|
||||
path: file.path,
|
||||
...(cwd?.trim() ? { cwd } : {}),
|
||||
...(editor ? { editor } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Could not open file",
|
||||
description:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "The file could not be opened in an editor.",
|
||||
});
|
||||
} finally {
|
||||
setOpening(false);
|
||||
}
|
||||
},
|
||||
[file.path, cwd],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
<button
|
||||
className="flex w-full items-center gap-2 bg-card/80 px-4 py-2 text-left hover:bg-accent/50 transition-colors"
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground">
|
||||
{file.path}
|
||||
</span>
|
||||
<div className="group flex w-full items-center gap-2 bg-card/80 px-4 py-2 hover:bg-accent/50 transition-colors">
|
||||
<button
|
||||
className="flex min-w-0 shrink items-center gap-2 text-left"
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="min-w-0 truncate font-mono text-xs text-foreground">
|
||||
{file.path}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label={`Copy file path for ${file.path}`}
|
||||
className={cn(
|
||||
"shrink-0 rounded-md p-1 text-muted-foreground transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100",
|
||||
copied ? "opacity-100 text-primary" : "opacity-0",
|
||||
)}
|
||||
onClick={() => void handleCopyPath()}
|
||||
title="Copy file path"
|
||||
type="button"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
{/* Invisible flex spacer that keeps the dead space between the
|
||||
path and the right-aligned actions clickable as a toggle. */}
|
||||
<button
|
||||
aria-hidden
|
||||
className="h-6 min-w-0 flex-1 cursor-pointer"
|
||||
onClick={onToggle}
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-label={`Open ${file.path} in editor`}
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100 disabled:opacity-50 data-[state=open]:opacity-100 data-[state=open]:bg-accent data-[state=open]:text-foreground"
|
||||
disabled={opening}
|
||||
title="Open in editor"
|
||||
type="button"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuLabel>Open in</DropdownMenuLabel>
|
||||
{editors.map((editor) => (
|
||||
<DropdownMenuItem
|
||||
key={editor.id}
|
||||
onSelect={() => void handleOpenInEditor(editor.id)}
|
||||
>
|
||||
<EditorIcon editorId={editor.id} />
|
||||
{editor.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{editors.length > 0 && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem
|
||||
onSelect={() => void handleOpenInEditor("default")}
|
||||
>
|
||||
<AppWindow aria-hidden />
|
||||
System default
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<span className="shrink-0 font-mono text-[11px] text-primary">
|
||||
+{file.additions}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-[11px] text-destructive">
|
||||
-{file.deletions}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="space-y-2 border-t border-border bg-card/40 px-4 py-3">
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { FileCode } from "lucide-react";
|
||||
import type { ComponentType, SVGProps } from "react";
|
||||
|
||||
// Brand glyphs (simple-icons style, single monochrome path) filled with
|
||||
// currentColor so they follow the menu's text color in light and dark mode.
|
||||
|
||||
function VsCodeIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path d="M22 5.75v12.48c0 .49-.28.94-.72 1.15-.91.43-2.46 1.18-3.33 1.59.03-.16.05-.33.05-.5V3.53C18 3.3 17.97 3.12 17.94 3c.94.46 2.44 1.18 3.33 1.6C21.72 4.81 22 5.26 22 5.75zM3.91 13.35c.89.8 1.73 1.56 2.51 2.28l-1.48 1.12c-.37.28-.89.27-1.25-.03l-.94-.79c-.46-.39-.48-1.09-.03-1.5C3.05 14.13 3.46 13.76 3.91 13.35zM16 3.53v4.81l-3.16 2.4-3.3-2.5c2.29-2.07 4.46-4.05 5.59-5.1.23-.22.56-.16.74.04.05.06.09.13.11.22C15.99 3.44 16 3.48 16 3.53zM16 20.47v-4.81L4.938 7.252c-.372-.283-.889-.27-1.247.03L2.754 8.066C2.289 8.456 2.271 9.162 2.72 9.569c2.747 2.488 9.998 9.06 12.41 11.291C15.462 21.167 16 20.93 16 20.47z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CursorIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path d="M11.503.131 1.891 5.678a.84.84 0 0 0-.42.726v11.188c0 .3.162.575.42.724l9.609 5.55a1 1 0 0 0 .998 0l9.61-5.55a.84.84 0 0 0 .42-.724V6.404a.84.84 0 0 0-.42-.726L12.497.131a1.01 1.01 0 0 0-.996 0M2.657 6.338h18.55c.263 0 .43.287.297.515L12.23 22.918c-.062.107-.229.064-.229-.06V12.335a.59.59 0 0 0-.295-.51l-9.11-5.257c-.109-.063-.064-.23.061-.23" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function WindsurfIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path d="M23.55 5.067c-1.2038-.002-2.1806.973-2.1806 2.1765v4.8676c0 .972-.8035 1.7594-1.7597 1.7594-.568 0-1.1352-.286-1.4718-.7659l-4.9713-7.1003c-.4125-.5896-1.0837-.941-1.8103-.941-1.1334 0-2.1533.9635-2.1533 2.153v4.8957c0 .972-.7969 1.7594-1.7596 1.7594-.57 0-1.1363-.286-1.4728-.7658L.4076 5.1598C.2822 4.9798 0 5.0688 0 5.2882v4.2452c0 .2147.0656.4228.1884.599l5.4748 7.8183c.3234.462.8006.8052 1.3509.9298 1.3771.313 2.6446-.747 2.6446-2.0977v-4.893c0-.972.7875-1.7593 1.7596-1.7593h.003a1.798 1.798 0 0 1 1.4718.7658l4.9723 7.0994c.4135.5905 1.05.941 1.8093.941 1.1587 0 2.1515-.9645 2.1515-2.153v-4.8948c0-.972.7875-1.7594 1.7596-1.7594h.194a.22.22 0 0 0 .2204-.2202v-4.622a.22.22 0 0 0-.2203-.2203Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ZedIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path d="M2.25 1.5a.75.75 0 0 0-.75.75v16.5H0V2.25A2.25 2.25 0 0 1 2.25 0h20.095c1.002 0 1.504 1.212.795 1.92L10.764 14.298h3.486V12.75h1.5v1.922a1.125 1.125 0 0 1-1.125 1.125H9.264l-2.578 2.578h11.689V9h1.5v9.375a1.5 1.5 0 0 1-1.5 1.5H5.185L2.562 22.5H21.75a.75.75 0 0 0 .75-.75V5.25H24v16.5A2.25 2.25 0 0 1 21.75 24H1.655C.653 24 .151 22.788.86 22.08L13.19 9.75H9.75v1.5h-1.5V9.375A1.125 1.125 0 0 1 9.375 8.25h5.314l2.625-2.625H5.625V15h-1.5V5.625a1.5 1.5 0 0 1 1.5-1.5h13.19L21.438 1.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function XcodeIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path d="M19.06 5.3327c.4517-.1936.7744-.2581 1.097-.1936.5163.1291.7744.5163.968.7098.1936.3872.9034.7744 1.2261.8389.2581.0645.7098-.6453 1.0325-1.2906.3227-.5808.5163-1.3552.4517-1.5488-.0645-.1936-.968-.5808-1.1616-.5808-.1291 0-.3872.1291-.8389.0645-.4517-.0645-.9034-.5808-1.1616-.968-.4517-.6453-1.097-1.0325-1.6778-1.3552-.6453-.3227-1.3552-.5163-2.065-.6453-1.0325-.2581-2.065-.4517-3.0975-.3227-.5808.0645-1.2906.1291-1.8069.3227-.0645 0-.1936.1936-.0645.1936s.5808.0645.5808.0645-.5807.1292-.5807.2583c0 .1291.0645.1291.1291.1291.0645 0 1.4842-.0645 2.065 0 .6453.1291 1.3552.4517 1.8069 1.2261.7744 1.4197.4517 2.7749.2581 3.2266-.968 2.1295-8.6472 15.2294-9.0344 16.1328-.3873.9034-.5163 1.4842.5807 2.065s1.6778.3227 2.0005-.0645c.3872-.5163 7.0339-17.1654 9.2925-18.2624zm-3.6138 8.7117h1.5488c1.0325 0 1.2261.5163 1.2261.7098.0645.5163-.1936 1.1616-1.2261 1.1616h-.968l.7744 1.2906c.4517.7744.2581 1.1616 0 1.4197-.3872.3872-1.2261.3872-1.6778-.4517l-.9034-1.5488c-.6453 1.4197-1.2906 2.9684-2.065 4.7753h4.0009c1.9359 0 3.5492-1.6133 3.5492-3.5492V6.5588c-.0645-.1291-.1936-.0645-.2581 0-.3872.4517-1.4842 2.0004-4.001 7.4856zm-9.8087 8.0019h-.3227c-2.3231 0-4.1945-1.8714-4.1945-4.1945V7.0105c0-2.3231 1.8714-4.1945 4.1945-4.1945h9.3571c-.1936-.1936-.968-.5163-1.7423-.4517-.3227 0-.968.1291-1.3552-.1291-.3872-.3227-.3227-.5163-.9034-.5163H4.9277c-2.6458 0-4.7753 2.1295-4.7753 4.7753v11.7447c0 2.6458 2.1295 4.7753 4.4527 4.7108.6452 0 .8388-.5162 1.0324-.9034zM20.4152 6.9459v10.9058c0 2.3231-1.8714 4.1945-4.1945 4.1945H11.897s-.3872 1.0325.8389 1.0325h3.8719c2.6458 0 4.7753-2.1295 4.7753-4.7753V8.8173c.0646-.9034-.7098-1.4842-.9679-1.8714zm-18.5851.0646v10.8413c0 1.9359 1.6133 3.5492 3.5492 3.5492h.5808c0-.0645.7744-1.4197 2.4522-4.2591.1936-.3872.4517-.7744.7098-1.2261H4.4114c-.5808 0-.9034-.3872-.968-.7098-.1291-.5163.1936-1.1616.9034-1.1616h2.3877l3.033-5.2916s-.7098-1.2906-.9034-1.6133c-.2582-.4517-.1291-.9034.129-1.1615.3872-.3872 1.0325-.5808 1.6778.4517l.2581.3872.2581-.3872c.5808-.8389.968-.7744 1.2906-.7098.5163.1291.8389.7098.3872 1.6133L8.864 14.0444h1.3552c.4517-.7744.9034-1.5488 1.3552-2.3877-.0645-.3227-.1291-.7098-.0645-1.0325.0645-.5163.3227-.968.6453-1.3552l.3872.6453c1.2261-2.1295 2.1295-3.9364 2.3877-4.6463.1291-.3872.3227-1.1616.1291-1.8069H5.3794c-2.0005.0001-3.5493 1.6134-3.5493 3.5494zM4.605 17.7872c0-.0645.7744-1.4197.7744-1.4197 1.2261-.3227 1.8069.4517 1.8714.5163 0 0-.8389 1.4842-1.097 1.7423s-.5808.3227-.9034.2581c-.5164-.129-.839-.6453-.6454-1.097z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IntelliJIdeaIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path d="M0 0v24h24V0zm3.723 3.111h5v1.834h-1.39v6.277h1.39v1.834h-5v-1.834h1.444V4.945H3.723zm11.055 0H17v6.5c0 .612-.055 1.111-.222 1.556-.167.444-.39.777-.723 1.11-.277.279-.666.557-1.11.668a3.933 3.933 0 0 1-1.445.278c-.778 0-1.444-.167-1.944-.445a4.81 4.81 0 0 1-1.279-1.056l1.39-1.555c.277.334.555.555.833.722.277.167.611.278.945.278.389 0 .721-.111 1-.389.221-.278.333-.667.333-1.278zM2.222 19.5h9V21h-9z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const EDITOR_ICONS: Record<string, ComponentType<SVGProps<SVGSVGElement>>> = {
|
||||
vscode: VsCodeIcon,
|
||||
"vscode-insiders": VsCodeIcon,
|
||||
cursor: CursorIcon,
|
||||
windsurf: WindsurfIcon,
|
||||
zed: ZedIcon,
|
||||
xcode: XcodeIcon,
|
||||
intellijidea: IntelliJIdeaIcon,
|
||||
};
|
||||
|
||||
export function EditorIcon({
|
||||
editorId,
|
||||
className,
|
||||
}: {
|
||||
editorId: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const Icon = EDITOR_ICONS[editorId] ?? FileCode;
|
||||
return <Icon aria-hidden className={className} />;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Search } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* A button-styled select whose menu is a searchable, filterable list — the same
|
||||
* interaction the workspace and branch pickers use. The trigger shows the
|
||||
* current value with no chevron; clicking it opens the popover.
|
||||
*/
|
||||
export function SearchableSelect({
|
||||
value,
|
||||
items,
|
||||
onSelect,
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
searchPlaceholder = "Search...",
|
||||
emptyLabel = "No results",
|
||||
placeholder = "Select",
|
||||
icon,
|
||||
triggerClassName,
|
||||
align = "start",
|
||||
placement = "top",
|
||||
}: {
|
||||
value: string;
|
||||
items: string[];
|
||||
onSelect: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
ariaLabel: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyLabel?: string;
|
||||
placeholder?: string;
|
||||
icon?: ReactNode;
|
||||
triggerClassName?: string;
|
||||
align?: "start" | "end";
|
||||
placement?: "top" | "bottom";
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close on outside click; reset the filter each time the menu opens.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSearch("");
|
||||
return;
|
||||
}
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
// pointerdown in the capture phase so we still fire before a portaled menu
|
||||
// (e.g. the Radix effort Select) handles its own trigger's pointerdown and
|
||||
// calls preventDefault, which would otherwise suppress a mousedown listener.
|
||||
document.addEventListener("pointerdown", handlePointerDown, true);
|
||||
return () =>
|
||||
document.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
}, [open]);
|
||||
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
items.filter((item) => item.toLowerCase().includes(search.toLowerCase())),
|
||||
[items, search],
|
||||
);
|
||||
|
||||
const handleSelect = (item: string) => {
|
||||
if (item !== value) onSelect(item);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={containerRef}>
|
||||
<button
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-md px-2 py-1 font-medium text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
triggerClassName,
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
title={value}
|
||||
type="button"
|
||||
>
|
||||
{icon}
|
||||
<span className="truncate">{value || placeholder}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute z-50 w-64 rounded-lg border border-border bg-popover shadow-xl",
|
||||
align === "end" ? "right-0" : "left-0",
|
||||
placement === "top" ? "bottom-full mb-2" : "top-full mt-2",
|
||||
)}
|
||||
>
|
||||
<div className="border-b border-border p-2">
|
||||
<div className="flex items-center gap-2 rounded-md bg-background px-2.5 py-1.5">
|
||||
<Search className="size-3 shrink-0 text-muted-foreground" />
|
||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-auto flex-1 border-0 bg-transparent px-0 py-0 text-xs shadow-none focus-visible:ring-0"
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
value={search}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex max-h-56 flex-col gap-0.5 overflow-y-auto p-1.5">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="px-2 py-2 text-xs text-muted-foreground">
|
||||
{emptyLabel}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((item) => (
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left transition-colors",
|
||||
item === value ? "bg-accent" : "hover:bg-accent/50",
|
||||
)}
|
||||
key={item}
|
||||
onClick={() => handleSelect(item)}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate text-xs text-foreground">
|
||||
{item}
|
||||
</span>
|
||||
{item === value && (
|
||||
<Check className="ml-2 size-3 shrink-0 text-foreground" />
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowRight, FolderPlus, Plus } from "lucide-react";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AuroraBackground } from "@/components/ui/aurora-bg";
|
||||
import { useWorkspace } from "@/contexts/workspace-context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { normalizeWorkspacePath } from "@/lib/workspace-paths";
|
||||
import { WelcomeWorkspaceControls } from "./welcome-workspace-controls";
|
||||
|
||||
interface QuickAction {
|
||||
id: string;
|
||||
@@ -15,6 +15,9 @@ interface QuickAction {
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
const HERO_VERBS = ["build", "create", "fix", "know"] as const;
|
||||
const HERO_CYCLE_MS = 2600;
|
||||
|
||||
const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
|
||||
{
|
||||
id: "review-changes",
|
||||
@@ -30,33 +33,44 @@ const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
|
||||
},
|
||||
];
|
||||
|
||||
function toWorkspaceName(path: string): string {
|
||||
const trimmed = path.trim().replace(/[\\/]+$/, "");
|
||||
if (!trimmed) return "Workspace";
|
||||
const parts = trimmed.split(/[\\/]/);
|
||||
return parts[parts.length - 1] || "Workspace";
|
||||
}
|
||||
function HeroHeading() {
|
||||
const [verbIndex, setVerbIndex] = useState(0);
|
||||
|
||||
function workspaceLabels(paths: string[]): Map<string, string> {
|
||||
const segments = paths.map((path) =>
|
||||
path
|
||||
.trim()
|
||||
.replace(/[\\/]+$/, "")
|
||||
.split(/[\\/]/)
|
||||
.filter(Boolean),
|
||||
);
|
||||
return new Map(
|
||||
paths.map((path, index) => {
|
||||
const parts = segments[index] ?? [];
|
||||
for (let depth = 1; depth <= parts.length; depth += 1) {
|
||||
const candidate = parts.slice(-depth).join("/");
|
||||
const matches = segments.filter(
|
||||
(other) => other.slice(-depth).join("/") === candidate,
|
||||
).length;
|
||||
if (matches === 1) return [path, candidate];
|
||||
}
|
||||
return [path, toWorkspaceName(path)];
|
||||
}),
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
if (media.matches) return;
|
||||
const interval = setInterval(() => {
|
||||
setVerbIndex((prev) => (prev + 1) % HERO_VERBS.length);
|
||||
}, HERO_CYCLE_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const verb = HERO_VERBS[verbIndex];
|
||||
|
||||
return (
|
||||
<h1
|
||||
id="hero-header"
|
||||
className="text-balance text-left text-[clamp(2rem,3vw,2.6rem)] font-semibold leading-[1.12] tracking-tight text-foreground"
|
||||
>
|
||||
<span className="sr-only">What would you like to build?</span>
|
||||
<span aria-hidden="true">
|
||||
What would you like to{" "}
|
||||
{/* key remounts the word each cycle so the chars re-trigger their entrance */}
|
||||
<span key={verb}>
|
||||
{verb.split("").map((char, index) => (
|
||||
<span
|
||||
className="hero-word-char"
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: the word remounts via the parent key each cycle, so char position is a stable, non-reordering identity
|
||||
key={`${verb}-${index}`}
|
||||
style={{ animationDelay: `${index * 45}ms` }}
|
||||
>
|
||||
{char}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
?
|
||||
</span>
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,12 +80,18 @@ export function WelcomeScreen({
|
||||
composer,
|
||||
onStartChat,
|
||||
quickActions,
|
||||
gitBranch,
|
||||
onListGitBranches,
|
||||
onSwitchGitBranch,
|
||||
}: {
|
||||
active: boolean;
|
||||
body: ReactNode;
|
||||
composer: ReactNode;
|
||||
onStartChat: (prompt: string) => void;
|
||||
quickActions: QuickAction[];
|
||||
gitBranch: string;
|
||||
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
|
||||
onSwitchGitBranch: (branch: string) => Promise<boolean>;
|
||||
}) {
|
||||
const {
|
||||
workspaceRoot,
|
||||
@@ -80,61 +100,13 @@ export function WelcomeScreen({
|
||||
switchWorkspace,
|
||||
pickWorkspaceDirectory,
|
||||
} = useWorkspace();
|
||||
const [switchingWorkspace, setSwitchingWorkspace] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [addingWorkspace, setAddingWorkspace] = useState(false);
|
||||
const availableWorkspaces = useMemo(() => {
|
||||
const next = new Map<string, string>();
|
||||
const register = (path: string) => {
|
||||
const trimmed = path.trim();
|
||||
if (trimmed) next.set(normalizeWorkspacePath(trimmed), trimmed);
|
||||
};
|
||||
register(workspaceRoot);
|
||||
for (const workspacePath of workspaces) register(workspacePath);
|
||||
return [...next.values()];
|
||||
}, [workspaceRoot, workspaces]);
|
||||
const actions =
|
||||
quickActions.length > 0 ? quickActions : DEFAULT_QUICK_ACTIONS;
|
||||
const labelsByWorkspace = useMemo(
|
||||
() => workspaceLabels(availableWorkspaces),
|
||||
[availableWorkspaces],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) void refreshWorkspaces();
|
||||
}, [active, refreshWorkspaces]);
|
||||
|
||||
const handleSelectWorkspace = useCallback(
|
||||
async (path: string) => {
|
||||
if (
|
||||
normalizeWorkspacePath(path) ===
|
||||
normalizeWorkspacePath(workspaceRoot) ||
|
||||
switchingWorkspace
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setSwitchingWorkspace(path);
|
||||
try {
|
||||
await switchWorkspace(path);
|
||||
} finally {
|
||||
setSwitchingWorkspace(null);
|
||||
}
|
||||
},
|
||||
[switchWorkspace, switchingWorkspace, workspaceRoot],
|
||||
);
|
||||
|
||||
const handleAddWorkspace = useCallback(async () => {
|
||||
if (addingWorkspace) return;
|
||||
setAddingWorkspace(true);
|
||||
try {
|
||||
const selected = await pickWorkspaceDirectory(workspaceRoot || undefined);
|
||||
if (selected) await switchWorkspace(selected);
|
||||
} finally {
|
||||
setAddingWorkspace(false);
|
||||
}
|
||||
}, [addingWorkspace, pickWorkspaceDirectory, switchWorkspace, workspaceRoot]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -154,60 +126,25 @@ export function WelcomeScreen({
|
||||
<div
|
||||
className={cn(
|
||||
active
|
||||
? "mx-auto flex w-full max-w-[960px] flex-col px-6 pb-32 pt-[clamp(8rem,26vh,17rem)] max-[720px]:px-4 max-[720px]:pb-20 max-[720px]:pt-16"
|
||||
? "mx-auto flex w-full max-w-240 flex-col px-6 pb-32 pt-[clamp(8rem,26vh,17rem)] max-[720px]:px-4 max-[720px]:pb-20 max-[720px]:pt-16"
|
||||
: "contents",
|
||||
)}
|
||||
>
|
||||
{active ? (
|
||||
<>
|
||||
<h1 className="text-balance text-center text-[clamp(2rem,3vw,2.6rem)] font-semibold leading-[1.12] tracking-[-0.025em] text-foreground">
|
||||
What would you like to build?
|
||||
</h1>
|
||||
<HeroHeading />
|
||||
|
||||
<div className="mt-11 flex min-w-0 items-center gap-1.5 text-sm">
|
||||
<fieldset className="flex min-h-8 min-w-0 flex-1 items-center gap-1.5 overflow-x-auto pb-1">
|
||||
<legend className="sr-only">Workspaces</legend>
|
||||
{availableWorkspaces.map((path) => {
|
||||
const isActive =
|
||||
normalizeWorkspacePath(path) ===
|
||||
normalizeWorkspacePath(workspaceRoot);
|
||||
const isSwitching = switchingWorkspace === path;
|
||||
return (
|
||||
<button
|
||||
aria-pressed={isActive}
|
||||
className={cn(
|
||||
"shrink-0 rounded-md px-3 py-1.5 font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
isActive
|
||||
? "bg-foreground text-background"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
)}
|
||||
disabled={Boolean(switchingWorkspace)}
|
||||
key={path}
|
||||
onClick={() => void handleSelectWorkspace(path)}
|
||||
title={path}
|
||||
type="button"
|
||||
>
|
||||
{isSwitching
|
||||
? "Switching..."
|
||||
: (labelsByWorkspace.get(path) ??
|
||||
toWorkspaceName(path))}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</fieldset>
|
||||
<button
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-1.5 font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring max-[480px]:px-2"
|
||||
disabled={addingWorkspace}
|
||||
onClick={() => void handleAddWorkspace()}
|
||||
type="button"
|
||||
>
|
||||
{addingWorkspace ? (
|
||||
<FolderPlus className="size-4 animate-pulse" />
|
||||
) : (
|
||||
<Plus className="size-4" />
|
||||
)}
|
||||
New project
|
||||
</button>
|
||||
<div className="mt-11 flex min-w-0 items-center">
|
||||
<WelcomeWorkspaceControls
|
||||
currentBranch={gitBranch}
|
||||
onListGitBranches={onListGitBranches}
|
||||
onPickWorkspaceDirectory={pickWorkspaceDirectory}
|
||||
onRefreshWorkspaces={refreshWorkspaces}
|
||||
onSwitchGitBranch={onSwitchGitBranch}
|
||||
onSwitchWorkspace={switchWorkspace}
|
||||
workspaceRoot={workspaceRoot}
|
||||
workspaces={workspaces}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
+400
@@ -0,0 +1,400 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Folder, GitBranch, Plus, Search } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { normalizeWorkspacePath } from "@/lib/workspace-paths";
|
||||
|
||||
function formatWorkspacePath(path: string): string {
|
||||
const unixHome = path.match(/^\/Users\/[^/]+\/(.*)$/);
|
||||
if (unixHome) return unixHome[1] ? `~/${unixHome[1]}` : "~";
|
||||
const linuxHome = path.match(/^\/home\/[^/]+\/(.*)$/);
|
||||
if (linuxHome) return linuxHome[1] ? `~/${linuxHome[1]}` : "~";
|
||||
const windowsHome = path.match(/^[A-Za-z]:\\Users\\[^\\]+\\(.*)$/);
|
||||
if (windowsHome) {
|
||||
const tail = windowsHome[1]?.replaceAll("\\", "/") || "";
|
||||
return tail ? `~/${tail}` : "~";
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function workspaceName(path: string): string {
|
||||
const trimmed = path.trim().replace(/[\\/]+$/, "");
|
||||
if (!trimmed) return "workspace";
|
||||
const parts = trimmed.split(/[\\/]/);
|
||||
return parts[parts.length - 1] || "workspace";
|
||||
}
|
||||
|
||||
const TRIGGER_CLASS =
|
||||
"inline-flex items-center gap-1.5 rounded-md border border-border/70 bg-background/80 px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
|
||||
const PANEL_CLASS =
|
||||
"absolute left-0 top-full z-50 mt-2 w-72 rounded-lg border border-border bg-popover shadow-xl";
|
||||
|
||||
function SearchInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-border p-2">
|
||||
<div className="flex items-center gap-2 rounded-md bg-background px-2.5 py-1.5">
|
||||
<Search className="size-3 shrink-0 text-muted-foreground" />
|
||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-auto flex-1 border-0 bg-transparent px-0 py-0 text-xs shadow-none focus-visible:ring-0"
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspacePicker({
|
||||
open,
|
||||
onToggle,
|
||||
onClose,
|
||||
workspaceRoot,
|
||||
workspaces,
|
||||
onRefreshWorkspaces,
|
||||
onSwitchWorkspace,
|
||||
onPickWorkspaceDirectory,
|
||||
}: {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
onClose: () => void;
|
||||
workspaceRoot: string;
|
||||
workspaces: string[];
|
||||
onRefreshWorkspaces: () => Promise<void>;
|
||||
onSwitchWorkspace: (workspacePath: string) => Promise<boolean>;
|
||||
onPickWorkspaceDirectory: (initialPath?: string) => Promise<string | null>;
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const [picking, setPicking] = useState(false);
|
||||
|
||||
const normalizedWorkspaceRoot = useMemo(
|
||||
() => normalizeWorkspacePath(workspaceRoot),
|
||||
[workspaceRoot],
|
||||
);
|
||||
|
||||
// Refresh the catalog and clear the filter each time the menu opens.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSearch("");
|
||||
void onRefreshWorkspaces();
|
||||
}, [open, onRefreshWorkspaces]);
|
||||
|
||||
// The active workspace can be an excluded path (restored session, process
|
||||
// cwd fallback); register it explicitly so it stays visible while active.
|
||||
const availableWorkspaces = useMemo(() => {
|
||||
const byNormalizedPath = new Map<string, string>();
|
||||
const register = (path: string) => {
|
||||
const trimmed = path.trim();
|
||||
if (trimmed)
|
||||
byNormalizedPath.set(normalizeWorkspacePath(trimmed), trimmed);
|
||||
};
|
||||
register(workspaceRoot);
|
||||
for (const path of workspaces) register(path);
|
||||
return [...byNormalizedPath.values()];
|
||||
}, [workspaceRoot, workspaces]);
|
||||
|
||||
const filteredWorkspaces = availableWorkspaces.filter((path) =>
|
||||
path.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
const handleSelect = async (path: string) => {
|
||||
const next = path.trim();
|
||||
if (!next || normalizeWorkspacePath(next) === normalizedWorkspaceRoot) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (switching) return;
|
||||
setSwitching(true);
|
||||
const switched = await onSwitchWorkspace(next);
|
||||
setSwitching(false);
|
||||
if (switched) onClose();
|
||||
};
|
||||
|
||||
const handleAddWorkspace = async () => {
|
||||
if (picking || switching) return;
|
||||
setPicking(true);
|
||||
try {
|
||||
const picked = await onPickWorkspaceDirectory(workspaceRoot || undefined);
|
||||
if (picked?.trim()) await handleSelect(picked.trim());
|
||||
} finally {
|
||||
setPicking(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative shrink-0">
|
||||
<button
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
className={TRIGGER_CLASS}
|
||||
onClick={onToggle}
|
||||
title={workspaceRoot}
|
||||
type="button"
|
||||
>
|
||||
<Folder className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="max-w-44 truncate">
|
||||
{workspaceName(workspaceRoot)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className={PANEL_CLASS}>
|
||||
<SearchInput
|
||||
onChange={setSearch}
|
||||
placeholder="Search workspaces"
|
||||
value={search}
|
||||
/>
|
||||
<div className="p-1.5">
|
||||
<div className="flex max-h-48 flex-col gap-0.5 overflow-y-auto">
|
||||
{filteredWorkspaces.length === 0 ? (
|
||||
<div className="px-2 py-2 text-xs text-muted-foreground">
|
||||
No workspaces found
|
||||
</div>
|
||||
) : (
|
||||
filteredWorkspaces.map((path) => {
|
||||
const isActive =
|
||||
normalizeWorkspacePath(path) === normalizedWorkspaceRoot;
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"flex h-auto w-full items-center justify-between rounded-md p-2 text-left",
|
||||
isActive ? "bg-accent" : "hover:bg-accent/50",
|
||||
)}
|
||||
disabled={switching}
|
||||
key={path}
|
||||
onClick={() => void handleSelect(path)}
|
||||
variant="ghost"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Folder className="size-3 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-xs text-foreground">
|
||||
{formatWorkspacePath(path)}
|
||||
</span>
|
||||
</span>
|
||||
{isActive && (
|
||||
<Check className="ml-2 size-3 shrink-0 text-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
className="mt-0.5 w-full justify-start text-xs text-muted-foreground"
|
||||
disabled={switching || picking}
|
||||
onClick={() => void handleAddWorkspace()}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
{picking ? "Opening folder picker..." : "Add project..."}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BranchPicker({
|
||||
open,
|
||||
onToggle,
|
||||
onClose,
|
||||
currentBranch,
|
||||
onListGitBranches,
|
||||
onSwitchGitBranch,
|
||||
}: {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
onClose: () => void;
|
||||
currentBranch: string;
|
||||
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
|
||||
onSwitchGitBranch: (branch: string) => Promise<boolean>;
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [branches, setBranches] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [switching, setSwitching] = useState(false);
|
||||
|
||||
// Load branches fresh each time the menu opens.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
setSearch("");
|
||||
setLoading(true);
|
||||
onListGitBranches()
|
||||
.then((payload) => {
|
||||
if (!cancelled) setBranches(payload.branches);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, onListGitBranches]);
|
||||
|
||||
const hasGit = currentBranch !== "no-git";
|
||||
const branchLabel = hasGit ? currentBranch : "No branch";
|
||||
|
||||
const filteredBranches = branches.filter((branch) =>
|
||||
branch.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
const handleSelect = async (branch: string) => {
|
||||
if (branch === currentBranch) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (switching) return;
|
||||
setSwitching(true);
|
||||
const switched = await onSwitchGitBranch(branch);
|
||||
setSwitching(false);
|
||||
if (switched) onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-w-0">
|
||||
<button
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
className={cn(TRIGGER_CLASS, "min-w-0 max-w-full")}
|
||||
onClick={onToggle}
|
||||
title={branchLabel}
|
||||
type="button"
|
||||
>
|
||||
<GitBranch className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 truncate">{branchLabel}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className={PANEL_CLASS}>
|
||||
<SearchInput
|
||||
onChange={setSearch}
|
||||
placeholder="Search branches"
|
||||
value={search}
|
||||
/>
|
||||
<div className="p-1.5">
|
||||
{loading ? (
|
||||
<div className="px-2 py-4 text-xs text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex max-h-56 flex-col gap-0.5 overflow-y-auto">
|
||||
{filteredBranches.length === 0 ? (
|
||||
<div className="px-2 py-2 text-xs text-muted-foreground">
|
||||
No branches found
|
||||
</div>
|
||||
) : (
|
||||
filteredBranches.map((branch) => (
|
||||
<Button
|
||||
className={cn(
|
||||
"flex h-auto items-center gap-2 rounded-md px-2 py-2 text-left",
|
||||
currentBranch === branch
|
||||
? "bg-accent"
|
||||
: "hover:bg-accent/50",
|
||||
)}
|
||||
disabled={switching}
|
||||
key={branch}
|
||||
onClick={() => void handleSelect(branch)}
|
||||
variant="ghost"
|
||||
>
|
||||
<GitBranch className="size-3 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">
|
||||
{branch}
|
||||
</span>
|
||||
{currentBranch === branch && (
|
||||
<Check className="ml-auto size-3 shrink-0 text-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WelcomeWorkspaceControls({
|
||||
workspaceRoot,
|
||||
workspaces,
|
||||
onRefreshWorkspaces,
|
||||
onSwitchWorkspace,
|
||||
onPickWorkspaceDirectory,
|
||||
currentBranch,
|
||||
onListGitBranches,
|
||||
onSwitchGitBranch,
|
||||
}: {
|
||||
workspaceRoot: string;
|
||||
workspaces: string[];
|
||||
onRefreshWorkspaces: () => Promise<void>;
|
||||
onSwitchWorkspace: (workspacePath: string) => Promise<boolean>;
|
||||
onPickWorkspaceDirectory: (initialPath?: string) => Promise<string | null>;
|
||||
currentBranch: string;
|
||||
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
|
||||
onSwitchGitBranch: (branch: string) => Promise<boolean>;
|
||||
}) {
|
||||
const [openMenu, setOpenMenu] = useState<"workspace" | "branch" | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close whichever menu is open when clicking outside the control row.
|
||||
useEffect(() => {
|
||||
if (!openMenu) return;
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setOpenMenu(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handlePointerDown);
|
||||
return () => document.removeEventListener("mousedown", handlePointerDown);
|
||||
}, [openMenu]);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2" ref={containerRef}>
|
||||
<WorkspacePicker
|
||||
onClose={() => setOpenMenu(null)}
|
||||
onPickWorkspaceDirectory={onPickWorkspaceDirectory}
|
||||
onRefreshWorkspaces={onRefreshWorkspaces}
|
||||
onSwitchWorkspace={onSwitchWorkspace}
|
||||
onToggle={() =>
|
||||
setOpenMenu((current) =>
|
||||
current === "workspace" ? null : "workspace",
|
||||
)
|
||||
}
|
||||
open={openMenu === "workspace"}
|
||||
workspaceRoot={workspaceRoot}
|
||||
workspaces={workspaces}
|
||||
/>
|
||||
<BranchPicker
|
||||
currentBranch={currentBranch}
|
||||
onClose={() => setOpenMenu(null)}
|
||||
onListGitBranches={onListGitBranches}
|
||||
onSwitchGitBranch={onSwitchGitBranch}
|
||||
onToggle={() =>
|
||||
setOpenMenu((current) => (current === "branch" ? null : "branch"))
|
||||
}
|
||||
open={openMenu === "branch"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -79,4 +79,30 @@ describe("WorkspaceSelector", () => {
|
||||
expect(onSwitchGitBranch).toHaveBeenCalledWith("feature/review");
|
||||
});
|
||||
});
|
||||
|
||||
it("lists the active workspace even when the catalog excludes it", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WorkspaceSelector
|
||||
currentBranch="main"
|
||||
onListGitBranches={vi.fn(async () => ({
|
||||
current: "main",
|
||||
branches: ["main"],
|
||||
}))}
|
||||
onPickWorkspaceDirectory={vi.fn(async () => null)}
|
||||
onRefreshWorkspaces={vi.fn(async () => undefined)}
|
||||
onSwitchGitBranch={vi.fn(async () => true)}
|
||||
onSwitchWorkspace={vi.fn(async () => true)}
|
||||
workspaceRoot="/Users/beatrix/Desktop"
|
||||
workspaces={["/workspace/one"]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await click(container.querySelector("#git-branch-btn") as Element);
|
||||
await vi.waitFor(() => {
|
||||
expect(container.textContent).toContain("~/Desktop");
|
||||
expect(container.textContent).toContain("/workspace/one");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { normalizeWorkspacePath } from "@/lib/workspace-paths";
|
||||
|
||||
function formatWorkspacePath(path: string): string {
|
||||
const unixHome = path.match(/^\/Users\/[^/]+\/(.*)$/);
|
||||
@@ -19,17 +20,6 @@ function formatWorkspacePath(path: string): string {
|
||||
return path;
|
||||
}
|
||||
|
||||
function normalizeWorkspacePath(path: string): string {
|
||||
const normalized = path.trim().replace(/[\\/]+$/, "");
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
if (/^[A-Za-z]:/.test(normalized)) {
|
||||
return normalized.toLowerCase();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function WorkspaceSelector({
|
||||
currentBranch,
|
||||
workspaceRoot,
|
||||
@@ -181,7 +171,20 @@ export function WorkspaceSelector({
|
||||
b.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
const filteredWorkspaces = workspaces.filter((w) =>
|
||||
// The catalog excludes non-project paths (home, Desktop, ~/.cline), but an
|
||||
// explicitly opened workspace must stay visible while it is active.
|
||||
const availableWorkspaces = useMemo(() => {
|
||||
const byNormalizedPath = new Map<string, string>();
|
||||
const register = (path: string) => {
|
||||
const trimmed = path.trim();
|
||||
if (trimmed) byNormalizedPath.set(normalizeWorkspacePath(trimmed), trimmed);
|
||||
};
|
||||
register(workspaceRoot);
|
||||
for (const path of workspaces) register(path);
|
||||
return [...byNormalizedPath.values()];
|
||||
}, [workspaceRoot, workspaces]);
|
||||
|
||||
const filteredWorkspaces = availableWorkspaces.filter((w) =>
|
||||
w.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
|
||||
@@ -457,7 +457,7 @@ function MarketplaceEntryCard({
|
||||
|
||||
if (!hasExpandableDetails) {
|
||||
return (
|
||||
<div className="relative grid gap-2 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20">
|
||||
<div className="relative grid min-w-0 gap-2 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
@@ -468,7 +468,7 @@ function MarketplaceEntryCard({
|
||||
<div
|
||||
aria-expanded={expanded}
|
||||
aria-label={`${expanded ? "Collapse" : "Expand"} ${entry.name}`}
|
||||
className="relative grid cursor-pointer gap-2 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
|
||||
className="relative grid min-w-0 cursor-pointer gap-2 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
|
||||
onClick={(event) => {
|
||||
if (
|
||||
event.target instanceof HTMLElement &&
|
||||
@@ -563,14 +563,14 @@ function MarketplaceSection({
|
||||
}) {
|
||||
const totalCount = entries.length + localOnlyInstalledItems.length;
|
||||
return (
|
||||
<section className="grid gap-3">
|
||||
<section className="grid min-w-0 gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold text-foreground">{title}</h2>
|
||||
<span className="text-sm text-muted-foreground">{totalCount}</span>
|
||||
</div>
|
||||
{headerContent}
|
||||
{totalCount > 0 ? (
|
||||
<div className="grid gap-3">
|
||||
<div className="grid min-w-0 gap-3">
|
||||
{localOnlyInstalledItems.map((item) => item.render())}
|
||||
{entries.map((entry) => {
|
||||
const key = entryKey(entry);
|
||||
@@ -786,8 +786,8 @@ export function MarketplaceView({
|
||||
|
||||
const marketplaceTagFilters =
|
||||
primitiveTags.length > 0 ? (
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
<div className="flex min-w-0 flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex min-w-0 gap-2 overflow-x-auto pb-1">
|
||||
{primitiveTags.map((tag) => (
|
||||
<TagButton
|
||||
active={selectedTag === tag.id}
|
||||
|
||||
@@ -15,17 +15,28 @@ import {
|
||||
CreditCard,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
LogIn,
|
||||
LogOut,
|
||||
Plus,
|
||||
Receipt,
|
||||
RefreshCw,
|
||||
User,
|
||||
UserCircleIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import { useAccount } from "@/contexts/account-context";
|
||||
import { desktopClient, openExternalUrl } from "@/lib/desktop-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DASHBOARD_URL = "https://app.cline.bot/dashboard";
|
||||
const USER_CREDITS_URL =
|
||||
"https://app.cline.bot/dashboard/account?tab=credits&redirect=true";
|
||||
const ORGANIZATION_CREDITS_URL =
|
||||
"https://app.cline.bot/dashboard/organization?tab=credits&redirect=true";
|
||||
const CREATE_ORGANIZATION_URL = "https://app.cline.bot/onboarding?step=1";
|
||||
const CREATE_ACCOUNT_URL = "https://app.cline.bot";
|
||||
|
||||
function normalizeAccountViewError(error: unknown): Error {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes("unsupported desktop command: cline_account")) {
|
||||
@@ -36,6 +47,18 @@ function normalizeAccountViewError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(message);
|
||||
}
|
||||
|
||||
function isAccountAuthError(message: string): boolean {
|
||||
// Only definitive signed-out signals belong here: matching broader
|
||||
// substrings like "auth token" or "unauthorized" turns transient refresh
|
||||
// failures and org-permission errors into a sign-in card with no retry.
|
||||
const normalized = message.toLowerCase();
|
||||
return (
|
||||
normalized.includes("no cline account auth token found") ||
|
||||
normalized.includes("requires re-authentication") ||
|
||||
normalized.includes("failed with status 401")
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data fetching helpers via sidecar command
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -118,6 +141,16 @@ async function fetchPaymentTransactions(): Promise<
|
||||
);
|
||||
}
|
||||
|
||||
async function switchActiveAccount(
|
||||
organizationId: string | null,
|
||||
): Promise<void> {
|
||||
await desktopClient.invoke("cline_account", {
|
||||
action: "clineAccount",
|
||||
operation: "switchAccount",
|
||||
organizationId,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -126,6 +159,7 @@ export function AccountView() {
|
||||
const [activeTab, setActiveTab] = useState<"overview" | "usage" | "billing">(
|
||||
"overview",
|
||||
);
|
||||
const { refreshAccount } = useAccount();
|
||||
|
||||
// Overview data
|
||||
const [user, setUser] = useState<ClineAccountUser | null>(null);
|
||||
@@ -137,6 +171,12 @@ export function AccountView() {
|
||||
>([]);
|
||||
const [overviewLoading, setOverviewLoading] = useState(true);
|
||||
const [overviewError, setOverviewError] = useState<string | null>(null);
|
||||
const [accountActionPending, setAccountActionPending] = useState<
|
||||
"sign-in" | "sign-out" | null
|
||||
>(null);
|
||||
// Organization id being switched to, "" while switching to the personal
|
||||
// account, null when no switch is in flight.
|
||||
const [switchTargetId, setSwitchTargetId] = useState<string | null>(null);
|
||||
|
||||
// Usage data
|
||||
const [usageTransactions, setUsageTransactions] = useState<
|
||||
@@ -156,6 +196,19 @@ export function AccountView() {
|
||||
const [billingLoaded, setBillingLoaded] = useState(false);
|
||||
const activeOrganization = organizations.find((org) => org.active) ?? null;
|
||||
|
||||
const resetAccountData = useCallback(() => {
|
||||
setUser(null);
|
||||
setBalance(null);
|
||||
setOrganizationBalance(null);
|
||||
setOrganizations([]);
|
||||
setUsageTransactions([]);
|
||||
setUsageLoaded(false);
|
||||
setUsageError(null);
|
||||
setPaymentTransactions([]);
|
||||
setBillingLoaded(false);
|
||||
setBillingError(null);
|
||||
}, []);
|
||||
|
||||
// -- Overview fetch --
|
||||
const loadOverview = useCallback(async () => {
|
||||
setOverviewLoading(true);
|
||||
@@ -176,17 +229,80 @@ export function AccountView() {
|
||||
setOrganizationBalance(organizationBalanceData);
|
||||
setOrganizations(orgsData);
|
||||
} catch (err) {
|
||||
resetAccountData();
|
||||
const message = normalizeAccountViewError(err).message;
|
||||
setOverviewError(message);
|
||||
} finally {
|
||||
setOverviewLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [resetAccountData]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadOverview();
|
||||
}, [loadOverview]);
|
||||
|
||||
const signIn = async () => {
|
||||
setAccountActionPending("sign-in");
|
||||
setOverviewError(null);
|
||||
try {
|
||||
await desktopClient.invoke("run_provider_oauth_login", {
|
||||
provider: "cline",
|
||||
});
|
||||
await loadOverview();
|
||||
setActiveTab("overview");
|
||||
} catch (err) {
|
||||
const message = normalizeAccountViewError(err).message;
|
||||
setOverviewError(message);
|
||||
resetAccountData();
|
||||
} finally {
|
||||
setAccountActionPending(null);
|
||||
void refreshAccount();
|
||||
}
|
||||
};
|
||||
|
||||
const signOut = async () => {
|
||||
setAccountActionPending("sign-out");
|
||||
try {
|
||||
await desktopClient.invoke("save_provider_settings", {
|
||||
provider: "cline",
|
||||
api_key: "",
|
||||
settings: {
|
||||
auth: {
|
||||
accessToken: "",
|
||||
refreshToken: "",
|
||||
accountId: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
resetAccountData();
|
||||
setActiveTab("overview");
|
||||
setOverviewError("No Cline account auth token found");
|
||||
} catch (err) {
|
||||
const message = normalizeAccountViewError(err).message;
|
||||
setOverviewError(message);
|
||||
} finally {
|
||||
setAccountActionPending(null);
|
||||
void refreshAccount();
|
||||
}
|
||||
};
|
||||
|
||||
const switchAccount = async (organizationId: string | null) => {
|
||||
if (switchTargetId !== null) {
|
||||
return;
|
||||
}
|
||||
setSwitchTargetId(organizationId ?? "");
|
||||
try {
|
||||
await switchActiveAccount(organizationId);
|
||||
await loadOverview();
|
||||
} catch (err) {
|
||||
const message = normalizeAccountViewError(err).message;
|
||||
setOverviewError(message);
|
||||
} finally {
|
||||
setSwitchTargetId(null);
|
||||
void refreshAccount();
|
||||
}
|
||||
};
|
||||
|
||||
// -- Usage fetch (lazy on tab switch) --
|
||||
const loadUsage = useCallback(async () => {
|
||||
const generation = usageGenerationRef.current;
|
||||
@@ -296,54 +412,154 @@ export function AccountView() {
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSignedOut = () => (
|
||||
<div className="rounded-lg border border-border p-6">
|
||||
<div className="mx-auto flex max-w-xl flex-col items-center gap-4 py-8 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<UserCircleIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
Sign in to Cline
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Connect your Cline account to review credits, usage, billing, and
|
||||
organization details.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={accountActionPending !== null}
|
||||
onClick={() => void signIn()}
|
||||
className="flex items-center gap-2 rounded-lg bg-primary px-3.5 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-60"
|
||||
>
|
||||
{accountActionPending === "sign-in" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<LogIn className="h-4 w-4" />
|
||||
)}
|
||||
{accountActionPending === "sign-in" ? "Signing in" : "Sign in"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openExternalUrl(CREATE_ACCOUNT_URL)}
|
||||
className="flex items-center gap-2 rounded-lg border border-border px-3.5 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
Create account
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderLoading = () => (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderAccountRow = (input: {
|
||||
key: string;
|
||||
name: string;
|
||||
subtitle: string;
|
||||
icon: React.ReactNode;
|
||||
active: boolean;
|
||||
switching: boolean;
|
||||
onSelect: () => void;
|
||||
}) => (
|
||||
<button
|
||||
key={input.key}
|
||||
type="button"
|
||||
disabled={input.active || switchTargetId !== null}
|
||||
onClick={input.onSelect}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-lg border border-border px-4 py-3 text-left transition-colors",
|
||||
input.active ? "cursor-default" : "hover:bg-accent/20",
|
||||
!input.active && switchTargetId !== null && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-secondary text-sm font-bold text-foreground">
|
||||
{input.icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground">{input.name}</p>
|
||||
<p className="text-xs text-muted-foreground capitalize">
|
||||
{input.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
{input.switching ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : input.active ? (
|
||||
<span className="rounded-full bg-primary/20 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
Active
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">Switch</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="mx-auto max-w-3xl px-8 py-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-foreground">Account</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-lg border border-border px-3.5 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Sign Out
|
||||
</button>
|
||||
{user && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={accountActionPending !== null}
|
||||
onClick={() => void signOut()}
|
||||
className="flex items-center gap-2 rounded-lg border border-border px-3.5 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-60"
|
||||
>
|
||||
{accountActionPending === "sign-out" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<LogOut className="h-4 w-4" />
|
||||
)}
|
||||
{accountActionPending === "sign-out" ? "Signing Out" : "Sign Out"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-6 flex items-center gap-0 border-b border-border">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={cn(
|
||||
"relative px-4 py-2.5 text-sm font-medium capitalize transition-colors",
|
||||
activeTab === tab
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{tab}
|
||||
{activeTab === tab && (
|
||||
<span className="absolute inset-x-0 -bottom-px h-0.5 bg-foreground" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{tabs.map((tab) => {
|
||||
const disabled = !user && tab !== "overview";
|
||||
return (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={cn(
|
||||
"relative px-4 py-2.5 text-sm font-medium capitalize transition-colors",
|
||||
activeTab === tab
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
disabled &&
|
||||
"cursor-not-allowed opacity-45 hover:text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{tab}
|
||||
{activeTab === tab && (
|
||||
<span className="absolute inset-x-0 -bottom-px h-0.5 bg-foreground" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Overview Tab */}
|
||||
{activeTab === "overview" && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{overviewLoading && renderLoading()}
|
||||
{overviewError && renderError(overviewError, loadOverview)}
|
||||
{overviewError &&
|
||||
(isAccountAuthError(overviewError)
|
||||
? renderSignedOut()
|
||||
: renderError(overviewError, loadOverview))}
|
||||
{!overviewLoading && !overviewError && user && (
|
||||
<>
|
||||
{/* User Profile Card */}
|
||||
@@ -365,14 +581,14 @@ export function AccountView() {
|
||||
Member since {formatDate(user.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="https://app.cline.bot/dashboard"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<button
|
||||
type="button"
|
||||
title="Open dashboard"
|
||||
onClick={() => void openExternalUrl(DASHBOARD_URL)}
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Link>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -388,15 +604,20 @@ export function AccountView() {
|
||||
: "Credits Balance"}
|
||||
</h3>
|
||||
</div>
|
||||
<Link
|
||||
href="https://app.cline.bot/dashboard/organization?tab=credits&redirect=true"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void openExternalUrl(
|
||||
activeOrganization
|
||||
? ORGANIZATION_CREDITS_URL
|
||||
: USER_CREDITS_URL,
|
||||
)
|
||||
}
|
||||
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Credit
|
||||
</Link>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold text-foreground">
|
||||
@@ -421,47 +642,39 @@ export function AccountView() {
|
||||
Organizations
|
||||
</h3>
|
||||
</div>
|
||||
<Link
|
||||
href="https://app.cline.bot/onboarding?step=1"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void openExternalUrl(CREATE_ORGANIZATION_URL)
|
||||
}
|
||||
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Create
|
||||
</Link>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{renderAccountRow({
|
||||
key: "personal",
|
||||
name: "Personal",
|
||||
subtitle: user.email ?? "Personal account",
|
||||
icon: <User className="h-4 w-4" />,
|
||||
active: !activeOrganization,
|
||||
switching: switchTargetId === "",
|
||||
onSelect: () => void switchAccount(null),
|
||||
})}
|
||||
{organizations.map((org) =>
|
||||
renderAccountRow({
|
||||
key: org.organizationId,
|
||||
name: org.name,
|
||||
subtitle: org.roles.join(", "),
|
||||
icon: org.name.charAt(0),
|
||||
active: org.active,
|
||||
switching: switchTargetId === org.organizationId,
|
||||
onSelect: () => void switchAccount(org.organizationId),
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
{organizations.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No organizations yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{organizations.map((org) => (
|
||||
<div
|
||||
key={org.organizationId}
|
||||
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 transition-colors hover:bg-accent/20"
|
||||
>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-secondary text-sm font-bold text-foreground">
|
||||
{org.name.charAt(0)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{org.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground capitalize">
|
||||
{org.roles.join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
{org.active && (
|
||||
<span className="rounded-full bg-primary/20 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageFrame, PageHeader } from "../page-layout";
|
||||
|
||||
const CAPABILITY_OPTIONS = [
|
||||
"streaming",
|
||||
@@ -204,326 +204,326 @@ export function AddProviderContent({
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="mx-auto max-w-3xl px-8 py-6">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<PageFrame contentClassName="max-w-4xl">
|
||||
<PageHeader
|
||||
description="Add an OpenAI-compatible provider and choose its available models."
|
||||
title="Add Provider"
|
||||
actions={
|
||||
<Button
|
||||
onClick={onBack}
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
variant="secondary"
|
||||
className="rounded-md p-1.5 transition-colors"
|
||||
aria-label="Back to providers"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Providers
|
||||
</Button>
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Add Provider
|
||||
</h2>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<h3 className="mb-4 text-sm font-semibold text-foreground">
|
||||
OpenAI-Compatible Provider
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Provider ID
|
||||
</Label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.providerId}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, providerId: e.target.value }))
|
||||
}
|
||||
placeholder="my-provider"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Lowercase ID used in provider registry.
|
||||
</p>
|
||||
{duplicateProviderId ? (
|
||||
<p className="mt-1 text-xs text-destructive">
|
||||
This provider ID already exists.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Provider Name
|
||||
</Label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, name: e.target.value }))
|
||||
}
|
||||
placeholder="My Provider"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Base URL
|
||||
</Label>
|
||||
<input
|
||||
type="url"
|
||||
value={form.baseUrl}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, baseUrl: e.target.value }))
|
||||
}
|
||||
placeholder="https://api.example.com/v1"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Model Source URL (Optional)
|
||||
</Label>
|
||||
<input
|
||||
type="url"
|
||||
value={form.modelsSourceUrl}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
modelsSourceUrl: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="https://api.example.com/v1/models"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Supported JSON: OpenAI `/models` shape with a `data` array, or a
|
||||
direct model array.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Models
|
||||
</Label>
|
||||
<div className="flex min-h-11 flex-wrap content-start gap-1.5 rounded-lg border border-border bg-input px-3 py-2 focus-within:ring-1 focus-within:ring-ring">
|
||||
{form.models.map((model) => (
|
||||
<span
|
||||
key={model}
|
||||
className="inline-flex items-center gap-1 rounded-md bg-primary/10 px-2 py-1 text-xs font-medium text-primary"
|
||||
>
|
||||
<span className="font-mono">{model}</span>
|
||||
<Button
|
||||
onClick={() => removeModel(model)}
|
||||
className="text-primary/60 hover:text-primary transition-colors"
|
||||
aria-label={`Remove ${model}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</span>
|
||||
))}
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<h3 className="mb-4 text-sm font-semibold text-foreground">
|
||||
OpenAI-Compatible Provider
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Provider ID
|
||||
</Label>
|
||||
<input
|
||||
type="text"
|
||||
value={modelInput}
|
||||
onChange={(e) => setModelInput(e.target.value)}
|
||||
onKeyDown={handleAddModel}
|
||||
placeholder={
|
||||
form.models.length === 0
|
||||
? "Type model ID and press Enter"
|
||||
: ""
|
||||
value={form.providerId}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, providerId: e.target.value }))
|
||||
}
|
||||
className="min-w-35 flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground/50 outline-none"
|
||||
placeholder="my-provider"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Lowercase ID used in provider registry.
|
||||
</p>
|
||||
{duplicateProviderId ? (
|
||||
<p className="mt-1 text-xs text-destructive">
|
||||
This provider ID already exists.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Add at least one model or set a Model Source URL.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{form.models.length > 1 ? (
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Default Model
|
||||
Provider Name
|
||||
</Label>
|
||||
<select
|
||||
value={form.defaultModel}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, defaultModel: e.target.value }))
|
||||
}
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{form.models.map((model) => (
|
||||
<option key={model} value={model}>
|
||||
{model}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
API Key (Optional)
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showApiKey ? "text" : "password"}
|
||||
value={form.apiKey}
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, apiKey: e.target.value }))
|
||||
setForm((prev) => ({ ...prev, name: e.target.value }))
|
||||
}
|
||||
placeholder="sk-..."
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 pr-20 font-mono text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
placeholder="My Provider"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
|
||||
<Button
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
className="rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label={showApiKey ? "Hide API key" : "Show API key"}
|
||||
>
|
||||
{showApiKey ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => navigator.clipboard.writeText(form.apiKey)}
|
||||
className="rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Copy API key"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-3 block text-xs font-medium text-muted-foreground">
|
||||
Capabilities
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CAPABILITY_OPTIONS.map((cap) => (
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Base URL
|
||||
</Label>
|
||||
<input
|
||||
type="url"
|
||||
value={form.baseUrl}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, baseUrl: e.target.value }))
|
||||
}
|
||||
placeholder="https://api.example.com/v1"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Model Source URL (Optional)
|
||||
</Label>
|
||||
<input
|
||||
type="url"
|
||||
value={form.modelsSourceUrl}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
modelsSourceUrl: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="https://api.example.com/v1/models"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Supported JSON: OpenAI `/models` shape with a `data` array, or a
|
||||
direct model array.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Models
|
||||
</Label>
|
||||
<div className="flex min-h-11 flex-wrap content-start gap-1.5 rounded-lg border border-border bg-input px-3 py-2 focus-within:ring-1 focus-within:ring-ring">
|
||||
{form.models.map((model) => (
|
||||
<span
|
||||
key={model}
|
||||
className="inline-flex items-center gap-1 rounded-md bg-primary/10 px-2 py-1 text-xs font-medium text-primary"
|
||||
>
|
||||
<span className="font-mono">{model}</span>
|
||||
<Button
|
||||
key={cap}
|
||||
onClick={() => toggleCapability(cap)}
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-1.5 text-xs font-medium transition-all",
|
||||
form.capabilities.includes(cap)
|
||||
? "border-primary/40 bg-primary/10 text-primary"
|
||||
: "border-border bg-card text-muted-foreground hover:border-muted-foreground/50 hover:text-foreground",
|
||||
)}
|
||||
onClick={() => removeModel(model)}
|
||||
className="text-primary/60 hover:text-primary transition-colors"
|
||||
aria-label={`Remove ${model}`}
|
||||
>
|
||||
{cap.replace(/-/g, " ")}
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
type="text"
|
||||
value={modelInput}
|
||||
onChange={(e) => setModelInput(e.target.value)}
|
||||
onKeyDown={handleAddModel}
|
||||
placeholder={
|
||||
form.models.length === 0 ? "Type model ID and press Enter" : ""
|
||||
}
|
||||
className="min-w-35 flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground/50 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Add at least one model or set a Model Source URL.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{form.models.length > 1 ? (
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Default Model
|
||||
</Label>
|
||||
<select
|
||||
value={form.defaultModel}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, defaultModel: e.target.value }))
|
||||
}
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{form.models.map((model) => (
|
||||
<option key={model} value={model}>
|
||||
{model}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
API Key (Optional)
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showApiKey ? "text" : "password"}
|
||||
value={form.apiKey}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, apiKey: e.target.value }))
|
||||
}
|
||||
placeholder="sk-..."
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 pr-20 font-mono text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
|
||||
<Button
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
variant="ghost"
|
||||
className="rounded-md p-1 transition-colors"
|
||||
aria-label={showApiKey ? "Hide API key" : "Show API key"}
|
||||
>
|
||||
{showApiKey ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => navigator.clipboard.writeText(form.apiKey)}
|
||||
variant="ghost"
|
||||
className="rounded-md p-1 transition-colors"
|
||||
aria-label="Copy API key"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<Button
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="flex w-full items-center justify-between px-5 py-4 text-sm font-medium text-foreground hover:bg-accent/30 transition-colors"
|
||||
>
|
||||
Advanced Settings
|
||||
<ChevronDown
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-3 block text-xs font-medium text-muted-foreground">
|
||||
Capabilities
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CAPABILITY_OPTIONS.map((cap) => (
|
||||
<Button
|
||||
key={cap}
|
||||
onClick={() => toggleCapability(cap)}
|
||||
className={cn(
|
||||
"h-4 w-4 text-muted-foreground transition-transform",
|
||||
showAdvanced && "rotate-180",
|
||||
"rounded-lg border px-3 py-1.5 text-xs font-medium transition-all",
|
||||
form.capabilities.includes(cap)
|
||||
? "border-primary/40 bg-primary/10 text-primary"
|
||||
: "border-border bg-card text-muted-foreground hover:border-muted-foreground/50 hover:text-foreground",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
>
|
||||
{cap.replace(/-/g, " ")}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<Button
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="flex w-full items-center justify-between px-5 py-4 text-sm font-medium transition-colors text-foreground/40"
|
||||
variant="ghost"
|
||||
>
|
||||
Advanced Settings
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 text-muted-foreground transition-transform",
|
||||
showAdvanced && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{showAdvanced ? (
|
||||
<div className="border-t border-border px-5 py-5 flex flex-col gap-5">
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Timeout (ms)
|
||||
</Label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.timeoutMs}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
timeoutMs: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="30000"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
{showAdvanced ? (
|
||||
<div className="border-t border-border px-5 py-5 flex flex-col gap-5">
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Timeout (ms)
|
||||
</Label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.timeoutMs}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
timeoutMs: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="30000"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Custom Headers
|
||||
</Label>
|
||||
<div className="flex flex-col gap-2">
|
||||
{Object.entries(form.headers).map(([key, value], idx) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={key}
|
||||
onChange={(e) =>
|
||||
updateHeaderKey(key, e.target.value, idx)
|
||||
}
|
||||
placeholder="Header name"
|
||||
className="flex-1 rounded-lg border border-border bg-input px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) =>
|
||||
updateHeaderValue(key, e.target.value)
|
||||
}
|
||||
placeholder="Value"
|
||||
className="flex-1 rounded-lg border border-border bg-input px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => removeHeader(key)}
|
||||
className="rounded-md p-2 text-muted-foreground hover:text-destructive transition-colors"
|
||||
aria-label="Remove header"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
onClick={addHeader}
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors w-fit"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add Header
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Custom Headers
|
||||
</Label>
|
||||
<div className="flex flex-col gap-2">
|
||||
{Object.entries(form.headers).map(([key, value], idx) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={key}
|
||||
onChange={(e) =>
|
||||
updateHeaderKey(key, e.target.value, idx)
|
||||
}
|
||||
placeholder="Header name"
|
||||
className="flex-1 rounded-lg border border-border bg-input px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => updateHeaderValue(key, e.target.value)}
|
||||
placeholder="Value"
|
||||
className="flex-1 rounded-lg border border-border bg-input px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => removeHeader(key)}
|
||||
className="rounded-md p-2 text-muted-foreground hover:text-destructive transition-colors"
|
||||
aria-label="Remove header"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
onClick={addHeader}
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors w-fit"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add Header
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<Button
|
||||
onClick={onBack}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleSave()}
|
||||
disabled={!canSave || saving}
|
||||
className={cn(
|
||||
"rounded-lg px-4 py-2 text-sm font-medium transition-colors",
|
||||
canSave && !saving
|
||||
? "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
: "bg-muted text-muted-foreground cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
{saving ? "Saving..." : "Add Provider"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<Button
|
||||
onClick={onBack}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleSave()}
|
||||
disabled={!canSave || saving}
|
||||
className={cn(
|
||||
"rounded-lg px-4 py-2 text-sm font-medium transition-colors",
|
||||
canSave && !saving
|
||||
? "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
: "bg-muted text-muted-foreground cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
{saving ? "Saving..." : "Add Provider"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { Circle, Minus, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import {
|
||||
ChevronRight,
|
||||
Circle,
|
||||
Minus,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -13,6 +21,11 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -23,6 +36,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -34,10 +48,26 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type MarketplaceLocalInstalledItem,
|
||||
MarketplaceView,
|
||||
} from "../marketplace-view";
|
||||
import { CommandBadge, PageFrame, PageHeader } from "../page-layout";
|
||||
|
||||
type McpTransportType = "stdio" | "sse" | "streamableHttp";
|
||||
|
||||
type McpServerType = "local" | "remote";
|
||||
|
||||
function serverTypeOf(transportType: McpTransportType): McpServerType {
|
||||
return transportType === "stdio" ? "local" : "remote";
|
||||
}
|
||||
|
||||
const TRANSPORT_TYPE_LABELS: Record<McpTransportType, string> = {
|
||||
stdio: "Local · stdio",
|
||||
sse: "Remote · SSE (legacy)",
|
||||
streamableHttp: "Remote · Streamable HTTP",
|
||||
};
|
||||
|
||||
interface McpServer {
|
||||
name: string;
|
||||
transportType: McpTransportType;
|
||||
@@ -178,6 +208,7 @@ export function McpServersContent() {
|
||||
const [formState, setFormState] = useState<McpServerFormState>(() =>
|
||||
createServerFormState(),
|
||||
);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [formErrorMessage, setFormErrorMessage] = useState<string | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<McpServer | null>(null);
|
||||
|
||||
@@ -287,7 +318,7 @@ export function McpServersContent() {
|
||||
if (form.transportType === "stdio") {
|
||||
const command = form.command.trim();
|
||||
if (!command) {
|
||||
throw new Error("Command is required for stdio transport.");
|
||||
throw new Error("Command is required for local servers.");
|
||||
}
|
||||
const args = splitCsv(form.argsText);
|
||||
return {
|
||||
@@ -304,7 +335,7 @@ export function McpServersContent() {
|
||||
}
|
||||
const url = form.url.trim();
|
||||
if (!url) {
|
||||
throw new Error("URL is required for sse and streamableHttp transport.");
|
||||
throw new Error("Server URL is required for remote servers.");
|
||||
}
|
||||
return {
|
||||
name,
|
||||
@@ -320,6 +351,7 @@ export function McpServersContent() {
|
||||
const openCreateDialog = () => {
|
||||
setEditorMode("create");
|
||||
setFormState(createServerFormState());
|
||||
setAdvancedOpen(false);
|
||||
setFormErrorMessage(null);
|
||||
setEditorOpen(true);
|
||||
};
|
||||
@@ -327,6 +359,9 @@ export function McpServersContent() {
|
||||
const openEditDialog = (server: McpServer) => {
|
||||
setEditorMode("edit");
|
||||
setFormState(createServerFormState(server));
|
||||
setAdvancedOpen(
|
||||
Boolean(server.cwd?.trim()) || server.metadata !== undefined,
|
||||
);
|
||||
setFormErrorMessage(null);
|
||||
setEditorOpen(true);
|
||||
};
|
||||
@@ -404,6 +439,117 @@ export function McpServersContent() {
|
||||
}));
|
||||
};
|
||||
|
||||
const renderServerActions = (server: McpServer) => {
|
||||
const isBusy = busyServerName === server.name;
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${server.name}`}
|
||||
onClick={() => openEditDialog(server)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Delete ${server.name}`}
|
||||
onClick={() => setDeleteTarget(server)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Switch
|
||||
checked={!server.disabled}
|
||||
onCheckedChange={(enabled) => toggleServer(server, !enabled)}
|
||||
disabled={isBusy}
|
||||
aria-label={`Enable ${server.name}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderServerDetails = (server: McpServer) => (
|
||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||
{server.command && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Command:</span>{" "}
|
||||
{server.command}
|
||||
</p>
|
||||
)}
|
||||
{server.args && server.args.length > 0 && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Args:</span>{" "}
|
||||
{server.args.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
{server.cwd && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">CWD:</span> {server.cwd}
|
||||
</p>
|
||||
)}
|
||||
{server.url && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">URL:</span> {server.url}
|
||||
</p>
|
||||
)}
|
||||
{server.env && Object.keys(server.env).length > 0 && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Env:</span>{" "}
|
||||
{stringifyRedactedKeyValuePairs(server.env)}
|
||||
</p>
|
||||
)}
|
||||
{server.headers && Object.keys(server.headers).length > 0 && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Headers:</span>{" "}
|
||||
{stringifyKeyValuePairs(server.headers)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderServerCard = (server: McpServer) => (
|
||||
<div
|
||||
key={server.name}
|
||||
className="rounded-lg border border-border px-5 py-4 transition-colors hover:bg-accent/20"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Circle
|
||||
className={cn(
|
||||
"h-2.5 w-2.5 shrink-0",
|
||||
server.disabled
|
||||
? "fill-muted-foreground/40 text-muted-foreground/40"
|
||||
: "fill-primary text-primary",
|
||||
)}
|
||||
/>
|
||||
<h3 className="text-sm font-semibold text-foreground">{server.name}</h3>
|
||||
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{TRANSPORT_TYPE_LABELS[server.transportType] ?? server.transportType}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
{renderServerActions(server)}
|
||||
</div>
|
||||
<div className="mt-2.5 ml-5.5">{renderServerDetails(server)}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const installedItems = sortedServers.map(
|
||||
(server): MarketplaceLocalInstalledItem => ({
|
||||
key: server.name,
|
||||
matchValues: [server.name],
|
||||
render: () => renderServerCard(server),
|
||||
renderMatchedBadges: () => (
|
||||
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{TRANSPORT_TYPE_LABELS[server.transportType] ?? server.transportType}
|
||||
</span>
|
||||
),
|
||||
renderMatchedControls: () => renderServerActions(server),
|
||||
renderMatchedDetails: () => renderServerDetails(server),
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<PageFrame>
|
||||
<PageHeader
|
||||
@@ -458,112 +604,12 @@ export function McpServersContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
|
||||
Loading MCP servers...
|
||||
</div>
|
||||
) : sortedServers.length === 0 ? (
|
||||
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
|
||||
No MCP servers configured.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{sortedServers.map((server) => {
|
||||
const isBusy = busyServerName === server.name;
|
||||
return (
|
||||
<div
|
||||
key={server.name}
|
||||
className="rounded-lg border border-border px-5 py-4 transition-colors hover:bg-accent/20"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Circle
|
||||
className={cn(
|
||||
"h-2.5 w-2.5 shrink-0",
|
||||
server.disabled
|
||||
? "fill-muted-foreground/40 text-muted-foreground/40"
|
||||
: "fill-primary text-primary",
|
||||
)}
|
||||
/>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{server.name}
|
||||
</h3>
|
||||
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{server.transportType}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${server.name}`}
|
||||
onClick={() => openEditDialog(server)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Delete ${server.name}`}
|
||||
onClick={() => setDeleteTarget(server)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Switch
|
||||
checked={!server.disabled}
|
||||
onCheckedChange={(enabled) =>
|
||||
toggleServer(server, !enabled)
|
||||
}
|
||||
disabled={isBusy}
|
||||
aria-label={`Enable ${server.name}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2.5 ml-5.5 flex flex-col gap-1 text-xs text-muted-foreground">
|
||||
{server.command && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Command:</span>{" "}
|
||||
{server.command}
|
||||
</p>
|
||||
)}
|
||||
{server.args && server.args.length > 0 && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Args:</span>{" "}
|
||||
{server.args.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
{server.cwd && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">CWD:</span>{" "}
|
||||
{server.cwd}
|
||||
</p>
|
||||
)}
|
||||
{server.url && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">URL:</span>{" "}
|
||||
{server.url}
|
||||
</p>
|
||||
)}
|
||||
{server.env && Object.keys(server.env).length > 0 && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Env:</span>{" "}
|
||||
{stringifyRedactedKeyValuePairs(server.env)}
|
||||
</p>
|
||||
)}
|
||||
{server.headers && Object.keys(server.headers).length > 0 && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Headers:</span>{" "}
|
||||
{stringifyKeyValuePairs(server.headers)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<MarketplaceView
|
||||
chrome="embedded"
|
||||
installedItems={installedItems}
|
||||
onInstalledItemsChanged={() => refreshServers()}
|
||||
primitive="mcp"
|
||||
/>
|
||||
<Dialog
|
||||
open={editorOpen}
|
||||
onOpenChange={(open) => {
|
||||
@@ -579,7 +625,9 @@ export function McpServersContent() {
|
||||
{editorMode === "edit" ? "Edit MCP Server" : "Add MCP Server"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update the MCP server stored in{" "}
|
||||
{editorMode === "edit"
|
||||
? "Update the MCP server stored in "
|
||||
: "The server is saved to "}
|
||||
<code className="font-mono">
|
||||
{settingsPath || "cline_mcp_settings.json"}
|
||||
</code>
|
||||
@@ -604,25 +652,60 @@ export function McpServersContent() {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Transport type</Label>
|
||||
<Select
|
||||
value={formState.transportType}
|
||||
<Label>Server type</Label>
|
||||
<RadioGroup
|
||||
className="grid gap-2"
|
||||
value={serverTypeOf(formState.transportType)}
|
||||
onValueChange={(value) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
transportType: value as McpTransportType,
|
||||
transportType:
|
||||
value === "local"
|
||||
? "stdio"
|
||||
: serverTypeOf(current.transportType) === "remote"
|
||||
? current.transportType
|
||||
: "streamableHttp",
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select transport" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="stdio">stdio</SelectItem>
|
||||
<SelectItem value="sse">sse</SelectItem>
|
||||
<SelectItem value="streamableHttp">streamableHttp</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Label
|
||||
htmlFor="mcp-server-type-local"
|
||||
className="flex cursor-pointer items-start gap-3 rounded-md border border-border px-3 py-2.5 font-normal has-[[data-state=checked]]:border-primary/60 has-[[data-state=checked]]:bg-accent/30"
|
||||
>
|
||||
<RadioGroupItem
|
||||
className="mt-0.5"
|
||||
id="mcp-server-type-local"
|
||||
value="local"
|
||||
/>
|
||||
<span className="grid gap-0.5">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Local
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Runs a command on this machine (stdio). Recommended when
|
||||
available.
|
||||
</span>
|
||||
</span>
|
||||
</Label>
|
||||
<Label
|
||||
htmlFor="mcp-server-type-remote"
|
||||
className="flex cursor-pointer items-start gap-3 rounded-md border border-border px-3 py-2.5 font-normal has-[[data-state=checked]]:border-primary/60 has-[[data-state=checked]]:bg-accent/30"
|
||||
>
|
||||
<RadioGroupItem
|
||||
className="mt-0.5"
|
||||
id="mcp-server-type-remote"
|
||||
value="remote"
|
||||
/>
|
||||
<span className="grid gap-0.5">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Remote
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Connects to a hosted server over HTTP by URL.
|
||||
</span>
|
||||
</span>
|
||||
</Label>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{formState.transportType === "stdio" ? (
|
||||
@@ -655,20 +738,6 @@ export function McpServersContent() {
|
||||
placeholder="-y, @modelcontextprotocol/server-github"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-cwd">Working directory</Label>
|
||||
<Input
|
||||
id="mcp-cwd"
|
||||
value={formState.cwd}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
cwd: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="/path/to/project"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label>Environment variables</Label>
|
||||
@@ -747,23 +816,83 @@ export function McpServersContent() {
|
||||
placeholder="Authorization=Bearer token"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Transport</Label>
|
||||
<Select
|
||||
value={formState.transportType}
|
||||
onValueChange={(value) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
transportType: value as McpTransportType,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select transport" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="streamableHttp">
|
||||
Streamable HTTP (recommended)
|
||||
</SelectItem>
|
||||
<SelectItem value="sse">SSE (legacy)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-metadata">Metadata JSON</Label>
|
||||
<Textarea
|
||||
id="mcp-metadata"
|
||||
value={formState.metadataText}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
metadataText: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder='{"key":"value"}'
|
||||
/>
|
||||
</div>
|
||||
<Collapsible
|
||||
className="grid gap-3"
|
||||
onOpenChange={setAdvancedOpen}
|
||||
open={advancedOpen}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-fit items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 transition-transform",
|
||||
advancedOpen && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
Advanced
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="grid gap-4">
|
||||
{formState.transportType === "stdio" && (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-cwd">Working directory</Label>
|
||||
<Input
|
||||
id="mcp-cwd"
|
||||
value={formState.cwd}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
cwd: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="/path/to/project"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-metadata">Metadata JSON</Label>
|
||||
<Textarea
|
||||
id="mcp-metadata"
|
||||
value={formState.metadataText}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
metadataText: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder='{"key":"value"}'
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<div className="flex items-center justify-between rounded-md border border-border px-3 py-2">
|
||||
<div>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
Trash2,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -51,6 +51,12 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import { readModelSelectionStorageFromWindow } from "@/lib/model-selection";
|
||||
import { normalizeProviderId } from "@/lib/provider-id";
|
||||
@@ -355,7 +361,43 @@ export function RoutineSchedulesContent() {
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(() => !routineOverviewCache);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [busyScheduleId, setBusyScheduleId] = useState<string | null>(null);
|
||||
// Sets rather than single ids: several rows can have in-flight actions at
|
||||
// once, and one action finishing must not clear another row's busy state.
|
||||
const [busyScheduleIds, setBusyScheduleIds] = useState<ReadonlySet<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [triggeringScheduleIds, setTriggeringScheduleIds] = useState<
|
||||
ReadonlySet<string>
|
||||
>(new Set());
|
||||
// Ref mirrors busyScheduleIds so a second click on the same row is
|
||||
// rejected synchronously — two rapid clicks can both fire before React
|
||||
// re-renders the disabled state, and state alone can't distinguish them.
|
||||
const busyScheduleIdsRef = useRef<Set<string>>(new Set());
|
||||
const beginScheduleAction = (scheduleId: string): boolean => {
|
||||
if (busyScheduleIdsRef.current.has(scheduleId)) {
|
||||
return false;
|
||||
}
|
||||
busyScheduleIdsRef.current.add(scheduleId);
|
||||
setBusyScheduleIds(new Set(busyScheduleIdsRef.current));
|
||||
return true;
|
||||
};
|
||||
const endScheduleAction = (scheduleId: string) => {
|
||||
busyScheduleIdsRef.current.delete(scheduleId);
|
||||
setBusyScheduleIds(new Set(busyScheduleIdsRef.current));
|
||||
};
|
||||
const setScheduleTriggering = (scheduleId: string, triggering: boolean) => {
|
||||
setTriggeringScheduleIds((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (triggering) {
|
||||
next.add(scheduleId);
|
||||
} else {
|
||||
next.delete(scheduleId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const [viewingSchedule, setViewingSchedule] =
|
||||
useState<RoutineSchedule | null>(null);
|
||||
const [schedulePendingDelete, setSchedulePendingDelete] =
|
||||
useState<RoutineSchedule | null>(null);
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
@@ -613,7 +655,9 @@ export function RoutineSchedulesContent() {
|
||||
schedule: RoutineSchedule,
|
||||
enabled: boolean,
|
||||
) => {
|
||||
setBusyScheduleId(schedule.scheduleId);
|
||||
if (!beginScheduleAction(schedule.scheduleId)) {
|
||||
return;
|
||||
}
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
if (enabled) {
|
||||
@@ -630,16 +674,23 @@ export function RoutineSchedulesContent() {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setErrorMessage(message);
|
||||
} finally {
|
||||
setBusyScheduleId(null);
|
||||
endScheduleAction(schedule.scheduleId);
|
||||
}
|
||||
};
|
||||
|
||||
const triggerSchedule = async (scheduleId: string) => {
|
||||
setBusyScheduleId(scheduleId);
|
||||
const triggerSchedule = async (schedule: RoutineSchedule) => {
|
||||
if (!beginScheduleAction(schedule.scheduleId)) {
|
||||
return;
|
||||
}
|
||||
setScheduleTriggering(schedule.scheduleId, true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
await desktopClient.invoke("trigger_routine_schedule", {
|
||||
schedule_id: scheduleId,
|
||||
schedule_id: schedule.scheduleId,
|
||||
});
|
||||
toast({
|
||||
title: "Run started",
|
||||
description: `"${schedule.name}" was queued to run now.`,
|
||||
});
|
||||
await refreshSchedules({ force: true, showLoading: false });
|
||||
window.setTimeout(() => {
|
||||
@@ -648,13 +699,21 @@ export function RoutineSchedulesContent() {
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setErrorMessage(message);
|
||||
toast({
|
||||
title: "Failed to start run",
|
||||
description: message,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setBusyScheduleId(null);
|
||||
endScheduleAction(schedule.scheduleId);
|
||||
setScheduleTriggering(schedule.scheduleId, false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteSchedule = async (scheduleId: string) => {
|
||||
setBusyScheduleId(scheduleId);
|
||||
if (!beginScheduleAction(scheduleId)) {
|
||||
return;
|
||||
}
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
await desktopClient.invoke("delete_routine_schedule", {
|
||||
@@ -666,7 +725,7 @@ export function RoutineSchedulesContent() {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setErrorMessage(message);
|
||||
} finally {
|
||||
setBusyScheduleId(null);
|
||||
endScheduleAction(scheduleId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -917,7 +976,7 @@ export function RoutineSchedulesContent() {
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{sortedSchedules.map((schedule) => {
|
||||
const isBusy = busyScheduleId === schedule.scheduleId;
|
||||
const isBusy = busyScheduleIds.has(schedule.scheduleId);
|
||||
const activeExecution = executionBySchedule.get(
|
||||
schedule.scheduleId,
|
||||
);
|
||||
@@ -952,70 +1011,113 @@ export function RoutineSchedulesContent() {
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`View ${schedule.name}`}
|
||||
onClick={() => {
|
||||
window.alert(JSON.stringify(schedule, null, 2));
|
||||
}}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${schedule.name}`}
|
||||
onClick={() => openEditDialog(schedule)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Run ${schedule.name} now`}
|
||||
onClick={() => void triggerSchedule(schedule.scheduleId)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={
|
||||
schedule.enabled
|
||||
? `Pause ${schedule.name}`
|
||||
: `Resume ${schedule.name}`
|
||||
}
|
||||
onClick={() =>
|
||||
void upsertScheduleEnabled(schedule, !schedule.enabled)
|
||||
}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{schedule.enabled ? (
|
||||
<Pause className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Delete ${schedule.name}`}
|
||||
onClick={() => setSchedulePendingDelete(schedule)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Switch
|
||||
checked={schedule.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
void upsertScheduleEnabled(schedule, checked)
|
||||
}
|
||||
disabled={isBusy}
|
||||
aria-label={`Enable ${schedule.name}`}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`View ${schedule.name}`}
|
||||
onClick={() => setViewingSchedule(schedule)}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>View details</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${schedule.name}`}
|
||||
onClick={() => openEditDialog(schedule)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit schedule</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Run ${schedule.name} now`}
|
||||
onClick={() => void triggerSchedule(schedule)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{triggeringScheduleIds.has(schedule.scheduleId) ? (
|
||||
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Run now</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={
|
||||
schedule.enabled
|
||||
? `Pause ${schedule.name}`
|
||||
: `Resume ${schedule.name}`
|
||||
}
|
||||
onClick={() =>
|
||||
void upsertScheduleEnabled(
|
||||
schedule,
|
||||
!schedule.enabled,
|
||||
)
|
||||
}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{schedule.enabled ? (
|
||||
<Pause className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{schedule.enabled
|
||||
? "Pause schedule"
|
||||
: "Resume schedule"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Delete ${schedule.name}`}
|
||||
onClick={() => setSchedulePendingDelete(schedule)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete schedule</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Switch
|
||||
checked={schedule.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
void upsertScheduleEnabled(schedule, checked)
|
||||
}
|
||||
disabled={isBusy}
|
||||
aria-label={`Enable ${schedule.name}`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{schedule.enabled
|
||||
? "Enabled — click to disable"
|
||||
: "Disabled — click to enable"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1095,6 +1197,58 @@ export function RoutineSchedulesContent() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<Dialog
|
||||
open={Boolean(viewingSchedule)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setViewingSchedule(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{viewingSchedule?.name ?? "Schedule"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Full configuration for this schedule.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{viewingSchedule && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-1 gap-1.5 text-xs sm:grid-cols-2">
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Cron:</span>{" "}
|
||||
<span className="font-mono">
|
||||
{viewingSchedule.cronPattern}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Mode:</span>{" "}
|
||||
{viewingSchedule.mode}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Model:</span>{" "}
|
||||
{formatScheduleModel(viewingSchedule)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Enabled:</span>{" "}
|
||||
{viewingSchedule.enabled ? "yes" : "no"}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Last run:</span>{" "}
|
||||
{formatDateTime(viewingSchedule.lastRunAt)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Next run:</span>{" "}
|
||||
{formatDateTime(viewingSchedule.nextRunAt)}
|
||||
</p>
|
||||
</div>
|
||||
<pre className="max-h-80 overflow-auto rounded-md border border-border bg-muted/30 p-3 text-xs">
|
||||
{JSON.stringify(viewingSchedule, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<AlertDialog
|
||||
open={Boolean(schedulePendingDelete)}
|
||||
onOpenChange={(open) => {
|
||||
@@ -1115,7 +1269,7 @@ export function RoutineSchedulesContent() {
|
||||
<AlertDialogCancel
|
||||
disabled={
|
||||
schedulePendingDelete
|
||||
? busyScheduleId === schedulePendingDelete.scheduleId
|
||||
? busyScheduleIds.has(schedulePendingDelete.scheduleId)
|
||||
: false
|
||||
}
|
||||
>
|
||||
@@ -1124,7 +1278,7 @@ export function RoutineSchedulesContent() {
|
||||
<AlertDialogAction
|
||||
disabled={
|
||||
!schedulePendingDelete ||
|
||||
busyScheduleId === schedulePendingDelete.scheduleId
|
||||
busyScheduleIds.has(schedulePendingDelete.scheduleId)
|
||||
}
|
||||
onClick={() => {
|
||||
if (schedulePendingDelete) {
|
||||
|
||||
@@ -17,7 +17,7 @@ import { PageFrame, PageHeader } from "../page-layout";
|
||||
import { AccountView } from "./account-view";
|
||||
import { AddProviderContent, type AddProviderPayload } from "./add-provider";
|
||||
import { ChannelsContent } from "./channels-view";
|
||||
import { CustomizationSectionView, RulesView } from "./extensions-view";
|
||||
import { CustomizationSectionView } from "./extensions-view";
|
||||
import { McpServersContent } from "./mcp-view";
|
||||
import {
|
||||
ProviderDetailContent,
|
||||
@@ -33,15 +33,25 @@ import { toSettingsPatch } from "./settings-patch";
|
||||
export const SETTINGS_SECTIONS = [
|
||||
"General",
|
||||
"Models",
|
||||
"MCP Servers",
|
||||
"MCP Marketplace",
|
||||
"Customizations",
|
||||
"Channels",
|
||||
"Schedules",
|
||||
"Account",
|
||||
] as const;
|
||||
|
||||
export type SettingsSection = (typeof SETTINGS_SECTIONS)[number];
|
||||
// Mirrors the Cline Hub dashboard's Customizations nav group.
|
||||
export const CUSTOMIZATION_SECTIONS = [
|
||||
"Plugins",
|
||||
"Skills",
|
||||
"MCP",
|
||||
"Hooks",
|
||||
"Rules",
|
||||
"Agents",
|
||||
"Tools",
|
||||
] as const;
|
||||
|
||||
export type SettingsSection =
|
||||
| (typeof SETTINGS_SECTIONS)[number]
|
||||
| (typeof CUSTOMIZATION_SECTIONS)[number];
|
||||
type GlobalSettingsResponse = {
|
||||
telemetryOptOut: boolean;
|
||||
autoUpdateEnabled: boolean;
|
||||
@@ -400,12 +410,20 @@ export function SettingsView({
|
||||
const content =
|
||||
activeNav === "Models" ? (
|
||||
providerContent
|
||||
) : activeNav === "MCP Servers" ? (
|
||||
) : activeNav === "Plugins" ? (
|
||||
<CustomizationSectionView catalogPrimitive="plugin" section="Plugins" />
|
||||
) : activeNav === "Skills" ? (
|
||||
<CustomizationSectionView catalogPrimitive="skill" section="Skills" />
|
||||
) : activeNav === "MCP" ? (
|
||||
<McpServersContent />
|
||||
) : activeNav === "MCP Marketplace" ? (
|
||||
<CustomizationSectionView catalogPrimitive="mcp" section="MCP" />
|
||||
) : activeNav === "Customizations" ? (
|
||||
<RulesView />
|
||||
) : activeNav === "Hooks" ? (
|
||||
<CustomizationSectionView section="Hooks" />
|
||||
) : activeNav === "Rules" ? (
|
||||
<CustomizationSectionView section="Rules" />
|
||||
) : activeNav === "Agents" ? (
|
||||
<CustomizationSectionView section="Agents" />
|
||||
) : activeNav === "Tools" ? (
|
||||
<CustomizationSectionView section="Tools" />
|
||||
) : activeNav === "Channels" ? (
|
||||
<ChannelsContent />
|
||||
) : activeNav === "Schedules" ? (
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import type { ClineAccountUser } from "@cline/core";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ACCOUNT_IDENTITY_STORAGE_KEY,
|
||||
AccountProvider,
|
||||
isSignedOutAccountError,
|
||||
parseCachedAccountUser,
|
||||
useAccount,
|
||||
} from "./account-context";
|
||||
|
||||
const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() }));
|
||||
vi.mock("@/lib/desktop-client", () => ({ desktopClient: { invoke } }));
|
||||
|
||||
function makeUser(overrides: Partial<ClineAccountUser> = {}): ClineAccountUser {
|
||||
return {
|
||||
id: "user-1",
|
||||
email: "beatrix@cline.bot",
|
||||
displayName: "Beatrix",
|
||||
photoUrl: "",
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-01T00:00:00Z",
|
||||
organizations: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function Probe() {
|
||||
const { user, activeOrganization } = useAccount();
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="account-name">{user?.displayName ?? "none"}</span>
|
||||
<span data-testid="account-org">
|
||||
{activeOrganization?.name ?? "none"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
function probeText(testId: string): string | null | undefined {
|
||||
return container.querySelector(`[data-testid="${testId}"]`)?.textContent;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
window.localStorage.clear();
|
||||
invoke.mockReset();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("account context", () => {
|
||||
it("parses only cached payloads that look like an account user", () => {
|
||||
expect(parseCachedAccountUser(null)).toBeNull();
|
||||
expect(parseCachedAccountUser("not json")).toBeNull();
|
||||
expect(parseCachedAccountUser(JSON.stringify({ user: 42 }))).toBeNull();
|
||||
expect(
|
||||
parseCachedAccountUser(JSON.stringify({ user: makeUser() }))?.displayName,
|
||||
).toBe("Beatrix");
|
||||
});
|
||||
|
||||
it("classifies signed-out errors separately from transient failures", () => {
|
||||
expect(
|
||||
isSignedOutAccountError(new Error("No Cline account auth token found")),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isSignedOutAccountError(
|
||||
new Error(
|
||||
'OAuth credentials for provider "cline" are no longer valid. Re-run authentication for this provider.',
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(isSignedOutAccountError(new Error("fetch failed"))).toBe(false);
|
||||
});
|
||||
|
||||
it("fetches the signed-in user on mount and caches the identity", async () => {
|
||||
invoke.mockResolvedValue(
|
||||
makeUser({
|
||||
organizations: [
|
||||
{
|
||||
active: true,
|
||||
memberId: "member-1",
|
||||
name: "Cline Bot Inc",
|
||||
organizationId: "org-1",
|
||||
roles: ["admin"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AccountProvider>
|
||||
<Probe />
|
||||
</AccountProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(probeText("account-name")).toBe("Beatrix");
|
||||
expect(probeText("account-org")).toBe("Cline Bot Inc");
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith("cline_account", {
|
||||
action: "clineAccount",
|
||||
operation: "fetchMe",
|
||||
});
|
||||
expect(
|
||||
parseCachedAccountUser(
|
||||
window.localStorage.getItem(ACCOUNT_IDENTITY_STORAGE_KEY),
|
||||
)?.email,
|
||||
).toBe("beatrix@cline.bot");
|
||||
});
|
||||
|
||||
it("clears the cached identity when the account is signed out", async () => {
|
||||
window.localStorage.setItem(
|
||||
ACCOUNT_IDENTITY_STORAGE_KEY,
|
||||
JSON.stringify({ user: makeUser() }),
|
||||
);
|
||||
invoke.mockRejectedValue(new Error("No Cline account auth token found"));
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AccountProvider>
|
||||
<Probe />
|
||||
</AccountProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(probeText("account-name")).toBe("none");
|
||||
});
|
||||
expect(
|
||||
window.localStorage.getItem(ACCOUNT_IDENTITY_STORAGE_KEY),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the cached identity when the refresh fails transiently", async () => {
|
||||
window.localStorage.setItem(
|
||||
ACCOUNT_IDENTITY_STORAGE_KEY,
|
||||
JSON.stringify({ user: makeUser() }),
|
||||
);
|
||||
invoke.mockRejectedValue(
|
||||
new Error("Desktop backend transport unavailable"),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AccountProvider>
|
||||
<Probe />
|
||||
</AccountProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalled();
|
||||
});
|
||||
expect(probeText("account-name")).toBe("Beatrix");
|
||||
expect(
|
||||
window.localStorage.getItem(ACCOUNT_IDENTITY_STORAGE_KEY),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import type { ClineAccountOrganization, ClineAccountUser } from "@cline/core";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
|
||||
export const ACCOUNT_IDENTITY_STORAGE_KEY = "cline.code.account-identity.v1";
|
||||
|
||||
const SIGNED_OUT_ERROR_MARKERS = [
|
||||
"No Cline account auth token found",
|
||||
"no longer valid",
|
||||
];
|
||||
|
||||
type AccountContextValue = {
|
||||
user: ClineAccountUser | null;
|
||||
organizations: ClineAccountOrganization[];
|
||||
activeOrganization: ClineAccountOrganization | null;
|
||||
refreshAccount: () => Promise<void>;
|
||||
};
|
||||
|
||||
const AccountContext = createContext<AccountContextValue>({
|
||||
user: null,
|
||||
organizations: [],
|
||||
activeOrganization: null,
|
||||
refreshAccount: async () => undefined,
|
||||
});
|
||||
|
||||
export function parseCachedAccountUser(
|
||||
raw: string | null,
|
||||
): ClineAccountUser | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { user?: ClineAccountUser | null };
|
||||
const user = parsed?.user;
|
||||
if (!user || typeof user !== "object") {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
typeof user.email !== "string" &&
|
||||
typeof user.displayName !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return user;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readCachedAccountUser(): ClineAccountUser | null {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return parseCachedAccountUser(
|
||||
window.localStorage.getItem(ACCOUNT_IDENTITY_STORAGE_KEY),
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCachedAccountUser(user: ClineAccountUser | null): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (user) {
|
||||
window.localStorage.setItem(
|
||||
ACCOUNT_IDENTITY_STORAGE_KEY,
|
||||
JSON.stringify({ user }),
|
||||
);
|
||||
} else {
|
||||
window.localStorage.removeItem(ACCOUNT_IDENTITY_STORAGE_KEY);
|
||||
}
|
||||
} catch {
|
||||
// Account identity still works for this session without the cache.
|
||||
}
|
||||
}
|
||||
|
||||
export function isSignedOutAccountError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return SIGNED_OUT_ERROR_MARKERS.some((marker) => message.includes(marker));
|
||||
}
|
||||
|
||||
export function AccountProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<ClineAccountUser | null>(null);
|
||||
|
||||
const refreshAccount = useCallback(async () => {
|
||||
try {
|
||||
const me = await desktopClient.invoke<ClineAccountUser>("cline_account", {
|
||||
action: "clineAccount",
|
||||
operation: "fetchMe",
|
||||
});
|
||||
setUser(me ?? null);
|
||||
writeCachedAccountUser(me ?? null);
|
||||
} catch (error) {
|
||||
if (isSignedOutAccountError(error)) {
|
||||
setUser(null);
|
||||
writeCachedAccountUser(null);
|
||||
}
|
||||
// Transient failures (offline, sidecar restarting) keep the cached
|
||||
// identity rather than flashing a signed-out state.
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Seed from the cached identity after mount so the signed-in name renders
|
||||
// without waiting on the network fetch, which revalidates it right after.
|
||||
// localStorage must not be read during the initial render: the server
|
||||
// renders the signed-out state, and a differing first client render would
|
||||
// be a hydration mismatch.
|
||||
setUser((current) => current ?? readCachedAccountUser());
|
||||
void refreshAccount();
|
||||
}, [refreshAccount]);
|
||||
|
||||
const value = useMemo<AccountContextValue>(() => {
|
||||
const organizations = user?.organizations ?? [];
|
||||
return {
|
||||
user,
|
||||
organizations,
|
||||
activeOrganization:
|
||||
organizations.find((organization) => organization.active) ?? null,
|
||||
refreshAccount,
|
||||
};
|
||||
}, [refreshAccount, user]);
|
||||
|
||||
return (
|
||||
<AccountContext.Provider value={value}>{children}</AccountContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAccount(): AccountContextValue {
|
||||
return useContext(AccountContext);
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import type { SessionHookEvent } from "@/lib/session-diff";
|
||||
export type ProcessContext = {
|
||||
workspaceRoot: string;
|
||||
cwd: string;
|
||||
homeDir?: string;
|
||||
platform?: string;
|
||||
appVersion?: string;
|
||||
};
|
||||
|
||||
export type AgentChunkEvent = {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { ToastAction } from "@/components/ui/toast";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import { desktopClient, isTauriAvailable } from "@/lib/desktop-client";
|
||||
|
||||
export type AppUpdateStatus = {
|
||||
state: "idle" | "checking" | "downloading" | "ready" | "error";
|
||||
version?: string | null;
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
const POLL_INTERVAL_MS = 30_000;
|
||||
|
||||
// Module-scoped so a page remount does not re-toast an update the user
|
||||
// already dismissed while the Rust side still reports it as "ready".
|
||||
let notifiedVersion: string | null = null;
|
||||
|
||||
/**
|
||||
* Watches the Tauri shell's auto-updater. Updates are checked, downloaded, and
|
||||
* installed in the background by the Rust side; once one is staged this hook
|
||||
* surfaces a persistent toast offering a one-click restart into the new
|
||||
* version. Ignoring the toast is fine too — the staged update takes effect on
|
||||
* the next launch. No-op in web/sidecar mode where there is no app bundle to
|
||||
* update.
|
||||
*/
|
||||
export function useAppUpdate() {
|
||||
useEffect(() => {
|
||||
if (!isTauriAvailable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const poll = async () => {
|
||||
let status: AppUpdateStatus;
|
||||
try {
|
||||
status =
|
||||
await desktopClient.invoke<AppUpdateStatus>("get_update_status");
|
||||
} catch {
|
||||
// Update status is best-effort; ignore transient bridge failures.
|
||||
return;
|
||||
}
|
||||
if (cancelled || status.state !== "ready" || !status.version) {
|
||||
return;
|
||||
}
|
||||
if (notifiedVersion === status.version) {
|
||||
return;
|
||||
}
|
||||
notifiedVersion = status.version;
|
||||
toast({
|
||||
title: `Update ready: v${status.version}`,
|
||||
description:
|
||||
"The new version has been downloaded and will be used the next time the app starts.",
|
||||
duration: Number.POSITIVE_INFINITY,
|
||||
action: (
|
||||
<ToastAction
|
||||
altText="Restart now"
|
||||
onClick={() => {
|
||||
void desktopClient.invoke("restart_to_apply_update");
|
||||
}}
|
||||
>
|
||||
Restart now
|
||||
</ToastAction>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
void poll();
|
||||
const interval = setInterval(() => {
|
||||
void poll();
|
||||
}, POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -53,6 +53,100 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("useChatSession", () => {
|
||||
it("asks the user to select a workspace before submitting", async () => {
|
||||
await act(async () => {
|
||||
current.setConfig((previous) => ({
|
||||
...previous,
|
||||
workspaceRoot: "",
|
||||
cwd: "",
|
||||
}));
|
||||
});
|
||||
invokeMock.mockClear();
|
||||
|
||||
await act(async () => current.sendPrompt("Start the task"));
|
||||
|
||||
expect(current.error).toBe("Select a workspace before trying again.");
|
||||
expect(current.messages.at(-1)).toMatchObject({
|
||||
role: "error",
|
||||
content: "Select a workspace before trying again.",
|
||||
});
|
||||
expect(invokeMock).not.toHaveBeenCalledWith(
|
||||
"chat_session_command",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces raw workspace manifest errors with actionable copy", async () => {
|
||||
invokeMock.mockImplementation(async (command: string) => {
|
||||
if (command === "get_process_context") {
|
||||
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
|
||||
}
|
||||
if (command === "chat_session_command") {
|
||||
throw new Error(
|
||||
'[{"origin":"string","code":"too_small","path":["workspaces","/","hint"],"message":"Too small: expected string to have >=1 characters"}]',
|
||||
);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
await act(async () => current.start(current.config));
|
||||
|
||||
expect(current.error).toBe("Select a workspace before trying again.");
|
||||
expect(current.messages.at(-1)?.content).toBe(
|
||||
"Select a workspace before trying again.",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
finishReason: "completed",
|
||||
expected:
|
||||
'[{"code":"too_small","path":["workspaces","/","hint"],"message":"expected string to have >=1 characters"}]',
|
||||
},
|
||||
{
|
||||
finishReason: "error",
|
||||
expected: "Select a workspace before trying again.",
|
||||
},
|
||||
])("handles schema-like assistant text for $finishReason responses", async ({
|
||||
finishReason,
|
||||
expected,
|
||||
}) => {
|
||||
const schemaLikeText =
|
||||
'[{"code":"too_small","path":["workspaces","/","hint"],"message":"expected string to have >=1 characters"}]';
|
||||
invokeMock.mockImplementation(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === "get_process_context") {
|
||||
return {
|
||||
cwd: "/workspace/cline",
|
||||
workspaceRoot: "/workspace/cline",
|
||||
};
|
||||
}
|
||||
if (command === "chat_session_command") {
|
||||
const request = args?.request as
|
||||
| { action?: string; config?: { sessionId?: string } }
|
||||
| undefined;
|
||||
if (request?.action === "start") {
|
||||
return { sessionId: request.config?.sessionId ?? "session-test" };
|
||||
}
|
||||
if (request?.action === "send") {
|
||||
return {
|
||||
ok: true,
|
||||
result: { text: schemaLikeText, finishReason },
|
||||
};
|
||||
}
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => current.sendPrompt("Explain this validation error"));
|
||||
|
||||
expect(
|
||||
current.messages.findLast((message) => message.role === "assistant")
|
||||
?.content,
|
||||
).toBe(expected);
|
||||
});
|
||||
|
||||
it("publishes the first user message before cold session startup resolves", async () => {
|
||||
let resolveStart: ((value: { sessionId: string }) => void) | undefined;
|
||||
const startResponse = new Promise<{ sessionId: string }>((resolve) => {
|
||||
|
||||
@@ -46,6 +46,7 @@ import type {
|
||||
import {
|
||||
normalizeWorkspacePath,
|
||||
readWorkspaceSelectionFromWindow,
|
||||
registerHostHomeDirectory,
|
||||
} from "@/lib/workspace-paths";
|
||||
|
||||
export { DEFAULT_CHAT_CONFIG } from "@/hooks/chat-session/constants";
|
||||
@@ -69,12 +70,29 @@ const BUSY_STATUSES = new Set<ChatSessionStatus>([
|
||||
"stopping",
|
||||
]);
|
||||
|
||||
const WORKSPACE_SELECTION_REQUIRED_MESSAGE =
|
||||
"Select a workspace before trying again.";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers (pure, no hooks)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function userFacingMessage(message: string): string {
|
||||
const normalized = message.toLowerCase();
|
||||
const isWorkspaceManifestValidationError =
|
||||
normalized.includes("workspaces") &&
|
||||
(normalized.includes("too_small") ||
|
||||
normalized.includes("expected string to have >=1"));
|
||||
const isMissingWorkspaceConfig = normalized.includes(
|
||||
"config.cwd or config.workspaceroot is required",
|
||||
);
|
||||
return isWorkspaceManifestValidationError || isMissingWorkspaceConfig
|
||||
? WORKSPACE_SELECTION_REQUIRED_MESSAGE
|
||||
: message;
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
return userFacingMessage(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
|
||||
function makeErrorChatMessage(
|
||||
@@ -95,6 +113,9 @@ function validateConfig(
|
||||
):
|
||||
| { parsed: ChatSessionConfig; error: null }
|
||||
| { parsed: null; error: string } {
|
||||
if (!config.workspaceRoot.trim()) {
|
||||
return { parsed: null, error: WORKSPACE_SELECTION_REQUIRED_MESSAGE };
|
||||
}
|
||||
const runtimeConfig = normalizeRuntimeConfig(config);
|
||||
const result = ChatSessionConfigSchema.safeParse(runtimeConfig);
|
||||
if (!result.success) {
|
||||
@@ -475,6 +496,9 @@ export function useChatSession() {
|
||||
const ctx = await desktopClient.invoke<ProcessContext>(
|
||||
"get_process_context",
|
||||
);
|
||||
if (ctx.homeDir) {
|
||||
registerHostHomeDirectory(ctx.homeDir);
|
||||
}
|
||||
const rememberedWorkspace =
|
||||
readWorkspaceSelectionFromWindow().lastWorkspace;
|
||||
const validation = rememberedWorkspace
|
||||
@@ -1221,8 +1245,11 @@ export function useChatSession() {
|
||||
const fallbackAssistantTurn = extractAssistantTurnDataFromRpcMessages(
|
||||
result?.messages,
|
||||
);
|
||||
const rawAssistantText = assistantText || fallbackAssistantTurn.text;
|
||||
const resolvedAssistantText =
|
||||
assistantText || fallbackAssistantTurn.text;
|
||||
result?.finishReason === "error"
|
||||
? userFacingMessage(rawAssistantText)
|
||||
: rawAssistantText;
|
||||
if (resolvedAssistantText) {
|
||||
const assistantMessageId =
|
||||
activeAssistantMessageIdRef.current ?? makeId("assistant");
|
||||
@@ -1376,7 +1403,9 @@ export function useChatSession() {
|
||||
addMessage(
|
||||
makeErrorChatMessage(
|
||||
activeSessionId,
|
||||
toolError?.trim() ||
|
||||
(toolError?.trim()
|
||||
? userFacingMessage(toolError.trim())
|
||||
: undefined) ||
|
||||
"Runtime turn failed before an assistant response was produced.",
|
||||
),
|
||||
);
|
||||
|
||||
@@ -9,7 +9,10 @@ import type {
|
||||
SessionHistoryStatus,
|
||||
SessionMetadata,
|
||||
} from "@/lib/session-history";
|
||||
import { getSessionMetadataTitle } from "@/lib/session-history";
|
||||
import {
|
||||
getSessionMetadataGitBranch,
|
||||
getSessionMetadataTitle,
|
||||
} from "@/lib/session-history";
|
||||
|
||||
type CliDiscoveredSession = Omit<SessionHistoryItem, "status"> & {
|
||||
status: string;
|
||||
@@ -23,6 +26,7 @@ export interface SessionThread {
|
||||
time: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
gitBranch?: string;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalCostUsd?: number;
|
||||
@@ -179,10 +183,10 @@ export function basenamePath(input?: string): string {
|
||||
function toTitle(session: SessionHistoryItem): string {
|
||||
const metadataTitle = getSessionMetadataTitle(session.metadata);
|
||||
if (metadataTitle) {
|
||||
return metadataTitle.slice(0, 70);
|
||||
return metadataTitle;
|
||||
}
|
||||
const line = normalizeTitle(session.prompt).trim().split("\n")[0]?.trim();
|
||||
if (line) return line.slice(0, 70);
|
||||
if (line) return line;
|
||||
return `Session ${session.sessionId.slice(-6)}`;
|
||||
}
|
||||
|
||||
@@ -196,7 +200,7 @@ function titleFromMessages(messages: SessionMessage[]): string | null {
|
||||
typeof message.content === "string" ? message.content : "";
|
||||
const line = normalizeTitle(content).trim().split("\n")[0]?.trim();
|
||||
if (line) {
|
||||
return line.slice(0, 70);
|
||||
return line;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -234,6 +238,7 @@ function toThread(session: SessionHistoryItem): SessionThread {
|
||||
time: formatRelativeTime(session.endedAt || session.startedAt),
|
||||
provider: session.provider || "",
|
||||
model: session.model || "",
|
||||
gitBranch: getSessionMetadataGitBranch(session.metadata) || undefined,
|
||||
status: normalizeDiscoveredStatus(session.status, session.prompt),
|
||||
};
|
||||
}
|
||||
@@ -331,6 +336,8 @@ function areSessionsEquivalent(
|
||||
a.startedAt !== b.startedAt ||
|
||||
a.endedAt !== b.endedAt ||
|
||||
a.prompt !== b.prompt ||
|
||||
getSessionMetadataGitBranch(a.metadata) !==
|
||||
getSessionMetadataGitBranch(b.metadata) ||
|
||||
getSessionMetadataTitle(a.metadata) !==
|
||||
getSessionMetadataTitle(b.metadata) ||
|
||||
a.workspaceRoot !== b.workspaceRoot ||
|
||||
@@ -362,6 +369,7 @@ function areThreadsEquivalent(
|
||||
a.time !== b.time ||
|
||||
a.provider !== b.provider ||
|
||||
a.model !== b.model ||
|
||||
a.gitBranch !== b.gitBranch ||
|
||||
a.inputTokens !== b.inputTokens ||
|
||||
a.outputTokens !== b.outputTokens ||
|
||||
a.totalCostUsd !== b.totalCostUsd ||
|
||||
|
||||
@@ -99,13 +99,15 @@ const RECONNECT_MAX_DELAY_MS = 4_000;
|
||||
// Commands that should be routed to Tauri's native invoke bridge instead of
|
||||
// the WebSocket transport — only applicable in the full Tauri app shell.
|
||||
// In sidecar/web mode these commands are handled by the sidecar over WebSocket.
|
||||
function isTauriAvailable(): boolean {
|
||||
export function isTauriAvailable(): boolean {
|
||||
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
||||
}
|
||||
|
||||
const NATIVE_COMMANDS = new Set([
|
||||
"pick_workspace_directory",
|
||||
"open_mcp_settings_file",
|
||||
"get_update_status",
|
||||
"restart_to_apply_update",
|
||||
]);
|
||||
|
||||
class DesktopClient {
|
||||
@@ -342,3 +344,18 @@ class DesktopClient {
|
||||
}
|
||||
|
||||
export const desktopClient = new DesktopClient();
|
||||
|
||||
/**
|
||||
* Open a URL in the user's default browser. Inside the Tauri shell,
|
||||
* `target="_blank"` anchors are silently dropped (no window opener is
|
||||
* configured), so external links must be routed through the sidecar, which
|
||||
* runs on the host and can spawn the platform opener. In plain web mode the
|
||||
* browser handles it directly.
|
||||
*/
|
||||
export async function openExternalUrl(url: string): Promise<void> {
|
||||
if (isTauriAvailable()) {
|
||||
await desktopClient.invoke("open_external_url", { url });
|
||||
return;
|
||||
}
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { invoke, setTitle } = vi.hoisted(() => ({
|
||||
invoke: vi.fn(),
|
||||
setTitle: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("@/lib/desktop-client", () => ({
|
||||
desktopClient: { invoke },
|
||||
isTauriAvailable: () => window.__TAURI_INTERNALS__ !== undefined,
|
||||
}));
|
||||
vi.mock("@tauri-apps/api/window", () => ({
|
||||
getCurrentWindow: () => ({ setTitle }),
|
||||
}));
|
||||
|
||||
async function importFresh() {
|
||||
vi.resetModules();
|
||||
return await import("./desktop-window-title");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
invoke.mockReset();
|
||||
setTitle.mockClear();
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test-only global shim for the Tauri bridge marker
|
||||
delete (window as any).__TAURI_INTERNALS__;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("desktop window title", () => {
|
||||
it("builds a versioned title, falling back to the base title without a version", async () => {
|
||||
const { buildDesktopWindowTitle, DEFAULT_DESKTOP_WINDOW_TITLE } =
|
||||
await importFresh();
|
||||
expect(buildDesktopWindowTitle("1.2.3")).toBe(
|
||||
`${DEFAULT_DESKTOP_WINDOW_TITLE} v1.2.3`,
|
||||
);
|
||||
expect(buildDesktopWindowTitle(" 1.2.3 ")).toBe(
|
||||
`${DEFAULT_DESKTOP_WINDOW_TITLE} v1.2.3`,
|
||||
);
|
||||
expect(buildDesktopWindowTitle(undefined)).toBe(
|
||||
DEFAULT_DESKTOP_WINDOW_TITLE,
|
||||
);
|
||||
expect(buildDesktopWindowTitle("")).toBe(DEFAULT_DESKTOP_WINDOW_TITLE);
|
||||
});
|
||||
|
||||
it("does nothing outside the Tauri shell", async () => {
|
||||
const { syncDesktopWindowTitle } = await importFresh();
|
||||
await syncDesktopWindowTitle();
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
expect(setTitle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets the native window title once the sidecar reports a version", async () => {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test-only global shim for the Tauri bridge marker
|
||||
(window as any).__TAURI_INTERNALS__ = {};
|
||||
invoke.mockResolvedValue({
|
||||
workspaceRoot: "",
|
||||
cwd: "",
|
||||
appVersion: "1.2.3",
|
||||
});
|
||||
|
||||
const { syncDesktopWindowTitle, DEFAULT_DESKTOP_WINDOW_TITLE } =
|
||||
await importFresh();
|
||||
await syncDesktopWindowTitle();
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("get_process_context");
|
||||
expect(setTitle).toHaveBeenCalledWith(
|
||||
`${DEFAULT_DESKTOP_WINDOW_TITLE} v1.2.3`,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the title alone when the version is missing or the sidecar call fails", async () => {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test-only global shim for the Tauri bridge marker
|
||||
(window as any).__TAURI_INTERNALS__ = {};
|
||||
invoke.mockResolvedValue({ workspaceRoot: "", cwd: "" });
|
||||
|
||||
const { syncDesktopWindowTitle } = await importFresh();
|
||||
await syncDesktopWindowTitle();
|
||||
expect(setTitle).not.toHaveBeenCalled();
|
||||
|
||||
invoke.mockRejectedValue(
|
||||
new Error("Desktop backend transport unavailable"),
|
||||
);
|
||||
await syncDesktopWindowTitle();
|
||||
expect(setTitle).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ProcessContext } from "@/hooks/chat-session/types";
|
||||
import { desktopClient, isTauriAvailable } from "@/lib/desktop-client";
|
||||
|
||||
export const DEFAULT_DESKTOP_WINDOW_TITLE = "Cline Code";
|
||||
|
||||
export function buildDesktopWindowTitle(version: string | undefined): string {
|
||||
const trimmed = version?.trim();
|
||||
return trimmed
|
||||
? `${DEFAULT_DESKTOP_WINDOW_TITLE} v${trimmed}`
|
||||
: DEFAULT_DESKTOP_WINDOW_TITLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tauri's window title is static in tauri.conf.json; append the running app
|
||||
* version once the sidecar reports it. No-op outside the Tauri shell (e.g.
|
||||
* sidecar/web dev mode), where there is no native window to retitle.
|
||||
*/
|
||||
export async function syncDesktopWindowTitle(): Promise<void> {
|
||||
if (!isTauriAvailable()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const ctx = await desktopClient.invoke<ProcessContext>(
|
||||
"get_process_context",
|
||||
);
|
||||
if (!ctx.appVersion?.trim()) {
|
||||
return;
|
||||
}
|
||||
const { getCurrentWindow } = await import("@tauri-apps/api/window");
|
||||
await getCurrentWindow().setTitle(buildDesktopWindowTitle(ctx.appVersion));
|
||||
} catch {
|
||||
// Keep the default static title if the sidecar or window API is unavailable.
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,10 @@ export type SessionHistoryStatus =
|
||||
|
||||
export type SessionMetadata = {
|
||||
title?: string;
|
||||
git?: {
|
||||
url?: string;
|
||||
branch?: string;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
@@ -31,3 +35,13 @@ export function getSessionMetadataTitle(metadata?: SessionMetadata): string {
|
||||
}
|
||||
return typeof metadata.title === "string" ? metadata.title.trim() : "";
|
||||
}
|
||||
|
||||
export function getSessionMetadataGitBranch(
|
||||
metadata?: SessionMetadata,
|
||||
): string {
|
||||
const git = metadata?.git;
|
||||
if (!git || typeof git !== "object" || Array.isArray(git)) {
|
||||
return "";
|
||||
}
|
||||
return typeof git.branch === "string" ? git.branch.trim() : "";
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
filterWorkspacePaths,
|
||||
isAbsoluteFilePath,
|
||||
isExcludedWorkspacePath,
|
||||
mergeWorkspacePaths,
|
||||
normalizeWorkspacePath,
|
||||
parseWorkspaceSelectionStorage,
|
||||
registerHostHomeDirectory,
|
||||
resolveWorkspaceFilePath,
|
||||
workspacePathsFromSessions,
|
||||
} from "./workspace-paths";
|
||||
|
||||
@@ -17,6 +22,33 @@ describe("workspace paths", () => {
|
||||
expect(normalizeWorkspacePath("/")).toBe("/");
|
||||
});
|
||||
|
||||
it("detects absolute file paths across platforms", () => {
|
||||
expect(isAbsoluteFilePath("/Users/renee/cline/docs/a.mdx")).toBe(true);
|
||||
expect(isAbsoluteFilePath("C:\\Users\\renee\\a.mdx")).toBe(true);
|
||||
expect(isAbsoluteFilePath("C:/Users/renee/a.mdx")).toBe(true);
|
||||
expect(isAbsoluteFilePath("\\\\server\\share\\a.mdx")).toBe(true);
|
||||
expect(isAbsoluteFilePath("docs/a.mdx")).toBe(false);
|
||||
expect(isAbsoluteFilePath("./docs/a.mdx")).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves relative diff paths against the session cwd", () => {
|
||||
expect(resolveWorkspaceFilePath("docs/a.mdx", "/Users/renee/cline")).toBe(
|
||||
"/Users/renee/cline/docs/a.mdx",
|
||||
);
|
||||
expect(
|
||||
resolveWorkspaceFilePath("./docs/a.mdx", "/Users/renee/cline/"),
|
||||
).toBe("/Users/renee/cline/docs/a.mdx");
|
||||
expect(
|
||||
resolveWorkspaceFilePath("/Users/renee/cline/docs/a.mdx", "/elsewhere"),
|
||||
).toBe("/Users/renee/cline/docs/a.mdx");
|
||||
expect(resolveWorkspaceFilePath("docs/a.mdx", undefined)).toBe(
|
||||
"docs/a.mdx",
|
||||
);
|
||||
expect(resolveWorkspaceFilePath("docs\\a.mdx", "C:\\Users\\renee")).toBe(
|
||||
"C:\\Users\\renee\\docs\\a.mdx",
|
||||
);
|
||||
});
|
||||
|
||||
it("retains known projects when discovery returns an incomplete subset", () => {
|
||||
const known = ["/projects/a", "/projects/b", "/projects/c", "/projects/d"];
|
||||
const afterFirstPick = mergeWorkspacePaths(known, [
|
||||
@@ -45,6 +77,36 @@ describe("workspace paths", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the first-seen order so earlier groups rank first", () => {
|
||||
expect(
|
||||
mergeWorkspacePaths(
|
||||
["/projects/zulu", "/projects/mike"],
|
||||
["/projects/alpha", "/projects/zulu/"],
|
||||
),
|
||||
).toEqual(["/projects/zulu", "/projects/mike", "/projects/alpha"]);
|
||||
});
|
||||
|
||||
it("orders the catalog by the most recent session in each workspace", () => {
|
||||
const paths = workspacePathsFromSessions([
|
||||
{ workspaceRoot: "/projects/old", startedAt: "2026-01-05T00:00:00Z" },
|
||||
{
|
||||
workspaceRoot: "/projects/active",
|
||||
startedAt: "2026-02-01T00:00:00Z",
|
||||
endedAt: "2026-02-01T01:00:00Z",
|
||||
},
|
||||
{ workspaceRoot: "/projects/old", startedAt: "2026-03-01T00:00:00Z" },
|
||||
{ workspaceRoot: "/projects/mid", startedAt: "2026-02-15T00:00:00Z" },
|
||||
{ workspaceRoot: "/projects/undated" },
|
||||
]);
|
||||
|
||||
expect(paths).toEqual([
|
||||
"/projects/old",
|
||||
"/projects/mid",
|
||||
"/projects/active",
|
||||
"/projects/undated",
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds the project catalog from every loaded history workspace", () => {
|
||||
const sessions = Array.from({ length: 25 }, (_, index) => ({
|
||||
workspaceRoot: `/projects/project-${String(index + 1).padStart(2, "0")}`,
|
||||
@@ -74,4 +136,93 @@ describe("workspace paths", () => {
|
||||
workspaces: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("excludes .cline-internal paths from the workspace catalog", () => {
|
||||
expect(
|
||||
isExcludedWorkspacePath("/Users/beatrix/.cline/worktrees/5e0b3/sdk-wip"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isExcludedWorkspacePath(
|
||||
"/Users/beatrix/.cline/plugins/_installed/git/github.com/example-plugin",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isExcludedWorkspacePath("C:\\Users\\Saoud\\.cline\\worktrees\\abc"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
describe("with a registered host home directory", () => {
|
||||
afterEach(() => {
|
||||
registerHostHomeDirectory("");
|
||||
});
|
||||
|
||||
it("excludes a non-standard home and its Desktop but keeps projects inside them", () => {
|
||||
registerHostHomeDirectory("/srv/homes/bea/");
|
||||
|
||||
expect(isExcludedWorkspacePath("/srv/homes/bea")).toBe(true);
|
||||
expect(isExcludedWorkspacePath("/srv/homes/bea/Desktop")).toBe(true);
|
||||
expect(isExcludedWorkspacePath("/srv/homes/bea/projects/app")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isExcludedWorkspacePath("/srv/homes/beatrix")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches Windows homes case-insensitively", () => {
|
||||
registerHostHomeDirectory("D:\\Homes\\Bea");
|
||||
|
||||
expect(isExcludedWorkspacePath("d:\\homes\\bea\\")).toBe(true);
|
||||
expect(isExcludedWorkspacePath("D:\\Homes\\Bea\\Desktop")).toBe(true);
|
||||
expect(isExcludedWorkspacePath("D:\\Homes\\Bea\\cline")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("excludes home and Desktop directories but keeps projects inside them", () => {
|
||||
expect(isExcludedWorkspacePath("/Users/beatrix")).toBe(true);
|
||||
expect(isExcludedWorkspacePath("/Users/beatrix/Desktop/")).toBe(true);
|
||||
expect(isExcludedWorkspacePath("/home/beatrix")).toBe(true);
|
||||
expect(isExcludedWorkspacePath("/root")).toBe(true);
|
||||
expect(isExcludedWorkspacePath("C:\\Users\\Saoud")).toBe(true);
|
||||
expect(isExcludedWorkspacePath("C:\\Users\\Saoud\\Desktop")).toBe(true);
|
||||
|
||||
expect(isExcludedWorkspacePath("/Users/beatrix/dev/cline")).toBe(false);
|
||||
expect(isExcludedWorkspacePath("/Users/beatrix/Desktop/my-app")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isExcludedWorkspacePath("/home/beatrix/projects")).toBe(false);
|
||||
expect(isExcludedWorkspacePath("/workspace/cline")).toBe(false);
|
||||
expect(isExcludedWorkspacePath("C:\\Users\\Saoud\\Cline")).toBe(false);
|
||||
});
|
||||
|
||||
it("filters excluded paths out of session-derived workspaces", () => {
|
||||
const paths = workspacePathsFromSessions([
|
||||
{ workspaceRoot: "/projects/app" },
|
||||
{ workspaceRoot: "/Users/beatrix/.cline/worktrees/97815/sdk-wip" },
|
||||
{ cwd: "/Users/beatrix/Desktop" },
|
||||
{ cwd: "/Users/beatrix" },
|
||||
{ cwd: "/projects/tool" },
|
||||
]);
|
||||
|
||||
expect(paths).toEqual(["/projects/app", "/projects/tool"]);
|
||||
});
|
||||
|
||||
it("scrubs excluded paths from the stored catalog while keeping the selection", () => {
|
||||
expect(
|
||||
parseWorkspaceSelectionStorage(
|
||||
JSON.stringify({
|
||||
lastWorkspace: "/Users/beatrix/Desktop",
|
||||
workspaces: [
|
||||
"/projects/one",
|
||||
"/Users/beatrix/.cline/worktrees/5e0b3/sdk-wip",
|
||||
"/Users/beatrix",
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
lastWorkspace: "/Users/beatrix/Desktop",
|
||||
workspaces: ["/projects/one"],
|
||||
});
|
||||
expect(
|
||||
filterWorkspacePaths(["/projects/one", "/Users/beatrix/Desktop"]),
|
||||
).toEqual(["/projects/one"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,8 @@ export type WorkspaceSelectionStorage = {
|
||||
export type WorkspacePathSource = {
|
||||
cwd?: string;
|
||||
workspaceRoot?: string;
|
||||
startedAt?: string;
|
||||
endedAt?: string;
|
||||
};
|
||||
|
||||
export function normalizeWorkspacePath(path: string): string {
|
||||
@@ -21,6 +23,11 @@ export function normalizeWorkspacePath(path: string): string {
|
||||
return /^[A-Za-z]:/.test(normalized) ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedupes paths across groups, keeping the first spelling seen and the
|
||||
* first-seen position, so callers control the ranking (e.g. session recency)
|
||||
* through argument order.
|
||||
*/
|
||||
export function mergeWorkspacePaths(
|
||||
...pathGroups: ReadonlyArray<readonly string[]>
|
||||
): string[] {
|
||||
@@ -34,15 +41,120 @@ export function mergeWorkspacePaths(
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...byNormalizedPath.values()].sort((a, b) => a.localeCompare(b));
|
||||
return [...byNormalizedPath.values()];
|
||||
}
|
||||
|
||||
const WINDOWS_ABSOLUTE_PATH_PATTERN = /^(?:[A-Za-z]:[\\/]|\\\\)/;
|
||||
|
||||
export function isAbsoluteFilePath(path: string): boolean {
|
||||
return path.startsWith("/") || WINDOWS_ABSOLUTE_PATH_PATTERN.test(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff tool outputs carry paths as the agent wrote them — sometimes absolute,
|
||||
* sometimes relative to the session cwd. The webview has no `node:path`, so
|
||||
* relative paths are joined against the cwd with the separator style the cwd
|
||||
* already uses.
|
||||
*/
|
||||
export function resolveWorkspaceFilePath(path: string, cwd?: string): string {
|
||||
const trimmed = path.trim();
|
||||
const base = (cwd ?? "").trim();
|
||||
if (!trimmed || !base || isAbsoluteFilePath(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
const separator = base.includes("\\") && !base.includes("/") ? "\\" : "/";
|
||||
return `${base.replace(/[\\/]+$/, "")}${separator}${trimmed.replace(/^\.\//, "")}`;
|
||||
}
|
||||
|
||||
const POSIX_HOME_OR_DESKTOP_PATTERN =
|
||||
/^(?:\/Users\/[^/]+|\/home\/[^/]+|\/root)(?:\/Desktop)?$/;
|
||||
const WINDOWS_HOME_OR_DESKTOP_PATTERN =
|
||||
/^[a-z]:[\\/]users[\\/][^\\/]+(?:[\\/]desktop)?$/i;
|
||||
|
||||
let hostHomePath = "";
|
||||
|
||||
/**
|
||||
* The webview bundle has no usable `process.env`, so standard home locations
|
||||
* are matched by the patterns above and the sidecar reports the real host
|
||||
* home directory through `get_process_context` to cover non-standard ones.
|
||||
*/
|
||||
export function registerHostHomeDirectory(path: string): void {
|
||||
hostHomePath = normalizeWorkspacePath(path);
|
||||
}
|
||||
|
||||
function isRegisteredHomeOrDesktop(normalized: string): boolean {
|
||||
if (!hostHomePath) {
|
||||
return false;
|
||||
}
|
||||
if (normalized === hostHomePath) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
normalized.startsWith(hostHomePath) &&
|
||||
/^[\\/]desktop$/i.test(normalized.slice(hostHomePath.length))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sessions can run anywhere (Cline-internal worktrees and plugin installs
|
||||
* under `.cline`, or a shell's default cwd like the home or Desktop
|
||||
* directory), but those locations are not projects to offer in the
|
||||
* workspace catalog. The active workspace root is registered separately,
|
||||
* so an explicitly opened directory still shows while selected.
|
||||
*/
|
||||
export function isExcludedWorkspacePath(path: string): boolean {
|
||||
const normalized = normalizeWorkspacePath(path);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (normalized.split(/[\\/]/).includes(".cline")) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
isRegisteredHomeOrDesktop(normalized) ||
|
||||
POSIX_HOME_OR_DESKTOP_PATTERN.test(normalized) ||
|
||||
WINDOWS_HOME_OR_DESKTOP_PATTERN.test(normalized)
|
||||
);
|
||||
}
|
||||
|
||||
export function filterWorkspacePaths(paths: readonly string[]): string[] {
|
||||
return paths.filter((path) => !isExcludedWorkspacePath(path));
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspaces with the most recent session activity come first; paths whose
|
||||
* sessions carry no parseable timestamp fall back to alphabetical order at
|
||||
* the end.
|
||||
*/
|
||||
export function workspacePathsFromSessions(
|
||||
sessions: readonly WorkspacePathSource[],
|
||||
): string[] {
|
||||
return mergeWorkspacePaths(
|
||||
sessions.map((session) => session.workspaceRoot || session.cwd || ""),
|
||||
);
|
||||
const lastActivityByPath = new Map<string, number>();
|
||||
for (const session of sessions) {
|
||||
const normalized = normalizeWorkspacePath(
|
||||
session.workspaceRoot || session.cwd || "",
|
||||
);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
const activity = Date.parse(session.endedAt ?? session.startedAt ?? "");
|
||||
if (Number.isNaN(activity)) {
|
||||
continue;
|
||||
}
|
||||
const known = lastActivityByPath.get(normalized);
|
||||
if (known === undefined || activity > known) {
|
||||
lastActivityByPath.set(normalized, activity);
|
||||
}
|
||||
}
|
||||
return filterWorkspacePaths(
|
||||
mergeWorkspacePaths(
|
||||
sessions.map((session) => session.workspaceRoot || session.cwd || ""),
|
||||
),
|
||||
).sort((a, b) => {
|
||||
const aTime = lastActivityByPath.get(normalizeWorkspacePath(a)) ?? 0;
|
||||
const bTime = lastActivityByPath.get(normalizeWorkspacePath(b)) ?? 0;
|
||||
return bTime === aTime ? a.localeCompare(b) : bTime - aTime;
|
||||
});
|
||||
}
|
||||
|
||||
export function parseWorkspaceSelectionStorage(
|
||||
@@ -67,7 +179,9 @@ export function parseWorkspaceSelectionStorage(
|
||||
: [];
|
||||
return {
|
||||
lastWorkspace,
|
||||
workspaces: mergeWorkspacePaths(workspaces, [lastWorkspace]),
|
||||
workspaces: filterWorkspacePaths(
|
||||
mergeWorkspacePaths(workspaces, [lastWorkspace]),
|
||||
),
|
||||
};
|
||||
} catch {
|
||||
return { lastWorkspace: "", workspaces: [] };
|
||||
@@ -98,9 +212,9 @@ export function writeWorkspaceSelectionToWindow(
|
||||
WORKSPACE_SELECTION_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
lastWorkspace: value.lastWorkspace.trim(),
|
||||
workspaces: mergeWorkspacePaths(value.workspaces, [
|
||||
value.lastWorkspace,
|
||||
]),
|
||||
workspaces: filterWorkspacePaths(
|
||||
mergeWorkspacePaths(value.workspaces, [value.lastWorkspace]),
|
||||
),
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { DEFAULT_AUTO_APPROVAL_SETTINGS } from "@shared/AutoApprovalSettings"
|
||||
import { AutoApprovalSettingsRequest } from "@shared/proto/cline/state"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { describe, it, vi } from "vitest"
|
||||
import type { Controller } from ".."
|
||||
import { updateAutoApprovalSettings } from "./updateAutoApprovalSettings"
|
||||
|
||||
@@ -58,9 +59,11 @@ describe("updateAutoApprovalSettings", () => {
|
||||
},
|
||||
}
|
||||
|
||||
expect(controller.stateManager.setGlobalState.mock.calls).toEqual([["autoApprovalSettings", expectedSettings]])
|
||||
expect(controller.stateManager.setTaskSettings.mock.calls).toEqual([["task-1", "autoApprovalSettings", expectedSettings]])
|
||||
expect(controller.postStateToWebview.mock.calls.length).toBe(1)
|
||||
assert.deepEqual(controller.stateManager.setGlobalState.mock.calls, [["autoApprovalSettings", expectedSettings]])
|
||||
assert.deepEqual(controller.stateManager.setTaskSettings.mock.calls, [
|
||||
["task-1", "autoApprovalSettings", expectedSettings],
|
||||
])
|
||||
assert.equal(controller.postStateToWebview.mock.calls.length, 1)
|
||||
})
|
||||
|
||||
it("does not create a task override when no task is active", async () => {
|
||||
@@ -76,9 +79,9 @@ describe("updateAutoApprovalSettings", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(controller.stateManager.setGlobalState.mock.calls.length).toBe(1)
|
||||
expect(controller.stateManager.setTaskSettings.mock.calls.length).toBe(0)
|
||||
expect(controller.postStateToWebview.mock.calls.length).toBe(1)
|
||||
assert.equal(controller.stateManager.setGlobalState.mock.calls.length, 1)
|
||||
assert.equal(controller.stateManager.setTaskSettings.mock.calls.length, 0)
|
||||
assert.equal(controller.postStateToWebview.mock.calls.length, 1)
|
||||
})
|
||||
|
||||
it("ignores stale auto-approval settings versions", async () => {
|
||||
@@ -100,8 +103,8 @@ describe("updateAutoApprovalSettings", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(controller.stateManager.setGlobalState.mock.calls.length).toBe(0)
|
||||
expect(controller.stateManager.setTaskSettings.mock.calls.length).toBe(0)
|
||||
expect(controller.postStateToWebview.mock.calls.length).toBe(0)
|
||||
assert.equal(controller.stateManager.setGlobalState.mock.calls.length, 0)
|
||||
assert.equal(controller.stateManager.setTaskSettings.mock.calls.length, 0)
|
||||
assert.equal(controller.postStateToWebview.mock.calls.length, 0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -239,7 +239,10 @@ export async function updateSettings(controller: Controller, request: UpdateSett
|
||||
controller.stateManager.setGlobalState("defaultTerminalProfile", request.defaultTerminalProfile)
|
||||
// Update the live terminal manager so new terminals use the new profile.
|
||||
// Existing terminals are left open — they're keyed by effective shell
|
||||
// and reused when compatible, or skipped when not.
|
||||
// and reused when compatible, or skipped when not. No session rebuild
|
||||
// is needed: the run_commands tool re-reads the profile each time a
|
||||
// model request is built, so the description and execution both pick
|
||||
// up the new shell at the next request boundary.
|
||||
controller.terminalManager?.setDefaultTerminalProfile(request.defaultTerminalProfile)
|
||||
}
|
||||
|
||||
|
||||
@@ -192,7 +192,10 @@ export async function updateSettingsCli(controller: Controller, request: UpdateS
|
||||
controller.stateManager.setGlobalState("defaultTerminalProfile", defaultTerminalProfile)
|
||||
// Update the live terminal manager so new terminals use the new profile.
|
||||
// Existing terminals are left open — they're keyed by effective shell
|
||||
// and reused when compatible, or skipped when not.
|
||||
// and reused when compatible, or skipped when not. No session rebuild
|
||||
// is needed: the run_commands tool re-reads the profile each time a
|
||||
// model request is built, so the description and execution both pick
|
||||
// up the new shell at the next request boundary.
|
||||
controller.terminalManager?.setDefaultTerminalProfile(defaultTerminalProfile)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,10 +262,15 @@ export class VscodeTerminalManager {
|
||||
return mergePromise(process, promise)
|
||||
}
|
||||
|
||||
async getOrCreateTerminal(cwd: string): Promise<ITerminalInfo> {
|
||||
/**
|
||||
* @param profileId Terminal profile to create/match the terminal with.
|
||||
* Defaults to the current setting; callers that captured the profile
|
||||
* earlier (e.g. when the model request was built) pass it here so a
|
||||
* settings change does not switch shells under an in-flight tool call.
|
||||
*/
|
||||
async getOrCreateTerminal(cwd: string, profileId: string = this.defaultTerminalProfile): Promise<ITerminalInfo> {
|
||||
const terminals = TerminalRegistry.getAllTerminals()
|
||||
const expectedShellPath =
|
||||
this.defaultTerminalProfile !== "default" ? getShellForProfile(this.defaultTerminalProfile) : undefined
|
||||
const expectedShellPath = profileId !== "default" ? getShellForProfile(profileId) : undefined
|
||||
// Resolve effective shell for comparison (so "default" and "zsh" match on macOS)
|
||||
const effectiveExpected = VscodeTerminalManager.effectiveShellPath(expectedShellPath)
|
||||
|
||||
|
||||
@@ -33,8 +33,11 @@ export class SdkTerminalExecutionModeCoordinator {
|
||||
if (previous === next) {
|
||||
return
|
||||
}
|
||||
this.requestRebuild(`Terminal execution mode changed: ${previous} -> ${next}`)
|
||||
}
|
||||
|
||||
Logger.log(`[SdkController] Terminal execution mode changed: ${previous} -> ${next}`)
|
||||
private requestRebuild(reason: string): void {
|
||||
Logger.log(`[SdkController] ${reason}`)
|
||||
|
||||
const activeSession = this.options.sessions.getActiveSession()
|
||||
if (!activeSession) {
|
||||
|
||||
@@ -1,11 +1,33 @@
|
||||
import { CommandExitError } from "@cline/core"
|
||||
import { EventEmitter } from "events"
|
||||
import * as fs from "fs"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import * as vscode from "vscode"
|
||||
import type { VscodeTerminalManager } from "@/hosts/vscode/terminal/VscodeTerminalManager"
|
||||
import type { TerminalCompletionDetails } from "@/integrations/terminal/types"
|
||||
import { SdkForegroundCommandCoordinator } from "./sdk-foreground-command-coordinator"
|
||||
import { executeForeground, formatCommandForTerminal, PROCEED_LOG_MAX_BYTES } from "./vscode-run-commands-tool"
|
||||
import {
|
||||
createVscodeRunCommandsTool,
|
||||
executeForeground,
|
||||
formatCommandForTerminal,
|
||||
PROCEED_LOG_MAX_BYTES,
|
||||
} from "./vscode-run-commands-tool"
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
existsSync: vi.fn<(path: fs.PathLike) => boolean>(),
|
||||
getGlobalSettingsKey: vi.fn(() => "default"),
|
||||
}))
|
||||
|
||||
vi.mock("fs", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("fs")>()),
|
||||
existsSync: mocks.existsSync,
|
||||
}))
|
||||
|
||||
vi.mock("@/core/storage/StateManager", () => ({
|
||||
StateManager: {
|
||||
get: () => ({ getGlobalSettingsKey: mocks.getGlobalSettingsKey }),
|
||||
},
|
||||
}))
|
||||
|
||||
// The real telemetry proxy lazily initializes TelemetryService, which requires
|
||||
// a HostProvider that unit tests don't set up.
|
||||
@@ -17,6 +39,74 @@ vi.mock("@services/telemetry", () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
const originalPlatform = process.platform
|
||||
const originalEnv = { ...process.env }
|
||||
const originalGetConfiguration = vscode.workspace.getConfiguration
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, "platform", { value: originalPlatform })
|
||||
process.env = { ...originalEnv }
|
||||
vscode.workspace.getConfiguration = originalGetConfiguration
|
||||
mocks.existsSync.mockReset()
|
||||
mocks.getGlobalSettingsKey.mockReset()
|
||||
mocks.getGlobalSettingsKey.mockReturnValue("default")
|
||||
})
|
||||
|
||||
describe("createVscodeRunCommandsTool", () => {
|
||||
it("constructs a cmd tool from the stock array-valued Command Prompt profile", () => {
|
||||
Object.defineProperty(process, "platform", { value: "win32" })
|
||||
process.env.windir = "C:\\Windows"
|
||||
mocks.existsSync.mockImplementation((candidate) => candidate === "C:\\Windows\\System32\\cmd.exe")
|
||||
vscode.workspace.getConfiguration = () =>
|
||||
({
|
||||
get: (key: string) => {
|
||||
if (key === "defaultProfile.windows") {
|
||||
return "Command Prompt"
|
||||
}
|
||||
if (key === "profiles.windows") {
|
||||
return {
|
||||
"Command Prompt": {
|
||||
path: [`\${env:windir}\\Sysnative\\cmd.exe`, `\${env:windir}\\System32\\cmd.exe`],
|
||||
},
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
}) as never
|
||||
|
||||
const tool = createVscodeRunCommandsTool({
|
||||
cwd: "C:\\workspace",
|
||||
getTerminalManager: () => {
|
||||
throw new Error("Terminal manager should not be created during tool construction")
|
||||
},
|
||||
vscodeTerminalExecutionMode: "vscodeTerminal",
|
||||
})
|
||||
|
||||
expect(tool.name).toBe("run_commands")
|
||||
expect(tool.description).toContain("Commands run through cmd.exe")
|
||||
})
|
||||
|
||||
it("re-derives the description from the current profile on each read", () => {
|
||||
Object.defineProperty(process, "platform", { value: "win32" })
|
||||
mocks.existsSync.mockReturnValue(true)
|
||||
mocks.getGlobalSettingsKey.mockReturnValue("cmd")
|
||||
|
||||
const tool = createVscodeRunCommandsTool({
|
||||
cwd: "C:\\workspace",
|
||||
getTerminalManager: () => {
|
||||
throw new Error("Terminal manager should not be created during tool construction")
|
||||
},
|
||||
vscodeTerminalExecutionMode: "vscodeTerminal",
|
||||
})
|
||||
expect(tool.description).toContain("Commands run through cmd.exe")
|
||||
|
||||
// A profile change takes effect at the next description read (the
|
||||
// model-request boundary), without a session rebuild.
|
||||
mocks.getGlobalSettingsKey.mockReturnValue("powershell-7")
|
||||
expect(tool.description).toContain("Commands run through PowerShell")
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Minimal fake of the process object returned by VscodeTerminalManager.runCommand():
|
||||
* an EventEmitter that is also awaitable (mirroring mergePromise in
|
||||
@@ -191,6 +281,19 @@ describe("executeForeground", () => {
|
||||
expect(result).toBe("hello")
|
||||
})
|
||||
|
||||
it("passes the caller's terminal profile through to getOrCreateTerminal", async () => {
|
||||
const process = createFakeTerminalProcess({ lines: ["ok"] })
|
||||
const getOrCreateTerminal = vi.fn(async () => ({ terminal: { show: () => {} } }) as never)
|
||||
const terminalManager = {
|
||||
getOrCreateTerminal,
|
||||
runCommand: () => process,
|
||||
} as unknown as VscodeTerminalManager
|
||||
|
||||
await executeForeground("echo ok", "/workspace", terminalManager, 1000, undefined, undefined, "wsl-bash")
|
||||
|
||||
expect(getOrCreateTerminal).toHaveBeenCalledWith("/workspace", "wsl-bash")
|
||||
})
|
||||
|
||||
it("throws CommandExitError with the exit code on non-zero exit", async () => {
|
||||
const terminalManager = createFakeTerminalManager(
|
||||
createFakeTerminalProcess({ lines: ["boom"], completionDetails: { exitCode: 127 } }),
|
||||
|
||||
@@ -165,9 +165,10 @@ export async function executeForeground(
|
||||
maxOutputChars: number,
|
||||
abortSignal?: AbortSignal,
|
||||
foregroundCommands?: SdkForegroundCommandCoordinator,
|
||||
terminalProfileId?: string,
|
||||
): Promise<string> {
|
||||
const terminalCommand = formatCommandForTerminal(command)
|
||||
const terminalInfo = await terminalManager.getOrCreateTerminal(cwd)
|
||||
const terminalInfo = await terminalManager.getOrCreateTerminal(cwd, terminalProfileId)
|
||||
terminalInfo.terminal.show()
|
||||
|
||||
const process = terminalManager.runCommand(terminalInfo, terminalCommand)
|
||||
@@ -289,26 +290,56 @@ export async function executeForeground(
|
||||
// Tool factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The shell selected by the user's terminal profile setting at one moment:
|
||||
* the profile ID for foreground terminal creation and the shell executable
|
||||
* it resolves to for background spawning and description building.
|
||||
*/
|
||||
interface ShellSnapshot {
|
||||
profileId: string
|
||||
shell: string
|
||||
}
|
||||
|
||||
/** Resolves the shell the user's terminal profile setting selects right now. */
|
||||
function takeShellSnapshot(): ShellSnapshot {
|
||||
// The setting is typed string, but guard empty values the same way the
|
||||
// settings handlers do (they skip persisting "" but older stores may hold one).
|
||||
const profileId = StateManager.get().getGlobalSettingsKey("defaultTerminalProfile") || "default"
|
||||
return { profileId, shell: getShellForProfile(profileId) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the custom `run_commands` tool for the VSCode extension.
|
||||
*
|
||||
* This tool suppresses and replaces the SDK's built-in `run_commands` tool.
|
||||
* The terminal execution mode is captured when the session's tool set is built.
|
||||
* Switching modes rebuilds the active SDK session so the tool timeout and
|
||||
* execution mode stay aligned.
|
||||
* The terminal execution mode is captured when the session's tool set is
|
||||
* built; switching modes rebuilds the active SDK session so the tool timeout
|
||||
* and execution path follow it.
|
||||
*
|
||||
* The shell is snapshotted each time the runtime reads the tool description,
|
||||
* which happens when a model request is built. Tool calls produced by that
|
||||
* request execute with the same snapshot, so changing the terminal profile
|
||||
* while the model is generating does not change the shell under commands the
|
||||
* model has already planned: the new shell is named in the next request (the
|
||||
* one carrying these tool results) and used by the commands it produces.
|
||||
*/
|
||||
export function createVscodeRunCommandsTool(options: VscodeRunCommandsToolOptions): AgentTool {
|
||||
return createShellTool(createVscodeShellExecutor(options), {
|
||||
const state = { snapshot: takeShellSnapshot() }
|
||||
return createShellTool(createVscodeShellExecutor(options, state), {
|
||||
cwd: options.cwd,
|
||||
bashTimeoutMs: options.bashTimeoutMs,
|
||||
shell: () => {
|
||||
state.snapshot = takeShellSnapshot()
|
||||
return state.snapshot.shell
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function createVscodeShellExecutor(options: VscodeRunCommandsToolOptions): ShellExecutor {
|
||||
function createVscodeShellExecutor(options: VscodeRunCommandsToolOptions, state: { snapshot: ShellSnapshot }): ShellExecutor {
|
||||
const { cwd, getTerminalManager } = options
|
||||
const executionMode = options.vscodeTerminalExecutionMode ?? "backgroundExec"
|
||||
|
||||
// Lazy-init background executor — recreated when the user's shell profile changes.
|
||||
// Lazy-init background executor — recreated when the snapshotted shell changes.
|
||||
let bgExecutor: ShellExecutor | undefined
|
||||
let bgExecutorShell: string | undefined
|
||||
|
||||
@@ -318,12 +349,12 @@ function createVscodeShellExecutor(options: VscodeRunCommandsToolOptions): Shell
|
||||
return async (command, commandCwd, context): Promise<string> => {
|
||||
Logger.log(`[VscodeRunCommands] Executing command in ${executionMode} mode`)
|
||||
|
||||
if (executionMode === "backgroundExec") {
|
||||
// Background path — use SDK's createShellExecutor
|
||||
// Resolve shell from the user's terminal profile setting
|
||||
const profileId = (StateManager.get().getGlobalSettingsKey("defaultTerminalProfile") as string) || "default"
|
||||
const shell = getShellForProfile(profileId)
|
||||
// Execute with the shell named in the model request that produced this
|
||||
// tool call, not the setting's current value (see createVscodeRunCommandsTool).
|
||||
const { profileId, shell } = state.snapshot
|
||||
|
||||
if (executionMode === "backgroundExec") {
|
||||
// Background path — use SDK's createShellExecutor.
|
||||
// Recreate the executor if the shell has changed
|
||||
if (!bgExecutor || bgExecutorShell !== shell) {
|
||||
bgExecutorShell = shell
|
||||
@@ -365,6 +396,7 @@ function createVscodeShellExecutor(options: VscodeRunCommandsToolOptions): Shell
|
||||
MAX_COMMAND_OUTPUT_CHARS,
|
||||
context.signal,
|
||||
options.foregroundCommands,
|
||||
profileId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,10 @@ export function createShellExecutor() {
|
||||
return async () => ""
|
||||
}
|
||||
|
||||
// The real createShellTool, so tests exercise the actual description
|
||||
// building and shell classification (getShellKind) rather than a stub that
|
||||
// would have to duplicate those invariants.
|
||||
export { createShellTool } from "../../../../sdk/packages/core/src/extensions/tools/definitions"
|
||||
// Real (dependency-light) edit-executor implementations, re-exported from the sdk source so
|
||||
// the diff-edit coordinator and its tests exercise the actual content/parse semantics. These
|
||||
// modules only pull in node:fs/node:path and the patch parser — not the heavy core runtime.
|
||||
@@ -114,13 +118,6 @@ export { createEditorExecutor } from "../../../../sdk/packages/core/src/extensio
|
||||
export type { EditFileInput } from "../../../../sdk/packages/core/src/extensions/tools/schemas"
|
||||
export type { ApplyPatchExecutor, EditorExecutor } from "../../../../sdk/packages/core/src/extensions/tools/types"
|
||||
|
||||
export function createShellTool(execute: unknown) {
|
||||
return {
|
||||
name: "run_commands",
|
||||
execute,
|
||||
}
|
||||
}
|
||||
|
||||
export interface SessionHistoryRecord {
|
||||
id: string
|
||||
metadata?: Record<string, unknown>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, it, mock } from "bun:test"
|
||||
import { expect } from "chai"
|
||||
import * as actualFs from "fs"
|
||||
import * as actualOs from "os"
|
||||
import * as vscode from "vscode"
|
||||
|
||||
@@ -15,6 +16,16 @@ const osMock = () => ({ ...osMockNamespace, default: osMockNamespace })
|
||||
mock.module("os", osMock)
|
||||
mock.module("node:os", osMock)
|
||||
|
||||
// getShell() probes the filesystem for PowerShell 7 when no Windows terminal
|
||||
// profile is configured. Route existsSync through a mutable delegate so tests
|
||||
// control which PowerShell installs "exist" regardless of the host machine.
|
||||
let existsSyncImpl: typeof actualFs.existsSync = actualFs.existsSync
|
||||
const existsSyncDelegate = ((path: unknown) => existsSyncImpl(path as string)) as typeof actualFs.existsSync
|
||||
const fsMockNamespace = { ...actualFs, existsSync: existsSyncDelegate }
|
||||
const fsMock = () => ({ ...fsMockNamespace, default: fsMockNamespace })
|
||||
mock.module("fs", fsMock)
|
||||
mock.module("node:fs", fsMock)
|
||||
|
||||
import { getShell } from "@utils/shell"
|
||||
|
||||
describe("Shell Detection Tests", () => {
|
||||
@@ -22,6 +33,7 @@ describe("Shell Detection Tests", () => {
|
||||
let originalEnv: NodeJS.ProcessEnv
|
||||
let originalGetConfig: typeof vscode.workspace.getConfiguration
|
||||
let originalUserInfo: typeof actualOs.userInfo
|
||||
let originalExistsSync: typeof actualFs.existsSync
|
||||
|
||||
// Helper to mock VS Code configuration
|
||||
function mockVsCodeConfig(platformKey: string, defaultProfileName: string | null, profiles: Record<string, any>) {
|
||||
@@ -45,6 +57,7 @@ describe("Shell Detection Tests", () => {
|
||||
originalEnv = { ...process.env }
|
||||
originalGetConfig = vscode.workspace.getConfiguration
|
||||
originalUserInfo = userInfoImpl
|
||||
originalExistsSync = existsSyncImpl
|
||||
|
||||
// Clear environment variables for a clean test
|
||||
delete process.env.SHELL
|
||||
@@ -52,6 +65,9 @@ describe("Shell Detection Tests", () => {
|
||||
|
||||
// Default userInfo() mock
|
||||
userInfoImpl = (() => ({ shell: null })) as any
|
||||
// Default: PowerShell 7 is not installed, so the Windows default
|
||||
// resolves to legacy Windows PowerShell.
|
||||
existsSyncImpl = (() => false) as any
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -60,6 +76,7 @@ describe("Shell Detection Tests", () => {
|
||||
process.env = originalEnv
|
||||
vscode.workspace.getConfiguration = originalGetConfig
|
||||
userInfoImpl = originalUserInfo
|
||||
existsSyncImpl = originalExistsSync
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -71,12 +88,63 @@ describe("Shell Detection Tests", () => {
|
||||
})
|
||||
|
||||
it("uses explicit PowerShell 7 path from VS Code config (profile path)", () => {
|
||||
existsSyncImpl = ((candidate: actualFs.PathLike) =>
|
||||
candidate === "C:\\Program Files\\PowerShell\\7\\pwsh.exe") as typeof actualFs.existsSync
|
||||
mockVsCodeConfig("windows", "PowerShell", {
|
||||
PowerShell: { path: "C:\\Program Files\\PowerShell\\7\\pwsh.exe" },
|
||||
})
|
||||
expect(getShell()).to.equal("C:\\Program Files\\PowerShell\\7\\pwsh.exe")
|
||||
})
|
||||
|
||||
it("expands and selects the first configured profile path when it exists", () => {
|
||||
process.env.windir = "C:\\Windows"
|
||||
existsSyncImpl = (() => true) as typeof actualFs.existsSync
|
||||
mockVsCodeConfig("windows", "Command Prompt", {
|
||||
"Command Prompt": {
|
||||
path: [`\${env:windir}\\Sysnative\\cmd.exe`, `\${env:windir}\\System32\\cmd.exe`],
|
||||
},
|
||||
})
|
||||
|
||||
expect(getShell()).to.equal("C:\\Windows\\Sysnative\\cmd.exe")
|
||||
})
|
||||
|
||||
it("falls through configured profile paths in order", () => {
|
||||
process.env.windir = "C:\\Windows"
|
||||
existsSyncImpl = ((candidate: actualFs.PathLike) =>
|
||||
candidate === "C:\\Windows\\System32\\cmd.exe") as typeof actualFs.existsSync
|
||||
mockVsCodeConfig("windows", "Command Prompt", {
|
||||
"Command Prompt": {
|
||||
path: [`\${env:windir}\\Sysnative\\cmd.exe`, `\${env:windir}\\System32\\cmd.exe`],
|
||||
},
|
||||
})
|
||||
|
||||
expect(getShell()).to.equal("C:\\Windows\\System32\\cmd.exe")
|
||||
})
|
||||
|
||||
it("skips profile paths with variable references it cannot expand", () => {
|
||||
existsSyncImpl = ((candidate: actualFs.PathLike) =>
|
||||
candidate === "C:\\Windows\\System32\\cmd.exe") as typeof actualFs.existsSync
|
||||
process.env.windir = "C:\\Windows"
|
||||
mockVsCodeConfig("windows", "Command Prompt", {
|
||||
"Command Prompt": {
|
||||
path: [`\${workspaceFolder}\\tools\\cmd.exe`, `\${env:windir}\\System32\\cmd.exe`],
|
||||
},
|
||||
})
|
||||
|
||||
expect(getShell()).to.equal("C:\\Windows\\System32\\cmd.exe")
|
||||
})
|
||||
|
||||
it("resolves a configured executable name from PATH", () => {
|
||||
process.env.PATH = "C:\\Tools;C:\\Windows\\System32"
|
||||
existsSyncImpl = ((candidate: actualFs.PathLike) =>
|
||||
candidate === "C:\\Windows\\System32\\cmd.exe") as typeof actualFs.existsSync
|
||||
mockVsCodeConfig("windows", "Command Prompt", {
|
||||
"Command Prompt": { path: "cmd.exe" },
|
||||
})
|
||||
|
||||
expect(getShell()).to.equal("C:\\Windows\\System32\\cmd.exe")
|
||||
})
|
||||
|
||||
it("uses PowerShell 7 path if source is 'PowerShell' but no explicit path", () => {
|
||||
mockVsCodeConfig("windows", "PowerShell", {
|
||||
PowerShell: { source: "PowerShell" },
|
||||
@@ -117,18 +185,36 @@ describe("Shell Detection Tests", () => {
|
||||
expect(getShell()).to.equal("C:\\Windows\\System32\\cmd.exe")
|
||||
})
|
||||
|
||||
it("respects userInfo() if no VS Code config is available", () => {
|
||||
it("defaults to PowerShell 7 when no profile is configured and pwsh is installed", () => {
|
||||
vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any
|
||||
userInfoImpl = () => ({ shell: "C:\\Custom\\PowerShell.exe" }) as any
|
||||
process.env.ProgramW6432 = "C:\\Program Files"
|
||||
existsSyncImpl = (() => true) as any
|
||||
|
||||
expect(getShell()).to.equal("C:\\Custom\\PowerShell.exe")
|
||||
expect(getShell()).to.equal("C:\\Program Files\\PowerShell\\7\\pwsh.exe")
|
||||
})
|
||||
|
||||
it("respects an odd COMSPEC if no userInfo shell is available", () => {
|
||||
it("defaults to Store-installed pwsh when that is the only pwsh present", () => {
|
||||
vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any
|
||||
process.env.LOCALAPPDATA = "C:\\Users\\Test\\AppData\\Local"
|
||||
const storePwsh = "C:\\Users\\Test\\AppData\\Local\\Microsoft\\WindowsApps\\pwsh.exe"
|
||||
existsSyncImpl = ((path: string) => path === storePwsh) as any
|
||||
|
||||
expect(getShell()).to.equal(storePwsh)
|
||||
})
|
||||
|
||||
it("defaults to legacy Windows PowerShell when no profile is configured and pwsh is absent", () => {
|
||||
vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any
|
||||
existsSyncImpl = (() => false) as any
|
||||
|
||||
expect(getShell()).to.equal("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe")
|
||||
})
|
||||
|
||||
it("ignores userInfo() and COMSPEC — VS Code's default terminal ignores them too", () => {
|
||||
vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any
|
||||
userInfoImpl = () => ({ shell: "C:\\Custom\\OtherShell.exe" }) as any
|
||||
process.env.COMSPEC = "D:\\CustomCmd\\cmd.exe"
|
||||
|
||||
expect(getShell()).to.equal("D:\\CustomCmd\\cmd.exe")
|
||||
expect(getShell()).to.equal("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -141,12 +227,31 @@ describe("Shell Detection Tests", () => {
|
||||
})
|
||||
|
||||
it("uses VS Code profile path if available", () => {
|
||||
existsSyncImpl = ((candidate: actualFs.PathLike) => candidate === "/usr/local/bin/fish") as typeof actualFs.existsSync
|
||||
mockVsCodeConfig("osx", "MyCustomShell", {
|
||||
MyCustomShell: { path: "/usr/local/bin/fish" },
|
||||
})
|
||||
expect(getShell()).to.equal("/usr/local/bin/fish")
|
||||
})
|
||||
|
||||
it("expands and selects the first existing path in an array-valued profile", () => {
|
||||
existsSyncImpl = ((candidate: actualFs.PathLike) => candidate === "/bin/zsh") as typeof actualFs.existsSync
|
||||
mockVsCodeConfig("osx", "MyCustomShell", {
|
||||
MyCustomShell: { path: ["/opt/homebrew/bin/zsh", "/bin/zsh"] },
|
||||
})
|
||||
expect(getShell()).to.equal("/bin/zsh")
|
||||
})
|
||||
|
||||
it("falls back past a configured path that does not exist", () => {
|
||||
existsSyncImpl = (() => false) as typeof actualFs.existsSync
|
||||
mockVsCodeConfig("osx", "MyCustomShell", {
|
||||
MyCustomShell: { path: "/missing/shell" },
|
||||
})
|
||||
userInfoImpl = () => ({ shell: "/opt/homebrew/bin/zsh" }) as any
|
||||
|
||||
expect(getShell()).to.equal("/opt/homebrew/bin/zsh")
|
||||
})
|
||||
|
||||
it("falls back to userInfo().shell if no VS Code config is available", () => {
|
||||
vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any
|
||||
userInfoImpl = () => ({ shell: "/opt/homebrew/bin/zsh" }) as any
|
||||
@@ -177,12 +282,30 @@ describe("Shell Detection Tests", () => {
|
||||
})
|
||||
|
||||
it("uses VS Code profile path if available", () => {
|
||||
existsSyncImpl = ((candidate: actualFs.PathLike) => candidate === "/usr/bin/fish") as typeof actualFs.existsSync
|
||||
mockVsCodeConfig("linux", "CustomProfile", {
|
||||
CustomProfile: { path: "/usr/bin/fish" },
|
||||
})
|
||||
expect(getShell()).to.equal("/usr/bin/fish")
|
||||
})
|
||||
|
||||
it("expands and selects the first existing path in an array-valued profile", () => {
|
||||
existsSyncImpl = ((candidate: actualFs.PathLike) => candidate === "/bin/bash") as typeof actualFs.existsSync
|
||||
mockVsCodeConfig("linux", "CustomProfile", {
|
||||
CustomProfile: { path: ["/usr/bin/fish", "/bin/bash"] },
|
||||
})
|
||||
expect(getShell()).to.equal("/bin/bash")
|
||||
})
|
||||
|
||||
it("resolves a bare executable name from PATH without PATHEXT probing", () => {
|
||||
process.env.PATH = "/opt/tools:/usr/bin"
|
||||
existsSyncImpl = ((candidate: actualFs.PathLike) => candidate === "/usr/bin/fish") as typeof actualFs.existsSync
|
||||
mockVsCodeConfig("linux", "CustomProfile", {
|
||||
CustomProfile: { path: "fish" },
|
||||
})
|
||||
expect(getShell()).to.equal("/usr/bin/fish")
|
||||
})
|
||||
|
||||
it("falls back to userInfo().shell if no VS Code config is available", () => {
|
||||
vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any
|
||||
userInfoImpl = () => ({ shell: "/usr/bin/zsh" }) as any
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as childProcess from "child_process"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { WINDOWS_POWERSHELL_7_PATH, WINDOWS_POWERSHELL_LEGACY_PATH } from "./shell"
|
||||
import { getWindowsPwshInstallPaths, WINDOWS_POWERSHELL_LEGACY_PATH } from "./shell"
|
||||
|
||||
const POWERSHELL_PROBE_TIMEOUT_MS = 1200
|
||||
|
||||
@@ -16,14 +16,7 @@ export function getFallbackWindowsPowerShellPath(): string {
|
||||
}
|
||||
|
||||
export function getWindowsPowerShellCandidates(): string[] {
|
||||
const programFiles = process.env.ProgramW6432 || process.env.ProgramFiles || "C:\\Program Files"
|
||||
|
||||
const envAbsoluteCandidates = [
|
||||
`${programFiles}\\PowerShell\\7\\pwsh.exe`,
|
||||
`${programFiles}\\PowerShell\\6\\pwsh.exe`,
|
||||
WINDOWS_POWERSHELL_7_PATH,
|
||||
WINDOWS_POWERSHELL_LEGACY_PATH,
|
||||
]
|
||||
const envAbsoluteCandidates = [...getWindowsPwshInstallPaths(), WINDOWS_POWERSHELL_LEGACY_PATH]
|
||||
|
||||
const commandNameFallbacks = ["pwsh.exe", "pwsh", "powershell.exe", "powershell"]
|
||||
|
||||
|
||||
+129
-24
@@ -1,5 +1,8 @@
|
||||
import { existsSync } from "fs"
|
||||
import { userInfo } from "os"
|
||||
import * as nodePath from "path"
|
||||
import * as vscode from "vscode"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
|
||||
export const WINDOWS_POWERSHELL_7_PATH = "C:\\Program Files\\PowerShell\\7\\pwsh.exe"
|
||||
export const WINDOWS_POWERSHELL_LEGACY_PATH = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
|
||||
@@ -29,21 +32,24 @@ const SHELL_PATHS = {
|
||||
FALLBACK: "/bin/sh",
|
||||
} as const
|
||||
|
||||
// VS Code permits `path: string | string[]` in terminal profiles on every
|
||||
// platform (the stock Windows "Command Prompt" profile is an array), so all
|
||||
// three profile shapes model both forms.
|
||||
interface MacTerminalProfile {
|
||||
path?: string
|
||||
path?: string | string[]
|
||||
}
|
||||
|
||||
type MacTerminalProfiles = Record<string, MacTerminalProfile>
|
||||
|
||||
interface WindowsTerminalProfile {
|
||||
path?: string
|
||||
path?: string | string[]
|
||||
source?: "PowerShell" | "WSL"
|
||||
}
|
||||
|
||||
type WindowsTerminalProfiles = Record<string, WindowsTerminalProfile>
|
||||
|
||||
interface LinuxTerminalProfile {
|
||||
path?: string
|
||||
path?: string | string[]
|
||||
}
|
||||
|
||||
type LinuxTerminalProfiles = Record<string, LinuxTerminalProfile>
|
||||
@@ -89,6 +95,83 @@ function getLinuxTerminalConfig() {
|
||||
// 2) Platform-Specific VS Code Shell Retrieval
|
||||
// -----------------------------------------------------
|
||||
|
||||
function isWindows(): boolean {
|
||||
return process.platform === "win32"
|
||||
}
|
||||
|
||||
/** The path module matching the host platform's separators and semantics. */
|
||||
function hostPath(): nodePath.PlatformPath {
|
||||
return isWindows() ? nodePath.win32 : nodePath.posix
|
||||
}
|
||||
|
||||
function getEnvironmentVariable(name: string): string | undefined {
|
||||
// Windows environment variable names are case-insensitive; POSIX names
|
||||
// are case-sensitive.
|
||||
if (!isWindows()) {
|
||||
return process.env[name]
|
||||
}
|
||||
const entry = Object.entries(process.env).find(([key]) => key.toLowerCase() === name.toLowerCase())
|
||||
return entry?.[1]
|
||||
}
|
||||
|
||||
/** Expands VS Code's `${env:NAME}` references in a profile path. */
|
||||
function expandShellPath(candidate: string): string {
|
||||
return candidate.replace(/\$\{env:([^}]+)\}/gi, (reference, name: string) => {
|
||||
return getEnvironmentVariable(name.trim()) ?? reference
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a profile path candidate to an existing executable: absolute or
|
||||
* relative paths are checked directly; bare names are searched on PATH
|
||||
* (with PATHEXT on Windows, mirroring how VS Code launches profiles).
|
||||
*/
|
||||
function findExecutable(candidate: string): string | null {
|
||||
const path = hostPath()
|
||||
if (path.basename(candidate) !== candidate) {
|
||||
const normalized = path.normalize(candidate)
|
||||
return existsSync(normalized) ? normalized : null
|
||||
}
|
||||
|
||||
const pathValue = getEnvironmentVariable("PATH")
|
||||
if (!pathValue) {
|
||||
return null
|
||||
}
|
||||
|
||||
const extensions =
|
||||
!isWindows() || path.extname(candidate) ? [""] : (getEnvironmentVariable("PATHEXT") ?? ".COM;.EXE;.BAT;.CMD").split(";")
|
||||
for (const directory of pathValue.split(path.delimiter)) {
|
||||
for (const extension of extensions) {
|
||||
const executable = path.join(directory, `${candidate}${extension}`)
|
||||
if (existsSync(executable)) {
|
||||
return executable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** Resolves the first usable path in a VS Code terminal profile. */
|
||||
function resolveShellPath(configuredPath: string | string[] | undefined): string | null {
|
||||
const candidates = typeof configuredPath === "string" ? [configuredPath] : configuredPath
|
||||
for (const candidate of candidates ?? []) {
|
||||
const expandedPath = expandShellPath(candidate)
|
||||
if (expandedPath.includes("${")) {
|
||||
// Only ${env:NAME} references are expanded here. VS Code resolves
|
||||
// more variable kinds (e.g. ${workspaceFolder}); surface the gap
|
||||
// instead of silently skipping the user's configured shell.
|
||||
Logger.warn(`[shell] Skipping terminal profile path with unresolved variable reference: ${candidate}`)
|
||||
continue
|
||||
}
|
||||
const executable = findExecutable(expandedPath)
|
||||
if (executable) {
|
||||
return executable
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Attempts to retrieve a shell path from VS Code config on Windows. */
|
||||
function getWindowsShellFromVSCode(): string | null {
|
||||
const { defaultProfileName, profiles } = getWindowsTerminalConfig()
|
||||
@@ -97,14 +180,15 @@ function getWindowsShellFromVSCode(): string | null {
|
||||
}
|
||||
|
||||
const profile = profiles[defaultProfileName]
|
||||
const configuredShell = resolveShellPath(profile?.path)
|
||||
|
||||
// If the profile name indicates PowerShell, do version-based detection.
|
||||
// In testing it was found these typically do not have a path, and this
|
||||
// implementation manages to deductively get the correct version of PowerShell
|
||||
if (defaultProfileName.toLowerCase().includes("powershell")) {
|
||||
if (profile?.path) {
|
||||
if (configuredShell) {
|
||||
// If there's an explicit PowerShell path, return that
|
||||
return profile.path
|
||||
return configuredShell
|
||||
}
|
||||
if (profile?.source === "PowerShell") {
|
||||
// If the profile is sourced from PowerShell, assume the newest
|
||||
@@ -115,8 +199,8 @@ function getWindowsShellFromVSCode(): string | null {
|
||||
}
|
||||
|
||||
// If there's a specific path, return that immediately
|
||||
if (profile?.path) {
|
||||
return profile.path
|
||||
if (configuredShell) {
|
||||
return configuredShell
|
||||
}
|
||||
|
||||
// If the profile indicates WSL
|
||||
@@ -135,8 +219,7 @@ function getMacShellFromVSCode(): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
const profile = profiles[defaultProfileName]
|
||||
return profile?.path || null
|
||||
return resolveShellPath(profiles[defaultProfileName]?.path)
|
||||
}
|
||||
|
||||
/** Attempts to retrieve a shell path from VS Code config on Linux. */
|
||||
@@ -146,8 +229,7 @@ function getLinuxShellFromVSCode(): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
const profile = profiles[defaultProfileName]
|
||||
return profile?.path || null
|
||||
return resolveShellPath(profiles[defaultProfileName]?.path)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------
|
||||
@@ -171,11 +253,6 @@ function getShellFromUserInfo(): string | null {
|
||||
function getShellFromEnv(): string | null {
|
||||
const { env } = process
|
||||
|
||||
if (process.platform === "win32") {
|
||||
// On Windows, COMSPEC typically holds cmd.exe
|
||||
return env.COMSPEC || "C:\\Windows\\System32\\cmd.exe"
|
||||
}
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
// On macOS/Linux, SHELL is commonly the environment variable
|
||||
return env.SHELL || "/bin/zsh"
|
||||
@@ -304,6 +381,35 @@ export function getShellForProfile(profileId: string): string {
|
||||
// 5) Publicly Exposed Shell Getter
|
||||
// -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Absolute paths where a modern PowerShell (pwsh) may be installed, most
|
||||
* preferred first: MSI/ZIP installs under Program Files (either architecture),
|
||||
* then the Microsoft Store install under LOCALAPPDATA. This is the single
|
||||
* candidate list shared with the async prober in utils/powershell.ts.
|
||||
*/
|
||||
export function getWindowsPwshInstallPaths(): string[] {
|
||||
const programFiles = process.env.ProgramW6432 || process.env.ProgramFiles || "C:\\Program Files"
|
||||
const localAppData = process.env.LOCALAPPDATA
|
||||
return [
|
||||
`${programFiles}\\PowerShell\\7\\pwsh.exe`,
|
||||
`${programFiles}\\PowerShell\\6\\pwsh.exe`,
|
||||
SHELL_PATHS.POWERSHELL_7,
|
||||
...(localAppData ? [`${localAppData}\\Microsoft\\WindowsApps\\pwsh.exe`] : []),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* The shell VS Code launches on Windows when the user has not configured a
|
||||
* default terminal profile: its built-in default is PowerShell (pwsh when
|
||||
* installed, Windows PowerShell otherwise) — never cmd.exe. Mirroring that
|
||||
* here keeps the "default" profile meaning the same shell whether commands
|
||||
* run in a visible VS Code terminal or a background child process.
|
||||
*/
|
||||
function getWindowsDefaultShell(): string {
|
||||
const pwsh = getWindowsPwshInstallPaths().find((candidate) => existsSync(candidate))
|
||||
return pwsh ?? SHELL_PATHS.POWERSHELL_LEGACY
|
||||
}
|
||||
|
||||
export function getShell(): string {
|
||||
// 1. Check VS Code config first.
|
||||
if (process.platform === "win32") {
|
||||
@@ -312,7 +418,12 @@ export function getShell(): string {
|
||||
if (windowsShell) {
|
||||
return windowsShell
|
||||
}
|
||||
} else if (process.platform === "darwin") {
|
||||
// No profile configured — match the shell VS Code's default terminal
|
||||
// would launch. userInfo()/COMSPEC are not consulted: VS Code's own
|
||||
// terminal ignores them too, and they would resolve to cmd.exe.
|
||||
return getWindowsDefaultShell()
|
||||
}
|
||||
if (process.platform === "darwin") {
|
||||
// macOS from VS Code
|
||||
const macShell = getMacShellFromVSCode()
|
||||
if (macShell) {
|
||||
@@ -338,12 +449,6 @@ export function getShell(): string {
|
||||
return envShell
|
||||
}
|
||||
|
||||
// 4. Finally, fall back to a default
|
||||
if (process.platform === "win32") {
|
||||
// On Windows, if we got here, we have no config, no COMSPEC, and one very messed up operating system.
|
||||
// Use CMD as a last resort
|
||||
return SHELL_PATHS.CMD
|
||||
}
|
||||
// On macOS/Linux, fallback to a POSIX shell - This is the behavior of our old shell detection method.
|
||||
// 4. Fall back to a POSIX shell - This is the behavior of our old shell detection method.
|
||||
return SHELL_PATHS.FALLBACK
|
||||
}
|
||||
|
||||
+13
-21
@@ -89,19 +89,18 @@
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/eventemitter": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
|
||||
"integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
|
||||
"integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/fetch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
|
||||
"integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
|
||||
"integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.1",
|
||||
"@protobufjs/inquire": "^1.1.0"
|
||||
"@protobufjs/aspromise": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@protobufjs/float": {
|
||||
@@ -110,12 +109,6 @@
|
||||
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/inquire": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz",
|
||||
"integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/path": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
|
||||
@@ -667,24 +660,23 @@
|
||||
}
|
||||
},
|
||||
"node_modules/protobufjs": {
|
||||
"version": "7.5.8",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.8.tgz",
|
||||
"integrity": "sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA==",
|
||||
"version": "7.6.5",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz",
|
||||
"integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.2",
|
||||
"@protobufjs/base64": "^1.1.2",
|
||||
"@protobufjs/codegen": "^2.0.5",
|
||||
"@protobufjs/eventemitter": "^1.1.0",
|
||||
"@protobufjs/fetch": "^1.1.0",
|
||||
"@protobufjs/eventemitter": "^1.1.1",
|
||||
"@protobufjs/fetch": "^1.1.1",
|
||||
"@protobufjs/float": "^1.0.2",
|
||||
"@protobufjs/inquire": "^1.1.1",
|
||||
"@protobufjs/path": "^1.1.2",
|
||||
"@protobufjs/pool": "^1.1.0",
|
||||
"@protobufjs/utf8": "^1.1.1",
|
||||
"@types/node": ">=13.7.0",
|
||||
"long": "^5.0.0"
|
||||
"long": "^5.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
|
||||
@@ -12,6 +12,6 @@
|
||||
},
|
||||
"overrides": {
|
||||
"tar-fs": "^3.1.1",
|
||||
"protobufjs": "7.5.8"
|
||||
"protobufjs": "7.6.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"lucide-react": "^0.511.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"marked": "^15.0.12",
|
||||
"mermaid": "11.12.3",
|
||||
"mermaid": "11.16.0",
|
||||
"posthog-js": "^1.224.0",
|
||||
"pretty-bytes": "^6.1.1",
|
||||
"react": "^18.3.1",
|
||||
@@ -92,7 +92,7 @@
|
||||
"vitest": "^3.0.5"
|
||||
},
|
||||
"overrides": {
|
||||
"protobufjs": "7.5.8"
|
||||
"protobufjs": "7.6.5"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-linux-arm64-gnu": "^4.40.0",
|
||||
|
||||
@@ -49,8 +49,6 @@ When the context window approaches its limit, Cline automatically compresses old
|
||||
|
||||
This is why task scoping matters: a focused task keeps relevant information in the context window. A sprawling task fills the window with noise, pushing out useful context.
|
||||
|
||||
If your starting context seems high even for simple prompts, add a [`.clineignore`](/customization/clineignore) file to exclude dependencies, build artifacts, and other files Cline doesn't need. This can dramatically reduce your baseline token usage.
|
||||
|
||||
<Tip>
|
||||
For long-running tasks, enable [Auto-Compact](/features/auto-compact) to intelligently manage context as you work.
|
||||
</Tip>
|
||||
|
||||
@@ -1,11 +1,44 @@
|
||||
---
|
||||
title: ".clineignore"
|
||||
sidebarTitle: ".clineignore"
|
||||
description: "Control which files and directories Cline can access in your project."
|
||||
title: ".clineignore (deprecate soon)"
|
||||
sidebarTitle: ".clineignore (deprecate soon)"
|
||||
description: "Control which files and directories Cline can access in your project. This feature will be deprecated soon."
|
||||
---
|
||||
|
||||
<Warning>
|
||||
**`.clineignore` will be deprecated soon.**
|
||||
|
||||
`.clineignore` filters what Cline loads automatically, but it is not a security or access-control boundary — ignored files can still be read via explicit `@` mentions or shell commands. We're moving away from it as a supported feature.
|
||||
</Warning>
|
||||
|
||||
## Restricting File Access with a Plugin
|
||||
|
||||
While `.clineignore` is being phased out, you can get a stronger, enforced restriction on **file reads and edits** with the [Block Ignored File Access](https://docs.cline.bot/sdk/plugin-examples#block-ignored-file-access) Cline plugin example. Unlike `.clineignore`, it uses a `beforeTool` runtime hook to actively **block** tool calls: when `read_files`, `editor`, or `apply_patch` targets a file ignored by your workspace's `.gitignore` file.
|
||||
|
||||
Install it into your project with:
|
||||
|
||||
```bash
|
||||
cline plugin install https://github.com/cline/cline/blob/main/sdk/examples/plugins/gitignore-read-files-guard.ts --cwd .
|
||||
```
|
||||
|
||||
<Note>
|
||||
This plugin covers only part of what `.clineignore` did:
|
||||
- It **guards file reads/edits** (`read_files`, `editor`, `apply_patch`) — it does **not** filter `search_files`/`list_files` results or guard shell commands, so it is not a full context-reduction tool.
|
||||
- It reads your **`.gitignore`** (not a separate `.clineignore`) and requires a Git repository.
|
||||
- Plugins run on the Cline **SDK, CLI, and Kanban**. Support in the VS Code and JetBrains extensions arrives as they migrate onto the SDK.
|
||||
|
||||
See [Plugins](/customization/plugins) for installation details and scopes.
|
||||
</Note>
|
||||
|
||||
---
|
||||
|
||||
<Info>
|
||||
The rest of this page is the original `.clineignore` reference, retained for existing users while the feature is phased out.
|
||||
</Info>
|
||||
|
||||
The `.clineignore` file tells Cline which files and directories to skip when analyzing your codebase. It works like `.gitignore`: create a file named `.clineignore` in your project root, add patterns for files you want excluded, and Cline will ignore them.
|
||||
|
||||
|
||||
|
||||
## Why It Matters
|
||||
|
||||
Without a `.clineignore`, Cline may load your entire project into context, including dependencies, build artifacts, and generated files. This wastes tokens, increases costs, and can push useful context out of the window.
|
||||
@@ -89,7 +122,7 @@ When Cline scans your project to build context, it checks each file path against
|
||||
- Automatic context gathering during conversations
|
||||
- Search results when Cline looks for relevant code
|
||||
|
||||
You can still reference ignored files explicitly using [@ mentions](/core-workflows/working-with-files). If you type `@/node_modules/some-package/index.js`, Cline will read that specific file even though `node_modules/` is in your `.clineignore`. The ignore rules control automatic loading, not explicit access.
|
||||
As noted above, explicit [@ mentions](/core-workflows/working-with-files) still bypass these rules — for example, `@/node_modules/some-package/index.js` reads that file even though `node_modules/` is ignored. Ignore rules control automatic loading, not explicit access.
|
||||
|
||||
<Note>
|
||||
`.clineignore` is separate from `.gitignore`. Files tracked by Git but irrelevant to Cline (like large test fixtures or data files) should go in `.clineignore` even if they're not in `.gitignore`.
|
||||
@@ -97,13 +130,13 @@ You can still reference ignored files explicitly using [@ mentions](/core-workfl
|
||||
|
||||
## Tips
|
||||
|
||||
- Add `.clineignore` early in your project. It's easier to start with broad exclusions and narrow them than to debug why context is bloated later.
|
||||
- Check your token usage in the task header after adding a `.clineignore`. The difference is often dramatic.
|
||||
- If Cline seems to be missing context about a file, check whether it's being excluded by your ignore patterns.
|
||||
- For monorepos or multi-root workspaces, each workspace root can have its own `.clineignore`. See [Multi-Root Workspaces](/features/multiroot-workspace) for details.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
- [Cline Rules](/customization/cline-rules) - Define persistent instructions for Cline
|
||||
- [Task Management](/core-workflows/task-management#context-window) - Understand how context windows work
|
||||
- [Auto-Compact](/features/auto-compact) - Automatic context compression during long tasks
|
||||
|
||||
@@ -251,18 +251,3 @@ Both limitations are restored when you return to a single-folder workspace.
|
||||
|
||||
- Break large tasks into workspace-specific operations when possible
|
||||
- Use [Plan mode](/core-workflows/plan-and-act) to let Cline understand structure first
|
||||
- Add a `.clineignore` file to reduce noise, speed up scanning, and keep Cline focused on source code:
|
||||
|
||||
```text
|
||||
# Dependencies
|
||||
**/node_modules/
|
||||
|
||||
# Build outputs
|
||||
**/dist/
|
||||
**/build/
|
||||
|
||||
# VCS metadata
|
||||
**/.git/
|
||||
```
|
||||
|
||||
For more patterns and gotchas, see the [.clineignore File Guide](/customization/clineignore).
|
||||
|
||||
@@ -50,6 +50,7 @@ ClinePass includes the following models, tested and benchmarked for coding agent
|
||||
| Model | Model ID |
|
||||
|-------|------------|
|
||||
| GLM-5.2 | `cline-pass/glm-5.2` |
|
||||
| Kimi K3 | `cline-pass/kimi-k3` |
|
||||
| Kimi K2.7 Code | `cline-pass/kimi-k2.7-code` |
|
||||
| Kimi K2.6 | `cline-pass/kimi-k2.6` |
|
||||
| DeepSeek V4 Pro | `cline-pass/deepseek-v4-pro` |
|
||||
@@ -91,6 +92,7 @@ ClinePass is a flat monthly subscription, so you are not charged the individual
|
||||
| Model | Input | Output | Cached Read | Cached Write |
|
||||
|-------|-------|--------|-------------|--------------|
|
||||
| GLM-5.2 | $1.40 | $4.40 | $0.26 | - |
|
||||
| Kimi K3 | $3.00 | $15.00 | $0.30 | - |
|
||||
| Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - |
|
||||
| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - |
|
||||
| DeepSeek V4 Pro | $1.74 | $3.48 | $0.0145 | - |
|
||||
|
||||
@@ -154,7 +154,7 @@ Rules:
|
||||
- [Skills](/customization/skills)
|
||||
- [Hooks](/customization/hooks)
|
||||
- [Plugins](/customization/plugins)
|
||||
- [.clineignore](/customization/clineignore)
|
||||
- [.clineignore](/customization/clineignore) (deprecated)
|
||||
|
||||
## Security Notes
|
||||
|
||||
|
||||
+4
-1
@@ -66,11 +66,14 @@
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"overrides": {
|
||||
"@opentui/core": "0.4.3",
|
||||
"@opentui/react": "0.4.3",
|
||||
"tar-fs": ">=3.1.1",
|
||||
"tar": "^7.5.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
"serialize-javascript": ">=7.0.3",
|
||||
"protobufjs": "7.5.8",
|
||||
"protobufjs": "7.6.5",
|
||||
"mermaid": "11.16.0",
|
||||
"diff": "8.0.4",
|
||||
"@sap-cloud-sdk/connectivity": "4.6.0",
|
||||
"@sap-cloud-sdk/http-client": "4.6.0",
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# Cline SDK Changelog
|
||||
|
||||
## 0.0.65
|
||||
|
||||
- Claude Code and Codex provider SDKs are now optional peer dependencies loaded on demand, dramatically cutting install size
|
||||
- Added Kimi K3 to the bundled ClinePass model fallback
|
||||
- Runs now retry once after refreshing expired OAuth credentials
|
||||
- Team runs: the spawn tool is no longer exposed to teammate agents
|
||||
- Team runs: errored teammate runs now report as failed instead of completed
|
||||
- Improved shell-command parsing to fix a Windows shell mismatch
|
||||
- New `@cline/ui` agent chat components with Storybook and npm packaging
|
||||
- Updated the bundled model catalog
|
||||
|
||||
## 0.0.64
|
||||
|
||||
- Improved max output token handling across providers (gateway routing, OpenAI vendor, and reasoning models)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/agents",
|
||||
"version": "0.0.64",
|
||||
"version": "0.0.65",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cline/cline",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user