mirror of
https://github.com/cline/cline.git
synced 2026-09-15 21:04:27 +08:00
Compare commits
95
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 | ||
|
|
f33ab3a872 | ||
|
|
2ca8364ffc | ||
|
|
2ef81be703 | ||
|
|
359445ae0c | ||
|
|
d9e2e9c76b | ||
|
|
d859a86a6f | ||
|
|
0b7b9c1b3d | ||
|
|
557d725690 | ||
|
|
7274d8badc | ||
|
|
d1837366c0 | ||
|
|
c380daf4a3 | ||
|
|
c564045d81 | ||
|
|
9a5e1751b2 | ||
|
|
131e25e1a1 | ||
|
|
a7ff007af9 | ||
|
|
ef27f45080 | ||
|
|
e3c6d51072 | ||
|
|
48bac25548 | ||
|
|
37f5f104f3 | ||
|
|
3577b52404 | ||
|
|
1843bc8ed0 | ||
|
|
fead00ec57 | ||
|
|
238107d21c | ||
|
|
2063a661bd | ||
|
|
ec02d5862e | ||
|
|
8452084842 | ||
|
|
a41129a5db | ||
|
|
1ea34be611 | ||
|
|
e72bc3cd14 | ||
|
|
9c907af826 | ||
|
|
e8d3d82522 | ||
|
|
7f9d2e96d9 | ||
|
|
d618f8073a | ||
|
|
eb21ba583c | ||
|
|
f29c25395c | ||
|
|
84c9b587a6 | ||
|
|
6dca234d8e | ||
|
|
9217eacbbd |
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.cline/skills/publish-desktop
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.cline/skills/publish-desktop
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
name: publish-desktop
|
||||
description: Use when preparing, tagging, and publishing a Cline Code desktop app (apps/examples/desktop-app) release. Guides changelog drafting, version bumps in package.json + tauri.conf.json, desktop-vX.Y.Z tags, and the desktop-publish GitHub workflow that builds, signs, notarizes, and updates the auto-update feed.
|
||||
---
|
||||
|
||||
# Desktop App Release
|
||||
|
||||
Use this skill when the user asks to release the desktop app, publish Cline Code, bump the desktop version, create a `desktop-vX.Y.Z` tag, or trigger the desktop publish workflow.
|
||||
|
||||
> Working directory: run every command below from the repository root.
|
||||
|
||||
Desktop releases are macOS-only today (signed + notarized DMG for Apple Silicon and Intel) and are built entirely in GitHub Actions — there is no local publish path. Installed apps discover new releases automatically through the Tauri updater, so publishing a release is what ships the update to every existing user.
|
||||
|
||||
## Release contract
|
||||
|
||||
- Version sources (must match each other and the tag): `apps/examples/desktop-app/package.json` and `apps/examples/desktop-app/src-tauri/tauri.conf.json`. (`src-tauri/Cargo.toml` has its own version but `tauri.conf.json` overrides it; no need to touch it.)
|
||||
- Release tag: `desktop-vX.Y.Z`, where `X.Y.Z` matches both version files.
|
||||
- Release prep includes approved release notes, the version bumps, and an `apps/examples/desktop-app/CHANGELOG.md` update.
|
||||
- Publish path: `.github/workflows/desktop-publish.yml` (workflow_dispatch, requires the tag to exist, point at the checked-out commit, and be reachable from `origin/main`).
|
||||
- The workflow creates the `desktop-vX.Y.Z` GitHub release (DMGs + updater artifacts + `latest.json`) and refreshes the rolling `desktop-latest` release, which is the static auto-update feed every installed app polls. Never delete the `desktop-latest` release or tag.
|
||||
- The changelog's top `## X.Y.Z` section is extracted verbatim into the GitHub release body, the Slack announcement, and the updater manifest notes.
|
||||
- Always ask before pushing commits or tags.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Gather context.
|
||||
|
||||
```sh
|
||||
git status --short --branch
|
||||
git fetch origin --tags
|
||||
git tag --list 'desktop-v*' --sort=-v:refname | head -10
|
||||
node -p "require('./apps/examples/desktop-app/package.json').version"
|
||||
node -p "require('./apps/examples/desktop-app/src-tauri/tauri.conf.json').version"
|
||||
```
|
||||
|
||||
If there is no `desktop-v*` tag yet, this is the first release; use the desktop app's first commit as the baseline and say the baseline is inferred.
|
||||
|
||||
2. Collect release commits.
|
||||
|
||||
```sh
|
||||
git log <last-desktop-tag>..HEAD --oneline --no-merges -- apps/examples/desktop-app sdk/packages .github/workflows/desktop-publish.yml
|
||||
```
|
||||
|
||||
The sidecar bundles `@cline/core` and friends from the monorepo, so SDK changes ship inside the desktop app too. Fold user-visible SDK changes (providers, models, behavior fixes) into the notes; skip purely internal ones.
|
||||
|
||||
3. Draft user-facing release notes.
|
||||
|
||||
Flat bullet list, user-facing language. Present the draft and wait for approval before editing files.
|
||||
|
||||
4. Decide the version bump.
|
||||
|
||||
Ask whether this is patch, minor, major, or an explicit version. Do not guess if the user has not made it clear.
|
||||
|
||||
5. Update release files.
|
||||
|
||||
- `apps/examples/desktop-app/package.json` → new version
|
||||
- `apps/examples/desktop-app/src-tauri/tauri.conf.json` → same version
|
||||
- Prepend `## X.Y.Z` (no date) to `apps/examples/desktop-app/CHANGELOG.md` with the approved notes.
|
||||
|
||||
6. Verify before committing.
|
||||
|
||||
```sh
|
||||
bun -F @cline/code typecheck
|
||||
bun test apps/examples/desktop-app/scripts/generate-update-manifest.test.ts
|
||||
```
|
||||
|
||||
The full desktop bundle can only be built on macOS; the workflow's build job is the real verification. For extra local confidence on a Mac checkout, `bun run package:desktop:mac --allow-unsigned-mac` from the app directory.
|
||||
|
||||
7. Commit release changes.
|
||||
|
||||
```sh
|
||||
git add apps/examples/desktop-app/package.json apps/examples/desktop-app/src-tauri/tauri.conf.json apps/examples/desktop-app/CHANGELOG.md
|
||||
git commit -m "chore(desktop): release vX.Y.Z"
|
||||
```
|
||||
|
||||
Ask before pushing the release commit, then before creating and pushing the tag:
|
||||
|
||||
```sh
|
||||
git push origin HEAD
|
||||
git tag -a desktop-vX.Y.Z -m "Desktop vX.Y.Z"
|
||||
git push origin refs/tags/desktop-vX.Y.Z
|
||||
```
|
||||
|
||||
8. Publish.
|
||||
|
||||
The release commit must be on `main` and the tag pushed first.
|
||||
|
||||
```sh
|
||||
gh workflow run desktop-publish.yml -f git_tag=desktop-vX.Y.Z -f confirm_publish=publish
|
||||
gh run list --workflow=desktop-publish.yml --limit=1 --json url,status,conclusion,createdAt --jq '.[0]'
|
||||
```
|
||||
|
||||
The workflow builds both architectures in parallel (aarch64 native, x86_64 cross-compiled), signs with the Developer ID certificate, notarizes with the App Store Connect API key, signs updater artifacts with the Tauri updater key, creates the GitHub release, refreshes `desktop-latest/latest.json`, and posts to Slack. Notarization typically adds 2–10 minutes.
|
||||
|
||||
If the workflow fails on missing credentials, see "Repo secrets (one-time setup)" below.
|
||||
|
||||
9. Verify the update feed after the run succeeds.
|
||||
|
||||
```sh
|
||||
curl -sL https://github.com/cline/cline/releases/download/desktop-latest/latest.json | head -30
|
||||
```
|
||||
|
||||
The `version` field must be the new release and both `darwin-aarch64` and `darwin-x86_64` URLs must point at the new `desktop-vX.Y.Z` assets. Installed apps pick the update up on next launch or within 2 hours.
|
||||
|
||||
10. Final response.
|
||||
|
||||
Report: version, tag, changelog updated, commit hash, what was pushed, workflow URL, and the feed verification result.
|
||||
|
||||
## Repo secrets (one-time setup)
|
||||
|
||||
The workflow needs these repository secrets. The Apple ones come from the same
|
||||
Apple Developer account used for manual signing (see the app README's "macOS
|
||||
signing & notarization" section for how to obtain them):
|
||||
|
||||
| Secret | Value |
|
||||
| --- | --- |
|
||||
| `APPLE_CERTIFICATE` | Base64 of the **Developer ID Application** identity exported from Keychain Access as `.p12` (must include the private key): `base64 -i certificate.p12 \| pbcopy` |
|
||||
| `APPLE_CERTIFICATE_PASSWORD` | The password chosen when exporting the `.p12` |
|
||||
| `APPLE_SIGNING_IDENTITY` | `Developer ID Application: <Team Name> (<TEAMID>)` — from `security find-identity -v -p codesigning` |
|
||||
| `APPLE_API_KEY` | App Store Connect API **Key ID** (notarization) |
|
||||
| `APPLE_API_KEY_CONTENT` | Contents of the `AuthKey_<KEYID>.p8` file |
|
||||
| `APPLE_API_ISSUER` | App Store Connect **Issuer ID** (UUID from Users and Access → Integrations) |
|
||||
| `TAURI_SIGNING_PRIVATE_KEY` | Contents of the Tauri updater private key (`tauri signer generate`). If this key is ever lost, shipped apps can no longer verify updates — guard it. |
|
||||
| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Password for that key |
|
||||
|
||||
The Slack + telemetry secrets (`SLACK_RELEASE_BOT_TOKEN`, `TELEMETRY_SERVICE_API_KEY`,
|
||||
OTEL settings) are shared with the CLI publish workflow and already configured.
|
||||
@@ -0,0 +1,158 @@
|
||||
---
|
||||
name: publish-ui
|
||||
description: Prepare, validate, and publish standalone @cline/ui npm releases. Use when bumping the UI package version, publishing latest or next through ui-publish.yml, checking UI release readiness, or completing the one-time npm trusted-publishing bootstrap.
|
||||
---
|
||||
|
||||
# Publish UI
|
||||
|
||||
Release `@cline/ui` independently from the Cline SDK runtime packages.
|
||||
|
||||
## Release contract
|
||||
|
||||
- Version source: `sdk/packages/ui/package.json`.
|
||||
- Workflow: `.github/workflows/ui-publish.yml`.
|
||||
- The package keeps `internal: true` only to stay out of the SDK's shared
|
||||
version/publish scripts. It is still a public npm package because
|
||||
`private: false` and `publishConfig.access: public` control npm publication.
|
||||
- `latest` is the production channel. `next` is an opt-in preview channel.
|
||||
- Use prerelease versions such as `0.2.0-next.0` for `next`; do not publish a
|
||||
version intended for `latest` under the preview tag because npm versions
|
||||
cannot be republished.
|
||||
- There is no UI Git tag, GitHub release, schedule, or Slack announcement.
|
||||
- The workflow runs only by manual dispatch. Every release attempt runs the UI
|
||||
quality checks before publishing and requires `confirm_publish=publish` from
|
||||
`main`.
|
||||
- The publish job and npm trust relationship use the protected `Publish`
|
||||
environment.
|
||||
- Every npm publication needs a new semver version; npm versions are immutable.
|
||||
- Always ask before pushing commits, triggering the publish workflow, changing
|
||||
npm trust settings, or running a local publish command.
|
||||
|
||||
## Normal release
|
||||
|
||||
1. Inspect the branch, current version, npm state, and UI changes.
|
||||
|
||||
```sh
|
||||
git status --short --branch
|
||||
node -p "require('./sdk/packages/ui/package.json').version"
|
||||
npm view @cline/ui dist-tags versions --json
|
||||
git log --oneline --no-merges -- \
|
||||
sdk/packages/ui apps/examples/desktop-app/webview/components/views/chat \
|
||||
.github/workflows/ui-publish.yml
|
||||
```
|
||||
|
||||
2. Ask for the npm channel and version together. For `latest`, ask for patch,
|
||||
minor, major, or an explicit version. For `next`, require an explicit
|
||||
prerelease version such as `0.2.0-next.0`. Do not guess. Update only
|
||||
`sdk/packages/ui/package.json` and its workspace version in `bun.lock`. Do
|
||||
not run the SDK version command.
|
||||
|
||||
3. Validate the release candidate.
|
||||
|
||||
```sh
|
||||
bun install --filter @cline/ui --filter @cline/code --frozen-lockfile
|
||||
bun -F @cline/ui typecheck
|
||||
bun -F @cline/ui test
|
||||
bun -F @cline/ui test:package
|
||||
bun -F @cline/ui build-storybook
|
||||
bun -F @cline/code test:chat-ui
|
||||
```
|
||||
|
||||
The packed-package test installs the tarball with Bun/React 19 and with
|
||||
npm/Node/React 18.
|
||||
Inspect `bun pm pack --dry-run` when the exported file set changed.
|
||||
|
||||
4. Commit the version bump separately from feature work. Ask before pushing.
|
||||
|
||||
```sh
|
||||
git add sdk/packages/ui/package.json bun.lock
|
||||
git commit -m "chore(ui): release vX.Y.Z"
|
||||
git push origin HEAD
|
||||
```
|
||||
|
||||
5. After the release commit reaches `main`, restate the selected npm tag and ask
|
||||
for explicit publish approval. Then trigger and watch the standalone
|
||||
workflow:
|
||||
|
||||
```sh
|
||||
run_url=$(gh workflow run ui-publish.yml --ref main \
|
||||
-f npm_tag=latest \
|
||||
-f confirm_publish=publish)
|
||||
test -n "$run_url"
|
||||
run_id=${run_url##*/}
|
||||
gh run watch "$run_id" --exit-status
|
||||
```
|
||||
|
||||
Use `npm_tag=next` only for a deliberate preview. Do not report success until
|
||||
the workflow succeeds and npm shows the exact version under the selected tag.
|
||||
|
||||
```sh
|
||||
npm view @cline/ui dist-tags versions --json
|
||||
```
|
||||
|
||||
## One-time npm bootstrap
|
||||
|
||||
Use this only while `npm view @cline/ui` returns `E404`. npm requires the
|
||||
package to exist before its GitHub trusted publisher can be configured.
|
||||
|
||||
1. Merge the package and `ui-publish.yml` to `main`. Start from a clean,
|
||||
reviewed `main` checkout. Verify authentication, account 2FA, and write
|
||||
access to the `@cline` npm organization. The `npm trust` command in step 4
|
||||
requires npm CLI 11.15 or newer; the automated trusted-publishing workflow
|
||||
itself enforces npm 11.5.1 or newer.
|
||||
|
||||
```sh
|
||||
npm --version
|
||||
npm whoami
|
||||
npm view @cline/ui version
|
||||
```
|
||||
|
||||
If npm is older than 11.15, ask before upgrading with
|
||||
`npm install -g npm@^11.15.0`.
|
||||
|
||||
2. Run the normal release validation in step 3 above. Then build, pack, test,
|
||||
and inspect the exact initial tarball. Record the absolute archive path
|
||||
printed by the final command.
|
||||
|
||||
```sh
|
||||
bun -F @cline/ui build
|
||||
pack_dir=$(mktemp -d)
|
||||
(cd sdk/packages/ui && bun pm pack --ignore-scripts --destination "$pack_dir" --quiet)
|
||||
tarball=$(find "$pack_dir" -maxdepth 1 -name '*.tgz' -print -quit)
|
||||
test -n "$tarball"
|
||||
bun sdk/packages/ui/scripts/smoke-package.ts "$tarball"
|
||||
tar -tzf "$tarball"
|
||||
printf 'Bootstrap archive: %s\n' "$tarball"
|
||||
```
|
||||
|
||||
3. Ask for explicit approval, then publish the initial version publicly under
|
||||
`latest`:
|
||||
|
||||
```sh
|
||||
npm publish /absolute/path/from-step-2.tgz --access public --tag latest
|
||||
```
|
||||
|
||||
4. Ask separately before configuring the standalone workflow as the trusted
|
||||
publisher:
|
||||
|
||||
```sh
|
||||
npm trust github @cline/ui \
|
||||
--repo cline/cline \
|
||||
--file ui-publish.yml \
|
||||
--env Publish \
|
||||
--allow-publish
|
||||
```
|
||||
|
||||
5. Verify both package state and trust. Every later release uses the workflow;
|
||||
do not add a long-lived npm token.
|
||||
|
||||
```sh
|
||||
npm view @cline/ui dist-tags versions --json
|
||||
npm trust list @cline/ui
|
||||
```
|
||||
|
||||
## Final report
|
||||
|
||||
Report the version and npm tag, release commit, whether anything was pushed,
|
||||
workflow URL or bootstrap result, npm verification, and tests/builds run. If
|
||||
the package still returns `E404`, state that bootstrap remains required.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Publish UI"
|
||||
short_description: "Prepare and publish the Cline UI package"
|
||||
default_prompt: "Use $publish-ui to prepare and publish a new @cline/ui npm release."
|
||||
@@ -8,8 +8,9 @@ HTTP-controlled debugger for the VSCode extension at `src/dev/debug-harness/serv
|
||||
# Build extension first if needed (protos + esbuild):
|
||||
bun run protos && IS_DEV=true bun esbuild.mjs
|
||||
|
||||
# Launch (skip-build if already built):
|
||||
bun src/dev/debug-harness/server.ts --skip-build --auto-launch
|
||||
# Launch (skip-build if already built). Run with node, NOT bun — Playwright's
|
||||
# Electron launch times out under bun:
|
||||
node src/dev/debug-harness/server.ts --skip-build --auto-launch
|
||||
|
||||
# In another terminal:
|
||||
curl localhost:19229/api -d '{"method":"status"}'
|
||||
|
||||
@@ -16,6 +16,7 @@ This file is the secret sauce for working effectively in this codebase. It captu
|
||||
- The whole repo (including `apps/vscode`) uses **bun** for package management and task running. Emit `bun run X` / `bun install` / `bunx <bin>` / `bun file.ts`, never npm/npx. Node remains the *runtime* (VS Code's extension host and the standalone cline-core are Node), so Node-runtime tokens are legitimate and must not be "fixed" to bun — see @.clinerules/bun-and-node.md for the keep-list vs rewrite-list.
|
||||
- Avoid provider-specific string matching / hardcoded provider branches when fixing provider/config plumbing. Prefer provider metadata, shared catalog/defaults, explicit protocol/client capabilities, or centralized normalization utilities that apply by data shape rather than `providerId === "..."`. If a provider exception seems necessary, stop and explain why instead of adding ad-hoc string matching.
|
||||
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `bun run compile`, not `bun run build`).
|
||||
- When reading a configuration files that users may edit, use `readFileStrippingUtf8Bom`, `readFileSyncStrippingUtf8Bom`, or `stripUtf8Bom` from `@cline/shared/node`. DON'T strip byte order marks of user files handled by tools/passed to models.
|
||||
- When creating PRs, contributors should not create changelog-entry files. Maintainers handle release versioning and changelog curation during the release process.
|
||||
- When adding new feature flags, see this PR as a reference https://github.com/cline/cline/pull/7566
|
||||
- Additional instructions about making requests: @.clinerules/network.md
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
name: desktop-publish
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
git_tag:
|
||||
description: "Existing release tag to publish, for example desktop-v0.1.0"
|
||||
required: true
|
||||
type: string
|
||||
confirm_publish:
|
||||
description: 'Type "publish" to confirm the desktop release.'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: .
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate release tag
|
||||
if: |
|
||||
github.repository == 'cline/cline' &&
|
||||
github.event.inputs.confirm_publish == 'publish' &&
|
||||
!endsWith(github.actor, '[bot]')
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.git_tag }}
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Validate release tag
|
||||
id: version
|
||||
env:
|
||||
TAG: ${{ github.event.inputs.git_tag }}
|
||||
run: |
|
||||
if ! printf "%s\n" "$TAG" | grep -Eq '^desktop-v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
|
||||
echo "git_tag must look like desktop-vX.Y.Z, got: ${TAG}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION="${TAG#desktop-v}"
|
||||
PACKAGE_VERSION=$(node -p "require('./apps/examples/desktop-app/package.json').version")
|
||||
TAURI_VERSION=$(node -p "require('./apps/examples/desktop-app/src-tauri/tauri.conf.json').version")
|
||||
|
||||
if [ "$PACKAGE_VERSION" != "$VERSION" ]; then
|
||||
echo "apps/examples/desktop-app/package.json version ${PACKAGE_VERSION} does not match ${TAG}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$TAURI_VERSION" != "$VERSION" ]; then
|
||||
echo "apps/examples/desktop-app/src-tauri/tauri.conf.json version ${TAURI_VERSION} does not match ${TAG}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TAG_COMMIT=$(git rev-parse "${TAG}^{commit}")
|
||||
HEAD_COMMIT=$(git rev-parse HEAD)
|
||||
if [ "$TAG_COMMIT" != "$HEAD_COMMIT" ]; then
|
||||
echo "${TAG} does not point at the checked out commit"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch origin +main:refs/remotes/origin/main
|
||||
if ! git merge-base --is-ancestor "$HEAD_COMMIT" origin/main; then
|
||||
echo "${TAG} is not reachable from origin/main"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
name: Build macOS (${{ matrix.arch }})
|
||||
needs: validate
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 90
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
include:
|
||||
- target: aarch64-apple-darwin
|
||||
arch: aarch64
|
||||
- target: x86_64-apple-darwin
|
||||
arch: x86_64
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.tag }}
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Cache Rust build
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: apps/examples/desktop-app/src-tauri
|
||||
key: ${{ matrix.target }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build SDK packages
|
||||
run: bun run build:sdk
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
|
||||
- name: Write App Store Connect API key
|
||||
env:
|
||||
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }}
|
||||
run: |
|
||||
if [ -z "$APPLE_API_KEY_CONTENT" ]; then
|
||||
echo "APPLE_API_KEY_CONTENT secret is not configured"
|
||||
exit 1
|
||||
fi
|
||||
printf "%s" "$APPLE_API_KEY_CONTENT" > "$RUNNER_TEMP/AuthKey.p8"
|
||||
|
||||
- name: Build, sign, and notarize desktop bundle
|
||||
working-directory: apps/examples/desktop-app
|
||||
run: bunx tauri build --target ${{ matrix.target }} --config src-tauri/tauri.release.conf.json
|
||||
env:
|
||||
# Developer ID signing (Tauri imports the cert into a temp keychain)
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
# Notarization via App Store Connect API key. Tauri reads the Key ID
|
||||
# from APPLE_API_KEY; APPLE_API_KEY_ID alone silently skips notarization.
|
||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||
APPLE_API_KEY_PATH: ${{ runner.temp }}/AuthKey.p8
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
# Updater artifact signing (minisign keypair, independent of Apple)
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
- name: Collect artifacts
|
||||
working-directory: apps/examples/desktop-app
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
ARCH: ${{ matrix.arch }}
|
||||
TARGET: ${{ matrix.target }}
|
||||
run: |
|
||||
BUNDLE_DIR="src-tauri/target/${TARGET}/release/bundle"
|
||||
OUT="dist/publish"
|
||||
mkdir -p "$OUT"
|
||||
|
||||
DMG=$(find "$BUNDLE_DIR/dmg" -name '*.dmg' -print -quit)
|
||||
if [ -z "$DMG" ]; then
|
||||
echo "no DMG produced under $BUNDLE_DIR/dmg"
|
||||
exit 1
|
||||
fi
|
||||
cp "$DMG" "$OUT/Cline-Code_${VERSION}_${ARCH}.dmg"
|
||||
|
||||
TARBALL=$(find "$BUNDLE_DIR/macos" -name '*.app.tar.gz' -print -quit)
|
||||
if [ -z "$TARBALL" ] || [ ! -f "${TARBALL}.sig" ]; then
|
||||
echo "updater artifact or signature missing under $BUNDLE_DIR/macos"
|
||||
exit 1
|
||||
fi
|
||||
cp "$TARBALL" "$OUT/Cline-Code_${VERSION}_${ARCH}.app.tar.gz"
|
||||
cp "${TARBALL}.sig" "$OUT/Cline-Code_${VERSION}_${ARCH}.app.tar.gz.sig"
|
||||
|
||||
ls -lh "$OUT"
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-${{ matrix.arch }}
|
||||
path: apps/examples/desktop-app/dist/publish/*
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: Create GitHub release
|
||||
needs: [validate, build]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.tag }}
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist/desktop
|
||||
merge-multiple: true
|
||||
|
||||
- name: Get Changelog Entry
|
||||
id: changelog
|
||||
run: |
|
||||
# Grab content between the first "## " header and the next one
|
||||
CONTENT=$(awk '/^## [0-9]/{if(found) exit; found=1; next} found{print}' apps/examples/desktop-app/CHANGELOG.md)
|
||||
echo "content<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
printf "%s\n" "$CONTENT" > "$RUNNER_TEMP/release-notes.md"
|
||||
|
||||
- name: Generate updater manifest
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
TAG: ${{ needs.validate.outputs.tag }}
|
||||
run: |
|
||||
bun apps/examples/desktop-app/scripts/generate-update-manifest.ts \
|
||||
--version "$VERSION" \
|
||||
--tag "$TAG" \
|
||||
--dir dist/desktop \
|
||||
--out dist/desktop/latest.json \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--notes-file "$RUNNER_TEMP/release-notes.md"
|
||||
cat dist/desktop/latest.json
|
||||
|
||||
- name: Get Previous Desktop Tag
|
||||
id: prev_tag
|
||||
env:
|
||||
CURRENT_TAG: ${{ needs.validate.outputs.tag }}
|
||||
run: |
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 --match 'desktop-v*' "$CURRENT_TAG^" 2>/dev/null || echo "")
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ needs.validate.outputs.tag }}
|
||||
name: "Desktop v${{ needs.validate.outputs.version }}"
|
||||
# The repo-wide "latest" release stays owned by CLI releases; the
|
||||
# desktop auto-update feed is the rolling desktop-latest release.
|
||||
make_latest: "false"
|
||||
files: dist/desktop/*
|
||||
body: |
|
||||
${{ steps.changelog.outputs.content }}
|
||||
|
||||
${{ steps.prev_tag.outputs.prev_tag != '' && format('**Full Changelog**: https://github.com/{0}/compare/{1}...{2}', github.repository, steps.prev_tag.outputs.prev_tag, needs.validate.outputs.tag) || '' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Update auto-update feed (desktop-latest)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if ! gh release view desktop-latest >/dev/null 2>&1; then
|
||||
gh release create desktop-latest \
|
||||
--title "Cline Code desktop (auto-update feed)" \
|
||||
--notes "Rolling release backing the desktop app auto-updater. The latest.json asset points at the newest desktop-vX.Y.Z release. Do not delete." \
|
||||
--latest=false \
|
||||
--target "$(git rev-parse HEAD)"
|
||||
fi
|
||||
gh release upload desktop-latest dist/desktop/latest.json --clobber
|
||||
|
||||
- name: Summary
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
TAG: ${{ needs.validate.outputs.tag }}
|
||||
run: |
|
||||
echo "Published Cline Code desktop v${VERSION}"
|
||||
echo "Release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${TAG}"
|
||||
echo "Auto-update feed refreshed: https://github.com/${GITHUB_REPOSITORY}/releases/download/desktop-latest/latest.json"
|
||||
|
||||
- name: Post release to Slack
|
||||
uses: slackapi/slack-github-action@v3.0.1
|
||||
with:
|
||||
method: chat.postMessage
|
||||
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: "C0APVKGGZFC"
|
||||
text: "Cline Code desktop v${{ needs.validate.outputs.version }}"
|
||||
blocks:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: "Cline Code desktop v${{ needs.validate.outputs.version }}"
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
text: "<https://github.com/${{ github.repository }}/releases/tag/${{ needs.validate.outputs.tag }}|Download DMG> — installed apps auto-update on next launch${{ steps.prev_tag.outputs.prev_tag != '' && format(' | Full Changelog: https://github.com/{0}/compare/{1}...{2}', github.repository, steps.prev_tag.outputs.prev_tag, needs.validate.outputs.tag) || '' }}"
|
||||
@@ -0,0 +1,205 @@
|
||||
name: ext-vscode-ab-package
|
||||
|
||||
# Build (and optionally publish) the combined A/B VSIX: a tiny loader plus two
|
||||
# complete extension bundles — `next/` from the SDK-based apps/vscode on main,
|
||||
# `legacy/` from the legacy-extension branch. Cohort selection happens at
|
||||
# runtime via PostHog flags; see apps/vscode-rollout/README.md for the design
|
||||
# and the rollout runbook.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Combined VSIX version — must exceed every previously published version (e.g. 4.1.0)"
|
||||
required: true
|
||||
type: string
|
||||
next-ref:
|
||||
description: "Ref to build the next (SDK) bundle from"
|
||||
required: true
|
||||
default: "main"
|
||||
type: string
|
||||
legacy-ref:
|
||||
description: "Ref to build the legacy bundle from"
|
||||
required: true
|
||||
default: "legacy-extension"
|
||||
type: string
|
||||
publish:
|
||||
description: "Publish to the VS Code Marketplace (unchecked: just build the .vsix artifact)"
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ext-vscode-ab-package-${{ github.event.inputs.version }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
package:
|
||||
name: Build combined (legacy + next) VSIX
|
||||
runs-on: ubuntu-latest
|
||||
environment: publish
|
||||
steps:
|
||||
- name: Checkout next (SDK) source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.next-ref }}
|
||||
path: next-src
|
||||
lfs: true
|
||||
|
||||
- name: Checkout legacy source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.legacy-ref }}
|
||||
path: legacy-src
|
||||
lfs: true
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install next workspace dependencies
|
||||
working-directory: next-src
|
||||
run: bun install
|
||||
|
||||
# @cline/* are local workspace symlinks to source packages; apps/vscode's
|
||||
# `package` script does NOT build them, so without this the esbuild step
|
||||
# fails on a fresh checkout. (The nightly workflow already does this.)
|
||||
- name: Build SDK packages
|
||||
working-directory: next-src
|
||||
run: bun run build:sdk
|
||||
|
||||
# Stamp the combined version into each bundle's package.json AFTER
|
||||
# install and BEFORE its build: the About tab and telemetry
|
||||
# extension_version read the bundle's own manifest, so without this
|
||||
# the VSIX reports three different versions depending on where you
|
||||
# look. (The nightly workflow gets the same alignment via nightlify.mjs.)
|
||||
- name: Align next bundle version
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/set-version.mjs --dir "$GITHUB_WORKSPACE/next-src/apps/vscode" --version "${{ github.event.inputs.version }}"
|
||||
|
||||
- name: Build next bundle
|
||||
working-directory: next-src/apps/vscode
|
||||
env:
|
||||
CLINE_ENVIRONMENT: production
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
# Inlined by esbuild: attributes every telemetry event with
|
||||
# extension_variant and unlocks the bundle's authoritative
|
||||
# extension.rollout.bundle_activated capture. Rollout builds only.
|
||||
CLINE_ROLLOUT_VARIANT: next
|
||||
# Match the stable publish workflow's OpenTelemetry production defaults.
|
||||
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 }}
|
||||
run: bun run package
|
||||
|
||||
- name: Install legacy dependencies
|
||||
working-directory: legacy-src
|
||||
run: |
|
||||
npm --prefix apps/vscode install --include=optional
|
||||
npm --prefix apps/vscode/webview-ui install --include=optional
|
||||
|
||||
- name: Align legacy bundle version
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/set-version.mjs --dir "$GITHUB_WORKSPACE/legacy-src/apps/vscode" --version "${{ github.event.inputs.version }}"
|
||||
|
||||
- name: Build legacy bundle
|
||||
working-directory: legacy-src/apps/vscode
|
||||
env:
|
||||
CLINE_ENVIRONMENT: production
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
CLINE_ROLLOUT_VARIANT: legacy
|
||||
# Match the stable publish workflow's OpenTelemetry production defaults.
|
||||
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 }}
|
||||
run: npm run package
|
||||
|
||||
- name: Build loader and run rollout tests
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
run: |
|
||||
bun run typecheck
|
||||
bun run test
|
||||
bun run build:production
|
||||
|
||||
- name: Stitch combined VSIX staging
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: |
|
||||
node scripts/stitch.mjs \
|
||||
--next "$GITHUB_WORKSPACE/next-src/apps/vscode" \
|
||||
--legacy "$GITHUB_WORKSPACE/legacy-src/apps/vscode" \
|
||||
--loader dist/extension.js \
|
||||
--version "${{ github.event.inputs.version }}" \
|
||||
--out "$GITHUB_WORKSPACE/staging"
|
||||
|
||||
- name: Smoke-test loader against staging
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/smoke-loader.mjs "$GITHUB_WORKSPACE/staging"
|
||||
|
||||
# This workflow publishes the STABLE identity. If nightlify ever leaks
|
||||
# into this path the union manifest would ship under the wrong name.
|
||||
# The bundle sub-manifest checks guard the set-version.mjs stamping:
|
||||
# the About tab and telemetry extension_version read those files.
|
||||
- name: Assert stable manifest identity
|
||||
working-directory: staging
|
||||
env:
|
||||
EXPECTED_VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
node -e '
|
||||
const assert = require("node:assert");
|
||||
const expected = process.env.EXPECTED_VERSION;
|
||||
const pkg = require("./package.json");
|
||||
assert.equal(pkg.name, "claude-dev", `unexpected name ${pkg.name}`);
|
||||
assert.equal(pkg.publisher, "saoudrizwan", `unexpected publisher ${pkg.publisher}`);
|
||||
assert.equal(pkg.version, expected, `unexpected union version ${pkg.version}`);
|
||||
for (const bundle of ["next", "legacy"]) {
|
||||
const sub = require(`./${bundle}/package.json`);
|
||||
assert.equal(sub.version, expected, `unexpected ${bundle} bundle version ${sub.version}`);
|
||||
}
|
||||
console.log(`stable identity ok: ${pkg.publisher}.${pkg.name}@${pkg.version} (bundle versions aligned)`);
|
||||
'
|
||||
|
||||
- name: Package VSIX
|
||||
working-directory: staging
|
||||
run: |
|
||||
npm install -g @vscode/vsce
|
||||
# Preserve the narrowly scoped VSCE `sendgrid` scanner exemption used by
|
||||
# both standalone bundle workflows. No SendGrid credential is intentionally
|
||||
# supplied here; inspect the reported artifact before widening the exemption.
|
||||
vsce package --no-dependencies --allow-package-secrets sendgrid --out "claude-dev-${{ github.event.inputs.version }}.vsix"
|
||||
|
||||
- name: Upload VSIX artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: claude-dev-${{ github.event.inputs.version }}
|
||||
path: staging/claude-dev-${{ github.event.inputs.version }}.vsix
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Publish to Marketplace
|
||||
if: ${{ github.event.inputs.publish == 'true' }}
|
||||
working-directory: staging
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
run: |
|
||||
if [[ -z "$VSCE_PAT" ]]; then
|
||||
echo "Error: VSCE_PAT is required to publish."
|
||||
exit 1
|
||||
fi
|
||||
vsce publish --no-dependencies --packagePath "claude-dev-${{ github.event.inputs.version }}.vsix"
|
||||
@@ -1,17 +1,40 @@
|
||||
name: ext-vscode-publish-nightly
|
||||
|
||||
# Publishes saoudrizwan.cline-nightly as the COMBINED A/B VSIX: the rollout
|
||||
# loader plus two complete extension bundles — `next/` from this ref's
|
||||
# apps/vscode (SDK-based) and `legacy/` from the legacy-extension branch.
|
||||
# Cohort selection happens at runtime via PostHog flags; see
|
||||
# apps/vscode-rollout/README.md for the design and rollout runbook.
|
||||
#
|
||||
# The stable-identity equivalent of this pipeline is ext-vscode-ab-package.yml
|
||||
# (manual dispatch, publishes claude-dev). Shared logic lives in
|
||||
# apps/vscode-rollout/scripts (nightlify/gen-manifest/stitch/smoke) so both
|
||||
# workflows stay thin. The single-bundle nightly path this replaced
|
||||
# (apps/vscode/scripts/publish-nightly.mjs) remains for manual feature-branch
|
||||
# pre-release publishes.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every day at 4:00 AM PST (12:00 UTC)
|
||||
- cron: "0 12 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
legacy-ref:
|
||||
description: "Ref to build the legacy bundle from"
|
||||
required: false
|
||||
default: "legacy-extension"
|
||||
type: string
|
||||
dry-run:
|
||||
description: "Build and upload the .vsix artifact without publishing or tagging"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
run-name: "Publish Nightly from ${{ github.ref_name }} @ ${{ github.sha }}"
|
||||
run-name: "Publish Combined Nightly from ${{ github.ref_name }} @ ${{ github.sha }}"
|
||||
|
||||
# Prevent concurrent publish runs on the same branch. The nightly publish script
|
||||
# generates the extension version from a seconds-resolution timestamp, so parallel
|
||||
# runs on the same ref can collide on the same version and cause publish failures
|
||||
# or inconsistent tagging. Runs on different branches proceed independently.
|
||||
# Prevent concurrent publish runs on the same branch: the version is generated
|
||||
# from a seconds-resolution timestamp, so parallel runs on the same ref can
|
||||
# collide on the same version and cause publish failures or inconsistent tagging.
|
||||
concurrency:
|
||||
group: ext-vscode-publish-nightly-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
@@ -20,7 +43,7 @@ permissions: {}
|
||||
|
||||
jobs:
|
||||
test:
|
||||
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
|
||||
if: github.repository == 'cline/cline'
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
@@ -30,60 +53,79 @@ jobs:
|
||||
needs: test
|
||||
permissions:
|
||||
contents: write
|
||||
name: Publish Cline (Nightly) Extension
|
||||
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
|
||||
name: Publish Cline (Nightly) Combined Extension
|
||||
# Defense in depth: only protected main may enter the publishing environment.
|
||||
# This `if` is advisory because a dispatched branch runs its own copy of this
|
||||
# file; the enforced gate is the PublishNightly environment's deployment-branch
|
||||
# policy, which must also allow only main.
|
||||
if: github.repository == 'cline/cline' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
environment: PublishNightly
|
||||
# The VS Code extension's package.json and lockfiles live under apps/vscode/
|
||||
# (the repo root has no package.json). Mirror ext-vscode-test.yml so install
|
||||
# and publish steps run in the correct workspace.
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
|
||||
steps:
|
||||
- name: Checkout selected branch
|
||||
- name: Checkout next (SDK) source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
path: next-src
|
||||
lfs: true
|
||||
persist-credentials: false
|
||||
|
||||
- name: Show build source
|
||||
working-directory: ${{ github.workspace }}
|
||||
- name: Checkout legacy source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# NOTE: inputs are empty strings on `schedule` events, so the ||
|
||||
# fallback (not the input's declared default) is what the cron uses.
|
||||
ref: ${{ inputs.legacy-ref || 'legacy-extension' }}
|
||||
path: legacy-src
|
||||
lfs: true
|
||||
persist-credentials: false
|
||||
|
||||
- name: Show build sources
|
||||
env:
|
||||
# Routed through env rather than interpolated into the script body so
|
||||
# a crafted dispatch input can't inject shell (hygiene: dispatchers
|
||||
# need write access anyway, but keep the pattern clean).
|
||||
LEGACY_REF: ${{ inputs.legacy-ref || 'legacy-extension' }}
|
||||
run: |
|
||||
echo "Building ref: $GITHUB_REF"
|
||||
echo "Building sha: $GITHUB_SHA"
|
||||
git --no-pager log -1 --oneline
|
||||
echo "next: $(git -C next-src rev-parse HEAD)"
|
||||
echo "legacy: $(git -C legacy-src rev-parse HEAD) ($LEGACY_REF)"
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
|
||||
# Node is still REQUIRED in the publish job (not just for install): the publish
|
||||
# scripts run as `node ./scripts/publish-*.mjs` and shell out to `npx ovsx`.
|
||||
# setup-bun does not provide a Node runtime, so keep setup-node here.
|
||||
# Pinned to Node 22 because newer LTS (Node 24 / npm 11) can make vsce's
|
||||
# `npm list` dependency detection fail with ELSPROBLEMS during packaging.
|
||||
# Node is required beyond install: the rollout scripts run under node and
|
||||
# publishing shells out to vsce/ovsx. Pinned to Node 22 because newer LTS
|
||||
# (Node 24 / npm 11) can make vsce's dependency detection fail.
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
# Single root install resolves the whole bun workspace at once (replaces the
|
||||
# per-package `npm ci` steps for apps/vscode + webview-ui).
|
||||
- name: Install workspace dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
# ONE version for the next bundle, the legacy bundle, and the union
|
||||
# manifest: gen-manifest hard-fails if the bundle identities diverge.
|
||||
# Same scheme as the standalone nightly: <major>.<minor>.<unix-seconds>
|
||||
# from next's base version, so it keeps outranking earlier nightlies.
|
||||
- name: Compute nightly version
|
||||
id: version
|
||||
run: |
|
||||
BASE=$(node -p "require('./next-src/apps/vscode/package.json').version")
|
||||
VERSION="$(echo "$BASE" | cut -d. -f1,2).$(date +%s)"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Combined nightly version: $VERSION (base $BASE)"
|
||||
|
||||
- name: Install next workspace dependencies
|
||||
working-directory: next-src
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
# @cline/* are local workspace symlinks to source packages; build dist/ before
|
||||
# packaging/publishing the extension.
|
||||
- name: Build SDK packages
|
||||
working-directory: ${{ github.workspace }}
|
||||
working-directory: next-src
|
||||
run: bun run build:sdk
|
||||
|
||||
- name: Assert better-sqlite3 native binary present
|
||||
working-directory: next-src/apps/vscode
|
||||
run: |
|
||||
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
|
||||
if [ ! -f "$NODE_FILE" ]; then
|
||||
@@ -93,20 +135,24 @@ jobs:
|
||||
fi
|
||||
echo "Found better-sqlite3 native binary: $NODE_FILE"
|
||||
|
||||
# vsce is now a workspace devDependency (on node_modules/.bin), but ovsx is not
|
||||
# vendored and the publish script invokes it via `npx ovsx`, so install ovsx
|
||||
# globally here (npm is available via setup-node). vsce is installed globally
|
||||
# too to preserve the script's existing PATH expectations.
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
# Rewrite each bundle's package.json to the cline-nightly identity BEFORE
|
||||
# its build (runtime command/config IDs derive from the manifest) and
|
||||
# AFTER dependency install (workspace self-links key off the original
|
||||
# package name).
|
||||
- name: Nightlify next bundle manifest
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/nightlify.mjs --dir "$GITHUB_WORKSPACE/next-src/apps/vscode" --version "${{ steps.version.outputs.version }}"
|
||||
|
||||
- name: Publish Nightly Extension
|
||||
- name: Build next bundle
|
||||
working-directory: next-src/apps/vscode
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
CLINE_ENVIRONMENT: production
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
CLINE_ENVIRONMENT: production
|
||||
# Inlined by esbuild: attributes every telemetry event with
|
||||
# extension_variant and unlocks the bundle's authoritative
|
||||
# extension.rollout.bundle_activated capture. Rollout builds only.
|
||||
CLINE_ROLLOUT_VARIANT: next
|
||||
# OpenTelemetry production defaults (can be overridden at runtime)
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
@@ -114,12 +160,129 @@ jobs:
|
||||
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 }}
|
||||
# The script itself runs under `node ./scripts/publish-nightly.mjs`; bun run
|
||||
# just launches it. Node + npm (for `npx ovsx`) are provided by setup-node above.
|
||||
run: bun run publish:marketplace:nightly
|
||||
run: bun run package
|
||||
|
||||
- name: Install legacy dependencies
|
||||
working-directory: legacy-src
|
||||
run: |
|
||||
npm --prefix apps/vscode install --include=optional
|
||||
npm --prefix apps/vscode/webview-ui install --include=optional
|
||||
|
||||
- name: Nightlify legacy bundle manifest
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/nightlify.mjs --dir "$GITHUB_WORKSPACE/legacy-src/apps/vscode" --version "${{ steps.version.outputs.version }}"
|
||||
|
||||
- name: Build legacy bundle
|
||||
working-directory: legacy-src/apps/vscode
|
||||
env:
|
||||
CLINE_ENVIRONMENT: production
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
CLINE_ROLLOUT_VARIANT: legacy
|
||||
# Legacy's esbuild inlines these too (its own publish workflow passes
|
||||
# them) — omitting them here would ship the legacy bundle with the
|
||||
# OTel pipeline dead, unlike what legacy users get today.
|
||||
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 }}
|
||||
run: npm run package
|
||||
|
||||
- name: Build loader and run rollout tests
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
run: |
|
||||
bun run typecheck
|
||||
bun run test
|
||||
bun run build:production
|
||||
|
||||
- name: Stitch combined VSIX staging
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: |
|
||||
node scripts/stitch.mjs \
|
||||
--next "$GITHUB_WORKSPACE/next-src/apps/vscode" \
|
||||
--legacy "$GITHUB_WORKSPACE/legacy-src/apps/vscode" \
|
||||
--loader dist/extension.js \
|
||||
--version "${{ steps.version.outputs.version }}" \
|
||||
--out "$GITHUB_WORKSPACE/staging"
|
||||
|
||||
- name: Smoke-test loader against staging
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/smoke-loader.mjs "$GITHUB_WORKSPACE/staging"
|
||||
|
||||
# The nightly identity must have fully propagated (nightlify -> both
|
||||
# bundle manifests -> union manifest) or we'd publish over the stable
|
||||
# extension ID. The bundle sub-manifest checks guard the version
|
||||
# stamping: the About tab and telemetry extension_version read those.
|
||||
- name: Assert nightly manifest identity
|
||||
working-directory: staging
|
||||
env:
|
||||
EXPECTED_VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
node -e '
|
||||
const assert = require("node:assert");
|
||||
const expected = process.env.EXPECTED_VERSION;
|
||||
const pkg = require("./package.json");
|
||||
assert.equal(pkg.name, "cline-nightly", `unexpected name ${pkg.name}`);
|
||||
assert.equal(pkg.publisher, "saoudrizwan", `unexpected publisher ${pkg.publisher}`);
|
||||
assert.equal(pkg.version, expected, `unexpected union version ${pkg.version}`);
|
||||
for (const bundle of ["next", "legacy"]) {
|
||||
const sub = require(`./${bundle}/package.json`);
|
||||
assert.equal(sub.name, "cline-nightly", `unexpected ${bundle} bundle name ${sub.name}`);
|
||||
assert.equal(sub.version, expected, `unexpected ${bundle} bundle version ${sub.version}`);
|
||||
}
|
||||
console.log(`nightly identity ok: ${pkg.publisher}.${pkg.name}@${pkg.version} (bundle identities aligned)`);
|
||||
'
|
||||
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
- name: Package VSIX
|
||||
working-directory: staging
|
||||
# Preserve the narrowly scoped VSCE `sendgrid` scanner exemption used by
|
||||
# both standalone bundle workflows. No SendGrid credential is intentionally
|
||||
# supplied here; inspect the reported artifact before widening the exemption.
|
||||
run: vsce package --no-dependencies --allow-package-secrets sendgrid --out "cline-nightly-${{ steps.version.outputs.version }}.vsix"
|
||||
|
||||
- name: Upload VSIX artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cline-nightly-${{ steps.version.outputs.version }}
|
||||
path: staging/cline-nightly-${{ steps.version.outputs.version }}.vsix
|
||||
if-no-files-found: error
|
||||
|
||||
# The job is main-only; step-level dry-run gating still permits a build-only
|
||||
# rehearsal without publishing or tagging.
|
||||
- name: Publish to VS Code Marketplace and Open VSX
|
||||
if: github.ref == 'refs/heads/main' && inputs.dry-run != true
|
||||
working-directory: staging
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
run: |
|
||||
if [[ -z "$VSCE_PAT" ]]; then
|
||||
echo "Error: VSCE_PAT is required to publish."
|
||||
exit 1
|
||||
fi
|
||||
vsce publish --no-dependencies --packagePath "cline-nightly-${{ steps.version.outputs.version }}.vsix"
|
||||
if [[ -n "$OVSX_PAT" ]]; then
|
||||
npx ovsx publish --packagePath "cline-nightly-${{ steps.version.outputs.version }}.vsix" --pat "$OVSX_PAT"
|
||||
else
|
||||
echo "WARNING: OVSX_PAT not set; skipping Open VSX publish."
|
||||
fi
|
||||
|
||||
- name: Tag published commit
|
||||
working-directory: ${{ github.workspace }}
|
||||
if: github.ref == 'refs/heads/main' && inputs.dry-run != true
|
||||
# Best-effort bookkeeping: the default GITHUB_TOKEN cannot create a ref
|
||||
# whose commit modifies workflow files (no workflows permission exists
|
||||
# for it), so this step fails whenever HEAD touched .github/workflows.
|
||||
# The publish already succeeded by this point — don't mark the run red;
|
||||
# push the tag manually with user credentials when it matters.
|
||||
continue-on-error: true
|
||||
working-directory: next-src
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
@@ -127,10 +290,11 @@ jobs:
|
||||
SHORT_SHA=$(git rev-parse --short=12 HEAD)
|
||||
TIMESTAMP=$(date -u +"%Y%m%d%H%M%S")
|
||||
TAG="nightly-${SAFE_REF}-${TIMESTAMP}-${SHORT_SHA}"
|
||||
LEGACY_SHA=$(git -C "$GITHUB_WORKSPACE/legacy-src" rev-parse HEAD)
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag -a "$TAG" -m "Cline Nightly published from ${GITHUB_REF_NAME} at ${GITHUB_SHA}"
|
||||
git tag -a "$TAG" -m "Cline Nightly (combined A/B) published from ${GITHUB_REF_NAME} at ${GITHUB_SHA} (legacy bundle: ${LEGACY_SHA})"
|
||||
# Use an explicit HTTPS remote with GH_TOKEN because checkout was run with
|
||||
# persist-credentials: false, so actions/checkout did not persist a git credential helper.
|
||||
git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "refs/tags/${TAG}"
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
name: ui-publish
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
npm_tag:
|
||||
description: "npm distribution tag"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- next
|
||||
- latest
|
||||
default: next
|
||||
confirm_publish:
|
||||
description: 'Type "publish" to publish @cline/ui to npm'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
name: UI quality and package checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24.x"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --filter @cline/ui --filter @cline/code --frozen-lockfile
|
||||
|
||||
- name: Typecheck UI
|
||||
run: bun -F @cline/ui typecheck
|
||||
|
||||
- name: Test UI
|
||||
run: bun -F @cline/ui test
|
||||
|
||||
- name: Build Storybook
|
||||
run: bun -F @cline/ui build-storybook
|
||||
|
||||
- name: Build UI package
|
||||
run: bun -F @cline/ui build
|
||||
|
||||
- name: Test desktop chat integration
|
||||
run: bun -F @cline/code test:chat-ui
|
||||
|
||||
- name: Pack publish artifact
|
||||
id: pack
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pack_dir="$RUNNER_TEMP/ui-npm-pack"
|
||||
mkdir -p "$pack_dir"
|
||||
cd sdk/packages/ui
|
||||
bun pm pack --ignore-scripts --destination "$pack_dir" --quiet
|
||||
archive=$(find "$pack_dir" -maxdepth 1 -name '*.tgz' -print -quit)
|
||||
test -n "$archive"
|
||||
echo "archive=$archive" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Test packed package
|
||||
env:
|
||||
UI_PACKAGE_ARCHIVE: ${{ steps.pack.outputs.archive }}
|
||||
run: bun sdk/packages/ui/scripts/smoke-package.ts "$UI_PACKAGE_ARCHIVE"
|
||||
|
||||
- name: Upload publish artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ui-npm-package
|
||||
path: ${{ runner.temp }}/ui-npm-pack/*.tgz
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
publish:
|
||||
name: Publish @cline/ui
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' &&
|
||||
github.repository == 'cline/cline' &&
|
||||
github.ref == 'refs/heads/main' &&
|
||||
inputs.confirm_publish == 'publish' &&
|
||||
!endsWith(github.actor, '[bot]')
|
||||
needs: quality
|
||||
runs-on: ubuntu-latest
|
||||
environment: Publish
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24.x"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Download publish artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ui-npm-package
|
||||
path: ${{ runner.temp }}/ui-npm-pack
|
||||
|
||||
- name: Verify publish tooling
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm_version=$(npm --version)
|
||||
echo "npm ${npm_version}"
|
||||
node -e 'const [major, minor, patch] = process.argv[1].split(".").map(Number); if (major < 11 || (major === 11 && (minor < 5 || (minor === 5 && patch < 1)))) { console.error("npm 11.5.1 or newer is required for trusted publishing"); process.exit(1); }' "$npm_version"
|
||||
|
||||
- name: Publish package
|
||||
shell: bash
|
||||
env:
|
||||
NPM_CONFIG_PROVENANCE: "true"
|
||||
NPM_TAG: ${{ inputs.npm_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
archive=$(find "$RUNNER_TEMP/ui-npm-pack" -maxdepth 1 -name '*.tgz' -print -quit)
|
||||
if [ -z "$archive" ]; then
|
||||
echo "UI package archive was not downloaded"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version=$(tar -xOf "$archive" package/package.json | node -e 'let input=""; process.stdin.on("data", chunk => input += chunk); process.stdin.on("end", () => process.stdout.write(JSON.parse(input).version))')
|
||||
if npm view "@cline/ui@${version}" version >/dev/null 2>&1; then
|
||||
echo "@cline/ui@${version} already exists; bump sdk/packages/ui/package.json before publishing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
npm publish "$archive" --tag "$NPM_TAG" --access public
|
||||
echo "Published @cline/ui@${version} with npm tag '${NPM_TAG}'"
|
||||
@@ -1,5 +1,41 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.46
|
||||
|
||||
- Fixed out-of-credits detection so the CLI reliably recognizes the Cline API's real `insufficient_credits` (402) error and shows the "add credits" card instead of a generic error
|
||||
|
||||
## 3.0.45
|
||||
|
||||
- Smaller install: the Claude Code and Codex providers are now optional and loaded on demand, cutting `npm i -g cline` from ~640MB to ~285MB (from SDK v0.0.65)
|
||||
- Kimi K3 is now available as a ClinePass model (from SDK v0.0.65)
|
||||
- Runs now retry once after refreshing expired OAuth credentials (from SDK v0.0.65)
|
||||
- Team runs: the spawn tool is no longer exposed to teammates, and errored teammate runs now report as failed instead of completed (from SDK v0.0.65)
|
||||
- Hub status output now includes version numbers
|
||||
- Updated the bundled model catalog (from SDK v0.0.65)
|
||||
|
||||
## 3.0.44
|
||||
|
||||
- Improved max output token handling across providers (gateway routing, OpenAI vendor, and reasoning models) (from SDK v0.0.64)
|
||||
- Frontmatter and configuration files that start with a UTF-8 byte order mark (e.g. saved by Windows editors) now parse correctly (from SDK v0.0.64)
|
||||
|
||||
## 3.0.43
|
||||
|
||||
- The CLI now automatically trusts your operating system's certificate store, so it works behind corporate proxies and TLS-inspecting firewalls without manually setting `NODE_EXTRA_CA_CERTS` (fixes "unable to get local issuer certificate" errors, including Windows intermediate CA stores)
|
||||
|
||||
## 3.0.42
|
||||
|
||||
- Fixed Ollama native API routing so context window and timeout settings work again
|
||||
|
||||
## 3.0.41
|
||||
|
||||
- Compaction now shows progress status in the TUI
|
||||
- Model IDs are now suggested from OpenAI-compatible endpoints when configuring a provider
|
||||
- Workspace git info (branch/remote) is now persisted and refreshed across sessions
|
||||
- Compaction no longer runs during an active turn
|
||||
- Fixed a crash when the terminal title was updated during TUI teardown
|
||||
- The API key fallback hint is now highlighted for better visibility
|
||||
- Benign git states are no longer reported as workspace initialization errors
|
||||
|
||||
## 3.0.40
|
||||
|
||||
- Added a manual API key escape hatch for Cline OAuth providers, so you can enter a key by hand from settings
|
||||
|
||||
+16
-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) |
|
||||
@@ -346,9 +346,24 @@ Desktop-integrated approval mode is also supported via env wiring (`CLINE_TOOL_A
|
||||
- `CLINE_LOG_LEVEL` - Runtime log level (`trace|debug|info|warn|error|fatal|silent`, default `info`)
|
||||
- `CLINE_LOG_PATH` - Runtime log file path (default `<CLINE_DATA_DIR>/logs/cline.log`)
|
||||
- `CLINE_LOG_NAME` - Logger name embedded in runtime log records
|
||||
- `CLINE_DEBUG` - Set to `1`/`true` to print wrapper diagnostics (e.g. the CA bundle summary)
|
||||
|
||||
`--key` takes precedence over environment variables.
|
||||
|
||||
## Certificate trust
|
||||
|
||||
The CLI automatically trusts your operating system's certificate store, so it
|
||||
works behind corporate TLS-inspecting proxies and with self-signed/internal
|
||||
endpoints without any setup. On launch the `cline` wrapper harvests the OS trust
|
||||
anchors and writes them to `~/.cline/cli-node-extra-ca-certs.pem`, then points
|
||||
the runtime's `NODE_EXTRA_CA_CERTS` at that bundle. The file is regenerated when
|
||||
it changes and is safe to delete (it is rebuilt on the next run).
|
||||
|
||||
If you set `NODE_EXTRA_CA_CERTS` yourself, your certificates are **merged** into
|
||||
that bundle alongside the system store rather than replacing it. Run with
|
||||
`CLINE_DEBUG=1` to see how many OS and user CAs were loaded and where the bundle
|
||||
was written.
|
||||
|
||||
## Contributing
|
||||
|
||||
See [DEVELOPMENT.md](./DEVELOPMENT.md) for local development setup, monorepo structure, and TUI architecture. See [DISTRIBUTION.md](./DISTRIBUTION.md) for how the CLI is packaged and distributed.
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
// Auto-discovery of OS trust anchors for the Cline CLI.
|
||||
//
|
||||
// Bun does not read the OS trust store, so the 3.x CLI cannot see corporate
|
||||
// MITM / self-signed CAs out of the box. This runs in the Node `bin/cline`
|
||||
// wrapper (not Bun), reads the full OS store via tls.getCACertificates("system")
|
||||
// (Node >= 22, no --use-system-ca flag), and hands the certs to the Bun child
|
||||
// via NODE_EXTRA_CA_CERTS, which both runtimes honor. Mirrors the JetBrains
|
||||
// plugin's configureCertificates(), sourcing from the OS instead of the IDE.
|
||||
//
|
||||
// Dependency-free CommonJS with injectable modules so it is unit-testable and
|
||||
// ships verbatim in the published wrapper package.
|
||||
|
||||
const PEM_MARKER = "-----BEGIN CERTIFICATE-----";
|
||||
const CERT_BLOCK =
|
||||
/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g;
|
||||
|
||||
/**
|
||||
* Returns only the complete certificate blocks from PEM text, or null when
|
||||
* there are none. User files may also hold private keys (combined cert+key
|
||||
* PEMs) or other sections, which must never be copied into the managed
|
||||
* bundle. Files that contain nothing but certificates pass through verbatim
|
||||
* so unchanged bundles keep hash-skipping the rewrite.
|
||||
*/
|
||||
function sanitizePem(text) {
|
||||
const blocks = text.match(CERT_BLOCK) ?? [];
|
||||
if (blocks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const rest = text.replace(CERT_BLOCK, "");
|
||||
if (/^\s*$/.test(rest)) {
|
||||
return text;
|
||||
}
|
||||
return `${blocks.join("\n")}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns OS-trusted certificates as PEM strings, or [] when unavailable.
|
||||
* tls.getCACertificates("system") requires Node >= 22.
|
||||
*/
|
||||
function harvestSystemCerts(tlsModule) {
|
||||
try {
|
||||
const tls = tlsModule || require("node:tls");
|
||||
if (typeof tls.getCACertificates !== "function") {
|
||||
return [];
|
||||
}
|
||||
const certs = tls.getCACertificates("system");
|
||||
if (!Array.isArray(certs)) {
|
||||
return [];
|
||||
}
|
||||
return certs.filter(
|
||||
(cert) => typeof cert === "string" && cert.includes(PEM_MARKER),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the file's certificate blocks as PEM text, or null when missing,
|
||||
* unreadable, or holding no complete certificate block.
|
||||
*/
|
||||
function readUserBundle(fsModule, userPath) {
|
||||
if (!userPath) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const fs = fsModule || require("node:fs");
|
||||
const stat = fs.statSync(userPath, { throwIfNoEntry: false });
|
||||
if (!stat || !stat.isFile()) {
|
||||
return null;
|
||||
}
|
||||
// Binary DER would not have loaded in the runtime either; require PEM.
|
||||
return sanitizePem(fs.readFileSync(userPath, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the user's NODE_EXTRA_CA_CERTS value into PEM strings. Node treats the
|
||||
* value as a single file, but some users set an OS-path-delimited list; the
|
||||
* whole value is tried as one file first, then split.
|
||||
* The managed bundle is excluded so reading it back never re-appends its certs.
|
||||
*/
|
||||
function readUserCerts(fsModule, pathModule, value, managedPath) {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
const fs = fsModule || require("node:fs");
|
||||
const path = pathModule || require("node:path");
|
||||
const candidates = [];
|
||||
const whole = readUserBundle(fs, value);
|
||||
if (whole) {
|
||||
candidates.push({ filePath: value, pem: whole });
|
||||
} else if (value.includes(path.delimiter)) {
|
||||
for (const segment of value.split(path.delimiter)) {
|
||||
const trimmed = segment.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
const pem = readUserBundle(fs, trimmed);
|
||||
if (pem) {
|
||||
candidates.push({ filePath: trimmed, pem });
|
||||
}
|
||||
}
|
||||
}
|
||||
const pems = [];
|
||||
for (const candidate of candidates) {
|
||||
const isManaged =
|
||||
managedPath &&
|
||||
path.resolve(candidate.filePath) === path.resolve(managedPath);
|
||||
if (!isManaged) {
|
||||
pems.push(candidate.pem);
|
||||
}
|
||||
}
|
||||
return pems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenates the user PEMs (if any) and the system certificates into one
|
||||
* bundle. A separating newline is inserted between parts so adjacent END/BEGIN
|
||||
* markers cannot fuse into one invalid line.
|
||||
*/
|
||||
function buildBundle({ systemCerts, userPems }) {
|
||||
const parts = [...(userPems ?? []), ...systemCerts];
|
||||
return parts
|
||||
.map((part) => (part.endsWith("\n") ? part : `${part}\n`))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** Counts individual PEM certificates across the given bundle strings. */
|
||||
function countCerts(pems) {
|
||||
let count = 0;
|
||||
for (const pem of pems) {
|
||||
count += pem.split(PEM_MARKER).length - 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function readFileIfExists(fs, filePath) {
|
||||
try {
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveClineDir(env, os, path) {
|
||||
return env.CLINE_DIR?.trim() || path.join(os.homedir(), ".cline");
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the api-unavailable warning should print. Stamped per Node version
|
||||
* in the cline dir so the nudge shows once rather than on every command; a
|
||||
* version change (upgrade that still falls short, or downgrade) re-arms it.
|
||||
* When the stamp cannot be read or written, warn — bookkeeping failures must
|
||||
* never suppress a real diagnostic.
|
||||
*/
|
||||
function shouldWarnApiUnavailable(env, deps = {}) {
|
||||
const fs = deps.fs || require("node:fs");
|
||||
const os = deps.os || require("node:os");
|
||||
const path = deps.path || require("node:path");
|
||||
const version = deps.nodeVersion || process.versions.node;
|
||||
const dir = resolveClineDir(env, os, path);
|
||||
const stamp = path.join(dir, `.ca-api-warned-${version}`);
|
||||
try {
|
||||
if (fs.existsSync(stamp)) {
|
||||
return false;
|
||||
}
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(stamp, "", { mode: 0o600 });
|
||||
return true;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Atomically writes [content] to [target]; returns true on success. */
|
||||
function writeBundle(fs, dir, target, content) {
|
||||
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
// Owner read/write: the bundle holds public CA material, not secrets,
|
||||
// but there is no reason to make it world-writable.
|
||||
fs.writeFileSync(tmp, content, { mode: 0o600 });
|
||||
try {
|
||||
fs.renameSync(tmp, target);
|
||||
} catch {
|
||||
// Windows can reject rename over a file a concurrent child holds open.
|
||||
fs.rmSync(target, { force: true });
|
||||
fs.renameSync(tmp, target);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
// Never leave a partial temp file behind (e.g. ENOSPC mid-write).
|
||||
try {
|
||||
fs.rmSync(tmp, { force: true });
|
||||
} catch {
|
||||
// Ignore: best-effort cleanup.
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Harvests OS trust anchors, merges them with any user NODE_EXTRA_CA_CERTS, and
|
||||
* points env.NODE_EXTRA_CA_CERTS at a single managed PEM bundle. Mutates `env`
|
||||
* in place. Returns an outcome the caller can log; `action` is one of
|
||||
* "unchanged" | "written" | "write-failed-reused" | "write-failed" |
|
||||
* "no-system-certs" | "api-unavailable".
|
||||
*/
|
||||
function configureNodeExtraCaCerts(env, deps = {}) {
|
||||
const fs = deps.fs || require("node:fs");
|
||||
const os = deps.os || require("node:os");
|
||||
const path = deps.path || require("node:path");
|
||||
const tls = deps.tls || require("node:tls");
|
||||
|
||||
// tls.getCACertificates("system") needs Node >= 22.15; on older Nodes the
|
||||
// harvest cannot run at all, which the caller should surface to the user.
|
||||
if (typeof tls.getCACertificates !== "function") {
|
||||
return {
|
||||
action: "api-unavailable",
|
||||
path: null,
|
||||
systemCertCount: 0,
|
||||
userCertCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const systemCerts = harvestSystemCerts(tls);
|
||||
if (systemCerts.length === 0) {
|
||||
// Nothing to add: leave any user-provided NODE_EXTRA_CA_CERTS untouched
|
||||
// and let the runtime fall back to its bundled CAs.
|
||||
return {
|
||||
action: "no-system-certs",
|
||||
path: null,
|
||||
systemCertCount: 0,
|
||||
userCertCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const managedDir = resolveClineDir(env, os, path);
|
||||
const managedPath = path.join(managedDir, "cli-node-extra-ca-certs.pem");
|
||||
const userValue = (env.NODE_EXTRA_CA_CERTS || "").trim() || null;
|
||||
const userPems = readUserCerts(fs, path, userValue, managedPath);
|
||||
const bundle = buildBundle({ systemCerts, userPems });
|
||||
const base = {
|
||||
path: managedPath,
|
||||
systemCertCount: systemCerts.length,
|
||||
userCertCount: countCerts(userPems),
|
||||
};
|
||||
|
||||
// Skip the rewrite when the bundle is already current. Avoids per-launch I/O
|
||||
// and the concurrent-rename race in the steady state.
|
||||
if (readFileIfExists(fs, managedPath) === bundle) {
|
||||
env.NODE_EXTRA_CA_CERTS = managedPath;
|
||||
return { ...base, action: "unchanged" };
|
||||
}
|
||||
|
||||
if (writeBundle(fs, managedDir, managedPath, bundle)) {
|
||||
env.NODE_EXTRA_CA_CERTS = managedPath;
|
||||
return { ...base, action: "written" };
|
||||
}
|
||||
|
||||
// Write failed: fall back to a previously-written bundle if one exists.
|
||||
if (readFileIfExists(fs, managedPath)) {
|
||||
env.NODE_EXTRA_CA_CERTS = managedPath;
|
||||
return { ...base, action: "write-failed-reused" };
|
||||
}
|
||||
return { ...base, path: null, action: "write-failed" };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
harvestSystemCerts,
|
||||
sanitizePem,
|
||||
readUserBundle,
|
||||
readUserCerts,
|
||||
buildBundle,
|
||||
countCerts,
|
||||
configureNodeExtraCaCerts,
|
||||
shouldWarnApiUnavailable,
|
||||
};
|
||||
@@ -23,6 +23,48 @@ const childEnv = {
|
||||
CLINE_WRAPPER_PATH: scriptPath,
|
||||
};
|
||||
|
||||
// Auto-discover OS trust anchors and pass them to the Bun child via
|
||||
// NODE_EXTRA_CA_CERTS. The Bun runtime does not read the OS store on its own,
|
||||
// so corporate/self-signed CAs would otherwise fail. This wrapper runs on
|
||||
// Node, which can read the full store here.
|
||||
try {
|
||||
const caCerts = require("./ca-certs.cjs");
|
||||
const outcome = caCerts.configureNodeExtraCaCerts(childEnv);
|
||||
const debug =
|
||||
process.env.CLINE_DEBUG === "1" || process.env.CLINE_DEBUG === "true";
|
||||
// Not debug-gated: on old Nodes the harvest silently doing nothing is
|
||||
// indistinguishable from a broken corporate proxy. Stamped per Node
|
||||
// version so the nudge shows once, not on every command.
|
||||
if (
|
||||
outcome &&
|
||||
outcome.action === "api-unavailable" &&
|
||||
!childEnv.NODE_EXTRA_CA_CERTS &&
|
||||
caCerts.shouldWarnApiUnavailable(childEnv)
|
||||
) {
|
||||
console.warn(
|
||||
`[cline] Node ${process.versions.node} cannot read the OS trust store (needs >= 22.15); ` +
|
||||
"corporate or self-signed CAs may fail TLS. Upgrade Node or set NODE_EXTRA_CA_CERTS.",
|
||||
);
|
||||
}
|
||||
if (debug && outcome) {
|
||||
if (outcome.action === "no-system-certs") {
|
||||
console.warn(
|
||||
"[cline] No OS trust anchors found; relying on the runtime's bundled CAs.",
|
||||
);
|
||||
} else if (outcome.action === "write-failed") {
|
||||
console.warn(
|
||||
"[cline] Could not write the managed CA bundle; relying on the runtime's bundled CAs.",
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
`[cline] Trust: ${outcome.systemCertCount} OS + ${outcome.userCertCount} user CAs (${outcome.action}) -> ${outcome.path}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best effort: fall back to the runtime's default trust on any failure.
|
||||
}
|
||||
|
||||
function run(target) {
|
||||
const result = childProcess.spawnSync(target, process.argv.slice(2), {
|
||||
stdio: "inherit",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.40",
|
||||
"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"
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { delimiter, join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
// The helper ships as CommonJS in the published wrapper package, so it is
|
||||
// loaded via require rather than an ESM import.
|
||||
const caCerts = require("../../bin/ca-certs.cjs") as {
|
||||
harvestSystemCerts: (tls?: unknown) => string[];
|
||||
readUserBundle: (fs: unknown, p: string | null) => string | null;
|
||||
readUserCerts: (
|
||||
fs: unknown,
|
||||
path: unknown,
|
||||
value: string | null,
|
||||
managedPath: string | null,
|
||||
) => string[];
|
||||
buildBundle: (input: {
|
||||
systemCerts: string[];
|
||||
userPems?: string[];
|
||||
}) => string;
|
||||
countCerts: (pems: string[]) => number;
|
||||
configureNodeExtraCaCerts: (
|
||||
env: Record<string, string>,
|
||||
deps?: { tls?: unknown; fs?: unknown },
|
||||
) => {
|
||||
action: string;
|
||||
path: string | null;
|
||||
systemCertCount: number;
|
||||
userCertCount: number;
|
||||
};
|
||||
shouldWarnApiUnavailable: (
|
||||
env: Record<string, string>,
|
||||
deps?: { fs?: unknown; nodeVersion?: string },
|
||||
) => boolean;
|
||||
};
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const certSystem =
|
||||
"-----BEGIN CERTIFICATE-----\nSYSTEM\n-----END CERTIFICATE-----\n";
|
||||
const certUser = "-----BEGIN CERTIFICATE-----\nUSER\n-----END CERTIFICATE-----";
|
||||
|
||||
function fakeTls(certs: unknown) {
|
||||
return { getCACertificates: () => certs };
|
||||
}
|
||||
|
||||
describe("ca-certs", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "cline-ca-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("harvestSystemCerts", () => {
|
||||
it("returns only PEM strings from the system store", () => {
|
||||
expect(
|
||||
caCerts.harvestSystemCerts(fakeTls([certSystem, "not-a-cert", 42])),
|
||||
).toEqual([certSystem]);
|
||||
});
|
||||
|
||||
it("returns [] when getCACertificates is unavailable", () => {
|
||||
expect(caCerts.harvestSystemCerts({})).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] when getCACertificates throws", () => {
|
||||
expect(
|
||||
caCerts.harvestSystemCerts({
|
||||
getCACertificates: () => {
|
||||
throw new Error("nope");
|
||||
},
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readUserBundle", () => {
|
||||
it("returns PEM contents for a PEM file", () => {
|
||||
const p = join(dir, "user.pem");
|
||||
writeFileSync(p, certUser);
|
||||
expect(caCerts.readUserBundle(fs, p)).toBe(certUser);
|
||||
});
|
||||
|
||||
it("returns null for a non-PEM (DER) file", () => {
|
||||
const p = join(dir, "user.der");
|
||||
writeFileSync(p, Buffer.from([0x30, 0x82, 0x01, 0x02]));
|
||||
expect(caCerts.readUserBundle(fs, p)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a missing file and for null path", () => {
|
||||
expect(caCerts.readUserBundle(fs, join(dir, "nope.pem"))).toBeNull();
|
||||
expect(caCerts.readUserBundle(fs, null)).toBeNull();
|
||||
});
|
||||
|
||||
it("strips non-certificate sections such as private keys", () => {
|
||||
// Combined cert+key files (nginx/haproxy style) are common; the key
|
||||
// must never reach the managed bundle.
|
||||
const p = join(dir, "combined.pem");
|
||||
writeFileSync(
|
||||
p,
|
||||
`${certUser}\n-----BEGIN PRIVATE KEY-----\nSECRET\n-----END PRIVATE KEY-----\n`,
|
||||
);
|
||||
const out = caCerts.readUserBundle(fs, p);
|
||||
expect(out).toContain("USER");
|
||||
expect(out).not.toContain("PRIVATE KEY");
|
||||
expect(out).not.toContain("SECRET");
|
||||
});
|
||||
|
||||
it("keeps certificates-only files verbatim", () => {
|
||||
// Byte-identical passthrough keeps the unchanged-skip hash stable.
|
||||
const p = join(dir, "clean.pem");
|
||||
writeFileSync(p, `${certUser}\n${certSystem}`);
|
||||
expect(caCerts.readUserBundle(fs, p)).toBe(`${certUser}\n${certSystem}`);
|
||||
});
|
||||
|
||||
it("returns null for a BEGIN marker without a complete block", () => {
|
||||
const p = join(dir, "truncated.pem");
|
||||
writeFileSync(p, "-----BEGIN CERTIFICATE-----\ntruncated");
|
||||
expect(caCerts.readUserBundle(fs, p)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("readUserCerts", () => {
|
||||
it("reads a single PEM file path", () => {
|
||||
const p = join(dir, "corp.pem");
|
||||
writeFileSync(p, certUser);
|
||||
expect(caCerts.readUserCerts(fs, path, p, null)).toEqual([certUser]);
|
||||
});
|
||||
|
||||
it("splits a legacy OS-path-delimited value and reads each PEM", () => {
|
||||
// Legacy footgun: NODE_EXTRA_CA_CERTS="a.pem;b.pem".
|
||||
const a = join(dir, "a.pem");
|
||||
const b = join(dir, "b.pem");
|
||||
writeFileSync(a, certUser);
|
||||
writeFileSync(b, certSystem);
|
||||
expect(
|
||||
caCerts.readUserCerts(fs, path, [a, b].join(delimiter), null),
|
||||
).toEqual([certUser, certSystem]);
|
||||
});
|
||||
|
||||
it("skips missing segments in a delimited value", () => {
|
||||
const a = join(dir, "a.pem");
|
||||
writeFileSync(a, certUser);
|
||||
const value = [a, join(dir, "missing.pem")].join(delimiter);
|
||||
expect(caCerts.readUserCerts(fs, path, value, null)).toEqual([certUser]);
|
||||
});
|
||||
|
||||
it("excludes the managed bundle from user certs", () => {
|
||||
const managed = join(dir, "cli-node-extra-ca-certs.pem");
|
||||
writeFileSync(managed, certUser);
|
||||
expect(caCerts.readUserCerts(fs, path, managed, managed)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] for empty value", () => {
|
||||
expect(caCerts.readUserCerts(fs, path, null, null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildBundle", () => {
|
||||
it("merges user PEMs before system certs", () => {
|
||||
expect(
|
||||
caCerts.buildBundle({
|
||||
systemCerts: [certSystem],
|
||||
userPems: [certUser],
|
||||
}),
|
||||
).toBe(`${certUser}\n${certSystem}`);
|
||||
});
|
||||
|
||||
it("inserts a separating newline so END/BEGIN markers do not fuse", () => {
|
||||
// certUser has no trailing newline, so this proves the boundary fix.
|
||||
const merged = caCerts.buildBundle({
|
||||
systemCerts: [certSystem],
|
||||
userPems: [certUser],
|
||||
});
|
||||
expect(merged).not.toContain(
|
||||
"-----END CERTIFICATE----------BEGIN CERTIFICATE-----",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles no user PEMs", () => {
|
||||
expect(caCerts.buildBundle({ systemCerts: [certSystem] })).toBe(
|
||||
certSystem,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("configureNodeExtraCaCerts", () => {
|
||||
it("writes a managed bundle and points the env var at it", () => {
|
||||
const env: Record<string, string> = { CLINE_DIR: dir };
|
||||
const out = caCerts.configureNodeExtraCaCerts(env, {
|
||||
tls: fakeTls([certSystem]),
|
||||
});
|
||||
expect(out.action).toBe("written");
|
||||
expect(out.path).toBe(join(dir, "cli-node-extra-ca-certs.pem"));
|
||||
expect(env.NODE_EXTRA_CA_CERTS).toBe(out.path);
|
||||
expect(readFileSync(out.path as string, "utf8")).toContain("SYSTEM");
|
||||
});
|
||||
|
||||
it("merges a user-supplied NODE_EXTRA_CA_CERTS with system certs", () => {
|
||||
const userPath = join(dir, "corp.pem");
|
||||
writeFileSync(userPath, certUser);
|
||||
const env: Record<string, string> = {
|
||||
CLINE_DIR: dir,
|
||||
NODE_EXTRA_CA_CERTS: userPath,
|
||||
};
|
||||
const out = caCerts.configureNodeExtraCaCerts(env, {
|
||||
tls: fakeTls([certSystem]),
|
||||
});
|
||||
expect(out.userCertCount).toBe(1);
|
||||
const written = readFileSync(env.NODE_EXTRA_CA_CERTS, "utf8");
|
||||
expect(written).toContain("USER");
|
||||
expect(written).toContain("SYSTEM");
|
||||
});
|
||||
|
||||
it("reports unchanged and skips rewrite on the second run", () => {
|
||||
const env: Record<string, string> = { CLINE_DIR: dir };
|
||||
expect(
|
||||
caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([certSystem]) })
|
||||
.action,
|
||||
).toBe("written");
|
||||
expect(
|
||||
caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([certSystem]) })
|
||||
.action,
|
||||
).toBe("unchanged");
|
||||
});
|
||||
|
||||
it("does not re-append when the user already points at the managed bundle", () => {
|
||||
const env: Record<string, string> = { CLINE_DIR: dir };
|
||||
const first = caCerts.configureNodeExtraCaCerts(env, {
|
||||
tls: fakeTls([certSystem]),
|
||||
}).path as string;
|
||||
const env2: Record<string, string> = {
|
||||
CLINE_DIR: dir,
|
||||
NODE_EXTRA_CA_CERTS: first,
|
||||
};
|
||||
caCerts.configureNodeExtraCaCerts(env2, { tls: fakeTls([certSystem]) });
|
||||
const written = readFileSync(env2.NODE_EXTRA_CA_CERTS, "utf8");
|
||||
expect(written.match(/SYSTEM/g)?.length).toBe(1);
|
||||
});
|
||||
|
||||
it("no-ops when no system certs are available", () => {
|
||||
const env: Record<string, string> = {
|
||||
CLINE_DIR: dir,
|
||||
NODE_EXTRA_CA_CERTS: "/user/corp.pem",
|
||||
};
|
||||
const out = caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([]) });
|
||||
expect(out.action).toBe("no-system-certs");
|
||||
expect(out.path).toBeNull();
|
||||
expect(env.NODE_EXTRA_CA_CERTS).toBe("/user/corp.pem");
|
||||
});
|
||||
|
||||
it("reports api-unavailable on Nodes without getCACertificates", () => {
|
||||
const env: Record<string, string> = {
|
||||
CLINE_DIR: dir,
|
||||
NODE_EXTRA_CA_CERTS: "/user/corp.pem",
|
||||
};
|
||||
const out = caCerts.configureNodeExtraCaCerts(env, { tls: {} });
|
||||
expect(out.action).toBe("api-unavailable");
|
||||
expect(out.path).toBeNull();
|
||||
expect(env.NODE_EXTRA_CA_CERTS).toBe("/user/corp.pem");
|
||||
});
|
||||
|
||||
it("reports write-failed when the bundle cannot be written", () => {
|
||||
const realFs = require("node:fs");
|
||||
const failingFs = {
|
||||
...realFs,
|
||||
mkdirSync: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
writeFileSync: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
};
|
||||
const env: Record<string, string> = { CLINE_DIR: dir };
|
||||
const out = caCerts.configureNodeExtraCaCerts(env, {
|
||||
tls: fakeTls([certSystem]),
|
||||
fs: failingFs,
|
||||
});
|
||||
expect(out.action).toBe("write-failed");
|
||||
expect(out.path).toBeNull();
|
||||
expect(env.NODE_EXTRA_CA_CERTS).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reuses a stale bundle when the rewrite fails", () => {
|
||||
// First run writes the bundle normally.
|
||||
const env: Record<string, string> = { CLINE_DIR: dir };
|
||||
const managedPath = caCerts.configureNodeExtraCaCerts(env, {
|
||||
tls: fakeTls([certSystem]),
|
||||
}).path as string;
|
||||
|
||||
// Second run: writes fail, but the stale bundle is still readable.
|
||||
const realFs = require("node:fs");
|
||||
const failingFs = {
|
||||
...realFs,
|
||||
mkdirSync: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
writeFileSync: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
};
|
||||
const env2: Record<string, string> = { CLINE_DIR: dir };
|
||||
const out = caCerts.configureNodeExtraCaCerts(env2, {
|
||||
// A different system cert forces a rewrite attempt (not "unchanged").
|
||||
tls: fakeTls([certUser]),
|
||||
fs: failingFs,
|
||||
});
|
||||
|
||||
expect(out.action).toBe("write-failed-reused");
|
||||
expect(env2.NODE_EXTRA_CA_CERTS).toBe(managedPath);
|
||||
});
|
||||
});
|
||||
|
||||
describe("countCerts", () => {
|
||||
it("counts individual certificates, not files", () => {
|
||||
// One file holding two certs must report 2, not 1.
|
||||
const twoInOne = `${certUser}\n${certSystem}`;
|
||||
expect(caCerts.countCerts([twoInOne])).toBe(2);
|
||||
expect(caCerts.countCerts([certUser, certSystem])).toBe(2);
|
||||
expect(caCerts.countCerts([])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldWarnApiUnavailable", () => {
|
||||
it("warns once per Node version, then stays quiet", () => {
|
||||
const env = { CLINE_DIR: dir };
|
||||
const deps = { nodeVersion: "22.1.0" };
|
||||
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
|
||||
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(false);
|
||||
});
|
||||
|
||||
it("re-arms when the Node version changes", () => {
|
||||
const env = { CLINE_DIR: dir };
|
||||
expect(
|
||||
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.1.0" }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.14.0" }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.1.0" }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("still warns when the stamp cannot be written", () => {
|
||||
const realFs = require("node:fs");
|
||||
const failingFs = {
|
||||
...realFs,
|
||||
mkdirSync: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
writeFileSync: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
};
|
||||
const env = { CLINE_DIR: dir };
|
||||
const deps = { fs: failingFs, nodeVersion: "22.1.0" };
|
||||
// Bookkeeping failure must never suppress the diagnostic.
|
||||
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
|
||||
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, extname, join } from "node:path";
|
||||
import {
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type SkillConfig,
|
||||
type WorkflowConfig,
|
||||
} from "@cline/core";
|
||||
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
|
||||
import { Command } from "commander";
|
||||
import { getToolCatalog } from "../runtime/tools";
|
||||
import { loadInteractiveConfigData } from "../tui/interactive-config";
|
||||
@@ -209,7 +210,7 @@ async function runAgentsConfigCommand(
|
||||
continue;
|
||||
}
|
||||
const filePath = join(directory, entry.name);
|
||||
const raw = readFileSync(filePath, "utf8");
|
||||
const raw = readFileSyncStrippingUtf8Bom(filePath);
|
||||
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
const frontmatter = frontmatterMatch?.[1] ?? "";
|
||||
const nameMatch = frontmatter.match(/^\s*name:\s*(.+?)\s*$/m);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { version as cliVersion } from "../../package.json";
|
||||
import { getCliBuildInfo } from "../utils/common";
|
||||
|
||||
const {
|
||||
@@ -174,6 +175,40 @@ describe("runDoctorCommand", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("reports CLI and running hub Core versions", async () => {
|
||||
const cwd = "/workspace";
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
port: 25463,
|
||||
pid: 50174,
|
||||
coreVersion: "0.0.63",
|
||||
});
|
||||
mockProbeHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
port: 25463,
|
||||
pid: 50174,
|
||||
coreVersion: "0.0.64",
|
||||
});
|
||||
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
|
||||
|
||||
const output: string[] = [];
|
||||
const code = await runDoctorCommand(
|
||||
{ cwd, json: true },
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(JSON.parse(output[0] || "")).toMatchObject({
|
||||
cliVersion,
|
||||
coreVersion: "0.0.64",
|
||||
});
|
||||
});
|
||||
|
||||
it("doctor --fix clears wedged hub startup artifacts when no server is actually running", async () => {
|
||||
const cwd = mkdtempSync(path.join(os.tmpdir(), "doctor-hub-fix-"));
|
||||
tempDirs.push(cwd);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import open from "open";
|
||||
import { version as cliVersion } from "../../package.json";
|
||||
import { isProcessRunning } from "../connectors/common";
|
||||
import {
|
||||
type ActiveConnectorRecord,
|
||||
@@ -49,6 +50,8 @@ type SpawnedProcessRecord = {
|
||||
|
||||
type DoctorStatus = {
|
||||
cwd: string;
|
||||
cliVersion: string;
|
||||
coreVersion?: string;
|
||||
hubUrl?: string;
|
||||
hubHealthy: boolean;
|
||||
hubPid?: number;
|
||||
@@ -337,6 +340,8 @@ async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
|
||||
];
|
||||
return {
|
||||
cwd,
|
||||
cliVersion,
|
||||
coreVersion: health?.coreVersion ?? discovery?.coreVersion,
|
||||
hubUrl: current?.url,
|
||||
hubHealthy: !!health?.url,
|
||||
hubPid: current?.pid,
|
||||
@@ -419,6 +424,8 @@ export async function runDoctorCommand(
|
||||
io.writeln(JSON.stringify(before));
|
||||
return 0;
|
||||
}
|
||||
writeln(`cli version ${c.dim}${before.cliVersion}${c.reset}`);
|
||||
writeln(`core version ${c.dim}${before.coreVersion ?? "n/a"}${c.reset}`);
|
||||
writeln(`hub url ${c.dim}${before.hubUrl ?? "none"}${c.reset}`);
|
||||
writeln(
|
||||
`hub healthy ${c.dim}${before.hubHealthy ? "yes" : "no"}${before.hubPid ? ` (pid=${before.hubPid})` : ""}${c.reset}`,
|
||||
|
||||
@@ -34,6 +34,7 @@ vi.mock("@cline/core", () => ({
|
||||
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
|
||||
}));
|
||||
|
||||
import { version as cliVersion } from "../../package.json";
|
||||
import { createHubCommand } from "./hub";
|
||||
|
||||
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
|
||||
@@ -63,6 +64,7 @@ describe("createHubCommand", () => {
|
||||
port: 25463,
|
||||
pid: 50174,
|
||||
startedAt: "2026-01-01T00:00:00.000Z",
|
||||
coreVersion: "0.0.62",
|
||||
});
|
||||
|
||||
const output: string[] = [];
|
||||
@@ -88,6 +90,8 @@ describe("createHubCommand", () => {
|
||||
pid: 50174,
|
||||
startedAt: "2026-01-01T00:00:00.000Z",
|
||||
uptime: "1m 5s",
|
||||
cliVersion,
|
||||
coreVersion: "0.0.62",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "@cline/core";
|
||||
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import { version as cliVersion } from "../../package.json";
|
||||
|
||||
interface HubCommandIo {
|
||||
writeln: (text?: string) => void;
|
||||
@@ -134,6 +135,8 @@ export function createHubCommand(
|
||||
pid: health?.pid,
|
||||
startedAt: health?.startedAt,
|
||||
uptime,
|
||||
cliVersion,
|
||||
coreVersion: health?.coreVersion ?? discovery?.coreVersion,
|
||||
}),
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
type UserInstructionConfigService,
|
||||
type WorkflowConfig,
|
||||
} from "@cline/core";
|
||||
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
|
||||
import { getToolCatalog } from "../runtime/tools";
|
||||
import {
|
||||
type InteractiveSlashCommand,
|
||||
@@ -195,7 +196,7 @@ function loadAgentConfigItems(workspaceRoot: string): InteractiveConfigItem[] {
|
||||
continue;
|
||||
}
|
||||
const filePath = join(directory, entry.name);
|
||||
const raw = readFileSync(filePath, "utf8");
|
||||
const raw = readFileSyncStrippingUtf8Bom(filePath);
|
||||
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
const frontmatter = frontmatterMatch?.[1] ?? "";
|
||||
const nameMatch = frontmatter.match(/^\s*name:\s*(.+?)\s*$/m);
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
resolvePluginConfigSearchPaths,
|
||||
resolveAgentConfigSearchPaths as resolveSharedAgentConfigSearchPaths,
|
||||
} from "@cline/core";
|
||||
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
|
||||
import { readMcpServersResponse } from "./mcp";
|
||||
import type { JsonRecord } from "./types";
|
||||
|
||||
@@ -114,7 +115,7 @@ export async function listUserInstructionConfigs(
|
||||
const ext = extname(entry.name).toLowerCase();
|
||||
if (ext !== ".yml" && ext !== ".yaml") continue;
|
||||
const filePath = join(directory, entry.name);
|
||||
const raw = readFileSync(filePath, "utf8");
|
||||
const raw = readFileSyncStrippingUtf8Bom(filePath);
|
||||
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
const fm = fmMatch?.[1] ?? "";
|
||||
const nameMatch = fm.match(/^\s*name:\s*(.+?)\s*$/m);
|
||||
|
||||
@@ -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,11 +1,14 @@
|
||||
{
|
||||
"name": "@cline/code",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:ui": "bun -F @cline/ui build",
|
||||
"predev:web": "bun run build:ui",
|
||||
"dev:web": "next dev webview -p 3125 --turbo",
|
||||
"dev:sidecar": "bun run sidecar/index.ts",
|
||||
"dev": "tauri dev",
|
||||
"prebuild": "bun run build:ui",
|
||||
"build": "bun run bun.mts",
|
||||
"build:sidecar": "mkdir -p dist/sidecar && bun build ./sidecar/index.ts --outfile ./dist/sidecar/index.js --target bun",
|
||||
"build:sidecar:bin": "bun run scripts/build-sidecar-bin.ts",
|
||||
@@ -16,7 +19,10 @@
|
||||
"package:desktop:windows": "bun run scripts/package-desktop.ts --platform windows",
|
||||
"package:desktop:linux": "bun run scripts/package-desktop.ts --platform linux",
|
||||
"start": "next start webview",
|
||||
"pretypecheck": "bun run build:ui",
|
||||
"typecheck": "tsc -p tsconfig.dev.json --noEmit",
|
||||
"pretest:chat-ui": "bun run build:ui",
|
||||
"test:chat-ui": "vitest run webview/components/views/chat/chat-messages.test.tsx --config vitest.config.ts",
|
||||
"clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -70,6 +76,7 @@
|
||||
"lucide-react": "^0.564.0",
|
||||
"next": "16.2.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"pino": "^10.3.1",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.4",
|
||||
"react-day-picker": "9.13.2",
|
||||
|
||||
@@ -17,13 +17,33 @@ const resolveTargetTriple = async (): Promise<string> => {
|
||||
return host;
|
||||
};
|
||||
|
||||
// Bun cross-compiles --compile binaries, so a CI runner can produce the
|
||||
// sidecar for a different architecture than its own (e.g. the x86_64 macOS
|
||||
// bundle from an arm64 runner). Without an explicit --target, bun always
|
||||
// emits a host-arch binary even when Tauri is building for another triple.
|
||||
const resolveBunCompileTarget = (targetTriple: string): string | undefined => {
|
||||
if (targetTriple.startsWith("aarch64-apple-darwin"))
|
||||
return "bun-darwin-arm64";
|
||||
if (targetTriple.startsWith("x86_64-apple-darwin")) return "bun-darwin-x64";
|
||||
if (targetTriple.startsWith("x86_64-pc-windows")) return "bun-windows-x64";
|
||||
if (targetTriple.startsWith("x86_64-unknown-linux")) return "bun-linux-x64";
|
||||
if (targetTriple.startsWith("aarch64-unknown-linux"))
|
||||
return "bun-linux-arm64";
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
const targetTriple = await resolveTargetTriple();
|
||||
const extension = targetTriple.includes("windows") ? ".exe" : "";
|
||||
const outfile = `./src-tauri/bin/code-sidecar-${targetTriple}${extension}`;
|
||||
const bunTarget = resolveBunCompileTarget(targetTriple);
|
||||
|
||||
await $`mkdir -p src-tauri/bin`;
|
||||
await $`bun build ./sidecar/index.ts --compile --outfile ${outfile}`;
|
||||
if (bunTarget) {
|
||||
await $`bun build ./sidecar/index.ts --compile --target=${bunTarget} --outfile ${outfile}`;
|
||||
} else {
|
||||
await $`bun build ./sidecar/index.ts --compile --outfile ${outfile}`;
|
||||
}
|
||||
};
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { buildUpdateManifest } from "./generate-update-manifest";
|
||||
|
||||
const makeArtifactDir = (): string => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "update-manifest-"));
|
||||
writeFileSync(path.join(dir, "Cline-Code_0.1.0_aarch64.app.tar.gz"), "tar");
|
||||
writeFileSync(
|
||||
path.join(dir, "Cline-Code_0.1.0_aarch64.app.tar.gz.sig"),
|
||||
"sig-aarch64\n",
|
||||
);
|
||||
writeFileSync(path.join(dir, "Cline-Code_0.1.0_x86_64.app.tar.gz"), "tar");
|
||||
writeFileSync(
|
||||
path.join(dir, "Cline-Code_0.1.0_x86_64.app.tar.gz.sig"),
|
||||
"sig-x86_64\n",
|
||||
);
|
||||
writeFileSync(path.join(dir, "Cline-Code_0.1.0_aarch64.dmg"), "dmg");
|
||||
return dir;
|
||||
};
|
||||
|
||||
describe("buildUpdateManifest", () => {
|
||||
test("maps updater artifacts to darwin platform entries", () => {
|
||||
const dir = makeArtifactDir();
|
||||
const manifest = buildUpdateManifest({
|
||||
version: "0.1.0",
|
||||
tag: "desktop-v0.1.0",
|
||||
dir,
|
||||
repo: "cline/cline",
|
||||
notes: "notes",
|
||||
pubDate: "2026-07-21T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(manifest.version).toBe("0.1.0");
|
||||
expect(manifest.platforms["darwin-aarch64"]).toEqual({
|
||||
signature: "sig-aarch64",
|
||||
url: "https://github.com/cline/cline/releases/download/desktop-v0.1.0/Cline-Code_0.1.0_aarch64.app.tar.gz",
|
||||
});
|
||||
expect(manifest.platforms["darwin-x86_64"]).toEqual({
|
||||
signature: "sig-x86_64",
|
||||
url: "https://github.com/cline/cline/releases/download/desktop-v0.1.0/Cline-Code_0.1.0_x86_64.app.tar.gz",
|
||||
});
|
||||
// The DMG is a first-install artifact, not an updater artifact.
|
||||
expect(Object.keys(manifest.platforms)).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("throws when a signature file is missing", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "update-manifest-"));
|
||||
writeFileSync(path.join(dir, "Cline-Code_0.1.0_aarch64.app.tar.gz"), "tar");
|
||||
expect(() =>
|
||||
buildUpdateManifest({
|
||||
version: "0.1.0",
|
||||
tag: "desktop-v0.1.0",
|
||||
dir,
|
||||
repo: "cline/cline",
|
||||
notes: "notes",
|
||||
pubDate: "2026-07-21T00:00:00.000Z",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("throws when no updater artifacts exist", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "update-manifest-"));
|
||||
writeFileSync(path.join(dir, "Cline-Code_0.1.0_aarch64.dmg"), "dmg");
|
||||
expect(() =>
|
||||
buildUpdateManifest({
|
||||
version: "0.1.0",
|
||||
tag: "desktop-v0.1.0",
|
||||
dir,
|
||||
repo: "cline/cline",
|
||||
notes: "notes",
|
||||
pubDate: "2026-07-21T00:00:00.000Z",
|
||||
}),
|
||||
).toThrow(/no updater artifacts/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
// Generates the Tauri updater manifest (latest.json) from the updater
|
||||
// artifacts produced by the desktop-publish workflow. The manifest is uploaded
|
||||
// to the rolling `desktop-latest` GitHub release, which is the static endpoint
|
||||
// configured in src-tauri/tauri.conf.json; its platform URLs point back at the
|
||||
// immutable per-version release assets.
|
||||
//
|
||||
// Usage:
|
||||
// bun scripts/generate-update-manifest.ts \
|
||||
// --version 0.1.0 --tag desktop-v0.1.0 --dir dist/desktop \
|
||||
// --out dist/desktop/latest.json [--repo cline/cline] [--notes-file notes.md]
|
||||
|
||||
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
type UpdaterPlatformEntry = {
|
||||
signature: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type UpdateManifest = {
|
||||
version: string;
|
||||
notes: string;
|
||||
pub_date: string;
|
||||
platforms: Record<string, UpdaterPlatformEntry>;
|
||||
};
|
||||
|
||||
// Maps the arch token embedded in artifact file names (see the "Collect
|
||||
// artifacts" workflow step) to the platform keys the Tauri updater requests.
|
||||
const PLATFORM_KEY_BY_ARCH_SUFFIX: Record<string, string> = {
|
||||
aarch64: "darwin-aarch64",
|
||||
x86_64: "darwin-x86_64",
|
||||
};
|
||||
|
||||
const getArgValue = (args: string[], name: string): string | undefined => {
|
||||
const index = args.indexOf(name);
|
||||
if (index >= 0 && args[index + 1] && !args[index + 1].startsWith("--")) {
|
||||
return args[index + 1];
|
||||
}
|
||||
const prefix = `${name}=`;
|
||||
const inline = args.find((arg) => arg.startsWith(prefix));
|
||||
return inline?.slice(prefix.length);
|
||||
};
|
||||
|
||||
const archOfUpdaterArtifact = (fileName: string): string | undefined => {
|
||||
if (!fileName.endsWith(".app.tar.gz")) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.keys(PLATFORM_KEY_BY_ARCH_SUFFIX).find((arch) =>
|
||||
fileName.includes(`_${arch}`),
|
||||
);
|
||||
};
|
||||
|
||||
export const buildUpdateManifest = (options: {
|
||||
version: string;
|
||||
tag: string;
|
||||
dir: string;
|
||||
repo: string;
|
||||
notes: string;
|
||||
pubDate: string;
|
||||
}): UpdateManifest => {
|
||||
const platforms: Record<string, UpdaterPlatformEntry> = {};
|
||||
|
||||
for (const fileName of readdirSync(options.dir).sort()) {
|
||||
const arch = archOfUpdaterArtifact(fileName);
|
||||
if (!arch) {
|
||||
continue;
|
||||
}
|
||||
const signaturePath = path.join(options.dir, `${fileName}.sig`);
|
||||
const signature = readFileSync(signaturePath, "utf8").trim();
|
||||
if (!signature) {
|
||||
throw new Error(`empty updater signature at ${signaturePath}`);
|
||||
}
|
||||
platforms[PLATFORM_KEY_BY_ARCH_SUFFIX[arch]] = {
|
||||
signature,
|
||||
url: `https://github.com/${options.repo}/releases/download/${options.tag}/${encodeURIComponent(fileName)}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (Object.keys(platforms).length === 0) {
|
||||
throw new Error(
|
||||
`no updater artifacts (*.app.tar.gz with a known arch suffix) found in ${options.dir}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
version: options.version,
|
||||
notes: options.notes,
|
||||
pub_date: options.pubDate,
|
||||
platforms,
|
||||
};
|
||||
};
|
||||
|
||||
const main = () => {
|
||||
const args = process.argv.slice(2);
|
||||
const version = getArgValue(args, "--version");
|
||||
const tag = getArgValue(args, "--tag");
|
||||
const dir = getArgValue(args, "--dir");
|
||||
const out = getArgValue(args, "--out");
|
||||
const repo =
|
||||
getArgValue(args, "--repo") ??
|
||||
process.env.GITHUB_REPOSITORY ??
|
||||
"cline/cline";
|
||||
const notesFile = getArgValue(args, "--notes-file");
|
||||
|
||||
if (!version || !tag || !dir || !out) {
|
||||
throw new Error(
|
||||
"usage: generate-update-manifest.ts --version X.Y.Z --tag desktop-vX.Y.Z --dir <artifact dir> --out <latest.json> [--repo owner/repo] [--notes-file <file>]",
|
||||
);
|
||||
}
|
||||
|
||||
const notes = notesFile
|
||||
? readFileSync(notesFile, "utf8").trim()
|
||||
: `Cline Code v${version}`;
|
||||
|
||||
const manifest = buildUpdateManifest({
|
||||
version,
|
||||
tag,
|
||||
dir,
|
||||
repo,
|
||||
notes,
|
||||
pubDate: new Date().toISOString(),
|
||||
});
|
||||
|
||||
writeFileSync(out, `${JSON.stringify(manifest, null, "\t")}\n`);
|
||||
console.log(`wrote ${out}`);
|
||||
for (const [platform, entry] of Object.entries(manifest.platforms)) {
|
||||
console.log(`- ${platform}: ${entry.url}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (import.meta.main) {
|
||||
main();
|
||||
}
|
||||
@@ -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": [
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "@cline/ui/theme/index.css";
|
||||
@import "@cline/ui/components/agent-chat.css";
|
||||
|
||||
@source "../../node_modules/streamdown/dist";
|
||||
|
||||
@@ -19,6 +20,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% {
|
||||
@@ -159,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...");
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,182 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SessionFileDiff } from "@/lib/session-diff";
|
||||
import { DiffView } from "./diff-view";
|
||||
|
||||
const { invokeMock } = vi.hoisted(() => ({
|
||||
invokeMock: vi.fn(async (command: string) =>
|
||||
command === "list_available_editors"
|
||||
? [{ id: "vscode", label: "VS Code" }]
|
||||
: { path: "/repo/docs/a.mdx", editor: "VS Code" },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/desktop-client", () => ({
|
||||
desktopClient: { invoke: invokeMock },
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let writeText: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
// jsdom lacks the layout/pointer APIs the Radix dropdown menu touches.
|
||||
if (!("ResizeObserver" in globalThis)) {
|
||||
Object.assign(globalThis, {
|
||||
ResizeObserver: class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
});
|
||||
}
|
||||
Element.prototype.scrollIntoView ??= () => {};
|
||||
Element.prototype.hasPointerCapture ??= () => false;
|
||||
Element.prototype.setPointerCapture ??= () => {};
|
||||
Element.prototype.releasePointerCapture ??= () => {};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
invokeMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function click(element: Element): Promise<void> {
|
||||
await act(async () => {
|
||||
element.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true, cancelable: true }),
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
// Radix dropdown triggers open on pointerdown, not click.
|
||||
async function pointerDown(element: Element): Promise<void> {
|
||||
await act(async () => {
|
||||
element.dispatchEvent(
|
||||
new MouseEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
}),
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function buttonWithLabel(label: string): HTMLButtonElement {
|
||||
const button = container.querySelector<HTMLButtonElement>(
|
||||
`button[aria-label="${label}"]`,
|
||||
);
|
||||
expect(button).not.toBeNull();
|
||||
return button as HTMLButtonElement;
|
||||
}
|
||||
|
||||
// Menu items render in a portal attached to document.body.
|
||||
function menuItems(): HTMLElement[] {
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
||||
);
|
||||
}
|
||||
|
||||
const FILE_DIFF: SessionFileDiff = {
|
||||
path: "docs/a.mdx",
|
||||
additions: 2,
|
||||
deletions: 1,
|
||||
hunks: [],
|
||||
};
|
||||
|
||||
describe("DiffView file actions", () => {
|
||||
it("copies the cwd-resolved absolute file path", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<DiffView
|
||||
cwd="/Users/renee/cline"
|
||||
fileDiffs={[FILE_DIFF]}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await click(buttonWithLabel("Copy file path for docs/a.mdx"));
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith("/Users/renee/cline/docs/a.mdx");
|
||||
});
|
||||
|
||||
it("opens the file in a chosen editor through the desktop backend", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<DiffView
|
||||
cwd="/Users/renee/cline"
|
||||
fileDiffs={[FILE_DIFF]}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await pointerDown(buttonWithLabel("Open docs/a.mdx in editor"));
|
||||
|
||||
const labels = menuItems().map((item) => item.textContent);
|
||||
expect(labels).toEqual(["VS Code", "System default"]);
|
||||
|
||||
const vscodeItem = menuItems().find(
|
||||
(item) => item.textContent === "VS Code",
|
||||
);
|
||||
await click(vscodeItem as Element);
|
||||
|
||||
expect(invokeMock).toHaveBeenCalledWith("open_file_in_editor", {
|
||||
path: "docs/a.mdx",
|
||||
cwd: "/Users/renee/cline",
|
||||
editor: "vscode",
|
||||
});
|
||||
});
|
||||
|
||||
it("still offers the system default opener when editor detection fails", async () => {
|
||||
invokeMock.mockImplementation(async (command: string) => {
|
||||
if (command === "list_available_editors") {
|
||||
throw new Error("unsupported desktop command");
|
||||
}
|
||||
return { path: "/repo/docs/a.mdx", editor: "system default" };
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(<DiffView fileDiffs={[FILE_DIFF]} onClose={vi.fn()} />);
|
||||
});
|
||||
|
||||
await pointerDown(buttonWithLabel("Open docs/a.mdx in editor"));
|
||||
|
||||
const labels = menuItems().map((item) => item.textContent);
|
||||
expect(labels).toEqual(["System default"]);
|
||||
|
||||
await click(menuItems()[0] as Element);
|
||||
|
||||
expect(invokeMock).toHaveBeenCalledWith("open_file_in_editor", {
|
||||
path: "docs/a.mdx",
|
||||
editor: "default",
|
||||
});
|
||||
});
|
||||
|
||||
it("copies the path as-is when no cwd is available", async () => {
|
||||
await act(async () => {
|
||||
root.render(<DiffView fileDiffs={[FILE_DIFF]} onClose={vi.fn()} />);
|
||||
});
|
||||
|
||||
await click(buttonWithLabel("Copy file path for docs/a.mdx"));
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith("docs/a.mdx");
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronRight, Minus, Plus, X } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
AppWindow,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Minus,
|
||||
Plus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import type { SessionFileDiff } from "@/lib/session-diff";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resolveWorkspaceFilePath } from "@/lib/workspace-paths";
|
||||
import { EditorIcon } from "./editor-icons";
|
||||
|
||||
type DiffViewProps = {
|
||||
fileDiffs: SessionFileDiff[];
|
||||
cwd?: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function DiffView({ fileDiffs, onClose }: DiffViewProps) {
|
||||
type EditorOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export function DiffView({ fileDiffs, cwd, onClose }: DiffViewProps) {
|
||||
const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(new Set());
|
||||
const [editors, setEditors] = useState<EditorOption[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
desktopClient
|
||||
.invoke<EditorOption[]>("list_available_editors")
|
||||
.then((list) => {
|
||||
if (!cancelled && Array.isArray(list)) setEditors(list);
|
||||
})
|
||||
.catch(() => {
|
||||
// Older sidecars don't support the command; the menu still
|
||||
// offers the system default opener.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const _totals = useMemo(
|
||||
() =>
|
||||
@@ -40,8 +85,8 @@ export function DiffView({ fileDiffs, onClose }: DiffViewProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex h-10 items-center justify-between border-b border-border bg-card px-4">
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden">
|
||||
<div className="flex h-10 shrink-0 items-center justify-between border-b border-border bg-card px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
Uncommitted changes
|
||||
@@ -64,7 +109,7 @@ export function DiffView({ fileDiffs, onClose }: DiffViewProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
{fileDiffs.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center px-4 py-16 text-sm text-muted-foreground">
|
||||
No file changes in this session yet.
|
||||
@@ -74,6 +119,8 @@ export function DiffView({ fileDiffs, onClose }: DiffViewProps) {
|
||||
{fileDiffs.map((file) => (
|
||||
<DiffFileSection
|
||||
collapsed={collapsedFiles.has(file.path)}
|
||||
cwd={cwd}
|
||||
editors={editors}
|
||||
file={file}
|
||||
key={file.path}
|
||||
onToggle={() => toggleFileCollapse(file.path)}
|
||||
@@ -89,34 +136,147 @@ export function DiffView({ fileDiffs, onClose }: DiffViewProps) {
|
||||
function DiffFileSection({
|
||||
file,
|
||||
collapsed,
|
||||
cwd,
|
||||
editors,
|
||||
onToggle,
|
||||
}: {
|
||||
file: SessionFileDiff;
|
||||
collapsed: boolean;
|
||||
cwd?: string;
|
||||
editors: EditorOption[];
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [opening, setOpening] = useState(false);
|
||||
const copyResetTimerRef = useRef<number | null>(null);
|
||||
const resolvedPath = resolveWorkspaceFilePath(file.path, cwd);
|
||||
|
||||
const handleCopyPath = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(resolvedPath);
|
||||
setCopied(true);
|
||||
if (copyResetTimerRef.current !== null) {
|
||||
window.clearTimeout(copyResetTimerRef.current);
|
||||
}
|
||||
copyResetTimerRef.current = window.setTimeout(() => {
|
||||
setCopied(false);
|
||||
copyResetTimerRef.current = null;
|
||||
}, 1600);
|
||||
} catch {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Copy failed",
|
||||
description: "The file path could not be copied to the clipboard.",
|
||||
});
|
||||
}
|
||||
}, [resolvedPath]);
|
||||
|
||||
const handleOpenInEditor = useCallback(
|
||||
async (editor?: string) => {
|
||||
setOpening(true);
|
||||
try {
|
||||
await desktopClient.invoke("open_file_in_editor", {
|
||||
path: file.path,
|
||||
...(cwd?.trim() ? { cwd } : {}),
|
||||
...(editor ? { editor } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Could not open file",
|
||||
description:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "The file could not be opened in an editor.",
|
||||
});
|
||||
} finally {
|
||||
setOpening(false);
|
||||
}
|
||||
},
|
||||
[file.path, cwd],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
<button
|
||||
className="flex w-full items-center gap-2 bg-card/80 px-4 py-2 text-left hover:bg-accent/50 transition-colors"
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground">
|
||||
{file.path}
|
||||
</span>
|
||||
<div className="group flex w-full items-center gap-2 bg-card/80 px-4 py-2 hover:bg-accent/50 transition-colors">
|
||||
<button
|
||||
className="flex min-w-0 shrink items-center gap-2 text-left"
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="min-w-0 truncate font-mono text-xs text-foreground">
|
||||
{file.path}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label={`Copy file path for ${file.path}`}
|
||||
className={cn(
|
||||
"shrink-0 rounded-md p-1 text-muted-foreground transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100",
|
||||
copied ? "opacity-100 text-primary" : "opacity-0",
|
||||
)}
|
||||
onClick={() => void handleCopyPath()}
|
||||
title="Copy file path"
|
||||
type="button"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
{/* Invisible flex spacer that keeps the dead space between the
|
||||
path and the right-aligned actions clickable as a toggle. */}
|
||||
<button
|
||||
aria-hidden
|
||||
className="h-6 min-w-0 flex-1 cursor-pointer"
|
||||
onClick={onToggle}
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-label={`Open ${file.path} in editor`}
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100 disabled:opacity-50 data-[state=open]:opacity-100 data-[state=open]:bg-accent data-[state=open]:text-foreground"
|
||||
disabled={opening}
|
||||
title="Open in editor"
|
||||
type="button"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuLabel>Open in</DropdownMenuLabel>
|
||||
{editors.map((editor) => (
|
||||
<DropdownMenuItem
|
||||
key={editor.id}
|
||||
onSelect={() => void handleOpenInEditor(editor.id)}
|
||||
>
|
||||
<EditorIcon editorId={editor.id} />
|
||||
{editor.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{editors.length > 0 && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem
|
||||
onSelect={() => void handleOpenInEditor("default")}
|
||||
>
|
||||
<AppWindow aria-hidden />
|
||||
System default
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<span className="shrink-0 font-mono text-[11px] text-primary">
|
||||
+{file.additions}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-[11px] text-destructive">
|
||||
-{file.deletions}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="space-y-2 border-t border-border bg-card/40 px-4 py-3">
|
||||
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user