mirror of
https://github.com/cline/cline.git
synced 2026-09-15 13:02:17 +08:00
Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a21b21de84 | ||
|
|
ca6f6a6c23 | ||
|
|
3e06abc366 | ||
|
|
6518b05b6c | ||
|
|
f9f492319e | ||
|
|
c3ab557194 | ||
|
|
9fa37a9128 | ||
|
|
9cc7796bbe | ||
|
|
439baee17c | ||
|
|
469debdb30 | ||
|
|
79589c8417 | ||
|
|
8e5471de9c | ||
|
|
cd553d2343 | ||
|
|
7b776225b9 | ||
|
|
ae033761ab | ||
|
|
c961ae7730 | ||
|
|
e940b6a335 | ||
|
|
5a0780a6f9 | ||
|
|
847276f4a5 | ||
|
|
8b4d1973b5 | ||
|
|
a5211a3a5c | ||
|
|
fb1333fdc2 | ||
|
|
59113c309c | ||
|
|
48d0c38f52 | ||
|
|
c7ab9ff839 | ||
|
|
045518d19f | ||
|
|
099c6179e4 | ||
|
|
26037b17ac | ||
|
|
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 |
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,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) || '' }}"
|
||||
@@ -1,5 +1,18 @@
|
||||
# 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)
|
||||
|
||||
+1
-1
@@ -257,7 +257,7 @@ Schedules can route results back to chat surfaces with `--delivery-adapter`, `--
|
||||
| `--hooks-dir <path>` | Additional hooks directory hint for runtime hook injection |
|
||||
| `--acp` | ACP (Agent Client Protocol) mode |
|
||||
| `--thinking [none\|low\|medium\|high\|xhigh]` | Model thinking level when supported. Defaults to `medium` when the flag is provided without a level; thinking is off when the flag is omitted. |
|
||||
| `--compaction <agentic\|basic\|off>` | Context compaction mode. Defaults to `basic`; use `agentic` for LLM compaction or `off` to disable. |
|
||||
| `--compaction <agentic\|basic\|off>` | Context compaction mode. Defaults to `agentic`; use `basic` for local truncation or `off` to disable. |
|
||||
| `--retries <count>` | Maximum consecutive mistakes (retries) before halting (default: `3`) |
|
||||
| `--json` | Output NDJSON instead of styled text |
|
||||
| `--data-dir <path>` | Use isolated local state at `<path>` instead of `~/.cline` (enables sandbox mode automatically) |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.44",
|
||||
"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"
|
||||
|
||||
@@ -148,8 +148,10 @@ export function isJsonPath(path: string): boolean {
|
||||
return path.toLowerCase().endsWith(".json");
|
||||
}
|
||||
|
||||
export function parseMode(raw: string | undefined): "act" | "plan" | undefined {
|
||||
if (raw === "act" || raw === "plan") {
|
||||
export function parseMode(
|
||||
raw: string | undefined,
|
||||
): "act" | "plan" | "yolo" | undefined {
|
||||
if (raw === "act" || raw === "plan" || raw === "yolo") {
|
||||
return raw;
|
||||
}
|
||||
return undefined;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
|
||||
import type { Command } from "commander";
|
||||
import { ensureSchedulerHub } from "./client";
|
||||
import {
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
mergeScheduleMetadata,
|
||||
parseJsonObjectFlag,
|
||||
parseList,
|
||||
parseMode,
|
||||
resolveAddress,
|
||||
toPositiveInt,
|
||||
} from "./common";
|
||||
@@ -63,8 +65,8 @@ export function registerScheduleCommands(
|
||||
.option("--disabled", "Create in disabled state")
|
||||
.option("--max-parallel <n>", "Max parallel executions", "1")
|
||||
.option("--metadata-json <json>", "Metadata as JSON object")
|
||||
.option("--mode <act|plan>", "Execution mode")
|
||||
.option("--model <model>", "Model to use", "openai/gpt-5.3-codex")
|
||||
.option("--mode <act|plan|yolo>", "Execution mode", "yolo")
|
||||
.option("--model <model>", "Model to use", CLINE_DEFAULT_MODEL_ID)
|
||||
.option("--provider <id>", "Provider ID", "cline")
|
||||
.option("--system-prompt <text>", "System prompt override")
|
||||
.option("--tags <list>", "Comma-separated tags")
|
||||
@@ -96,7 +98,7 @@ export function registerScheduleCommands(
|
||||
prompt: opts.prompt,
|
||||
provider: opts.provider,
|
||||
model: opts.model,
|
||||
mode: opts.mode === "plan" ? "plan" : "act",
|
||||
mode: parseMode(opts.mode) ?? "yolo",
|
||||
workspaceRoot: opts.workspace,
|
||||
cwd: opts.cwd,
|
||||
systemPrompt: opts.systemPrompt,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, isAbsolute, resolve } from "node:path";
|
||||
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
|
||||
import type { Command } from "commander";
|
||||
import { ensureSchedulerHub } from "./client";
|
||||
import {
|
||||
@@ -39,7 +40,7 @@ function resolveImportedModelSelection(parsed: Record<string, unknown>): {
|
||||
modelSelection?.modelId ??
|
||||
parsed.modelId ??
|
||||
parsed.model ??
|
||||
"openai/gpt-5.3-codex",
|
||||
CLINE_DEFAULT_MODEL_ID,
|
||||
).trim();
|
||||
return { provider, model };
|
||||
}
|
||||
@@ -165,7 +166,10 @@ export function registerScheduleImportCommand(
|
||||
prompt: String(parsed.prompt ?? "").trim(),
|
||||
provider,
|
||||
model,
|
||||
mode: parsed.mode === "plan" ? "plan" : "act",
|
||||
mode:
|
||||
parseMode(
|
||||
typeof parsed.mode === "string" ? parsed.mode : undefined,
|
||||
) ?? "yolo",
|
||||
workspaceRoot,
|
||||
cwd: String(parsed.cwd ?? "").trim() || undefined,
|
||||
systemPrompt:
|
||||
@@ -229,7 +233,7 @@ export function registerScheduleUpdateCommand(
|
||||
.option("--enabled", "Enable the schedule")
|
||||
.option("--max-parallel <n>", "New max parallel executions")
|
||||
.option("--metadata-json <json>", "New metadata as JSON object")
|
||||
.option("--mode <act|plan>", "New execution mode")
|
||||
.option("--mode <act|plan|yolo>", "New execution mode")
|
||||
.option("--model <model>", "New model")
|
||||
.option("--name <name>", "New name")
|
||||
.option("--pause", "Pause the schedule")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -333,7 +333,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect(historyListCalls[0]?.[0]).not.toHaveProperty("workspaceRoot");
|
||||
expect(mockState.runAgentImports).toBe(0);
|
||||
expect(mockState.runInteractiveImports).toBe(0);
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
it("does not load runtime modules for root update", async () => {
|
||||
mockState.runAgentImports = 0;
|
||||
@@ -1302,7 +1302,6 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect.objectContaining({
|
||||
compaction: {
|
||||
enabled: true,
|
||||
strategy: "basic",
|
||||
},
|
||||
thinking: true,
|
||||
reasoningEffort: "medium",
|
||||
@@ -1389,7 +1388,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("enables truncation compaction by default for prompt runs", async () => {
|
||||
it("uses Core's agentic compaction default for prompt runs", async () => {
|
||||
mockState.runAgentCalls = 0;
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
|
||||
@@ -1404,7 +1403,6 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect.objectContaining({
|
||||
compaction: {
|
||||
enabled: true,
|
||||
strategy: "basic",
|
||||
},
|
||||
}),
|
||||
expect.anything(),
|
||||
|
||||
@@ -2,7 +2,7 @@ let activeRuntimeAbort: (() => void) | undefined;
|
||||
let activeRuntimeCleanup: (() => void) | undefined;
|
||||
let abortGraceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let abortInProgress = false;
|
||||
let savedRejectionListeners: Function[] | undefined;
|
||||
let savedRejectionListeners: Array<(...args: unknown[]) => void> | undefined;
|
||||
|
||||
export function setActiveRuntimeAbort(abortFn: (() => void) | undefined): void {
|
||||
activeRuntimeAbort = abortFn;
|
||||
@@ -49,9 +49,9 @@ export function markAbortInProgress(): void {
|
||||
// rejections in the LLM streaming layer that reach every registered
|
||||
// listener (including OpenTUI's error overlay). Swapping the listeners
|
||||
// is the only way to prevent them from surfacing to the user.
|
||||
savedRejectionListeners = process.rawListeners(
|
||||
"unhandledRejection",
|
||||
) as Function[];
|
||||
savedRejectionListeners = process.rawListeners("unhandledRejection") as Array<
|
||||
(...args: unknown[]) => void
|
||||
>;
|
||||
process.removeAllListeners("unhandledRejection");
|
||||
process.on("unhandledRejection", (_reason, promise) => {
|
||||
promise.catch(() => {});
|
||||
@@ -68,10 +68,7 @@ export function clearAbortInProgress(): void {
|
||||
if (savedRejectionListeners) {
|
||||
process.removeAllListeners("unhandledRejection");
|
||||
for (const listener of savedRejectionListeners) {
|
||||
process.on(
|
||||
"unhandledRejection",
|
||||
listener as (...args: unknown[]) => void,
|
||||
);
|
||||
process.on("unhandledRejection", listener);
|
||||
}
|
||||
savedRejectionListeners = undefined;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,25 @@ import {
|
||||
resolveCompactionProviderConfig,
|
||||
} from "./compaction";
|
||||
|
||||
const createHandlerMock = vi.fn();
|
||||
|
||||
// Core defaults to the agentic compaction strategy, which summarizes via a
|
||||
// real LLM handler. Stub only `createHandlerAsync` so no network call (or API
|
||||
// key) is needed; every other `@cline/llms` export stays real because
|
||||
// `@cline/core` re-exports them.
|
||||
vi.mock("@cline/llms", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@cline/llms")>()),
|
||||
createHandlerAsync: (config: unknown) => createHandlerMock(config),
|
||||
}));
|
||||
|
||||
async function* streamChunks(
|
||||
chunks: Array<Record<string, unknown>>,
|
||||
): AsyncGenerator<Record<string, unknown>> {
|
||||
for (const chunk of chunks) {
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
|
||||
function createConfig(): Config {
|
||||
return {
|
||||
providerId: "anthropic",
|
||||
@@ -46,6 +65,7 @@ function createProviderSettingsManager(): ProviderSettingsManager {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
createHandlerMock.mockReset();
|
||||
for (const tempDir of providerSettingsTempDirs.splice(0)) {
|
||||
rmSync(tempDir, { force: true, recursive: true });
|
||||
}
|
||||
@@ -163,6 +183,15 @@ describe("compactInteractiveMessages", () => {
|
||||
});
|
||||
|
||||
it("uses a useful target budget for manual compaction", async () => {
|
||||
const mockSummary = "## Goal\nMocked agentic compaction summary";
|
||||
createHandlerMock.mockReturnValue({
|
||||
createMessage: vi.fn(() =>
|
||||
streamChunks([
|
||||
{ type: "text", id: "summary-1", text: mockSummary },
|
||||
{ type: "done", id: "summary-1", success: true },
|
||||
]),
|
||||
),
|
||||
});
|
||||
const longText = "x".repeat(16_000);
|
||||
const messages = Array.from({ length: 10 }, (_, index) => ({
|
||||
role: index % 2 === 0 ? ("user" as const) : ("assistant" as const),
|
||||
@@ -189,6 +218,17 @@ describe("compactInteractiveMessages", () => {
|
||||
expect(compactedMessages.length).toBeGreaterThan(1);
|
||||
expect(compactedMessages.length).toBeLessThan(messages.length);
|
||||
expect(compactedTextLength).toBeGreaterThan(1_000);
|
||||
|
||||
// The agentic strategy folds older messages into a summary message
|
||||
// built from the (mocked) summarizer output.
|
||||
expect(createHandlerMock).toHaveBeenCalledTimes(1);
|
||||
const [summaryMessage] = compactedMessages;
|
||||
const summaryText = Array.isArray(summaryMessage?.content)
|
||||
? summaryMessage.content
|
||||
.map((block) => ("text" in block ? block.text : ""))
|
||||
.join("\n")
|
||||
: String(summaryMessage?.content ?? "");
|
||||
expect(summaryText).toContain(mockSummary);
|
||||
});
|
||||
|
||||
it("reports compaction when core returns changed messages with the same count", async () => {
|
||||
|
||||
@@ -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"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ function formatToolParams(
|
||||
const el = f.endLine != null ? String(f.endLine) : "undefined";
|
||||
const sep = i > 0 ? "; " : "";
|
||||
return (
|
||||
<span key={`${i}:${f.path}`}>
|
||||
<span key={`${f.path}:${sl}:${el}`}>
|
||||
{sep}
|
||||
{shortenPath(f.path)}
|
||||
<span fg="gray">
|
||||
|
||||
@@ -15,14 +15,12 @@ function createConfig(compaction?: Config["compaction"]): Config {
|
||||
}
|
||||
|
||||
describe("CLI compaction mode helpers", () => {
|
||||
it("defaults enabled compaction to basic truncation", () => {
|
||||
expect(DEFAULT_CLI_COMPACTION_MODE).toBe("basic");
|
||||
it("defaults enabled compaction to agentic summarization", () => {
|
||||
expect(DEFAULT_CLI_COMPACTION_MODE).toBe("agentic");
|
||||
expect(getCliCompactionMode(createConfig())).toBe(
|
||||
DEFAULT_CLI_COMPACTION_MODE,
|
||||
);
|
||||
expect(formatCliCompactionMode(DEFAULT_CLI_COMPACTION_MODE)).toBe(
|
||||
"Truncation",
|
||||
);
|
||||
expect(formatCliCompactionMode(DEFAULT_CLI_COMPACTION_MODE)).toBe("LLM");
|
||||
});
|
||||
|
||||
it("maps basic and off modes to core compaction config", () => {
|
||||
@@ -47,7 +45,6 @@ describe("CLI compaction mode helpers", () => {
|
||||
it("builds default and explicit core compaction config", () => {
|
||||
expect(buildCliCompactionConfig()).toEqual({
|
||||
enabled: true,
|
||||
strategy: "basic",
|
||||
});
|
||||
expect(buildCliCompactionConfig("agentic")).toEqual({
|
||||
enabled: true,
|
||||
|
||||
@@ -5,7 +5,7 @@ export const CLI_COMPACTION_MODES = ["basic", "agentic", "off"] as const;
|
||||
export const DEFAULT_CLI_COMPACTION_MODE: Extract<
|
||||
CliCompactionMode,
|
||||
"agentic" | "basic"
|
||||
> = "basic";
|
||||
> = "agentic";
|
||||
|
||||
const CLI_COMPACTION_MODE_ALIASES: Record<string, CliCompactionMode> = {
|
||||
agentic: "agentic",
|
||||
@@ -20,7 +20,7 @@ const CLI_COMPACTION_MODE_LABELS = {
|
||||
} as const satisfies Record<CliCompactionMode, string>;
|
||||
|
||||
export const CLI_COMPACTION_MODE_OPTION_DESCRIPTION =
|
||||
"Context compaction mode: agentic|basic|off (default: basic)";
|
||||
"Context compaction mode: agentic|basic|off (default: agentic)";
|
||||
|
||||
export const CLI_COMPACTION_MODE_EXPECTED_TEXT = '"agentic", "basic", or "off"';
|
||||
|
||||
@@ -31,8 +31,11 @@ export function parseCliCompactionMode(
|
||||
}
|
||||
|
||||
export function buildCliCompactionConfig(
|
||||
mode: CliCompactionMode | undefined = DEFAULT_CLI_COMPACTION_MODE,
|
||||
mode?: CliCompactionMode,
|
||||
): NonNullable<Config["compaction"]> {
|
||||
if (mode === undefined) {
|
||||
return { enabled: true };
|
||||
}
|
||||
if (mode === "off") {
|
||||
return { enabled: false };
|
||||
}
|
||||
@@ -43,9 +46,7 @@ export function getCliCompactionMode(config: Config): CliCompactionMode {
|
||||
if (config.compaction?.enabled === false) {
|
||||
return "off";
|
||||
}
|
||||
return config.compaction?.strategy === "agentic"
|
||||
? "agentic"
|
||||
: DEFAULT_CLI_COMPACTION_MODE;
|
||||
return config.compaction?.strategy ?? DEFAULT_CLI_COMPACTION_MODE;
|
||||
}
|
||||
|
||||
export function applyCliCompactionMode(
|
||||
|
||||
@@ -141,13 +141,18 @@ function captureRemoteConfigInitialized(bundle: RemoteConfigBundle): void {
|
||||
export async function prepareCliEnterpriseIntegration(
|
||||
input: ClineCoreStartInput,
|
||||
) {
|
||||
const workspacePath =
|
||||
input.config.workspaceRoot?.trim() || input.config.cwd?.trim();
|
||||
if (!workspacePath) {
|
||||
return undefined;
|
||||
}
|
||||
const bundle = await loadCliRemoteConfigBundle();
|
||||
if (!bundle) {
|
||||
return undefined;
|
||||
}
|
||||
captureRemoteConfigInitialized(bundle);
|
||||
return prepareRemoteConfigCoreIntegration({
|
||||
workspacePath: input.config.workspaceRoot ?? input.config.cwd,
|
||||
workspacePath,
|
||||
pluginName: "enterprise",
|
||||
controlPlane: {
|
||||
name: "cline-account",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as p from "@clack/prompts";
|
||||
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
|
||||
import {
|
||||
ensureSchedulerHub,
|
||||
type HubScheduleClient,
|
||||
@@ -135,10 +136,11 @@ async function actionCreate(client: HubScheduleClient): Promise<void> {
|
||||
const mode = await p.select({
|
||||
message: "Agent mode",
|
||||
options: [
|
||||
{ value: "yolo", label: "Yolo", hint: "execute without approvals" },
|
||||
{ value: "act", label: "Act", hint: "execute tasks" },
|
||||
{ value: "plan", label: "Plan", hint: "plan only" },
|
||||
],
|
||||
initialValue: "act",
|
||||
initialValue: "yolo",
|
||||
});
|
||||
if (isCancel(mode)) return;
|
||||
|
||||
@@ -214,8 +216,8 @@ async function actionCreate(client: HubScheduleClient): Promise<void> {
|
||||
cronPattern,
|
||||
prompt: (prompt as string).trim(),
|
||||
provider: provider ?? "cline",
|
||||
model: model ?? "openai/gpt-5.3-codex",
|
||||
mode: (mode as string) === "plan" ? "plan" : "act",
|
||||
model: model ?? CLINE_DEFAULT_MODEL_ID,
|
||||
mode: mode as "act" | "plan" | "yolo",
|
||||
workspaceRoot: (workspace as string).trim(),
|
||||
systemPrompt,
|
||||
maxIterations,
|
||||
|
||||
@@ -3,6 +3,12 @@ import {
|
||||
HubScheduleCommandService,
|
||||
HubScheduleService,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
CLINE_DEFAULT_MODEL_ID,
|
||||
ONE_TIME_SCHEDULE_CRON_PATTERN,
|
||||
ONE_TIME_SCHEDULE_RUN_AT_METADATA_KEY,
|
||||
readHubScheduleMode,
|
||||
} from "@cline/shared";
|
||||
import { asTrimmedString, toPositiveInt } from "./utils";
|
||||
|
||||
let scheduleService: HubScheduleService | undefined;
|
||||
@@ -36,6 +42,31 @@ async function clientCommand(
|
||||
return (reply.payload ?? {}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function routineScheduleTiming(
|
||||
args?: Record<string, unknown>,
|
||||
): { cronPattern: string; metadata?: Record<string, number> } | undefined {
|
||||
if (args?.schedule_type === "once") {
|
||||
const runAt =
|
||||
typeof args.run_at === "number" ? args.run_at : Number(args?.run_at);
|
||||
return Number.isFinite(runAt)
|
||||
? {
|
||||
cronPattern: ONE_TIME_SCHEDULE_CRON_PATTERN,
|
||||
metadata: { [ONE_TIME_SCHEDULE_RUN_AT_METADATA_KEY]: runAt },
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
const cronPattern = asTrimmedString(args?.cron_pattern);
|
||||
return cronPattern ? { cronPattern } : undefined;
|
||||
}
|
||||
|
||||
function asTrimmedStringArray(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const values = value
|
||||
.map((item) => asTrimmedString(item))
|
||||
.filter((item): item is string => item !== undefined);
|
||||
return values.length > 0 ? values : undefined;
|
||||
}
|
||||
|
||||
export async function handleRoutineScheduleCommand(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
@@ -76,23 +107,23 @@ export async function handleRoutineScheduleCommand(
|
||||
|
||||
if (command === "create_routine_schedule") {
|
||||
const name = asTrimmedString(args?.name);
|
||||
const cronPattern = asTrimmedString(args?.cron_pattern);
|
||||
const timing = routineScheduleTiming(args);
|
||||
const prompt = asTrimmedString(args?.prompt);
|
||||
const routineWorkspaceRoot = asTrimmedString(args?.workspace_root);
|
||||
if (!name || !cronPattern || !prompt || !routineWorkspaceRoot) {
|
||||
if (!name || !timing || !prompt || !routineWorkspaceRoot) {
|
||||
throw new Error(
|
||||
"createSchedule requires name, cron_pattern, prompt, and workspace_root",
|
||||
"createSchedule requires name, timing, prompt, and workspace_root",
|
||||
);
|
||||
}
|
||||
const created = await clientCommand("schedule.create", {
|
||||
name,
|
||||
cronPattern,
|
||||
...timing,
|
||||
prompt,
|
||||
modelSelection: {
|
||||
providerId: asTrimmedString(args?.provider) ?? "cline",
|
||||
modelId: asTrimmedString(args?.model) ?? "openai/gpt-5.3-codex",
|
||||
modelId: asTrimmedString(args?.model) ?? CLINE_DEFAULT_MODEL_ID,
|
||||
},
|
||||
mode: args?.mode === "plan" ? "plan" : "act",
|
||||
mode: readHubScheduleMode(args, "yolo"),
|
||||
workspaceRoot: routineWorkspaceRoot,
|
||||
cwd: asTrimmedString(args?.cwd),
|
||||
systemPrompt: asTrimmedString(args?.system_prompt),
|
||||
@@ -100,12 +131,7 @@ export async function handleRoutineScheduleCommand(
|
||||
timeoutSeconds: toPositiveInt(args?.timeout_seconds),
|
||||
maxParallel: toPositiveInt(args?.max_parallel) ?? 1,
|
||||
enabled: args?.enabled !== false,
|
||||
tags:
|
||||
Array.isArray(args?.tags) && args.tags.length > 0
|
||||
? (args.tags as string[])
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length > 0)
|
||||
: undefined,
|
||||
tags: asTrimmedStringArray(args?.tags),
|
||||
});
|
||||
return { schedule: created.schedule ?? null };
|
||||
}
|
||||
@@ -113,25 +139,26 @@ export async function handleRoutineScheduleCommand(
|
||||
const scheduleId = asTrimmedString(args?.schedule_id);
|
||||
if (!scheduleId) throw new Error(`${command} requires schedule_id`);
|
||||
if (command === "update_routine_schedule") {
|
||||
const mode = readHubScheduleMode(args);
|
||||
const name = asTrimmedString(args?.name);
|
||||
const cronPattern = asTrimmedString(args?.cron_pattern);
|
||||
const timing = routineScheduleTiming(args);
|
||||
const prompt = asTrimmedString(args?.prompt);
|
||||
const routineWorkspaceRoot = asTrimmedString(args?.workspace_root);
|
||||
if (!name || !cronPattern || !prompt || !routineWorkspaceRoot) {
|
||||
if (!name || !timing || !prompt || !routineWorkspaceRoot) {
|
||||
throw new Error(
|
||||
"updateSchedule requires schedule_id, name, cron_pattern, prompt, and workspace_root",
|
||||
"updateSchedule requires schedule_id, name, timing, prompt, and workspace_root",
|
||||
);
|
||||
}
|
||||
const reply = await clientCommand("schedule.update", {
|
||||
scheduleId,
|
||||
name,
|
||||
cronPattern,
|
||||
...timing,
|
||||
prompt,
|
||||
modelSelection: {
|
||||
providerId: asTrimmedString(args?.provider) ?? "cline",
|
||||
modelId: asTrimmedString(args?.model) ?? "openai/gpt-5.3-codex",
|
||||
modelId: asTrimmedString(args?.model) ?? CLINE_DEFAULT_MODEL_ID,
|
||||
},
|
||||
mode: args?.mode === "plan" ? "plan" : "act",
|
||||
...(mode === undefined ? {} : { mode }),
|
||||
workspaceRoot: routineWorkspaceRoot,
|
||||
cwd: asTrimmedString(args?.cwd) ?? null,
|
||||
systemPrompt:
|
||||
@@ -148,11 +175,7 @@ export async function handleRoutineScheduleCommand(
|
||||
: toPositiveInt(args?.timeout_seconds),
|
||||
maxParallel: toPositiveInt(args?.max_parallel) ?? 1,
|
||||
enabled: args?.enabled !== false,
|
||||
tags: Array.isArray(args?.tags)
|
||||
? (args.tags as string[])
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length > 0)
|
||||
: [],
|
||||
tags: asTrimmedStringArray(args?.tags) ?? [],
|
||||
});
|
||||
return { schedule: reply.schedule ?? null };
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ function summarizeClient(client: TrackedClient): {
|
||||
if (
|
||||
normalizedType === "code-sidecar" ||
|
||||
normalizedType === "code-sidecar-approvals" ||
|
||||
normalizedType === "code-sidecar-observer" ||
|
||||
normalizedType === "code-sidecar-list"
|
||||
) {
|
||||
return { key: "code-app", label: "Code App", name: "Code App" };
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@cline/shared": "workspace:*",
|
||||
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"@rive-app/react-webgl2": "^4.27.2",
|
||||
@@ -26,7 +27,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",
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CLINE_DEFAULT_MODEL_ID,
|
||||
ONE_TIME_SCHEDULE_CRON_PATTERN,
|
||||
ONE_TIME_SCHEDULE_RUN_AT_METADATA_KEY,
|
||||
} from "@cline/shared";
|
||||
import {
|
||||
Circle,
|
||||
Eye,
|
||||
@@ -72,6 +77,7 @@ interface RoutineSchedule {
|
||||
scheduleId: string;
|
||||
name: string;
|
||||
cronPattern: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
prompt: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
@@ -145,7 +151,7 @@ interface ProcessContext {
|
||||
}
|
||||
|
||||
const FALLBACK_PROVIDER_MODELS: Record<string, string[]> = {
|
||||
cline: ["anthropic/claude-sonnet-4.6"],
|
||||
cline: [CLINE_DEFAULT_MODEL_ID],
|
||||
anthropic: ["claude-sonnet-4-6"],
|
||||
"openai-native": ["gpt-5.3-codex"],
|
||||
openrouter: ["anthropic/claude-sonnet-4.6"],
|
||||
@@ -170,13 +176,9 @@ interface RoutineFormState {
|
||||
prompt: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
mode: "act" | "plan";
|
||||
workspaceRoot: string;
|
||||
cwd: string;
|
||||
systemPrompt: string;
|
||||
maxIterations: string;
|
||||
timeoutSeconds: string;
|
||||
maxParallel: string;
|
||||
tags: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
@@ -203,6 +205,15 @@ function formatDateTime(value?: DateTimeValue | null): string {
|
||||
return parsed.toLocaleString();
|
||||
}
|
||||
|
||||
function getOneTimeScheduleRunAt(
|
||||
schedule: RoutineSchedule,
|
||||
): number | undefined {
|
||||
const runAt = schedule.metadata?.[ONE_TIME_SCHEDULE_RUN_AT_METADATA_KEY];
|
||||
return typeof runAt === "number" && Number.isFinite(runAt)
|
||||
? runAt
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function formatScheduleModel(schedule: RoutineSchedule): string {
|
||||
const provider =
|
||||
schedule.modelSelection?.providerId?.trim() || schedule.provider?.trim();
|
||||
@@ -226,7 +237,7 @@ function getScheduleProviderModel(schedule: RoutineSchedule): {
|
||||
model:
|
||||
schedule.modelSelection?.modelId?.trim() ||
|
||||
schedule.model?.trim() ||
|
||||
"openai/gpt-5.3-codex",
|
||||
CLINE_DEFAULT_MODEL_ID,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -241,20 +252,27 @@ function formatExecutionResult(execution?: RoutineExecution): string {
|
||||
return when === "-" ? status : `${status} at ${when}`;
|
||||
}
|
||||
|
||||
function parseOptionalPositiveInt(text: string): number | undefined {
|
||||
const trimmed = text.trim();
|
||||
function asTrimmedFormString(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function parseOptionalPositiveInt(value: unknown): number | undefined {
|
||||
const trimmed = asTrimmedFormString(value);
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const value = Number.parseInt(trimmed, 10);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
const parsedValue = Number.parseInt(trimmed, 10);
|
||||
if (!Number.isFinite(parsedValue) || parsedValue <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
return parsedValue;
|
||||
}
|
||||
|
||||
function parseTags(text: string): string[] | undefined {
|
||||
const tags = text
|
||||
function parseTags(value: unknown): string[] | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const tags = value
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0);
|
||||
@@ -382,14 +400,10 @@ export function RoutineSchedulesContent() {
|
||||
scheduleDays: ["MON", "TUE", "WED", "THU", "FRI"],
|
||||
prompt: "Review PRs opened yesterday and summarize issues.",
|
||||
provider: "cline",
|
||||
model: "openai/gpt-5.3-codex",
|
||||
mode: "act",
|
||||
model: CLINE_DEFAULT_MODEL_ID,
|
||||
workspaceRoot: "",
|
||||
cwd: "",
|
||||
systemPrompt: "",
|
||||
maxIterations: "",
|
||||
timeoutSeconds: "",
|
||||
maxParallel: "1",
|
||||
tags: "",
|
||||
enabled: true,
|
||||
});
|
||||
@@ -702,13 +716,9 @@ export function RoutineSchedulesContent() {
|
||||
prompt: "Review PRs opened yesterday and summarize issues.",
|
||||
provider: preferredProvider,
|
||||
model: preferredModel,
|
||||
mode: "act",
|
||||
workspaceRoot: context.workspaceRoot || context.cwd,
|
||||
cwd: context.cwd || "",
|
||||
systemPrompt: "",
|
||||
maxIterations: "",
|
||||
timeoutSeconds: "",
|
||||
maxParallel: "1",
|
||||
tags: "",
|
||||
enabled: true,
|
||||
});
|
||||
@@ -716,6 +726,9 @@ export function RoutineSchedulesContent() {
|
||||
};
|
||||
|
||||
const openEditDialog = (schedule: RoutineSchedule) => {
|
||||
if (schedule.cronPattern === ONE_TIME_SCHEDULE_CRON_PATTERN) {
|
||||
return;
|
||||
}
|
||||
const { provider, model } = getScheduleProviderModel(schedule);
|
||||
const parsedCron = parseCronPattern(schedule.cronPattern);
|
||||
setEditingSchedule(schedule);
|
||||
@@ -738,22 +751,12 @@ export function RoutineSchedulesContent() {
|
||||
prompt: schedule.prompt,
|
||||
provider,
|
||||
model,
|
||||
mode: schedule.mode === "plan" ? "plan" : "act",
|
||||
workspaceRoot: schedule.workspaceRoot ?? "",
|
||||
cwd: schedule.cwd ?? "",
|
||||
systemPrompt: schedule.systemPrompt ?? "",
|
||||
maxIterations:
|
||||
typeof schedule.maxIterations === "number"
|
||||
? String(schedule.maxIterations)
|
||||
: "",
|
||||
timeoutSeconds:
|
||||
typeof schedule.timeoutSeconds === "number"
|
||||
? String(schedule.timeoutSeconds)
|
||||
: "",
|
||||
maxParallel:
|
||||
typeof schedule.maxParallel === "number"
|
||||
? String(schedule.maxParallel)
|
||||
: "1",
|
||||
tags: schedule.tags?.join(",") ?? "",
|
||||
enabled: schedule.enabled,
|
||||
});
|
||||
@@ -761,7 +764,7 @@ export function RoutineSchedulesContent() {
|
||||
};
|
||||
|
||||
const submitCreateForm = async () => {
|
||||
const name = createForm.name.trim();
|
||||
const name = asTrimmedFormString(createForm.name);
|
||||
if (!name) {
|
||||
setCreateFormError("Routine name is required.");
|
||||
return;
|
||||
@@ -775,12 +778,12 @@ export function RoutineSchedulesContent() {
|
||||
setCreateFormError("Select at least one day and a valid time.");
|
||||
return;
|
||||
}
|
||||
const prompt = createForm.prompt.trim();
|
||||
const prompt = asTrimmedFormString(createForm.prompt);
|
||||
if (!prompt) {
|
||||
setCreateFormError("Prompt is required.");
|
||||
return;
|
||||
}
|
||||
const workspaceRoot = createForm.workspaceRoot.trim();
|
||||
const workspaceRoot = asTrimmedFormString(createForm.workspaceRoot);
|
||||
if (!workspaceRoot) {
|
||||
setCreateFormError("Workspace root is required.");
|
||||
return;
|
||||
@@ -789,18 +792,17 @@ export function RoutineSchedulesContent() {
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const provider =
|
||||
normalizeProviderId(createForm.provider) ||
|
||||
normalizeProviderId(asTrimmedFormString(createForm.provider)) ||
|
||||
availableProviders[0] ||
|
||||
"cline";
|
||||
const model =
|
||||
createForm.model.trim() ||
|
||||
asTrimmedFormString(createForm.model) ||
|
||||
(visibleProviderModels[provider] ?? [])[0] ||
|
||||
"openai/gpt-5.3-codex";
|
||||
const maxIterations = parseOptionalPositiveInt(createForm.maxIterations);
|
||||
CLINE_DEFAULT_MODEL_ID;
|
||||
const systemPrompt = asTrimmedFormString(createForm.systemPrompt);
|
||||
const timeoutSeconds = parseOptionalPositiveInt(
|
||||
createForm.timeoutSeconds,
|
||||
);
|
||||
const maxParallel = parseOptionalPositiveInt(createForm.maxParallel) ?? 1;
|
||||
const tags = parseTags(createForm.tags);
|
||||
const command = editingSchedule
|
||||
? "update_routine_schedule"
|
||||
@@ -814,19 +816,16 @@ export function RoutineSchedulesContent() {
|
||||
prompt,
|
||||
provider,
|
||||
model,
|
||||
mode: createForm.mode,
|
||||
mode: editingSchedule?.mode ?? "yolo", // New routines must default to yolo mode.
|
||||
workspace_root: workspaceRoot,
|
||||
cwd: createForm.cwd.trim() || undefined,
|
||||
cwd: editingSchedule ? (editingSchedule.cwd ?? null) : workspaceRoot,
|
||||
system_prompt: editingSchedule
|
||||
? createForm.systemPrompt.trim() || null
|
||||
: createForm.systemPrompt.trim() || undefined,
|
||||
max_iterations: editingSchedule
|
||||
? (maxIterations ?? null)
|
||||
: maxIterations,
|
||||
? systemPrompt || null
|
||||
: systemPrompt || undefined,
|
||||
timeout_seconds: editingSchedule
|
||||
? (timeoutSeconds ?? null)
|
||||
: timeoutSeconds,
|
||||
max_parallel: maxParallel,
|
||||
max_parallel: 1,
|
||||
enabled: createForm.enabled,
|
||||
tags: tags ?? [],
|
||||
});
|
||||
@@ -948,7 +947,9 @@ export function RoutineSchedulesContent() {
|
||||
{schedule.mode}
|
||||
</span>
|
||||
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{schedule.cronPattern}
|
||||
{schedule.cronPattern === ONE_TIME_SCHEDULE_CRON_PATTERN
|
||||
? `Once · ${formatDateTime(getOneTimeScheduleRunAt(schedule))}`
|
||||
: schedule.cronPattern}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -967,7 +968,10 @@ export function RoutineSchedulesContent() {
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${schedule.name}`}
|
||||
onClick={() => openEditDialog(schedule)}
|
||||
disabled={isBusy}
|
||||
disabled={
|
||||
isBusy ||
|
||||
schedule.cronPattern === ONE_TIME_SCHEDULE_CRON_PATTERN
|
||||
}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -1373,27 +1377,6 @@ export function RoutineSchedulesContent() {
|
||||
</Combobox>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Mode</Label>
|
||||
<Select
|
||||
value={createForm.mode}
|
||||
onValueChange={(value) =>
|
||||
setCreateForm((prev) => ({
|
||||
...prev,
|
||||
mode: value === "plan" ? "plan" : "act",
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select mode" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="act">act</SelectItem>
|
||||
<SelectItem value="plan">plan</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="routine-workspace">Workspace root</Label>
|
||||
<Input
|
||||
@@ -1408,20 +1391,6 @@ export function RoutineSchedulesContent() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="routine-cwd">CWD (optional)</Label>
|
||||
<Input
|
||||
id="routine-cwd"
|
||||
value={createForm.cwd}
|
||||
onChange={(event) =>
|
||||
setCreateForm((prev) => ({
|
||||
...prev,
|
||||
cwd: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="routine-system-prompt">
|
||||
System prompt (optional)
|
||||
@@ -1439,23 +1408,6 @@ export function RoutineSchedulesContent() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="routine-max-iterations">
|
||||
Max iterations (optional)
|
||||
</Label>
|
||||
<Input
|
||||
id="routine-max-iterations"
|
||||
value={createForm.maxIterations}
|
||||
onChange={(event) =>
|
||||
setCreateForm((prev) => ({
|
||||
...prev,
|
||||
maxIterations: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="routine-timeout">
|
||||
Timeout seconds (optional)
|
||||
@@ -1473,21 +1425,6 @@ export function RoutineSchedulesContent() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="routine-max-parallel">Max parallel</Label>
|
||||
<Input
|
||||
id="routine-max-parallel"
|
||||
value={createForm.maxParallel}
|
||||
onChange={(event) =>
|
||||
setCreateForm((prev) => ({
|
||||
...prev,
|
||||
maxParallel: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="routine-tags">
|
||||
Tags (comma-separated, optional)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Cline Code Desktop Changelog
|
||||
|
||||
## 0.0.4
|
||||
|
||||
- Start chatting without opening a project folder — the app now supports workspace-free chat sessions.
|
||||
- New first-run onboarding flow to get you set up on launch.
|
||||
- Drag and drop files directly onto the chat to attach them.
|
||||
- Image attachments now display inline in the chat transcript.
|
||||
- Schedule one-time routines (not just recurring ones), with navigation to jump to a routine's run.
|
||||
- New custom overlay title bar with in-app navigation.
|
||||
- Redesigned channel setup as expandable cards.
|
||||
- Added a setting to replay the new-user experience.
|
||||
- Cleaner chat markdown rendering, and external links now open correctly in your browser.
|
||||
- Agent sessions now use agentic compaction by default, keeping long conversations within context more intelligently.
|
||||
- Fixed the agent not finding `gh` and other CLI tools by resolving your login shell's PATH.
|
||||
- Headless routines now default to YOLO mode so they can run unattended.
|
||||
- Fixed request metering for the SAP AI Core provider.
|
||||
|
||||
## 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.
|
||||
@@ -16,6 +16,20 @@ From `apps/examples/desktop-app/`:
|
||||
- `bun run package:desktop` - package the current OS desktop app into `dist/desktop/`
|
||||
- `bun run typecheck` - TypeScript check
|
||||
|
||||
## Login Shell PATH Resolution
|
||||
|
||||
Apps launched from Finder/the Dock inherit launchd's minimal `PATH`
|
||||
(`/usr/bin:/bin:/usr/sbin:/sbin`), not the one your shell profiles build, so
|
||||
agent-run commands would miss Homebrew-installed tools like `gh` even though
|
||||
they work fine from a terminal. At startup the sidecar asks the user's login
|
||||
shell — read from the account database via `getpwuid`, falling back to
|
||||
`$SHELL` — for its `PATH` and merges it into `process.env.PATH`, which every
|
||||
agent-spawned child (run_commands, MCP servers) inherits. Only `PATH` is
|
||||
imported, deliberately; other login-environment variables (`SSH_AUTH_SOCK`,
|
||||
API keys, `JAVA_HOME`-style tool roots) are not pulled in. Set
|
||||
`CLINE_SIDECAR_SKIP_SHELL_PATH=1` to disable. Implementation and details:
|
||||
[`sidecar/shell-path.ts`](./sidecar/shell-path.ts).
|
||||
|
||||
## Web Visual System
|
||||
|
||||
The framework-neutral color, typography, radius, and navigation contract lives
|
||||
@@ -25,7 +39,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:
|
||||
|
||||
@@ -72,7 +100,9 @@ Do not remove `src-tauri/entitlements.plist` or the `bundle.macOS.entitlements`
|
||||
Startup flow:
|
||||
|
||||
1. Tauri starts a persistent local desktop backend and keeps only native window/file-picker/open-path responsibilities.
|
||||
2. The desktop backend starts the Bun sidecar and exposes one websocket transport (`/transport`) for commands, queries, and pushed events.
|
||||
2. The desktop backend starts the Bun sidecar, which discovers or starts the
|
||||
canonical shared Cline Hub and exposes one websocket transport (`/transport`)
|
||||
for desktop commands, queries, and pushed events.
|
||||
3. The React app uses `lib/desktop-client.ts` and no longer imports `@tauri-apps/api/core` directly in feature code.
|
||||
4. Tool approval updates are pushed from the backend instead of polled from the UI.
|
||||
5. Session process context resolves `workspaceRoot` from git root and uses that same path as default `cwd` for chat runtime and git operations unless explicitly overridden.
|
||||
@@ -93,8 +123,8 @@ Desktop transport envelope:
|
||||
## Key Files
|
||||
|
||||
- [`src-tauri/src/main.rs`](./src-tauri/src/main.rs) - Tauri shell lifecycle, backend launch, and native-only commands
|
||||
- [`sidecar/index.ts`](./sidecar/index.ts) - persistent Bun sidecar backend
|
||||
- [`sidecar/chat-session.ts`](./sidecar/chat-session.ts) - in-process chat session runtime
|
||||
- [`sidecar/index.ts`](./sidecar/index.ts) - persistent Bun sidecar and Hub-daemon entry dispatch
|
||||
- [`sidecar/chat-session.ts`](./sidecar/chat-session.ts) - shared-Hub chat session adapter
|
||||
- [`webview/lib/desktop-client.ts`](./webview/lib/desktop-client.ts) - typed desktop websocket client
|
||||
- [`webview/hooks/use-chat-session.ts`](./webview/hooks/use-chat-session.ts) - UI chat session state + backend subscriptions
|
||||
- [`webview/lib/chat-schema.ts`](./webview/lib/chat-schema.ts) - chat message schema used by the UI
|
||||
@@ -108,11 +138,28 @@ 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.
|
||||
- Tauri restarts the desktop backend if the sidecar process exits and kills it on app teardown.
|
||||
- Chat sends now preflight provider credentials. If a provider that requires API-key auth is selected without a key, the UI blocks the turn with a clear error message instead of starting a hanging session.
|
||||
- If a turn completes with `finishReason=error` before any assistant content is produced, the UI now adds an explicit error chat message so failed turns are visible in the transcript.
|
||||
- If package changes are not reflected, rebuild SDK packages (`bun run build:sdk`). The next `cline rpc ensure` call should attach to the current build's sidecar automatically.
|
||||
- If package changes are not reflected, rebuild SDK packages (`bun run build:sdk`).
|
||||
The next desktop or CLI Hub connection will reuse a compatible running Hub or
|
||||
replace an incompatible one through the shared discovery path.
|
||||
- Provider settings updates are patch-style: only fields you edit are changed. Unset fields are preserved instead of being cleared.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/code",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:ui": "bun -F @cline/ui build",
|
||||
@@ -76,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();
|
||||
}
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
## Overview
|
||||
|
||||
The sidecar is a single Bun process that handles the desktop backend runtime directly.
|
||||
The sidecar is a Bun process that adapts the desktop UI and native operations to
|
||||
the shared Cline Hub.
|
||||
|
||||
It imports `@cline/core` directly and serves the Next.js frontend over HTTP + WebSocket.
|
||||
It imports `@cline/core`, discovers or starts the canonical shared Hub, registers
|
||||
as a Hub client, and serves the Next.js frontend over HTTP + WebSocket. The
|
||||
sidecar does not own a private agent runtime Hub.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
@@ -14,7 +17,7 @@ sidecar/
|
||||
├── server.ts # Bun HTTP server + WebSocket handlers
|
||||
├── context.ts # SidecarContext type and factory
|
||||
├── commands.ts # Command router
|
||||
├── chat-session.ts # In-process chat session management
|
||||
├── chat-session.ts # Shared-Hub chat session adapter
|
||||
├── session-data/ # Shared discovery, messages, artifacts, search helpers
|
||||
├── paths.ts # Path resolution
|
||||
├── types.ts # Shared types
|
||||
@@ -31,15 +34,23 @@ Event: { "type": "event", "event": { "name": string, "payload": unknown } }
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### 1. Chat Sessions — In-Process via LocalRuntimeHost
|
||||
### 1. Chat Sessions — Shared Hub Client
|
||||
|
||||
Instead of spawning a separate runtime bridge process, we use `LocalRuntimeHost` directly:
|
||||
`ClineCore` uses Hub mode without an explicit endpoint. Core therefore reuses
|
||||
the same compatible Hub discovered by the CLI or starts the canonical detached
|
||||
Hub when the desktop is the first client:
|
||||
|
||||
```typescript
|
||||
import { LocalRuntimeHost } from "@cline/core";
|
||||
|
||||
const sessionManager = await ClineCore.create({
|
||||
clientName: "cline-code",
|
||||
backendMode: "hub",
|
||||
hub: {
|
||||
strategy: "require-hub",
|
||||
workspaceRoot,
|
||||
cwd: workspaceRoot,
|
||||
clientType: "code-sidecar",
|
||||
displayName: "Code App sidecar",
|
||||
},
|
||||
capabilities: {
|
||||
requestToolApproval: async (request) => {
|
||||
// Push approval request to frontend via WebSocket event
|
||||
@@ -66,9 +77,15 @@ sessionManager.subscribe((event) => {
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Tool Approval — In-Memory Promise Resolution
|
||||
The compiled sidecar also recognizes Core's Hub-daemon launch mode. This lets
|
||||
the desktop start the same detached Hub when no CLI process has started it yet.
|
||||
Startup discovery and locking ensure concurrent clients converge on one Hub.
|
||||
|
||||
No more file-system watchers. Tool approvals use in-memory promise maps:
|
||||
### 2. Tool Approval — Client-Owned Promise Resolution
|
||||
|
||||
The shared Hub routes approval requests back to the client that created the
|
||||
session. Desktop approvals use in-memory promise maps while the webview is
|
||||
online:
|
||||
|
||||
```typescript
|
||||
const pendingApprovals = new Map<string, {
|
||||
@@ -96,12 +113,11 @@ const store = new SqliteSessionStore();
|
||||
|
||||
### 5. Routine Schedules — Direct Hub Commands
|
||||
|
||||
Routine operations now ensure the local hub server in-process and issue hub schedule commands directly. They are still called in-process, not via child script:
|
||||
Routine operations use the same connected Hub client as chat session
|
||||
observation. They never start a second in-process Hub:
|
||||
|
||||
```typescript
|
||||
import { ensureHubServer, sendHubCommand } from "@cline/core";
|
||||
await ensureHubServer({ runtimeHandlers: createLocalHubScheduleRuntimeHandlers() });
|
||||
await sendHubCommand({}, { command: "schedule.list", payload: { limit: 200 } });
|
||||
await ctx.hubClient.command("schedule.list", { limit: 200 });
|
||||
```
|
||||
|
||||
### 6. Native Commands
|
||||
@@ -122,7 +138,7 @@ Supported commands:
|
||||
|
||||
| Command | Implementation |
|
||||
|---------|---------------|
|
||||
| `chat_session_command` | `LocalRuntimeHost` in-process |
|
||||
| `chat_session_command` | shared Hub through `ClineCore` |
|
||||
| `list_provider_catalog` | `ProviderSettingsManager` + `listLocalProviders` |
|
||||
| `list_provider_models` | `getLocalProviderModels` |
|
||||
| `save_provider_settings` | `saveLocalProviderSettings` |
|
||||
@@ -144,7 +160,7 @@ Supported commands:
|
||||
| `get_process_context` | In-memory context |
|
||||
| `poll_tool_approvals` | In-memory pending map |
|
||||
| `respond_tool_approval` | In-memory promise resolution |
|
||||
| `list_routine_schedules` | local hub schedule commands |
|
||||
| `list_routine_schedules` | shared Hub schedule commands |
|
||||
| `list_user_instruction_configs` | Direct core API |
|
||||
| `pick_workspace_directory` | OS native dialog |
|
||||
| `open_mcp_settings_file` | OS `open` command |
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { existsSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
deleteMaterializedAttachments,
|
||||
discardAllTrackedAttachments,
|
||||
flushConsumedAttachments,
|
||||
markQueuedAttachmentsSubmitted,
|
||||
materializeUserFiles,
|
||||
reconcileQueuedAttachments,
|
||||
sessionAttachmentsDir,
|
||||
trackQueuedAttachments,
|
||||
} from "./attachments";
|
||||
import type { LiveSession } from "./types";
|
||||
|
||||
const sessionId = "attachment-test-session";
|
||||
let previousSessionDataDir: string | undefined;
|
||||
let testSessionDataDir: string;
|
||||
|
||||
function createSession(): LiveSession {
|
||||
return {
|
||||
config: {},
|
||||
messages: [],
|
||||
promptsInQueue: [],
|
||||
busy: false,
|
||||
startedAt: Date.now(),
|
||||
status: "idle",
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
|
||||
testSessionDataDir = join(
|
||||
tmpdir(),
|
||||
`cline-desktop-attachment-lifecycle-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (previousSessionDataDir === undefined) {
|
||||
delete process.env.CLINE_SESSION_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
|
||||
}
|
||||
rmSync(testSessionDataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("materialized attachment lifecycle", () => {
|
||||
it("only deletes files inside the session attachments dir", () => {
|
||||
const [staged] = materializeUserFiles(sessionId, [
|
||||
{ name: "notes.txt", content: "hello" },
|
||||
]) as string[];
|
||||
const outside = join(testSessionDataDir, "outside.txt");
|
||||
writeFileSync(outside, "keep me", "utf8");
|
||||
|
||||
deleteMaterializedAttachments(sessionId, [staged, outside]);
|
||||
|
||||
expect(existsSync(staged)).toBe(false);
|
||||
expect(existsSync(outside)).toBe(true);
|
||||
});
|
||||
|
||||
it("deletes consumed files when the turn ends", () => {
|
||||
const session = createSession();
|
||||
const files = materializeUserFiles(sessionId, [
|
||||
{ name: "a.txt", content: "a" },
|
||||
]) as string[];
|
||||
trackQueuedAttachments(
|
||||
session,
|
||||
[
|
||||
{
|
||||
id: "pending_1",
|
||||
prompt: "p",
|
||||
delivery: "queue",
|
||||
attachmentCount: 1,
|
||||
userFiles: files,
|
||||
},
|
||||
],
|
||||
files,
|
||||
);
|
||||
|
||||
markQueuedAttachmentsSubmitted(session, "pending_1");
|
||||
expect(existsSync(files[0] as string)).toBe(true);
|
||||
|
||||
flushConsumedAttachments(sessionId, session);
|
||||
expect(existsSync(files[0] as string)).toBe(false);
|
||||
expect(session.consumedAttachmentFiles?.size ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps files for a submitted prompt that gets requeued", () => {
|
||||
const session = createSession();
|
||||
const files = materializeUserFiles(sessionId, [
|
||||
{ name: "a.txt", content: "a" },
|
||||
]) as string[];
|
||||
trackQueuedAttachments(
|
||||
session,
|
||||
[
|
||||
{
|
||||
id: "pending_1",
|
||||
prompt: "p",
|
||||
delivery: "queue",
|
||||
attachmentCount: 1,
|
||||
userFiles: files,
|
||||
},
|
||||
],
|
||||
files,
|
||||
);
|
||||
markQueuedAttachmentsSubmitted(session, "pending_1");
|
||||
|
||||
// Drain send failed → prompt is back in the queue snapshot.
|
||||
reconcileQueuedAttachments(session, ["pending_1"]);
|
||||
flushConsumedAttachments(sessionId, session);
|
||||
expect(existsSync(files[0] as string)).toBe(true);
|
||||
expect(session.queuedAttachmentFiles?.get("pending_1")).toEqual(files);
|
||||
});
|
||||
|
||||
it("tracks files as consumed when the prompt is no longer queued", () => {
|
||||
const session = createSession();
|
||||
const files = materializeUserFiles(sessionId, [
|
||||
{ name: "a.txt", content: "a" },
|
||||
]) as string[];
|
||||
|
||||
trackQueuedAttachments(session, [], files);
|
||||
expect(session.queuedAttachmentFiles?.size ?? 0).toBe(0);
|
||||
expect(session.consumedAttachmentFiles?.size).toBe(1);
|
||||
});
|
||||
|
||||
it("discards all tracked files on session end", () => {
|
||||
const session = createSession();
|
||||
const queued = materializeUserFiles(sessionId, [
|
||||
{ name: "queued.txt", content: "q" },
|
||||
]) as string[];
|
||||
const consumed = materializeUserFiles(sessionId, [
|
||||
{ name: "consumed.txt", content: "c" },
|
||||
]) as string[];
|
||||
trackQueuedAttachments(
|
||||
session,
|
||||
[
|
||||
{
|
||||
id: "pending_1",
|
||||
prompt: "p",
|
||||
delivery: "queue",
|
||||
attachmentCount: 1,
|
||||
userFiles: queued,
|
||||
},
|
||||
],
|
||||
queued,
|
||||
);
|
||||
trackQueuedAttachments(session, [], consumed);
|
||||
|
||||
discardAllTrackedAttachments(sessionId, session);
|
||||
|
||||
expect(existsSync(queued[0] as string)).toBe(false);
|
||||
expect(existsSync(consumed[0] as string)).toBe(false);
|
||||
expect(existsSync(sessionAttachmentsDir(sessionId))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { basename, join, resolve, sep } from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import type { SessionPendingPrompt } from "@cline/core";
|
||||
import { sharedSessionDataDir } from "./paths";
|
||||
import type { ChatTurnAttachments, LiveSession } from "./types";
|
||||
|
||||
function queuedFilesMap(session: LiveSession): Map<string, string[]> {
|
||||
if (!session.queuedAttachmentFiles) {
|
||||
session.queuedAttachmentFiles = new Map();
|
||||
}
|
||||
return session.queuedAttachmentFiles;
|
||||
}
|
||||
|
||||
function consumedFilesMap(session: LiveSession): Map<string, string[]> {
|
||||
if (!session.consumedAttachmentFiles) {
|
||||
session.consumedAttachmentFiles = new Map();
|
||||
}
|
||||
return session.consumedAttachmentFiles;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Materialized user-attachment lifecycle
|
||||
//
|
||||
// Non-image attachments arrive from the webview as inline content and are
|
||||
// written to `<session-data>/<sessionId>/user-attachments/` so the SDK can
|
||||
// load them by path at turn start. The sidecar owns these files and must
|
||||
// delete them once consumed (turn completed) or discarded (queued prompt
|
||||
// removed / session ended) — otherwise user data accumulates on disk.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function sessionAttachmentsDir(sessionId: string): string {
|
||||
return join(sharedSessionDataDir(), sessionId, "user-attachments");
|
||||
}
|
||||
|
||||
export function materializeUserFiles(
|
||||
sessionId: string,
|
||||
files: ChatTurnAttachments["userFiles"],
|
||||
): string[] | undefined {
|
||||
if (!files?.length) {
|
||||
return undefined;
|
||||
}
|
||||
const attachmentDir = sessionAttachmentsDir(sessionId);
|
||||
mkdirSync(attachmentDir, { recursive: true });
|
||||
return files.map((file) => {
|
||||
const requestedName = basename(file.name.trim());
|
||||
const safeName =
|
||||
requestedName && requestedName !== "." && requestedName !== ".."
|
||||
? requestedName
|
||||
: "attachment.txt";
|
||||
const path = join(attachmentDir, `${randomUUID()}-${safeName}`);
|
||||
writeFileSync(path, file.content, "utf8");
|
||||
return path;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete materialized attachment files. Only paths inside the session's
|
||||
* user-attachments directory are removed, so files referenced from elsewhere
|
||||
* (e.g. `@`-mentions) are never touched.
|
||||
*/
|
||||
export function deleteMaterializedAttachments(
|
||||
sessionId: string,
|
||||
paths: string[] | undefined,
|
||||
): void {
|
||||
if (!paths?.length) return;
|
||||
const attachmentDir = resolve(sessionAttachmentsDir(sessionId)) + sep;
|
||||
for (const path of paths) {
|
||||
if (!resolve(path).startsWith(attachmentDir)) continue;
|
||||
try {
|
||||
rmSync(path, { force: true });
|
||||
} catch {
|
||||
// Best-effort cleanup; leftover files are removed with the session dir.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track files staged for a queued/steered prompt so they can be deleted once
|
||||
* the prompt is consumed or discarded. If the prompt is no longer in the
|
||||
* queue (already submitted), the files are tracked as consumed and deleted
|
||||
* when the running turn finishes.
|
||||
*/
|
||||
export function trackQueuedAttachments(
|
||||
session: LiveSession | undefined,
|
||||
prompts: SessionPendingPrompt[],
|
||||
userFiles: string[] | undefined,
|
||||
): void {
|
||||
if (!session || !userFiles?.length) return;
|
||||
const match = prompts.find((prompt) =>
|
||||
isDeepStrictEqual(prompt.userFiles, userFiles),
|
||||
);
|
||||
if (match) {
|
||||
queuedFilesMap(session).set(match.id, userFiles);
|
||||
} else {
|
||||
// Not in the queue → already being consumed by the running turn. Key by a
|
||||
// fresh id so it never collides with a prompt-id key used elsewhere in the
|
||||
// consumed bucket.
|
||||
consumedFilesMap(session).set(randomUUID(), userFiles);
|
||||
}
|
||||
}
|
||||
|
||||
/** Move a submitted queued prompt's files into the consumed bucket. */
|
||||
export function markQueuedAttachmentsSubmitted(
|
||||
session: LiveSession | undefined,
|
||||
promptId: string,
|
||||
): void {
|
||||
const files = session?.queuedAttachmentFiles?.get(promptId);
|
||||
if (!session || !files) return;
|
||||
session.queuedAttachmentFiles?.delete(promptId);
|
||||
consumedFilesMap(session).set(promptId, files);
|
||||
}
|
||||
|
||||
/**
|
||||
* A prompt id reappearing in the queue means a submitted prompt was requeued
|
||||
* (e.g. the drain send failed) — move its files back to the queued bucket so
|
||||
* the turn-end flush does not delete files still pending consumption.
|
||||
*/
|
||||
export function reconcileQueuedAttachments(
|
||||
session: LiveSession | undefined,
|
||||
queuedPromptIds: string[],
|
||||
): void {
|
||||
if (!session?.consumedAttachmentFiles?.size) return;
|
||||
for (const id of queuedPromptIds) {
|
||||
const files = session.consumedAttachmentFiles.get(id);
|
||||
if (!files) continue;
|
||||
session.consumedAttachmentFiles.delete(id);
|
||||
queuedFilesMap(session).set(id, files);
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete files for prompts whose turn has finished. */
|
||||
export function flushConsumedAttachments(
|
||||
sessionId: string,
|
||||
session: LiveSession | undefined,
|
||||
): void {
|
||||
if (!session?.consumedAttachmentFiles?.size) return;
|
||||
for (const files of session.consumedAttachmentFiles.values()) {
|
||||
deleteMaterializedAttachments(sessionId, files);
|
||||
}
|
||||
session.consumedAttachmentFiles.clear();
|
||||
}
|
||||
|
||||
/** Delete every tracked file for a session (queued prompts are discarded). */
|
||||
export function discardAllTrackedAttachments(
|
||||
sessionId: string,
|
||||
session: LiveSession | undefined,
|
||||
): void {
|
||||
if (!session) return;
|
||||
flushConsumedAttachments(sessionId, session);
|
||||
if (!session.queuedAttachmentFiles?.size) return;
|
||||
for (const files of session.queuedAttachmentFiles.values()) {
|
||||
deleteMaterializedAttachments(sessionId, files);
|
||||
}
|
||||
session.queuedAttachmentFiles.clear();
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
import { existsSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { materializeUserFiles } from "./attachments";
|
||||
import {
|
||||
buildSessionConnectionUpdate,
|
||||
consumeWorkspaceMetadata,
|
||||
handleChatSessionCommand,
|
||||
hasProviderChanged,
|
||||
mergeSessionConfig,
|
||||
prewarmWorkspaceMetadata,
|
||||
shouldUpdateSessionConnection,
|
||||
WORKSPACE_METADATA_PREWARM_TTL_MS,
|
||||
@@ -87,6 +93,87 @@ 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("pathless session starts", () => {
|
||||
it("omits workspace paths and returns the SDK-resolved chat workspace", async () => {
|
||||
const start = vi.fn(async (input: { config: Record<string, unknown> }) => {
|
||||
expect(input.config).not.toHaveProperty("cwd");
|
||||
expect(input.config).not.toHaveProperty("workspaceRoot");
|
||||
return {
|
||||
sessionId: "session-pathless",
|
||||
manifest: {
|
||||
cwd: "/home/host/.cline/data/workspaces/chat",
|
||||
workspace_root: "/home/host/.cline/data/workspaces/chat",
|
||||
},
|
||||
manifestPath: "/tmp/session-pathless.json",
|
||||
messagesPath: "/tmp/session-pathless.messages.json",
|
||||
};
|
||||
});
|
||||
const ctx = {
|
||||
liveSessions: new Map(),
|
||||
sessionManager: { start },
|
||||
} as unknown as SidecarContext;
|
||||
|
||||
const result = (await handleChatSessionCommand(ctx, {
|
||||
action: "start",
|
||||
config: {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
enableTools: true,
|
||||
},
|
||||
})) as {
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
workspaceRoot: string;
|
||||
};
|
||||
|
||||
expect(result).toEqual({
|
||||
sessionId: "session-pathless",
|
||||
cwd: "/home/host/.cline/data/workspaces/chat",
|
||||
workspaceRoot: "/home/host/.cline/data/workspaces/chat",
|
||||
});
|
||||
expect(ctx.liveSessions.get("session-pathless")?.config).toMatchObject({
|
||||
cwd: "/home/host/.cline/data/workspaces/chat",
|
||||
workspaceRoot: "/home/host/.cline/data/workspaces/chat",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("first-send connection updates", () => {
|
||||
const baseConfig = {
|
||||
provider: "cline",
|
||||
@@ -100,12 +187,19 @@ describe("first-send connection updates", () => {
|
||||
config?: Record<string, unknown>;
|
||||
}) {
|
||||
const updateSessionConnection = vi.fn(async () => undefined);
|
||||
const send = vi.fn(async () => ({
|
||||
const send = vi.fn(async (_input?: unknown) => ({
|
||||
text: "done",
|
||||
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 +215,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 () => {
|
||||
@@ -140,6 +254,266 @@ describe("first-send connection updates", () => {
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows an image-only user turn", async () => {
|
||||
const { ctx, send, sessionId } = createContext();
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "",
|
||||
attachments: {
|
||||
userImages: ["data:image/png;base64,aGVsbG8="],
|
||||
userFiles: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(send).toHaveBeenCalledWith({
|
||||
sessionId,
|
||||
prompt: "",
|
||||
delivery: undefined,
|
||||
userImages: ["data:image/png;base64,aGVsbG8="],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
"queue",
|
||||
] as const)("forwards file attachments for %s delivery", async (delivery) => {
|
||||
const { ctx, send, sessionId } = createContext();
|
||||
const previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
|
||||
const testSessionDataDir = join(
|
||||
tmpdir(),
|
||||
`cline-desktop-attachments-${Date.now()}-${delivery ?? "immediate"}`,
|
||||
);
|
||||
let sentFileContent: string | undefined;
|
||||
send.mockImplementation(async (input?: unknown) => {
|
||||
const files = (input as { userFiles?: string[] } | undefined)?.userFiles;
|
||||
if (files?.[0]) {
|
||||
sentFileContent = readFileSync(files[0], "utf8");
|
||||
}
|
||||
return { text: "done", finishReason: "completed", messages: [] };
|
||||
});
|
||||
|
||||
try {
|
||||
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "",
|
||||
delivery,
|
||||
attachments: {
|
||||
userFiles: [{ name: "notes.txt", content: "hello" }],
|
||||
},
|
||||
});
|
||||
|
||||
const input = send.mock.calls[0]?.[0] as
|
||||
| { userFiles?: string[] }
|
||||
| undefined;
|
||||
expect(send).toHaveBeenCalledWith({
|
||||
sessionId,
|
||||
prompt: "",
|
||||
delivery,
|
||||
userImages: undefined,
|
||||
userFiles: [expect.stringMatching(/notes\.txt$/)],
|
||||
});
|
||||
expect(sentFileContent).toBe("hello");
|
||||
if (delivery === "queue") {
|
||||
// Queued attachments stay on disk until the prompt is consumed.
|
||||
expect(existsSync(input?.userFiles?.[0] ?? "")).toBe(true);
|
||||
} else {
|
||||
// Immediate turns delete the materialized file once the send resolves.
|
||||
expect(existsSync(input?.userFiles?.[0] ?? "")).toBe(false);
|
||||
}
|
||||
} finally {
|
||||
if (previousSessionDataDir === undefined) {
|
||||
delete process.env.CLINE_SESSION_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
|
||||
}
|
||||
rmSync(testSessionDataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("deletes materialized attachments when a queued prompt is removed", async () => {
|
||||
const { ctx, send, sessionId } = createContext();
|
||||
const previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
|
||||
const testSessionDataDir = join(
|
||||
tmpdir(),
|
||||
`cline-desktop-attachments-remove-${Date.now()}`,
|
||||
);
|
||||
|
||||
try {
|
||||
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
|
||||
const queue: Array<{
|
||||
id: string;
|
||||
prompt: string;
|
||||
delivery: "queue";
|
||||
attachmentCount: number;
|
||||
userFiles?: string[];
|
||||
}> = [];
|
||||
const manager = ctx.sessionManager as unknown as {
|
||||
send: typeof send;
|
||||
pendingPrompts: {
|
||||
list: (input: unknown) => Promise<unknown[]>;
|
||||
delete: (input: {
|
||||
sessionId: string;
|
||||
promptId: string;
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
manager.send = vi.fn(async (input?: unknown) => {
|
||||
const { prompt, userFiles } = input as {
|
||||
prompt: string;
|
||||
userFiles?: string[];
|
||||
};
|
||||
queue.push({
|
||||
id: "pending_1",
|
||||
prompt,
|
||||
delivery: "queue",
|
||||
attachmentCount: userFiles?.length ?? 0,
|
||||
userFiles,
|
||||
});
|
||||
return undefined;
|
||||
}) as unknown as typeof send;
|
||||
manager.pendingPrompts = {
|
||||
list: vi.fn(async () => [...queue]),
|
||||
delete: vi.fn(async ({ promptId }) => {
|
||||
const index = queue.findIndex((entry) => entry.id === promptId);
|
||||
const [removed] = index >= 0 ? queue.splice(index, 1) : [];
|
||||
return {
|
||||
sessionId,
|
||||
prompts: [...queue],
|
||||
prompt: removed,
|
||||
removed: index >= 0,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "queued with file",
|
||||
delivery: "queue",
|
||||
attachments: {
|
||||
userFiles: [{ name: "notes.txt", content: "hello" }],
|
||||
},
|
||||
});
|
||||
const filePath = queue[0]?.userFiles?.[0] ?? "";
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
expect(
|
||||
ctx.liveSessions
|
||||
.get(sessionId)
|
||||
?.queuedAttachmentFiles?.get("pending_1"),
|
||||
).toEqual([filePath]);
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "remove_pending_prompt",
|
||||
sessionId,
|
||||
promptId: "pending_1",
|
||||
});
|
||||
expect(existsSync(filePath)).toBe(false);
|
||||
expect(
|
||||
ctx.liveSessions.get(sessionId)?.queuedAttachmentFiles?.size ?? 0,
|
||||
).toBe(0);
|
||||
} finally {
|
||||
if (previousSessionDataDir === undefined) {
|
||||
delete process.env.CLINE_SESSION_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
|
||||
}
|
||||
rmSync(testSessionDataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("deletes tracked attachments when a session is reset", async () => {
|
||||
const { ctx, sessionId } = createContext();
|
||||
const previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
|
||||
const testSessionDataDir = join(
|
||||
tmpdir(),
|
||||
`cline-desktop-attachments-reset-${Date.now()}`,
|
||||
);
|
||||
|
||||
try {
|
||||
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
|
||||
const [queuedFile] = materializeUserFiles(sessionId, [
|
||||
{ name: "queued.txt", content: "q" },
|
||||
]) as string[];
|
||||
const [consumedFile] = materializeUserFiles(sessionId, [
|
||||
{ name: "consumed.txt", content: "c" },
|
||||
]) as string[];
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (!session) throw new Error("missing session");
|
||||
session.queuedAttachmentFiles = new Map([["pending_1", [queuedFile]]]);
|
||||
session.consumedAttachmentFiles = new Map([
|
||||
["pending_2", [consumedFile]],
|
||||
]);
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "reset",
|
||||
sessionId,
|
||||
});
|
||||
|
||||
expect(existsSync(queuedFile)).toBe(false);
|
||||
expect(existsSync(consumedFile)).toBe(false);
|
||||
expect(ctx.liveSessions.has(sessionId)).toBe(false);
|
||||
} finally {
|
||||
if (previousSessionDataDir === undefined) {
|
||||
delete process.env.CLINE_SESSION_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
|
||||
}
|
||||
rmSync(testSessionDataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves tracked attachments across re-attach", async () => {
|
||||
const { ctx, sessionId } = createContext();
|
||||
const previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
|
||||
const testSessionDataDir = join(
|
||||
tmpdir(),
|
||||
`cline-desktop-attachments-attach-${Date.now()}`,
|
||||
);
|
||||
|
||||
try {
|
||||
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
|
||||
const [queuedFile] = materializeUserFiles(sessionId, [
|
||||
{ name: "queued.txt", content: "q" },
|
||||
]) as string[];
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (!session) throw new Error("missing session");
|
||||
const queuedMap = new Map([["pending_1", [queuedFile]]]);
|
||||
session.queuedAttachmentFiles = queuedMap;
|
||||
(ctx.sessionManager as unknown as { get: unknown }).get = vi.fn(
|
||||
async () => ({
|
||||
status: "idle",
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
cwd: "/workspace",
|
||||
workspaceRoot: "/workspace",
|
||||
}),
|
||||
);
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "attach",
|
||||
sessionId,
|
||||
});
|
||||
|
||||
expect(existsSync(queuedFile)).toBe(true);
|
||||
expect(
|
||||
ctx.liveSessions
|
||||
.get(sessionId)
|
||||
?.queuedAttachmentFiles?.get("pending_1"),
|
||||
).toEqual([queuedFile]);
|
||||
} finally {
|
||||
if (previousSessionDataDir === undefined) {
|
||||
delete process.env.CLINE_SESSION_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
|
||||
}
|
||||
rmSync(testSessionDataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("updates a changed connection before sending", async () => {
|
||||
const { ctx, send, sessionId, updateSessionConnection } = createContext({
|
||||
config: { ...baseConfig, reasoningEffort: "low" },
|
||||
@@ -158,6 +532,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,
|
||||
|
||||
@@ -5,13 +5,22 @@ import {
|
||||
buildConnectionUpdate,
|
||||
buildWorkspaceMetadata,
|
||||
type ClineCore,
|
||||
type CoreSessionConfig,
|
||||
type ClineCoreStartConfig,
|
||||
createSessionCompactionState,
|
||||
projectSessionCompactionState,
|
||||
type SessionCompactionState,
|
||||
type SessionPendingPrompt,
|
||||
SessionSource,
|
||||
splitCoreSessionConfig,
|
||||
} from "@cline/core";
|
||||
import type { Message } from "@cline/llms";
|
||||
import { buildClineSystemPrompt } from "@cline/shared";
|
||||
import {
|
||||
deleteMaterializedAttachments,
|
||||
discardAllTrackedAttachments,
|
||||
materializeUserFiles,
|
||||
trackQueuedAttachments,
|
||||
} from "./attachments";
|
||||
import { emitChunk, nowMs, sendEvent } from "./context";
|
||||
import { readSessionManifest, sharedSessionDataDir } from "./paths";
|
||||
import type {
|
||||
@@ -159,6 +168,8 @@ function createLiveSession(
|
||||
prompt: overrides?.prompt,
|
||||
title: overrides?.title,
|
||||
attachedViaHub: overrides?.attachedViaHub ?? false,
|
||||
queuedAttachmentFiles: overrides?.queuedAttachmentFiles,
|
||||
consumedAttachmentFiles: overrides?.consumedAttachmentFiles,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -194,6 +205,11 @@ function readPositiveInteger(value: unknown): number | undefined {
|
||||
}
|
||||
|
||||
function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
|
||||
const rawWorkspaceRoot = config.workspaceRoot ?? config.workspace_root;
|
||||
const workspaceRoot =
|
||||
typeof rawWorkspaceRoot === "string" ? rawWorkspaceRoot.trim() : "";
|
||||
const cwd =
|
||||
(typeof config.cwd === "string" ? config.cwd.trim() : "") || workspaceRoot;
|
||||
const thinking =
|
||||
typeof config.thinking === "boolean" ? config.thinking : undefined;
|
||||
const reasoningEffort =
|
||||
@@ -212,8 +228,11 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
|
||||
modelId: config.model ?? config.modelId ?? "",
|
||||
mode: config.mode ?? "act",
|
||||
apiKey: config.apiKey ?? config.api_key ?? "",
|
||||
workspaceRoot: config.workspaceRoot ?? config.workspace_root ?? "",
|
||||
cwd: config.cwd ?? config.workspaceRoot ?? config.workspace_root ?? "",
|
||||
baseUrl: config.baseUrl,
|
||||
headers: config.headers,
|
||||
providerConfig: config.providerConfig,
|
||||
...(workspaceRoot ? { workspaceRoot } : {}),
|
||||
...(cwd ? { cwd } : {}),
|
||||
systemPrompt: config.systemPrompt ?? config.system_prompt ?? "",
|
||||
maxIterations: config.maxIterations ?? config.max_iterations,
|
||||
enableTools: config.enableTools ?? config.enable_tools ?? true,
|
||||
@@ -293,6 +312,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 ?? "",
|
||||
@@ -348,7 +415,13 @@ function sendPromptsInQueueSnapshot(
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
sendEvent(ctx, "prompts_in_queue_state", {
|
||||
sessionId,
|
||||
items: session?.promptsInQueue ?? [],
|
||||
items:
|
||||
session?.promptsInQueue.map(({ id, prompt, steer, attachmentCount }) => ({
|
||||
id,
|
||||
prompt,
|
||||
steer,
|
||||
attachmentCount,
|
||||
})) ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -358,6 +431,7 @@ function mapPendingPrompt(item: SessionPendingPrompt): PromptInQueue {
|
||||
prompt: item.prompt,
|
||||
steer: item.delivery === "steer",
|
||||
attachmentCount: item.attachmentCount,
|
||||
userImages: item.userImages,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -372,7 +446,12 @@ function applyPendingPrompts(
|
||||
session.promptsInQueue = mapped;
|
||||
}
|
||||
sendPromptsInQueueSnapshot(ctx, sessionId);
|
||||
return mapped;
|
||||
return mapped.map(({ id, prompt, steer, attachmentCount }) => ({
|
||||
id,
|
||||
prompt,
|
||||
steer,
|
||||
attachmentCount,
|
||||
}));
|
||||
}
|
||||
|
||||
function getSessionManager(ctx: SidecarContext): ClineCore {
|
||||
@@ -412,11 +491,12 @@ 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),
|
||||
...splitCoreSessionConfig(coreConfig as unknown as ClineCoreStartConfig),
|
||||
source: SessionSource.DESKTOP,
|
||||
interactive: true,
|
||||
...(initialMessages
|
||||
@@ -425,19 +505,24 @@ async function handleStart(
|
||||
toolPolicies: resolveToolPolicies(request.config),
|
||||
});
|
||||
const sessionId = startResult.sessionId;
|
||||
console.error(`[sidecar:handleStart] session started sessionId=${sessionId}`);
|
||||
const session = createLiveSession(request.config, {
|
||||
messages: initialMessages,
|
||||
prompt: initialMessages
|
||||
? derivePromptFromMessages(initialMessages)
|
||||
: undefined,
|
||||
title: requestedSessionId
|
||||
? readSessionMetadataTitle(requestedSessionId)
|
||||
: undefined,
|
||||
status: "idle",
|
||||
});
|
||||
const workspaceRoot = startResult.manifest.workspace_root;
|
||||
const cwd = startResult.manifest.cwd;
|
||||
ctx.logger?.log("Desktop chat session started", { sessionId });
|
||||
const session = createLiveSession(
|
||||
{ ...request.config, cwd, workspaceRoot },
|
||||
{
|
||||
messages: initialMessages,
|
||||
prompt: initialMessages
|
||||
? derivePromptFromMessages(initialMessages)
|
||||
: undefined,
|
||||
title: requestedSessionId
|
||||
? readSessionMetadataTitle(requestedSessionId)
|
||||
: undefined,
|
||||
status: "idle",
|
||||
},
|
||||
);
|
||||
ctx.liveSessions.set(sessionId, session);
|
||||
return { sessionId };
|
||||
return { sessionId, cwd, workspaceRoot };
|
||||
}
|
||||
|
||||
async function handleAttach(
|
||||
@@ -495,6 +580,11 @@ async function handleAttach(
|
||||
existing?.title,
|
||||
endedAt: isoTimestampToMs(session.endedAt),
|
||||
attachedViaHub: true,
|
||||
// Preserve tracked attachment files so re-attach (called on every
|
||||
// webview hydrate) does not orphan materialized files still awaiting
|
||||
// cleanup.
|
||||
queuedAttachmentFiles: existing?.queuedAttachmentFiles,
|
||||
consumedAttachmentFiles: existing?.consumedAttachmentFiles,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -510,80 +600,251 @@ 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 ClineCoreStartConfig,
|
||||
),
|
||||
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,
|
||||
): Promise<unknown> {
|
||||
const sessionId = request.sessionId?.trim();
|
||||
if (!sessionId) throw new Error("sessionId is required");
|
||||
const prompt = request.prompt?.trim();
|
||||
if (!prompt) throw new Error("prompt is required");
|
||||
const prompt = request.prompt?.trim() ?? "";
|
||||
const hasAttachments =
|
||||
(request.attachments?.userImages?.length ?? 0) > 0 ||
|
||||
(request.attachments?.userFiles?.length ?? 0) > 0;
|
||||
if (!prompt && !hasAttachments) {
|
||||
throw new Error("prompt or attachment 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)}`,
|
||||
);
|
||||
const result = await manager.send({
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const userFiles = materializeUserFiles(
|
||||
sessionId,
|
||||
prompt,
|
||||
delivery,
|
||||
userImages: request.attachments?.userImages,
|
||||
});
|
||||
console.error(
|
||||
`[sidecar:handleSend] manager.send resolved sessionId=${sessionId} finishReason=${result?.finishReason} textLen=${result?.text?.length ?? 0}`,
|
||||
request.attachments?.userFiles,
|
||||
);
|
||||
if (session) {
|
||||
session.busy = false;
|
||||
if (delivery === "queue") {
|
||||
if (session) {
|
||||
session.prompt = prompt;
|
||||
}
|
||||
await manager.send({
|
||||
sessionId,
|
||||
prompt,
|
||||
delivery: "queue",
|
||||
userImages: request.attachments?.userImages,
|
||||
userFiles,
|
||||
});
|
||||
const prompts = await manager.pendingPrompts.list({ sessionId });
|
||||
trackQueuedAttachments(session, prompts, userFiles);
|
||||
return {
|
||||
sessionId,
|
||||
ok: true,
|
||||
queued: true,
|
||||
promptsInQueue: applyPendingPrompts(ctx, sessionId, prompts),
|
||||
};
|
||||
}
|
||||
|
||||
ctx.logger?.debug("Sending desktop chat prompt", {
|
||||
sessionId,
|
||||
promptLength: prompt.length,
|
||||
delivery,
|
||||
});
|
||||
let result: Awaited<ReturnType<ClineCore["send"]>>;
|
||||
try {
|
||||
result = await manager.send({
|
||||
sessionId,
|
||||
prompt,
|
||||
delivery,
|
||||
userImages: request.attachments?.userImages,
|
||||
userFiles,
|
||||
});
|
||||
} catch (error) {
|
||||
deleteMaterializedAttachments(sessionId, userFiles);
|
||||
throw error;
|
||||
}
|
||||
if (result === undefined) {
|
||||
// The runtime queued or steered the prompt instead of running it
|
||||
// (busy interactive session / steer delivery) — track the files so
|
||||
// they are deleted once the prompt is consumed or discarded.
|
||||
if (userFiles?.length) {
|
||||
trackQueuedAttachments(
|
||||
session,
|
||||
await manager.pendingPrompts.list({ sessionId }),
|
||||
userFiles,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
deleteMaterializedAttachments(sessionId, userFiles);
|
||||
}
|
||||
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 +863,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 +884,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -732,7 +999,7 @@ async function handleFork(
|
||||
...forkConfig,
|
||||
systemPrompt,
|
||||
initialMessages: sourceMessages,
|
||||
}) as unknown as CoreSessionConfig,
|
||||
}) as unknown as ClineCoreStartConfig,
|
||||
),
|
||||
source: SessionSource.DESKTOP,
|
||||
interactive: true,
|
||||
@@ -741,6 +1008,10 @@ async function handleFork(
|
||||
toolPolicies: resolveToolPolicies(forkConfig),
|
||||
});
|
||||
const newSessionId = startResult.sessionId;
|
||||
discardAllTrackedAttachments(
|
||||
sourceSessionId,
|
||||
ctx.liveSessions.get(sourceSessionId),
|
||||
);
|
||||
ctx.liveSessions.delete(sourceSessionId);
|
||||
ctx.liveSessions.set(
|
||||
newSessionId,
|
||||
@@ -780,6 +1051,7 @@ async function handleReset(
|
||||
) {
|
||||
await getSessionManager(ctx).stop(sessionId);
|
||||
}
|
||||
discardAllTrackedAttachments(sessionId, session);
|
||||
ctx.liveSessions.delete(sessionId);
|
||||
sendPromptsInQueueSnapshot(ctx, sessionId);
|
||||
}
|
||||
@@ -818,7 +1090,7 @@ async function handleRestoreCheckpoint(
|
||||
buildCoreSessionConfig({
|
||||
...request.config,
|
||||
systemPrompt: await resolveSystemPrompt(request.config),
|
||||
}) as unknown as CoreSessionConfig,
|
||||
}) as unknown as ClineCoreStartConfig,
|
||||
),
|
||||
source: SessionSource.DESKTOP,
|
||||
interactive: true,
|
||||
@@ -830,6 +1102,10 @@ async function handleRestoreCheckpoint(
|
||||
if (!sessionId || !restoredMessages) {
|
||||
throw new Error("Checkpoint restore did not return a new session");
|
||||
}
|
||||
discardAllTrackedAttachments(
|
||||
sourceSessionId,
|
||||
ctx.liveSessions.get(sourceSessionId),
|
||||
);
|
||||
ctx.liveSessions.delete(sourceSessionId);
|
||||
ctx.liveSessions.set(
|
||||
sessionId,
|
||||
@@ -931,6 +1207,10 @@ async function handleRemovePendingPrompt(
|
||||
sessionId,
|
||||
promptId,
|
||||
});
|
||||
if (result.removed === true) {
|
||||
deleteMaterializedAttachments(sessionId, result.prompt?.userFiles);
|
||||
ctx.liveSessions.get(sessionId)?.queuedAttachmentFiles?.delete(promptId);
|
||||
}
|
||||
return {
|
||||
sessionId,
|
||||
removed: result.removed === true,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -263,6 +263,24 @@ export async function startConnectorChannel(
|
||||
): Promise<WebviewConnectorChannelsResponse> {
|
||||
const cliArgs = buildConnectorStartArgs(args);
|
||||
const channel = cliArgs[0] ?? "";
|
||||
if (listActiveConnectors().some((connector) => connector.type === channel)) {
|
||||
const stopResult = await runCliConnectCommand(workspaceRoot, [
|
||||
"--stop",
|
||||
channel,
|
||||
]);
|
||||
if (stopResult.code !== 0) {
|
||||
throw new Error(
|
||||
normalizeConnectorError(
|
||||
stopResult.stderr || stopResult.stdout,
|
||||
"connector stop failed",
|
||||
),
|
||||
);
|
||||
}
|
||||
await waitForConnectorState(
|
||||
() =>
|
||||
!listActiveConnectors().some((connector) => connector.type === channel),
|
||||
);
|
||||
}
|
||||
const result = await runCliConnectCommand(workspaceRoot, cliArgs);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
@@ -290,7 +308,7 @@ export async function stopConnectorChannel(
|
||||
if (!supported.has(channel)) {
|
||||
throw new Error(`unknown connector channel: ${channel}`);
|
||||
}
|
||||
const result = await runCliConnectCommand(workspaceRoot, [channel, "--stop"]);
|
||||
const result = await runCliConnectCommand(workspaceRoot, ["--stop", channel]);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
normalizeConnectorError(
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { existsSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { RuntimeCapabilities } from "@cline/core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SidecarContext } from "./types";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { materializeUserFiles } from "./attachments";
|
||||
import type { LiveSession, SidecarContext } from "./types";
|
||||
|
||||
const createCoreMock = vi.hoisted(() => vi.fn());
|
||||
const connectMock = vi.hoisted(() => vi.fn());
|
||||
const ensureCompatibleLocalHubUrlMock = vi.hoisted(() => vi.fn());
|
||||
const hubCommandMock = vi.hoisted(() => vi.fn());
|
||||
const hubGetConnectionErrorMock = vi.hoisted(() => vi.fn());
|
||||
const hubGetUrlMock = vi.hoisted(() => vi.fn());
|
||||
const hubIsConnectedMock = vi.hoisted(() => vi.fn());
|
||||
const nodeHubClientCtorMock = vi.hoisted(() => vi.fn());
|
||||
const resolveHubOwnerContextMock = vi.hoisted(() => vi.fn());
|
||||
const startHubWebSocketServerMock = vi.hoisted(() => vi.fn());
|
||||
const subscribeMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
@@ -17,19 +24,16 @@ vi.mock("@cline/core", async () => {
|
||||
ClineCore: {
|
||||
create: createCoreMock,
|
||||
},
|
||||
createLocalHubScheduleRuntimeHandlers: vi.fn(() => ({
|
||||
startSession: vi.fn(),
|
||||
sendSession: vi.fn(),
|
||||
abortSession: vi.fn(),
|
||||
stopSession: vi.fn(),
|
||||
})),
|
||||
resolveHubOwnerContext: resolveHubOwnerContextMock,
|
||||
startHubWebSocketServer: startHubWebSocketServerMock,
|
||||
ensureCompatibleLocalHubUrl: ensureCompatibleLocalHubUrlMock,
|
||||
NodeHubClient: class {
|
||||
constructor(options: unknown) {
|
||||
nodeHubClientCtorMock(options);
|
||||
}
|
||||
connect = connectMock;
|
||||
command = hubCommandMock;
|
||||
getConnectionError = hubGetConnectionErrorMock;
|
||||
getUrl = hubGetUrlMock;
|
||||
isConnected = hubIsConnectedMock;
|
||||
subscribe = subscribeMock;
|
||||
dispose = vi.fn();
|
||||
},
|
||||
@@ -53,20 +57,21 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
beforeEach(() => {
|
||||
createCoreMock.mockReset();
|
||||
connectMock.mockReset();
|
||||
ensureCompatibleLocalHubUrlMock.mockReset();
|
||||
hubCommandMock.mockReset();
|
||||
hubGetConnectionErrorMock.mockReset();
|
||||
hubGetUrlMock.mockReset();
|
||||
hubIsConnectedMock.mockReset();
|
||||
nodeHubClientCtorMock.mockReset();
|
||||
resolveHubOwnerContextMock.mockReset();
|
||||
startHubWebSocketServerMock.mockReset();
|
||||
subscribeMock.mockReset();
|
||||
connectMock.mockResolvedValue(undefined);
|
||||
resolveHubOwnerContextMock.mockReturnValue({
|
||||
ownerId: "code-sidecar-test",
|
||||
discoveryPath: "/tmp/code-sidecar-test.json",
|
||||
});
|
||||
startHubWebSocketServerMock.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
close: vi.fn(),
|
||||
});
|
||||
ensureCompatibleLocalHubUrlMock.mockResolvedValue(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
);
|
||||
hubCommandMock.mockResolvedValue({ ok: true, payload: {} });
|
||||
hubGetConnectionErrorMock.mockReturnValue(null);
|
||||
hubGetUrlMock.mockReturnValue("ws://127.0.0.1:25463/hub");
|
||||
hubIsConnectedMock.mockReturnValue(true);
|
||||
subscribeMock.mockReturnValue(() => {});
|
||||
createCoreMock.mockResolvedValue({
|
||||
runtimeAddress: "ws://127.0.0.1:25463/hub",
|
||||
@@ -83,15 +88,6 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
await initializeSessionManager(ctx);
|
||||
|
||||
expect(startHubWebSocketServerMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
port: 0,
|
||||
owner: {
|
||||
ownerId: "code-sidecar-test",
|
||||
discoveryPath: "/tmp/code-sidecar-test.json",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(createCoreMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
backendMode: "hub",
|
||||
@@ -102,22 +98,115 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
requestToolApproval: expect.any(Function),
|
||||
}),
|
||||
hub: expect.objectContaining({
|
||||
endpoint: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
strategy: "require-hub",
|
||||
workspaceRoot: "/workspace/project",
|
||||
cwd: "/workspace/project",
|
||||
clientType: "code-sidecar",
|
||||
displayName: "Code App sidecar",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const hubOptions = createCoreMock.mock.calls[0][0].hub;
|
||||
expect(hubOptions).not.toHaveProperty("endpoint");
|
||||
expect(hubOptions).not.toHaveProperty("authToken");
|
||||
expect(nodeHubClientCtorMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
clientType: "code-sidecar-approvals",
|
||||
clientType: "code-sidecar-observer",
|
||||
displayName: "Code App observer",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("wires the desktop logger and telemetry through the shared Hub client", 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(createCoreMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
clientName: "cline-code",
|
||||
logger,
|
||||
telemetry,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports the connected shared Hub endpoint in process context", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
);
|
||||
const { handleCommand } = await import("./commands");
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
|
||||
await initializeSessionManager(ctx);
|
||||
|
||||
await expect(handleCommand(ctx, "get_process_context")).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
hub: {
|
||||
status: "connected",
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("starts or reuses the shared Hub when a command needs a client", async () => {
|
||||
const { createSidecarContext, ensureSharedHubClient } = await import(
|
||||
"./context"
|
||||
);
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
|
||||
const hubClient = await ensureSharedHubClient(ctx);
|
||||
expect(hubClient).toBe(ctx.hubClient);
|
||||
|
||||
expect(ensureCompatibleLocalHubUrlMock).toHaveBeenCalledWith({
|
||||
strategy: "require-hub",
|
||||
workspaceRoot: "/workspace/project",
|
||||
cwd: "/workspace/project",
|
||||
});
|
||||
expect(nodeHubClientCtorMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
clientType: "code-sidecar-observer",
|
||||
}),
|
||||
);
|
||||
expect(connectMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("serializes queued image data when a queued prompt starts", async () => {
|
||||
const { serializeQueuedPromptStart } = await import("./context");
|
||||
|
||||
expect(
|
||||
JSON.parse(
|
||||
serializeQueuedPromptStart({
|
||||
promptId: "queued-prompt-1",
|
||||
prompt: "Describe this",
|
||||
attachmentCount: 1,
|
||||
userImages: ["data:image/png;base64,AQID"],
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
promptId: "queued-prompt-1",
|
||||
prompt: "Describe this",
|
||||
attachmentCount: 1,
|
||||
userImages: ["data:image/png;base64,AQID"],
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves askQuestion through the websocket request/response protocol", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
@@ -192,8 +281,7 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
requestToolApproval: expect.any(Function),
|
||||
}),
|
||||
hub: expect.objectContaining({
|
||||
endpoint: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
strategy: "require-hub",
|
||||
clientType: "code-sidecar",
|
||||
displayName: "Code App sidecar",
|
||||
}),
|
||||
@@ -253,4 +341,79 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
await handleCommand(ctx, "poll_tool_approvals", { sessionId: "sess-1" }),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("routes routine commands through the connected shared Hub client", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
);
|
||||
const { handleCommand } = await import("./commands");
|
||||
hubCommandMock.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: { scheduleId: "schedule-1", enabled: false } },
|
||||
});
|
||||
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
await initializeSessionManager(ctx);
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "pause_routine_schedule", {
|
||||
schedule_id: "schedule-1",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
schedule: { scheduleId: "schedule-1", enabled: false },
|
||||
});
|
||||
expect(hubCommandMock).toHaveBeenCalledWith("schedule.disable", {
|
||||
scheduleId: "schedule-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("disposeSidecarContext attachment cleanup", () => {
|
||||
let previousSessionDataDir: string | undefined;
|
||||
let testSessionDataDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
|
||||
testSessionDataDir = join(
|
||||
tmpdir(),
|
||||
`cline-desktop-dispose-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (previousSessionDataDir === undefined) {
|
||||
delete process.env.CLINE_SESSION_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
|
||||
}
|
||||
rmSync(testSessionDataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("deletes tracked attachments for all live sessions on shutdown", async () => {
|
||||
const { createSidecarContext, disposeSidecarContext } = await import(
|
||||
"./context"
|
||||
);
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
|
||||
const sessionId = "dispose-session";
|
||||
const [queuedFile] = materializeUserFiles(sessionId, [
|
||||
{ name: "queued.txt", content: "q" },
|
||||
]) as string[];
|
||||
const session: LiveSession = {
|
||||
config: {},
|
||||
messages: [],
|
||||
promptsInQueue: [],
|
||||
busy: false,
|
||||
startedAt: Date.now(),
|
||||
status: "idle",
|
||||
queuedAttachmentFiles: new Map([["pending_1", [queuedFile]]]),
|
||||
};
|
||||
ctx.liveSessions.set(sessionId, session);
|
||||
|
||||
await disposeSidecarContext(ctx, "test_shutdown");
|
||||
|
||||
expect(existsSync(queuedFile)).toBe(false);
|
||||
expect(ctx.liveSessions.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,18 +4,24 @@ import { homedir } from "node:os";
|
||||
import { dirname } from "node:path";
|
||||
import {
|
||||
type AgentToolContext,
|
||||
type BasicLogger,
|
||||
ClineCore,
|
||||
createLocalHubScheduleRuntimeHandlers,
|
||||
type CoreSessionEvent,
|
||||
ensureCompatibleLocalHubUrl,
|
||||
type ITelemetryService,
|
||||
NodeHubClient,
|
||||
resolveHubOwnerContext,
|
||||
type RuntimeCapabilities,
|
||||
setHomeDirIfUnset,
|
||||
startHubWebSocketServer,
|
||||
type ToolApprovalRequest,
|
||||
type ToolApprovalResult,
|
||||
} from "@cline/core";
|
||||
import type { AgentEvent } from "@cline/shared";
|
||||
import {
|
||||
discardAllTrackedAttachments,
|
||||
flushConsumedAttachments,
|
||||
markQueuedAttachmentsSubmitted,
|
||||
reconcileQueuedAttachments,
|
||||
} from "./attachments";
|
||||
import { sessionLogPath } from "./paths";
|
||||
import type {
|
||||
LiveSession,
|
||||
@@ -26,6 +32,10 @@ import type {
|
||||
} from "./types";
|
||||
|
||||
const ASK_QUESTION_TIMEOUT_MS = 5 * 60_000;
|
||||
const hubClientInitialization = new WeakMap<
|
||||
SidecarContext,
|
||||
Promise<NodeHubClient>
|
||||
>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers — WebSocket broadcast
|
||||
@@ -109,7 +119,29 @@ export function broadcastChunk(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getPromptsInQueue(session: LiveSession): PromptInQueue[] {
|
||||
return session.promptsInQueue;
|
||||
return session.promptsInQueue.map(
|
||||
({ id, prompt, steer, attachmentCount, userImages }) => ({
|
||||
id,
|
||||
prompt,
|
||||
steer,
|
||||
attachmentCount,
|
||||
userImages,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function serializeQueuedPromptStart(input: {
|
||||
promptId: string;
|
||||
prompt: string;
|
||||
attachmentCount?: number;
|
||||
userImages?: string[];
|
||||
}): string {
|
||||
return JSON.stringify({
|
||||
promptId: input.promptId,
|
||||
prompt: input.prompt,
|
||||
attachmentCount: input.attachmentCount ?? 0,
|
||||
userImages: input.userImages,
|
||||
});
|
||||
}
|
||||
|
||||
function sendPromptsInQueueSnapshot(
|
||||
@@ -301,9 +333,16 @@ function handleCoreSessionEvent(
|
||||
prompt: item.prompt ?? "",
|
||||
steer: item.delivery === "steer",
|
||||
attachmentCount: item.attachmentCount ?? 0,
|
||||
userImages: item.userImages,
|
||||
}))
|
||||
.filter((item) => item.id && item.prompt);
|
||||
.filter(
|
||||
(item) => item.id && (item.prompt || (item.attachmentCount ?? 0) > 0),
|
||||
);
|
||||
if (session) {
|
||||
reconcileQueuedAttachments(
|
||||
session,
|
||||
mapped.map((item) => item.id),
|
||||
);
|
||||
const previous = session.promptsInQueue;
|
||||
session.promptsInQueue = mapped;
|
||||
if (
|
||||
@@ -315,9 +354,11 @@ function handleCoreSessionEvent(
|
||||
ctx,
|
||||
sessionId,
|
||||
"chat_queued_prompt_start",
|
||||
JSON.stringify({
|
||||
serializeQueuedPromptStart({
|
||||
promptId: previous[0].id,
|
||||
prompt: previous[0].prompt,
|
||||
attachmentCount: previous[0].attachmentCount ?? 0,
|
||||
userImages: previous[0].userImages,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -326,14 +367,18 @@ function handleCoreSessionEvent(
|
||||
break;
|
||||
}
|
||||
case "pending_prompt_submitted": {
|
||||
const { sessionId, prompt, attachmentCount } = event.payload;
|
||||
const { sessionId, id, prompt, attachmentCount, userImages } =
|
||||
event.payload;
|
||||
markQueuedAttachmentsSubmitted(ctx.liveSessions.get(sessionId), id);
|
||||
emitChunk(
|
||||
ctx,
|
||||
sessionId,
|
||||
"chat_queued_prompt_start",
|
||||
JSON.stringify({
|
||||
serializeQueuedPromptStart({
|
||||
promptId: id,
|
||||
prompt,
|
||||
attachmentCount: attachmentCount ?? 0,
|
||||
userImages,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
@@ -346,6 +391,7 @@ function handleCoreSessionEvent(
|
||||
session.endedAt = nowMs();
|
||||
session.status = reason || "ended";
|
||||
}
|
||||
discardAllTrackedAttachments(sessionId, session);
|
||||
sendEvent(ctx, "chat_session_ended", { sessionId, reason });
|
||||
break;
|
||||
}
|
||||
@@ -365,6 +411,10 @@ function handleCoreSessionEvent(
|
||||
if (session) {
|
||||
session.status = status;
|
||||
session.busy = status === "running";
|
||||
if (status !== "running") {
|
||||
// The turn that consumed submitted attachments has finished.
|
||||
flushConsumedAttachments(sessionId, session);
|
||||
}
|
||||
}
|
||||
sendEvent(ctx, "chat_session_status", { sessionId, status });
|
||||
break;
|
||||
@@ -380,7 +430,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(),
|
||||
@@ -389,8 +445,9 @@ export function createSidecarContext(workspaceRoot: string): SidecarContext {
|
||||
pendingQuestions: new Map(),
|
||||
sessionManager: null,
|
||||
hubClient: null,
|
||||
hubServer: null,
|
||||
workspaceRoot,
|
||||
logger: observability.logger,
|
||||
telemetry: observability.telemetry,
|
||||
unsubscribeSessionEvents: null,
|
||||
};
|
||||
}
|
||||
@@ -404,6 +461,11 @@ export async function disposeSidecarContext(
|
||||
ctx.unsubscribeSessionEvents?.();
|
||||
ctx.unsubscribeSessionEvents = null;
|
||||
|
||||
for (const [sessionId, session] of ctx.liveSessions) {
|
||||
discardAllTrackedAttachments(sessionId, session);
|
||||
}
|
||||
ctx.liveSessions.clear();
|
||||
|
||||
for (const client of ctx.wsClients) {
|
||||
try {
|
||||
client.close?.();
|
||||
@@ -434,12 +496,6 @@ export async function disposeSidecarContext(
|
||||
cleanup.push(sessionManager.dispose(reason));
|
||||
}
|
||||
|
||||
const hubServer = ctx.hubServer;
|
||||
ctx.hubServer = null;
|
||||
if (hubServer) {
|
||||
cleanup.push(hubServer.close());
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(cleanup);
|
||||
const firstFailure = results.find(
|
||||
(result): result is PromiseRejectedResult => result.status === "rejected",
|
||||
@@ -692,19 +748,14 @@ export async function initializeSessionManager(
|
||||
ctx: SidecarContext,
|
||||
): Promise<void> {
|
||||
setHomeDirIfUnset(homedir());
|
||||
const hubServer = await startHubWebSocketServer({
|
||||
port: 0,
|
||||
owner: resolveHubOwnerContext(
|
||||
`code-sidecar:${process.pid}:${randomUUID()}`,
|
||||
),
|
||||
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
|
||||
});
|
||||
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,
|
||||
strategy: "require-hub",
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
clientType: "code-sidecar",
|
||||
@@ -717,25 +768,64 @@ export async function initializeSessionManager(
|
||||
handleCoreSessionEvent(ctx, event);
|
||||
});
|
||||
|
||||
const runtimeAddress = sessionManager.runtimeAddress?.trim();
|
||||
let hubClient: NodeHubClient | null = null;
|
||||
if (runtimeAddress) {
|
||||
hubClient = new NodeHubClient({
|
||||
url: runtimeAddress,
|
||||
authToken: hubServer.authToken,
|
||||
clientType: "code-sidecar-approvals",
|
||||
displayName: "Code App approvals",
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
});
|
||||
await hubClient.connect();
|
||||
hubClient.subscribe((event) => {
|
||||
handleHubLiveEvent(ctx, event);
|
||||
});
|
||||
try {
|
||||
await ensureSharedHubClient(ctx, sessionManager.runtimeAddress);
|
||||
} catch (error) {
|
||||
unsubscribe();
|
||||
await sessionManager.dispose("code_sidecar_hub_initialization_failed");
|
||||
throw error;
|
||||
}
|
||||
|
||||
ctx.sessionManager = sessionManager;
|
||||
ctx.hubClient = hubClient;
|
||||
ctx.hubServer = hubServer;
|
||||
ctx.unsubscribeSessionEvents = unsubscribe;
|
||||
}
|
||||
|
||||
export async function ensureSharedHubClient(
|
||||
ctx: SidecarContext,
|
||||
preferredUrl?: string,
|
||||
): Promise<NodeHubClient> {
|
||||
if (ctx.hubClient) {
|
||||
return ctx.hubClient;
|
||||
}
|
||||
const pending = hubClientInitialization.get(ctx);
|
||||
if (pending) {
|
||||
return await pending;
|
||||
}
|
||||
|
||||
const initialization = (async () => {
|
||||
const url =
|
||||
preferredUrl?.trim() ||
|
||||
(await ensureCompatibleLocalHubUrl({
|
||||
strategy: "require-hub",
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
}));
|
||||
if (!url) {
|
||||
throw new Error("Unable to start or connect to the shared Cline Hub.");
|
||||
}
|
||||
|
||||
const client = new NodeHubClient({
|
||||
url,
|
||||
clientType: "code-sidecar-observer",
|
||||
displayName: "Code App observer",
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
client.subscribe((event) => {
|
||||
handleHubLiveEvent(ctx, event);
|
||||
});
|
||||
ctx.hubClient = client;
|
||||
return client;
|
||||
} catch (error) {
|
||||
await client.dispose().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
})().finally(() => {
|
||||
hubClientInitialization.delete(ctx);
|
||||
});
|
||||
|
||||
hubClientInitialization.set(ctx, initialization);
|
||||
return await initialization;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { homedir } from "node:os";
|
||||
import { setHomeDirIfUnset } from "@cline/core";
|
||||
import { isHubDaemonProcess } from "@cline/shared";
|
||||
import { prewarmWorkspaceMetadata } from "./chat-session";
|
||||
import {
|
||||
createSidecarContext,
|
||||
disposeSidecarContext,
|
||||
initializeSessionManager,
|
||||
} from "./context";
|
||||
import { createDesktopObservability } from "./observability";
|
||||
import { resolveWorkspaceRoot } from "./paths";
|
||||
import { startServer } from "./server";
|
||||
import { ensureLoginShellPath } from "./shell-path";
|
||||
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;
|
||||
@@ -32,19 +40,48 @@ async function main() {
|
||||
throw new Error("sidecar must be run with Bun");
|
||||
}
|
||||
|
||||
// When launched from Finder/the Dock the app inherits launchd's minimal
|
||||
// PATH, so agent-spawned processes can't find shell-profile-installed
|
||||
// tools like `gh`. Kick resolution off first so it overlaps the rest of
|
||||
// startup, but await it before the session manager exists — that's what
|
||||
// spawns children (agent sessions, MCP servers, scheduled runs).
|
||||
const shellPathPromise = ensureLoginShellPath();
|
||||
|
||||
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);
|
||||
observability.logger.log(
|
||||
"Login shell PATH resolution",
|
||||
await shellPathPromise,
|
||||
);
|
||||
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 +92,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 +134,20 @@ async function main() {
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
async function runEntrypoint(): Promise<void> {
|
||||
if (isHubDaemonProcess()) {
|
||||
await import("@cline/core/hub/daemon-entry");
|
||||
return;
|
||||
}
|
||||
await main();
|
||||
}
|
||||
|
||||
runEntrypoint().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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readSessionMessages } from "./messages";
|
||||
|
||||
describe("readSessionMessages", () => {
|
||||
it("projects image content blocks without replacing them with placeholder text", async () => {
|
||||
const sessionId = `image-projection-${Date.now()}`;
|
||||
const liveSessions = new Map([
|
||||
[
|
||||
sessionId,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
id: "user-image",
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Describe this" },
|
||||
{
|
||||
type: "image",
|
||||
mediaType: "image/png",
|
||||
data: "aGVsbG8=",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
await expect(
|
||||
readSessionMessages(
|
||||
{ liveSessions } as Parameters<typeof readSessionMessages>[0],
|
||||
sessionId,
|
||||
),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
role: "user",
|
||||
content: "Describe this",
|
||||
images: [
|
||||
{
|
||||
id: "user-image_image_1",
|
||||
mediaType: "image/png",
|
||||
data: "aGVsbG8=",
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { validateImageMedia } from "@cline/shared";
|
||||
import {
|
||||
readSessionManifest,
|
||||
sharedSessionMessagesPath,
|
||||
@@ -121,6 +122,20 @@ function trimNonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function extractImageBlock(
|
||||
record: JsonRecord,
|
||||
): { mediaType: string; data: string } | undefined {
|
||||
const mediaType = trimNonEmptyString(record.mediaType);
|
||||
const data = trimNonEmptyString(record.data);
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
const validation = validateImageMedia(mediaType, data);
|
||||
return validation.ok
|
||||
? { mediaType: validation.mediaType, data: validation.base64 }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function readPersistedChatMessages(sessionId: string): unknown[] | null {
|
||||
const path = sharedSessionMessagesPath(sessionId);
|
||||
if (!existsSync(path)) {
|
||||
@@ -367,6 +382,7 @@ export async function readSessionMessages(
|
||||
}
|
||||
|
||||
const textParts: string[] = [];
|
||||
const images: Array<{ id: string; mediaType: string; data: string }> = [];
|
||||
const reasoningParts: string[] = [];
|
||||
let reasoningRedacted = false;
|
||||
let textSegmentIndex = 0;
|
||||
@@ -476,6 +492,16 @@ export async function readSessionMessages(
|
||||
reasoningRedacted = true;
|
||||
continue;
|
||||
}
|
||||
if (blockType === "image") {
|
||||
const image = extractImageBlock(record);
|
||||
if (image) {
|
||||
images.push({
|
||||
id: `${messageIdBase}_image_${blockIdx}`,
|
||||
...image,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const line = stringifyMessageContent(block);
|
||||
if (line.trim()) {
|
||||
textParts.push(line);
|
||||
@@ -483,6 +509,25 @@ export async function readSessionMessages(
|
||||
}
|
||||
|
||||
flushTextParts();
|
||||
if (images.length > 0) {
|
||||
const target = out
|
||||
.slice(outStartIndex)
|
||||
.find((item) => item.role === role);
|
||||
if (target) {
|
||||
target.images = images;
|
||||
} else {
|
||||
out.push({
|
||||
id: `${messageIdBase}_images`,
|
||||
sessionId,
|
||||
role,
|
||||
content: "",
|
||||
images,
|
||||
createdAt: nextCreatedAt++,
|
||||
meta: textMeta,
|
||||
});
|
||||
textMeta = undefined;
|
||||
}
|
||||
}
|
||||
if (reasoningParts.length > 0 || reasoningRedacted) {
|
||||
const reasoning = reasoningParts.join("\n").trim();
|
||||
const target = out
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
defaultShellFor,
|
||||
ensureLoginShellPath,
|
||||
extractMarkedPath,
|
||||
loginShellFor,
|
||||
mergePaths,
|
||||
resolveLoginShellPath,
|
||||
shellInvocation,
|
||||
} from "./shell-path";
|
||||
|
||||
const MARKER_START = "__CLINE_SIDECAR_PATH_START__";
|
||||
const MARKER_END = "__CLINE_SIDECAR_PATH_END__";
|
||||
|
||||
let tempDirs: string[] = [];
|
||||
|
||||
/**
|
||||
* Fake login shell: a /bin/sh script invoked as `fake-shell -i -l -c <cmd>`,
|
||||
* so the command to run arrives as $4. The default body mimics a login shell
|
||||
* whose profile prepends Homebrew before running the command.
|
||||
*/
|
||||
function writeFakeShell(
|
||||
script = 'PATH="/opt/homebrew/bin:/usr/bin"; eval "$4"',
|
||||
name = "fake-shell",
|
||||
): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cline-shell-path-"));
|
||||
tempDirs.push(dir);
|
||||
const shellPath = join(dir, name);
|
||||
writeFileSync(shellPath, `#!/bin/sh\n${script}\n`);
|
||||
chmodSync(shellPath, 0o755);
|
||||
return shellPath;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
tempDirs = [];
|
||||
});
|
||||
|
||||
describe("extractMarkedPath", () => {
|
||||
it("extracts the PATH between markers", () => {
|
||||
expect(
|
||||
extractMarkedPath(
|
||||
`${MARKER_START}/opt/homebrew/bin:/usr/bin${MARKER_END}`,
|
||||
),
|
||||
).toBe("/opt/homebrew/bin:/usr/bin");
|
||||
});
|
||||
|
||||
it("ignores shell profile noise around the markers", () => {
|
||||
const output = `Welcome!\nsome banner\n${MARKER_START}/usr/local/bin${MARKER_END}\ntrailing noise`;
|
||||
expect(extractMarkedPath(output)).toBe("/usr/local/bin");
|
||||
});
|
||||
|
||||
it("returns undefined when markers are missing or empty", () => {
|
||||
expect(extractMarkedPath("no markers here")).toBeUndefined();
|
||||
expect(extractMarkedPath(`${MARKER_START}${MARKER_END}`)).toBeUndefined();
|
||||
expect(extractMarkedPath(`${MARKER_START}/usr/bin`)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergePaths", () => {
|
||||
it("puts shell entries first and keeps current-only entries", () => {
|
||||
expect(
|
||||
mergePaths(
|
||||
"/opt/homebrew/bin:/usr/bin:/bin",
|
||||
"/usr/bin:/bin:/custom/bin",
|
||||
),
|
||||
).toBe("/opt/homebrew/bin:/usr/bin:/bin:/custom/bin");
|
||||
});
|
||||
|
||||
it("drops duplicate and empty entries", () => {
|
||||
expect(mergePaths("/a::/b:/a", "/b:/c:")).toBe("/a:/b:/c");
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultShellFor", () => {
|
||||
it("uses zsh on macOS and bash elsewhere", () => {
|
||||
expect(defaultShellFor("darwin")).toBe("/bin/zsh");
|
||||
expect(defaultShellFor("linux")).toBe("/bin/bash");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loginShellFor", () => {
|
||||
it("returns the passwd-database shell when one exists", () => {
|
||||
// The test runner's uid has a passwd entry, so $SHELL must lose.
|
||||
const shell = loginShellFor(process.platform, {
|
||||
SHELL: "/env/should-not-win",
|
||||
});
|
||||
expect(shell.startsWith("/")).toBe(true);
|
||||
expect(shell).not.toBe("/env/should-not-win");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shellInvocation", () => {
|
||||
it("uses separate login+interactive flags for posix-style shells", () => {
|
||||
expect(shellInvocation("/bin/zsh", "cmd")).toEqual({
|
||||
args: ["-i", "-l", "-c", "cmd"],
|
||||
});
|
||||
expect(shellInvocation("/opt/homebrew/bin/fish", "cmd")).toEqual({
|
||||
args: ["-i", "-l", "-c", "cmd"],
|
||||
});
|
||||
});
|
||||
|
||||
it("marks csh-family shells as login via argv0 (-l must be their sole flag)", () => {
|
||||
expect(shellInvocation("/bin/tcsh", "cmd")).toEqual({
|
||||
args: ["-c", "cmd"],
|
||||
argv0: "-tcsh",
|
||||
});
|
||||
expect(shellInvocation("/bin/csh", "cmd")).toEqual({
|
||||
args: ["-c", "cmd"],
|
||||
argv0: "-csh",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveLoginShellPath", () => {
|
||||
it("captures PATH from the shell", async () => {
|
||||
const shell = writeFakeShell();
|
||||
await expect(resolveLoginShellPath(shell)).resolves.toBe(
|
||||
"/opt/homebrew/bin:/usr/bin",
|
||||
);
|
||||
});
|
||||
|
||||
it("reads PATH from the environment, not the shell's own expansion", async () => {
|
||||
// Mimics fish: its "$PATH" expansion would space-join the entries,
|
||||
// but the printf runs inside /bin/sh, which reads the exported
|
||||
// colon-delimited PATH env var — so the shell's expansion rules
|
||||
// never apply. This fake shell never evals the command text; it
|
||||
// only exports PATH and runs the command via sh, like fish would.
|
||||
const shell = writeFakeShell(
|
||||
'PATH="/opt/homebrew/bin:/usr/bin"; export PATH; /bin/sh -c "$4"',
|
||||
);
|
||||
await expect(resolveLoginShellPath(shell)).resolves.toBe(
|
||||
"/opt/homebrew/bin:/usr/bin",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves undefined when the shell prints garbage", async () => {
|
||||
const shell = writeFakeShell('echo "no markers"');
|
||||
await expect(resolveLoginShellPath(shell)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves undefined when the shell is missing", async () => {
|
||||
await expect(
|
||||
resolveLoginShellPath("/nonexistent/shell"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("times out hung shells without rejecting", async () => {
|
||||
const shell = writeFakeShell("sleep 60");
|
||||
await expect(resolveLoginShellPath(shell, 200)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("invokes csh-family shells without login/interactive flags", async () => {
|
||||
// A csh stand-in that rejects any first flag other than -c.
|
||||
const shell = writeFakeShell(
|
||||
'[ "$1" = "-c" ] || exit 64; PATH="/opt/homebrew/bin:/usr/bin"; eval "$2"',
|
||||
"tcsh",
|
||||
);
|
||||
await expect(resolveLoginShellPath(shell)).resolves.toBe(
|
||||
"/opt/homebrew/bin:/usr/bin",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureLoginShellPath", () => {
|
||||
it("merges the login shell PATH into env.PATH", async () => {
|
||||
const shell = writeFakeShell();
|
||||
const env: NodeJS.ProcessEnv = { PATH: "/usr/bin:/bin" };
|
||||
const result = await ensureLoginShellPath({
|
||||
platform: "darwin",
|
||||
env,
|
||||
userShell: shell,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
status: "applied",
|
||||
pathEntries: 3,
|
||||
shell,
|
||||
});
|
||||
expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin:/bin");
|
||||
});
|
||||
|
||||
it("falls back to the default shell when $SHELL can't resolve", async () => {
|
||||
const fallbackShell = writeFakeShell();
|
||||
const env: NodeJS.ProcessEnv = { PATH: "/usr/bin" };
|
||||
const result = await ensureLoginShellPath({
|
||||
platform: "darwin",
|
||||
env,
|
||||
userShell: "/nonexistent/shell",
|
||||
fallbackShell,
|
||||
});
|
||||
expect(result.status).toBe("applied");
|
||||
expect(result).toMatchObject({ shell: fallbackShell });
|
||||
expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
|
||||
});
|
||||
|
||||
it("leaves PATH untouched when every shell fails", async () => {
|
||||
const env: NodeJS.ProcessEnv = { PATH: "/usr/bin" };
|
||||
const result = await ensureLoginShellPath({
|
||||
platform: "darwin",
|
||||
env,
|
||||
userShell: "/nonexistent/shell",
|
||||
fallbackShell: "/nonexistent/other-shell",
|
||||
});
|
||||
expect(result).toEqual({ status: "failed", shell: "/nonexistent/shell" });
|
||||
expect(env.PATH).toBe("/usr/bin");
|
||||
});
|
||||
|
||||
it("skips on windows", async () => {
|
||||
const env: NodeJS.ProcessEnv = { PATH: "C:\\Windows" };
|
||||
const result = await ensureLoginShellPath({ platform: "win32", env });
|
||||
expect(result).toEqual({ status: "skipped", reason: "windows" });
|
||||
});
|
||||
|
||||
it("skips when the escape hatch is set", async () => {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
PATH: "/usr/bin",
|
||||
CLINE_SIDECAR_SKIP_SHELL_PATH: "1",
|
||||
};
|
||||
const result = await ensureLoginShellPath({ platform: "darwin", env });
|
||||
expect(result.status).toBe("skipped");
|
||||
expect(env.PATH).toBe("/usr/bin");
|
||||
});
|
||||
|
||||
it("never exposes the resolved PATH in its result", async () => {
|
||||
const shell = writeFakeShell();
|
||||
const env: NodeJS.ProcessEnv = { PATH: "/usr/bin" };
|
||||
const result = await ensureLoginShellPath({
|
||||
platform: "darwin",
|
||||
env,
|
||||
userShell: shell,
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain("/opt/homebrew/bin");
|
||||
});
|
||||
|
||||
it("resolves against a real shell end to end", async () => {
|
||||
const env: NodeJS.ProcessEnv = { PATH: "/bin" };
|
||||
const result = await ensureLoginShellPath({
|
||||
platform: "linux",
|
||||
env,
|
||||
userShell: "/bin/sh",
|
||||
});
|
||||
expect(result.status).toBe("applied");
|
||||
expect(env.PATH).toContain("/bin");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Login-shell PATH resolution for the desktop sidecar.
|
||||
*
|
||||
* When the Tauri app is launched from Finder/the Dock on macOS, it inherits
|
||||
* launchd's minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin) instead of the
|
||||
* user's shell PATH. The sidecar — and every process it spawns for the agent
|
||||
* (bash tool, MCP servers) — then can't find tools like `gh` that live in
|
||||
* /opt/homebrew/bin or other shell-profile-added directories, even though
|
||||
* the same task works from the CLI in a terminal.
|
||||
*
|
||||
* At startup we ask the user's login shell for its PATH and merge it into
|
||||
* process.env.PATH, so child processes see the same PATH a terminal would.
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { userInfo } from "node:os";
|
||||
import { basename, delimiter } from "node:path";
|
||||
|
||||
const PATH_MARKER_START = "__CLINE_SIDECAR_PATH_START__";
|
||||
const PATH_MARKER_END = "__CLINE_SIDECAR_PATH_END__";
|
||||
|
||||
/**
|
||||
* Kept well under the Tauri shell's 5s endpoint-readiness poll: this
|
||||
* resolution overlaps sidecar startup but is awaited before the server
|
||||
* starts, so a pathological shell profile must not eat the whole window.
|
||||
*/
|
||||
const SHELL_TIMEOUT_MS = 2_000;
|
||||
|
||||
/**
|
||||
* The command every shell is asked to run. $PATH expansion happens inside
|
||||
* POSIX sh — not the user's shell — so shells with different expansion rules
|
||||
* (fish would space-join "$PATH") still produce a colon-delimited value; sh
|
||||
* reads the PATH environment variable the login shell exported.
|
||||
*/
|
||||
const PRINT_PATH_COMMAND = `/bin/sh -c 'printf "%s%s%s" "${PATH_MARKER_START}" "$PATH" "${PATH_MARKER_END}"'`;
|
||||
|
||||
/**
|
||||
* Escape hatch: set CLINE_SIDECAR_SKIP_SHELL_PATH=1 to leave PATH untouched
|
||||
* (e.g. if a broken shell profile makes resolution misbehave).
|
||||
*/
|
||||
const SKIP_ENV_VAR = "CLINE_SIDECAR_SKIP_SHELL_PATH";
|
||||
|
||||
export function defaultShellFor(platform: NodeJS.Platform): string {
|
||||
return platform === "darwin" ? "/bin/zsh" : "/bin/bash";
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's configured login shell. The account database is authoritative:
|
||||
* a GUI-launched process has no parent shell, so $SHELL may be unset there.
|
||||
* userInfo() reads getpwuid(), which on macOS goes through DirectoryServices
|
||||
* — the same source `dscl . -read /Users/$USER UserShell` reports — and on
|
||||
* Linux resolves via NSS (/etc/passwd et al.). $SHELL and the platform
|
||||
* default are fallbacks for environments with no passwd entry.
|
||||
*/
|
||||
export function loginShellFor(
|
||||
platform: NodeJS.Platform,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): string {
|
||||
try {
|
||||
const shell = userInfo().shell?.trim();
|
||||
if (shell) {
|
||||
return shell;
|
||||
}
|
||||
} catch {
|
||||
// No passwd entry for the current uid (some containers) — fall through.
|
||||
}
|
||||
return env.SHELL?.trim() || defaultShellFor(platform);
|
||||
}
|
||||
|
||||
export interface ShellInvocation {
|
||||
args: string[];
|
||||
/**
|
||||
* argv[0] the shell should see. A leading dash is the historical "you
|
||||
* are a login shell" signal, used where -l can't be passed as a flag.
|
||||
*/
|
||||
argv0?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* How to invoke a shell so it sources its profiles and runs a command.
|
||||
* csh/tcsh accept -l only as the sole flag, so they're marked login via the
|
||||
* argv[0] dash convention instead (sources ~/.login on top of the always-read
|
||||
* ~/.cshrc or ~/.tcshrc); everything else gets login (-l, ~/.zprofile —
|
||||
* Homebrew's shellenv) plus interactive (-i, ~/.zshrc — nvm-style version
|
||||
* managers) as separate flags.
|
||||
*/
|
||||
export function shellInvocation(
|
||||
shell: string,
|
||||
command: string,
|
||||
): ShellInvocation {
|
||||
const kind = basename(shell);
|
||||
if (kind === "csh" || kind === "tcsh") {
|
||||
return { args: ["-c", command], argv0: `-${kind}` };
|
||||
}
|
||||
return { args: ["-i", "-l", "-c", command] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the PATH value printed between the sentinel markers, ignoring any
|
||||
* noise a shell profile writes to stdout around it.
|
||||
*/
|
||||
export function extractMarkedPath(output: string): string | undefined {
|
||||
const start = output.indexOf(PATH_MARKER_START);
|
||||
if (start === -1) {
|
||||
return undefined;
|
||||
}
|
||||
const end = output.indexOf(PATH_MARKER_END, start);
|
||||
if (end === -1) {
|
||||
return undefined;
|
||||
}
|
||||
const value = output.slice(start + PATH_MARKER_START.length, end).trim();
|
||||
return value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the login shell's PATH with the current one: shell entries first (so
|
||||
* profile-managed dirs like /opt/homebrew/bin win), then any current entries
|
||||
* the shell PATH doesn't already contain (so explicitly-injected dirs from
|
||||
* the launching environment aren't lost). Duplicates are dropped.
|
||||
*/
|
||||
export function mergePaths(shellPath: string, currentPath: string): string {
|
||||
const entries = [
|
||||
...shellPath.split(delimiter),
|
||||
...currentPath.split(delimiter),
|
||||
]
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
return Array.from(new Set(entries)).join(delimiter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the user's shell with its profiles sourced and capture its PATH.
|
||||
* Resolves to undefined on any failure (missing shell, timeout, profile
|
||||
* error) — callers should treat that as "keep the current PATH".
|
||||
*/
|
||||
export function resolveLoginShellPath(
|
||||
shell: string,
|
||||
timeoutMs = SHELL_TIMEOUT_MS,
|
||||
): Promise<string | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
const invocation = shellInvocation(shell, PRINT_PATH_COMMAND);
|
||||
const child = spawn(shell, invocation.args, {
|
||||
argv0: invocation.argv0,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
detached: true,
|
||||
});
|
||||
|
||||
let output = "";
|
||||
let settled = false;
|
||||
const settle = (value: string | undefined) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
try {
|
||||
if (child.pid) {
|
||||
process.kill(-child.pid, "SIGKILL");
|
||||
}
|
||||
} catch {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
settle(undefined);
|
||||
}, timeoutMs);
|
||||
|
||||
child.stdout?.on("data", (data: Buffer) => {
|
||||
output += data.toString("utf8");
|
||||
});
|
||||
child.on("error", () => settle(undefined));
|
||||
child.on("close", () => settle(extractMarkedPath(output)));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the login shell's PATH and merge it into process.env.PATH. The
|
||||
* shell comes from the account database (see loginShellFor); if it can't
|
||||
* produce a PATH (exotic shell, broken profile), retry once with the
|
||||
* platform default shell before giving up.
|
||||
*
|
||||
* No-op on Windows (the GUI PATH comes from the registry there) and when
|
||||
* CLINE_SIDECAR_SKIP_SHELL_PATH is set. Failures are reported via the
|
||||
* returned status but never block startup. The result never contains the
|
||||
* resolved PATH itself so it is safe to log verbatim.
|
||||
*/
|
||||
export async function ensureLoginShellPath(options?: {
|
||||
platform?: NodeJS.Platform;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
timeoutMs?: number;
|
||||
/** Test seam: overrides passwd/$SHELL discovery of the user's shell. */
|
||||
userShell?: string;
|
||||
/** Test seam: overrides the platform-default fallback shell. */
|
||||
fallbackShell?: string;
|
||||
}): Promise<
|
||||
| { status: "applied"; pathEntries: number; shell: string }
|
||||
| { status: "skipped"; reason: string }
|
||||
| { status: "failed"; shell: string }
|
||||
> {
|
||||
const platform = options?.platform ?? process.platform;
|
||||
const env = options?.env ?? process.env;
|
||||
|
||||
if (platform === "win32") {
|
||||
return { status: "skipped", reason: "windows" };
|
||||
}
|
||||
if (env[SKIP_ENV_VAR]?.trim()) {
|
||||
return { status: "skipped", reason: SKIP_ENV_VAR };
|
||||
}
|
||||
|
||||
const userShell = options?.userShell ?? loginShellFor(platform, env);
|
||||
const fallbackShell = options?.fallbackShell ?? defaultShellFor(platform);
|
||||
const baseTimeoutMs = options?.timeoutMs ?? SHELL_TIMEOUT_MS;
|
||||
// The fallback gets half the budget so the combined worst case stays
|
||||
// bounded even when both shells hang (see SHELL_TIMEOUT_MS).
|
||||
const attempts: Array<[shell: string, timeoutMs: number]> =
|
||||
userShell === fallbackShell
|
||||
? [[userShell, baseTimeoutMs]]
|
||||
: [
|
||||
[userShell, baseTimeoutMs],
|
||||
[fallbackShell, baseTimeoutMs / 2],
|
||||
];
|
||||
|
||||
for (const [shell, timeoutMs] of attempts) {
|
||||
const shellPath = await resolveLoginShellPath(shell, timeoutMs);
|
||||
if (!shellPath) {
|
||||
continue;
|
||||
}
|
||||
const merged = mergePaths(shellPath, env.PATH ?? "");
|
||||
env.PATH = merged;
|
||||
return {
|
||||
status: "applied",
|
||||
pathEntries: merged.split(delimiter).length,
|
||||
shell,
|
||||
};
|
||||
}
|
||||
return { status: "failed", shell: userShell };
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import type {
|
||||
AgentToolContext,
|
||||
BasicLogger,
|
||||
ClineCore,
|
||||
HubServer,
|
||||
ITelemetryService,
|
||||
NodeHubClient,
|
||||
ToolApprovalResult,
|
||||
} from "@cline/core";
|
||||
@@ -41,6 +42,7 @@ export type PromptInQueue = {
|
||||
prompt: string;
|
||||
steer: boolean;
|
||||
attachmentCount?: number;
|
||||
userImages?: string[];
|
||||
};
|
||||
|
||||
export type LiveSession = {
|
||||
@@ -51,9 +53,14 @@ export type LiveSession = {
|
||||
startedAt: number;
|
||||
endedAt?: number;
|
||||
status: string;
|
||||
transitioningProvider?: boolean;
|
||||
prompt?: string;
|
||||
title?: string;
|
||||
attachedViaHub?: boolean;
|
||||
/** Materialized attachment files for prompts still waiting in the queue. */
|
||||
queuedAttachmentFiles?: Map<string, string[]>;
|
||||
/** Materialized attachment files whose prompt was submitted; deleted when the turn ends. */
|
||||
consumedAttachmentFiles?: Map<string, string[]>;
|
||||
};
|
||||
|
||||
export type ToolApprovalRequestItem = {
|
||||
@@ -104,8 +111,9 @@ export type SidecarContext = {
|
||||
pendingQuestions: Map<string, PendingAskQuestion>;
|
||||
sessionManager: ClineCore | null;
|
||||
hubClient: NodeHubClient | null;
|
||||
hubServer: HubServer | null;
|
||||
workspaceRoot: string;
|
||||
logger?: BasicLogger;
|
||||
telemetry?: ITelemetryService;
|
||||
unsubscribeSessionEvents: (() => void) | null;
|
||||
};
|
||||
export type BunRuntimeApi = {
|
||||
|
||||
+363
@@ -47,6 +47,15 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ashpd"
|
||||
version = "0.11.1"
|
||||
@@ -504,11 +513,16 @@ dependencies = [
|
||||
name = "cline-app"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-updater",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -718,6 +732,17 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.1.1"
|
||||
@@ -1017,6 +1042,16 @@ dependencies = [
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
@@ -1579,6 +1614,21 @@ dependencies = [
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
@@ -2016,6 +2066,12 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2143,9 +2199,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-cloud-kit",
|
||||
"objc2-core-data",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-core-image",
|
||||
"objc2-core-text",
|
||||
"objc2-core-video",
|
||||
"objc2-foundation",
|
||||
"objc2-quartz-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2165,6 +2229,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
@@ -2225,6 +2290,19 @@ dependencies = [
|
||||
"objc2-core-graphics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-video"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-io-surface",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-encode"
|
||||
version = "4.1.0"
|
||||
@@ -2248,6 +2326,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
@@ -2263,6 +2342,18 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-osa-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-quartz-core"
|
||||
version = "0.3.2"
|
||||
@@ -2326,6 +2417,12 @@ version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@@ -2342,6 +2439,20 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "osakit"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"objc2-osa-kit",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
version = "0.18.3"
|
||||
@@ -2796,15 +2907,20 @@ dependencies = [
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -2840,6 +2956,20 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
@@ -2868,6 +2998,79 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.42"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
|
||||
dependencies = [
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"jni",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier-android"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
@@ -2883,6 +3086,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.8.22"
|
||||
@@ -2946,6 +3158,29 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.36.1"
|
||||
@@ -3277,6 +3512,12 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "swift-rs"
|
||||
version = "1.0.7"
|
||||
@@ -3393,6 +3634,17 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.12.16"
|
||||
@@ -3512,6 +3764,55 @@ dependencies = [
|
||||
"tauri-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eefb2c18e8a605c23edb48fc56bb77381199e1a1e7f6ff0c9b970afe7b3cb8ee"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"glob",
|
||||
"plist",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri-utils",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-updater"
|
||||
version = "2.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"dirs",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"http",
|
||||
"infer",
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"osakit",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tar",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tokio",
|
||||
"url",
|
||||
"windows-sys 0.60.2",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.11.1"
|
||||
@@ -3730,6 +4031,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
@@ -4025,6 +4336,12 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -4387,6 +4704,15 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.38.2"
|
||||
@@ -4617,6 +4943,15 @@ dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
@@ -5035,6 +5370,16 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.1"
|
||||
@@ -5160,6 +5505,12 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.3"
|
||||
@@ -5193,6 +5544,18 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"indexmap 2.13.0",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
|
||||
@@ -10,8 +10,15 @@ 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"
|
||||
|
||||
[target."cfg(target_os = \"macos\")".dependencies]
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSImage", "NSResponder"] }
|
||||
objc2-foundation = { version = "0.3", features = ["NSString"] }
|
||||
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -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,111 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// Lock order: `process` may be held while acquiring `ws_endpoint`, never
|
||||
/// the reverse. Anything that touches both (including stop()) must either
|
||||
/// nest in that order or take them strictly sequentially.
|
||||
#[derive(Default)]
|
||||
struct DesktopBackendState {
|
||||
ws_endpoint: Mutex<Option<String>>,
|
||||
@@ -44,7 +153,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)),
|
||||
@@ -236,43 +349,7 @@ fn resolve_desktop_backend_binary_path(context: &AppContext) -> Option<PathBuf>
|
||||
candidates.into_iter().find(|path| path.exists())
|
||||
}
|
||||
|
||||
fn ensure_desktop_backend_started(
|
||||
state: &Arc<DesktopBackendState>,
|
||||
context: &AppContext,
|
||||
) -> Result<(), String> {
|
||||
if state.is_shutting_down() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
{
|
||||
let mut process_guard = state
|
||||
.process
|
||||
.lock()
|
||||
.map_err(|_| "failed to lock desktop backend process state")?;
|
||||
if let Some(existing) = process_guard.as_mut() {
|
||||
match existing.try_wait() {
|
||||
Ok(None) => {
|
||||
if state
|
||||
.ws_endpoint
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|value| value.as_ref().cloned())
|
||||
.map(|value| !value.trim().is_empty())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Ok(Some(_)) | Err(_) => {
|
||||
*process_guard = None;
|
||||
if let Ok(mut endpoint_guard) = state.ws_endpoint.lock() {
|
||||
*endpoint_guard = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_desktop_backend_process(context: &AppContext) -> Result<Child, String> {
|
||||
let mut command = if let Some(binary_path) = resolve_desktop_backend_binary_path(context) {
|
||||
let mut command = Command::new(binary_path);
|
||||
command.current_dir(&context.workspace_root);
|
||||
@@ -291,12 +368,54 @@ fn ensure_desktop_backend_started(
|
||||
));
|
||||
};
|
||||
|
||||
let mut child = command
|
||||
command
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("failed to start desktop backend sidecar: {e}"))?;
|
||||
.map_err(|e| format!("failed to start desktop backend sidecar: {e}"))
|
||||
}
|
||||
|
||||
fn ensure_desktop_backend_started(
|
||||
state: &Arc<DesktopBackendState>,
|
||||
context: &AppContext,
|
||||
) -> Result<(), String> {
|
||||
ensure_desktop_backend_started_with(state, || spawn_desktop_backend_process(context))
|
||||
}
|
||||
|
||||
fn ensure_desktop_backend_started_with(
|
||||
state: &Arc<DesktopBackendState>,
|
||||
spawn_backend: impl FnOnce() -> Result<Child, String>,
|
||||
) -> Result<(), String> {
|
||||
if state.is_shutting_down() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Hold the process lock for the entire check-and-spawn so concurrent
|
||||
// callers (setup, the health-check loop, endpoint fetches from the
|
||||
// webview) serialize: the second caller blocks here, then sees the live
|
||||
// child and returns instead of spawning a duplicate.
|
||||
let mut process_guard = state
|
||||
.process
|
||||
.lock()
|
||||
.map_err(|_| "failed to lock desktop backend process state")?;
|
||||
if let Some(existing) = process_guard.as_mut() {
|
||||
match existing.try_wait() {
|
||||
// A live child owns startup even while its endpoint is still
|
||||
// pending (login-shell PATH resolution plus session-manager init
|
||||
// take a few seconds). Spawning again here would orphan it and
|
||||
// race on the port.
|
||||
Ok(None) => return Ok(()),
|
||||
Ok(Some(_)) | Err(_) => {
|
||||
*process_guard = None;
|
||||
if let Ok(mut endpoint_guard) = state.ws_endpoint.lock() {
|
||||
*endpoint_guard = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut child = spawn_backend()?;
|
||||
|
||||
let stdout = child
|
||||
.stdout
|
||||
@@ -307,6 +426,7 @@ fn ensure_desktop_backend_started(
|
||||
.take()
|
||||
.ok_or_else(|| "failed to capture desktop backend stderr".to_string())?;
|
||||
|
||||
let child_pid = child.id();
|
||||
let state_for_stdout = state.clone();
|
||||
thread::spawn(move || {
|
||||
let mut reader = BufReader::new(stdout);
|
||||
@@ -335,8 +455,19 @@ fn ensure_desktop_backend_started(
|
||||
}
|
||||
eprintln!("[desktop-backend] {trimmed}");
|
||||
}
|
||||
if let Ok(mut endpoint_guard) = state_for_stdout.ws_endpoint.lock() {
|
||||
*endpoint_guard = None;
|
||||
// Only clear the endpoint if this thread's child is still the one
|
||||
// being tracked — a late EOF from a replaced child must not wipe the
|
||||
// endpoint its successor already published.
|
||||
let owns_tracked_child = state_for_stdout
|
||||
.process
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|guard| guard.as_ref().map(|child| child.id()) == Some(child_pid))
|
||||
.unwrap_or(false);
|
||||
if owns_tracked_child {
|
||||
if let Ok(mut endpoint_guard) = state_for_stdout.ws_endpoint.lock() {
|
||||
*endpoint_guard = None;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -352,10 +483,6 @@ fn ensure_desktop_backend_started(
|
||||
}
|
||||
});
|
||||
|
||||
let mut process_guard = state
|
||||
.process
|
||||
.lock()
|
||||
.map_err(|_| "failed to lock desktop backend process state")?;
|
||||
*process_guard = Some(child);
|
||||
Ok(())
|
||||
}
|
||||
@@ -423,7 +550,13 @@ fn get_desktop_backend_endpoint(
|
||||
context: State<'_, AppContext>,
|
||||
) -> Result<String, String> {
|
||||
ensure_desktop_backend_started(backend_state.inner(), context.inner())?;
|
||||
for _ in 0..50 {
|
||||
// Sidecar startup includes login-shell PATH resolution (bounded at 3s,
|
||||
// see sidecar/shell-path.ts) plus session-manager init, whose duration
|
||||
// varies by machine. Poll well past that combined worst case; the loop
|
||||
// returns as soon as the ready line arrives, so only failure waits long.
|
||||
// While pending this only waits — respawning is ensure's job, and it
|
||||
// refuses to start a second sidecar while the first one is still alive.
|
||||
for _ in 0..150 {
|
||||
if let Some(endpoint) = backend_state
|
||||
.ws_endpoint
|
||||
.lock()
|
||||
@@ -433,6 +566,18 @@ fn get_desktop_backend_endpoint(
|
||||
{
|
||||
return Ok(endpoint);
|
||||
}
|
||||
let child_exited = backend_state
|
||||
.process
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|mut guard| match guard.as_mut() {
|
||||
Some(child) => !matches!(child.try_wait(), Ok(None)),
|
||||
None => true,
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if child_exited {
|
||||
return Err("desktop backend exited before publishing its endpoint".to_string());
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
Err("desktop backend endpoint not ready".to_string())
|
||||
@@ -452,6 +597,79 @@ 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();
|
||||
}
|
||||
|
||||
/// Icon ids accepted by `set_app_icon`; kept in sync with APP_ICONS in
|
||||
/// webview/lib/app-icon.ts. Every non-default id has a matching bundled
|
||||
/// resource at icons/dock/<id>.png.
|
||||
const APP_DOCK_ICONS: [&str; 4] = ["classic", "sunrise", "steel", "midnight"];
|
||||
|
||||
#[tauri::command]
|
||||
fn set_app_icon(app: tauri::AppHandle, icon: String) -> Result<bool, String> {
|
||||
if !APP_DOCK_ICONS.contains(&icon.as_str()) {
|
||||
return Err(format!("unknown app icon: {icon}"));
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// "classic" also ships as a dock resource, so every choice loads the
|
||||
// same way; setApplicationIconImage's binding warns that passing nil
|
||||
// to restore the bundled icon may not be allowed.
|
||||
let icon_path = app
|
||||
.path()
|
||||
.resolve(
|
||||
format!("icons/dock/{icon}.png"),
|
||||
tauri::path::BaseDirectory::Resource,
|
||||
)
|
||||
.map_err(|e| format!("failed resolving dock icon resource: {e}"))?;
|
||||
if !icon_path.exists() {
|
||||
return Err(format!(
|
||||
"dock icon resource missing: {}",
|
||||
icon_path.display()
|
||||
));
|
||||
}
|
||||
app.run_on_main_thread(move || {
|
||||
use objc2::{AllocAnyThread, MainThreadMarker};
|
||||
use objc2_app_kit::{NSApplication, NSImage};
|
||||
use objc2_foundation::NSString;
|
||||
|
||||
let Some(mtm) = MainThreadMarker::new() else {
|
||||
return;
|
||||
};
|
||||
let ns_app = NSApplication::sharedApplication(mtm);
|
||||
let Some(image) = NSImage::initWithContentsOfFile(
|
||||
NSImage::alloc(),
|
||||
&NSString::from_str(&icon_path.to_string_lossy()),
|
||||
) else {
|
||||
eprintln!("[dock-icon] failed loading image: {}", icon_path.display());
|
||||
return;
|
||||
};
|
||||
// SAFETY: called on the main thread with a valid, non-nil image.
|
||||
unsafe { ns_app.setApplicationIconImage(Some(&image)) };
|
||||
})
|
||||
.map_err(|e| format!("failed switching dock icon: {e}"))?;
|
||||
Ok(true)
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let _ = app;
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_mcp_settings_file() -> Result<String, String> {
|
||||
let settings_path = resolve_mcp_settings_path()?;
|
||||
@@ -485,14 +703,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 +736,10 @@ 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,
|
||||
set_app_icon
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri app")
|
||||
@@ -521,3 +753,110 @@ fn main() {
|
||||
_ => {}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
/// A stand-in sidecar that stays alive without ever publishing a ready
|
||||
/// line — the endpoint-pending startup window that used to trigger
|
||||
/// duplicate spawns.
|
||||
fn spawn_pending_sidecar() -> Result<Child, String> {
|
||||
Command::new("sleep")
|
||||
.arg("30")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_startup_checks_reuse_live_child_while_endpoint_pending() {
|
||||
let state = Arc::new(DesktopBackendState::default());
|
||||
let spawn_count = AtomicUsize::new(0);
|
||||
for _ in 0..3 {
|
||||
ensure_desktop_backend_started_with(&state, || {
|
||||
spawn_count.fetch_add(1, Ordering::SeqCst);
|
||||
spawn_pending_sidecar()
|
||||
})
|
||||
.expect("startup check should succeed");
|
||||
}
|
||||
assert_eq!(spawn_count.load(Ordering::SeqCst), 1);
|
||||
// Kill the fake sidecar directly so stop() doesn't wait out its
|
||||
// graceful-exit window.
|
||||
if let Ok(mut guard) = state.process.lock() {
|
||||
if let Some(child) = guard.as_mut() {
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
state.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_startup_checks_spawn_exactly_one_child() {
|
||||
let state = Arc::new(DesktopBackendState::default());
|
||||
let spawn_count = Arc::new(AtomicUsize::new(0));
|
||||
let handles: Vec<_> = (0..8)
|
||||
.map(|_| {
|
||||
let state = state.clone();
|
||||
let spawn_count = spawn_count.clone();
|
||||
thread::spawn(move || {
|
||||
ensure_desktop_backend_started_with(&state, || {
|
||||
spawn_count.fetch_add(1, Ordering::SeqCst);
|
||||
spawn_pending_sidecar()
|
||||
})
|
||||
.expect("startup check should succeed");
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for handle in handles {
|
||||
handle.join().expect("startup thread should not panic");
|
||||
}
|
||||
assert_eq!(spawn_count.load(Ordering::SeqCst), 1);
|
||||
if let Ok(mut guard) = state.process.lock() {
|
||||
if let Some(child) = guard.as_mut() {
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
state.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exited_child_is_replaced_on_next_startup_check() {
|
||||
let state = Arc::new(DesktopBackendState::default());
|
||||
let spawn_count = AtomicUsize::new(0);
|
||||
let spawn_exiting = || {
|
||||
spawn_count.fetch_add(1, Ordering::SeqCst);
|
||||
Command::new("true")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())
|
||||
};
|
||||
ensure_desktop_backend_started_with(&state, || spawn_exiting())
|
||||
.expect("startup check should succeed");
|
||||
// Wait for the first child to exit so the next check sees a dead one.
|
||||
for _ in 0..100 {
|
||||
let exited = state
|
||||
.process
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|mut guard| match guard.as_mut() {
|
||||
Some(child) => !matches!(child.try_wait(), Ok(None)),
|
||||
None => true,
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if exited {
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
ensure_desktop_backend_started_with(&state, || spawn_exiting())
|
||||
.expect("startup check should succeed");
|
||||
assert_eq!(spawn_count.load(Ordering::SeqCst), 2);
|
||||
state.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Cline Code",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.4",
|
||||
"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": [
|
||||
{
|
||||
@@ -16,7 +24,10 @@
|
||||
"title": "Cline Code",
|
||||
"width": 1500,
|
||||
"height": 980,
|
||||
"resizable": true
|
||||
"resizable": true,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true,
|
||||
"dragDropEnabled": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
@@ -27,6 +38,7 @@
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"externalBin": ["bin/code-sidecar"],
|
||||
"resources": ["icons/dock/*.png"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"bundle": {
|
||||
"createUpdaterArtifacts": true
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@
|
||||
"@cline/agents": ["../../../sdk/packages/agents/src/index.ts"],
|
||||
"@cline/core": ["../../../sdk/packages/core/src/index.ts"],
|
||||
"@cline/core/hub": ["../../../sdk/packages/core/src/hub/index.ts"],
|
||||
"@cline/core/hub/daemon-entry": [
|
||||
"../../../sdk/packages/core/src/hub/daemon/entry.ts"
|
||||
],
|
||||
"@cline/llms": ["../../../sdk/packages/llms/src/index.ts"],
|
||||
"@cline/shared": ["../../../sdk/packages/shared/src/index.ts"],
|
||||
"@cline/shared/storage": [
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
"@cline/agents": ["../../../sdk/packages/agents/src/index.ts"],
|
||||
"@cline/core": ["../../../sdk/packages/core/src/index.ts"],
|
||||
"@cline/core/hub": ["../../../sdk/packages/core/src/hub/index.ts"],
|
||||
"@cline/core/hub/daemon-entry": [
|
||||
"../../../sdk/packages/core/src/hub/daemon/entry.ts"
|
||||
],
|
||||
"@cline/llms": ["../../../sdk/packages/llms/src/index.ts"],
|
||||
"@cline/shared": ["../../../sdk/packages/shared/src/index.ts"],
|
||||
"@cline/shared/storage": [
|
||||
|
||||
@@ -20,6 +20,208 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Chat Markdown (components/ui/markdown.tsx). Streamdown ships its defaults
|
||||
* as Tailwind utilities, which land in the layered @source output; these
|
||||
* unlayered rules win the cascade without needing !important.
|
||||
*/
|
||||
|
||||
/* Chat panes are narrow and the base text is --text-sm: tighten the rhythm
|
||||
* between top-level blocks from Streamdown's document-scale 1rem. */
|
||||
.cline-markdown > :not(:last-child) {
|
||||
margin-block-end: 0.75rem;
|
||||
}
|
||||
|
||||
/* Chat-scale heading ramp (defaults go up to text-3xl, huge next to 14px body). */
|
||||
.cline-markdown :is(h1, h2, h3, h4, h5, h6) {
|
||||
margin-block: 1.25rem 0.5rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.cline-markdown h1 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.cline-markdown h2 {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.cline-markdown h3 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.cline-markdown :is(h4, h5, h6) {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.cline-markdown hr {
|
||||
margin-block: 1rem;
|
||||
}
|
||||
|
||||
/* Outside markers so wrapped list lines align with their first line, not the
|
||||
* bullet (Streamdown uses list-inside). */
|
||||
.cline-markdown :is(ul, ol) {
|
||||
list-style-position: outside;
|
||||
padding-inline-start: 1.375rem;
|
||||
}
|
||||
|
||||
.cline-markdown li {
|
||||
padding-block: 0.125rem;
|
||||
}
|
||||
|
||||
.cline-markdown li::marker {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.cline-markdown [data-streamdown="inline-code"] {
|
||||
padding: 0.1em 0.35em;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
/*
|
||||
* Code blocks: Streamdown renders a double box — an outer sidebar-tinted card
|
||||
* holding a language header row plus an inner bordered body. Collapse it to a
|
||||
* single quiet block: no outer chrome, no language label, and the copy button
|
||||
* only appears while hovering the block.
|
||||
*/
|
||||
.cline-markdown [data-streamdown="code-block"] {
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.cline-markdown [data-streamdown="code-block-header"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cline-markdown [data-streamdown="code-block-body"] {
|
||||
border-radius: 0.5rem;
|
||||
background: var(--card);
|
||||
padding: 0.625rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* The body's <pre> paints the Shiki theme background, which never quite
|
||||
* matches the app theme; let the body's --card background show instead. */
|
||||
.cline-markdown [data-streamdown="code-block-body"] pre {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* The actions overlay's -mt-10 was sized to cover the (now hidden) header
|
||||
* row; re-anchor it over the top-right of the code body and reveal on
|
||||
* hover/focus so it stops competing with the code. Stickiness is kept so the
|
||||
* copy button stays reachable inside long blocks. */
|
||||
.cline-markdown [data-streamdown="code-block"]
|
||||
> div:has(> [data-streamdown="code-block-actions"]) {
|
||||
margin-block: 0 -2rem;
|
||||
padding: 0.375rem 0.375rem 0 0;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
/* While hidden the pill must not intercept clicks or text selection on the
|
||||
* code beneath it (it ships with pointer-events-auto). Keyboard focus is
|
||||
* unaffected by pointer-events, so tabbing still reveals it. */
|
||||
.cline-markdown [data-streamdown="code-block-actions"] {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cline-markdown
|
||||
[data-streamdown="code-block"]:hover
|
||||
> div:has(> [data-streamdown="code-block-actions"]),
|
||||
.cline-markdown
|
||||
[data-streamdown="code-block"]
|
||||
> div:focus-within:has(> [data-streamdown="code-block-actions"]) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.cline-markdown
|
||||
[data-streamdown="code-block"]:hover
|
||||
[data-streamdown="code-block-actions"],
|
||||
.cline-markdown
|
||||
[data-streamdown="code-block"]
|
||||
> div:focus-within
|
||||
[data-streamdown="code-block-actions"] {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.cline-markdown
|
||||
[data-streamdown="code-block"]
|
||||
> div:has(> [data-streamdown="code-block-actions"]) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.cline-markdown [data-streamdown="code-block-actions"] {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tables get the same single-box treatment as code blocks. */
|
||||
.cline-markdown [data-streamdown="table-wrapper"] {
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
/* Aurora background (components/ui/aurora-bg.tsx) */
|
||||
@keyframes aurora-drift {
|
||||
0% {
|
||||
@@ -160,3 +362,84 @@
|
||||
will-change: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Accent palettes (Settings -> General -> Accent color). The default
|
||||
* "violet" accent is the @cline/ui brand tokens untouched; each block below
|
||||
* re-anchors the interactive tokens on the html[data-cline-accent] attribute
|
||||
* set by lib/theme.ts. Values follow the brand token pattern: emphasis is a
|
||||
* pressed/hover shade of primary, ring is a softer focus shade, and dark
|
||||
* mode lifts lightness so the accent stays legible on dark surfaces.
|
||||
* Tokens like --chart-1 and --sidebar-primary alias var(--primary), so they
|
||||
* follow automatically.
|
||||
*/
|
||||
|
||||
:root[data-cline-accent="graphite"] {
|
||||
--primary: oklch(0.27 0.012 248);
|
||||
--primary-foreground: oklch(0.99 0 0);
|
||||
--primary-emphasis: oklch(0.2 0.012 248);
|
||||
--ring: oklch(0.55 0.012 248);
|
||||
}
|
||||
|
||||
:root.dark[data-cline-accent="graphite"] {
|
||||
--primary: oklch(0.92 0.006 248);
|
||||
--primary-foreground: oklch(0.18 0.01 248);
|
||||
--primary-emphasis: oklch(0.97 0.004 248);
|
||||
--ring: oklch(0.7 0.008 248);
|
||||
}
|
||||
|
||||
:root[data-cline-accent="cyan"] {
|
||||
--primary: oklch(0.6 0.12 222);
|
||||
--primary-foreground: oklch(0.99 0 0);
|
||||
--primary-emphasis: oklch(0.52 0.13 222);
|
||||
--ring: oklch(0.66 0.1 222);
|
||||
}
|
||||
|
||||
:root.dark[data-cline-accent="cyan"] {
|
||||
--primary: oklch(0.7 0.12 222);
|
||||
--primary-foreground: oklch(0.16 0.02 222);
|
||||
--primary-emphasis: oklch(0.77 0.11 222);
|
||||
--ring: oklch(0.7 0.1 222);
|
||||
}
|
||||
|
||||
:root[data-cline-accent="pink"] {
|
||||
--primary: oklch(0.75 0.1 354);
|
||||
--primary-foreground: oklch(0.28 0.08 354);
|
||||
--primary-emphasis: oklch(0.68 0.12 354);
|
||||
--ring: oklch(0.79 0.08 354);
|
||||
}
|
||||
|
||||
:root.dark[data-cline-accent="pink"] {
|
||||
--primary: oklch(0.78 0.1 354);
|
||||
--primary-foreground: oklch(0.25 0.07 354);
|
||||
--primary-emphasis: oklch(0.84 0.08 354);
|
||||
--ring: oklch(0.78 0.08 354);
|
||||
}
|
||||
|
||||
:root[data-cline-accent="espresso"] {
|
||||
--primary: oklch(0.36 0.035 35);
|
||||
--primary-foreground: oklch(0.98 0.005 35);
|
||||
--primary-emphasis: oklch(0.29 0.035 35);
|
||||
--ring: oklch(0.55 0.03 35);
|
||||
}
|
||||
|
||||
:root.dark[data-cline-accent="espresso"] {
|
||||
--primary: oklch(0.72 0.045 45);
|
||||
--primary-foreground: oklch(0.22 0.03 40);
|
||||
--primary-emphasis: oklch(0.79 0.04 45);
|
||||
--ring: oklch(0.72 0.04 45);
|
||||
}
|
||||
|
||||
:root[data-cline-accent="ember"] {
|
||||
--primary: oklch(0.6 0.19 33);
|
||||
--primary-foreground: oklch(0.99 0 0);
|
||||
--primary-emphasis: oklch(0.52 0.19 33);
|
||||
--ring: oklch(0.66 0.16 33);
|
||||
}
|
||||
|
||||
:root.dark[data-cline-accent="ember"] {
|
||||
--primary: oklch(0.7 0.17 36);
|
||||
--primary-foreground: oklch(0.17 0.03 33);
|
||||
--primary-emphasis: oklch(0.77 0.15 36);
|
||||
--ring: oklch(0.7 0.14 36);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ImagePlus } from "lucide-react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { AgentHeader } from "@/components/agent-header";
|
||||
import { AgentSidebar } from "@/components/agent-sidebar";
|
||||
import {
|
||||
@@ -24,25 +32,42 @@ import { ChatInputBar } from "@/components/views/chat/chat-input-bar";
|
||||
import { ChatMessages } from "@/components/views/chat/chat-messages";
|
||||
import { DiffView } from "@/components/views/chat/diff-view";
|
||||
import { WelcomeScreen } from "@/components/views/chat/welcome-chat";
|
||||
import { OnboardingView } from "@/components/views/onboarding/onboarding-view";
|
||||
import { SessionsView } from "@/components/views/sessions/sessions-view";
|
||||
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 { syncAppIcon } from "@/lib/app-icon";
|
||||
import type { ChatSessionConfig } from "@/lib/chat-schema";
|
||||
import {
|
||||
createDesktopAppState,
|
||||
type DesktopAppLocation,
|
||||
type DesktopAppView,
|
||||
desktopAppReducer,
|
||||
} from "@/lib/desktop-app-state";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import { syncDesktopWindowTitle } from "@/lib/desktop-window-title";
|
||||
import {
|
||||
hasCompletedOnboarding,
|
||||
markOnboardingCompleted,
|
||||
ONBOARDING_RESET_EVENT,
|
||||
} from "@/lib/onboarding";
|
||||
import {
|
||||
getSessionMetadataTitle,
|
||||
type SessionHistoryItem,
|
||||
type SessionMetadata,
|
||||
} from "@/lib/session-history";
|
||||
import { syncHubTheme, watchSystemHubTheme } from "@/lib/theme";
|
||||
import { syncHubAccent, syncHubTheme, watchSystemHubTheme } from "@/lib/theme";
|
||||
import {
|
||||
filterWorkspacePaths,
|
||||
mergeWorkspacePaths,
|
||||
normalizeWorkspacePath,
|
||||
readWorkspaceSelectionFromWindow,
|
||||
@@ -54,11 +79,7 @@ function makeThreadId(): string {
|
||||
return `thread_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
}
|
||||
|
||||
type Thread = {
|
||||
id: string;
|
||||
historySession?: SessionHistoryItem;
|
||||
hasStarted?: boolean;
|
||||
};
|
||||
type AppLocation = DesktopAppLocation<SettingsSection>;
|
||||
|
||||
function toThreadTitle(options: { title?: string; prompt?: string }): string {
|
||||
const preferredTitle = options.title?.trim();
|
||||
@@ -71,101 +92,91 @@ function toThreadTitle(options: { title?: string; prompt?: string }): string {
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [view, setView] = useState<"chat" | "sessions" | "settings">("chat");
|
||||
const [settingsSection, setSettingsSection] =
|
||||
useState<SettingsSection>("General");
|
||||
const [threads, setThreads] = useState<Thread[]>(() => [
|
||||
{ id: makeThreadId() },
|
||||
]);
|
||||
const [activeThreadId, setActiveThreadId] = useState<string>(
|
||||
() => threads[0]?.id,
|
||||
const [initialThreadId] = useState(makeThreadId);
|
||||
const [appState, dispatchApp] = useReducer(
|
||||
desktopAppReducer<SettingsSection>,
|
||||
initialThreadId,
|
||||
(threadId) => createDesktopAppState(threadId, "General"),
|
||||
);
|
||||
// Starts false on both server and first client render (hydration-safe);
|
||||
// the effect below reads the persisted state right after mount.
|
||||
const [showOnboarding, setShowOnboarding] = useState(false);
|
||||
const { navigation, threads } = appState;
|
||||
const { activeThreadId, settingsSection, view } = navigation.current;
|
||||
|
||||
const navigate = useCallback((destination: AppLocation) => {
|
||||
dispatchApp({ type: "navigate", destination });
|
||||
}, []);
|
||||
const navigateWith = useCallback(
|
||||
(destination: Partial<AppLocation>) => {
|
||||
navigate({ ...navigation.current, ...destination });
|
||||
},
|
||||
[navigate, navigation.current],
|
||||
);
|
||||
const handleNavigateBack = useCallback(() => {
|
||||
dispatchApp({ type: "back" });
|
||||
}, []);
|
||||
const handleNavigateForward = useCallback(() => {
|
||||
dispatchApp({ type: "forward" });
|
||||
}, []);
|
||||
|
||||
useAppUpdate();
|
||||
|
||||
useEffect(() => {
|
||||
setShowOnboarding(!hasCompletedOnboarding());
|
||||
const handleReset = () => setShowOnboarding(true);
|
||||
window.addEventListener(ONBOARDING_RESET_EVENT, handleReset);
|
||||
return () =>
|
||||
window.removeEventListener(ONBOARDING_RESET_EVENT, handleReset);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
syncHubTheme();
|
||||
syncHubAccent();
|
||||
return watchSystemHubTheme();
|
||||
}, []);
|
||||
|
||||
const handleNewThread = useCallback(() => {
|
||||
const id = makeThreadId();
|
||||
setThreads((prev) => [...prev, { id }]);
|
||||
setActiveThreadId(id);
|
||||
setView("chat");
|
||||
useEffect(() => {
|
||||
// The dock reverts to the bundled icon every launch; re-apply the
|
||||
// user's choice once the shell is up.
|
||||
void syncAppIcon();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void syncDesktopWindowTitle();
|
||||
}, []);
|
||||
|
||||
const handleNewThread = useCallback(() => {
|
||||
dispatchApp({ type: "new-thread", threadId: makeThreadId() });
|
||||
}, []);
|
||||
|
||||
const completeOnboarding = useCallback(() => {
|
||||
markOnboardingCompleted();
|
||||
setShowOnboarding(false);
|
||||
// A fresh thread remounts the chat pane so it picks up credentials and
|
||||
// the provider/model selection configured during onboarding.
|
||||
handleNewThread();
|
||||
}, [handleNewThread]);
|
||||
|
||||
const handleOpenSession = useCallback((session: SessionHistoryItem) => {
|
||||
const threadId = `session_${session.sessionId}`;
|
||||
setThreads((prev) => {
|
||||
const existingIdx = prev.findIndex((item) => item.id === threadId);
|
||||
if (existingIdx >= 0) {
|
||||
const next = [...prev];
|
||||
next[existingIdx] = {
|
||||
...next[existingIdx],
|
||||
hasStarted: true,
|
||||
historySession: session,
|
||||
};
|
||||
return next;
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{ id: threadId, hasStarted: true, historySession: session },
|
||||
];
|
||||
});
|
||||
setActiveThreadId(threadId);
|
||||
setView("chat");
|
||||
dispatchApp({ type: "open-session", session });
|
||||
}, []);
|
||||
|
||||
const handleDeleteSession = useCallback(
|
||||
(deletedSessionId: string, deletedThreadId?: string) => {
|
||||
const historyThreadId = `session_${deletedSessionId}`;
|
||||
const deletedWasActive =
|
||||
activeThreadId === deletedThreadId ||
|
||||
activeThreadId === historyThreadId;
|
||||
const fallback = deletedWasActive ? { id: makeThreadId() } : null;
|
||||
let emptyFallbackId: string | null = null;
|
||||
setThreads((prev) => {
|
||||
const next = prev.filter(
|
||||
(thread) =>
|
||||
thread.id !== deletedThreadId &&
|
||||
thread.id !== historyThreadId &&
|
||||
thread.historySession?.sessionId !== deletedSessionId,
|
||||
);
|
||||
if (fallback) {
|
||||
return [...next, fallback];
|
||||
}
|
||||
if (next.length === 0) {
|
||||
emptyFallbackId = makeThreadId();
|
||||
return [{ id: emptyFallbackId }];
|
||||
}
|
||||
return next;
|
||||
dispatchApp({
|
||||
type: "delete-session",
|
||||
deletedSessionId,
|
||||
deletedThreadId,
|
||||
fallbackThreadId: makeThreadId(),
|
||||
});
|
||||
if (fallback) {
|
||||
setActiveThreadId(fallback.id);
|
||||
return;
|
||||
}
|
||||
if (emptyFallbackId) {
|
||||
setActiveThreadId(emptyFallbackId);
|
||||
}
|
||||
},
|
||||
[activeThreadId],
|
||||
[],
|
||||
);
|
||||
|
||||
const handleUpdateSessionMetadata = useCallback(
|
||||
(sessionId: string, metadata: SessionMetadata) => {
|
||||
setThreads((prev) =>
|
||||
prev.map((thread) => {
|
||||
if (thread.historySession?.sessionId !== sessionId) {
|
||||
return thread;
|
||||
}
|
||||
return {
|
||||
...thread,
|
||||
historySession: {
|
||||
...thread.historySession,
|
||||
metadata,
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
dispatchApp({ type: "update-session-metadata", sessionId, metadata });
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -196,16 +207,22 @@ export default function Home() {
|
||||
handleNewThread();
|
||||
return;
|
||||
}
|
||||
setView("chat");
|
||||
}, [activeThread, handleNewThread]);
|
||||
navigateWith({ view: "chat" });
|
||||
}, [activeThread, handleNewThread, navigateWith]);
|
||||
const handleViewChange = useCallback(
|
||||
(nextView: DesktopAppView) => {
|
||||
navigateWith({ view: nextView });
|
||||
},
|
||||
[navigateWith],
|
||||
);
|
||||
const handleSettingsSectionChange = useCallback(
|
||||
(section: SettingsSection) => {
|
||||
navigateWith({ settingsSection: section, view: "settings" });
|
||||
},
|
||||
[navigateWith],
|
||||
);
|
||||
const handleThreadStarted = useCallback((threadId: string) => {
|
||||
setThreads((current) =>
|
||||
current.map((thread) =>
|
||||
thread.id === threadId && !thread.hasStarted
|
||||
? { ...thread, hasStarted: true }
|
||||
: thread,
|
||||
),
|
||||
);
|
||||
dispatchApp({ type: "thread-started", threadId });
|
||||
}, []);
|
||||
const sessionHistory = useSessionHistory({
|
||||
activeSessionId: activeHistorySessionId,
|
||||
@@ -213,69 +230,107 @@ export default function Home() {
|
||||
onOpenSession: handleOpenSession,
|
||||
onUpdateSessionMetadata: handleUpdateSessionMetadata,
|
||||
});
|
||||
const handleOpenSessionById = useCallback(
|
||||
async (sessionId: string) => {
|
||||
const cachedSession = sessionHistory.sessions.find(
|
||||
(session) => session.sessionId === sessionId,
|
||||
);
|
||||
if (cachedSession) {
|
||||
handleOpenSession(cachedSession);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const session = await desktopClient.invoke<SessionHistoryItem | null>(
|
||||
"get_discovered_session",
|
||||
{ session_id: sessionId },
|
||||
);
|
||||
if (!session) {
|
||||
throw new Error("The session for this run is no longer available.");
|
||||
}
|
||||
handleOpenSession(session);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Unable to open run",
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
},
|
||||
[handleOpenSession, sessionHistory.sessions],
|
||||
);
|
||||
const historyWorkspacePaths = useMemo(
|
||||
() => workspacePathsFromSessions(sessionHistory.sessions),
|
||||
[sessionHistory.sessions],
|
||||
);
|
||||
|
||||
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}
|
||||
onHome={handleHome}
|
||||
onNavigateBack={handleNavigateBack}
|
||||
onNavigateForward={handleNavigateForward}
|
||||
onNewThread={handleNewThread}
|
||||
onSettingsSectionChange={handleSettingsSectionChange}
|
||||
sessionHistory={sessionHistory}
|
||||
setView={handleViewChange}
|
||||
settingsSection={settingsSection}
|
||||
view={view}
|
||||
canNavigateBack={navigation.back.length > 0}
|
||||
canNavigateForward={navigation.forward.length > 0}
|
||||
/>
|
||||
) : 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={handleSettingsSectionChange}
|
||||
onOpenSession={handleOpenSessionById}
|
||||
section={settingsSection}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
{showOnboarding ? (
|
||||
<div className="fixed inset-0 z-50">
|
||||
<OnboardingView onComplete={completeOnboarding} />
|
||||
</div>
|
||||
) : null}
|
||||
</AccountProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -317,6 +372,7 @@ function ChatThreadPane({
|
||||
pendingToolApprovals,
|
||||
pendingAskQuestions,
|
||||
setConfig,
|
||||
setWorkspacePath,
|
||||
sendPrompt,
|
||||
steerPromptInQueue,
|
||||
updatePromptInQueue,
|
||||
@@ -332,6 +388,8 @@ function ChatThreadPane({
|
||||
} = useChatSession();
|
||||
const [promptInput, setPromptInput] = useState("");
|
||||
const [pendingAttachments, setPendingAttachments] = useState<File[]>([]);
|
||||
const [isDraggingFiles, setIsDraggingFiles] = useState(false);
|
||||
const dragDepthRef = useRef(0);
|
||||
const [showDiffView, setShowDiffView] = useState(false);
|
||||
const [deletingSession, setDeletingSession] = useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
@@ -345,16 +403,21 @@ 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);
|
||||
const hydratedSessionRef = useRef<string | null>(null);
|
||||
const resetThreadRef = useRef<string | null>(null);
|
||||
const manualTitleSessionRef = useRef<string | null>(null);
|
||||
const workspaceSelectionRequestRef = useRef(0);
|
||||
const workspaceRef = useRef({
|
||||
cwd: config.cwd,
|
||||
workspaceRoot: config.workspaceRoot,
|
||||
@@ -366,7 +429,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
|
||||
@@ -445,12 +510,15 @@ function ChatThreadPane({
|
||||
);
|
||||
|
||||
const refreshGitBranch = useCallback(async () => {
|
||||
const cwd = getWorkspaceCwd();
|
||||
if (!cwd) {
|
||||
setGitBranch("no-git");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = await desktopClient.invoke<{ branch?: string }>(
|
||||
"get_git_branch",
|
||||
{
|
||||
cwd: getWorkspaceCwd(),
|
||||
},
|
||||
{ cwd },
|
||||
);
|
||||
const branch = payload?.branch?.trim();
|
||||
setGitBranch(branch && branch.length > 0 ? branch : "no-git");
|
||||
@@ -463,13 +531,15 @@ function ChatThreadPane({
|
||||
current: string;
|
||||
branches: string[];
|
||||
}> => {
|
||||
const cwd = getWorkspaceCwd();
|
||||
if (!cwd) {
|
||||
return { current: "no-git", branches: [] };
|
||||
}
|
||||
try {
|
||||
const payload = await desktopClient.invoke<{
|
||||
current?: string;
|
||||
branches?: string[];
|
||||
}>("list_git_branches", {
|
||||
cwd: getWorkspaceCwd(),
|
||||
});
|
||||
}>("list_git_branches", { cwd });
|
||||
const current = payload?.current?.trim() || "no-git";
|
||||
const branches = Array.isArray(payload?.branches)
|
||||
? payload.branches.filter((item) => item.trim().length > 0)
|
||||
@@ -482,13 +552,14 @@ function ChatThreadPane({
|
||||
|
||||
const switchGitBranch = useCallback(
|
||||
async (nextBranch: string): Promise<boolean> => {
|
||||
const cwd = getWorkspaceCwd();
|
||||
if (!cwd) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const payload = await desktopClient.invoke<{ branch?: string }>(
|
||||
"checkout_git_branch",
|
||||
{
|
||||
cwd: getWorkspaceCwd(),
|
||||
branch: nextBranch,
|
||||
},
|
||||
{ cwd, branch: nextBranch },
|
||||
);
|
||||
const branch = payload?.branch?.trim();
|
||||
setGitBranch(branch && branch.length > 0 ? branch : "no-git");
|
||||
@@ -508,7 +579,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 +594,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
|
||||
@@ -541,6 +617,7 @@ function ChatThreadPane({
|
||||
if (!nextWorkspace) {
|
||||
return false;
|
||||
}
|
||||
const requestId = ++workspaceSelectionRequestRef.current;
|
||||
const normalizedNext = normalizeWorkspacePath(nextWorkspace);
|
||||
const normalizedCurrent = normalizeWorkspacePath(
|
||||
workspaceRef.current.workspaceRoot || workspaceRef.current.cwd || "",
|
||||
@@ -556,13 +633,14 @@ function ChatThreadPane({
|
||||
if (validation.valid !== true) {
|
||||
return false;
|
||||
}
|
||||
if (requestId !== workspaceSelectionRequestRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
workspaceRoot: nextWorkspace,
|
||||
cwd: nextWorkspace,
|
||||
}));
|
||||
setWorkspaces((prev) => mergeWorkspacePaths(prev, [nextWorkspace]));
|
||||
setWorkspacePath(nextWorkspace);
|
||||
setWorkspaces((prev) =>
|
||||
filterWorkspacePaths(mergeWorkspacePaths(prev, [nextWorkspace])),
|
||||
);
|
||||
|
||||
// Fire git branch + workspace list refresh in the background
|
||||
desktopClient
|
||||
@@ -570,11 +648,16 @@ function ChatThreadPane({
|
||||
cwd: nextWorkspace,
|
||||
})
|
||||
.then((payload) => {
|
||||
if (requestId !== workspaceSelectionRequestRef.current) {
|
||||
return;
|
||||
}
|
||||
const branch = payload?.branch?.trim();
|
||||
setGitBranch(branch && branch.length > 0 ? branch : "no-git");
|
||||
})
|
||||
.catch(() => {
|
||||
setGitBranch("no-git");
|
||||
if (requestId === workspaceSelectionRequestRef.current) {
|
||||
setGitBranch("no-git");
|
||||
}
|
||||
});
|
||||
|
||||
// Refresh the merged history, stored, and current workspace catalog.
|
||||
@@ -582,9 +665,16 @@ function ChatThreadPane({
|
||||
|
||||
return true;
|
||||
},
|
||||
[setConfig, refreshWorkspaces],
|
||||
[refreshWorkspaces, setWorkspacePath],
|
||||
);
|
||||
|
||||
const selectChat = useCallback(async (): Promise<boolean> => {
|
||||
workspaceSelectionRequestRef.current += 1;
|
||||
setWorkspacePath("");
|
||||
setGitBranch("no-git");
|
||||
return true;
|
||||
}, [setWorkspacePath]);
|
||||
|
||||
const pickWorkspaceDirectory = useCallback(
|
||||
async (initialPath?: string): Promise<string | null> => {
|
||||
try {
|
||||
@@ -842,6 +932,69 @@ function ChatThreadPane({
|
||||
threadId,
|
||||
]);
|
||||
|
||||
const handleAttachFiles = useCallback((files: File[]) => {
|
||||
setPendingAttachments((prev) => {
|
||||
const existing = new Set(
|
||||
prev.map((file) => `${file.name}:${file.size}:${file.lastModified}`),
|
||||
);
|
||||
const next = [...prev];
|
||||
for (const file of files) {
|
||||
const key = `${file.name}:${file.size}:${file.lastModified}`;
|
||||
if (!existing.has(key)) {
|
||||
existing.add(key);
|
||||
next.push(file);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Drag-and-drop file attachments. Requires `dragDropEnabled: false` on the
|
||||
// Tauri window — otherwise the native shell swallows OS file drags and these
|
||||
// HTML5 events never fire.
|
||||
const handleDragEnter = useCallback((event: React.DragEvent) => {
|
||||
if (!event.dataTransfer.types.includes("Files")) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
dragDepthRef.current += 1;
|
||||
setIsDraggingFiles(true);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((event: React.DragEvent) => {
|
||||
if (!event.dataTransfer.types.includes("Files")) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((event: React.DragEvent) => {
|
||||
if (!event.dataTransfer.types.includes("Files")) {
|
||||
return;
|
||||
}
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||
if (dragDepthRef.current === 0) {
|
||||
setIsDraggingFiles(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(event: React.DragEvent) => {
|
||||
if (!event.dataTransfer.types.includes("Files")) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
dragDepthRef.current = 0;
|
||||
setIsDraggingFiles(false);
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
if (files.length > 0) {
|
||||
handleAttachFiles(files);
|
||||
}
|
||||
},
|
||||
[handleAttachFiles],
|
||||
);
|
||||
|
||||
const attachmentList = pendingAttachments.map((file, index) => ({
|
||||
id: `${file.name}:${file.size}:${file.lastModified}:${index}`,
|
||||
name: file.name,
|
||||
@@ -926,6 +1079,7 @@ function ChatThreadPane({
|
||||
refreshWorkspaces,
|
||||
switchWorkspace,
|
||||
pickWorkspaceDirectory,
|
||||
selectChat,
|
||||
}),
|
||||
[
|
||||
resolvedWorkspaceRoot,
|
||||
@@ -934,6 +1088,7 @@ function ChatThreadPane({
|
||||
refreshWorkspaces,
|
||||
switchWorkspace,
|
||||
pickWorkspaceDirectory,
|
||||
selectChat,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -964,24 +1119,7 @@ function ChatThreadPane({
|
||||
<ChatInputBar
|
||||
attachments={attachmentList}
|
||||
onAbort={() => void abort()}
|
||||
onAttachFiles={(files) => {
|
||||
setPendingAttachments((prev) => {
|
||||
const existing = new Set(
|
||||
prev.map(
|
||||
(file) => `${file.name}:${file.size}:${file.lastModified}`,
|
||||
),
|
||||
);
|
||||
const next = [...prev];
|
||||
for (const file of files) {
|
||||
const key = `${file.name}:${file.size}:${file.lastModified}`;
|
||||
if (!existing.has(key)) {
|
||||
existing.add(key);
|
||||
next.push(file);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onAttachFiles={handleAttachFiles}
|
||||
onListGitBranches={listGitBranches}
|
||||
onRemoveAttachment={(id) => {
|
||||
setPendingAttachments((prev) =>
|
||||
@@ -1045,13 +1183,31 @@ function ChatThreadPane({
|
||||
|
||||
return (
|
||||
<WorkspaceProvider value={workspaceContextValue}>
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: Drag-and-drop target only; the paperclip button is the accessible attach path. */}
|
||||
<div
|
||||
className={
|
||||
isWelcomeState
|
||||
? "grid h-full min-h-0 flex-1 grid-rows-[minmax(0,1fr)] overflow-hidden"
|
||||
: "grid h-full min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden"
|
||||
? "relative grid h-full min-h-0 flex-1 grid-rows-[minmax(0,1fr)] overflow-hidden"
|
||||
: "relative grid h-full min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden"
|
||||
}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{isDraggingFiles ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
||||
<div className="flex flex-col items-center gap-2 rounded-xl border-2 border-dashed border-primary/60 bg-card px-10 py-8 shadow-lg">
|
||||
<ImagePlus className="h-8 w-8 text-primary" />
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Drop to attach
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Screenshots and files will be added to your next message
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{!isWelcomeState ? (
|
||||
<div className="z-20 border-b border-border/70 bg-background/85 backdrop-blur-sm">
|
||||
<AgentHeader
|
||||
@@ -1079,6 +1235,7 @@ function ChatThreadPane({
|
||||
body={
|
||||
showDiffView ? (
|
||||
<DiffView
|
||||
cwd={config.cwd || config.workspaceRoot}
|
||||
fileDiffs={fileDiffs}
|
||||
onClose={() => setShowDiffView(false)}
|
||||
/>
|
||||
@@ -1104,7 +1261,10 @@ function ChatThreadPane({
|
||||
)
|
||||
}
|
||||
composer={composer}
|
||||
gitBranch={gitBranch}
|
||||
onListGitBranches={listGitBranches}
|
||||
onStartChat={setPromptInput}
|
||||
onSwitchGitBranch={switchGitBranch}
|
||||
quickActions={[]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// @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 { AgentHeader } from "@/components/agent-header";
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("AgentHeader title editor", () => {
|
||||
it("preserves the displayed title width when editing starts", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AgentHeader
|
||||
canEditTitle
|
||||
onRenameTitle={vi.fn()}
|
||||
status="completed"
|
||||
title="A title wide enough to expose resizing"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const titleButton = container.querySelector<HTMLButtonElement>(
|
||||
'button[title="A title wide enough to expose resizing"]',
|
||||
);
|
||||
expect(titleButton).not.toBeNull();
|
||||
vi.spyOn(
|
||||
titleButton as HTMLButtonElement,
|
||||
"getBoundingClientRect",
|
||||
).mockReturnValue({
|
||||
width: 318,
|
||||
} as DOMRect);
|
||||
|
||||
await act(async () => {
|
||||
titleButton?.click();
|
||||
});
|
||||
|
||||
const titleForm = container.querySelector("form");
|
||||
const titleInput = container.querySelector<HTMLInputElement>("input");
|
||||
expect(titleForm?.style.width).toBe("318px");
|
||||
expect(titleInput?.className).toContain("w-full");
|
||||
expect(titleInput?.className).not.toContain("w-64");
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,7 @@ export function AgentHeader({
|
||||
}: AgentHeaderProps) {
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||
const [titleInput, setTitleInput] = useState("");
|
||||
const [titleEditorWidth, setTitleEditorWidth] = useState<number>();
|
||||
const additions = diff?.additions ?? 0;
|
||||
const deletions = diff?.deletions ?? 0;
|
||||
const hasChanges = additions + deletions > 0;
|
||||
@@ -98,15 +99,16 @@ export function AgentHeader({
|
||||
/>
|
||||
{isEditingTitle ? (
|
||||
<form
|
||||
className="m-0 min-w-0 flex-1"
|
||||
className="m-0 min-w-0 max-w-full shrink-0"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void submitTitle();
|
||||
}}
|
||||
style={{ width: titleEditorWidth }}
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-7 w-64 max-w-full text-sm"
|
||||
className="h-7 w-full text-sm"
|
||||
disabled={renamingTitle}
|
||||
onBlur={() => {
|
||||
void submitTitle();
|
||||
@@ -130,10 +132,13 @@ export function AgentHeader({
|
||||
"rounded px-1 py-0.5 transition-colors hover:bg-accent",
|
||||
)}
|
||||
disabled={!canEditTitle || renamingTitle}
|
||||
onClick={() => {
|
||||
onClick={(event) => {
|
||||
if (!canEditTitle || renamingTitle) {
|
||||
return;
|
||||
}
|
||||
setTitleEditorWidth(
|
||||
event.currentTarget.getBoundingClientRect().width,
|
||||
);
|
||||
setTitleInput(threadTitle);
|
||||
setIsEditingTitle(true);
|
||||
}}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -62,6 +70,15 @@ async function click(element: Element): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function hover(element: Element): Promise<void> {
|
||||
await act(async () => {
|
||||
element.dispatchEvent(
|
||||
new MouseEvent("pointerover", { bubbles: true, cancelable: true }),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
function buttonWithText(text: string, rootNode: ParentNode = container) {
|
||||
const button = [
|
||||
...rootNode.querySelectorAll<HTMLButtonElement>("button"),
|
||||
@@ -78,6 +95,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 +121,86 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("AgentSidebar session organization", () => {
|
||||
it("filters scheduled sessions without changing their titles", async () => {
|
||||
const scheduled = {
|
||||
...makeThread("scheduled", 1),
|
||||
source: "hub-schedule",
|
||||
};
|
||||
const regular = makeThread("regular", 1);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
onHome={vi.fn()}
|
||||
onNewThread={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory([scheduled, regular], vi.fn())}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
/>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(sessionIsVisible("scheduled session 1")).toBe(true);
|
||||
expect(container.textContent).not.toContain("(schedule)");
|
||||
|
||||
await click(
|
||||
container.querySelector('[aria-label="Filter sessions"]') as Element,
|
||||
);
|
||||
expect(document.body.textContent).not.toContain("Recent");
|
||||
const schedulesOption = await vi.waitFor(() => {
|
||||
const option = [
|
||||
...document.querySelectorAll<HTMLElement>('[role="menuitemradio"]'),
|
||||
].find((candidate) => candidate.textContent?.includes("Schedules"));
|
||||
expect(option).toBeDefined();
|
||||
return option as HTMLElement;
|
||||
});
|
||||
await click(schedulesOption);
|
||||
|
||||
expect(sessionIsVisible("scheduled session 1")).toBe(true);
|
||||
expect(sessionIsVisible("regular session 1")).toBe(false);
|
||||
});
|
||||
|
||||
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"],
|
||||
["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([
|
||||
"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) =>
|
||||
@@ -122,11 +222,12 @@ describe("AgentSidebar session organization", () => {
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
isHomeActive
|
||||
onHome={vi.fn()}
|
||||
onNewThread={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={sessionHistory}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
/>
|
||||
</SidebarProvider>,
|
||||
@@ -173,4 +274,298 @@ 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}
|
||||
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}
|
||||
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).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the desktop app version and connected Hub when the logo is hovered", async () => {
|
||||
const onHome = vi.fn();
|
||||
invoke.mockImplementation(async (command: string) => {
|
||||
if (command === "get_process_context") {
|
||||
return {
|
||||
appVersion: "1.2.3",
|
||||
hub: {
|
||||
error: null,
|
||||
status: "connected",
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error("No Cline account auth token found");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AccountProvider>
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
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 hover(logoButton as Element);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.textContent).toContain("Version 1.2.3");
|
||||
expect(document.body.textContent).toContain("Cline Hub @25463");
|
||||
expect(document.body.textContent).not.toContain(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
);
|
||||
});
|
||||
expect(onHome).not.toHaveBeenCalled();
|
||||
|
||||
await click(logoButton as Element);
|
||||
expect(onHome).toHaveBeenCalled();
|
||||
expect(invoke).toHaveBeenCalledWith("get_process_context");
|
||||
});
|
||||
|
||||
it("shows a disconnected Hub when process context has no live connection", async () => {
|
||||
invoke.mockResolvedValue({
|
||||
appVersion: "1.2.3",
|
||||
hub: {
|
||||
error: "Hub connection closed (code=1006)",
|
||||
status: "disconnected",
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AccountProvider>
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
onHome={vi.fn()}
|
||||
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();
|
||||
await hover(logoButton as Element);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.textContent).toContain("Cline Hub @25463");
|
||||
expect(document.body.textContent).toContain(
|
||||
"Hub connection closed (code=1006)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("hosts back and forward navigation in the draggable sidebar title bar", async () => {
|
||||
const onNavigateBack = vi.fn();
|
||||
const onNavigateForward = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AccountProvider>
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
canNavigateBack
|
||||
canNavigateForward
|
||||
onHome={vi.fn()}
|
||||
onNavigateBack={onNavigateBack}
|
||||
onNavigateForward={onNavigateForward}
|
||||
onNewThread={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory([], vi.fn())}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
/>
|
||||
</SidebarProvider>
|
||||
</AccountProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
const titleBar = container.querySelector("[data-tauri-drag-region]");
|
||||
expect(titleBar).not.toBeNull();
|
||||
expect(titleBar?.textContent).not.toContain("Cline Code");
|
||||
|
||||
await click(
|
||||
container.querySelector('[aria-label="Previous page"]') as Element,
|
||||
);
|
||||
await click(container.querySelector('[aria-label="Next page"]') as Element);
|
||||
expect(onNavigateBack).toHaveBeenCalledOnce();
|
||||
expect(onNavigateForward).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("places the logo and icon-only new-session action below the title bar", async () => {
|
||||
const onNewThread = vi.fn();
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AccountProvider>
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
onHome={vi.fn()}
|
||||
onNewThread={onNewThread}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory([], vi.fn())}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
/>
|
||||
</SidebarProvider>
|
||||
</AccountProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
const logo = container.querySelector('[aria-label="Cline home"]');
|
||||
const newSession = container.querySelector('[aria-label="New Session"]');
|
||||
expect(logo).not.toBeNull();
|
||||
expect(newSession).not.toBeNull();
|
||||
expect(newSession?.textContent).toBe("");
|
||||
await click(newSession as Element);
|
||||
expect(onNewThread).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("uses only the Cline logo for home in the collapsed sidebar", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AccountProvider>
|
||||
<SidebarProvider defaultOpen={false}>
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
onHome={vi.fn()}
|
||||
onNewThread={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory([], vi.fn())}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
/>
|
||||
</SidebarProvider>
|
||||
</AccountProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.querySelector('[aria-label="Cline home"]')).not.toBeNull();
|
||||
expect(container.querySelector('[aria-label="New Session"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to a signed-out footer without account data", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AccountProvider>
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
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,32 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Activity,
|
||||
ArrowDownUp,
|
||||
Blocks,
|
||||
Bot,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
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,
|
||||
@@ -68,14 +71,18 @@ 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 { SCHEDULED_SESSION_SOURCE } from "@/lib/session-history";
|
||||
import {
|
||||
groupThreadsByProject,
|
||||
INITIAL_VISIBLE_THREAD_COUNT,
|
||||
@@ -86,18 +93,47 @@ import { cn } from "@/lib/utils";
|
||||
type Thread = SessionThread;
|
||||
type AppView = "chat" | "sessions" | "settings";
|
||||
|
||||
const filterOptions = ["All", "Running", "Recent", "Pinned"] as const;
|
||||
const filterOptions = ["All", "Running", "Schedules", "Pinned"] as const;
|
||||
type FilterOption = (typeof filterOptions)[number];
|
||||
type SidebarSortMode = "time" | "project";
|
||||
type DesktopProcessContext = {
|
||||
appVersion?: unknown;
|
||||
hub?: {
|
||||
error?: unknown;
|
||||
status?: unknown;
|
||||
url?: unknown;
|
||||
};
|
||||
};
|
||||
type HubStatus = {
|
||||
connected: boolean;
|
||||
error: string | null;
|
||||
url: string | null;
|
||||
};
|
||||
|
||||
function hubPort(url: string | null): string | null {
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new URL(url).port || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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 +145,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,36 +182,25 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentSidebar({
|
||||
isHomeActive,
|
||||
canNavigateBack = false,
|
||||
canNavigateForward = false,
|
||||
onHome,
|
||||
onNavigateBack,
|
||||
onNavigateForward,
|
||||
onNewThread,
|
||||
onSettingsSectionChange,
|
||||
setView,
|
||||
@@ -160,8 +209,11 @@ export function AgentSidebar({
|
||||
activeSessionId,
|
||||
sessionHistory,
|
||||
}: {
|
||||
isHomeActive: boolean;
|
||||
canNavigateBack?: boolean;
|
||||
canNavigateForward?: boolean;
|
||||
onHome: () => void;
|
||||
onNavigateBack?: () => void;
|
||||
onNavigateForward?: () => void;
|
||||
onNewThread?: () => void;
|
||||
onSettingsSectionChange: (section: SettingsSection) => void;
|
||||
setView: (view: AppView) => void;
|
||||
@@ -172,6 +224,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 +265,46 @@ export function AgentSidebar({
|
||||
const [projectVisibleCounts, setProjectVisibleCounts] = useState<
|
||||
Record<string, number>
|
||||
>({});
|
||||
const [appVersion, setAppVersion] = useState<string | null>(null);
|
||||
const [hubStatus, setHubStatus] = useState<HubStatus | null>(null);
|
||||
|
||||
const loadProcessContext = useCallback(async () => {
|
||||
try {
|
||||
const context = await desktopClient.invoke<DesktopProcessContext>(
|
||||
"get_process_context",
|
||||
);
|
||||
const version =
|
||||
typeof context?.appVersion === "string"
|
||||
? context.appVersion.trim()
|
||||
: "";
|
||||
setAppVersion(version || null);
|
||||
const hubUrl =
|
||||
typeof context?.hub?.url === "string"
|
||||
? context.hub.url.trim() || null
|
||||
: null;
|
||||
setHubStatus({
|
||||
connected: context?.hub?.status === "connected",
|
||||
error:
|
||||
typeof context?.hub?.error === "string"
|
||||
? context.hub.error.trim() || null
|
||||
: null,
|
||||
url: hubUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
setHubStatus({
|
||||
connected: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unable to read Cline Hub status.",
|
||||
url: null,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProcessContext();
|
||||
}, [loadProcessContext]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCollapsed && searchOpen) {
|
||||
@@ -226,8 +326,8 @@ export function AgentSidebar({
|
||||
switch (filter) {
|
||||
case "Running":
|
||||
return filtered.filter((t) => t.status === "running");
|
||||
case "Recent":
|
||||
return filtered.slice(0, 8);
|
||||
case "Schedules":
|
||||
return filtered.filter((t) => t.source === SCHEDULED_SESSION_SOURCE);
|
||||
case "Pinned":
|
||||
return filtered.filter((t) => t.pinned);
|
||||
default:
|
||||
@@ -240,18 +340,16 @@ export function AgentSidebar({
|
||||
|
||||
const openThread = useCallback(
|
||||
(threadId: string) => {
|
||||
setView("chat");
|
||||
openHistoryThread(threadId);
|
||||
closeMobileSidebar();
|
||||
},
|
||||
[closeMobileSidebar, openHistoryThread, setView],
|
||||
[closeMobileSidebar, openHistoryThread],
|
||||
);
|
||||
|
||||
const openNewThread = useCallback(() => {
|
||||
setView("chat");
|
||||
onNewThread?.();
|
||||
closeMobileSidebar();
|
||||
}, [closeMobileSidebar, onNewThread, setView]);
|
||||
}, [closeMobileSidebar, onNewThread]);
|
||||
const openHome = useCallback(() => {
|
||||
onHome();
|
||||
closeMobileSidebar();
|
||||
@@ -267,11 +365,16 @@ export function AgentSidebar({
|
||||
const openSettingsSection = useCallback(
|
||||
(section: SettingsSection) => {
|
||||
onSettingsSectionChange(section);
|
||||
setView("settings");
|
||||
closeMobileSidebar();
|
||||
},
|
||||
[closeMobileSidebar, onSettingsSectionChange, setView],
|
||||
[closeMobileSidebar, onSettingsSectionChange],
|
||||
);
|
||||
const navigateBack = useCallback(() => {
|
||||
onNavigateBack?.();
|
||||
}, [onNavigateBack]);
|
||||
const navigateForward = useCallback(() => {
|
||||
onNavigateForward?.();
|
||||
}, [onNavigateForward]);
|
||||
|
||||
const startRenameThread = useCallback((thread: Thread) => {
|
||||
setEditingSessionId(thread.id);
|
||||
@@ -442,37 +545,107 @@ export function AgentSidebar({
|
||||
<div className="flex h-full min-h-0 w-full min-w-0 shrink-0 flex-col overflow-hidden bg-sidebar text-sidebar-foreground">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-16 shrink-0 items-center px-4",
|
||||
isCollapsed && "justify-center px-0",
|
||||
"flex h-12 shrink-0 items-center justify-end gap-0.5 pr-2 pl-[4.75rem]",
|
||||
isCollapsed && "px-0",
|
||||
)}
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<ClineLogo className="h-6 w-6" />
|
||||
</button>
|
||||
{!isCollapsed ? (
|
||||
<>
|
||||
<Button
|
||||
aria-label="Previous page"
|
||||
className="size-7 text-muted-foreground hover:text-sidebar-foreground"
|
||||
disabled={!canNavigateBack}
|
||||
onClick={navigateBack}
|
||||
size="icon"
|
||||
title="Previous page"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Next page"
|
||||
className="size-7 text-muted-foreground hover:text-sidebar-foreground"
|
||||
disabled={!canNavigateForward}
|
||||
onClick={navigateForward}
|
||||
size="icon"
|
||||
title="Next page"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={cn("shrink-0 px-3", isCollapsed && "px-1.5")}>
|
||||
<Button
|
||||
className={cn(
|
||||
"min-w-0 justify-start",
|
||||
view === "chat" &&
|
||||
isHomeActive &&
|
||||
"bg-sidebar-accent text-sidebar-accent-foreground",
|
||||
isCollapsed && "mx-auto size-9 justify-center px-0",
|
||||
)}
|
||||
aria-label="Home"
|
||||
onClick={openHome}
|
||||
title="Home"
|
||||
variant="sidebarItem"
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 shrink-0 items-center justify-between px-3",
|
||||
isCollapsed && "px-1.5",
|
||||
)}
|
||||
>
|
||||
<HoverCard
|
||||
closeDelay={100}
|
||||
openDelay={0}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
void loadProcessContext();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Home className="size-4" />
|
||||
{!isCollapsed ? "Home" : null}
|
||||
</Button>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
aria-label="Cline home"
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-md text-sidebar-foreground transition-colors hover:bg-sidebar-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
|
||||
onClick={openHome}
|
||||
title="Home"
|
||||
type="button"
|
||||
>
|
||||
<ClineLogo className="size-6" />
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align="start" className="w-64 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>
|
||||
<div className="mt-3 border-border border-t pt-3">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"h-2 w-2 shrink-0 rounded-full",
|
||||
hubStatus?.connected
|
||||
? "bg-emerald-500"
|
||||
: "bg-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
<span className="font-medium">
|
||||
Cline Hub @{hubPort(hubStatus?.url ?? null) ?? "unknown"}
|
||||
</span>
|
||||
</div>
|
||||
{hubStatus && !hubStatus.connected && (
|
||||
<p className="mt-1 text-[11px] text-destructive">
|
||||
{hubStatus.error ?? "Cline Hub is not connected."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
{!isCollapsed ? (
|
||||
<Button
|
||||
aria-label="New Session"
|
||||
className="size-8 shrink-0 justify-center px-0"
|
||||
onClick={openNewThread}
|
||||
title="New Session"
|
||||
type="button"
|
||||
variant="sidebarItem"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isCollapsed ? (
|
||||
@@ -483,18 +656,7 @@ export function AgentSidebar({
|
||||
collapsed
|
||||
onSelect={openSettingsSection}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
aria-label="New session"
|
||||
className="mx-auto size-9 justify-center px-0"
|
||||
onClick={openNewThread}
|
||||
title="New session"
|
||||
type="button"
|
||||
variant="sidebarItem"
|
||||
>
|
||||
<MessageSquare className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
) : null}
|
||||
<Button
|
||||
aria-label="Expand sidebar"
|
||||
className="mx-auto size-9 justify-center px-0"
|
||||
@@ -542,17 +704,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 +830,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 +985,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(title);
|
||||
const pending = pendingAction !== null;
|
||||
const workspacePath = thread.workspacePath || thread.codebase;
|
||||
const statusDotClass = pending
|
||||
? "bg-yellow-400"
|
||||
: thread.status === "running"
|
||||
@@ -835,20 +995,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 +1028,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,13 +1062,15 @@ 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}>
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span
|
||||
className="min-w-0 truncate font-mono"
|
||||
className="min-w-0 truncate font-mono font-thin text-foreground"
|
||||
title={fullValue}
|
||||
>
|
||||
{value}
|
||||
@@ -942,6 +1091,35 @@ 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,
|
||||
],
|
||||
["Branch", thread.gitBranch],
|
||||
["Provider", thread.provider],
|
||||
["Model", thread.model],
|
||||
["Tokens", formatTokenCount(thread.inputTokens, thread.outputTokens)],
|
||||
["Cost", formatCostUsd(thread.totalCostUsd)],
|
||||
["ID", thread.id],
|
||||
["Source", thread.source],
|
||||
["Updated", thread.time],
|
||||
];
|
||||
return items.filter((item): item is [string, string, string?] =>
|
||||
Boolean(item[1]),
|
||||
);
|
||||
}
|
||||
|
||||
function EditableSessionTitle({
|
||||
value,
|
||||
disabled,
|
||||
|
||||
@@ -98,9 +98,32 @@ describe("MemoizedMarkdown interactions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("requires confirmation before opening an external link", async () => {
|
||||
test("opens honest external links directly in the default browser", async () => {
|
||||
const url = "https://example.com/review?source=cline";
|
||||
await renderMarkdown({ content: `[Review docs](${url})` });
|
||||
const link = await vi.waitFor(() => {
|
||||
const renderedLink = container.querySelector<HTMLAnchorElement>(
|
||||
'[data-streamdown="link"]',
|
||||
);
|
||||
expect(renderedLink).not.toBeNull();
|
||||
return renderedLink as HTMLAnchorElement;
|
||||
});
|
||||
expect(link.getAttribute("href")).toBe(url);
|
||||
expect(link.getAttribute("title")).toBe(url);
|
||||
|
||||
await click(link);
|
||||
expect(document.querySelector('[role="alertdialog"]')).toBeNull();
|
||||
expect(openWindow).toHaveBeenCalledTimes(1);
|
||||
expect(openWindow).toHaveBeenCalledWith(
|
||||
url,
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
});
|
||||
|
||||
test("requires confirmation before opening a deceptive external link", async () => {
|
||||
const url = "https://example.com/review?source=cline";
|
||||
await renderMarkdown({ content: `[github.com/cline](${url})` });
|
||||
const link = await vi.waitFor(() => {
|
||||
const renderedLink = container.querySelector<HTMLElement>(
|
||||
'[data-streamdown="link"]',
|
||||
@@ -140,12 +163,50 @@ describe("MemoizedMarkdown interactions", () => {
|
||||
await click(getButton("Open link"));
|
||||
|
||||
expect(openWindow).toHaveBeenCalledTimes(1);
|
||||
expect(openWindow).toHaveBeenCalledWith(url, "_blank", "noreferrer");
|
||||
expect(openWindow).toHaveBeenCalledWith(
|
||||
url,
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('[role="alertdialog"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// The open_external_url sidecar command only opens http(s)/mailto/tel, and
|
||||
// relies on Streamdown's harden step blocking every other scheme before it
|
||||
// reaches SafeMarkdownLink. If a Streamdown upgrade starts letting other
|
||||
// schemes through, confirming those links would silently open nothing.
|
||||
test("blocks link schemes the sidecar cannot open before they render", async () => {
|
||||
for (const url of ["vscode://settings/editor", "ftp://example.com/f"]) {
|
||||
await renderMarkdown({ content: `[Open app](${url})` });
|
||||
await vi.waitFor(() => {
|
||||
expect(container.textContent).toContain("Open app");
|
||||
});
|
||||
expect(container.querySelector('[data-streamdown="link"]')).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("opens mailto links directly through the external opener", async () => {
|
||||
await renderMarkdown({ content: "[Email us](mailto:hi@cline.bot)" });
|
||||
const link = await vi.waitFor(() => {
|
||||
const renderedLink = container.querySelector<HTMLElement>(
|
||||
'[data-streamdown="link"]',
|
||||
);
|
||||
expect(renderedLink).not.toBeNull();
|
||||
return renderedLink as HTMLElement;
|
||||
});
|
||||
|
||||
expect(link.tagName).toBe("A");
|
||||
await click(link);
|
||||
expect(document.querySelector('[role="alertdialog"]')).toBeNull();
|
||||
expect(openWindow).toHaveBeenCalledWith(
|
||||
"mailto:hi@cline.bot",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps same-document links navigable without a confirmation", async () => {
|
||||
await renderMarkdown({ content: "[Details](#details)" });
|
||||
const link = container.querySelector<HTMLAnchorElement>(
|
||||
|
||||
@@ -55,16 +55,86 @@ const ready = true;
|
||||
expect(html).toContain("stillStreaming");
|
||||
});
|
||||
|
||||
test("routes external links through confirmation controls", () => {
|
||||
test("renders honest external links with their real destination", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<MemoizedMarkdown content="[Review](https://example.com/review)" />,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-streamdown="link"');
|
||||
expect(html).toContain("Review");
|
||||
expect(html).toContain('href="https://example.com/review"');
|
||||
expect(html).not.toContain('aria-haspopup="dialog"');
|
||||
});
|
||||
|
||||
test("keeps external links whose URL text matches the destination direct", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<MemoizedMarkdown content="[example.com/review](https://www.example.com/review)" />,
|
||||
);
|
||||
|
||||
expect(html).toContain('href="https://www.example.com/review"');
|
||||
expect(html).not.toContain('aria-haspopup="dialog"');
|
||||
});
|
||||
|
||||
test("routes deceptive URL-text links through confirmation controls", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<MemoizedMarkdown content="[github.com/cline](https://evil.example/payload)" />,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-streamdown="link"');
|
||||
expect(html).toContain('href="#confirm-external-link"');
|
||||
expect(html).toContain('aria-haspopup="dialog"');
|
||||
expect(html).not.toContain('href="https://example.com/review"');
|
||||
expect(html).not.toContain('href="https://evil.example/payload"');
|
||||
});
|
||||
|
||||
test("treats fully qualified trailing-dot hostnames like their plain form", () => {
|
||||
const deceptiveHtml = renderToStaticMarkup(
|
||||
<MemoizedMarkdown content="[github.com.](https://evil.example/payload)" />,
|
||||
);
|
||||
expect(deceptiveHtml).toContain('href="#confirm-external-link"');
|
||||
expect(deceptiveHtml).not.toContain('href="https://evil.example/payload"');
|
||||
|
||||
const honestHtml = renderToStaticMarkup(
|
||||
<MemoizedMarkdown content="[github.com.](https://github.com/cline)" />,
|
||||
);
|
||||
expect(honestHtml).toContain('href="https://github.com/cline"');
|
||||
expect(honestHtml).not.toContain('aria-haspopup="dialog"');
|
||||
});
|
||||
|
||||
test("treats protocol-relative labels like their https form", () => {
|
||||
const deceptiveHtml = renderToStaticMarkup(
|
||||
<MemoizedMarkdown content="[//github.com](https://evil.example/payload)" />,
|
||||
);
|
||||
expect(deceptiveHtml).toContain('href="#confirm-external-link"');
|
||||
expect(deceptiveHtml).not.toContain('href="https://evil.example/payload"');
|
||||
|
||||
const honestHtml = renderToStaticMarkup(
|
||||
<MemoizedMarkdown content="[//github.com](https://github.com/cline)" />,
|
||||
);
|
||||
expect(honestHtml).toContain('href="https://github.com/cline"');
|
||||
expect(honestHtml).not.toContain('aria-haspopup="dialog"');
|
||||
});
|
||||
|
||||
test("sees through inline formatting inside deceptive URL text", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<MemoizedMarkdown content="[**github.com**/cline](https://evil.example/payload)" />,
|
||||
);
|
||||
|
||||
expect(html).toContain('href="#confirm-external-link"');
|
||||
expect(html).not.toContain('href="https://evil.example/payload"');
|
||||
});
|
||||
|
||||
test("treats scheme and port mismatches as deceptive", () => {
|
||||
const schemeHtml = renderToStaticMarkup(
|
||||
<MemoizedMarkdown content="[https://example.com](http://example.com/login)" />,
|
||||
);
|
||||
expect(schemeHtml).toContain('href="#confirm-external-link"');
|
||||
expect(schemeHtml).not.toContain('href="http://example.com/login"');
|
||||
|
||||
const portHtml = renderToStaticMarkup(
|
||||
<MemoizedMarkdown content="[example.com](https://example.com:8080/admin)" />,
|
||||
);
|
||||
expect(portHtml).toContain('href="#confirm-external-link"');
|
||||
expect(portHtml).not.toContain('href="https://example.com:8080/admin"');
|
||||
});
|
||||
|
||||
test("leaves app-local and fragment links navigable", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cjk } from "@streamdown/cjk";
|
||||
import type { ComponentProps, MouseEvent } from "react";
|
||||
import { memo, useState } from "react";
|
||||
import type { ComponentProps, MouseEvent, ReactNode } from "react";
|
||||
import { isValidElement, memo, useState } from "react";
|
||||
import {
|
||||
type Components,
|
||||
type ControlsConfig,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type LinkSafetyModalProps,
|
||||
Streamdown,
|
||||
} from "streamdown";
|
||||
import { openExternalUrl } from "@/lib/desktop-client";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -61,6 +62,82 @@ export function MarkdownLinkSafetyModal({
|
||||
|
||||
type MarkdownLinkProps = ComponentProps<"a"> & ExtraProps;
|
||||
|
||||
function extractLinkText(children: ReactNode): string {
|
||||
if (typeof children === "string" || typeof children === "number") {
|
||||
return String(children);
|
||||
}
|
||||
if (Array.isArray(children)) {
|
||||
return children.map(extractLinkText).join("");
|
||||
}
|
||||
// Inline formatting (**bold**, `code`, …) nests the label text inside
|
||||
// elements; recurse so styled hostnames can't dodge the deception check.
|
||||
if (isValidElement(children)) {
|
||||
return extractLinkText(
|
||||
(children.props as { children?: ReactNode }).children,
|
||||
);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// Tolerates one trailing dot after the TLD ("github.com." resolves the same
|
||||
// as "github.com" in browsers) and a protocol-relative "//" prefix, so those
|
||||
// label spellings can't slip past the deception check.
|
||||
const urlLikeTextPattern =
|
||||
/^(?:https?:\/\/|\/\/)?(?:[\w-]+\.)+[a-z]{2,}\.?(?:[/:?#]\S*)?$/i;
|
||||
|
||||
type LinkParts = {
|
||||
protocol: string;
|
||||
hostname: string;
|
||||
port: string;
|
||||
explicitScheme: boolean;
|
||||
};
|
||||
|
||||
function parseLinkParts(value: string): LinkParts | null {
|
||||
const explicitScheme = /^[a-z][a-z\d+.-]*:/i.test(value);
|
||||
const withScheme = explicitScheme
|
||||
? value
|
||||
: value.startsWith("//")
|
||||
? `https:${value}`
|
||||
: `https://${value}`;
|
||||
try {
|
||||
const parsed = new URL(withScheme);
|
||||
const hostname = parsed.hostname
|
||||
.toLowerCase()
|
||||
.replace(/\.+$/, "")
|
||||
.replace(/^www\./, "");
|
||||
if (!hostname) return null;
|
||||
return {
|
||||
explicitScheme,
|
||||
hostname,
|
||||
port: parsed.port,
|
||||
protocol: parsed.protocol,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A link is deceptive when its visible text reads as a URL that does not
|
||||
* match the real destination — the one shape where a click genuinely
|
||||
* surprises the user. Only those links get the confirmation dialog;
|
||||
* ordinary external links open directly. The label and destination must
|
||||
* agree on hostname and port, and on scheme when the label states one.
|
||||
*/
|
||||
function isDeceptiveLink(children: ReactNode, url: string): boolean {
|
||||
const text = extractLinkText(children).trim();
|
||||
if (!text || !urlLikeTextPattern.test(text)) return false;
|
||||
const textParts = parseLinkParts(text);
|
||||
if (!textParts) return false;
|
||||
const urlParts = parseLinkParts(url);
|
||||
if (!urlParts) return true;
|
||||
return (
|
||||
textParts.hostname !== urlParts.hostname ||
|
||||
textParts.port !== urlParts.port ||
|
||||
(textParts.explicitScheme && textParts.protocol !== urlParts.protocol)
|
||||
);
|
||||
}
|
||||
|
||||
function SafeMarkdownLink({
|
||||
children,
|
||||
className,
|
||||
@@ -108,6 +185,36 @@ function SafeMarkdownLink({
|
||||
);
|
||||
}
|
||||
|
||||
// Streamdown's harden step only lets http(s), mailto, tel, and
|
||||
// protocol-relative URLs reach this component, matching the sidecar's
|
||||
// open_external_url allowlist. Protocol-relative URLs fail the sidecar's
|
||||
// `new URL()` parse, so pin them to https before handing them off.
|
||||
const externalUrl = url.startsWith("//") ? `https:${url}` : url;
|
||||
const openExternally = () => void openExternalUrl(externalUrl);
|
||||
|
||||
if (!isDeceptiveLink(children, externalUrl)) {
|
||||
const openDirectly = (event: MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault();
|
||||
openExternally();
|
||||
};
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
className={`wrap-anywhere font-medium text-primary underline ${className ?? ""}`}
|
||||
data-streamdown="link"
|
||||
href={externalUrl}
|
||||
onAuxClick={(event) => {
|
||||
if (event.button === 1) openDirectly(event);
|
||||
}}
|
||||
onClick={openDirectly}
|
||||
rel="noreferrer"
|
||||
title={title ?? externalUrl}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const openConfirmation = (event: MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault();
|
||||
setIsOpen(true);
|
||||
@@ -127,15 +234,15 @@ function SafeMarkdownLink({
|
||||
href="#confirm-external-link"
|
||||
onAuxClick={confirmMiddleClick}
|
||||
onClick={openConfirmation}
|
||||
title={title ?? url}
|
||||
title={title ?? externalUrl}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
<MarkdownLinkSafetyModal
|
||||
isOpen={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
onConfirm={() => window.open(url, "_blank", "noreferrer")}
|
||||
url={url}
|
||||
onConfirm={openExternally}
|
||||
url={externalUrl}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -202,7 +309,7 @@ export const MemoizedMarkdown = memo(
|
||||
controls={streamdownControls}
|
||||
dir="auto"
|
||||
isAnimating={streaming}
|
||||
lineNumbers
|
||||
lineNumbers={false}
|
||||
mode={streaming ? "streaming" : "static"}
|
||||
normalizeHtmlIndentation
|
||||
parseIncompleteMarkdown={streaming}
|
||||
|
||||
@@ -28,7 +28,9 @@ const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
const SIDEBAR_WIDTH = 240;
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||
const SIDEBAR_WIDTH_ICON = "3rem";
|
||||
// The collapsed desktop sidebar still owns the macOS title-bar controls when
|
||||
// the native title bar overlays the webview.
|
||||
const SIDEBAR_WIDTH_ICON = "4.5rem";
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||
const SIDEBAR_WIDTH_COOKIE_NAME = "sidebar_width";
|
||||
const SIDEBAR_MIN_WIDTH = 224;
|
||||
@@ -313,8 +315,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)]"
|
||||
|
||||
@@ -60,6 +60,7 @@ describe("ChatInputBar", () => {
|
||||
refreshWorkspaces: vi.fn(async () => undefined),
|
||||
switchWorkspace: vi.fn(async () => true),
|
||||
pickWorkspaceDirectory: vi.fn(async () => null),
|
||||
selectChat: vi.fn(async () => true),
|
||||
}}
|
||||
>
|
||||
<ChatInputBar
|
||||
@@ -139,6 +140,7 @@ describe("ChatInputBar", () => {
|
||||
refreshWorkspaces: vi.fn(async () => undefined),
|
||||
switchWorkspace: vi.fn(async () => true),
|
||||
pickWorkspaceDirectory: vi.fn(async () => null),
|
||||
selectChat: vi.fn(async () => true),
|
||||
}}
|
||||
>
|
||||
<ChatInputBar
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared/browser";
|
||||
import {
|
||||
ArrowUp,
|
||||
Brain,
|
||||
@@ -13,14 +14,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 +35,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 = {
|
||||
@@ -69,7 +63,7 @@ const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
|
||||
];
|
||||
|
||||
const FALLBACK_PROVIDER_MODELS: Record<string, string[]> = {
|
||||
cline: ["anthropic/claude-sonnet-4.6"],
|
||||
cline: [CLINE_DEFAULT_MODEL_ID],
|
||||
anthropic: ["claude-sonnet-4-6"],
|
||||
"openai-native": ["gpt-5.5"],
|
||||
openrouter: ["anthropic/claude-sonnet-4.6"],
|
||||
@@ -77,7 +71,7 @@ const FALLBACK_PROVIDER_MODELS: Record<string, string[]> = {
|
||||
};
|
||||
|
||||
const FALLBACK_PROVIDER_REASONING_MODELS: Record<string, string[]> = {
|
||||
cline: ["anthropic/claude-sonnet-4.6"],
|
||||
cline: [CLINE_DEFAULT_MODEL_ID],
|
||||
anthropic: ["claude-sonnet-4-6"],
|
||||
"openai-native": ["gpt-5.5"],
|
||||
openrouter: ["anthropic/claude-sonnet-4.6"],
|
||||
@@ -1045,7 +1039,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 +1081,6 @@ export function ChatInputBar({
|
||||
}
|
||||
onProviderChange={onProviderChange}
|
||||
provider={provider}
|
||||
variant={variant}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
@@ -1097,7 +1090,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 +1121,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 +1174,6 @@ function ModelSelector({
|
||||
provider,
|
||||
model,
|
||||
isBusy,
|
||||
variant,
|
||||
onProviderChange,
|
||||
onModelChange,
|
||||
onModelSupportsReasoningChange,
|
||||
@@ -1189,7 +1181,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 +1407,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 +1430,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,312 @@ 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 image attachments", () => {
|
||||
it("renders persisted image blocks in the user message", async () => {
|
||||
await renderMessages([
|
||||
{
|
||||
id: "user-image",
|
||||
sessionId: "session-1",
|
||||
role: "user",
|
||||
content: "Describe this",
|
||||
images: [
|
||||
{ id: "user-image-1", mediaType: "image/png", data: "aGVsbG8=" },
|
||||
],
|
||||
createdAt: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
const image = container.querySelector<HTMLImageElement>(
|
||||
'img[alt="Attachment 1"]',
|
||||
);
|
||||
expect(image?.src).toBe("data:image/png;base64,aGVsbG8=");
|
||||
expect(image?.className).toContain("max-h-[225px]");
|
||||
expect(image?.className).toContain("max-w-[225px]");
|
||||
expect(container.textContent).toContain("Describe this");
|
||||
});
|
||||
|
||||
it("expands an attachment within the conversation and closes it", async () => {
|
||||
await renderMessages([
|
||||
{
|
||||
id: "user-image",
|
||||
sessionId: "session-1",
|
||||
role: "user",
|
||||
content: "Describe this",
|
||||
images: [
|
||||
{ id: "user-image-1", mediaType: "image/png", data: "aGVsbG8=" },
|
||||
],
|
||||
createdAt: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
const expand = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Expand attachment 1"]',
|
||||
);
|
||||
await act(async () => expand?.click());
|
||||
|
||||
expect(
|
||||
container.querySelector(
|
||||
'[role="dialog"][aria-label="Expanded attachment"]',
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector<HTMLImageElement>(
|
||||
'img[alt="Expanded attachment"]',
|
||||
)?.src,
|
||||
).toBe("data:image/png;base64,aGVsbG8=");
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
||||
});
|
||||
expect(container.querySelector('[role="dialog"]')).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...");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,11 +34,16 @@ import {
|
||||
SplitIcon,
|
||||
SquareTerminalIcon,
|
||||
UndoIcon,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { memo, useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import type { ChatMessage, ChatSessionStatus } from "@/lib/chat-schema";
|
||||
import type {
|
||||
ChatMessage,
|
||||
ChatMessageImage,
|
||||
ChatSessionStatus,
|
||||
} from "@/lib/chat-schema";
|
||||
import { parseApplyPatchInput } from "@/lib/session-diff";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MemoizedMarkdown } from "../../ui/markdown";
|
||||
@@ -92,6 +97,29 @@ type AskQuestionRequestItem = {
|
||||
};
|
||||
};
|
||||
|
||||
type ChatRenderItem =
|
||||
| { type: "message"; message: ChatMessage }
|
||||
| { type: "tools"; messages: ChatMessage[] };
|
||||
|
||||
function groupConsecutiveToolMessages(
|
||||
messages: ChatMessage[],
|
||||
): ChatRenderItem[] {
|
||||
const items: ChatRenderItem[] = [];
|
||||
for (const message of messages) {
|
||||
const previous = items.at(-1);
|
||||
if (message.role === "tool") {
|
||||
if (previous?.type === "tools") {
|
||||
previous.messages.push(message);
|
||||
} else {
|
||||
items.push({ type: "tools", messages: [message] });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
items.push({ type: "message", message });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
const IS_DEBUG = process.env.NODE_ENV === "test";
|
||||
|
||||
function ChatMessagesImpl({
|
||||
@@ -116,6 +144,18 @@ function ChatMessagesImpl({
|
||||
.find((message) => message.role === "error");
|
||||
const shouldShowErrorBanner =
|
||||
Boolean(error) && (!lastErrorMessage || lastErrorMessage.content !== error);
|
||||
// Core reports "running" as soon as the turn is dispatched, well before the
|
||||
// first streamed chunk arrives, so keep the thinking indicator up until the
|
||||
// model produces output (or something else needs the user's attention).
|
||||
const lastConversationMessage = [...messages]
|
||||
.reverse()
|
||||
.find((message) => message.role !== "status");
|
||||
const isAwaitingFirstOutput =
|
||||
status === "running" &&
|
||||
!streamingMessageId &&
|
||||
lastConversationMessage?.role === "user" &&
|
||||
pendingToolApprovals.length === 0 &&
|
||||
pendingAskQuestions.length === 0;
|
||||
const [showSwitchTransition, setShowSwitchTransition] = useState(false);
|
||||
const [toolApprovalActions, setToolApprovalActions] = useState<
|
||||
Record<string, "approving" | "rejecting">
|
||||
@@ -138,9 +178,28 @@ function ChatMessagesImpl({
|
||||
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
|
||||
const [forkingMessageId, setForkingMessageId] = useState<string | null>(null);
|
||||
const [forkErrors, setForkErrors] = useState<Record<string, string>>({});
|
||||
const [expandedImage, setExpandedImage] = useState<{
|
||||
sessionId: string | null;
|
||||
image: ChatMessageImage;
|
||||
} | null>(null);
|
||||
const visibleExpandedImage =
|
||||
expandedImage?.sessionId === sessionId ? expandedImage.image : null;
|
||||
const showIdleDetails =
|
||||
!hasMessages && !isSessionSwitching && !showSwitchTransition;
|
||||
|
||||
useEffect(() => {
|
||||
if (!visibleExpandedImage) {
|
||||
return;
|
||||
}
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setExpandedImage(null);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [visibleExpandedImage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSessionSwitching) {
|
||||
setShowSwitchTransition((prev) => (prev ? false : prev));
|
||||
@@ -318,7 +377,7 @@ function ChatMessagesImpl({
|
||||
|
||||
return (
|
||||
<Conversation
|
||||
className="h-full min-h-0 min-w-0"
|
||||
className="relative isolate h-full min-h-0 min-w-0 overflow-hidden"
|
||||
key={sessionId ?? "new-chat"}
|
||||
>
|
||||
<ConversationViewport
|
||||
@@ -364,36 +423,50 @@ function ChatMessagesImpl({
|
||||
requestErrors={askQuestionErrors}
|
||||
/>
|
||||
) : null}
|
||||
{messages.map((message) => (
|
||||
<MessageBubble
|
||||
isStreaming={streamingMessageId === message.id}
|
||||
key={message.id}
|
||||
message={message}
|
||||
onCopyRawText={() =>
|
||||
void handleCopyMessage(message.id, message.content)
|
||||
}
|
||||
onRestoreCheckpoint={(runCount) =>
|
||||
void handleRestoreCheckpoint(message.id, runCount)
|
||||
}
|
||||
restoreDisabled={
|
||||
!onRestoreCheckpoint ||
|
||||
status === "starting" ||
|
||||
status === "running" ||
|
||||
status === "stopping" ||
|
||||
isSessionSwitching
|
||||
}
|
||||
restoreError={checkpointErrors[message.id]}
|
||||
restorePending={checkpointActions[message.id] === "undoing"}
|
||||
wasCopied={copiedMessageId === message.id}
|
||||
onForkSession={
|
||||
onForkSession
|
||||
? () => void handleForkSession(message.id)
|
||||
: undefined
|
||||
}
|
||||
forkPending={forkingMessageId === message.id}
|
||||
forkError={forkErrors[message.id]}
|
||||
/>
|
||||
))}
|
||||
{groupConsecutiveToolMessages(messages).map((item) => {
|
||||
if (item.type === "tools") {
|
||||
return (
|
||||
<ToolMessageBlock
|
||||
key={`tools_${item.messages[0]?.id ?? "empty"}`}
|
||||
messages={item.messages}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const { message } = item;
|
||||
return (
|
||||
<MessageBubble
|
||||
isStreaming={streamingMessageId === message.id}
|
||||
key={message.id}
|
||||
message={message}
|
||||
onExpandImage={(image) =>
|
||||
setExpandedImage({ sessionId, image })
|
||||
}
|
||||
onCopyRawText={() =>
|
||||
void handleCopyMessage(message.id, message.content)
|
||||
}
|
||||
onRestoreCheckpoint={(runCount) =>
|
||||
void handleRestoreCheckpoint(message.id, runCount)
|
||||
}
|
||||
restoreDisabled={
|
||||
!onRestoreCheckpoint ||
|
||||
status === "starting" ||
|
||||
status === "running" ||
|
||||
status === "stopping" ||
|
||||
isSessionSwitching
|
||||
}
|
||||
restoreError={checkpointErrors[message.id]}
|
||||
restorePending={checkpointActions[message.id] === "undoing"}
|
||||
wasCopied={copiedMessageId === message.id}
|
||||
onForkSession={
|
||||
onForkSession
|
||||
? () => void handleForkSession(message.id)
|
||||
: undefined
|
||||
}
|
||||
forkPending={forkingMessageId === message.id}
|
||||
forkError={forkErrors[message.id]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{showSwitchTransition ? (
|
||||
@@ -418,7 +491,8 @@ function ChatMessagesImpl({
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
{status === "starting" && !isSessionSwitching ? (
|
||||
{(status === "starting" || isAwaitingFirstOutput) &&
|
||||
!isSessionSwitching ? (
|
||||
<div className="mt-4 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Thinking...
|
||||
@@ -442,6 +516,39 @@ function ChatMessagesImpl({
|
||||
</ConversationContent>
|
||||
</ConversationViewport>
|
||||
<ConversationScrollButton />
|
||||
{visibleExpandedImage ? (
|
||||
<div
|
||||
aria-label="Expanded attachment"
|
||||
aria-modal="true"
|
||||
className="absolute inset-0 z-50 flex items-center justify-center bg-background/95 p-4 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
>
|
||||
<button
|
||||
aria-label="Close expanded attachment"
|
||||
className="absolute inset-0 cursor-zoom-out"
|
||||
onClick={() => setExpandedImage(null)}
|
||||
type="button"
|
||||
/>
|
||||
<div className="pointer-events-none relative z-10 flex h-full w-full items-center justify-center">
|
||||
{/* biome-ignore lint/performance/noImgElement: User-provided data URLs cannot use Next's optimizer. */}
|
||||
<img
|
||||
alt="Expanded attachment"
|
||||
className="max-h-full max-w-full rounded-lg object-contain shadow-2xl"
|
||||
src={`data:${visibleExpandedImage.mediaType};base64,${visibleExpandedImage.data}`}
|
||||
/>
|
||||
<Button
|
||||
aria-label="Close image viewer"
|
||||
className="pointer-events-auto absolute right-0 top-0 rounded-full"
|
||||
onClick={() => setExpandedImage(null)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Conversation>
|
||||
);
|
||||
}
|
||||
@@ -644,6 +751,7 @@ function MessageBubble({
|
||||
message,
|
||||
isStreaming = false,
|
||||
onCopyRawText,
|
||||
onExpandImage,
|
||||
onRestoreCheckpoint,
|
||||
restoreDisabled = false,
|
||||
restorePending = false,
|
||||
@@ -656,6 +764,7 @@ function MessageBubble({
|
||||
message: ChatMessage;
|
||||
isStreaming?: boolean;
|
||||
onCopyRawText?: () => void;
|
||||
onExpandImage?: (image: ChatMessageImage) => void;
|
||||
onRestoreCheckpoint?: (runCount: number) => void;
|
||||
restoreDisabled?: boolean;
|
||||
restorePending?: boolean;
|
||||
@@ -668,24 +777,21 @@ function MessageBubble({
|
||||
const isUser = message.role === "user";
|
||||
const isError = message.role === "error";
|
||||
const checkpoint = message.meta?.checkpoint;
|
||||
const displayContent = formatChatMessageContent(
|
||||
message.role,
|
||||
message.content,
|
||||
);
|
||||
const shouldRenderAssistantActions =
|
||||
message.role === "assistant" &&
|
||||
!isStreaming &&
|
||||
!isError &&
|
||||
Boolean(displayContent.trim()) &&
|
||||
Boolean(onCopyRawText || onForkSession);
|
||||
const shouldRenderUserActions =
|
||||
isUser && Boolean(onCopyRawText || checkpoint);
|
||||
const keepUserActionsVisible = restorePending || Boolean(restoreError);
|
||||
const keepAssistantActionsVisible = forkPending || Boolean(forkError);
|
||||
|
||||
if (message.role === "tool") {
|
||||
return <ToolMessageBlock message={message} />;
|
||||
}
|
||||
|
||||
const displayContent = formatChatMessageContent(
|
||||
message.role,
|
||||
message.content,
|
||||
);
|
||||
const reasoningContent = message.reasoning?.trim() || "";
|
||||
|
||||
return (
|
||||
@@ -699,12 +805,35 @@ function MessageBubble({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="my-1 min-w-0 max-w-full wrap-break-word">
|
||||
<MemoizedMarkdown
|
||||
content={displayContent || " "}
|
||||
streaming={isStreaming && message.role === "assistant"}
|
||||
/>
|
||||
</div>
|
||||
{message.images?.length ? (
|
||||
<div className="grid max-w-2xl gap-2">
|
||||
{message.images.map((image, index) => (
|
||||
<button
|
||||
aria-label={`Expand attachment ${index + 1}`}
|
||||
className="cursor-zoom-in overflow-hidden rounded-lg border border-border bg-muted text-left transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
key={image.id}
|
||||
onClick={() => onExpandImage?.(image)}
|
||||
type="button"
|
||||
>
|
||||
{/* biome-ignore lint/performance/noImgElement: User-provided data URLs do not have dimensions and cannot use Next's optimizer. */}
|
||||
<img
|
||||
alt={`Attachment ${index + 1}`}
|
||||
className="max-h-[225px] max-w-[225px] object-contain"
|
||||
src={`data:${image.mediaType};base64,${image.data}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{displayContent ? (
|
||||
<div className="my-1 min-w-0 max-w-full wrap-break-word">
|
||||
<MemoizedMarkdown
|
||||
content={displayContent}
|
||||
streaming={isStreaming && message.role === "assistant"}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</MessageContent>
|
||||
|
||||
{shouldRenderUserActions ? (
|
||||
@@ -822,6 +951,13 @@ type ToolPayload = {
|
||||
type ToolSummary = {
|
||||
label: string;
|
||||
details: string[];
|
||||
aggregate?: {
|
||||
key: string;
|
||||
count: number;
|
||||
noun: string;
|
||||
completedVerb: string;
|
||||
progressVerb: string;
|
||||
};
|
||||
diff?: {
|
||||
additions: number;
|
||||
deletions: number;
|
||||
@@ -921,7 +1057,10 @@ function classifyTool(
|
||||
)
|
||||
return "file-edit";
|
||||
if (["bash", "run_commands"].includes(normalized)) return "bash";
|
||||
if (["spawn_agent", "spawn-agent", "spawn_agent_tool"].includes(normalized))
|
||||
if (
|
||||
["spawn_agent", "spawn-agent", "spawn_agent_tool"].includes(normalized) ||
|
||||
normalized.startsWith("subagent_")
|
||||
)
|
||||
return "spawn";
|
||||
return "tool";
|
||||
}
|
||||
@@ -1038,6 +1177,13 @@ function buildToolSummary(
|
||||
if (files.length > 0) {
|
||||
return {
|
||||
label: `${inProgress ? "Reading" : "Read"} ${pluralize(files.length, "file")}`,
|
||||
aggregate: {
|
||||
key: "read-files",
|
||||
count: files.length,
|
||||
noun: "file",
|
||||
completedVerb: "Read",
|
||||
progressVerb: "Reading",
|
||||
},
|
||||
details: files.map(
|
||||
(file) => `${inProgress ? "Reading" : "Read"} ${toDisplayPath(file)}`,
|
||||
),
|
||||
@@ -1050,6 +1196,13 @@ function buildToolSummary(
|
||||
if (queries.length > 0) {
|
||||
return {
|
||||
label: `${inProgress ? "Exploring" : "Explored"} ${pluralize(queries.length, "search")}`,
|
||||
aggregate: {
|
||||
key: "searches",
|
||||
count: queries.length,
|
||||
noun: "search",
|
||||
completedVerb: "Explored",
|
||||
progressVerb: "Exploring",
|
||||
},
|
||||
details: queries.map((query) => query),
|
||||
};
|
||||
}
|
||||
@@ -1060,6 +1213,13 @@ function buildToolSummary(
|
||||
if (commands.length > 0) {
|
||||
return {
|
||||
label: `${inProgress ? "Running" : "Ran"} ${pluralize(commands.length, "command")}`,
|
||||
aggregate: {
|
||||
key: "commands",
|
||||
count: commands.length,
|
||||
noun: "command",
|
||||
completedVerb: "Ran",
|
||||
progressVerb: "Running",
|
||||
},
|
||||
details: commands.map((command) => command.trim()),
|
||||
};
|
||||
}
|
||||
@@ -1080,6 +1240,13 @@ function buildToolSummary(
|
||||
if (urls.length > 0) {
|
||||
return {
|
||||
label: `${inProgress ? "Exploring" : "Explored"} ${pluralize(urls.length, "link")}`,
|
||||
aggregate: {
|
||||
key: "links",
|
||||
count: urls.length,
|
||||
noun: "link",
|
||||
completedVerb: "Explored",
|
||||
progressVerb: "Exploring",
|
||||
},
|
||||
details: urls.map(
|
||||
(url) => `${inProgress ? "Fetching" : "Fetched"} ${url}`,
|
||||
),
|
||||
@@ -1100,6 +1267,13 @@ function buildToolSummary(
|
||||
const deletions = fileDiffs.reduce((sum, d) => sum + d.deletions, 0);
|
||||
return {
|
||||
label: `${inProgress ? "Editing" : "Edited"} ${pluralize(fileDiffs.length, "file")}`,
|
||||
aggregate: {
|
||||
key: "edited-files",
|
||||
count: fileDiffs.length,
|
||||
noun: "file",
|
||||
completedVerb: "Edited",
|
||||
progressVerb: "Editing",
|
||||
},
|
||||
diff: { additions, deletions },
|
||||
details: fileDiffs.map(
|
||||
(d) =>
|
||||
@@ -1147,18 +1321,30 @@ function buildToolSummary(
|
||||
: "Edited";
|
||||
// The label already carries all the information; no expandable details.
|
||||
const detail = `${action} ${path}`;
|
||||
const aggregate = {
|
||||
key: "edited-files",
|
||||
count: 1,
|
||||
noun: "file",
|
||||
completedVerb: "Edited",
|
||||
progressVerb: "Editing",
|
||||
};
|
||||
if (diff) {
|
||||
return { label: detail, diff, details: [] };
|
||||
return { label: detail, aggregate, diff, details: [] };
|
||||
}
|
||||
return { label: detail, details: [] };
|
||||
return { label: detail, aggregate, details: [] };
|
||||
}
|
||||
|
||||
const query =
|
||||
typeof asRecord(result)?.query === "string"
|
||||
? (asRecord(result)?.query as string)
|
||||
: "";
|
||||
const displayToolName = normalized.startsWith("subagent_")
|
||||
? "spawn_agent"
|
||||
: toolName;
|
||||
const fallback =
|
||||
query || (inProgress ? `Running ${toolName}` : toolName) || "Tool";
|
||||
query ||
|
||||
(inProgress ? `Running ${displayToolName}` : displayToolName) ||
|
||||
"Tool";
|
||||
return { label: fallback, details: [fallback] };
|
||||
}
|
||||
|
||||
@@ -1188,7 +1374,16 @@ function buildToolSummaryFromMeta(
|
||||
return { label: inProgress ? `Running ${toolName}` : toolName, details: [] };
|
||||
}
|
||||
|
||||
function ToolMessageBlock({ message }: { message: ChatMessage }) {
|
||||
type ToolPresentation = {
|
||||
message: ChatMessage;
|
||||
payload: ToolPayload | null;
|
||||
toolName: string;
|
||||
kind: ReturnType<typeof classifyTool>;
|
||||
inProgress: boolean;
|
||||
summary: ToolSummary;
|
||||
};
|
||||
|
||||
function buildToolPresentation(message: ChatMessage): ToolPresentation {
|
||||
const payload = parseToolPayload(message.content);
|
||||
const toolName = message.meta?.toolName || payload?.toolName || "tool";
|
||||
const hookEventName = message.meta?.hookEventName;
|
||||
@@ -1197,8 +1392,77 @@ function ToolMessageBlock({ message }: { message: ChatMessage }) {
|
||||
hookEventName === "history_tool_use" ||
|
||||
(Boolean(payload) && payload?.result == null && !payload?.isError);
|
||||
const kind = classifyTool(toolName);
|
||||
const isFileRead = ["read_files", "file_read", "file-read"].includes(
|
||||
toolName.toLowerCase(),
|
||||
const summary = payload
|
||||
? buildToolSummary(toolName, payload.input, payload.result, inProgress)
|
||||
: buildToolSummaryFromMeta(toolName, kind, inProgress);
|
||||
return { message, payload, toolName, kind, inProgress, summary };
|
||||
}
|
||||
|
||||
function buildGroupedToolLabel(presentations: ToolPresentation[]): string {
|
||||
if (presentations.length === 1) {
|
||||
return presentations[0]?.summary.label ?? "Tool";
|
||||
}
|
||||
|
||||
type Segment =
|
||||
| { type: "label"; label: string }
|
||||
| {
|
||||
type: "aggregate";
|
||||
aggregate: NonNullable<ToolSummary["aggregate"]> & {
|
||||
inProgress: boolean;
|
||||
};
|
||||
};
|
||||
const segments: Segment[] = [];
|
||||
for (const presentation of presentations) {
|
||||
const aggregate = presentation.summary.aggregate;
|
||||
if (!aggregate) {
|
||||
segments.push({ type: "label", label: presentation.summary.label });
|
||||
continue;
|
||||
}
|
||||
|
||||
const previous = segments.at(-1);
|
||||
if (
|
||||
previous?.type === "aggregate" &&
|
||||
previous.aggregate.key === aggregate.key
|
||||
) {
|
||||
segments[segments.length - 1] = {
|
||||
type: "aggregate",
|
||||
aggregate: {
|
||||
...previous.aggregate,
|
||||
count: previous.aggregate.count + aggregate.count,
|
||||
inProgress: previous.aggregate.inProgress || presentation.inProgress,
|
||||
},
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
segments.push({
|
||||
type: "aggregate",
|
||||
aggregate: { ...aggregate, inProgress: presentation.inProgress },
|
||||
});
|
||||
}
|
||||
|
||||
return segments
|
||||
.map((segment) => {
|
||||
if (segment.type === "label") return segment.label;
|
||||
const { aggregate } = segment;
|
||||
const verb = aggregate.inProgress
|
||||
? aggregate.progressVerb
|
||||
: aggregate.completedVerb;
|
||||
return `${verb} ${pluralize(aggregate.count, aggregate.noun)}`;
|
||||
})
|
||||
.join(". ");
|
||||
}
|
||||
|
||||
function ToolMessageBlock({ messages }: { messages: ChatMessage[] }) {
|
||||
const presentations = messages.map(buildToolPresentation);
|
||||
const first = presentations[0];
|
||||
if (!first) return null;
|
||||
const hasError = presentations.some(({ payload }) => payload?.isError);
|
||||
const isRunning = presentations.some(({ inProgress }) => inProgress);
|
||||
const kinds = new Set(presentations.map(({ kind }) => kind));
|
||||
const kind = kinds.size === 1 ? first.kind : "tool";
|
||||
const isFileRead = presentations.every(({ toolName }) =>
|
||||
["read_files", "file_read", "file-read"].includes(toolName.toLowerCase()),
|
||||
);
|
||||
const Icon = isFileRead
|
||||
? FileIcon
|
||||
@@ -1211,58 +1475,77 @@ function ToolMessageBlock({ message }: { message: ChatMessage }) {
|
||||
: kind === "spawn"
|
||||
? Bot
|
||||
: FileSearch;
|
||||
const summary = payload
|
||||
? buildToolSummary(toolName, payload.input, payload.result, inProgress)
|
||||
: buildToolSummaryFromMeta(toolName, kind, inProgress);
|
||||
const details = summary.details;
|
||||
const inputPreview =
|
||||
IS_DEBUG && payload ? formatToolValue(payload.input) : "";
|
||||
const resultPreview = payload?.isError ? formatToolValue(payload.result) : "";
|
||||
const details = presentations.flatMap(({ message, summary }) =>
|
||||
summary.details.map((detail) => ({
|
||||
detail,
|
||||
key: `${message.id}_${detail}`,
|
||||
})),
|
||||
);
|
||||
const inputPreviews = IS_DEBUG
|
||||
? presentations
|
||||
.map(({ message, payload, toolName }) => ({
|
||||
key: message.id,
|
||||
toolName,
|
||||
value: payload ? formatToolValue(payload.input) : "",
|
||||
}))
|
||||
.filter(({ value }) => Boolean(value))
|
||||
: [];
|
||||
const resultPreviews = presentations
|
||||
.map(({ message, payload, toolName }) => ({
|
||||
key: message.id,
|
||||
toolName,
|
||||
value: payload?.isError ? formatToolValue(payload.result) : "",
|
||||
}))
|
||||
.filter(({ value }) => Boolean(value));
|
||||
const hasExpandedSections =
|
||||
details.length > 0 || Boolean(inputPreview || resultPreview);
|
||||
details.length > 0 || inputPreviews.length > 0 || resultPreviews.length > 0;
|
||||
const diff = presentations.reduce(
|
||||
(total, { summary }) => ({
|
||||
additions: total.additions + (summary.diff?.additions ?? 0),
|
||||
deletions: total.deletions + (summary.diff?.deletions ?? 0),
|
||||
}),
|
||||
{ additions: 0, deletions: 0 },
|
||||
);
|
||||
|
||||
return (
|
||||
<ToolActivity expandable={hasExpandedSections}>
|
||||
<ToolActivityTrigger
|
||||
additions={summary.diff?.additions}
|
||||
deletions={summary.diff?.deletions}
|
||||
additions={diff.additions || undefined}
|
||||
deletions={diff.deletions || undefined}
|
||||
icon={
|
||||
payload?.isError ? (
|
||||
hasError ? (
|
||||
<AlertCircle className="size-4 text-destructive/80" />
|
||||
) : (
|
||||
<Icon className="size-4" />
|
||||
)
|
||||
}
|
||||
label={summary.label}
|
||||
status={payload?.isError ? "error" : inProgress ? "running" : "success"}
|
||||
label={buildGroupedToolLabel(presentations)}
|
||||
status={hasError ? "error" : isRunning ? "running" : "success"}
|
||||
/>
|
||||
<ToolActivityContent>
|
||||
{details.length > 0 ? (
|
||||
<ToolActivityDetails>
|
||||
{details.map((detail) => (
|
||||
<div key={`${message.id}_${detail}`}>{detail}</div>
|
||||
{details.map(({ detail, key }) => (
|
||||
<div key={key}>{detail}</div>
|
||||
))}
|
||||
</ToolActivityDetails>
|
||||
) : null}
|
||||
{inputPreview ? (
|
||||
<div className="space-y-1">
|
||||
{inputPreviews.map((preview) => (
|
||||
<div className="space-y-1" key={`input_${preview.key}`}>
|
||||
<div className="text-[11px] uppercase tracking-wide text-muted-foreground/80">
|
||||
Input
|
||||
{presentations.length > 1 ? `${preview.toolName} input` : "Input"}
|
||||
</div>
|
||||
<ToolActivityCode className="text-sm">
|
||||
{inputPreview}
|
||||
{preview.value}
|
||||
</ToolActivityCode>
|
||||
</div>
|
||||
) : null}
|
||||
{resultPreview ? (
|
||||
payload?.isError ? (
|
||||
<div className="mt-1 text-destructive">{resultPreview}</div>
|
||||
) : (
|
||||
<ToolActivityCode className="max-h-64 text-sm">
|
||||
{resultPreview}
|
||||
</ToolActivityCode>
|
||||
)
|
||||
) : null}
|
||||
))}
|
||||
{resultPreviews.map((preview) => (
|
||||
<div className="mt-1 text-destructive" key={`result_${preview.key}`}>
|
||||
{presentations.length > 1 ? `${preview.toolName}: ` : null}
|
||||
{preview.value}
|
||||
</div>
|
||||
))}
|
||||
</ToolActivityContent>
|
||||
</ToolActivity>
|
||||
);
|
||||
|
||||
@@ -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,110 @@
|
||||
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}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<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}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<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}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<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}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<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}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<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}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<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,37 +1,127 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
// @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 { WorkspaceProvider } from "@/contexts/workspace-context";
|
||||
import { WelcomeScreen } from "./welcome-chat";
|
||||
|
||||
describe("WelcomeScreen", () => {
|
||||
it("renders every known project instead of capping the project strip", () => {
|
||||
const workspaces = Array.from(
|
||||
{ length: 6 },
|
||||
(_, index) => `/projects/project-${index + 1}`,
|
||||
);
|
||||
const html = renderToStaticMarkup(
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
window.matchMedia = vi.fn().mockReturnValue({
|
||||
matches: true,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function renderWelcomeScreen({
|
||||
workspaceRoot,
|
||||
workspaces,
|
||||
selectChat = vi.fn(async () => true),
|
||||
onListGitBranches = vi.fn(async () => ({
|
||||
current: "main",
|
||||
branches: ["main"],
|
||||
})),
|
||||
}: {
|
||||
workspaceRoot: string;
|
||||
workspaces: string[];
|
||||
selectChat?: () => Promise<boolean>;
|
||||
onListGitBranches?: () => Promise<{
|
||||
current: string;
|
||||
branches: string[];
|
||||
}>;
|
||||
}): Promise<void> {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WorkspaceProvider
|
||||
value={{
|
||||
workspaceRoot: workspaces[0] ?? "",
|
||||
workspaceRoot,
|
||||
workspaces,
|
||||
listWorkspaces: vi.fn(async () => workspaces),
|
||||
refreshWorkspaces: vi.fn(async () => undefined),
|
||||
switchWorkspace: vi.fn(async () => true),
|
||||
pickWorkspaceDirectory: vi.fn(async () => null),
|
||||
selectChat,
|
||||
}}
|
||||
>
|
||||
<WelcomeScreen
|
||||
active
|
||||
body={null}
|
||||
composer={null}
|
||||
gitBranch="main"
|
||||
onListGitBranches={onListGitBranches}
|
||||
onStartChat={vi.fn()}
|
||||
onSwitchGitBranch={vi.fn(async () => true)}
|
||||
quickActions={[]}
|
||||
/>
|
||||
</WorkspaceProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
async function clickButton(text: string, last = false): Promise<void> {
|
||||
const buttons = [
|
||||
...container.querySelectorAll<HTMLButtonElement>("button"),
|
||||
].filter((candidate) => candidate.textContent?.includes(text));
|
||||
const button = last ? buttons.at(-1) : buttons[0];
|
||||
expect(button).toBeDefined();
|
||||
await act(async () => {
|
||||
button?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe("WelcomeScreen", () => {
|
||||
it("renders every known project in the opened workspace menu", async () => {
|
||||
const workspaces = Array.from(
|
||||
{ length: 6 },
|
||||
(_, index) => `/projects/project-${index + 1}`,
|
||||
);
|
||||
await renderWelcomeScreen({
|
||||
workspaceRoot: workspaces[0] ?? "",
|
||||
workspaces,
|
||||
});
|
||||
|
||||
await clickButton("project-1");
|
||||
|
||||
for (let index = 1; index <= workspaces.length; index += 1) {
|
||||
expect(html).toContain(`project-${index}`);
|
||||
expect(container.textContent).toContain(`project-${index}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("selects Just chat from the pathless workspace menu", async () => {
|
||||
const selectChat = vi.fn(async () => true);
|
||||
const onListGitBranches = vi.fn(async () => ({
|
||||
current: "main",
|
||||
branches: ["main"],
|
||||
}));
|
||||
await renderWelcomeScreen({
|
||||
workspaceRoot: "",
|
||||
workspaces: ["/projects/existing"],
|
||||
selectChat,
|
||||
onListGitBranches,
|
||||
});
|
||||
|
||||
expect(container.querySelector('button[title="main"]')).toBeNull();
|
||||
expect(onListGitBranches).not.toHaveBeenCalled();
|
||||
await clickButton("Chat");
|
||||
expect(container.textContent).toContain("/projects/existing");
|
||||
await clickButton("Just chat", true);
|
||||
|
||||
expect(selectChat).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
@@ -79,62 +99,15 @@ export function WelcomeScreen({
|
||||
refreshWorkspaces,
|
||||
switchWorkspace,
|
||||
pickWorkspaceDirectory,
|
||||
selectChat,
|
||||
} = 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 +127,26 @@ 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}
|
||||
onSelectChat={selectChat}
|
||||
onSwitchGitBranch={onSwitchGitBranch}
|
||||
onSwitchWorkspace={switchWorkspace}
|
||||
workspaceRoot={workspaceRoot}
|
||||
workspaces={workspaces}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
+444
@@ -0,0 +1,444 @@
|
||||
"use client";
|
||||
|
||||
import { isChatWorkspacePath } from "@cline/shared/browser";
|
||||
import {
|
||||
Check,
|
||||
FilePlus2,
|
||||
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,
|
||||
onSelectChat,
|
||||
}: {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
onClose: () => void;
|
||||
workspaceRoot: string;
|
||||
workspaces: string[];
|
||||
onRefreshWorkspaces: () => Promise<void>;
|
||||
onSwitchWorkspace: (workspacePath: string) => Promise<boolean>;
|
||||
onPickWorkspaceDirectory: (initialPath?: string) => Promise<string | null>;
|
||||
onSelectChat: () => Promise<boolean>;
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const [picking, setPicking] = useState(false);
|
||||
const [selectingChat, setSelectingChat] = useState(false);
|
||||
const isChatWorkspace =
|
||||
!workspaceRoot.trim() || isChatWorkspacePath(workspaceRoot);
|
||||
|
||||
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);
|
||||
};
|
||||
if (!isChatWorkspace) register(workspaceRoot);
|
||||
for (const path of workspaces) register(path);
|
||||
return [...byNormalizedPath.values()];
|
||||
}, [isChatWorkspace, 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 || selectingChat || switching) return;
|
||||
setPicking(true);
|
||||
try {
|
||||
const picked = await onPickWorkspaceDirectory(
|
||||
isChatWorkspace ? undefined : workspaceRoot || undefined,
|
||||
);
|
||||
if (picked?.trim()) await handleSelect(picked.trim());
|
||||
} finally {
|
||||
setPicking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectChat = async () => {
|
||||
if (picking || selectingChat || switching) return;
|
||||
setSelectingChat(true);
|
||||
try {
|
||||
if (await onSelectChat()) onClose();
|
||||
} finally {
|
||||
setSelectingChat(false);
|
||||
}
|
||||
};
|
||||
|
||||
const workspaceLabel = isChatWorkspace
|
||||
? "Chat"
|
||||
: workspaceName(workspaceRoot);
|
||||
|
||||
return (
|
||||
<div className="relative shrink-0">
|
||||
<button
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
className={TRIGGER_CLASS}
|
||||
onClick={onToggle}
|
||||
title={workspaceLabel}
|
||||
type="button"
|
||||
>
|
||||
<Folder className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="max-w-44 truncate">{workspaceLabel}</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 || selectingChat}
|
||||
onClick={() => void handleAddWorkspace()}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
{picking ? "Opening folder picker..." : "Add project..."}
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full justify-start text-xs text-muted-foreground"
|
||||
disabled={switching || picking || selectingChat}
|
||||
onClick={() => void handleSelectChat()}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<FilePlus2 className="size-3" />
|
||||
{selectingChat ? "Switching to chat..." : "Just chat"}
|
||||
</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,
|
||||
onSelectChat,
|
||||
currentBranch,
|
||||
onListGitBranches,
|
||||
onSwitchGitBranch,
|
||||
}: {
|
||||
workspaceRoot: string;
|
||||
workspaces: string[];
|
||||
onRefreshWorkspaces: () => Promise<void>;
|
||||
onSwitchWorkspace: (workspacePath: string) => Promise<boolean>;
|
||||
onPickWorkspaceDirectory: (initialPath?: string) => Promise<string | null>;
|
||||
onSelectChat: () => Promise<boolean>;
|
||||
currentBranch: string;
|
||||
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
|
||||
onSwitchGitBranch: (branch: string) => Promise<boolean>;
|
||||
}) {
|
||||
const [openMenu, setOpenMenu] = useState<"workspace" | "branch" | null>(null);
|
||||
const isChatWorkspace =
|
||||
!workspaceRoot.trim() || isChatWorkspacePath(workspaceRoot);
|
||||
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}
|
||||
onSelectChat={onSelectChat}
|
||||
onSwitchWorkspace={onSwitchWorkspace}
|
||||
onToggle={() =>
|
||||
setOpenMenu((current) =>
|
||||
current === "workspace" ? null : "workspace",
|
||||
)
|
||||
}
|
||||
open={openMenu === "workspace"}
|
||||
workspaceRoot={workspaceRoot}
|
||||
workspaces={workspaces}
|
||||
/>
|
||||
{!isChatWorkspace ? (
|
||||
<BranchPicker
|
||||
currentBranch={currentBranch}
|
||||
onClose={() => setOpenMenu(null)}
|
||||
onListGitBranches={onListGitBranches}
|
||||
onSwitchGitBranch={onSwitchGitBranch}
|
||||
onToggle={() =>
|
||||
setOpenMenu((current) => (current === "branch" ? null : "branch"))
|
||||
}
|
||||
open={openMenu === "branch"}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -79,4 +79,58 @@ 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");
|
||||
});
|
||||
});
|
||||
|
||||
it("labels the SDK chat workspace as Chat without listing the raw path", async () => {
|
||||
const temporaryWorkspace = "/home/host/.cline/data/workspaces/chat";
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WorkspaceSelector
|
||||
currentBranch="no-git"
|
||||
onListGitBranches={vi.fn(async () => ({
|
||||
current: "no-git",
|
||||
branches: [],
|
||||
}))}
|
||||
onPickWorkspaceDirectory={vi.fn(async () => null)}
|
||||
onRefreshWorkspaces={vi.fn(async () => undefined)}
|
||||
onSwitchGitBranch={vi.fn(async () => false)}
|
||||
onSwitchWorkspace={vi.fn(async () => true)}
|
||||
workspaceRoot={temporaryWorkspace}
|
||||
workspaces={["/workspace/one"]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Chat");
|
||||
await click(container.querySelector("#git-branch-btn") as Element);
|
||||
await vi.waitFor(() => {
|
||||
expect(container.textContent).toContain("/workspace/one");
|
||||
});
|
||||
expect(container.textContent).not.toContain(temporaryWorkspace);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { isChatWorkspacePath } from "@cline/shared/browser";
|
||||
import { Check, FolderCode, GitBranch, Plus, Search } from "lucide-react";
|
||||
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 +21,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,
|
||||
@@ -64,9 +55,12 @@ export function WorkspaceSelector({
|
||||
const [newBranchName, setNewBranchName] = useState("");
|
||||
|
||||
const workspaceName = useMemo(() => {
|
||||
if (isChatWorkspacePath(workspaceRoot)) {
|
||||
return "Chat";
|
||||
}
|
||||
const trimmed = workspaceRoot.trim().replace(/[\\/]+$/, "");
|
||||
if (!trimmed) {
|
||||
return "workspace";
|
||||
return "Chat";
|
||||
}
|
||||
const parts = trimmed.split(/[\\/]/);
|
||||
return parts[parts.length - 1] || "workspace";
|
||||
@@ -181,7 +175,21 @@ 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);
|
||||
};
|
||||
if (!isChatWorkspacePath(workspaceRoot)) register(workspaceRoot);
|
||||
for (const path of workspaces) register(path);
|
||||
return [...byNormalizedPath.values()];
|
||||
}, [workspaceRoot, workspaces]);
|
||||
|
||||
const filteredWorkspaces = availableWorkspaces.filter((w) =>
|
||||
w.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import { desktopClient, openExternalUrl } from "@/lib/desktop-client";
|
||||
import {
|
||||
fetchMarketplaceCatalog,
|
||||
type MarketplaceCatalog,
|
||||
@@ -228,6 +228,15 @@ function EntryDetails({
|
||||
<a
|
||||
className="mt-1 inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
||||
href={env.url}
|
||||
onAuxClick={(event) => {
|
||||
if (event.button !== 1) return;
|
||||
event.preventDefault();
|
||||
if (env.url) void openExternalUrl(env.url);
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
if (env.url) void openExternalUrl(env.url);
|
||||
}}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
@@ -457,7 +466,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 +477,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 +572,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 +795,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}
|
||||
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
// @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 { AccountProvider } from "@/contexts/account-context";
|
||||
import {
|
||||
MODEL_SELECTION_STORAGE_KEY,
|
||||
parseModelSelectionStorage,
|
||||
} from "@/lib/model-selection";
|
||||
import type { Provider } from "@/lib/provider-schema";
|
||||
import { OnboardingView, sortProvidersForApiKeySetup } from "./onboarding-view";
|
||||
|
||||
const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() }));
|
||||
vi.mock("@/lib/desktop-client", () => ({
|
||||
desktopClient: { invoke },
|
||||
openExternalUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
function makeProvider(overrides: Partial<Provider> = {}): Provider {
|
||||
return {
|
||||
id: "anthropic",
|
||||
name: "Anthropic",
|
||||
models: 4,
|
||||
color: "#000",
|
||||
letter: "A",
|
||||
enabled: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("sortProvidersForApiKeySetup", () => {
|
||||
it("drops OAuth-managed providers and ranks popular ones first", () => {
|
||||
const sorted = sortProvidersForApiKeySetup([
|
||||
makeProvider({ id: "zai", name: "Z AI" }),
|
||||
makeProvider({ id: "cline", name: "Cline" }),
|
||||
makeProvider({ id: "openai-codex", name: "ChatGPT" }),
|
||||
makeProvider({ id: "openrouter", name: "OpenRouter" }),
|
||||
makeProvider({ id: "anthropic", name: "Anthropic" }),
|
||||
makeProvider({ id: "baseten", name: "Baseten" }),
|
||||
]);
|
||||
expect(sorted.map((provider) => provider.id)).toEqual([
|
||||
"anthropic",
|
||||
"openrouter",
|
||||
"baseten",
|
||||
"zai",
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops providers the API-key form cannot fully configure", () => {
|
||||
const apiKeyField = {
|
||||
path: "apiKey",
|
||||
label: "API Key",
|
||||
type: "password" as const,
|
||||
};
|
||||
const sorted = sortProvidersForApiKeySetup([
|
||||
makeProvider({
|
||||
id: "vertex",
|
||||
name: "Google Vertex AI",
|
||||
configFields: [
|
||||
{ path: "gcp.projectId", label: "Project", type: "text" },
|
||||
apiKeyField,
|
||||
],
|
||||
}),
|
||||
makeProvider({
|
||||
id: "bedrock",
|
||||
name: "AWS Bedrock",
|
||||
configFields: [
|
||||
{ path: "aws.region", label: "Region", type: "text" },
|
||||
apiKeyField,
|
||||
],
|
||||
}),
|
||||
makeProvider({
|
||||
id: "claude-code",
|
||||
name: "Claude Code",
|
||||
configFields: [],
|
||||
}),
|
||||
makeProvider({
|
||||
id: "ollama",
|
||||
name: "Ollama",
|
||||
configFields: [
|
||||
apiKeyField,
|
||||
{ path: "baseUrl", label: "Base URL", type: "url" },
|
||||
],
|
||||
}),
|
||||
makeProvider({ id: "anthropic", name: "Anthropic" }),
|
||||
]);
|
||||
expect(sorted.map((provider) => provider.id)).toEqual([
|
||||
"anthropic",
|
||||
"ollama",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OnboardingView", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
window.localStorage.clear();
|
||||
invoke.mockReset();
|
||||
// AccountProvider fetches the account on mount; unresolved auth means
|
||||
// the signed-out variant of the connect step renders.
|
||||
invoke.mockImplementation(async (command: string) => {
|
||||
if (command === "cline_account") {
|
||||
throw new Error("No Cline account auth token found");
|
||||
}
|
||||
if (command === "list_provider_catalog") {
|
||||
return {
|
||||
providers: [
|
||||
makeProvider(),
|
||||
makeProvider({ id: "openrouter", name: "OpenRouter" }),
|
||||
],
|
||||
settingsPath: "/tmp/providers.json",
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
function buttonByText(text: string): HTMLButtonElement {
|
||||
const button = Array.from(container.querySelectorAll("button")).find(
|
||||
(candidate) => candidate.textContent?.trim() === text,
|
||||
);
|
||||
if (!button) {
|
||||
throw new Error(`button not found: ${text}`);
|
||||
}
|
||||
return button;
|
||||
}
|
||||
|
||||
async function render(onComplete = vi.fn()) {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AccountProvider>
|
||||
<OnboardingView onComplete={onComplete} />
|
||||
</AccountProvider>,
|
||||
);
|
||||
});
|
||||
return onComplete;
|
||||
}
|
||||
|
||||
it("walks from welcome to the connect step", async () => {
|
||||
await render();
|
||||
expect(container.textContent).toContain("Build software your way");
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Get started").click();
|
||||
});
|
||||
expect(container.textContent).toContain("Set up Cline");
|
||||
expect(container.textContent).toContain("Sign in with Cline");
|
||||
expect(container.textContent).toContain("Use your own API key");
|
||||
});
|
||||
|
||||
it("completes without connecting when skipped", async () => {
|
||||
const onComplete = await render();
|
||||
await act(async () => {
|
||||
buttonByText("Get started").click();
|
||||
});
|
||||
await act(async () => {
|
||||
buttonByText("Skip for now").click();
|
||||
});
|
||||
expect(onComplete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("records Cline as the provider when a signed-in user continues", async () => {
|
||||
// Simulate replaying onboarding after previously using another provider.
|
||||
window.localStorage.setItem(
|
||||
MODEL_SELECTION_STORAGE_KEY,
|
||||
JSON.stringify({ lastProvider: "anthropic", lastModelByProvider: {} }),
|
||||
);
|
||||
invoke.mockImplementation(async (command: string) => {
|
||||
if (command === "cline_account") {
|
||||
return { email: "dev@example.com", displayName: "Dev" };
|
||||
}
|
||||
if (command === "list_provider_catalog") {
|
||||
return { providers: [makeProvider()], settingsPath: "/tmp/p.json" };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
await render();
|
||||
await act(async () => {
|
||||
buttonByText("Get started").click();
|
||||
});
|
||||
expect(container.textContent).toContain("Signed in as");
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Continue").click();
|
||||
});
|
||||
expect(container.textContent).toContain("You're all set");
|
||||
expect(
|
||||
parseModelSelectionStorage(
|
||||
window.localStorage.getItem(MODEL_SELECTION_STORAGE_KEY),
|
||||
).lastProvider,
|
||||
).toBe("cline");
|
||||
});
|
||||
|
||||
it("saves an API key provider and remembers the selection", async () => {
|
||||
const onComplete = await render();
|
||||
await act(async () => {
|
||||
buttonByText("Get started").click();
|
||||
});
|
||||
// Expand the bring-your-own-key form; drive state through the select's
|
||||
// props via the API key path (jsdom cannot open the radix listbox).
|
||||
const expandButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(candidate) => candidate.textContent?.includes("Use your own API key"),
|
||||
);
|
||||
expect(expandButton).toBeDefined();
|
||||
await act(async () => {
|
||||
expandButton?.click();
|
||||
});
|
||||
expect(container.textContent).toContain("Choose a provider");
|
||||
|
||||
// Sign-in path still available alongside the expanded form.
|
||||
invoke.mockImplementation(async (command: string) => {
|
||||
if (command === "run_provider_oauth_login") {
|
||||
return { provider: "cline", accessToken: "token" };
|
||||
}
|
||||
if (command === "cline_account") {
|
||||
throw new Error("No Cline account auth token found");
|
||||
}
|
||||
return {};
|
||||
});
|
||||
await act(async () => {
|
||||
buttonByText("Sign in").click();
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith("run_provider_oauth_login", {
|
||||
provider: "cline",
|
||||
});
|
||||
expect(container.textContent).toContain("You're all set");
|
||||
expect(
|
||||
parseModelSelectionStorage(
|
||||
window.localStorage.getItem(MODEL_SELECTION_STORAGE_KEY),
|
||||
).lastProvider,
|
||||
).toBe("cline");
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Start building").click();
|
||||
});
|
||||
expect(onComplete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,540 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
LogIn,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { AuroraBackground } from "@/components/ui/aurora-bg";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useAccount } from "@/contexts/account-context";
|
||||
import { OAUTH_MANAGED_PROVIDERS } from "@/hooks/chat-session/constants";
|
||||
import { desktopClient, openExternalUrl } from "@/lib/desktop-client";
|
||||
import {
|
||||
readModelSelectionStorageFromWindow,
|
||||
writeModelSelectionStorageToWindow,
|
||||
} from "@/lib/model-selection";
|
||||
import type { Provider, ProviderCatalogResponse } from "@/lib/provider-schema";
|
||||
|
||||
const CREATE_ACCOUNT_URL = "https://app.cline.bot";
|
||||
|
||||
export type OnboardingStep = "welcome" | "connect" | "done";
|
||||
|
||||
type OnboardingConnection =
|
||||
| { kind: "cline" }
|
||||
| { kind: "provider"; providerName: string };
|
||||
|
||||
/**
|
||||
* Providers surfaced first in the bring-your-own-key picker. Everything else
|
||||
* from the catalog follows alphabetically.
|
||||
*/
|
||||
const PREFERRED_PROVIDER_ORDER = [
|
||||
"anthropic",
|
||||
"openai-native",
|
||||
"openrouter",
|
||||
"gemini",
|
||||
"xai",
|
||||
"groq",
|
||||
"mistral",
|
||||
"deepseek",
|
||||
"ollama",
|
||||
];
|
||||
|
||||
/**
|
||||
* True when entering an API key is all the provider needs: it declares an
|
||||
* API-key config field and nothing beyond key/base-URL. Providers with
|
||||
* structured setup (Vertex `gcp.*`, Bedrock `aws.*`) or no API-key field at
|
||||
* all (Claude Code) would "connect" here without working, so they stay in
|
||||
* Settings where the full form lives. Missing metadata means the catalog
|
||||
* fell back to a plain API-key field.
|
||||
*/
|
||||
function isApiKeyOnlyProvider(provider: Provider): boolean {
|
||||
const fields = provider.configFields;
|
||||
if (!fields) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
fields.some((field) => field.path === "apiKey") &&
|
||||
fields.every((field) => field.path === "apiKey" || field.path === "baseUrl")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders the provider catalog for the API-key setup step: OAuth-managed
|
||||
* providers (Cline itself, ChatGPT, OCA) are excluded because they have
|
||||
* dedicated sign-in paths, providers needing more than an API key are
|
||||
* excluded because this form only collects one, popular API-key providers
|
||||
* come first, and the rest follow alphabetically.
|
||||
*/
|
||||
export function sortProvidersForApiKeySetup(providers: Provider[]): Provider[] {
|
||||
const rank = (id: string) => {
|
||||
const index = PREFERRED_PROVIDER_ORDER.indexOf(id);
|
||||
return index === -1 ? PREFERRED_PROVIDER_ORDER.length : index;
|
||||
};
|
||||
return providers
|
||||
.filter(
|
||||
(provider) =>
|
||||
!OAUTH_MANAGED_PROVIDERS.has(provider.id) &&
|
||||
isApiKeyOnlyProvider(provider),
|
||||
)
|
||||
.sort((a, b) => rank(a.id) - rank(b.id) || a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers the connected provider (and its default model when known) so the
|
||||
* chat composer opens pointed at what the user just set up.
|
||||
*/
|
||||
function rememberProviderSelection(provider: {
|
||||
id: string;
|
||||
defaultModelId?: string;
|
||||
}): void {
|
||||
const selection = readModelSelectionStorageFromWindow();
|
||||
writeModelSelectionStorageToWindow({
|
||||
lastProvider: provider.id,
|
||||
lastModelByProvider: provider.defaultModelId
|
||||
? {
|
||||
...selection.lastModelByProvider,
|
||||
[provider.id]: provider.defaultModelId,
|
||||
}
|
||||
: selection.lastModelByProvider,
|
||||
});
|
||||
}
|
||||
|
||||
function OnboardingCard({
|
||||
children,
|
||||
wide = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
wide?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
wide
|
||||
? "relative z-10 w-full max-w-130 rounded-3xl border border-border/50 bg-background/80 p-8 shadow-2xl backdrop-blur-2xl max-[720px]:p-6"
|
||||
: "relative z-10 w-full max-w-105 rounded-3xl border border-border/50 bg-background/80 p-8 shadow-2xl backdrop-blur-2xl max-[720px]:p-6"
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WelcomeStep({ onContinue }: { onContinue: () => void }) {
|
||||
return (
|
||||
<OnboardingCard>
|
||||
<div className="flex flex-col items-center py-4 text-center">
|
||||
<img
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-28 w-auto drop-shadow-[0_16px_32px_color-mix(in_oklab,var(--brand-violet)_35%,transparent)]"
|
||||
draggable={false}
|
||||
height={477}
|
||||
src="/cline-logo-glass.png"
|
||||
width={486}
|
||||
/>
|
||||
<h1 className="mt-5 text-3xl font-semibold tracking-tight text-foreground">
|
||||
Cline
|
||||
</h1>
|
||||
<p className="mt-2 text-[15px] text-muted-foreground">
|
||||
Build software your way
|
||||
</p>
|
||||
<Button
|
||||
className="mt-9 h-11 w-full rounded-full text-[15px]"
|
||||
onClick={onContinue}
|
||||
type="button"
|
||||
>
|
||||
Get started
|
||||
</Button>
|
||||
<p className="mt-4 text-xs text-muted-foreground">
|
||||
Takes less than a minute. Everything can be changed later in Settings.
|
||||
</p>
|
||||
</div>
|
||||
</OnboardingCard>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectStep({
|
||||
onBack,
|
||||
onConnected,
|
||||
onSkip,
|
||||
}: {
|
||||
onBack: () => void;
|
||||
onConnected: (connection: OnboardingConnection) => void;
|
||||
onSkip: () => void;
|
||||
}) {
|
||||
const { user, refreshAccount } = useAccount();
|
||||
const [signingIn, setSigningIn] = useState(false);
|
||||
const [signInError, setSignInError] = useState<string | null>(null);
|
||||
|
||||
const [showApiKeyForm, setShowApiKeyForm] = useState(false);
|
||||
const [providers, setProviders] = useState<Provider[]>([]);
|
||||
const [providersLoading, setProvidersLoading] = useState(true);
|
||||
const [providersError, setProvidersError] = useState<string | null>(null);
|
||||
const [selectedProviderId, setSelectedProviderId] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function loadProviders() {
|
||||
try {
|
||||
const payload = await desktopClient.invoke<ProviderCatalogResponse>(
|
||||
"list_provider_catalog",
|
||||
);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setProviders(sortProvidersForApiKeySetup(payload.providers ?? []));
|
||||
setProvidersError(null);
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setProvidersError(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setProvidersLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
void loadProviders();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const signInWithCline = useCallback(async () => {
|
||||
setSigningIn(true);
|
||||
setSignInError(null);
|
||||
try {
|
||||
await desktopClient.invoke("run_provider_oauth_login", {
|
||||
provider: "cline",
|
||||
});
|
||||
rememberProviderSelection({ id: "cline" });
|
||||
await refreshAccount();
|
||||
onConnected({ kind: "cline" });
|
||||
} catch (error) {
|
||||
setSignInError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setSigningIn(false);
|
||||
}
|
||||
}, [onConnected, refreshAccount]);
|
||||
|
||||
const selectedProvider =
|
||||
providers.find((provider) => provider.id === selectedProviderId) ?? null;
|
||||
|
||||
const connectProvider = useCallback(async () => {
|
||||
if (!selectedProvider || !apiKey.trim()) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
await desktopClient.invoke("save_provider_settings", {
|
||||
provider: selectedProvider.id,
|
||||
enabled: true,
|
||||
api_key: apiKey.trim(),
|
||||
});
|
||||
rememberProviderSelection(selectedProvider);
|
||||
onConnected({
|
||||
kind: "provider",
|
||||
providerName: selectedProvider.name,
|
||||
});
|
||||
} catch (error) {
|
||||
setSaveError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [apiKey, onConnected, selectedProvider]);
|
||||
|
||||
return (
|
||||
<OnboardingCard wide>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
aria-label="Back"
|
||||
className="-ml-2 size-8 rounded-full p-0 text-muted-foreground"
|
||||
onClick={onBack}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground">
|
||||
Set up Cline
|
||||
</h1>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Choose how Cline connects to a model. You can add more providers anytime
|
||||
in Settings.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-3">
|
||||
{/* Cline account */}
|
||||
<div className="rounded-2xl border border-primary/30 bg-primary/5 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-[15px] font-semibold text-foreground">
|
||||
Sign in with Cline
|
||||
</p>
|
||||
<Badge className="bg-primary/15 text-primary" variant="secondary">
|
||||
Recommended
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Latest models with regular free promos. No API keys needed.
|
||||
</p>
|
||||
{user ? (
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm text-foreground">
|
||||
Signed in as{" "}
|
||||
<span className="font-medium">
|
||||
{user.displayName || user.email}
|
||||
</span>
|
||||
</p>
|
||||
<Button
|
||||
className="rounded-full"
|
||||
onClick={() => {
|
||||
rememberProviderSelection({ id: "cline" });
|
||||
onConnected({ kind: "cline" });
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
className="rounded-full"
|
||||
disabled={signingIn}
|
||||
onClick={() => void signInWithCline()}
|
||||
type="button"
|
||||
>
|
||||
{signingIn ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<LogIn className="size-4" />
|
||||
)}
|
||||
{signingIn ? "Waiting for browser..." : "Sign in"}
|
||||
</Button>
|
||||
<button
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={() => void openExternalUrl(CREATE_ACCOUNT_URL)}
|
||||
type="button"
|
||||
>
|
||||
Create account
|
||||
<ExternalLink className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{signInError ? (
|
||||
<p className="mt-2 text-xs text-destructive" role="alert">
|
||||
Sign in failed: {signInError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Bring your own key */}
|
||||
<div className="rounded-2xl border border-border/70 bg-background/60 p-4">
|
||||
<button
|
||||
aria-expanded={showApiKeyForm}
|
||||
className="flex w-full items-start gap-3 text-left"
|
||||
onClick={() => setShowApiKeyForm((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
<span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-secondary text-muted-foreground">
|
||||
<KeyRound className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-[15px] font-semibold text-foreground">
|
||||
Use your own API key
|
||||
</span>
|
||||
<span className="mt-0.5 block text-sm text-muted-foreground">
|
||||
Anthropic, OpenAI, OpenRouter, and more.
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{showApiKeyForm ? (
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
{providersError ? (
|
||||
<p className="text-xs text-destructive" role="alert">
|
||||
Failed to load providers: {providersError}
|
||||
</p>
|
||||
) : (
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
setSelectedProviderId(value);
|
||||
setSaveError(null);
|
||||
}}
|
||||
value={selectedProviderId || undefined}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label="Provider"
|
||||
className="w-full bg-background"
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
providersLoading
|
||||
? "Loading providers..."
|
||||
: "Choose a provider"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers.map((provider) => (
|
||||
<SelectItem key={provider.id} value={provider.id}>
|
||||
{provider.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<Input
|
||||
aria-label="API key"
|
||||
autoComplete="off"
|
||||
className="bg-background"
|
||||
onChange={(event) => {
|
||||
setApiKey(event.target.value);
|
||||
setSaveError(null);
|
||||
}}
|
||||
placeholder={
|
||||
selectedProvider
|
||||
? `${selectedProvider.name} API key`
|
||||
: "API key"
|
||||
}
|
||||
type="password"
|
||||
value={apiKey}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
{selectedProvider?.docUrl ? (
|
||||
<button
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={() =>
|
||||
void openExternalUrl(selectedProvider.docUrl ?? "")
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{selectedProvider.docLabel || "Get an API key"}
|
||||
<ExternalLink className="size-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<Button
|
||||
className="rounded-full"
|
||||
disabled={!selectedProvider || !apiKey.trim() || saving}
|
||||
onClick={() => void connectProvider()}
|
||||
type="button"
|
||||
>
|
||||
{saving ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
{saving ? "Connecting..." : "Connect"}
|
||||
</Button>
|
||||
</div>
|
||||
{saveError ? (
|
||||
<p className="text-xs text-destructive" role="alert">
|
||||
Failed to save provider: {saveError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex justify-center">
|
||||
<button
|
||||
className="text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={onSkip}
|
||||
type="button"
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
</div>
|
||||
</OnboardingCard>
|
||||
);
|
||||
}
|
||||
|
||||
function DoneStep({
|
||||
connection,
|
||||
onFinish,
|
||||
}: {
|
||||
connection: OnboardingConnection | null;
|
||||
onFinish: () => void;
|
||||
}) {
|
||||
return (
|
||||
<OnboardingCard>
|
||||
<div className="flex flex-col items-center py-4 text-center">
|
||||
<CheckCircle2 aria-hidden="true" className="size-10 text-primary" />
|
||||
<h1 className="mt-4 text-2xl font-semibold tracking-tight text-foreground">
|
||||
You're all set
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{connection?.kind === "provider"
|
||||
? `${connection.providerName} is connected. Pick a project and start your first session.`
|
||||
: "Your Cline account is connected. Pick a project and start your first session."}
|
||||
</p>
|
||||
<Button
|
||||
className="mt-8 h-11 w-full rounded-full text-[15px]"
|
||||
onClick={onFinish}
|
||||
type="button"
|
||||
>
|
||||
Start building
|
||||
</Button>
|
||||
</div>
|
||||
</OnboardingCard>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-screen first-run experience: welcome, connect a model provider (Cline
|
||||
* account or bring-your-own API key), done. Rendered by the app shell while
|
||||
* onboarding has not been completed (see lib/onboarding.ts); `onComplete`
|
||||
* marks it completed and returns to the chat.
|
||||
*/
|
||||
export function OnboardingView({
|
||||
onComplete,
|
||||
initialStep = "welcome",
|
||||
}: {
|
||||
onComplete: () => void;
|
||||
initialStep?: OnboardingStep;
|
||||
}) {
|
||||
const [step, setStep] = useState<OnboardingStep>(initialStep);
|
||||
const [connection, setConnection] = useState<OnboardingConnection | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full items-center justify-center overflow-hidden bg-background p-6">
|
||||
<AuroraBackground />
|
||||
{step === "welcome" ? (
|
||||
<WelcomeStep onContinue={() => setStep("connect")} />
|
||||
) : step === "connect" ? (
|
||||
<ConnectStep
|
||||
onBack={() => setStep("welcome")}
|
||||
onConnected={(nextConnection) => {
|
||||
setConnection(nextConnection);
|
||||
setStep("done");
|
||||
}}
|
||||
onSkip={onComplete}
|
||||
/>
|
||||
) : (
|
||||
<DoneStep connection={connection} onFinish={onComplete} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, type HTMLAttributes } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ChannelsContent } from "./channels-view";
|
||||
|
||||
const { invokeMock } = vi.hoisted(() => ({
|
||||
invokeMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/desktop-client", () => ({
|
||||
desktopClient: { invoke: invokeMock },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/scroll-area", () => ({
|
||||
ScrollArea: ({ children, ...props }: HTMLAttributes<HTMLDivElement>) => (
|
||||
<div {...props}>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const telegramChannel = {
|
||||
id: "telegram",
|
||||
name: "Telegram",
|
||||
type: "polling" as const,
|
||||
hint: "No public URL needed.",
|
||||
fields: [
|
||||
{
|
||||
flag: "-k",
|
||||
label: "Bot token",
|
||||
placeholder: "7123456789:AAH...",
|
||||
required: true,
|
||||
help: ["Copy the token from @BotFather."],
|
||||
},
|
||||
],
|
||||
security: {
|
||||
prompt: "Restrict access to your Telegram user ID?",
|
||||
fields: [
|
||||
{
|
||||
key: "userId",
|
||||
label: "Your Telegram user ID",
|
||||
placeholder: "123456789",
|
||||
requiredMessage: "User ID is required to restrict access",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const slackChannel = {
|
||||
id: "slack",
|
||||
name: "Slack",
|
||||
type: "hybrid" as const,
|
||||
hint: "Public URL for webhook mode; leave blank for socket mode.",
|
||||
fields: [
|
||||
{
|
||||
flag: "--bot-token",
|
||||
label: "Bot token",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
flag: "--base-url",
|
||||
label: "Public base URL",
|
||||
placeholder: "leave blank for socket mode",
|
||||
},
|
||||
{
|
||||
flag: "--signing-secret",
|
||||
label: "Signing secret",
|
||||
required: true,
|
||||
includeWhen: { flag: "--base-url", notEquals: "" },
|
||||
},
|
||||
{
|
||||
flag: "--app-token",
|
||||
label: "App-level token",
|
||||
required: true,
|
||||
includeWhen: { flag: "--base-url", equals: "" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const gchatChannel = {
|
||||
id: "gchat",
|
||||
name: "Google Chat",
|
||||
type: "webhook" as const,
|
||||
hint: "Requires Google Cloud credentials and a public URL.",
|
||||
fields: [
|
||||
{
|
||||
flag: "--credentials-json",
|
||||
label: "Service account credentials JSON",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
class ResizeObserverStub {
|
||||
disconnect() {}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, {
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
ResizeObserver: ResizeObserverStub,
|
||||
});
|
||||
HTMLElement.prototype.scrollTo = vi.fn();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
invokeMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function renderChannels() {
|
||||
await act(async () => {
|
||||
root.render(<ChannelsContent />);
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(invokeMock).toHaveBeenCalledWith("list_connector_channels");
|
||||
});
|
||||
}
|
||||
|
||||
async function click(element: Element): Promise<void> {
|
||||
await act(async () => {
|
||||
element.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true, cancelable: true }),
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
async function changeInput(
|
||||
input: HTMLInputElement,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
const setValue = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
await act(async () => {
|
||||
setValue?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
async function changeTextarea(
|
||||
textarea: HTMLTextAreaElement,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
const setValue = Object.getOwnPropertyDescriptor(
|
||||
HTMLTextAreaElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
await act(async () => {
|
||||
setValue?.call(textarea, value);
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
function buttonWithText(text: string, rootElement: ParentNode = container) {
|
||||
const button = [
|
||||
...rootElement.querySelectorAll<HTMLButtonElement>("button"),
|
||||
].find((candidate) => candidate.textContent?.includes(text));
|
||||
expect(button).toBeDefined();
|
||||
return button as HTMLButtonElement;
|
||||
}
|
||||
|
||||
function channelListIds(): string[] {
|
||||
return [
|
||||
...container.querySelectorAll<HTMLButtonElement>(
|
||||
'button[id^="channel-"][id$="-trigger"]',
|
||||
),
|
||||
].map((button) =>
|
||||
button.id.replace(/^channel-/, "").replace(/-trigger$/, ""),
|
||||
);
|
||||
}
|
||||
|
||||
describe("ChannelsContent", () => {
|
||||
it("renders the backend catalog and starts Telegram with canonical field and security keys", async () => {
|
||||
const initialResponse = {
|
||||
available: [telegramChannel, slackChannel],
|
||||
active: [],
|
||||
};
|
||||
invokeMock.mockImplementation(async (command: string) => {
|
||||
if (command === "list_connector_channels") {
|
||||
return initialResponse;
|
||||
}
|
||||
if (command === "start_connector_channel") {
|
||||
return {
|
||||
...initialResponse,
|
||||
active: [
|
||||
{
|
||||
id: "telegram:test_bot",
|
||||
type: "telegram",
|
||||
pid: 42,
|
||||
hubUrl: "ws://127.0.0.1:4317",
|
||||
botUsername: "test_bot",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected command: ${command}`);
|
||||
});
|
||||
|
||||
await renderChannels();
|
||||
await vi.waitFor(() => {
|
||||
expect(container.textContent).toContain("Telegram");
|
||||
expect(container.textContent).toContain("Slack");
|
||||
});
|
||||
expect(channelListIds()).toEqual(["slack", "telegram"]);
|
||||
expect(container.textContent).not.toContain("Mattermost");
|
||||
expect(
|
||||
container
|
||||
.querySelector('button[aria-label="Connect Telegram"]')
|
||||
?.getAttribute("aria-checked"),
|
||||
).toBe("false");
|
||||
|
||||
await click(buttonWithText("Telegram"));
|
||||
const tokenInput = container.querySelector<HTMLInputElement>(
|
||||
"#channel-telegram-credential--k",
|
||||
) as HTMLInputElement;
|
||||
await changeInput(tokenInput, "7123456789:test-token");
|
||||
await click(
|
||||
container.querySelector('button[aria-label="Show Bot token"]') as Element,
|
||||
);
|
||||
expect(tokenInput.type).toBe("text");
|
||||
await click(
|
||||
container.querySelector("#channel-telegram-security-toggle") as Element,
|
||||
);
|
||||
await changeInput(
|
||||
container.querySelector<HTMLInputElement>(
|
||||
"#channel-telegram-security-userId",
|
||||
) as HTMLInputElement,
|
||||
"123456789",
|
||||
);
|
||||
await click(buttonWithText("Save"));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(invokeMock).toHaveBeenCalledWith("start_connector_channel", {
|
||||
channel: "telegram",
|
||||
values: { "-k": "7123456789:test-token" },
|
||||
security: {
|
||||
enabled: true,
|
||||
values: { userId: "123456789" },
|
||||
},
|
||||
});
|
||||
expect(container.textContent).toContain("@test_bot");
|
||||
expect(
|
||||
container
|
||||
.querySelector('button[aria-label="Disconnect Telegram"]')
|
||||
?.getAttribute("aria-checked"),
|
||||
).toBe("true");
|
||||
expect(channelListIds()).toEqual(["telegram", "slack"]);
|
||||
expect(tokenInput.type).toBe("password");
|
||||
expect(tokenInput.value).toBe("7123456789:test-token");
|
||||
});
|
||||
});
|
||||
|
||||
it("masks multiline credentials before and after connecting", async () => {
|
||||
const initialResponse = { available: [gchatChannel], active: [] };
|
||||
invokeMock.mockImplementation(async (command: string) => {
|
||||
if (command === "list_connector_channels") {
|
||||
return initialResponse;
|
||||
}
|
||||
if (command === "start_connector_channel") {
|
||||
return {
|
||||
...initialResponse,
|
||||
active: [
|
||||
{
|
||||
id: "gchat:cline-bot",
|
||||
type: "gchat",
|
||||
pid: 43,
|
||||
hubUrl: "ws://127.0.0.1:4317",
|
||||
userName: "cline-bot",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected command: ${command}`);
|
||||
});
|
||||
|
||||
await renderChannels();
|
||||
await vi.waitFor(() => {
|
||||
expect(container.textContent).toContain("Google Chat");
|
||||
});
|
||||
await click(buttonWithText("Google Chat"));
|
||||
const textarea = container.querySelector<HTMLTextAreaElement>(
|
||||
"#channel-gchat-credential---credentials-json",
|
||||
) as HTMLTextAreaElement;
|
||||
expect(textarea.className).toContain("[-webkit-text-security:disc]");
|
||||
await changeTextarea(textarea, '{"private_key":"secret"}');
|
||||
await click(
|
||||
container.querySelector(
|
||||
'button[aria-label="Show Service account credentials JSON"]',
|
||||
) as Element,
|
||||
);
|
||||
expect(textarea.className).not.toContain("[-webkit-text-security:disc]");
|
||||
await click(buttonWithText("Save"));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(invokeMock).toHaveBeenCalledWith("start_connector_channel", {
|
||||
channel: "gchat",
|
||||
values: { "--credentials-json": '{"private_key":"secret"}' },
|
||||
security: { enabled: false, values: {} },
|
||||
});
|
||||
expect(textarea.className).toContain("[-webkit-text-security:disc]");
|
||||
expect(textarea.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("retains submitted credentials when connecting fails", async () => {
|
||||
invokeMock.mockImplementation(async (command: string) => {
|
||||
if (command === "list_connector_channels") {
|
||||
return { available: [telegramChannel], active: [] };
|
||||
}
|
||||
if (command === "start_connector_channel") {
|
||||
throw new Error("connector failed to start");
|
||||
}
|
||||
throw new Error(`Unexpected command: ${command}`);
|
||||
});
|
||||
|
||||
await renderChannels();
|
||||
await vi.waitFor(() => {
|
||||
expect(container.textContent).toContain("Telegram");
|
||||
});
|
||||
await click(buttonWithText("Telegram"));
|
||||
const tokenInput = container.querySelector<HTMLInputElement>(
|
||||
"#channel-telegram-credential--k",
|
||||
) as HTMLInputElement;
|
||||
await changeInput(tokenInput, "7123456789:retry-token");
|
||||
await click(buttonWithText("Save"));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(container.textContent).toContain("connector failed to start");
|
||||
expect(tokenInput.value).toBe("7123456789:retry-token");
|
||||
expect(tokenInput.disabled).toBe(false);
|
||||
expect(
|
||||
container
|
||||
.querySelector("#channel-telegram-trigger")
|
||||
?.getAttribute("aria-expanded"),
|
||||
).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
it("saves edited configuration for an active channel", async () => {
|
||||
const activeConnector = {
|
||||
id: "telegram:first_bot",
|
||||
type: "telegram",
|
||||
pid: 41,
|
||||
hubUrl: "ws://127.0.0.1:4317",
|
||||
botUsername: "first_bot",
|
||||
};
|
||||
invokeMock.mockImplementation(async (command: string) => {
|
||||
if (command === "list_connector_channels") {
|
||||
return { available: [telegramChannel], active: [activeConnector] };
|
||||
}
|
||||
if (command === "start_connector_channel") {
|
||||
return {
|
||||
available: [telegramChannel],
|
||||
active: [activeConnector],
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected command: ${command}`);
|
||||
});
|
||||
|
||||
await renderChannels();
|
||||
await vi.waitFor(() => {
|
||||
expect(container.textContent).toContain("Telegram");
|
||||
});
|
||||
await click(buttonWithText("Telegram"));
|
||||
const tokenInput = container.querySelector<HTMLInputElement>(
|
||||
"#channel-telegram-credential--k",
|
||||
) as HTMLInputElement;
|
||||
expect(tokenInput.disabled).toBe(false);
|
||||
expect(
|
||||
(
|
||||
container.querySelector(
|
||||
"#channel-telegram-security-toggle",
|
||||
) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(false);
|
||||
expect(container.textContent).not.toContain("New Connection");
|
||||
await changeInput(tokenInput, "7123456789:updated-token");
|
||||
await click(buttonWithText("Save"));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(invokeMock).toHaveBeenCalledWith("start_connector_channel", {
|
||||
channel: "telegram",
|
||||
values: { "-k": "7123456789:updated-token" },
|
||||
security: { enabled: false, values: {} },
|
||||
});
|
||||
expect(container.textContent).toContain("@first_bot");
|
||||
expect(container.textContent).toContain("Active connection");
|
||||
});
|
||||
});
|
||||
|
||||
it("switches Slack conditional fields and blocks a missing visible required field", async () => {
|
||||
invokeMock.mockResolvedValue({ available: [slackChannel], active: [] });
|
||||
|
||||
await renderChannels();
|
||||
await vi.waitFor(() => {
|
||||
expect(container.textContent).toContain("Slack");
|
||||
});
|
||||
const slackTrigger = container.querySelector(
|
||||
"#channel-slack-trigger",
|
||||
) as HTMLButtonElement;
|
||||
expect(slackTrigger.getAttribute("aria-expanded")).toBe("false");
|
||||
await click(
|
||||
container.querySelector('button[aria-label="Connect Slack"]') as Element,
|
||||
);
|
||||
|
||||
expect(slackTrigger.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(container.textContent).toContain("Bot token is required");
|
||||
expect(container.textContent).toContain("App-level token");
|
||||
expect(container.textContent).not.toContain("Signing secret");
|
||||
await changeInput(
|
||||
container.querySelector<HTMLInputElement>(
|
||||
"#channel-slack-credential---base-url",
|
||||
) as HTMLInputElement,
|
||||
"https://example.com",
|
||||
);
|
||||
expect(container.textContent).toContain("Signing secret");
|
||||
expect(container.textContent).not.toContain("App-level token");
|
||||
|
||||
await click(buttonWithText("Save"));
|
||||
expect(container.textContent).toContain("Bot token is required");
|
||||
expect(invokeMock).not.toHaveBeenCalledWith(
|
||||
"start_connector_channel",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("resets the active connection from the expanded channel footer", async () => {
|
||||
const activeConnector = {
|
||||
id: "telegram:test_bot",
|
||||
type: "telegram",
|
||||
pid: 42,
|
||||
hubUrl: "ws://127.0.0.1:4317",
|
||||
botUsername: "test_bot",
|
||||
};
|
||||
invokeMock.mockImplementation(async (command: string) => {
|
||||
if (command === "list_connector_channels") {
|
||||
return { available: [telegramChannel], active: [activeConnector] };
|
||||
}
|
||||
if (command === "stop_connector_channel") {
|
||||
return { available: [telegramChannel], active: [] };
|
||||
}
|
||||
throw new Error(`Unexpected command: ${command}`);
|
||||
});
|
||||
|
||||
await renderChannels();
|
||||
await vi.waitFor(() => {
|
||||
expect(container.textContent).toContain("Telegram");
|
||||
});
|
||||
await click(buttonWithText("Telegram"));
|
||||
expect(
|
||||
container.querySelector('button[aria-label^="Disconnect @"]'),
|
||||
).toBeNull();
|
||||
await click(buttonWithText("Reset"));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.textContent).toContain("Reset Telegram?");
|
||||
});
|
||||
await click(
|
||||
buttonWithText(
|
||||
"Reset",
|
||||
document.querySelector('[role="alertdialog"]') as Element,
|
||||
),
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(invokeMock).toHaveBeenCalledWith("stop_connector_channel", {
|
||||
channel: "telegram",
|
||||
});
|
||||
expect(
|
||||
container
|
||||
.querySelector('button[aria-label="Connect Telegram"]')
|
||||
?.getAttribute("aria-checked"),
|
||||
).toBe("false");
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,16 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
APP_ICONS,
|
||||
type AppIconId,
|
||||
appIconAssetPath,
|
||||
readStoredAppIcon,
|
||||
setStoredAppIcon,
|
||||
} from "@/lib/app-icon";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import { resetOnboarding } from "@/lib/onboarding";
|
||||
import type {
|
||||
Provider,
|
||||
ProviderCatalogResponse,
|
||||
@@ -8,16 +18,20 @@ import type {
|
||||
ProviderSettingsUpdate,
|
||||
} from "@/lib/provider-schema";
|
||||
import {
|
||||
type HubAccent,
|
||||
type HubTheme,
|
||||
readStoredHubAccent,
|
||||
readStoredHubTheme,
|
||||
readSystemHubTheme,
|
||||
setStoredHubAccent,
|
||||
setStoredHubTheme,
|
||||
} from "@/lib/theme";
|
||||
import { cn } from "@/lib/utils";
|
||||
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 +47,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;
|
||||
@@ -61,9 +85,11 @@ let providerCatalogCache: {
|
||||
export function SettingsView({
|
||||
section,
|
||||
onNavigateSection,
|
||||
onOpenSession,
|
||||
}: {
|
||||
section: SettingsSection;
|
||||
onNavigateSection: (section: SettingsSection) => void;
|
||||
onOpenSession?: (sessionId: string) => void | Promise<void>;
|
||||
}) {
|
||||
const activeNav = section;
|
||||
const [providers, setProviders] = useState<Provider[]>(
|
||||
@@ -400,16 +426,24 @@ 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" ? (
|
||||
<RoutineSchedulesContent />
|
||||
<RoutineSchedulesContent onOpenSession={onOpenSession} />
|
||||
) : activeNav === "Account" ? (
|
||||
<AccountView />
|
||||
) : activeNav === "General" ? (
|
||||
@@ -430,11 +464,35 @@ export function SettingsView({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Swatches shown in the accent picker. The swatch color is the accent's
|
||||
* light-mode primary (see the [data-cline-accent] blocks in globals.css);
|
||||
* violet reads the live brand token so it always matches the default theme.
|
||||
*/
|
||||
const ACCENT_OPTIONS: { id: HubAccent; label: string; swatch: string }[] = [
|
||||
{ id: "violet", label: "Violet", swatch: "var(--brand-violet)" },
|
||||
{ id: "graphite", label: "Graphite", swatch: "oklch(0.27 0.012 248)" },
|
||||
{ id: "cyan", label: "Cyan", swatch: "oklch(0.6 0.12 222)" },
|
||||
{ id: "pink", label: "Pink", swatch: "oklch(0.75 0.1 354)" },
|
||||
{ id: "espresso", label: "Espresso", swatch: "oklch(0.36 0.035 35)" },
|
||||
{ id: "ember", label: "Ember", swatch: "oklch(0.6 0.19 33)" },
|
||||
];
|
||||
|
||||
function GeneralSettingsContent() {
|
||||
const [theme, setTheme] = useState<HubTheme>(() => {
|
||||
if (typeof window === "undefined") return "light";
|
||||
return readStoredHubTheme() ?? readSystemHubTheme();
|
||||
});
|
||||
const [accent, setAccent] = useState<HubAccent>(() => {
|
||||
if (typeof window === "undefined") return "violet";
|
||||
return readStoredHubAccent();
|
||||
});
|
||||
const [appIcon, setAppIcon] = useState<AppIconId>(() => {
|
||||
if (typeof window === "undefined") return "classic";
|
||||
return readStoredAppIcon();
|
||||
});
|
||||
const [appIconError, setAppIconError] = useState<string | null>(null);
|
||||
const appIconRequestRef = useRef(0);
|
||||
const [telemetryOptOut, setTelemetryOptOut] = useState(false);
|
||||
const [telemetryLoading, setTelemetryLoading] = useState(true);
|
||||
const [telemetrySaving, setTelemetrySaving] = useState(false);
|
||||
@@ -517,6 +575,37 @@ function GeneralSettingsContent() {
|
||||
setTheme(setStoredHubTheme(nextTheme));
|
||||
};
|
||||
|
||||
const updateAccent = (nextAccent: HubAccent) => {
|
||||
setAccent(setStoredHubAccent(nextAccent));
|
||||
};
|
||||
|
||||
const updateAppIcon = async (nextIcon: AppIconId) => {
|
||||
const requestId = ++appIconRequestRef.current;
|
||||
const previousIcon = appIcon;
|
||||
setAppIcon(nextIcon);
|
||||
setAppIconError(null);
|
||||
try {
|
||||
await setStoredAppIcon(nextIcon);
|
||||
} catch (error) {
|
||||
// A newer selection supersedes this request; rolling back now
|
||||
// would clobber it.
|
||||
if (appIconRequestRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
setAppIcon(previousIcon);
|
||||
setAppIconError(error instanceof Error ? error.message : String(error));
|
||||
// Storage was written before the native call failed; roll it back
|
||||
// so the persisted choice matches what the dock actually shows.
|
||||
await setStoredAppIcon(previousIcon).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
// resetOnboarding dispatches ONBOARDING_RESET_EVENT, which the app shell
|
||||
// listens for to re-enter the first-run flow immediately.
|
||||
const replayOnboarding = () => {
|
||||
resetOnboarding();
|
||||
};
|
||||
|
||||
return (
|
||||
<PageFrame>
|
||||
<PageHeader
|
||||
@@ -539,6 +628,84 @@ function GeneralSettingsContent() {
|
||||
onCheckedChange={updateTheme}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-h-20 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
|
||||
<div>
|
||||
<p className="text-[17px] font-semibold text-foreground">
|
||||
Accent color
|
||||
</p>
|
||||
<p className="mt-1 text-[15px] text-muted-foreground">
|
||||
Tint buttons, links, and highlights across the app.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2.5">
|
||||
{ACCENT_OPTIONS.map((option) => (
|
||||
<button
|
||||
aria-label={option.label}
|
||||
aria-pressed={accent === option.id}
|
||||
className={cn(
|
||||
"size-7 rounded-full border border-foreground/10 transition-transform hover:scale-110",
|
||||
accent === option.id &&
|
||||
"ring-2 ring-ring ring-offset-2 ring-offset-background",
|
||||
)}
|
||||
key={option.id}
|
||||
onClick={() => updateAccent(option.id)}
|
||||
style={{ backgroundColor: option.swatch }}
|
||||
title={option.label}
|
||||
type="button"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-20 items-center justify-between gap-5 border-b py-4 max-[720px]:flex-col max-[720px]:items-stretch">
|
||||
<div>
|
||||
<p className="text-[17px] font-semibold text-foreground">
|
||||
App icon
|
||||
</p>
|
||||
<p className="mt-1 text-[15px] text-muted-foreground">
|
||||
Pick the icon Cline shows in the Dock.
|
||||
</p>
|
||||
{appIconError ? (
|
||||
<p className="mt-2 text-xs text-destructive" role="alert">
|
||||
Failed to change app icon: {appIconError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-start gap-4">
|
||||
{APP_ICONS.map((icon) => (
|
||||
<button
|
||||
aria-label={icon.label}
|
||||
aria-pressed={appIcon === icon.id}
|
||||
className="group flex flex-col items-center gap-1.5"
|
||||
key={icon.id}
|
||||
onClick={() => void updateAppIcon(icon.id)}
|
||||
type="button"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
className={cn(
|
||||
"size-14 rounded-2xl transition-transform group-hover:scale-105",
|
||||
appIcon === icon.id &&
|
||||
"ring-2 ring-ring ring-offset-2 ring-offset-background",
|
||||
)}
|
||||
draggable={false}
|
||||
height={112}
|
||||
src={appIconAssetPath(icon.id)}
|
||||
width={112}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs",
|
||||
appIcon === icon.id
|
||||
? "font-medium text-foreground"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{icon.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-20 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
|
||||
<div>
|
||||
<p className="text-[17px] font-semibold text-foreground">
|
||||
@@ -581,6 +748,26 @@ function GeneralSettingsContent() {
|
||||
onCheckedChange={(checked) => void updateTelemetryOptOut(!checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-h-20 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
|
||||
<div>
|
||||
<p className="text-[17px] font-semibold text-foreground">
|
||||
New user experience
|
||||
</p>
|
||||
<p className="mt-1 text-[15px] text-muted-foreground">
|
||||
Replay the first-run experience new users see when they open Cline
|
||||
for the first time.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="shrink-0"
|
||||
onClick={replayOnboarding}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
Replay
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</PageFrame>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user