mirror of
https://github.com/cline/cline.git
synced 2026-09-01 15:11:04 +08:00
Compare commits
52 Commits
cli-v3.0.60
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8eb5f3d57f | |||
| 0852992f3b | |||
| 4ab091b959 | |||
| 7a6beb9f0d | |||
| f5370ad4cf | |||
| a7a57509e2 | |||
| c4e09725f8 | |||
| 34f803fad4 | |||
| bcfa7c7e4d | |||
| c64743eb36 | |||
| c096030ace | |||
| 48d6385274 | |||
| cea134be06 | |||
| 1986fa56de | |||
| 27350f243c | |||
| 60c74bc727 | |||
| aa815cd41a | |||
| 1fbcfab05d | |||
| ce71fe5eb9 | |||
| aa4753f4ab | |||
| 52d5e1a515 | |||
| 2208d185a4 | |||
| 936c018689 | |||
| 957a4bf5d9 | |||
| b532b174ba | |||
| b78f6d16d0 | |||
| 839074d7c1 | |||
| 9e7c1a3f9a | |||
| 29530caa58 | |||
| 691fcb6b67 | |||
| c97e4af8fa | |||
| 889010b0b9 | |||
| 89c2efa970 | |||
| f753a01d85 | |||
| 908e09815e | |||
| 8eca7575b4 | |||
| 006de710d5 | |||
| 62f471f233 | |||
| c017c7016e | |||
| 4bfef7087f | |||
| 1d5d3b0055 | |||
| 80dd573156 | |||
| ce2f7a00bb | |||
| ad1408636e | |||
| 8981079a43 | |||
| b4fd4ee0cd | |||
| 89970ea794 | |||
| ee0982cb98 | |||
| 7718142ef2 | |||
| 70654acc3e | |||
| c8f1caa88c | |||
| 7673b30e4d |
@@ -7,7 +7,7 @@ description: Use when preparing, tagging, and publishing an apps/cli npm release
|
||||
|
||||
Use this skill when the user asks to release the CLI, publish `cline`, bump the CLI version, draft release notes, create a `cli-vX.Y.Z` tag, or trigger the CLI publish workflow.
|
||||
|
||||
The CLI is npm-only. Do not add alternate distribution or signing steps.
|
||||
The CLI is npm-only. Do not add alternate distribution channels. Windows binaries are Authenticode-signed automatically by the publish workflow via Azure Trusted Signing (see the `.github/actions/sign-windows-cli` composite action and "Windows code signing" in `apps/cli/DISTRIBUTION.md`); if the signing secrets are not configured the workflow warns and publishes unsigned binaries. Local publishes (`bun release cli`) do not sign — prefer the GitHub Actions publish path for releases users run on Windows.
|
||||
|
||||
> Working directory: run every command below from the repository root. Paths and scripts (e.g. `apps/cli/package.json`, `sdk/packages/`, `bun release cli`, `bun run version`) are written relative to the repo root.
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ Use this skill when the user asks to release the desktop app, publish the Cline
|
||||
|
||||
> Working directory: run every command below from the repository root.
|
||||
|
||||
Desktop releases are macOS-only today (a single signed + notarized universal DMG that runs natively on both 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 **on that channel**.
|
||||
Desktop releases ship two platforms, built entirely in GitHub Actions — there is no local publish path. macOS: a single signed + notarized universal DMG that runs natively on both Apple Silicon and Intel. Windows: an Authenticode-signed NSIS installer (`<Product>_<version>_x64-setup.exe`), signed via Azure Trusted Signing in the `build-windows` job (jsign through Tauri's `signCommand`, see `apps/examples/desktop-app/scripts/tauri-sign-windows.ps1`; requires the repo-level `AZURE_*` secrets including `AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP`, plus a `PublishDesktop`-environment federated credential on the `cline-cli-signing` Entra app). Installed apps discover new releases automatically through the Tauri updater, so publishing a release is what ships the update to every existing user **on that channel**.
|
||||
|
||||
## Release contract
|
||||
|
||||
@@ -21,7 +21,7 @@ Desktop releases are macOS-only today (a single signed + notarized universal DMG
|
||||
- Release prep includes approved release notes, the version bumps, and an `apps/examples/desktop-app/CHANGELOG.md` update — committed on `main` for stable, on `desktop-experimental` for beta.
|
||||
- Publish path: `.github/workflows/desktop-publish.yml` (workflow_dispatch, requires the tag to exist, point at the checked-out commit, and be reachable from the channel's branch — `origin/main` for stable, `origin/desktop-experimental` for beta).
|
||||
- **Both channels dispatch from `main`.** This is a security invariant, not a convenience: the run executes `main`'s workflow copy and only the checkout points at the tag, so the signing-secret gates (the `github.ref == main` check and the PublishDesktop environment's main-only deployment-branch policy) hold for beta too. Never add `desktop-experimental` to the PublishDesktop deployment-branch policy.
|
||||
- The workflow creates the tag's GitHub release (universal DMG + updater artifact + `latest.json`; marked prerelease for beta) and refreshes the channel's rolling feed release, which is the static auto-update feed every installed app on that channel polls. Never delete the `desktop-latest` or `desktop-beta` release or tag.
|
||||
- The workflow creates the tag's GitHub release (universal DMG + macOS updater artifact + Windows NSIS installer with its updater signature + `latest.json`; marked prerelease for beta) and refreshes the channel's rolling feed release, which is the static auto-update feed every installed app on that channel polls. Never delete the `desktop-latest` or `desktop-beta` release or tag.
|
||||
- The changelog's `## <version>` section (exact-match, not "topmost") is extracted verbatim into the GitHub release body, the Slack announcement, and the updater manifest notes.
|
||||
- Always ask before pushing commits or tags.
|
||||
|
||||
@@ -120,7 +120,7 @@ gh api repos/cline/cline/actions/runs/<run-id>/pending_deployments \
|
||||
|
||||
Nothing after `validate` runs — and no signing key is readable — until then.
|
||||
|
||||
The workflow builds one universal macOS bundle (`tauri build --target universal-apple-darwin` lipos the aarch64 + x86_64 Rust binaries; the Bun sidecar is lipo'd by `build-sidecar-bin.ts`; beta adds the `tauri.beta.conf.json` overlay), verifies every Mach-O in the bundle carries both slices and that the compiled binary embeds exactly its own channel's feed URL, signs with the Developer ID certificate, notarizes with the App Store Connect API key, signs the updater artifact with the Tauri updater key, creates the GitHub release (prerelease for beta), refreshes the channel's feed (`desktop-latest/latest.json` or `desktop-beta/latest.json`), and posts to Slack. Notarization typically adds 2–10 minutes.
|
||||
The workflow builds one universal macOS bundle (`tauri build --target universal-apple-darwin` lipos the aarch64 + x86_64 Rust binaries; the Bun sidecar is lipo'd by `build-sidecar-bin.ts`; beta adds the `tauri.beta.conf.json` overlay), verifies every Mach-O in the bundle carries both slices and that the compiled binary embeds exactly its own channel's feed URL, signs with the Developer ID certificate, notarizes with the App Store Connect API key, and signs the updater artifact with the Tauri updater key. In parallel, `build-windows` builds the x64 NSIS installer on a Windows runner, Authenticode-signs every binary via Azure Trusted Signing (Tauri `signCommand` -> `scripts/tauri-sign-windows.ps1`), runs the same feed-endpoint and telemetry guardrails, and verifies the shipped installer with `Get-AuthenticodeSignature`. The release job then creates the GitHub release (prerelease for beta), refreshes the channel's feed (`desktop-latest/latest.json` or `desktop-beta/latest.json`), and posts to Slack. Notarization typically adds 2–10 minutes.
|
||||
|
||||
If the workflow fails on missing credentials, see "Publish secrets (one-time setup)" below.
|
||||
|
||||
@@ -131,7 +131,7 @@ curl -sL https://github.com/cline/cline/releases/download/desktop-latest/latest.
|
||||
curl -sL https://github.com/cline/cline/releases/download/desktop-beta/latest.json | head -30 # beta
|
||||
```
|
||||
|
||||
The `version` field must be the new release and both `darwin-aarch64` and `darwin-x86_64` entries must point at the same new universal `.app.tar.gz` asset under the release tag (each slice of the fat binary requests its own arch key at runtime, so both keys serve the one artifact). Installed apps on that channel — including older per-arch installs — pick the update up on next launch or within 2 hours.
|
||||
The `version` field must be the new release; both `darwin-aarch64` and `darwin-x86_64` entries must point at the same new universal `.app.tar.gz` asset under the release tag (each slice of the fat binary requests its own arch key at runtime, so both keys serve the one artifact), and the `windows-x86_64` entry must point at the new `*_x64-setup.exe` asset. Installed apps on that channel — including older per-arch installs — pick the update up on next launch or within 2 hours.
|
||||
|
||||
After a **beta** publish, also confirm the stable feed was not touched: `desktop-latest/latest.json` must still serve the previous stable version. (The workflow guards this fail-closed, but it is cheap to verify and catastrophic to miss — the updater comparator is a plain semver "newer than", so a beta manifest on `desktop-latest` would auto-update every stable install onto the beta.)
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
name: Sign Windows CLI binaries
|
||||
description: >
|
||||
Authenticode-signs the compiled Windows CLI executables with Azure Trusted
|
||||
Signing (via jsign, so it runs on Linux runners) and verifies the resulting
|
||||
signatures. If the Azure Trusted Signing secrets are not configured, the
|
||||
action logs a warning and exits successfully so releases keep working while
|
||||
signing infrastructure is being provisioned.
|
||||
|
||||
inputs:
|
||||
azure-client-id:
|
||||
description: Client ID of the Entra app with the Trusted Signing Certificate Profile Signer role (OIDC federated credential, no client secret).
|
||||
required: false
|
||||
default: ""
|
||||
azure-tenant-id:
|
||||
description: Entra tenant ID.
|
||||
required: false
|
||||
default: ""
|
||||
azure-subscription-id:
|
||||
description: Azure subscription ID containing the Trusted Signing account.
|
||||
required: false
|
||||
default: ""
|
||||
endpoint:
|
||||
description: Trusted Signing account endpoint, for example https://eus.codesigning.azure.net.
|
||||
required: false
|
||||
default: ""
|
||||
account:
|
||||
description: Trusted Signing account name.
|
||||
required: false
|
||||
default: ""
|
||||
certificate-profile:
|
||||
description: Trusted Signing certificate profile name.
|
||||
required: false
|
||||
default: ""
|
||||
files:
|
||||
description: Newline-separated list of PE files to sign.
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Check signing configuration
|
||||
id: check
|
||||
shell: bash
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ inputs.azure-client-id }}
|
||||
AZURE_TENANT_ID: ${{ inputs.azure-tenant-id }}
|
||||
AZURE_SUBSCRIPTION_ID: ${{ inputs.azure-subscription-id }}
|
||||
SIGNING_ENDPOINT: ${{ inputs.endpoint }}
|
||||
SIGNING_ACCOUNT: ${{ inputs.account }}
|
||||
SIGNING_PROFILE: ${{ inputs.certificate-profile }}
|
||||
run: |
|
||||
missing=()
|
||||
set_count=0
|
||||
for var in AZURE_CLIENT_ID AZURE_TENANT_ID AZURE_SUBSCRIPTION_ID SIGNING_ENDPOINT SIGNING_ACCOUNT SIGNING_PROFILE; do
|
||||
if [ -z "${!var}" ]; then
|
||||
missing+=("$var")
|
||||
else
|
||||
set_count=$((set_count + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "${#missing[@]}" -eq 0 ]; then
|
||||
echo "Azure Trusted Signing is configured; Windows binaries will be signed."
|
||||
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||
elif [ "$set_count" -eq 0 ]; then
|
||||
echo "::warning::Azure Trusted Signing is not configured; publishing UNSIGNED Windows binaries. Set the AZURE_* and AZURE_TRUSTED_SIGNING_* repository secrets to enable signing."
|
||||
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
# Partial configuration is almost certainly a typo'd or renamed
|
||||
# secret. Fail loudly instead of silently publishing unsigned.
|
||||
echo "::error::Azure Trusted Signing is PARTIALLY configured; refusing to publish. Missing: ${missing[*]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Azure login (OIDC)
|
||||
if: steps.check.outputs.enabled == 'true'
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
|
||||
with:
|
||||
client-id: ${{ inputs.azure-client-id }}
|
||||
tenant-id: ${{ inputs.azure-tenant-id }}
|
||||
subscription-id: ${{ inputs.azure-subscription-id }}
|
||||
|
||||
- name: Sign Windows binaries
|
||||
if: steps.check.outputs.enabled == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
SIGNING_ENDPOINT: ${{ inputs.endpoint }}
|
||||
SIGNING_ACCOUNT: ${{ inputs.account }}
|
||||
SIGNING_PROFILE: ${{ inputs.certificate-profile }}
|
||||
FILES: ${{ inputs.files }}
|
||||
JSIGN_VERSION: "7.5"
|
||||
JSIGN_SHA256: "602a51c3545a6dc4fb99bd2ea7152b26d1345916d0c93ddfbd5936cb735af91c"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
JSIGN_JAR="${RUNNER_TEMP}/jsign-${JSIGN_VERSION}.jar"
|
||||
curl -fsSL -o "$JSIGN_JAR" "https://github.com/ebourg/jsign/releases/download/${JSIGN_VERSION}/jsign-${JSIGN_VERSION}.jar"
|
||||
echo "${JSIGN_SHA256} ${JSIGN_JAR}" | sha256sum --check --strict
|
||||
|
||||
JSIGN_STOREPASS=$(az account get-access-token --resource https://codesigning.azure.net --query accessToken --output tsv)
|
||||
echo "::add-mask::${JSIGN_STOREPASS}"
|
||||
export JSIGN_STOREPASS
|
||||
|
||||
# jsign expects the endpoint host, not the URL. Tolerate both the
|
||||
# portal's display form (trailing slash) and the bare form.
|
||||
KEYSTORE="${SIGNING_ENDPOINT#https://}"
|
||||
KEYSTORE="${KEYSTORE%/}"
|
||||
|
||||
while IFS= read -r file; do
|
||||
[ -z "$file" ] && continue
|
||||
echo "Signing ${file}"
|
||||
java -jar "$JSIGN_JAR" \
|
||||
--storetype TRUSTEDSIGNING \
|
||||
--keystore "$KEYSTORE" \
|
||||
--storepass env:JSIGN_STOREPASS \
|
||||
--alias "${SIGNING_ACCOUNT}/${SIGNING_PROFILE}" \
|
||||
--alg SHA-256 \
|
||||
--tsaurl http://timestamp.acs.microsoft.com \
|
||||
--tsmode RFC3161 \
|
||||
--replace \
|
||||
"$file"
|
||||
done <<< "$FILES"
|
||||
|
||||
- name: Verify signatures
|
||||
if: steps.check.outputs.enabled == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
FILES: ${{ inputs.files }}
|
||||
# Authenticode chains anchor to the Microsoft Identity Verification
|
||||
# Root CA 2020, which is not in the Mozilla TLS bundle, so fetch it
|
||||
# explicitly (pinned) for osslsigncode chain validation.
|
||||
MS_ROOT_URL: "https://www.microsoft.com/pkiops/certs/Microsoft%20Identity%20Verification%20Root%20Certificate%20Authority%202020.crt"
|
||||
MS_ROOT_SHA256: "5367f20c7ade0e2bca790915056d086b720c33c1fa2a2661acf787e3292e1270"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if ! command -v osslsigncode >/dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq osslsigncode
|
||||
fi
|
||||
|
||||
MS_ROOT_DER="${RUNNER_TEMP}/ms-identity-root-2020.crt"
|
||||
MS_ROOT_PEM="${RUNNER_TEMP}/ms-identity-root-2020.pem"
|
||||
curl -fsSL -o "$MS_ROOT_DER" "$MS_ROOT_URL"
|
||||
echo "${MS_ROOT_SHA256} ${MS_ROOT_DER}" | sha256sum --check --strict
|
||||
openssl x509 -inform DER -in "$MS_ROOT_DER" -out "$MS_ROOT_PEM"
|
||||
|
||||
while IFS= read -r file; do
|
||||
[ -z "$file" ] && continue
|
||||
echo "Verifying signature on ${file}"
|
||||
# Timestamp countersignature chain is checked separately by Windows;
|
||||
# -ignore-timestamp only skips TSA chain validation here, not the
|
||||
# Authenticode chain itself.
|
||||
osslsigncode verify -in "$file" -CAfile "$MS_ROOT_PEM" -ignore-timestamp
|
||||
done <<< "$FILES"
|
||||
@@ -190,6 +190,19 @@ jobs:
|
||||
ls -lh "$dir/bin/"
|
||||
done
|
||||
|
||||
- name: Sign Windows binaries
|
||||
uses: ./.github/actions/sign-windows-cli
|
||||
with:
|
||||
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
|
||||
account: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
||||
certificate-profile: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_CLI }}
|
||||
files: |
|
||||
apps/cli/dist/cli-windows-x64/bin/cline.exe
|
||||
apps/cli/dist/cli-windows-arm64/bin/cline.exe
|
||||
|
||||
- name: Publish to NPM with latest tag
|
||||
env:
|
||||
NPM_CONFIG_PROVENANCE: "true"
|
||||
@@ -447,6 +460,20 @@ jobs:
|
||||
ls -lh "$dir/bin/"
|
||||
done
|
||||
|
||||
- name: Sign Windows binaries
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
uses: ./.github/actions/sign-windows-cli
|
||||
with:
|
||||
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
|
||||
account: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
||||
certificate-profile: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_CLI }}
|
||||
files: |
|
||||
apps/cli/dist/cli-windows-x64/bin/cline.exe
|
||||
apps/cli/dist/cli-windows-arm64/bin/cline.exe
|
||||
|
||||
- name: Publish to NPM with nightly tag
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
|
||||
@@ -462,9 +462,282 @@ jobs:
|
||||
path: apps/examples/desktop-app/dist/publish/*
|
||||
if-no-files-found: error
|
||||
|
||||
build-windows:
|
||||
name: Build Windows (x64)
|
||||
needs: validate
|
||||
# Same gate rationale as the macOS build job above. This job additionally
|
||||
# needs id-token: write for Azure OIDC: Windows binaries are
|
||||
# Authenticode-signed with Azure Trusted Signing, authenticated through the
|
||||
# PublishDesktop-environment federated credential on the cline-cli-signing
|
||||
# Entra app (subject repo:cline/cline:environment:PublishDesktop).
|
||||
if: github.ref == 'refs/heads/main'
|
||||
environment: PublishDesktop
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
# All-or-nothing: an unsigned Windows desktop build is never acceptable
|
||||
# (Smart App Control / WDAC block unsigned exes and SmartScreen flags
|
||||
# unsigned installers), and Tauri would skip updater-artifact signing
|
||||
# silently if the updater key were missing. Unlike the CLI pipeline
|
||||
# there is no unsigned fallback here.
|
||||
- name: Verify signing secrets are present
|
||||
shell: bash
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
|
||||
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
||||
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
missing=()
|
||||
for name in AZURE_CLIENT_ID AZURE_TENANT_ID AZURE_SUBSCRIPTION_ID \
|
||||
AZURE_TRUSTED_SIGNING_ENDPOINT AZURE_TRUSTED_SIGNING_ACCOUNT_NAME \
|
||||
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP \
|
||||
TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
|
||||
[ -n "${!name}" ] || missing+=("$name")
|
||||
done
|
||||
|
||||
if [ ${#missing[@]} -gt 0 ]; then
|
||||
echo "Missing signing secrets for the Windows desktop build:"
|
||||
printf ' - %s\n' "${missing[@]}"
|
||||
echo
|
||||
echo "The AZURE_* names are repository secrets; the TAURI_* names"
|
||||
echo "live in the PublishDesktop environment. Refusing to build an"
|
||||
echo "unsigned Windows desktop release."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "All Windows signing secrets are present."
|
||||
|
||||
# Every action in this job is SHA-pinned (unlike elsewhere in this
|
||||
# file): they run with id-token: write and the updater signing key in
|
||||
# scope, so a hijacked upstream tag must not be able to reach the
|
||||
# signing identity or tamper with what gets signed and uploaded.
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.tag }}
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable branch
|
||||
with:
|
||||
# With a SHA-pinned action the toolchain no longer comes from the
|
||||
# ref name, so it must be set explicitly.
|
||||
toolchain: stable
|
||||
|
||||
# No Rust build cache, mirroring the macOS job: this job holds the
|
||||
# updater signing key and an Azure signing session, and a restored cache
|
||||
# archive is attacker-controlled if the Actions cache is poisoned.
|
||||
|
||||
- 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: Azure login (OIDC)
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
# Tauri invokes signCommand once per staged binary (main exe, sidecar,
|
||||
# NSIS uninstaller, and the installer itself). The overlay is generated
|
||||
# here rather than committed because signCommand needs an absolute path
|
||||
# to the signing script on this runner.
|
||||
- name: Write signing config overlay
|
||||
shell: bash
|
||||
run: |
|
||||
SCRIPT_PATH="${GITHUB_WORKSPACE//\\//}/apps/examples/desktop-app/scripts/tauri-sign-windows.ps1"
|
||||
SIGN_CONF="${RUNNER_TEMP//\\//}/tauri-windows-sign.conf.json"
|
||||
cat > "$SIGN_CONF" <<EOF
|
||||
{
|
||||
"\$schema": "https://schema.tauri.app/config/2",
|
||||
"bundle": {
|
||||
"windows": {
|
||||
"signCommand": "pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File ${SCRIPT_PATH} %1"
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
cat "$SIGN_CONF"
|
||||
echo "SIGN_CONF=${SIGN_CONF}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build and sign desktop bundle
|
||||
shell: bash
|
||||
working-directory: apps/examples/desktop-app
|
||||
# NSIS only: the MSI (WiX) target adds nothing for direct-download
|
||||
# distribution and the updater uses the NSIS artifact. $CONFIG_ARGS is
|
||||
# deliberately unquoted: it must word-split into separate flags.
|
||||
run: bunx tauri build --bundles nsis $CONFIG_ARGS --config "$SIGN_CONF"
|
||||
env:
|
||||
CONFIG_ARGS: ${{ needs.validate.outputs.channel == 'beta' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.beta.conf.json' || '--config src-tauri/tauri.release.conf.json' }}
|
||||
# Telemetry inlined into the sidecar at compile time, same as macOS.
|
||||
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 }}
|
||||
# Authenticode signing via scripts/tauri-sign-windows.ps1 (jsign +
|
||||
# Azure Trusted Signing; the token comes from the azure/login session)
|
||||
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
|
||||
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
||||
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP }}
|
||||
# Updater artifact signing (minisign keypair, same key as macOS)
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
# Same guardrail as the macOS job: assert the compiled binary embeds
|
||||
# this channel's updater feed URL and not the other channel's. Checked
|
||||
# on the unbundled main exe because NSIS compresses the installer
|
||||
# contents, which defeats a string search on the installer itself.
|
||||
- name: Verify updater feed endpoint
|
||||
shell: bash
|
||||
working-directory: apps/examples/desktop-app
|
||||
env:
|
||||
CHANNEL: ${{ needs.validate.outputs.channel }}
|
||||
run: |
|
||||
case "$CHANNEL" in
|
||||
stable)
|
||||
WANT="releases/download/desktop-latest/latest.json"
|
||||
FORBID="releases/download/desktop-beta/latest.json"
|
||||
;;
|
||||
beta)
|
||||
WANT="releases/download/desktop-beta/latest.json"
|
||||
FORBID="releases/download/desktop-latest/latest.json"
|
||||
;;
|
||||
*)
|
||||
echo "unknown channel: ${CHANNEL}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
found=0
|
||||
for bin in src-tauri/target/release/*.exe; do
|
||||
if grep -a "$FORBID" "$bin" >/dev/null; then
|
||||
echo "$bin embeds the other channel's feed URL (${FORBID})"
|
||||
exit 1
|
||||
fi
|
||||
if grep -a "$WANT" "$bin" >/dev/null; then
|
||||
found=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$found" -ne 1 ]; then
|
||||
echo "No exe in src-tauri/target/release embeds ${WANT}."
|
||||
echo "The updater endpoint overlay did not apply; check the"
|
||||
echo "--config flags on the build step and tauri.beta.conf.json."
|
||||
exit 1
|
||||
fi
|
||||
echo "Updater endpoint verified: ${WANT}"
|
||||
|
||||
# Same guardrail as the macOS job, run natively on the Windows sidecar.
|
||||
- name: Verify sidecar telemetry config was inlined
|
||||
shell: bash
|
||||
working-directory: apps/examples/desktop-app
|
||||
run: |
|
||||
SELFCHECK=$(./src-tauri/bin/code-sidecar-x86_64-pc-windows-msvc.exe --telemetry-selfcheck)
|
||||
echo "$SELFCHECK"
|
||||
if ! printf '%s' "$SELFCHECK" | grep -q '"enabled":true'; then
|
||||
echo "Packaged sidecar reports telemetry disabled."
|
||||
echo "Check the OTEL_* / TELEMETRY_SERVICE_API_KEY env on the"
|
||||
echo "'Build and sign desktop bundle' step and the --define"
|
||||
echo "inlining in scripts/build-sidecar-bin.ts."
|
||||
exit 1
|
||||
fi
|
||||
if printf '%s' "$SELFCHECK" | grep -Eq '"otlp_endpoint_host":"(invalid-endpoint-url)?"'; then
|
||||
echo "Packaged sidecar reports telemetry enabled but its OTLP"
|
||||
echo "endpoint is missing, unparseable, or not an http(s) URL."
|
||||
echo "Check the OTEL_EXPORTER_OTLP_ENDPOINT secret."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Collect artifacts
|
||||
shell: bash
|
||||
working-directory: apps/examples/desktop-app
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
PRODUCT: ${{ needs.validate.outputs.product }}
|
||||
run: |
|
||||
BUNDLE_DIR="src-tauri/target/release/bundle"
|
||||
OUT="dist/publish"
|
||||
mkdir -p "$OUT"
|
||||
|
||||
# "Cline" -> Cline, "Cline Beta" -> Cline-Beta
|
||||
PREFIX="${PRODUCT// /-}"
|
||||
|
||||
SETUP=$(find "$BUNDLE_DIR/nsis" -name '*-setup.exe' -print -quit)
|
||||
if [ -z "$SETUP" ]; then
|
||||
echo "no NSIS installer produced under $BUNDLE_DIR/nsis"
|
||||
exit 1
|
||||
fi
|
||||
# The .sig is the updater (minisign) signature; without it the
|
||||
# manifest generator cannot publish a windows-x86_64 entry.
|
||||
if [ ! -f "${SETUP}.sig" ]; then
|
||||
echo "updater signature missing next to $SETUP"
|
||||
exit 1
|
||||
fi
|
||||
cp "$SETUP" "$OUT/${PREFIX}_${VERSION}_x64-setup.exe"
|
||||
cp "${SETUP}.sig" "$OUT/${PREFIX}_${VERSION}_x64-setup.exe.sig"
|
||||
|
||||
ls -lh "$OUT"
|
||||
|
||||
# Independent Authenticode gate on the exact artifact users download.
|
||||
# The signing script already verifies each file it signs, but this step
|
||||
# would still catch an installer that skipped signCommand entirely.
|
||||
- name: Verify Authenticode signatures
|
||||
shell: pwsh
|
||||
working-directory: apps/examples/desktop-app
|
||||
run: |
|
||||
# The Tauri bundler signs the sidecar in place, so check it here too;
|
||||
# a WDAC-locked machine blocks the app at runtime if the sidecar it
|
||||
# spawns is unsigned, even when the installer itself is fine.
|
||||
$files = @(Get-ChildItem dist/publish/*.exe) + @(Get-Item src-tauri/bin/code-sidecar-x86_64-pc-windows-msvc.exe)
|
||||
if ($files.Count -lt 2) { throw "expected at least the installer and the sidecar to verify" }
|
||||
foreach ($file in $files) {
|
||||
$sig = Get-AuthenticodeSignature $file.FullName
|
||||
if ($sig.Status -ne "Valid") {
|
||||
throw "Invalid Authenticode signature for $($file.Name): $($sig.Status) - $($sig.StatusMessage)"
|
||||
}
|
||||
Write-Host "$($file.Name): Valid ($($sig.SignerCertificate.Subject))"
|
||||
}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: desktop-windows-x64
|
||||
path: apps/examples/desktop-app/dist/publish/*
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: Create GitHub release
|
||||
needs: [validate, build]
|
||||
needs: [validate, build, build-windows]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -270,6 +270,9 @@ The postinstall script runs in diverse environments (CI, Docker, restricted perm
|
||||
### Windows
|
||||
Windows binaries are `.exe` files. The build script appends `.exe` to the output filename on Windows targets. The resolver handles this. npm on Windows generates `.cmd` shims for bin entries automatically.
|
||||
|
||||
### Windows code signing
|
||||
Windows application control (Smart App Control, WDAC, AppLocker) blocks unsigned executables at launch, regardless of how they were installed — npm distribution gets no exemption ([#12934](https://github.com/cline/cline/issues/12934)). The publish workflow Authenticode-signs `cli-windows-x64/bin/cline.exe` and `cli-windows-arm64/bin/cline.exe` with Azure Trusted Signing before publishing, via the `.github/actions/sign-windows-cli` composite action. Signing runs on the Linux publish runner using [jsign](https://ebourg.github.io/jsign/) (`--storetype TRUSTEDSIGNING`) with an OIDC-federated Entra app, then verifies the signature chain with `osslsigncode` against the Microsoft Identity Verification Root CA 2020. If all `AZURE_*` / `AZURE_TRUSTED_SIGNING_*` repository secrets are absent, the action logs a warning and the release ships unsigned rather than failing; if only some resolve (a typo'd or renamed secret), the release fails loudly instead. The certificate profile secret is suffixed `_CLI` because the desktop app will later get its own profile; the other five secrets are shared. Note that signing bun-compiled executables requires Bun >= 1.2.23 (earlier versions located the embedded bundle relative to the end of the file, which signing corrupts).
|
||||
|
||||
### File permissions
|
||||
Compiled binaries need to be executable (`chmod 755`). The build script sets this after copying. The postinstall also sets permissions on the cached binary. Some npm packaging steps can strip permissions, so both handle this defensively.
|
||||
|
||||
|
||||
@@ -72,6 +72,29 @@ function run(target) {
|
||||
});
|
||||
if (result.error) {
|
||||
console.error(result.error.message);
|
||||
// Windows application control (Smart App Control, WDAC, AppLocker)
|
||||
// blocks the child exe at launch, which Node surfaces only as an
|
||||
// opaque "spawnSync ... UNKNOWN" error. Point users at the real cause.
|
||||
const code = result.error.code;
|
||||
if (
|
||||
os.platform() === "win32" &&
|
||||
(code === "UNKNOWN" || code === "EACCES" || code === "EPERM")
|
||||
) {
|
||||
console.error(
|
||||
"\nWindows refused to start the Cline binary:\n " +
|
||||
target +
|
||||
"\n\n" +
|
||||
"This usually means an application control policy (Smart App Control,\n" +
|
||||
"WDAC, or AppLocker) or antivirus blocked the executable. To confirm,\n" +
|
||||
"run the path above directly in a terminal and check the error Windows\n" +
|
||||
"reports, or inspect its signature with:\n\n" +
|
||||
' Get-AuthenticodeSignature "' +
|
||||
target +
|
||||
'"\n\n' +
|
||||
"If it was blocked by policy, allow the file or ask your administrator\n" +
|
||||
"to trust it. See https://github.com/cline/cline/issues for known issues.",
|
||||
);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
if (typeof result.status === "number") {
|
||||
|
||||
@@ -186,7 +186,9 @@ export function createHubCommand(
|
||||
);
|
||||
if (!ok) {
|
||||
io.writeErr(
|
||||
draining ? "Hub drain request failed." : "Hub un-drain request failed.",
|
||||
draining
|
||||
? "Hub drain request failed."
|
||||
: "Hub un-drain request failed.",
|
||||
);
|
||||
fail();
|
||||
return;
|
||||
|
||||
@@ -63,7 +63,11 @@ describe("resolveSystemPrompt workspace metadata", () => {
|
||||
writeFileSync(join(cwd, "README.md"), "test\n");
|
||||
execFileSync("git", ["add", "README.md"], { cwd });
|
||||
execFileSync("git", ["commit", "-m", "initial"], { cwd });
|
||||
execFileSync("git", ["remote", "add", "origin", "https://example.com/cline/repo.git"], { cwd });
|
||||
execFileSync(
|
||||
"git",
|
||||
["remote", "add", "origin", "https://example.com/cline/repo.git"],
|
||||
{ cwd },
|
||||
);
|
||||
const commit = execFileSync("git", ["rev-parse", "HEAD"], {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
|
||||
@@ -657,11 +657,18 @@ export function ChatEntryView(props: {
|
||||
* token identity, so settled content never re-renders.
|
||||
* tableOptions preserves the bordered table style that coalesced
|
||||
* mode used by default (top-level defaults to borderless columns).
|
||||
*
|
||||
* streaming stays true even after the entry settles: flipping the
|
||||
* prop makes MarkdownRenderable rebuild every block from scratch
|
||||
* (updateBlocks(true) skips all reuse paths), so the finished
|
||||
* message flashes back to unhighlighted text while tree-sitter
|
||||
* re-highlights. opencode's TUI keeps streaming={true} for the
|
||||
* same reason. entry.streaming still drives the spinner glyph.
|
||||
*/}
|
||||
<markdown
|
||||
content={content}
|
||||
syntaxStyle={getSyntaxStyle(theme, mode)}
|
||||
streaming={entry.streaming}
|
||||
streaming={true}
|
||||
internalBlockMode="top-level"
|
||||
tableOptions={{ style: "grid" }}
|
||||
fg={defaultFg}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { AgentMode } from "@cline/core";
|
||||
import type { ToolApprovalRequest, ToolApprovalResult } from "@cline/shared";
|
||||
import {
|
||||
type ToolApprovalRequest,
|
||||
type ToolApprovalResult,
|
||||
USER_REJECTED_TOOL_REASON,
|
||||
} from "@cline/shared";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { RuntimeToolInteraction, TuiProps } from "../types";
|
||||
|
||||
@@ -36,16 +40,16 @@ function toRuntimeToolInteraction(
|
||||
};
|
||||
}
|
||||
|
||||
function deniedToolResult(request: ToolApprovalRequest): ToolApprovalResult {
|
||||
function deniedToolResult(): ToolApprovalResult {
|
||||
return {
|
||||
approved: false,
|
||||
reason: `Tool "${request.toolName}" was denied by user`,
|
||||
reason: USER_REJECTED_TOOL_REASON,
|
||||
};
|
||||
}
|
||||
|
||||
function dismissPendingInteraction(pending: PendingRuntimeToolInteraction) {
|
||||
if (pending.kind === "tool_approval") {
|
||||
pending.resolve(deniedToolResult(pending.request));
|
||||
pending.resolve(deniedToolResult());
|
||||
return;
|
||||
}
|
||||
pending.resolve("[User dismissed the question]");
|
||||
@@ -111,9 +115,7 @@ export function useRuntimeDialogBridge(input: {
|
||||
if (!pending || pending.id !== id || pending.kind !== "tool_approval") {
|
||||
return;
|
||||
}
|
||||
pending.resolve(
|
||||
approved ? { approved: true } : deniedToolResult(pending.request),
|
||||
);
|
||||
pending.resolve(approved ? { approved: true } : deniedToolResult());
|
||||
const hasNext = finishActive(id);
|
||||
if (!hasNext) {
|
||||
refocusTextarea();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ProviderSettingsManager } from "@cline/core";
|
||||
import { isProviderSettingsUsable } from "../../utils/provider-readiness";
|
||||
import { isProviderSettingsUsable, ProviderSettingsManager } from "@cline/core";
|
||||
import type { TuiProps } from "../types";
|
||||
|
||||
export function isProviderConfigured(config: TuiProps["config"]): boolean {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { createInterface } from "node:readline";
|
||||
import type { ToolApprovalRequest, ToolApprovalResult } from "@cline/shared";
|
||||
import {
|
||||
type ToolApprovalRequest,
|
||||
type ToolApprovalResult,
|
||||
USER_REJECTED_TOOL_REASON,
|
||||
} from "@cline/shared";
|
||||
import { truncate } from "./helpers";
|
||||
import { c, getActiveCliSession, write } from "./output";
|
||||
|
||||
@@ -91,7 +95,7 @@ async function requestTerminalToolApproval(
|
||||
}
|
||||
return {
|
||||
approved: false,
|
||||
reason: `Tool "${request.toolName}" was denied by user`,
|
||||
reason: USER_REJECTED_TOOL_REASON,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -264,6 +264,19 @@ export async function handleDesktopCommand(
|
||||
) {
|
||||
return [...ctx.sessions.values()].map(toWebviewSessionSummary);
|
||||
}
|
||||
if (command === "search_sessions") {
|
||||
if (!ctx.uiClient) throw new Error("Hub is not connected");
|
||||
const query = String(args?.query ?? "").trim();
|
||||
if (!query) return [];
|
||||
return await ctx.uiClient.searchSessions({
|
||||
query,
|
||||
limit: typeof args?.limit === "number" ? args.limit : 50,
|
||||
workspaceRoot:
|
||||
typeof args?.workspaceRoot === "string"
|
||||
? args.workspaceRoot
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
if (command === "read_session_hooks") {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"media-chrome": "^4.18.1",
|
||||
"mermaid": "11.16.0",
|
||||
"mermaid": "11.16.1",
|
||||
"motion": "^12.38.0",
|
||||
"nanoid": "^5.1.7",
|
||||
"next-themes": "^0.4.6",
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
PlugIcon,
|
||||
RotateCcwIcon,
|
||||
RssIcon,
|
||||
SearchIcon,
|
||||
ServerIcon,
|
||||
SettingsIcon,
|
||||
Trash2Icon,
|
||||
@@ -63,6 +64,7 @@ import type {
|
||||
import { PageFrame, PageHeader } from "./components/views/page-layout";
|
||||
import type { CustomizationSection } from "./components/views/settings/extensions-view";
|
||||
import type { SettingsSection } from "./components/views/settings/settings-view";
|
||||
import { desktopClient } from "./lib/desktop-client";
|
||||
import { syncHubTheme } from "./lib/theme";
|
||||
import { postToHost } from "./vscode";
|
||||
|
||||
@@ -740,7 +742,18 @@ function SessionsView({
|
||||
onRenameSession: (sessionId: string, title: string) => Promise<void> | void;
|
||||
sessions: WebviewSessionSummary[];
|
||||
}) {
|
||||
type SearchHit = {
|
||||
sessionId: string;
|
||||
documentId: string;
|
||||
title: string;
|
||||
workspaceRoot: string;
|
||||
role: string;
|
||||
snippet: string;
|
||||
};
|
||||
const [sessionFilters, setSessionFilters] = useState<string[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchHits, setSearchHits] = useState<SearchHit[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
|
||||
const [editingTitle, setEditingTitle] = useState("");
|
||||
const [deleteSessionCandidate, setDeleteSessionCandidate] =
|
||||
@@ -772,6 +785,34 @@ function SessionsView({
|
||||
});
|
||||
}, [sessions, sessionFilters, sortDirection]);
|
||||
|
||||
useEffect(() => {
|
||||
const query = searchQuery.trim();
|
||||
if (!query) {
|
||||
setSearchHits([]);
|
||||
setSearching(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setSearching(true);
|
||||
const timer = setTimeout(() => {
|
||||
void desktopClient
|
||||
.invoke<SearchHit[]>("search_sessions", { query, limit: 50 })
|
||||
.then((hits) => {
|
||||
if (!cancelled) setSearchHits(hits);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setSearchHits([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setSearching(false);
|
||||
});
|
||||
}, 200);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [searchQuery]);
|
||||
|
||||
const startRenameSession = (session: WebviewSessionSummary) => {
|
||||
setEditingSessionId(session.sessionId);
|
||||
setEditingTitle(session.title || shortId(session.sessionId));
|
||||
@@ -894,6 +935,52 @@ function SessionsView({
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="relative mb-3">
|
||||
<SearchIcon className="pointer-events-none absolute left-3 top-2.5 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="Search all session history"
|
||||
className="pl-9"
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder="Search messages, commands, errors, and file paths across all sessions…"
|
||||
value={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{searchQuery.trim() ? (
|
||||
<section className="mb-4 overflow-hidden rounded-lg border bg-card">
|
||||
{searching ? (
|
||||
<p className="px-4 py-5 text-sm text-muted-foreground">
|
||||
Searching…
|
||||
</p>
|
||||
) : searchHits.length === 0 ? (
|
||||
<p className="px-4 py-5 text-sm text-muted-foreground">
|
||||
No matching session history.
|
||||
</p>
|
||||
) : (
|
||||
searchHits.map((hit) => (
|
||||
<button
|
||||
className="block w-full border-b px-4 py-3 text-left last:border-b-0 hover:bg-accent/40"
|
||||
key={hit.documentId}
|
||||
onClick={() => onOpenSession(hit.sessionId)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<span className="truncate">{hit.title}</span>
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
{hit.role}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
|
||||
{hit.snippet}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{hit.workspaceRoot}
|
||||
</p>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="w-full min-w-0 overflow-x-auto">
|
||||
<div className="grid w-full min-w-[56rem] grid-cols-[minmax(12rem,1.35fr)_minmax(7rem,0.85fr)_minmax(10rem,1.1fr)_5rem_5rem_4.5rem_5.5rem_2rem] gap-x-4 bg-muted/40 px-4 py-3 text-[15px] font-medium text-muted-foreground">
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import type { GeneratedMedia } from "@cline/shared/browser";
|
||||
import {
|
||||
type GeneratedMedia,
|
||||
USER_REJECTED_TOOL_REASON,
|
||||
} from "@cline/shared/browser";
|
||||
import { GeneratedMediaContent } from "@cline/ui";
|
||||
import {
|
||||
CheckIcon,
|
||||
@@ -1182,7 +1185,7 @@ export default function Chat({
|
||||
type: "approval_response",
|
||||
approvalId,
|
||||
approved,
|
||||
reason: approved ? "Approved in Cline Hub." : "Rejected in Cline Hub.",
|
||||
reason: approved ? "Approved in Cline Hub." : USER_REJECTED_TOOL_REASON,
|
||||
});
|
||||
setStatus(approved ? "Approval sent." : "Rejection sent.");
|
||||
};
|
||||
|
||||
@@ -1,5 +1,39 @@
|
||||
# Cline Desktop Changelog
|
||||
|
||||
## 0.0.21
|
||||
|
||||
- Marketplace is now a two-pane explorer: a browsable list on the left and full catalog metadata for the selected item on the right, with category tag filters that collapse behind a "more" toggle
|
||||
- Stopping a session now actually stops everything it started. Stop stays available while child agents are running, and an abort propagates to delegated subagents and to teammates instead of leaving orphaned work running in the background; cancelled teammate tasks now persist as cancelled
|
||||
- Fixed the ask-a-question tool's option text overflowing instead of wrapping
|
||||
- You can now drop file attachments anywhere over the chat input, not just on the small attach target
|
||||
- Cline provider models now refresh from the live catalog, so newly released models show up without waiting for an app update
|
||||
- Provider 401/403 responses are now classified as authentication errors rather than generic request failures, so a bad or missing API key is distinguishable from a real provider outage
|
||||
- Fixed Langfuse tracing never initializing in release builds — the minified bundle broke tracer detection, so telemetry worked in dev and silently did nothing in the shipped app. Also updated for AI SDK 7's telemetry API
|
||||
- Refreshed the model catalog. Adds TokenGo and Volcengine Ark, and updates model lists, pricing, and the resolved default model for ~36 providers (including Hugging Face, Mistral, OpenRouter, Together, NanoGPT, Requesty, Baseten, Cloudflare Workers AI, and DigitalOcean) — if you use one of those without pinning a model, you will get a different default
|
||||
|
||||
## 0.0.20
|
||||
|
||||
- Cline Desktop now ships on Windows: releases include a code-signed x64 installer, and installed apps auto-update on the same feed macOS does
|
||||
- Windows shell fixes: background processes (the sidecar, git) no longer pop visible console windows; updates now download in the background and install when you restart the app; the MCP settings path falls back to `USERPROFILE` when `HOME` is unset
|
||||
- Tool results that return images — screenshots from browser or MCP tools — now render as inline images you can click to expand, with a carousel for stepping through multiple images, instead of raw base64 text
|
||||
- Session search now covers your full indexed history. The sidebar search icon opens the command bar (Cmd/Ctrl+P) with server-ranked results, instead of a sidebar-local dialog that first loaded every session into memory
|
||||
- Onboarding has a new GitHub integration step
|
||||
- Fixed scheduled tasks disappearing after the app updated — hub-managed schedules were being wiped by cron reconciliation on restart
|
||||
- Agent-created schedules now live in one user-level home (`~/.cline/schedules`) instead of being scattered across whichever chat folder created them, and they now appear on the Schedules page
|
||||
- A finished scheduled session now surfaces its final answer: the completing step auto-expands, is labeled "Scheduled task completed" (or failed), and its summary renders as markdown
|
||||
- Suggested routine templates now ask for a specific final report, so a scheduled run ends with something readable
|
||||
- Providers no longer show as "Configured" on the strength of a leftover settings entry with no real credentials, and the badge now updates live after connecting or saving credentials instead of waiting for a remount
|
||||
- Fixed OpenAI Codex (ChatGPT subscription) sign-in silently dead-ending when callback port 1455 was already in use — it now fails immediately with an actionable error, and OAuth redirect errors surface instead of a confusing "Missing authorization code"
|
||||
- Codex and OCA sign-ins are no longer dropped when a token refresh hits a transient network failure or server error
|
||||
- Checkpoint restore now refuses to reset your workspace when commits were made after the checkpoint, instead of silently knocking them off the branch
|
||||
- Fixed an enabled-but-offline remote MCP server stalling session startup until the session was torn down
|
||||
- Global rules stored at `~/Cline/Rules` are now discovered (previously only `~/Documents/Cline/Rules`), fixing rules that never reached the model on WSL and headless installs
|
||||
- `apply_patch` now preserves a file's own CRLF line endings
|
||||
- The window title bar stays draggable across every view
|
||||
- Voice input's Live and After recording badges now have tooltips explaining them
|
||||
- Removed the box shadow from the chat message actions row
|
||||
- The hub no longer watches agenda spec directories while the todo tool is disabled, dropping an OS watch handle per known workspace
|
||||
|
||||
## 0.0.19
|
||||
|
||||
- Fixed the background Cline process ballooning in memory during long sessions — session status updates were carrying a full copy of the conversation transcript to every connected client, which on a multi-megabyte task could grow the process to tens of gigabytes. Status updates now carry only state (status, usage, model, workspace, checkpoint); the transcript is fetched on demand
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/code",
|
||||
"version": "0.0.19",
|
||||
"version": "0.0.21",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:ui": "bun -F @cline/ui build",
|
||||
|
||||
@@ -15,9 +15,7 @@ async function reserveAvailablePort(): Promise<number> {
|
||||
reject(new Error("Failed to reserve a sidecar port"));
|
||||
return;
|
||||
}
|
||||
server.close((error) =>
|
||||
error ? reject(error) : resolve(address.port),
|
||||
);
|
||||
server.close((error) => (error ? reject(error) : resolve(address.port)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -77,6 +77,66 @@ describe("buildUpdateManifest", () => {
|
||||
expect(Object.keys(manifest.platforms)).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("maps a Windows NSIS setup artifact to windows-x86_64", () => {
|
||||
const dir = makeUniversalArtifactDir();
|
||||
writeFileSync(path.join(dir, "Cline-Code_0.1.0_x64-setup.exe"), "nsis");
|
||||
writeFileSync(
|
||||
path.join(dir, "Cline-Code_0.1.0_x64-setup.exe.sig"),
|
||||
"sig-windows-x64\n",
|
||||
);
|
||||
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.platforms["windows-x86_64"]).toEqual({
|
||||
signature: "sig-windows-x64",
|
||||
url: "https://github.com/cline/cline/releases/download/desktop-v0.1.0/Cline-Code_0.1.0_x64-setup.exe",
|
||||
});
|
||||
// darwin entries from the universal artifact are unaffected.
|
||||
expect(Object.keys(manifest.platforms).sort()).toEqual([
|
||||
"darwin-aarch64",
|
||||
"darwin-x86_64",
|
||||
"windows-x86_64",
|
||||
]);
|
||||
});
|
||||
|
||||
test("ignores non-updater exe files without a setup arch suffix", () => {
|
||||
const dir = makeUniversalArtifactDir();
|
||||
writeFileSync(path.join(dir, "Cline-Code_0.1.0_x64.exe"), "exe");
|
||||
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(Object.keys(manifest.platforms).sort()).toEqual([
|
||||
"darwin-aarch64",
|
||||
"darwin-x86_64",
|
||||
]);
|
||||
});
|
||||
|
||||
test("throws when a Windows setup artifact is missing its signature", () => {
|
||||
const dir = makeUniversalArtifactDir();
|
||||
writeFileSync(path.join(dir, "Cline-Code_0.1.0_x64-setup.exe"), "nsis");
|
||||
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 universal and per-arch artifacts claim the same platform", () => {
|
||||
const dir = makePerArchArtifactDir();
|
||||
writeFileSync(
|
||||
|
||||
@@ -24,17 +24,25 @@ export type UpdateManifest = {
|
||||
platforms: Record<string, UpdaterPlatformEntry>;
|
||||
};
|
||||
|
||||
// Maps the arch token embedded in artifact file names (see the "Collect
|
||||
// Maps the arch token embedded in macOS artifact file names (see the "Collect
|
||||
// artifacts" workflow step) to the platform keys the Tauri updater requests.
|
||||
// A universal (fat) bundle serves both macOS architectures: each slice of the
|
||||
// installed app requests its own compile-time arch key at runtime, and both
|
||||
// keys point at the same artifact and signature.
|
||||
const PLATFORM_KEYS_BY_ARCH_SUFFIX: Record<string, string[]> = {
|
||||
const MACOS_PLATFORM_KEYS_BY_ARCH_SUFFIX: Record<string, string[]> = {
|
||||
aarch64: ["darwin-aarch64"],
|
||||
x86_64: ["darwin-x86_64"],
|
||||
universal: ["darwin-aarch64", "darwin-x86_64"],
|
||||
};
|
||||
|
||||
// On Windows the updater artifact is the NSIS installer itself
|
||||
// (createUpdaterArtifacts signs the setup exe with the updater key), named
|
||||
// `<Product>_<version>_<arch>-setup.exe` by the Tauri bundler.
|
||||
const WINDOWS_PLATFORM_KEYS_BY_ARCH_SUFFIX: Record<string, string[]> = {
|
||||
x64: ["windows-x86_64"],
|
||||
arm64: ["windows-aarch64"],
|
||||
};
|
||||
|
||||
const getArgValue = (args: string[], name: string): string | undefined => {
|
||||
const index = args.indexOf(name);
|
||||
if (index >= 0 && args[index + 1] && !args[index + 1].startsWith("--")) {
|
||||
@@ -45,13 +53,22 @@ const getArgValue = (args: string[], name: string): string | undefined => {
|
||||
return inline?.slice(prefix.length);
|
||||
};
|
||||
|
||||
const archOfUpdaterArtifact = (fileName: string): string | undefined => {
|
||||
if (!fileName.endsWith(".app.tar.gz")) {
|
||||
return undefined;
|
||||
const platformKeysOfUpdaterArtifact = (
|
||||
fileName: string,
|
||||
): string[] | undefined => {
|
||||
if (fileName.endsWith(".app.tar.gz")) {
|
||||
const arch = Object.keys(MACOS_PLATFORM_KEYS_BY_ARCH_SUFFIX).find(
|
||||
(candidate) => fileName.includes(`_${candidate}`),
|
||||
);
|
||||
return arch ? MACOS_PLATFORM_KEYS_BY_ARCH_SUFFIX[arch] : undefined;
|
||||
}
|
||||
return Object.keys(PLATFORM_KEYS_BY_ARCH_SUFFIX).find((arch) =>
|
||||
fileName.includes(`_${arch}`),
|
||||
);
|
||||
if (fileName.endsWith("-setup.exe")) {
|
||||
const arch = Object.keys(WINDOWS_PLATFORM_KEYS_BY_ARCH_SUFFIX).find(
|
||||
(candidate) => fileName.endsWith(`_${candidate}-setup.exe`),
|
||||
);
|
||||
return arch ? WINDOWS_PLATFORM_KEYS_BY_ARCH_SUFFIX[arch] : undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const buildUpdateManifest = (options: {
|
||||
@@ -65,8 +82,8 @@ export const buildUpdateManifest = (options: {
|
||||
const platforms: Record<string, UpdaterPlatformEntry> = {};
|
||||
|
||||
for (const fileName of readdirSync(options.dir).sort()) {
|
||||
const arch = archOfUpdaterArtifact(fileName);
|
||||
if (!arch) {
|
||||
const platformKeys = platformKeysOfUpdaterArtifact(fileName);
|
||||
if (!platformKeys) {
|
||||
continue;
|
||||
}
|
||||
const signaturePath = path.join(options.dir, `${fileName}.sig`);
|
||||
@@ -74,7 +91,7 @@ export const buildUpdateManifest = (options: {
|
||||
if (!signature) {
|
||||
throw new Error(`empty updater signature at ${signaturePath}`);
|
||||
}
|
||||
for (const platformKey of PLATFORM_KEYS_BY_ARCH_SUFFIX[arch]) {
|
||||
for (const platformKey of platformKeys) {
|
||||
if (platforms[platformKey]) {
|
||||
throw new Error(
|
||||
`multiple updater artifacts claim platform ${platformKey}; found ${fileName} after ${platforms[platformKey].url}`,
|
||||
@@ -89,7 +106,7 @@ export const buildUpdateManifest = (options: {
|
||||
|
||||
if (Object.keys(platforms).length === 0) {
|
||||
throw new Error(
|
||||
`no updater artifacts (*.app.tar.gz with a known arch suffix) found in ${options.dir}`,
|
||||
`no updater artifacts (*.app.tar.gz or *-setup.exe with a known arch suffix) found in ${options.dir}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# Authenticode-signs one PE file with Azure Trusted Signing via jsign.
|
||||
#
|
||||
# Invoked by the Tauri bundler through `bundle > windows > signCommand` (the
|
||||
# desktop-publish workflow generates a config overlay pointing here), once per
|
||||
# binary it stages: the main app exe, the code-sidecar external binary, the
|
||||
# NSIS uninstaller, and the NSIS installer itself.
|
||||
#
|
||||
# Requirements (all provided by the desktop-publish Windows job):
|
||||
# - an azure/login OIDC session (jsign's token comes from `az account get-access-token`)
|
||||
# - AZURE_TRUSTED_SIGNING_ENDPOINT / _ACCOUNT_NAME / _CERTIFICATE_PROFILE env vars
|
||||
# - java on PATH (preinstalled on GitHub Windows runners)
|
||||
#
|
||||
# Mirrors the CLI pipeline (.github/actions/sign-windows-cli): same jsign
|
||||
# version and flags, same Microsoft timestamp service. Kept as a standalone
|
||||
# script so the signing behavior is reviewable in the repo rather than inlined
|
||||
# in a generated config string.
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory = $true, Position = 0)]
|
||||
[string] $Path
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$jsignVersion = "7.5"
|
||||
$jsignSha256 = "602A51C3545A6DC4FB99BD2EA7152B26D1345916D0C93DDFBD5936CB735AF91C"
|
||||
|
||||
$endpoint = $env:AZURE_TRUSTED_SIGNING_ENDPOINT
|
||||
$account = $env:AZURE_TRUSTED_SIGNING_ACCOUNT_NAME
|
||||
$certProfile = $env:AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE
|
||||
if (-not $endpoint -or -not $account -or -not $certProfile) {
|
||||
throw "Azure Trusted Signing env vars are not set (AZURE_TRUSTED_SIGNING_ENDPOINT/_ACCOUNT_NAME/_CERTIFICATE_PROFILE)"
|
||||
}
|
||||
|
||||
$resolved = (Resolve-Path $Path).Path
|
||||
|
||||
# jsign expects the endpoint host; tolerate the portal's trailing-slash form.
|
||||
$keystore = $endpoint -replace '^https://', '' -replace '/$', ''
|
||||
|
||||
$jar = Join-Path $env:RUNNER_TEMP "jsign-$jsignVersion.jar"
|
||||
if (-not (Test-Path $jar)) {
|
||||
Invoke-WebRequest -Uri "https://github.com/ebourg/jsign/releases/download/$jsignVersion/jsign-$jsignVersion.jar" -OutFile $jar
|
||||
}
|
||||
$actualHash = (Get-FileHash -Algorithm SHA256 $jar).Hash
|
||||
if ($actualHash -ne $jsignSha256) {
|
||||
Remove-Item $jar -Force
|
||||
throw "jsign jar checksum mismatch: expected $jsignSha256, got $actualHash"
|
||||
}
|
||||
|
||||
# Short-lived bearer token from the azure/login OIDC session. Fetched per
|
||||
# invocation (signCommand runs once per file) so a long Rust build beforehand
|
||||
# can never leave us with an expired token. Passed to jsign via env, not argv.
|
||||
$env:JSIGN_STOREPASS = (az account get-access-token --resource https://codesigning.azure.net --query accessToken --output tsv)
|
||||
if (-not $env:JSIGN_STOREPASS) {
|
||||
throw "failed to acquire an Azure access token; is azure/login configured on this job?"
|
||||
}
|
||||
|
||||
Write-Host "Signing $resolved"
|
||||
java -jar $jar `
|
||||
--storetype TRUSTEDSIGNING `
|
||||
--keystore $keystore `
|
||||
--storepass env:JSIGN_STOREPASS `
|
||||
--alias "$account/$certProfile" `
|
||||
--alg SHA-256 `
|
||||
--tsaurl http://timestamp.acs.microsoft.com `
|
||||
--tsmode RFC3161 `
|
||||
--replace `
|
||||
$resolved
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "jsign failed for $resolved (exit $LASTEXITCODE)"
|
||||
}
|
||||
|
||||
$signature = Get-AuthenticodeSignature $resolved
|
||||
if ($signature.Status -ne "Valid") {
|
||||
throw "signature verification failed for ${resolved}: $($signature.Status) - $($signature.StatusMessage)"
|
||||
}
|
||||
Write-Host "Signed and verified: $resolved ($($signature.SignerCertificate.Subject))"
|
||||
@@ -0,0 +1,280 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { isClineAccountNotAuthenticatedResult } from "../webview/lib/cline-account-state";
|
||||
import {
|
||||
listClineGitHubRepositories,
|
||||
listClineIntegrations,
|
||||
resolveGitHubInstallUrl,
|
||||
} from "./commands-integrations";
|
||||
import type { SidecarContext } from "./types";
|
||||
|
||||
const getProviderSettingsMock = vi.hoisted(() => vi.fn());
|
||||
const resolveProviderApiKeyMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@cline/core")>("@cline/core");
|
||||
return {
|
||||
...actual,
|
||||
ProviderSettingsManager: class {
|
||||
getProviderSettings = getProviderSettingsMock;
|
||||
},
|
||||
RuntimeOAuthTokenManager: class {
|
||||
resolveProviderApiKey = resolveProviderApiKeyMock;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function createContext() {
|
||||
const capture = vi.fn();
|
||||
const ctx = {
|
||||
telemetry: { capture },
|
||||
logger: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
|
||||
} as unknown as SidecarContext;
|
||||
return { ctx, capture };
|
||||
}
|
||||
|
||||
const REQUEST_OPTIONS = {
|
||||
apiBaseUrl: "https://api.example.com",
|
||||
appBaseUrl: "https://app.example.com",
|
||||
authToken: "test-token",
|
||||
} as const;
|
||||
|
||||
function requestOptions(fetchImpl: ReturnType<typeof vi.fn>) {
|
||||
return {
|
||||
...REQUEST_OPTIONS,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getProviderSettingsMock.mockReset();
|
||||
resolveProviderApiKeyMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("listClineIntegrations", () => {
|
||||
it("lists integrations through the envelope with a bearer token", async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
jsonResponse({ success: true, data: [{ provider: "github" }] }),
|
||||
);
|
||||
|
||||
const result = await listClineIntegrations(requestOptions(fetchImpl));
|
||||
|
||||
expect(result).toEqual([{ provider: "github" }]);
|
||||
const [url, init] = fetchImpl.mock.calls[0] as [URL, RequestInit];
|
||||
expect(String(url)).toBe("https://api.example.com/api/v1/integrations");
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe(
|
||||
"Bearer test-token",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listClineGitHubRepositories", () => {
|
||||
it("lists GitHub repositories from the repositories endpoint", async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
jsonResponse({ success: true, data: [{ full_name: "cline/cline" }] }),
|
||||
);
|
||||
|
||||
const result = await listClineGitHubRepositories(requestOptions(fetchImpl));
|
||||
|
||||
expect(result).toEqual([{ full_name: "cline/cline" }]);
|
||||
expect(String(fetchImpl.mock.calls[0][0])).toBe(
|
||||
"https://api.example.com/api/v1/integrations/github/repositories",
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces the API envelope error message on failures", async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
jsonResponse(
|
||||
{ success: false, error: "failed to list integrations" },
|
||||
500,
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
listClineIntegrations(requestOptions(fetchImpl)),
|
||||
).rejects.toThrow("failed to list integrations");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveGitHubInstallUrl", () => {
|
||||
it("resolves the GitHub install URL from the redirect location", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
location: "https://github.com/apps/cline/installations/new?state=abc",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await resolveGitHubInstallUrl(requestOptions(fetchImpl));
|
||||
|
||||
expect(result).toEqual({
|
||||
url: "https://github.com/apps/cline/installations/new?state=abc",
|
||||
});
|
||||
const [url, init] = fetchImpl.mock.calls[0] as [URL, RequestInit];
|
||||
expect(url.origin + url.pathname).toBe(
|
||||
"https://api.example.com/api/v1/integrations/github/install",
|
||||
);
|
||||
// The post-install browser hop must land on the Cline dashboard.
|
||||
expect(url.searchParams.get("redirect")).toBe(
|
||||
"https://app.example.com/dashboard/integrations",
|
||||
);
|
||||
// The redirect must be read, not followed: the Location URL is the result.
|
||||
expect(init.redirect).toBe("manual");
|
||||
});
|
||||
|
||||
it("resolves a relative redirect location against the request URL", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: "//github.com/apps/cline/installations/new" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await resolveGitHubInstallUrl(requestOptions(fetchImpl));
|
||||
|
||||
// A bare relative Location would blow up later in the URL opener.
|
||||
expect(result).toEqual({
|
||||
url: "https://github.com/apps/cline/installations/new",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["https://evil.example/apps/cline", "evil.example"],
|
||||
["https://github.com.evil.example/apps/cline", "github.com.evil.example"],
|
||||
// Subdomains are not part of the install flow, so they are not allowed
|
||||
// either -- the host must be exactly github.com.
|
||||
["https://gist.github.com/apps/cline", "gist.github.com"],
|
||||
])("rejects a redirect to a non-GitHub host (%s)", async (location, host) => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response(null, { status: 302, headers: { location } }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
resolveGitHubInstallUrl(requestOptions(fetchImpl)),
|
||||
).rejects.toThrow(`unexpected host: ${host}`);
|
||||
});
|
||||
|
||||
it("rejects a redirect that does not use https", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: "http://github.com/apps/cline" },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
resolveGitHubInstallUrl(requestOptions(fetchImpl)),
|
||||
).rejects.toThrow("must use https");
|
||||
});
|
||||
|
||||
it("rejects a redirect location that is not a usable URL", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: "http://" },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
resolveGitHubInstallUrl(requestOptions(fetchImpl)),
|
||||
).rejects.toThrow("not a valid URL");
|
||||
});
|
||||
|
||||
it("throws when the install endpoint does not answer with a redirect", async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
jsonResponse({ error: "authentication required" }, 401),
|
||||
);
|
||||
|
||||
await expect(
|
||||
resolveGitHubInstallUrl(requestOptions(fetchImpl)),
|
||||
).rejects.toThrow("authentication required");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cline_integrations command auth states", () => {
|
||||
it("returns a typed not-authenticated result when signed out, without calling the API", async () => {
|
||||
const { ctx, capture } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue(null);
|
||||
getProviderSettingsMock.mockReturnValue(undefined);
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { handleCommand } = await import("./commands");
|
||||
const result = await handleCommand(ctx, "cline_integrations", {
|
||||
operation: "list",
|
||||
});
|
||||
|
||||
expect(isClineAccountNotAuthenticatedResult(result)).toBe(true);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls the Cline API with the resolved fresh token when signed in", async () => {
|
||||
const { ctx } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({
|
||||
apiKey: "fresh-token",
|
||||
refreshed: true,
|
||||
});
|
||||
getProviderSettingsMock.mockReturnValue(undefined);
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
jsonResponse({ success: true, data: [{ provider: "github" }] }),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { handleCommand } = await import("./commands");
|
||||
const result = await handleCommand(ctx, "cline_integrations", {
|
||||
operation: "list",
|
||||
});
|
||||
|
||||
expect(result).toEqual([{ provider: "github" }]);
|
||||
const [url, init] = fetchMock.mock.calls[0] as [URL, RequestInit];
|
||||
expect(String(url)).toContain("/api/v1/integrations");
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe(
|
||||
"Bearer fresh-token",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unknown operations", async () => {
|
||||
const { ctx } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({
|
||||
apiKey: "fresh-token",
|
||||
refreshed: true,
|
||||
});
|
||||
getProviderSettingsMock.mockReturnValue(undefined);
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { handleCommand } = await import("./commands");
|
||||
await expect(
|
||||
handleCommand(ctx, "cline_integrations", {
|
||||
operation: "dropIntegrations",
|
||||
}),
|
||||
).rejects.toThrow("Unsupported Cline integrations operation");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import type {
|
||||
ClineGitHubRepository,
|
||||
ClineIntegration,
|
||||
} from "../webview/lib/cline-integrations-types";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
|
||||
const GITHUB_INSTALL_HOST = "github.com";
|
||||
|
||||
function resolveInstallRedirect(location: string, requestUrl: URL): string {
|
||||
let resolved: URL;
|
||||
try {
|
||||
resolved = new URL(location, requestUrl);
|
||||
} catch {
|
||||
throw new Error(`GitHub install redirect is not a valid URL: ${location}`);
|
||||
}
|
||||
if (resolved.protocol !== "https:") {
|
||||
throw new Error(
|
||||
`GitHub install redirect must use https, got: ${resolved.protocol}`,
|
||||
);
|
||||
}
|
||||
if (resolved.hostname !== GITHUB_INSTALL_HOST) {
|
||||
throw new Error(
|
||||
`GitHub install redirect pointed at an unexpected host: ${resolved.hostname}`,
|
||||
);
|
||||
}
|
||||
return resolved.toString();
|
||||
}
|
||||
|
||||
export interface ClineIntegrationsRequestOptions {
|
||||
apiBaseUrl: string;
|
||||
/** Frontend origin the browser install flow returns to when it finishes. */
|
||||
appBaseUrl: string;
|
||||
authToken: string;
|
||||
requestTimeoutMs?: number;
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
export async function listClineIntegrations(
|
||||
options: ClineIntegrationsRequestOptions,
|
||||
): Promise<ClineIntegration[]> {
|
||||
const data = await requestClineApiJson("/api/v1/integrations", options);
|
||||
return Array.isArray(data) ? (data as ClineIntegration[]) : [];
|
||||
}
|
||||
|
||||
export async function listClineGitHubRepositories(
|
||||
options: ClineIntegrationsRequestOptions,
|
||||
): Promise<ClineGitHubRepository[]> {
|
||||
const data = await requestClineApiJson(
|
||||
"/api/v1/integrations/github/repositories",
|
||||
options,
|
||||
);
|
||||
return Array.isArray(data) ? (data as ClineGitHubRepository[]) : [];
|
||||
}
|
||||
|
||||
export async function resolveGitHubInstallUrl(
|
||||
options: ClineIntegrationsRequestOptions,
|
||||
): Promise<{ url: string }> {
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const installUrl = new URL(
|
||||
"/api/v1/integrations/github/install",
|
||||
options.apiBaseUrl,
|
||||
);
|
||||
installUrl.searchParams.set(
|
||||
"redirect",
|
||||
new URL("/dashboard/integrations", options.appBaseUrl).toString(),
|
||||
);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
options.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
);
|
||||
try {
|
||||
const response = await fetchImpl(installUrl, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${options.authToken}` },
|
||||
redirect: "manual",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const location = response.headers.get("location");
|
||||
if (response.status >= 300 && response.status < 400 && location?.trim()) {
|
||||
return { url: resolveInstallRedirect(location.trim(), installUrl) };
|
||||
}
|
||||
|
||||
const text = await response.text().catch(() => "");
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = text.trim() ? JSON.parse(text) : undefined;
|
||||
} catch {
|
||||
parsed = undefined;
|
||||
}
|
||||
throw new Error(formatRequestFailure(response.status, text, parsed));
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function getEnvelopeError(parsed: unknown): string | undefined {
|
||||
if (typeof parsed !== "object" || parsed === null || !("error" in parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
const error = (parsed as { error?: unknown }).error;
|
||||
return typeof error === "string" && error.trim() ? error : undefined;
|
||||
}
|
||||
|
||||
function formatRequestFailure(
|
||||
status: number,
|
||||
bodyText: string,
|
||||
parsed: unknown,
|
||||
): string {
|
||||
const envelopeError = getEnvelopeError(parsed);
|
||||
if (envelopeError) {
|
||||
return envelopeError;
|
||||
}
|
||||
const body = bodyText.trim();
|
||||
if (body) {
|
||||
const preview = body.length > 200 ? `${body.slice(0, 200)}...` : body;
|
||||
return `Cline integrations request failed with status ${status}: ${preview}`;
|
||||
}
|
||||
return `Cline integrations request failed with status ${status}`;
|
||||
}
|
||||
|
||||
async function requestClineApiJson(
|
||||
endpoint: string,
|
||||
options: ClineIntegrationsRequestOptions,
|
||||
): Promise<unknown> {
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
options.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
);
|
||||
try {
|
||||
const response = await fetchImpl(new URL(endpoint, options.apiBaseUrl), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${options.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
let parsed: unknown;
|
||||
if (text.trim()) {
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
formatRequestFailure(response.status, text, undefined),
|
||||
);
|
||||
}
|
||||
throw new Error("Cline integrations response was not valid JSON");
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(formatRequestFailure(response.status, text, parsed));
|
||||
}
|
||||
|
||||
if (typeof parsed === "object" && parsed !== null && "success" in parsed) {
|
||||
const envelope = parsed as {
|
||||
success?: unknown;
|
||||
error?: unknown;
|
||||
data?: unknown;
|
||||
};
|
||||
if (typeof envelope.success === "boolean") {
|
||||
if (!envelope.success) {
|
||||
throw new Error(
|
||||
getEnvelopeError(parsed) || "Cline integrations request failed",
|
||||
);
|
||||
}
|
||||
return envelope.data ?? null;
|
||||
}
|
||||
}
|
||||
return parsed ?? null;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,8 @@ import {
|
||||
import { resolveAudioTranscriptionRoute } from "@cline/llms";
|
||||
import {
|
||||
CLINE_DEFAULT_MODEL_ID,
|
||||
formatSessionSearchPreview,
|
||||
formatSessionSearchTitle,
|
||||
getClineEnvironmentConfig,
|
||||
isCanonicalBase64,
|
||||
ONE_TIME_SCHEDULE_CRON_PATTERN,
|
||||
@@ -59,6 +61,11 @@ import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
|
||||
import packageJson from "../package.json";
|
||||
import { CLINE_ACCOUNT_NOT_AUTHENTICATED_RESULT } from "../webview/lib/cline-account-state";
|
||||
import { MAX_RECORDED_AUDIO_BYTES } from "../webview/lib/voice-input-limits";
|
||||
import {
|
||||
listClineGitHubRepositories,
|
||||
listClineIntegrations,
|
||||
resolveGitHubInstallUrl,
|
||||
} from "./commands-integrations";
|
||||
import {
|
||||
connectorChannelsPayload,
|
||||
startConnectorChannel,
|
||||
@@ -534,6 +541,67 @@ async function listSessionsFromSidecarManager(
|
||||
.slice(0, max);
|
||||
}
|
||||
|
||||
async function withSearchDeadline<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error("Session search timed out")),
|
||||
timeoutMs,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function metadataSessionSearchHits(
|
||||
value: unknown,
|
||||
query: string,
|
||||
): JsonRecord[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const normalizedQuery = query.toLocaleLowerCase();
|
||||
return value.flatMap((item) => {
|
||||
if (!item || typeof item !== "object") return [];
|
||||
const session = item as JsonRecord;
|
||||
const metadata =
|
||||
session.metadata && typeof session.metadata === "object"
|
||||
? (session.metadata as JsonRecord)
|
||||
: {};
|
||||
const sessionId = String(session.sessionId ?? "").trim();
|
||||
if (!sessionId) return [];
|
||||
const rawTitle = String(
|
||||
metadata.title ?? session.title ?? session.prompt ?? sessionId,
|
||||
).trim();
|
||||
const prompt = String(session.prompt ?? metadata.prompt ?? "");
|
||||
const title = formatSessionSearchTitle(rawTitle) || sessionId;
|
||||
const workspaceRoot = String(session.workspaceRoot ?? session.cwd ?? "");
|
||||
const searchable = [rawTitle, prompt, workspaceRoot, session.model]
|
||||
.join("\n")
|
||||
.toLocaleLowerCase();
|
||||
if (!searchable.includes(normalizedQuery)) return [];
|
||||
return [
|
||||
{
|
||||
sessionId,
|
||||
documentId: `${sessionId}:metadata`,
|
||||
ordinal: -1,
|
||||
role: "session",
|
||||
startedAt: String(session.startedAt ?? session.createdAt ?? ""),
|
||||
workspaceRoot,
|
||||
title,
|
||||
snippet: formatSessionSearchPreview("session", prompt || title),
|
||||
score: 0,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Git helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -616,7 +684,15 @@ async function handleRoutineScheduleCommand(
|
||||
hubCommand: string,
|
||||
payload?: Record<string, unknown>,
|
||||
) => {
|
||||
const reply = await hubClient.command(hubCommand as never, payload);
|
||||
// The desktop app runs chats (and therefore agent-created schedules)
|
||||
// across many workspace folders, while this hub client is registered
|
||||
// against the app's own launch directory. Ask the hub for schedules
|
||||
// across all workspaces so the Schedules page manages every schedule
|
||||
// on this machine, not just the launch-directory scope.
|
||||
const reply = await hubClient.command(hubCommand as never, {
|
||||
...payload,
|
||||
allWorkspaces: true,
|
||||
});
|
||||
if (!reply.ok) {
|
||||
throw new Error(
|
||||
reply.error?.message ?? `hub command failed: ${hubCommand}`,
|
||||
@@ -1390,6 +1466,49 @@ export async function handleCommand(
|
||||
typeof args?.limit === "number" ? args.limit : 300,
|
||||
);
|
||||
}
|
||||
if (command === "search_sessions") {
|
||||
const query = String(args?.query ?? "").trim();
|
||||
if (!query) return [];
|
||||
const limit =
|
||||
typeof args?.limit === "number" && Number.isFinite(args.limit)
|
||||
? Math.max(1, Math.min(200, Math.trunc(args.limit)))
|
||||
: 50;
|
||||
const workspaceRoot =
|
||||
typeof args?.workspaceRoot === "string"
|
||||
? args.workspaceRoot.trim() || undefined
|
||||
: undefined;
|
||||
if (ctx.hubClient) {
|
||||
try {
|
||||
const reply = await withSearchDeadline(
|
||||
ctx.hubClient.command("session.search", {
|
||||
query,
|
||||
limit,
|
||||
workspaceRoot,
|
||||
}),
|
||||
750,
|
||||
);
|
||||
if (
|
||||
reply.ok &&
|
||||
Array.isArray(reply.payload?.hits) &&
|
||||
reply.payload.hits.length > 0
|
||||
) {
|
||||
return reply.payload.hits.slice(0, limit).map((hit) => ({
|
||||
...hit,
|
||||
title: formatSessionSearchTitle(hit.title),
|
||||
snippet: formatSessionSearchPreview(hit.role, hit.snippet),
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
// Fall back to metadata-only search when the index is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
const sessions = await withSearchDeadline(
|
||||
listSessionsFromSidecarManager(ctx, 500),
|
||||
1_000,
|
||||
).catch(() => []);
|
||||
return metadataSessionSearchHits(sessions, query).slice(0, limit);
|
||||
}
|
||||
if (command === "get_discovered_session") {
|
||||
const sessionId = String(args?.sessionId ?? args?.session_id ?? "").trim();
|
||||
if (!sessionId) throw new Error("session id is required");
|
||||
@@ -1603,6 +1722,37 @@ export async function handleCommand(
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Cline integrations (GitHub App) ────────────────────────────────
|
||||
if (command === "cline_integrations") {
|
||||
const operation = String(args?.operation ?? "").trim();
|
||||
if (!operation) throw new Error("operation is required");
|
||||
const manager = new ProviderSettingsManager();
|
||||
|
||||
const authToken = await resolveFreshClineAuthToken(ctx, manager);
|
||||
if (!authToken) {
|
||||
return CLINE_ACCOUNT_NOT_AUTHENTICATED_RESULT;
|
||||
}
|
||||
const settings = manager.getProviderSettings("cline");
|
||||
const environment = getClineEnvironmentConfig();
|
||||
const requestOptions = {
|
||||
apiBaseUrl: settings?.baseUrl?.trim() || environment.apiBaseUrl,
|
||||
appBaseUrl: environment.appBaseUrl,
|
||||
authToken,
|
||||
};
|
||||
switch (operation) {
|
||||
case "list":
|
||||
return await listClineIntegrations(requestOptions);
|
||||
case "listGitHubRepositories":
|
||||
return await listClineGitHubRepositories(requestOptions);
|
||||
case "githubInstallUrl":
|
||||
return await resolveGitHubInstallUrl(requestOptions);
|
||||
default:
|
||||
throw new Error(
|
||||
`Unsupported Cline integrations operation: ${operation}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Provider management ────────────────────────────────────────────
|
||||
if (command === "list_provider_catalog") {
|
||||
const manager = new ProviderSettingsManager();
|
||||
|
||||
@@ -210,6 +210,157 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
expect(connectMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("returns indexed search results without listing every session", async () => {
|
||||
const { handleCommand } = await import("./commands");
|
||||
const { createSidecarContext } = await import("./context");
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
const oversizedPrompt = `<user_input mode="act">${"generate an image ".repeat(3_000)}</user_input>`;
|
||||
const hits = [
|
||||
{
|
||||
sessionId: "session-1",
|
||||
documentId: "session-1:0",
|
||||
ordinal: 0,
|
||||
role: "user",
|
||||
startedAt: "2026-08-27T12:00:00.000Z",
|
||||
workspaceRoot: "/workspace/project",
|
||||
title: oversizedPrompt,
|
||||
snippet: oversizedPrompt,
|
||||
score: -1,
|
||||
},
|
||||
];
|
||||
const command = vi.fn(async () => ({ ok: true, payload: { hits } }));
|
||||
const list = vi.fn(async () => []);
|
||||
ctx.hubClient = { command } as never;
|
||||
ctx.sessionManager = { list } as never;
|
||||
|
||||
const results = (await handleCommand(ctx, "search_sessions", {
|
||||
query: "generate",
|
||||
})) as Array<{ title: string; snippet: string }>;
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({
|
||||
sessionId: "session-1",
|
||||
documentId: "session-1:0",
|
||||
}),
|
||||
]);
|
||||
expect(results[0]?.title.length).toBeLessThanOrEqual(240);
|
||||
expect(results[0]?.snippet.length).toBeLessThanOrEqual(480);
|
||||
expect(results[0]?.title).not.toContain("user_input");
|
||||
expect(results[0]?.snippet).not.toContain("user_input");
|
||||
expect(command).toHaveBeenCalledWith("session.search", {
|
||||
query: "generate",
|
||||
limit: 50,
|
||||
workspaceRoot: undefined,
|
||||
});
|
||||
expect(list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to session metadata while the index has no hits", async () => {
|
||||
const { handleCommand } = await import("./commands");
|
||||
const { createSidecarContext } = await import("./context");
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
const command = vi.fn(async () => ({ ok: true, payload: { hits: [] } }));
|
||||
const oversizedPrompt = `<user_input mode="act">${"generate an image ".repeat(3_000)}</user_input>`;
|
||||
const list = vi.fn(async () => [
|
||||
{
|
||||
sessionId: "session-1",
|
||||
startedAt: "2026-08-27T12:00:00.000Z",
|
||||
workspaceRoot: "/workspace/project",
|
||||
prompt: oversizedPrompt,
|
||||
metadata: { title: oversizedPrompt },
|
||||
},
|
||||
]);
|
||||
ctx.hubClient = { command } as never;
|
||||
ctx.sessionManager = { list } as never;
|
||||
|
||||
const results = (await handleCommand(ctx, "search_sessions", {
|
||||
query: "generate",
|
||||
})) as Array<{ title: string; snippet: string }>;
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({
|
||||
sessionId: "session-1",
|
||||
documentId: "session-1:metadata",
|
||||
}),
|
||||
]);
|
||||
expect(results[0]?.title.length).toBeLessThanOrEqual(240);
|
||||
expect(results[0]?.snippet.length).toBeLessThanOrEqual(480);
|
||||
expect(results[0]?.title).not.toContain("user_input");
|
||||
expect(results[0]?.snippet).not.toContain("user_input");
|
||||
expect(list).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("falls back to session metadata when the hub search call rejects", async () => {
|
||||
const { handleCommand } = await import("./commands");
|
||||
const { createSidecarContext } = await import("./context");
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
const command = vi.fn(async () => {
|
||||
throw new Error("hub connection lost");
|
||||
});
|
||||
const list = vi.fn(async () => [
|
||||
{
|
||||
sessionId: "session-1",
|
||||
startedAt: "2026-08-27T12:00:00.000Z",
|
||||
workspaceRoot: "/workspace/project",
|
||||
prompt: "generate an image of a puppy",
|
||||
metadata: { title: "generate an image of a puppy" },
|
||||
},
|
||||
]);
|
||||
ctx.hubClient = { command } as never;
|
||||
ctx.sessionManager = { list } as never;
|
||||
|
||||
const results = (await handleCommand(ctx, "search_sessions", {
|
||||
query: "generate",
|
||||
})) as Array<{ sessionId: string; documentId: string }>;
|
||||
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({
|
||||
sessionId: "session-1",
|
||||
documentId: "session-1:metadata",
|
||||
}),
|
||||
]);
|
||||
expect(command).toHaveBeenCalledOnce();
|
||||
expect(list).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("falls back to session metadata when the hub search call exceeds the deadline", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { handleCommand } = await import("./commands");
|
||||
const { createSidecarContext } = await import("./context");
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
// Never resolves: exercises the withSearchDeadline race timing out
|
||||
// rather than the hub call rejecting.
|
||||
const command = vi.fn(() => new Promise(() => {}));
|
||||
const list = vi.fn(async () => [
|
||||
{
|
||||
sessionId: "session-1",
|
||||
startedAt: "2026-08-27T12:00:00.000Z",
|
||||
workspaceRoot: "/workspace/project",
|
||||
prompt: "generate an image of a puppy",
|
||||
metadata: { title: "generate an image of a puppy" },
|
||||
},
|
||||
]);
|
||||
ctx.hubClient = { command } as never;
|
||||
ctx.sessionManager = { list } as never;
|
||||
|
||||
const pending = handleCommand(ctx, "search_sessions", {
|
||||
query: "generate",
|
||||
}) as Promise<Array<{ sessionId: string; documentId: string }>>;
|
||||
await vi.advanceTimersByTimeAsync(750);
|
||||
const results = await pending;
|
||||
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({
|
||||
sessionId: "session-1",
|
||||
documentId: "session-1:metadata",
|
||||
}),
|
||||
]);
|
||||
expect(command).toHaveBeenCalledOnce();
|
||||
expect(list).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("forwards raw hub tool updates to attached desktop sessions", async () => {
|
||||
const { createSidecarContext, handleHubLiveEvent } = await import(
|
||||
"./context"
|
||||
@@ -929,6 +1080,7 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
schedule: { scheduleId: "schedule-1", enabled: false },
|
||||
});
|
||||
expect(hubCommandMock).toHaveBeenCalledWith("schedule.disable", {
|
||||
allWorkspaces: true,
|
||||
scheduleId: "schedule-1",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -114,23 +114,25 @@ export function syncSidecarApprovalReadiness(
|
||||
ctx: SidecarContext,
|
||||
): Promise<void> {
|
||||
const previous = approvalReadinessUpdates.get(ctx) ?? Promise.resolve();
|
||||
const update = previous.catch(() => undefined).then(async () => {
|
||||
const hubClient = ctx.hubClient;
|
||||
if (!hubClient) return;
|
||||
await hubClient.updateCapabilities(
|
||||
[...ctx.wsClients].some(
|
||||
(client) => client.data?.canApproveTools === true,
|
||||
)
|
||||
? [
|
||||
{
|
||||
name: HUB_CLIENT_TOOL_APPROVAL_CAPABILITY,
|
||||
description:
|
||||
"Cline Code has a live user surface for tool review.",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
});
|
||||
const update = previous
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
const hubClient = ctx.hubClient;
|
||||
if (!hubClient) return;
|
||||
await hubClient.updateCapabilities(
|
||||
[...ctx.wsClients].some(
|
||||
(client) => client.data?.canApproveTools === true,
|
||||
)
|
||||
? [
|
||||
{
|
||||
name: HUB_CLIENT_TOOL_APPROVAL_CAPABILITY,
|
||||
description:
|
||||
"Cline Code has a live user surface for tool review.",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
});
|
||||
approvalReadinessUpdates.set(ctx, update);
|
||||
return update.finally(() => {
|
||||
if (approvalReadinessUpdates.get(ctx) === update) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { homedir } from "node:os";
|
||||
import {
|
||||
createClineTelemetryServiceConfig,
|
||||
readGlobalSettings,
|
||||
setHomeDirIfUnset,
|
||||
setModelToolEnabledGlobally,
|
||||
watchManagedHubBuildMismatch,
|
||||
} from "@cline/core";
|
||||
import { captureSdkError, claimHubDaemonProcess } from "@cline/shared";
|
||||
@@ -65,6 +67,20 @@ async function main() {
|
||||
pid: process.pid,
|
||||
});
|
||||
|
||||
// Web search is opt-in elsewhere in Cline, but the desktop app defaults
|
||||
// it to on. Seed the shared setting only when the user has never set it,
|
||||
// so an explicit off (from any Cline app) stays off. Best-effort: an
|
||||
// unwritable settings file must not block startup over a default.
|
||||
try {
|
||||
if (readGlobalSettings().tools?.web_search === undefined) {
|
||||
setModelToolEnabledGlobally("web_search", true);
|
||||
}
|
||||
} catch (error) {
|
||||
observability.logger.error?.("Failed to seed web search default", {
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
prewarmWorkspaceMetadata(workspaceRoot);
|
||||
observability.logger.log(
|
||||
"Login shell PATH resolution",
|
||||
|
||||
@@ -112,6 +112,12 @@ struct UpdateState {
|
||||
// concurrently and the later one can overwrite a freshly staged "ready"
|
||||
// with "idle"/"error" decided from its stale pre-await snapshot.
|
||||
cycle: tokio::sync::Mutex<()>,
|
||||
// Windows only: the downloaded-but-not-installed update. On Windows,
|
||||
// Update::install launches the NSIS installer and std::process::exit(0)s
|
||||
// immediately, so installation must wait for the user-initiated restart
|
||||
// instead of running inside the background cycle like it does on macOS.
|
||||
#[cfg(windows)]
|
||||
pending_install: Mutex<Option<(tauri_plugin_updater::Update, Vec<u8>)>>,
|
||||
}
|
||||
|
||||
impl UpdateState {
|
||||
@@ -224,12 +230,30 @@ async fn check_and_install_update(app: &tauri::AppHandle, state: &UpdateState) {
|
||||
return;
|
||||
}
|
||||
set_update_status(app, state, "downloading", Some(version.clone()), None);
|
||||
// macOS: install right away — it only swaps the .app on disk and
|
||||
// the running app keeps going until the user restarts. Windows:
|
||||
// download only, because install() launches the NSIS installer
|
||||
// and exits the process on the spot; the staged bytes are
|
||||
// installed by restart_to_apply_update instead.
|
||||
#[cfg(not(windows))]
|
||||
match update.download_and_install(|_, _| {}, || {}).await {
|
||||
Ok(()) => set_update_status(app, state, "ready", Some(version), None),
|
||||
Err(error) => {
|
||||
set_update_status(app, state, "error", Some(version), Some(error.to_string()))
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
match update.download(|_, _| {}, || {}).await {
|
||||
Ok(bytes) => {
|
||||
if let Ok(mut pending) = state.pending_install.lock() {
|
||||
*pending = Some((update, bytes));
|
||||
}
|
||||
set_update_status(app, state, "ready", Some(version), None);
|
||||
}
|
||||
Err(error) => {
|
||||
set_update_status(app, state, "error", Some(version), Some(error.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
if ready_version.is_none() {
|
||||
@@ -285,8 +309,15 @@ impl DesktopBackendState {
|
||||
// exits itself, finishing session persistence as an orphan.
|
||||
#[cfg(unix)]
|
||||
let _ = Command::new("kill").arg(child.id().to_string()).status();
|
||||
// Windows has no SIGTERM equivalent, so terminate outright.
|
||||
// Reap the child too: TerminateProcess is quick, and the
|
||||
// update-restart path needs the sidecar exe's file lock
|
||||
// released before the NSIS installer replaces it.
|
||||
#[cfg(not(unix))]
|
||||
let _ = child.kill();
|
||||
{
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
*process_guard = None;
|
||||
}
|
||||
@@ -314,13 +345,29 @@ struct DesktopBackendReadyLine {
|
||||
mode: Option<String>,
|
||||
}
|
||||
|
||||
/// The release binary is a GUI-subsystem app (no console), so on Windows
|
||||
/// every console-subsystem child (git, cmd, the sidecar) would otherwise
|
||||
/// allocate its own visible console window. Piped stdio does not prevent
|
||||
/// that; only CREATE_NO_WINDOW does.
|
||||
#[cfg(windows)]
|
||||
fn hide_console_window(command: &mut Command) {
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn hide_console_window(_command: &mut Command) {}
|
||||
|
||||
fn resolve_workspace_root(launch_cwd: &str) -> String {
|
||||
let output = Command::new("git")
|
||||
let mut command = Command::new("git");
|
||||
command
|
||||
.arg("-C")
|
||||
.arg(launch_cwd)
|
||||
.arg("rev-parse")
|
||||
.arg("--show-toplevel")
|
||||
.output();
|
||||
.arg("--show-toplevel");
|
||||
hide_console_window(&mut command);
|
||||
let output = command.output();
|
||||
|
||||
match output {
|
||||
Ok(result) if result.status.success() => {
|
||||
@@ -438,7 +485,9 @@ fn spawn_desktop_backend_process(context: &AppContext) -> Result<Child, String>
|
||||
command
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
hide_console_window(&mut command);
|
||||
command
|
||||
.spawn()
|
||||
.map_err(|e| format!("failed to start desktop backend sidecar: {e}"))
|
||||
}
|
||||
@@ -561,7 +610,11 @@ fn resolve_mcp_settings_path() -> Result<PathBuf, String> {
|
||||
return Ok(PathBuf::from(trimmed));
|
||||
}
|
||||
}
|
||||
let home = std::env::var("HOME").map_err(|_| "HOME is not set".to_string())?;
|
||||
// USERPROFILE is the Windows equivalent of HOME (and what the sidecar's
|
||||
// homedir() resolves there); HOME is usually unset on Windows.
|
||||
let home = std::env::var("HOME")
|
||||
.or_else(|_| std::env::var("USERPROFILE"))
|
||||
.map_err(|_| "neither HOME nor USERPROFILE is set".to_string())?;
|
||||
Ok(PathBuf::from(home)
|
||||
.join(".cline")
|
||||
.join("data")
|
||||
@@ -585,8 +638,10 @@ fn open_path_with_default_app(path: &Path) -> Result<(), String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let path_arg = path.to_string_lossy().to_string();
|
||||
let status = Command::new("cmd")
|
||||
.args(["/C", "start", "", &path_arg])
|
||||
let mut command = Command::new("cmd");
|
||||
command.args(["/C", "start", "", &path_arg]);
|
||||
hide_console_window(&mut command);
|
||||
let status = command
|
||||
.status()
|
||||
.map_err(|e| format!("failed to open path: {e}"))?;
|
||||
if status.success() {
|
||||
@@ -673,10 +728,30 @@ fn get_update_status(update_state: State<'_, Arc<UpdateState>>) -> UpdateStatus
|
||||
fn restart_to_apply_update(
|
||||
app: tauri::AppHandle,
|
||||
backend_state: State<'_, Arc<DesktopBackendState>>,
|
||||
update_state: State<'_, Arc<UpdateState>>,
|
||||
) {
|
||||
// restart() never returns, so the run-loop Exit handler does not get a
|
||||
// chance to stop the sidecar; shut it down explicitly first.
|
||||
// Neither restart() nor install() returns, so the run-loop Exit handler
|
||||
// does not get a chance to stop the sidecar; shut it down explicitly
|
||||
// first. On Windows this also releases the sidecar exe's file lock,
|
||||
// which the NSIS installer needs in order to replace it.
|
||||
backend_state.stop();
|
||||
// Windows: install the bytes staged by the background cycle. install()
|
||||
// launches the NSIS installer (which relaunches the app when done) and
|
||||
// exits this process, so it only returns on failure — fall through to a
|
||||
// plain restart of the current version in that case.
|
||||
#[cfg(windows)]
|
||||
if let Some((update, bytes)) = update_state
|
||||
.pending_install
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut pending| pending.take())
|
||||
{
|
||||
if let Err(error) = update.install(bytes) {
|
||||
eprintln!("[updater] failed to launch the update installer: {error}");
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
let _ = update_state;
|
||||
app.restart();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Cline",
|
||||
"version": "0.0.19",
|
||||
"version": "0.0.21",
|
||||
"identifier": "bot.cline.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ImagePlus, Loader2 } from "lucide-react";
|
||||
import { AttachmentDropZone } from "@cline/ui";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import {
|
||||
useCallback,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
import { AgentHeader } from "@/components/agent-header";
|
||||
import { AgentSidebar } from "@/components/agent-sidebar";
|
||||
import { HubUpdateRequiredDialog } from "@/components/hub-update-required-dialog";
|
||||
import { SessionCommandBar } from "@/components/session-command-bar";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -36,6 +38,11 @@ import { WelcomeScreen } from "@/components/views/chat/welcome-chat";
|
||||
import { WelcomeSetupNotice } from "@/components/views/chat/welcome-setup-notice";
|
||||
import type { OnboardingStep } from "@/components/views/onboarding/onboarding-view";
|
||||
import type { SettingsSection } from "@/components/views/settings/sections";
|
||||
import {
|
||||
WindowTitleBar,
|
||||
WindowTitleBarContent,
|
||||
WindowTitleBarProvider,
|
||||
} from "@/components/window-title-bar";
|
||||
import { AccountProvider } from "@/contexts/account-context";
|
||||
import { WorkspaceProvider } from "@/contexts/workspace-context";
|
||||
import { useAppUpdate } from "@/hooks/use-app-update";
|
||||
@@ -165,6 +172,9 @@ export default function Home() {
|
||||
// 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 [commandBarOpen, setCommandBarOpen] = useState(false);
|
||||
// Shared by the sidebar search icon and the Cmd/Ctrl+P shortcut.
|
||||
const handleOpenCommandBar = useCallback(() => setCommandBarOpen(true), []);
|
||||
// "welcome" for the full first-run flow; "connect" when re-entered from
|
||||
// the in-app "connect a model" notice, which should land directly on the
|
||||
// provider setup step.
|
||||
@@ -312,8 +322,8 @@ export default function Home() {
|
||||
},
|
||||
[navigateWith],
|
||||
);
|
||||
// Standard app shortcuts: Cmd/Ctrl+N for a new session, Cmd/Ctrl+, for
|
||||
// settings — matching the tray menu actions.
|
||||
// Standard app shortcuts: Cmd/Ctrl+P for session search, Cmd/Ctrl+N for a
|
||||
// new session, and Cmd/Ctrl+, for settings.
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (showOnboarding) {
|
||||
@@ -325,6 +335,9 @@ export default function Home() {
|
||||
if (event.key === "n" || event.key === "N") {
|
||||
event.preventDefault();
|
||||
handleNewThread();
|
||||
} else if (event.key === "p" || event.key === "P") {
|
||||
event.preventDefault();
|
||||
setCommandBarOpen((current) => !current);
|
||||
} else if (event.key === ",") {
|
||||
event.preventDefault();
|
||||
handleViewChange("settings");
|
||||
@@ -420,98 +433,117 @@ export default function Home() {
|
||||
return (
|
||||
<AccountProvider>
|
||||
<SidebarProvider>
|
||||
<div
|
||||
aria-hidden={showOnboarding ? true : undefined}
|
||||
className="flex h-screen w-full overflow-hidden bg-background text-foreground"
|
||||
// The onboarding overlay is opaque and sits on top of the whole
|
||||
// shell; hiding the shell keeps its aurora + animations from
|
||||
// being composited every frame underneath while it still mounts
|
||||
// and loads (providers, history, transport) in the background.
|
||||
// `inert` additionally keeps the covered controls out of the
|
||||
// keyboard tab order and assistive tech while it is hidden.
|
||||
inert={showOnboarding ? true : undefined}
|
||||
style={showOnboarding ? { visibility: "hidden" } : undefined}
|
||||
<WindowTitleBarProvider
|
||||
contentEnabled={!showOnboarding && view === "chat"}
|
||||
>
|
||||
<Sidebar
|
||||
className="border-r border-sidebar-border"
|
||||
collapsible="icon"
|
||||
<div
|
||||
aria-hidden={showOnboarding ? true : undefined}
|
||||
className="flex h-screen w-full overflow-hidden bg-background text-foreground"
|
||||
// The onboarding overlay is opaque and sits on top of the whole
|
||||
// shell; hiding the shell keeps its aurora + animations from
|
||||
// being composited every frame underneath while it still mounts
|
||||
// and loads (providers, history, transport) in the background.
|
||||
// `inert` additionally keeps the covered controls out of the
|
||||
// keyboard tab order and assistive tech while it is hidden.
|
||||
inert={showOnboarding ? true : undefined}
|
||||
style={showOnboarding ? { visibility: "hidden" } : undefined}
|
||||
>
|
||||
<AgentSidebar
|
||||
activeSessionId={activeHistorySessionId}
|
||||
newTaskActive={newTaskActive}
|
||||
onHome={handleHome}
|
||||
onNavigateBack={handleNavigateBack}
|
||||
onNavigateForward={handleNavigateForward}
|
||||
onSettingsSectionChange={handleSettingsSectionChange}
|
||||
sessionHistory={sessionHistory}
|
||||
setView={handleViewChange}
|
||||
settingsSection={settingsSection}
|
||||
view={view}
|
||||
canNavigateBack={navigation.back.length > 0}
|
||||
canNavigateForward={navigation.forward.length > 0}
|
||||
/>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
|
||||
<SidebarTrigger className="absolute left-20 top-0 z-40 md:hidden" />
|
||||
{view === "sessions" ? (
|
||||
<SessionsView
|
||||
<Sidebar
|
||||
className="border-r border-sidebar-border"
|
||||
collapsible="icon"
|
||||
>
|
||||
<AgentSidebar
|
||||
activeSessionId={activeHistorySessionId}
|
||||
history={sessionHistory}
|
||||
newTaskActive={newTaskActive}
|
||||
onHome={handleHome}
|
||||
onNavigateBack={handleNavigateBack}
|
||||
onNavigateForward={handleNavigateForward}
|
||||
onOpenSearch={handleOpenCommandBar}
|
||||
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}
|
||||
initialPromptDraft={activeThread.initialPromptDraft}
|
||||
knownWorkspacePaths={historyWorkspacePaths}
|
||||
onInitialPromptDraftConsumed={
|
||||
handleInitialPromptDraftConsumed
|
||||
}
|
||||
onUpdateSessionMetadata={handleUpdateSessionMetadata}
|
||||
threadId={activeThread.id}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
onNewThread={handleNewThread}
|
||||
onOpenSession={handleOpenSession}
|
||||
onOpenSessionById={handleOpenSessionById}
|
||||
onOpenSetup={handleOpenSetup}
|
||||
onOpenModelSettings={() =>
|
||||
handleSettingsSectionChange("Models")
|
||||
}
|
||||
parentSession={activeParentSession}
|
||||
onOpenVoiceInputSettings={() =>
|
||||
handleSettingsSectionChange("Voice")
|
||||
}
|
||||
onThreadStarted={handleThreadStarted}
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
|
||||
<SidebarTrigger className="absolute left-20 top-0 z-40 md:hidden" />
|
||||
<WindowTitleBar />
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{view === "sessions" ? (
|
||||
<SessionsView
|
||||
activeSessionId={activeHistorySessionId}
|
||||
history={sessionHistory}
|
||||
/>
|
||||
) : 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}
|
||||
initialPromptDraft={activeThread.initialPromptDraft}
|
||||
knownWorkspacePaths={historyWorkspacePaths}
|
||||
onInitialPromptDraftConsumed={
|
||||
handleInitialPromptDraftConsumed
|
||||
}
|
||||
onUpdateSessionMetadata={handleUpdateSessionMetadata}
|
||||
threadId={activeThread.id}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
onNewThread={handleNewThread}
|
||||
onOpenSession={handleOpenSession}
|
||||
onOpenSessionById={handleOpenSessionById}
|
||||
onOpenSetup={handleOpenSetup}
|
||||
onOpenModelSettings={() =>
|
||||
handleSettingsSectionChange("Models")
|
||||
}
|
||||
parentSession={activeParentSession}
|
||||
onOpenVoiceInputSettings={() =>
|
||||
handleSettingsSectionChange("Voice")
|
||||
}
|
||||
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}
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</div>
|
||||
{showOnboarding ? (
|
||||
<div className="fixed inset-0 z-50 bg-background">
|
||||
<WindowTitleBar
|
||||
className="absolute inset-x-0 top-0 z-10"
|
||||
hostContent={false}
|
||||
/>
|
||||
<div className="h-full">
|
||||
<OnboardingView
|
||||
initialStep={onboardingInitialStep}
|
||||
onComplete={completeOnboarding}
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
) : null}
|
||||
</WindowTitleBarProvider>
|
||||
</SidebarProvider>
|
||||
{showOnboarding ? (
|
||||
<div className="fixed inset-0 z-50">
|
||||
<OnboardingView
|
||||
initialStep={onboardingInitialStep}
|
||||
onComplete={completeOnboarding}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<HubUpdateRequiredDialog />
|
||||
<SessionCommandBar
|
||||
onOpenChange={setCommandBarOpen}
|
||||
onOpenSession={handleOpenSessionById}
|
||||
open={commandBarOpen && !showOnboarding}
|
||||
/>
|
||||
</AccountProvider>
|
||||
);
|
||||
}
|
||||
@@ -606,8 +638,6 @@ function ChatThreadPane({
|
||||
promptInputRef.current = value;
|
||||
}, []);
|
||||
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);
|
||||
@@ -1257,52 +1287,6 @@ function ChatThreadPane({
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 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 = useMemo(
|
||||
() =>
|
||||
pendingAttachments.map((file, index) => ({
|
||||
@@ -1526,6 +1510,7 @@ function ChatThreadPane({
|
||||
const composer = (
|
||||
<ChatInputBar
|
||||
attachments={attachmentList}
|
||||
hasRunningAgents={agentActivity.running > 0}
|
||||
onAbort={handleAbort}
|
||||
onAttachFiles={handleAttachFiles}
|
||||
onListGitBranches={listGitBranches}
|
||||
@@ -1558,55 +1543,41 @@ 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
|
||||
{/* Requires `dragDropEnabled: false` on the Tauri window so the native shell does not swallow OS file drags. */}
|
||||
<AttachmentDropZone
|
||||
className={
|
||||
isWelcomeState
|
||||
? "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"
|
||||
? "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-[minmax(0,1fr)_auto] overflow-hidden"
|
||||
}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onAttachFiles={handleAttachFiles}
|
||||
>
|
||||
{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="cline-view-enter z-20 border-b border-border/70 bg-background/85 backdrop-blur-sm">
|
||||
<AgentHeader
|
||||
agentActivity={agentActivity}
|
||||
agents={agents}
|
||||
agentsError={agentsError}
|
||||
agentsLoading={agentsLoading}
|
||||
onAgentsOpenChange={setAgentPanelOpen}
|
||||
onOpenAgentSession={onOpenAgentSession}
|
||||
onOpenParentSession={onOpenSessionById}
|
||||
parentSession={hideDeletedSessionUi ? undefined : parentSession}
|
||||
canEditTitle={Boolean(activeSessionForTitle)}
|
||||
canDeleteSession={Boolean(activeSessionToDelete)}
|
||||
deletingSession={deletingSession}
|
||||
diff={headerDiff}
|
||||
onDeleteSession={requestDeleteSession}
|
||||
onNewThread={onNewThread}
|
||||
onOpenDiff={handleOpenDiff}
|
||||
onRenameTitle={handleRenameTitle}
|
||||
renamingTitle={renamingSession}
|
||||
status={status}
|
||||
title={threadTitle}
|
||||
/>
|
||||
</div>
|
||||
<WindowTitleBarContent>
|
||||
<div className="cline-view-enter z-20 border-b border-border/70 bg-background/85 backdrop-blur-sm">
|
||||
<AgentHeader
|
||||
agentActivity={agentActivity}
|
||||
agents={agents}
|
||||
agentsError={agentsError}
|
||||
agentsLoading={agentsLoading}
|
||||
onAgentsOpenChange={setAgentPanelOpen}
|
||||
onOpenAgentSession={onOpenAgentSession}
|
||||
onOpenParentSession={onOpenSessionById}
|
||||
parentSession={hideDeletedSessionUi ? undefined : parentSession}
|
||||
canEditTitle={Boolean(activeSessionForTitle)}
|
||||
canDeleteSession={Boolean(activeSessionToDelete)}
|
||||
deletingSession={deletingSession}
|
||||
diff={headerDiff}
|
||||
onDeleteSession={requestDeleteSession}
|
||||
onNewThread={onNewThread}
|
||||
onOpenDiff={handleOpenDiff}
|
||||
onRenameTitle={handleRenameTitle}
|
||||
renamingTitle={renamingSession}
|
||||
status={status}
|
||||
title={threadTitle}
|
||||
/>
|
||||
</div>
|
||||
</WindowTitleBarContent>
|
||||
) : null}
|
||||
<WelcomeScreen
|
||||
active={isWelcomeState}
|
||||
@@ -1655,7 +1626,7 @@ function ChatThreadPane({
|
||||
onOpenSession={onOpenSessionById}
|
||||
onSwitchGitBranch={switchGitBranch}
|
||||
/>
|
||||
</div>
|
||||
</AttachmentDropZone>
|
||||
<AlertDialog
|
||||
open={deleteConfirmOpen}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
@@ -22,17 +22,17 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("AgentHeader title bar", () => {
|
||||
it("makes non-interactive header space draggable", async () => {
|
||||
it("leaves drag-region ownership to the persistent window title bar", async () => {
|
||||
await act(async () => {
|
||||
root.render(<AgentHeader status="completed" title="A session" />);
|
||||
});
|
||||
|
||||
expect(
|
||||
container.querySelector("header")?.getAttribute("data-tauri-drag-region"),
|
||||
).toBe("deep");
|
||||
container.querySelector("header")?.hasAttribute("data-tauri-drag-region"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("renders a read-only title as draggable text", async () => {
|
||||
it("renders a read-only title as non-interactive text", async () => {
|
||||
await act(async () => {
|
||||
root.render(<AgentHeader status="completed" title="Read-only session" />);
|
||||
});
|
||||
|
||||
@@ -123,10 +123,7 @@ function AgentHeaderImpl({
|
||||
const triggerDeleteSession = () => onDeleteSession?.();
|
||||
|
||||
return (
|
||||
<header
|
||||
className="flex h-12 items-center justify-between gap-2 px-4 max-md:h-7 max-md:pl-28 md:group-data-[state=collapsed]/sidebar-wrapper:pl-7"
|
||||
data-tauri-drag-region="deep"
|
||||
>
|
||||
<header className="flex h-12 items-center justify-between gap-2 px-4 max-md:h-7 max-md:pl-28 md:group-data-[state=collapsed]/sidebar-wrapper:pl-7">
|
||||
{/* Left: thread title */}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<SessionStatus
|
||||
|
||||
@@ -88,20 +88,6 @@ async function hover(element: Element): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function changeField(
|
||||
element: HTMLInputElement | HTMLTextAreaElement,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
await act(async () => {
|
||||
const prototype = Object.getPrototypeOf(element) as object;
|
||||
const setter = Object.getOwnPropertyDescriptor(prototype, "value")?.set;
|
||||
setter?.call(element, value);
|
||||
element.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
element.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function buttonWithText(text: string, rootNode: ParentNode = container) {
|
||||
const button = [
|
||||
...rootNode.querySelectorAll<HTMLButtonElement>("button"),
|
||||
@@ -321,7 +307,10 @@ describe("AgentSidebar session organization", () => {
|
||||
|
||||
it("deletes a session through the row's hover trash button", async () => {
|
||||
const deleteThread = vi.fn(async () => undefined);
|
||||
const sessionHistory = makeSessionHistory([makeThread("alpha", 1)], vi.fn());
|
||||
const sessionHistory = makeSessionHistory(
|
||||
[makeThread("alpha", 1)],
|
||||
vi.fn(),
|
||||
);
|
||||
(sessionHistory as { deleteThread: unknown }).deleteThread = deleteThread;
|
||||
|
||||
await act(async () => {
|
||||
@@ -996,9 +985,7 @@ describe("AgentSidebar session organization", () => {
|
||||
expect(customizeRow.className.split(" ")).toContain(
|
||||
"bg-surface-hover-lighter",
|
||||
);
|
||||
expect(customizeRow.className.split(" ")).not.toContain(
|
||||
"bg-surface-hover",
|
||||
);
|
||||
expect(customizeRow.className.split(" ")).not.toContain("bg-surface-hover");
|
||||
// Sub-tabs are indented under the parent row.
|
||||
expect(installedRow.className.split(" ")).toContain("pl-8!");
|
||||
|
||||
@@ -1048,11 +1035,12 @@ describe("AgentSidebar session organization", () => {
|
||||
expect(inactiveRow.getAttribute("aria-current")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens session search in a dialog from the logo-row icon", async () => {
|
||||
it("opens the global search command bar from the logo-row icon without loading full history", async () => {
|
||||
const sessionHistory = makeSessionHistory(
|
||||
[makeThread("alpha", 1), makeThread("beta", 1)],
|
||||
vi.fn(),
|
||||
);
|
||||
const onOpenSearch = vi.fn();
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AccountProvider>
|
||||
@@ -1060,6 +1048,7 @@ describe("AgentSidebar session organization", () => {
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
onHome={vi.fn()}
|
||||
onOpenSearch={onOpenSearch}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={sessionHistory}
|
||||
setView={vi.fn()}
|
||||
@@ -1078,33 +1067,12 @@ describe("AgentSidebar session organization", () => {
|
||||
);
|
||||
expect(searchButton).not.toBeNull();
|
||||
await click(searchButton as Element);
|
||||
// Opening search pulls the full history so unloaded sessions match too.
|
||||
expect(sessionHistory.loadAllSessions).toHaveBeenCalledOnce();
|
||||
|
||||
const searchInput = await vi.waitFor(() => {
|
||||
const input = document.querySelector<HTMLInputElement>(
|
||||
'[data-slot="command-input"]',
|
||||
);
|
||||
expect(input).not.toBeNull();
|
||||
return input as HTMLInputElement;
|
||||
});
|
||||
expect(searchInput.placeholder).toBe("Search sessions...");
|
||||
|
||||
await changeField(searchInput, "alpha");
|
||||
const match = await vi.waitFor(() => {
|
||||
const items = [
|
||||
...document.querySelectorAll<HTMLElement>('[data-slot="command-item"]'),
|
||||
];
|
||||
expect(items).toHaveLength(1);
|
||||
return items[0] as HTMLElement;
|
||||
});
|
||||
expect(match.textContent).toContain("alpha session 1");
|
||||
|
||||
await click(match);
|
||||
expect(sessionHistory.openThread).toHaveBeenCalledWith("alpha-1");
|
||||
await vi.waitFor(() =>
|
||||
expect(document.querySelector('[data-slot="command-input"]')).toBeNull(),
|
||||
);
|
||||
// The icon opens the indexed command bar owned by the page shell...
|
||||
expect(onOpenSearch).toHaveBeenCalledOnce();
|
||||
// ...instead of a sidebar-local dialog that eagerly pulled the entire
|
||||
// session history just to filter titles client-side.
|
||||
expect(sessionHistory.loadAllSessions).not.toHaveBeenCalled();
|
||||
expect(document.querySelector('[data-slot="command-input"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("uses only the Cline logo for home in the collapsed sidebar", async () => {
|
||||
|
||||
@@ -46,13 +46,6 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@@ -255,6 +248,7 @@ export function AgentSidebar({
|
||||
onHome,
|
||||
onNavigateBack,
|
||||
onNavigateForward,
|
||||
onOpenSearch,
|
||||
onSettingsSectionChange,
|
||||
setView,
|
||||
settingsSection,
|
||||
@@ -269,6 +263,8 @@ export function AgentSidebar({
|
||||
onHome: () => void;
|
||||
onNavigateBack?: () => void;
|
||||
onNavigateForward?: () => void;
|
||||
/** Opens the global session search command bar (also bound to Cmd/Ctrl+P). */
|
||||
onOpenSearch?: () => void;
|
||||
onSettingsSectionChange: (section: SettingsSection) => void;
|
||||
setView: (view: AppView) => void;
|
||||
settingsSection: SettingsSection;
|
||||
@@ -291,7 +287,6 @@ export function AgentSidebar({
|
||||
forkThread: forkHistoryThread,
|
||||
hasLoadedHistory,
|
||||
isLoadingMore,
|
||||
loadAllSessions,
|
||||
loadOlderSessions,
|
||||
mayHaveMoreSessions,
|
||||
openThread: openHistoryThread,
|
||||
@@ -305,7 +300,6 @@ export function AgentSidebar({
|
||||
const [filter, setFilter] = useState<FilterOption>("All");
|
||||
const [sourceFilter, setSourceFilter] = useState(ALL_SESSION_SOURCES);
|
||||
const [sortMode, setSortMode] = useState<SidebarSortMode>("time");
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [showMoreCount, setShowMoreCount] = useState(
|
||||
INITIAL_VISIBLE_THREAD_COUNT,
|
||||
);
|
||||
@@ -422,20 +416,6 @@ export function AgentSidebar({
|
||||
const navigateForward = useCallback(() => {
|
||||
onNavigateForward?.();
|
||||
}, [onNavigateForward]);
|
||||
const openSearch = useCallback(() => {
|
||||
setSearchOpen(true);
|
||||
// The sidebar only pages in recent history; pull the rest so older
|
||||
// sessions are searchable too.
|
||||
void loadAllSessions();
|
||||
}, [loadAllSessions]);
|
||||
const openSearchResult = useCallback(
|
||||
(threadId: string) => {
|
||||
setSearchOpen(false);
|
||||
openThread(threadId);
|
||||
},
|
||||
[openThread],
|
||||
);
|
||||
|
||||
const startRenameThread = useCallback((thread: Thread) => {
|
||||
setEditingSessionId(thread.id);
|
||||
setEditingTitle(normalizeTitle(thread.title));
|
||||
@@ -840,8 +820,8 @@ export function AgentSidebar({
|
||||
<Button
|
||||
aria-label="Search sessions"
|
||||
className="size-8 shrink-0 justify-center px-0"
|
||||
onClick={openSearch}
|
||||
title="Search sessions"
|
||||
onClick={onOpenSearch}
|
||||
title="Search sessions (Cmd/Ctrl+P)"
|
||||
type="button"
|
||||
variant="sidebarItem"
|
||||
>
|
||||
@@ -1210,35 +1190,6 @@ export function AgentSidebar({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<CommandDialog
|
||||
description="Search sessions by title, project, or path"
|
||||
onOpenChange={setSearchOpen}
|
||||
open={searchOpen}
|
||||
title="Search sessions"
|
||||
>
|
||||
<CommandInput placeholder="Search sessions..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoadingMore
|
||||
? "Searching older sessions..."
|
||||
: "No sessions found."}
|
||||
</CommandEmpty>
|
||||
{threads.map((thread) => (
|
||||
<CommandItem
|
||||
key={thread.id}
|
||||
onSelect={() => openSearchResult(thread.id)}
|
||||
value={`${normalizeTitle(thread.title)} ${thread.codebase} ${thread.workspacePath} ${thread.id}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{normalizeTitle(thread.title)}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{thread.time}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
<AlertDialog
|
||||
open={deleteConfirmThread !== null}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
// @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 { SessionCommandBar } from "@/components/session-command-bar";
|
||||
|
||||
const desktopMocks = vi.hoisted(() => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/desktop-client", () => ({ desktopClient: desktopMocks }));
|
||||
|
||||
type SearchHit = {
|
||||
sessionId: string;
|
||||
documentId: string;
|
||||
ordinal: number;
|
||||
role: string;
|
||||
startedAt: string;
|
||||
workspaceRoot: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
};
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function changeInput(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
await act(async () => {
|
||||
setter?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForDebounce() {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 210));
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
desktopMocks.invoke.mockReset();
|
||||
HTMLElement.prototype.hasPointerCapture = vi.fn(() => false);
|
||||
HTMLElement.prototype.setPointerCapture = vi.fn();
|
||||
HTMLElement.prototype.releasePointerCapture = vi.fn();
|
||||
HTMLElement.prototype.scrollIntoView = vi.fn();
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("SessionCommandBar", () => {
|
||||
it("keeps typing responsive, ignores stale searches, and lazily renders bounded results", async () => {
|
||||
const firstSearch = deferred<SearchHit[]>();
|
||||
const secondSearch = deferred<SearchHit[]>();
|
||||
desktopMocks.invoke
|
||||
.mockReturnValueOnce(firstSearch.promise)
|
||||
.mockReturnValueOnce(secondSearch.promise);
|
||||
const onOpenChange = vi.fn();
|
||||
const onOpenSession = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<SessionCommandBar
|
||||
onOpenChange={onOpenChange}
|
||||
onOpenSession={onOpenSession}
|
||||
open
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const input = document.querySelector<HTMLInputElement>("[cmdk-input]");
|
||||
expect(input).not.toBeNull();
|
||||
expect(document.body.textContent).toContain("Cmd/Ctrl+P");
|
||||
await changeInput(input as HTMLInputElement, "generate");
|
||||
await waitForDebounce();
|
||||
expect(desktopMocks.invoke).toHaveBeenCalledTimes(1);
|
||||
|
||||
await changeInput(input as HTMLInputElement, "generate puppy");
|
||||
expect(input?.value).toBe("generate puppy");
|
||||
expect(document.querySelectorAll("[cmdk-item]")).toHaveLength(0);
|
||||
await waitForDebounce();
|
||||
expect(desktopMocks.invoke).toHaveBeenCalledTimes(2);
|
||||
|
||||
firstSearch.resolve([
|
||||
{
|
||||
sessionId: "stale-session",
|
||||
documentId: "stale-session:0",
|
||||
ordinal: 0,
|
||||
role: "user",
|
||||
startedAt: "2026-08-27T12:00:00.000Z",
|
||||
workspaceRoot: "/workspace/project",
|
||||
title: "stale result",
|
||||
snippet: "stale result",
|
||||
},
|
||||
]);
|
||||
await act(async () => Promise.resolve());
|
||||
expect(document.querySelectorAll("[cmdk-item]")).toHaveLength(0);
|
||||
|
||||
const oversized = `<user_input mode="act">${"generate puppy ".repeat(3_000)}</user_input>`;
|
||||
secondSearch.resolve(
|
||||
Array.from({ length: 40 }, (_, index) => ({
|
||||
sessionId: `session-${index}`,
|
||||
documentId: `session-${index}:0`,
|
||||
ordinal: 0,
|
||||
role: "user",
|
||||
startedAt: "2026-08-27T12:00:00.000Z",
|
||||
workspaceRoot: "/workspace/project",
|
||||
title: oversized,
|
||||
snippet: oversized,
|
||||
})),
|
||||
);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const initialItems = document.querySelectorAll<HTMLElement>("[cmdk-item]");
|
||||
expect(initialItems).toHaveLength(15);
|
||||
expect(document.body.textContent).toContain("Showing 15 of 40 results");
|
||||
expect(document.body.textContent).not.toContain("user_input");
|
||||
expect(initialItems[0]?.querySelector("svg")).toBeNull();
|
||||
expect(initialItems[0]?.getAttribute("data-value")?.length).toBeLessThan(
|
||||
1_000,
|
||||
);
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
expect(onOpenSession).not.toHaveBeenCalled();
|
||||
|
||||
const list = document.querySelector<HTMLElement>("[cmdk-list]");
|
||||
expect(list).not.toBeNull();
|
||||
Object.defineProperties(list as HTMLElement, {
|
||||
scrollHeight: { configurable: true, value: 1_000 },
|
||||
clientHeight: { configurable: true, value: 300 },
|
||||
scrollTop: { configurable: true, value: 650 },
|
||||
});
|
||||
await act(async () => {
|
||||
list?.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(document.querySelectorAll("[cmdk-item]")).toHaveLength(30);
|
||||
expect(document.body.textContent).toContain("Showing 30 of 40 results");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
formatSessionSearchPreview,
|
||||
formatSessionSearchTitle,
|
||||
} from "@cline/shared/browser";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { type UIEvent, useEffect, useState } from "react";
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
|
||||
type SessionSearchHit = {
|
||||
sessionId: string;
|
||||
documentId: string;
|
||||
ordinal: number;
|
||||
role: string;
|
||||
startedAt: string;
|
||||
workspaceRoot: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
};
|
||||
|
||||
const SESSION_SEARCH_RESULT_BATCH_SIZE = 15;
|
||||
|
||||
export function SessionCommandBar({
|
||||
open,
|
||||
onOpenChange,
|
||||
onOpenSession,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onOpenSession: (sessionId: string) => void | Promise<void>;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [hits, setHits] = useState<SessionSearchHit[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [visibleHitCount, setVisibleHitCount] = useState(
|
||||
SESSION_SEARCH_RESULT_BATCH_SIZE,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery("");
|
||||
setHits([]);
|
||||
setSearching(false);
|
||||
setVisibleHitCount(SESSION_SEARCH_RESULT_BATCH_SIZE);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const normalized = query.trim();
|
||||
if (!open || !normalized) {
|
||||
setHits([]);
|
||||
setSearching(false);
|
||||
setVisibleHitCount(SESSION_SEARCH_RESULT_BATCH_SIZE);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setSearching(true);
|
||||
const timer = setTimeout(() => {
|
||||
void desktopClient
|
||||
.invoke<SessionSearchHit[]>(
|
||||
"search_sessions",
|
||||
{
|
||||
query: normalized,
|
||||
limit: 50,
|
||||
},
|
||||
{ timeoutMs: 3_000 },
|
||||
)
|
||||
.then((results) => {
|
||||
if (!cancelled) {
|
||||
setHits(
|
||||
results.map((hit) => ({
|
||||
...hit,
|
||||
title: formatSessionSearchTitle(hit.title),
|
||||
snippet: formatSessionSearchPreview(hit.role, hit.snippet),
|
||||
})),
|
||||
);
|
||||
setVisibleHitCount(SESSION_SEARCH_RESULT_BATCH_SIZE);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setHits([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setSearching(false);
|
||||
});
|
||||
}, 180);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [open, query]);
|
||||
|
||||
const handleQueryChange = (value: string) => {
|
||||
setQuery(value);
|
||||
setHits([]);
|
||||
setVisibleHitCount(SESSION_SEARCH_RESULT_BATCH_SIZE);
|
||||
setSearching(Boolean(value.trim()));
|
||||
};
|
||||
|
||||
const handleResultListScroll = (event: UIEvent<HTMLDivElement>) => {
|
||||
const list = event.currentTarget;
|
||||
if (list.scrollHeight - list.scrollTop - list.clientHeight > 120) return;
|
||||
setVisibleHitCount((current) =>
|
||||
Math.min(current + SESSION_SEARCH_RESULT_BATCH_SIZE, hits.length),
|
||||
);
|
||||
};
|
||||
|
||||
const visibleHits = hits.slice(0, visibleHitCount);
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
className="h-[min(38rem,calc(100vh-2rem))] w-[min(56rem,calc(100vw-2rem))] max-w-none sm:max-w-none"
|
||||
description="Search messages across all Cline sessions"
|
||||
onOpenChange={onOpenChange}
|
||||
open={open}
|
||||
// Hits arrive filtered and ranked by the FTS index; letting cmdk
|
||||
// re-score them would reorder results and drop hits whose matched
|
||||
// text lives outside the truncated snippet.
|
||||
shouldFilter={false}
|
||||
showCloseButton={false}
|
||||
title="Search session history"
|
||||
>
|
||||
<CommandInput
|
||||
onValueChange={handleQueryChange}
|
||||
placeholder="Search all session history…"
|
||||
value={query}
|
||||
/>
|
||||
<CommandList
|
||||
className="min-h-0 max-h-none flex-1"
|
||||
onScroll={handleResultListScroll}
|
||||
>
|
||||
{searching ? (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Searching sessions…
|
||||
</div>
|
||||
) : null}
|
||||
{!searching && query.trim() ? (
|
||||
<CommandEmpty>No matching session history.</CommandEmpty>
|
||||
) : null}
|
||||
{!searching && hits.length > 0 ? (
|
||||
<CommandGroup heading="Session history">
|
||||
{visibleHits.map((hit) => (
|
||||
<CommandItem
|
||||
className="items-start py-3"
|
||||
key={hit.documentId}
|
||||
onSelect={() => {
|
||||
onOpenChange(false);
|
||||
void onOpenSession(hit.sessionId);
|
||||
}}
|
||||
value={`${hit.documentId} ${hit.title} ${hit.snippet} ${hit.workspaceRoot}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="shrink-0 text-xs capitalize text-muted-foreground">
|
||||
{hit.role}
|
||||
</span>
|
||||
<span className="truncate font-medium">{hit.title}</span>
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 whitespace-normal text-xs text-muted-foreground">
|
||||
{hit.snippet}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-[11px] text-muted-foreground/70">
|
||||
{hit.workspaceRoot}
|
||||
</p>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
{visibleHitCount < hits.length ? (
|
||||
<div className="py-2 text-center text-[11px] text-muted-foreground">
|
||||
Showing {visibleHitCount} of {hits.length} results
|
||||
</div>
|
||||
) : null}
|
||||
</CommandGroup>
|
||||
) : null}
|
||||
{!query.trim() ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
Search messages, commands, errors, and file paths.
|
||||
</div>
|
||||
) : null}
|
||||
</CommandList>
|
||||
<div className="flex items-center justify-between border-t px-3 py-2 text-[11px] text-muted-foreground">
|
||||
<span>Navigate with ↑↓ and open with ↵</span>
|
||||
<kbd className="rounded border bg-muted px-1.5 py-0.5 font-sans">
|
||||
Cmd/Ctrl+P
|
||||
</kbd>
|
||||
</div>
|
||||
</CommandDialog>
|
||||
);
|
||||
}
|
||||
@@ -34,12 +34,15 @@ function CommandDialog({
|
||||
children,
|
||||
className,
|
||||
showCloseButton = true,
|
||||
shouldFilter = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
showCloseButton?: boolean;
|
||||
/** Pass false when results are already filtered/ranked by the caller (e.g. server-side search). */
|
||||
shouldFilter?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
@@ -51,7 +54,10 @@ function CommandDialog({
|
||||
className={cn("overflow-hidden p-0", className)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
<Command
|
||||
className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
|
||||
shouldFilter={shouldFilter}
|
||||
>
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
|
||||
@@ -152,15 +152,21 @@ function deferred<T>() {
|
||||
}
|
||||
|
||||
async function renderVoiceComposer({
|
||||
hasRunningAgents = false,
|
||||
onAbort = vi.fn(),
|
||||
onPromptInputChange = vi.fn(),
|
||||
onSend = vi.fn(),
|
||||
prompt = "",
|
||||
promptVersion = 0,
|
||||
status = "idle",
|
||||
}: {
|
||||
hasRunningAgents?: boolean;
|
||||
onAbort?: ReturnType<typeof vi.fn>;
|
||||
onPromptInputChange?: ReturnType<typeof vi.fn>;
|
||||
onSend?: ReturnType<typeof vi.fn>;
|
||||
prompt?: string;
|
||||
promptVersion?: number;
|
||||
status?: ChatSessionStatus;
|
||||
} = {}) {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
@@ -168,9 +174,10 @@ async function renderVoiceComposer({
|
||||
<ChatInputBar
|
||||
attachments={[]}
|
||||
gitBranch="main"
|
||||
hasRunningAgents={hasRunningAgents}
|
||||
mode="act"
|
||||
model="test-model"
|
||||
onAbort={vi.fn()}
|
||||
onAbort={onAbort}
|
||||
onAttachFiles={vi.fn()}
|
||||
onEditPromptInQueue={vi.fn()}
|
||||
onListGitBranches={vi.fn(async () => ({
|
||||
@@ -191,7 +198,7 @@ async function renderVoiceComposer({
|
||||
promptsInQueue={[]}
|
||||
provider="cline"
|
||||
reasoningEffort="low"
|
||||
status="idle"
|
||||
status={status}
|
||||
summary={{ toolCalls: 0, tokensIn: 0, tokensOut: 0 }}
|
||||
thinking
|
||||
/>
|
||||
@@ -202,6 +209,23 @@ async function renderVoiceComposer({
|
||||
}
|
||||
|
||||
describe("ChatInputBar", () => {
|
||||
it("allows a parent session with a running child agent to be stopped", async () => {
|
||||
const onAbort = vi.fn();
|
||||
await renderVoiceComposer({
|
||||
hasRunningAgents: true,
|
||||
onAbort,
|
||||
status: "idle",
|
||||
});
|
||||
|
||||
const stopButton = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Stop agent"]',
|
||||
);
|
||||
expect(stopButton).not.toBeNull();
|
||||
|
||||
await act(async () => stopButton?.click());
|
||||
expect(onAbort).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("builds slash commands from both workflows and skills", () => {
|
||||
expect(
|
||||
buildUserInstructionSlashCommands({
|
||||
|
||||
@@ -279,6 +279,7 @@ export type PromptDraft = {
|
||||
type ChatInputBarProps = {
|
||||
variant?: "conversation" | "welcome";
|
||||
status: ChatSessionStatus;
|
||||
hasRunningAgents?: boolean;
|
||||
provider: string;
|
||||
model: string;
|
||||
modelContextWindow?: number;
|
||||
@@ -322,6 +323,7 @@ type ChatInputBarProps = {
|
||||
function ChatInputBarImpl({
|
||||
variant = "conversation",
|
||||
status,
|
||||
hasRunningAgents = false,
|
||||
provider,
|
||||
model,
|
||||
modelContextWindow,
|
||||
@@ -416,7 +418,8 @@ function ChatInputBarImpl({
|
||||
}, [promptDraft, setPromptInput]);
|
||||
const isBusy =
|
||||
status === "starting" || status === "running" || status === "stopping";
|
||||
const canAbort = status === "running" || status === "stopping";
|
||||
const canAbort =
|
||||
status === "running" || status === "stopping" || hasRunningAgents;
|
||||
const hasDraft = promptInput.trim().length > 0 || attachments.length > 0;
|
||||
const [speechInputActive, setSpeechInputActive] = useState(false);
|
||||
const speechInputActiveRef = useRef(false);
|
||||
|
||||
@@ -191,6 +191,146 @@ describe("ChatMessages tool disclosures", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("renders image content returned by a tool instead of raw base64", async () => {
|
||||
const screenshotData = "aGVsbG8=";
|
||||
await renderMessages([
|
||||
{
|
||||
id: "tool-screenshot",
|
||||
sessionId: "session-1",
|
||||
role: "tool",
|
||||
content: JSON.stringify({
|
||||
toolName: "computer_use",
|
||||
input: { action: "screenshot" },
|
||||
result: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Screenshot captured at /tmp/screenshot.png",
|
||||
},
|
||||
{
|
||||
type: "image",
|
||||
data: screenshotData,
|
||||
mimeType: "image/png",
|
||||
},
|
||||
],
|
||||
}),
|
||||
createdAt: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
const trigger = [...container.querySelectorAll("button")].find((element) =>
|
||||
element.textContent?.includes("Computer use"),
|
||||
);
|
||||
expect(trigger?.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(container.textContent).toContain(
|
||||
"Screenshot captured at /tmp/screenshot.png",
|
||||
);
|
||||
expect(container.textContent).not.toContain(screenshotData);
|
||||
|
||||
const image = container.querySelector<HTMLImageElement>(
|
||||
'img[alt="Generated result 1"]',
|
||||
);
|
||||
expect(image?.src).toBe(`data:image/png;base64,${screenshotData}`);
|
||||
|
||||
await act(async () => image?.closest("button")?.click());
|
||||
expect(
|
||||
container.querySelector(
|
||||
'[role="dialog"][aria-label="Expanded attachment"]',
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("navigates multiple images returned by a single tool call", async () => {
|
||||
await renderMessages([
|
||||
{
|
||||
id: "tool-multi-screenshot",
|
||||
sessionId: "session-1",
|
||||
role: "tool",
|
||||
content: JSON.stringify({
|
||||
toolName: "computer_use",
|
||||
input: { action: "screenshot" },
|
||||
result: [
|
||||
{ type: "image", data: "Zmlyc3Q=", mimeType: "image/png" },
|
||||
{ type: "image", data: "c2Vjb25k", mimeType: "image/png" },
|
||||
],
|
||||
}),
|
||||
createdAt: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
container.querySelector<HTMLImageElement>('img[alt="Generated result 1"]')
|
||||
?.src,
|
||||
).toBe("data:image/png;base64,Zmlyc3Q=");
|
||||
expect(container.querySelector('img[alt="Generated result 2"]')).toBeNull();
|
||||
expect(container.textContent).toContain("1 / 2");
|
||||
|
||||
const next = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Next generated image"]',
|
||||
);
|
||||
await act(async () => next?.click());
|
||||
|
||||
expect(
|
||||
container.querySelector<HTMLImageElement>('img[alt="Generated result 2"]')
|
||||
?.src,
|
||||
).toBe("data:image/png;base64,c2Vjb25k");
|
||||
expect(container.textContent).toContain("2 / 2");
|
||||
});
|
||||
|
||||
it("auto-expands submit_and_exit and renders its summary as markdown", async () => {
|
||||
const summary = "## Report\n\nChecked **3 feeds**, all healthy.";
|
||||
await renderMessages([
|
||||
{
|
||||
id: "tool-submit",
|
||||
sessionId: "session-1",
|
||||
role: "tool",
|
||||
content: JSON.stringify({
|
||||
toolName: "submit_and_exit",
|
||||
input: { summary, verified: true },
|
||||
result: summary,
|
||||
}),
|
||||
createdAt: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
// The final answer of the run is visible without a click…
|
||||
const trigger = [...container.querySelectorAll("button")].find((element) =>
|
||||
element.textContent?.includes("Scheduled task completed"),
|
||||
);
|
||||
expect(trigger?.getAttribute("aria-expanded")).toBe("true");
|
||||
// …rendered as markdown structure, not a monospace code block.
|
||||
const panel = document.getElementById(
|
||||
trigger?.getAttribute("aria-controls") ?? "",
|
||||
);
|
||||
const markdown = panel?.querySelector(".cline-markdown");
|
||||
expect(markdown?.querySelector("h2")?.textContent).toBe("Report");
|
||||
expect(markdown?.textContent).toContain("3 feeds");
|
||||
expect(markdown?.textContent).not.toContain("##");
|
||||
expect(markdown?.textContent).not.toContain("**");
|
||||
// The final answer renders in full foreground color, overriding the
|
||||
// panel's muted tool-detail gray.
|
||||
expect(markdown?.closest(".text-foreground")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("labels an errored submit_and_exit as failed", async () => {
|
||||
await renderMessages([
|
||||
{
|
||||
id: "tool-submit-error",
|
||||
sessionId: "session-1",
|
||||
role: "tool",
|
||||
content: JSON.stringify({
|
||||
toolName: "submit_and_exit",
|
||||
input: { summary: "Attempted report.", verified: false },
|
||||
isError: true,
|
||||
result: { error: "submit_and_exit timed out after 15000ms" },
|
||||
}),
|
||||
createdAt: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(container.textContent).toContain("Scheduled task failed");
|
||||
expect(container.textContent).not.toContain("Scheduled task completed");
|
||||
});
|
||||
|
||||
it("renders consecutive tool calls as individual rows", async () => {
|
||||
const tools: ChatMessage[] = [
|
||||
{
|
||||
|
||||
@@ -545,6 +545,7 @@ function ChatMessagesImpl({
|
||||
<ToolMessageBlock
|
||||
key={`tools_${child.messages[0]?.id ?? "empty"}`}
|
||||
messages={child.messages}
|
||||
onExpandImage={handleExpandImage}
|
||||
onProceedWhileRunning={onProceedWhileRunning}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ChatMessageImage } from "@/lib/chat-schema";
|
||||
|
||||
export function MessageImageCarousel({
|
||||
images,
|
||||
onExpandImage,
|
||||
}: {
|
||||
images: ChatMessageImage[];
|
||||
onExpandImage?: (image: ChatMessageImage) => void;
|
||||
}) {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const lastIndex = images.length - 1;
|
||||
const safeIndex = Math.min(activeIndex, lastIndex);
|
||||
const image = images[safeIndex];
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIndex((index) => Math.min(index, lastIndex));
|
||||
}, [lastIndex]);
|
||||
|
||||
if (!image) return null;
|
||||
|
||||
return (
|
||||
<div className="relative w-fit max-w-2xl">
|
||||
<button
|
||||
aria-label={`Expand generated image ${safeIndex + 1}`}
|
||||
className="cursor-zoom-in overflow-hidden rounded-lg border border-border bg-muted text-left transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => onExpandImage?.(image)}
|
||||
type="button"
|
||||
>
|
||||
{/* biome-ignore lint/performance/noImgElement: In-memory data URLs do not have dimensions and cannot use Next's optimizer. */}
|
||||
<img
|
||||
alt={`Generated result ${safeIndex + 1}`}
|
||||
className="max-h-56.25 max-w-56.25 object-contain"
|
||||
src={`data:${image.mediaType};base64,${image.data}`}
|
||||
/>
|
||||
</button>
|
||||
{images.length > 1 ? (
|
||||
<>
|
||||
<button
|
||||
aria-label="Previous generated image"
|
||||
className="absolute left-1 top-1/2 flex size-7 -translate-y-1/2 items-center justify-center rounded-full border border-border bg-background/85 text-foreground shadow-sm backdrop-blur-sm transition-opacity hover:bg-background disabled:cursor-not-allowed disabled:opacity-35"
|
||||
disabled={safeIndex === 0}
|
||||
onClick={() => setActiveIndex((index) => Math.max(0, index - 1))}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Next generated image"
|
||||
className="absolute right-1 top-1/2 flex size-7 -translate-y-1/2 items-center justify-center rounded-full border border-border bg-background/85 text-foreground shadow-sm backdrop-blur-sm transition-opacity hover:bg-background disabled:cursor-not-allowed disabled:opacity-35"
|
||||
disabled={safeIndex === lastIndex}
|
||||
onClick={() =>
|
||||
setActiveIndex((index) => Math.min(lastIndex, index + 1))
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
<div className="absolute bottom-1 left-1/2 -translate-x-1/2 rounded-full bg-background/85 px-2 py-0.5 text-[11px] text-foreground shadow-sm backdrop-blur-sm">
|
||||
{safeIndex + 1} / {images.length}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+3
-68
@@ -10,15 +10,13 @@ import {
|
||||
} from "@cline/ui/components/agent-chat";
|
||||
import {
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Loader2,
|
||||
PencilIcon,
|
||||
SplitIcon,
|
||||
UndoIcon,
|
||||
} from "lucide-react";
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import { memo } from "react";
|
||||
import type {
|
||||
ChatMessage,
|
||||
ChatMessageImage,
|
||||
@@ -28,72 +26,9 @@ import { cn } from "@/lib/utils";
|
||||
import { MemoizedMarkdown } from "../../../ui/markdown";
|
||||
import { formatChatMessageContent } from "../message-content";
|
||||
import { isSystemSteeringMessage } from "./group-messages";
|
||||
import { MessageImageCarousel } from "./image-carousel";
|
||||
import { ReasoningBlock } from "./reasoning-block";
|
||||
|
||||
function AssistantImageCarousel({
|
||||
images,
|
||||
onExpandImage,
|
||||
}: {
|
||||
images: ChatMessageImage[];
|
||||
onExpandImage?: (image: ChatMessageImage) => void;
|
||||
}) {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const lastIndex = images.length - 1;
|
||||
const safeIndex = Math.min(activeIndex, lastIndex);
|
||||
const image = images[safeIndex];
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIndex((index) => Math.min(index, lastIndex));
|
||||
}, [lastIndex]);
|
||||
|
||||
if (!image) return null;
|
||||
|
||||
return (
|
||||
<div className="relative w-fit max-w-2xl">
|
||||
<button
|
||||
aria-label={`Expand generated image ${safeIndex + 1}`}
|
||||
className="cursor-zoom-in overflow-hidden rounded-lg border border-border bg-muted text-left transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => onExpandImage?.(image)}
|
||||
type="button"
|
||||
>
|
||||
{/* biome-ignore lint/performance/noImgElement: In-memory data URLs do not have dimensions and cannot use Next's optimizer. */}
|
||||
<img
|
||||
alt={`Generated result ${safeIndex + 1}`}
|
||||
className="max-h-56.25 max-w-56.25 object-contain"
|
||||
src={`data:${image.mediaType};base64,${image.data}`}
|
||||
/>
|
||||
</button>
|
||||
{images.length > 1 ? (
|
||||
<>
|
||||
<button
|
||||
aria-label="Previous generated image"
|
||||
className="absolute left-1 top-1/2 flex size-7 -translate-y-1/2 items-center justify-center rounded-full border border-border bg-background/85 text-foreground shadow-sm backdrop-blur-sm transition-opacity hover:bg-background disabled:cursor-not-allowed disabled:opacity-35"
|
||||
disabled={safeIndex === 0}
|
||||
onClick={() => setActiveIndex((index) => Math.max(0, index - 1))}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Next generated image"
|
||||
className="absolute right-1 top-1/2 flex size-7 -translate-y-1/2 items-center justify-center rounded-full border border-border bg-background/85 text-foreground shadow-sm backdrop-blur-sm transition-opacity hover:bg-background disabled:cursor-not-allowed disabled:opacity-35"
|
||||
disabled={safeIndex === lastIndex}
|
||||
onClick={() =>
|
||||
setActiveIndex((index) => Math.min(lastIndex, index + 1))
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
<div className="absolute bottom-1 left-1/2 -translate-x-1/2 rounded-full bg-background/85 px-2 py-0.5 text-[11px] text-foreground shadow-sm backdrop-blur-sm">
|
||||
{safeIndex + 1} / {images.length}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageImages({
|
||||
images,
|
||||
isUser,
|
||||
@@ -105,7 +40,7 @@ function MessageImages({
|
||||
}) {
|
||||
if (!isUser) {
|
||||
return (
|
||||
<AssistantImageCarousel images={images} onExpandImage={onExpandImage} />
|
||||
<MessageImageCarousel images={images} onExpandImage={onExpandImage} />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+91
-3
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { GeneratedMediaContent } from "@cline/ui";
|
||||
import {
|
||||
ToolActivity,
|
||||
ToolActivityCode,
|
||||
@@ -13,14 +14,21 @@ import Ansi from "ansi-to-react";
|
||||
import { AlertCircle, Loader2 } from "lucide-react";
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ChatMessage } from "@/lib/chat-schema";
|
||||
import {
|
||||
type ChatMessage,
|
||||
type ChatMessageImage,
|
||||
ChatMessageImageSchema,
|
||||
} from "@/lib/chat-schema";
|
||||
import { appendCappedCommandOutput } from "@/lib/command-output";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MemoizedMarkdown } from "../../../ui/markdown";
|
||||
import { IS_DEBUG, STREAMING_TITLE_CLASS } from "./constants";
|
||||
import { MessageImageCarousel } from "./image-carousel";
|
||||
import { getToolNameIcon } from "./tool-icons";
|
||||
import {
|
||||
buildToolPresentation,
|
||||
extractRunCommandOutput,
|
||||
extractSubmitSummaryText,
|
||||
formatToolValue,
|
||||
} from "./tool-summaries";
|
||||
|
||||
@@ -53,14 +61,36 @@ function ToolLabel({
|
||||
|
||||
const ToolCallRow = memo(function ToolCallRow({
|
||||
message,
|
||||
onExpandImage,
|
||||
onProceedWhileRunning,
|
||||
}: {
|
||||
message: ChatMessage;
|
||||
onExpandImage?: (image: ChatMessageImage) => void;
|
||||
onProceedWhileRunning?: ProceedWhileRunningHandler;
|
||||
}) {
|
||||
const { payload, toolName, inProgress, summary } =
|
||||
buildToolPresentation(message);
|
||||
const isCommand = summary.kind === "command";
|
||||
// submit_and_exit carries the run's final answer (scheduled tasks end with
|
||||
// it), so surface it expanded and rendered as markdown rather than leaving
|
||||
// it collapsed behind a code block.
|
||||
const isSubmit = summary.toolName === "submit_and_exit";
|
||||
const submitText = isSubmit
|
||||
? extractSubmitSummaryText(payload) || summary.outputText || ""
|
||||
: "";
|
||||
// The generic fallback label ("Submit and exit") describes the tool, not
|
||||
// the moment; name the milestone the row represents instead.
|
||||
const labelParts = isSubmit
|
||||
? [
|
||||
{
|
||||
text: inProgress
|
||||
? "Completing scheduled task"
|
||||
: payload?.isError
|
||||
? "Scheduled task failed"
|
||||
: "Scheduled task completed",
|
||||
},
|
||||
]
|
||||
: summary.labelParts;
|
||||
const commandOutputSource = isCommand
|
||||
? message.meta?.toolOutput ||
|
||||
(payload?.isError
|
||||
@@ -106,8 +136,23 @@ const ToolCallRow = memo(function ToolCallRow({
|
||||
: [];
|
||||
});
|
||||
const hasFileDiffs = fileDiffs.length > 0;
|
||||
const outputImages = summary.outputMedia.flatMap((media, index) => {
|
||||
if (media.modality !== "image") return [];
|
||||
const image = ChatMessageImageSchema.safeParse({
|
||||
id: `${message.id}_tool_image_${index}`,
|
||||
mediaType: media.mediaType,
|
||||
data: media.data,
|
||||
});
|
||||
return image.success ? [image.data] : [];
|
||||
});
|
||||
const otherOutputMedia = summary.outputMedia.filter(
|
||||
(media) => media.modality !== "image",
|
||||
);
|
||||
const shouldAutoOpen =
|
||||
hasFileDiffs || (inProgress && (Boolean(commandOutput) || canProceed));
|
||||
hasFileDiffs ||
|
||||
Boolean(submitText) ||
|
||||
summary.outputMedia.length > 0 ||
|
||||
(inProgress && (Boolean(commandOutput) || canProceed));
|
||||
const [open, setOpen] = useState(shouldAutoOpen);
|
||||
const [userToggled, setUserToggled] = useState(false);
|
||||
const [isProceeding, setIsProceeding] = useState(false);
|
||||
@@ -147,7 +192,9 @@ const ToolCallRow = memo(function ToolCallRow({
|
||||
const hasExpandedSections =
|
||||
details.length > 0 ||
|
||||
fileDiffs.length > 0 ||
|
||||
Boolean(submitText) ||
|
||||
Boolean(isCommand ? commandOutput : summary.outputText) ||
|
||||
summary.outputMedia.length > 0 ||
|
||||
Boolean(summary.errorText) ||
|
||||
Boolean(inputPreview) ||
|
||||
canProceed;
|
||||
@@ -169,7 +216,7 @@ const ToolCallRow = memo(function ToolCallRow({
|
||||
<Icon className="size-4" />
|
||||
)
|
||||
}
|
||||
label={<ToolLabel isRunning={inProgress} parts={summary.labelParts} />}
|
||||
label={<ToolLabel isRunning={inProgress} parts={labelParts} />}
|
||||
showDisclosureIcon={false}
|
||||
status={hasError ? "error" : inProgress ? "running" : "success"}
|
||||
/>
|
||||
@@ -210,11 +257,48 @@ const ToolCallRow = memo(function ToolCallRow({
|
||||
isRunning={inProgress}
|
||||
output={commandOutput}
|
||||
/>
|
||||
) : submitText ? (
|
||||
// The summary is the run's final answer: full foreground color,
|
||||
// not the panel's muted tool-detail gray.
|
||||
<div className="mt-1 min-w-0 max-w-full wrap-break-word text-foreground">
|
||||
<MemoizedMarkdown content={submitText} />
|
||||
</div>
|
||||
) : summary.outputText ? (
|
||||
<ToolActivityCode className="mt-1 max-h-64 overflow-auto whitespace-pre-wrap break-words font-mono text-xs">
|
||||
{summary.outputText}
|
||||
</ToolActivityCode>
|
||||
) : null}
|
||||
{outputImages.length > 0 ? (
|
||||
<div className="mt-2">
|
||||
<MessageImageCarousel
|
||||
images={outputImages}
|
||||
onExpandImage={onExpandImage}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{otherOutputMedia.length > 0 ? (
|
||||
<div className="mt-2 flex max-w-2xl flex-col gap-2">
|
||||
{otherOutputMedia.map((media, index) => (
|
||||
<GeneratedMediaContent
|
||||
classNames={{
|
||||
audio: "w-full",
|
||||
video: "max-h-96 max-w-full rounded-lg",
|
||||
file: "text-sm underline",
|
||||
unavailable:
|
||||
"rounded-lg border border-border bg-muted p-3 text-sm",
|
||||
}}
|
||||
key={`${message.id}_tool_media_${index}`}
|
||||
media={{
|
||||
id: `${message.id}_tool_media_${index}`,
|
||||
modality: media.modality,
|
||||
mediaType: media.mediaType,
|
||||
name: media.name,
|
||||
source: { type: "base64", data: media.data },
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{inputPreview ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-[11px] uppercase tracking-wide text-muted-foreground/80">
|
||||
@@ -307,9 +391,11 @@ function CommandOutputTerminal({
|
||||
export const ToolMessageBlock = memo(
|
||||
function ToolMessageBlock({
|
||||
messages,
|
||||
onExpandImage,
|
||||
onProceedWhileRunning,
|
||||
}: {
|
||||
messages: ChatMessage[];
|
||||
onExpandImage?: (image: ChatMessageImage) => void;
|
||||
onProceedWhileRunning?: ProceedWhileRunningHandler;
|
||||
}) {
|
||||
if (messages.length === 0) return null;
|
||||
@@ -319,6 +405,7 @@ export const ToolMessageBlock = memo(
|
||||
<ToolCallRow
|
||||
key={message.id}
|
||||
message={message}
|
||||
onExpandImage={onExpandImage}
|
||||
onProceedWhileRunning={onProceedWhileRunning}
|
||||
/>
|
||||
))}
|
||||
@@ -328,5 +415,6 @@ export const ToolMessageBlock = memo(
|
||||
(prev, next) =>
|
||||
prev.messages.length === next.messages.length &&
|
||||
prev.messages.every((message, index) => message === next.messages[index]) &&
|
||||
prev.onExpandImage === next.onExpandImage &&
|
||||
prev.onProceedWhileRunning === next.onProceedWhileRunning,
|
||||
);
|
||||
|
||||
+25
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import type { ChatMessage } from "@/lib/chat-schema";
|
||||
import {
|
||||
buildToolPresentation,
|
||||
extractSubmitSummaryText,
|
||||
formatToolValue,
|
||||
parseToolPayload,
|
||||
} from "./tool-summaries";
|
||||
@@ -51,6 +52,30 @@ describe("formatToolValue", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractSubmitSummaryText", () => {
|
||||
it("reads the summary from structured or JSON-string input", () => {
|
||||
expect(
|
||||
extractSubmitSummaryText({
|
||||
toolName: "submit_and_exit",
|
||||
input: { summary: "All done.", verified: true },
|
||||
}),
|
||||
).toBe("All done.");
|
||||
expect(
|
||||
extractSubmitSummaryText({
|
||||
toolName: "submit_and_exit",
|
||||
input: '{"summary":"From string input."}',
|
||||
}),
|
||||
).toBe("From string input.");
|
||||
});
|
||||
|
||||
it("returns empty for missing payloads or summaries", () => {
|
||||
expect(extractSubmitSummaryText(null)).toBe("");
|
||||
expect(
|
||||
extractSubmitSummaryText({ toolName: "submit_and_exit", input: {} }),
|
||||
).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildToolPresentation", () => {
|
||||
it("marks a payload without a result as in progress", () => {
|
||||
const presentation = buildToolPresentation(
|
||||
|
||||
@@ -95,6 +95,15 @@ export function extractRunCommandOutput(value: unknown): string {
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* The final answer carried in a `submit_and_exit` call's input. Present as
|
||||
* soon as the call starts; the result merely echoes it back.
|
||||
*/
|
||||
export function extractSubmitSummaryText(payload: ToolPayload | null): string {
|
||||
const input = asRecord(normalizeDisplayValue(payload?.input));
|
||||
return recordString(input, "summary");
|
||||
}
|
||||
|
||||
export function parseToolPayload(raw: string): ToolPayload | null {
|
||||
try {
|
||||
return JSON.parse(raw) as ToolPayload;
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import type { AgendaTaskRecord } from "@cline/shared";
|
||||
import { type AgentQuickAction, AgentQuickActions } from "@cline/ui";
|
||||
import {
|
||||
type AgentQuickAction,
|
||||
AgentQuickActions,
|
||||
AgentWelcomeHero,
|
||||
} from "@cline/ui";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { AgendaTaskReviewDialog } from "@/components/agenda-task-review-dialog";
|
||||
@@ -10,7 +14,6 @@ import { isAgendaTaskExpired, useAgendaTasks } from "@/hooks/use-agenda-tasks";
|
||||
import { AGENDA_UI_ENABLED } from "@/lib/feature-flags";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SessionContent } from "./session-content";
|
||||
import { WelcomeHero } from "./welcome-hero";
|
||||
import { WelcomeWorkspaceControls } from "./welcome-workspace-controls";
|
||||
|
||||
export function WelcomeScreen({
|
||||
@@ -131,7 +134,7 @@ export function WelcomeScreen({
|
||||
{active ? (
|
||||
<div className="cline-view-enter">
|
||||
<h1 className="sr-only">What would you like to build?</h1>
|
||||
<WelcomeHero />
|
||||
<AgentWelcomeHero />
|
||||
|
||||
<div className="mt-11 flex min-w-0 items-center">
|
||||
<WelcomeWorkspaceControls
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* Values the pointer calculation needs when layout bounds are unavailable.
|
||||
*
|
||||
* Visual geometry belongs to welcome-hero.module.css. These two fallback sizes
|
||||
* mirror its default layout because jsdom and older webviews may not expose the
|
||||
* rendered grid bounds used by the normal pointer path.
|
||||
*/
|
||||
export const WELCOME_HERO_POINTER_CONFIG = {
|
||||
defaultFrameHeight: 220,
|
||||
defaultGridHeight: 520,
|
||||
eyes: {
|
||||
travel: 6, // Maximum distance the eyes move toward the pointer.
|
||||
falloffDistance: 200, // Pointer distance needed to reach maximum travel.
|
||||
smoothing: 0.22, // Fraction of the remaining distance moved per frame.
|
||||
},
|
||||
} as const;
|
||||
@@ -1 +0,0 @@
|
||||
export { WelcomeHero, type WelcomeHeroProps } from "./welcome-hero";
|
||||
-208
@@ -1,208 +0,0 @@
|
||||
.root {
|
||||
/* Default inline layout. Alternate compositions override only this set. */
|
||||
--welcome-hero-width: 1200px;
|
||||
--welcome-hero-height: 220px;
|
||||
--welcome-hero-viewport-gutter: 16px;
|
||||
--welcome-grid-height: 520px;
|
||||
--welcome-grid-initial-x: 50%;
|
||||
--welcome-grid-initial-y: 280px;
|
||||
--welcome-grid-x: var(--welcome-grid-initial-x);
|
||||
--welcome-grid-y: var(--welcome-grid-initial-y);
|
||||
--welcome-grid-layer-mask: radial-gradient(
|
||||
ellipse 44.7% 45% at 44.7% 50%,
|
||||
black 50%,
|
||||
transparent 100%
|
||||
);
|
||||
--welcome-grid-mask: url("/welcome-hero/grid-tile.svg");
|
||||
--welcome-grid-tile-size: 400px;
|
||||
--welcome-inner-mask: url("/welcome-hero/inner-mask.svg");
|
||||
--welcome-bot-top: -2px;
|
||||
--welcome-bot-offset-x: -100px;
|
||||
--welcome-bot-width: 204px;
|
||||
--welcome-bot-height: 192px;
|
||||
--welcome-bot-fill-mask: url("/welcome-hero/bot-fill-mask.svg");
|
||||
--welcome-bot-outline-mask: url("/welcome-hero/bot-outline-mask.svg");
|
||||
--welcome-eye-top: 83px;
|
||||
--welcome-eye-width: 18px;
|
||||
--welcome-eye-height: 52px;
|
||||
--welcome-eye-left-offset-x: -37px;
|
||||
--welcome-eye-right-offset-x: 19.5px;
|
||||
--welcome-eye-x: 0px;
|
||||
--welcome-eye-y: 0px;
|
||||
|
||||
/* Appearance controls. */
|
||||
--welcome-hero-color: oklch(from var(--primary) 0.68 calc(c * 0.8) h);
|
||||
--welcome-grid-color: oklch(from var(--welcome-hero-color) l calc(c * 0.4) h);
|
||||
--welcome-grid-opacity: 0.2;
|
||||
--welcome-inner-opacity: 2%;
|
||||
--welcome-bot-fill-opacity: 13%;
|
||||
--welcome-bot-stroke-opacity: 12%;
|
||||
|
||||
position: relative;
|
||||
left: 50%;
|
||||
isolation: isolate;
|
||||
width: min(
|
||||
calc(
|
||||
100vw -
|
||||
var(--welcome-hero-viewport-gutter) -
|
||||
var(--welcome-hero-viewport-gutter)
|
||||
),
|
||||
var(--welcome-hero-width)
|
||||
);
|
||||
max-width: none;
|
||||
height: var(--welcome-hero-height);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.root[data-welcome-hero-layout="full-bleed"] {
|
||||
--welcome-hero-width: 100vw;
|
||||
--welcome-hero-height: 100%;
|
||||
--welcome-hero-viewport-gutter: 0px;
|
||||
--welcome-grid-height: 100%;
|
||||
--welcome-grid-initial-y: 50%;
|
||||
--welcome-grid-layer-mask: none;
|
||||
}
|
||||
|
||||
.root[data-welcome-hero-layout="wide-grid"] {
|
||||
--welcome-hero-width: 1600px;
|
||||
--welcome-grid-height: 960px;
|
||||
--welcome-grid-initial-y: 370px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: var(--welcome-grid-height);
|
||||
pointer-events: none;
|
||||
-webkit-mask-image: var(--welcome-grid-layer-mask);
|
||||
mask-image: var(--welcome-grid-layer-mask);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.grid::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
content: "";
|
||||
background: radial-gradient(
|
||||
ellipse 44% 41.25% at var(--welcome-grid-x) var(--welcome-grid-y),
|
||||
var(--welcome-grid-color) 0%,
|
||||
color-mix(in srgb, var(--welcome-grid-color) 0%, transparent) 100%
|
||||
);
|
||||
-webkit-mask-image: var(--welcome-grid-mask);
|
||||
mask-image: var(--welcome-grid-mask);
|
||||
-webkit-mask-position: center;
|
||||
mask-position: center;
|
||||
-webkit-mask-repeat: repeat;
|
||||
mask-repeat: repeat;
|
||||
-webkit-mask-size: var(--welcome-grid-tile-size) var(--welcome-grid-tile-size);
|
||||
mask-size: var(--welcome-grid-tile-size) var(--welcome-grid-tile-size);
|
||||
opacity: var(--welcome-grid-opacity);
|
||||
}
|
||||
|
||||
.inner,
|
||||
.botFill,
|
||||
.botOutline {
|
||||
position: absolute;
|
||||
display: block;
|
||||
top: var(--welcome-bot-top);
|
||||
left: calc(50% + var(--welcome-bot-offset-x));
|
||||
width: var(--welcome-bot-width);
|
||||
height: var(--welcome-bot-height);
|
||||
max-width: none;
|
||||
pointer-events: none;
|
||||
-webkit-mask-position: center;
|
||||
mask-position: center;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
-webkit-mask-size: 100% 100%;
|
||||
mask-size: 100% 100%;
|
||||
}
|
||||
|
||||
.inner {
|
||||
z-index: 1;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--welcome-hero-color) var(--welcome-inner-opacity),
|
||||
transparent
|
||||
);
|
||||
-webkit-mask-image: var(--welcome-inner-mask);
|
||||
mask-image: var(--welcome-inner-mask);
|
||||
}
|
||||
|
||||
.botFill {
|
||||
z-index: 2;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--welcome-hero-color) var(--welcome-bot-fill-opacity),
|
||||
transparent
|
||||
);
|
||||
-webkit-mask-image: var(--welcome-bot-fill-mask);
|
||||
mask-image: var(--welcome-bot-fill-mask);
|
||||
}
|
||||
|
||||
.botOutline {
|
||||
z-index: 3;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--welcome-hero-color) var(--welcome-bot-stroke-opacity),
|
||||
transparent
|
||||
);
|
||||
-webkit-mask-image: var(--welcome-bot-outline-mask);
|
||||
mask-image: var(--welcome-bot-outline-mask);
|
||||
}
|
||||
|
||||
.eye {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: var(--welcome-eye-top);
|
||||
width: var(--welcome-eye-width);
|
||||
height: var(--welcome-eye-height);
|
||||
border: 1px solid
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--welcome-hero-color) var(--welcome-bot-stroke-opacity),
|
||||
transparent
|
||||
);
|
||||
border-radius: 999px;
|
||||
pointer-events: none;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--welcome-hero-color) var(--welcome-bot-fill-opacity),
|
||||
transparent
|
||||
);
|
||||
backface-visibility: hidden;
|
||||
transform: translate3d(var(--welcome-eye-x), var(--welcome-eye-y), 0);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.eyeLeft {
|
||||
left: calc(50% + var(--welcome-eye-left-offset-x));
|
||||
}
|
||||
|
||||
.eyeRight {
|
||||
left: calc(50% + var(--welcome-eye-right-offset-x));
|
||||
}
|
||||
|
||||
[class~="dark"] .root {
|
||||
--welcome-hero-color: oklch(from var(--primary) 0.9 calc(c * 0.8) h);
|
||||
--welcome-grid-opacity: 0.2;
|
||||
--welcome-inner-opacity: 6%;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.grid::before {
|
||||
background: radial-gradient(
|
||||
ellipse 44% 41.25% at var(--welcome-grid-initial-x)
|
||||
var(--welcome-grid-initial-y),
|
||||
var(--welcome-grid-color) 0%,
|
||||
color-mix(in srgb, var(--welcome-grid-color) 0%, transparent) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.eye {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { useRef } from "react";
|
||||
import { useWelcomeHeroPointer } from "./use-welcome-hero-pointer";
|
||||
import styles from "./welcome-hero.module.css";
|
||||
|
||||
export type WelcomeHeroLayout = "default" | "full-bleed" | "wide-grid";
|
||||
|
||||
export interface WelcomeHeroProps {
|
||||
className?: string;
|
||||
interactive?: boolean;
|
||||
layout?: WelcomeHeroLayout;
|
||||
variant?: "full" | "grid-only" | "bot-only";
|
||||
}
|
||||
|
||||
/** Composable welcome bot illustration and grid backdrop. */
|
||||
export function WelcomeHero({
|
||||
className,
|
||||
interactive,
|
||||
layout = "default",
|
||||
variant = "full",
|
||||
}: WelcomeHeroProps) {
|
||||
const heroRef = useRef<HTMLDivElement>(null);
|
||||
const showsGrid = variant !== "bot-only";
|
||||
const showsBot = variant !== "grid-only";
|
||||
const tracksPointer = interactive ?? showsBot;
|
||||
useWelcomeHeroPointer(heroRef, tracksPointer);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={clsx(styles.root, className)}
|
||||
data-welcome-hero
|
||||
data-welcome-hero-interactive={tracksPointer}
|
||||
data-welcome-hero-layout={layout}
|
||||
data-welcome-hero-variant={variant}
|
||||
ref={heroRef}
|
||||
>
|
||||
{showsGrid ? (
|
||||
<div className={styles.grid} data-welcome-hero-layer="grid" />
|
||||
) : null}
|
||||
{showsBot ? (
|
||||
<>
|
||||
<span className={styles.inner} data-welcome-hero-layer="inner" />
|
||||
<span className={styles.botFill} data-welcome-hero-layer="bot-fill" />
|
||||
<span
|
||||
className={styles.botOutline}
|
||||
data-welcome-hero-layer="bot-outline"
|
||||
/>
|
||||
<span
|
||||
className={`${styles.eye} ${styles.eyeLeft}`}
|
||||
data-welcome-hero-eye="left"
|
||||
/>
|
||||
<span
|
||||
className={`${styles.eye} ${styles.eyeRight}`}
|
||||
data-welcome-hero-eye="right"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,826 @@
|
||||
import {
|
||||
ArrowUpRight,
|
||||
BadgeCheck,
|
||||
Github,
|
||||
Globe,
|
||||
Puzzle,
|
||||
Scale,
|
||||
Search,
|
||||
Server,
|
||||
Trash2,
|
||||
User,
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { type CSSProperties, useEffect, useMemo, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { desktopClient, openExternalUrl } from "@/lib/desktop-client";
|
||||
import {
|
||||
fetchMarketplaceCatalog,
|
||||
type MarketplaceCatalog,
|
||||
type MarketplaceEntry,
|
||||
type MarketplacePrimitiveType,
|
||||
} from "@/lib/marketplace";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Marketplace explorer: a two-pane master/detail directory in the spirit of
|
||||
* an IDE extensions panel. The left rail lists every catalog entry grouped by
|
||||
* primitive maturity (Skills, then MCP, then plugins); the right pane is a
|
||||
* full detail page for the selected entry with the catalog's metadata
|
||||
* (author, license, verified state, tags, install command, env setup) and
|
||||
* links out to the entry's homepage and repository.
|
||||
*/
|
||||
|
||||
/** Ordered most-mature first: skills > MCP servers > plugins. */
|
||||
const MATURITY_ORDER: MarketplacePrimitiveType[] = ["skill", "mcp", "plugin"];
|
||||
|
||||
type TypeMeta = {
|
||||
label: string;
|
||||
plural: string;
|
||||
icon: typeof Server;
|
||||
};
|
||||
|
||||
const TYPE_META: Record<MarketplacePrimitiveType, TypeMeta> = {
|
||||
skill: {
|
||||
label: "Skill",
|
||||
plural: "Skills",
|
||||
icon: Zap,
|
||||
},
|
||||
mcp: {
|
||||
label: "MCP Server",
|
||||
plural: "MCP",
|
||||
icon: Server,
|
||||
},
|
||||
plugin: {
|
||||
label: "Plugin",
|
||||
plural: "Plugins",
|
||||
icon: Puzzle,
|
||||
},
|
||||
};
|
||||
|
||||
const CODE_FONT_STYLE: CSSProperties = {
|
||||
fontFamily:
|
||||
'"Geist Mono Variable", ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace',
|
||||
};
|
||||
|
||||
const INSTALL_TIMEOUT_MS = 300_000;
|
||||
|
||||
/** Tag pills shown while the category row is collapsed. */
|
||||
const COLLAPSED_TAG_COUNT = 4;
|
||||
|
||||
function entryKey(entry: Pick<MarketplaceEntry, "id" | "type">): string {
|
||||
return `${entry.type}:${entry.id}`;
|
||||
}
|
||||
|
||||
function entrySearchText(
|
||||
entry: MarketplaceEntry,
|
||||
tagLabels: Map<string, string>,
|
||||
): string {
|
||||
return [
|
||||
entry.name,
|
||||
entry.tagline,
|
||||
entry.description,
|
||||
entry.type,
|
||||
entry.author?.name ?? "",
|
||||
...entry.tags.map((tag) => tagLabels.get(tag) ?? tag),
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
type EntryActionState =
|
||||
| { status: "idle" }
|
||||
| { status: "installing" }
|
||||
| { status: "uninstalling" }
|
||||
| { status: "installed"; message: string }
|
||||
| { status: "uninstalled"; message: string }
|
||||
| { status: "failed"; message: string };
|
||||
|
||||
type MarketplaceInstallResult = {
|
||||
status: "installed" | "uninstalled";
|
||||
message: string;
|
||||
output?: string;
|
||||
};
|
||||
|
||||
type MarketplaceInstallStatusResult = {
|
||||
installedKeys: string[];
|
||||
};
|
||||
|
||||
type MarketplaceDirectory = {
|
||||
catalog: MarketplaceCatalog | null;
|
||||
errorMessage: string | null;
|
||||
loading: boolean;
|
||||
tagLabels: Map<string, string>;
|
||||
installedKeys: Set<string>;
|
||||
installedReady: boolean;
|
||||
actionStates: Map<string, EntryActionState>;
|
||||
install: (entry: MarketplaceEntry) => Promise<void>;
|
||||
uninstall: (entry: MarketplaceEntry) => Promise<void>;
|
||||
};
|
||||
|
||||
function useMarketplaceDirectory(): MarketplaceDirectory {
|
||||
const [catalog, setCatalog] = useState<MarketplaceCatalog | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [installedKeys, setInstalledKeys] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [installedReady, setInstalledReady] = useState(false);
|
||||
const [actionStates, setActionStates] = useState<
|
||||
Map<string, EntryActionState>
|
||||
>(() => new Map());
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const nextCatalog = await fetchMarketplaceCatalog();
|
||||
if (!cancelled) {
|
||||
setCatalog(nextCatalog);
|
||||
setErrorMessage(null);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
setInstalledReady(true);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!catalog) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const response =
|
||||
await desktopClient.invoke<MarketplaceInstallStatusResult>(
|
||||
"list_marketplace_installed_entries",
|
||||
{ entries: catalog.entries },
|
||||
);
|
||||
if (!cancelled) {
|
||||
setInstalledKeys(new Set(response.installedKeys));
|
||||
}
|
||||
} catch {
|
||||
// Keep current installed status when the check fails.
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setInstalledReady(true);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [catalog]);
|
||||
|
||||
const tagLabels = useMemo(
|
||||
() => new Map(catalog?.tags.map((tag) => [tag.id, tag.label]) ?? []),
|
||||
[catalog?.tags],
|
||||
);
|
||||
|
||||
const setEntryState = (entry: MarketplaceEntry, state: EntryActionState) => {
|
||||
const key = entryKey(entry);
|
||||
setActionStates((current) => {
|
||||
const next = new Map(current);
|
||||
next.set(key, state);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const install = async (entry: MarketplaceEntry) => {
|
||||
const key = entryKey(entry);
|
||||
const current = actionStates.get(key);
|
||||
if (
|
||||
current?.status === "installing" ||
|
||||
current?.status === "uninstalling"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setEntryState(entry, { status: "installing" });
|
||||
try {
|
||||
const result = await desktopClient.invoke<MarketplaceInstallResult>(
|
||||
"install_marketplace_entry",
|
||||
{ entry },
|
||||
{ timeoutMs: INSTALL_TIMEOUT_MS },
|
||||
);
|
||||
setEntryState(entry, { status: "installed", message: result.message });
|
||||
setInstalledKeys((prev) => new Set(prev).add(key));
|
||||
} catch (error) {
|
||||
setEntryState(entry, {
|
||||
status: "failed",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const uninstall = async (entry: MarketplaceEntry) => {
|
||||
const key = entryKey(entry);
|
||||
const current = actionStates.get(key);
|
||||
if (
|
||||
current?.status === "installing" ||
|
||||
current?.status === "uninstalling"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setEntryState(entry, { status: "uninstalling" });
|
||||
try {
|
||||
const result = await desktopClient.invoke<MarketplaceInstallResult>(
|
||||
"uninstall_marketplace_entry",
|
||||
{ entry },
|
||||
{ timeoutMs: INSTALL_TIMEOUT_MS },
|
||||
);
|
||||
setEntryState(entry, { status: "uninstalled", message: result.message });
|
||||
setInstalledKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
} catch (error) {
|
||||
setEntryState(entry, {
|
||||
status: "failed",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
catalog,
|
||||
errorMessage,
|
||||
loading: !catalog && !errorMessage,
|
||||
tagLabels,
|
||||
installedKeys,
|
||||
installedReady,
|
||||
actionStates,
|
||||
install,
|
||||
uninstall,
|
||||
};
|
||||
}
|
||||
|
||||
function actionLabelFor(
|
||||
state: EntryActionState | undefined,
|
||||
installed: boolean,
|
||||
ready: boolean,
|
||||
): string {
|
||||
if (!ready) return "Checking...";
|
||||
if (state?.status === "installing") return "Installing...";
|
||||
if (state?.status === "uninstalling") return "Uninstalling...";
|
||||
return installed ? "Uninstall" : "Install";
|
||||
}
|
||||
|
||||
function isBusy(state: EntryActionState | undefined): boolean {
|
||||
return state?.status === "installing" || state?.status === "uninstalling";
|
||||
}
|
||||
|
||||
function ListRow({
|
||||
entry,
|
||||
installed,
|
||||
onSelect,
|
||||
selected,
|
||||
}: {
|
||||
entry: MarketplaceEntry;
|
||||
installed: boolean;
|
||||
onSelect: () => void;
|
||||
selected: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full min-w-0 items-center gap-2.5 rounded-lg px-2.5 py-2 text-left transition-colors",
|
||||
selected ? "bg-primary/10" : "hover:bg-surface-hover-lighter",
|
||||
)}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex min-w-0 items-center gap-1">
|
||||
<span className="truncate text-sm font-medium text-foreground">
|
||||
{entry.name}
|
||||
</span>
|
||||
{entry.verified ? (
|
||||
<BadgeCheck className="size-3.5 shrink-0 text-sky-500" />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{entry.tagline}
|
||||
</span>
|
||||
</span>
|
||||
{installed ? (
|
||||
<span
|
||||
className="size-1.5 shrink-0 rounded-full bg-emerald-500"
|
||||
title="Installed"
|
||||
/>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaCell({
|
||||
icon: Icon,
|
||||
label,
|
||||
onOpen,
|
||||
value,
|
||||
}: {
|
||||
icon: typeof Globe;
|
||||
label: string;
|
||||
onOpen?: () => void;
|
||||
value: string;
|
||||
}) {
|
||||
const content = (
|
||||
<>
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Icon className="size-3" />
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 block truncate text-sm font-medium text-foreground",
|
||||
onOpen && "group-hover:underline",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
if (onOpen) {
|
||||
return (
|
||||
<button
|
||||
className="group min-w-0 rounded-md text-left"
|
||||
onClick={onOpen}
|
||||
type="button"
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return <div className="min-w-0">{content}</div>;
|
||||
}
|
||||
|
||||
function DetailPane({
|
||||
directory,
|
||||
entry,
|
||||
onSelectTag,
|
||||
}: {
|
||||
directory: MarketplaceDirectory;
|
||||
entry: MarketplaceEntry;
|
||||
onSelectTag: (tag: string) => void;
|
||||
}) {
|
||||
const meta = TYPE_META[entry.type];
|
||||
const key = entryKey(entry);
|
||||
const state = directory.actionStates.get(key);
|
||||
const installed = directory.installedKeys.has(key);
|
||||
const busy = isBusy(state);
|
||||
const requiredEnv =
|
||||
entry.install.env?.filter((env) => env.required !== false) ?? [];
|
||||
const optionalEnv =
|
||||
entry.install.env?.filter((env) => env.required === false) ?? [];
|
||||
const message =
|
||||
state?.status === "installed" ||
|
||||
state?.status === "uninstalled" ||
|
||||
state?.status === "failed"
|
||||
? state.message
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full min-w-0 flex-1">
|
||||
<div className="mx-auto grid max-w-3xl gap-6 px-8 py-8 max-[900px]:px-5">
|
||||
<div className="flex items-start gap-5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h1 className="min-w-0 truncate text-2xl font-semibold text-foreground">
|
||||
{entry.name}
|
||||
</h1>
|
||||
{entry.verified ? (
|
||||
<Badge className="border border-sky-500/20 bg-sky-500/10 text-sky-700 dark:text-sky-300">
|
||||
<BadgeCheck />
|
||||
Verified
|
||||
</Badge>
|
||||
) : null}
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
{meta.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{entry.tagline}
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
disabled={!directory.installedReady || busy}
|
||||
onClick={() =>
|
||||
installed
|
||||
? void directory.uninstall(entry)
|
||||
: void directory.install(entry)
|
||||
}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={installed ? "destructive" : "default"}
|
||||
>
|
||||
{busy || !directory.installedReady ? <Spinner /> : null}
|
||||
{installed && !busy ? <Trash2 className="size-4" /> : null}
|
||||
{actionLabelFor(state, installed, directory.installedReady)}
|
||||
</Button>
|
||||
{entry.homepage ? (
|
||||
<Button
|
||||
onClick={() => void openExternalUrl(entry.homepage as string)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Globe className="size-4" />
|
||||
Learn more
|
||||
<ArrowUpRight className="size-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
) : null}
|
||||
{entry.repo ? (
|
||||
<Button
|
||||
onClick={() => void openExternalUrl(entry.repo as string)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Github className="size-4" />
|
||||
Repository
|
||||
<ArrowUpRight className="size-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{message ? (
|
||||
<output
|
||||
className={cn(
|
||||
"mt-2 block text-xs",
|
||||
state?.status === "failed"
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{message}
|
||||
</output>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 rounded-xl border bg-card p-4 max-[720px]:grid-cols-2">
|
||||
{entry.author ? (
|
||||
<MetaCell
|
||||
icon={User}
|
||||
label="Author"
|
||||
onOpen={
|
||||
entry.author.url
|
||||
? () => void openExternalUrl(entry.author?.url as string)
|
||||
: undefined
|
||||
}
|
||||
value={entry.author.name}
|
||||
/>
|
||||
) : null}
|
||||
<MetaCell
|
||||
icon={Scale}
|
||||
label="License"
|
||||
value={entry.license ?? "Not specified"}
|
||||
/>
|
||||
<MetaCell icon={meta.icon} label="Type" value={meta.plural} />
|
||||
</div>
|
||||
|
||||
<section className="grid gap-2">
|
||||
<h2 className="text-sm font-semibold text-foreground">About</h2>
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
{entry.description}
|
||||
</p>
|
||||
{entry.tags.length > 0 ? (
|
||||
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||
{entry.tags.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => onSelectTag(tag)}
|
||||
title={`Filter by ${directory.tagLabels.get(tag) ?? tag}`}
|
||||
type="button"
|
||||
>
|
||||
<Badge
|
||||
className="cursor-pointer text-muted-foreground transition-colors hover:bg-surface-hover-lighter hover:text-foreground"
|
||||
variant="outline"
|
||||
>
|
||||
{directory.tagLabels.get(tag) ?? tag}
|
||||
</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{requiredEnv.length > 0 || optionalEnv.length > 0 ? (
|
||||
<section className="grid gap-2">
|
||||
<h2 className="text-sm font-semibold text-foreground">
|
||||
Environment setup
|
||||
</h2>
|
||||
<div className="grid gap-2">
|
||||
{[...requiredEnv, ...optionalEnv].map((env) => (
|
||||
<div className="rounded-lg border bg-card p-3" key={env.name}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<code
|
||||
className="text-xs font-semibold"
|
||||
style={CODE_FONT_STYLE}
|
||||
>
|
||||
{env.name}
|
||||
</code>
|
||||
<Badge variant="outline">
|
||||
{env.required === false ? "Optional" : "Required"}
|
||||
</Badge>
|
||||
</div>
|
||||
{env.description ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{env.description}
|
||||
</p>
|
||||
) : null}
|
||||
{env.url ? (
|
||||
<button
|
||||
className="mt-1 inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
||||
onClick={() => void openExternalUrl(env.url as string)}
|
||||
type="button"
|
||||
>
|
||||
Get value
|
||||
<ArrowUpRight className="size-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{entry.install.notes ? (
|
||||
<p className="rounded-lg border bg-muted/30 p-3 text-xs leading-5 text-muted-foreground">
|
||||
{entry.install.notes}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
export function MarketplaceExplorerView() {
|
||||
const directory = useMarketplaceDirectory();
|
||||
const [query, setQuery] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState<MarketplacePrimitiveType | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedTag, setSelectedTag] = useState<string | null>(null);
|
||||
const [tagsExpanded, setTagsExpanded] = useState(false);
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
|
||||
// Type + query filtering happens before tag filtering so the tag pill
|
||||
// counts reflect what each tag would narrow the current list down to.
|
||||
const typeAndQueryEntries = useMemo(() => {
|
||||
const entries = directory.catalog?.entries ?? [];
|
||||
const normalized = query.trim().toLowerCase();
|
||||
return entries.filter(
|
||||
(entry) =>
|
||||
(!typeFilter || entry.type === typeFilter) &&
|
||||
(normalized.length === 0 ||
|
||||
entrySearchText(entry, directory.tagLabels).includes(normalized)),
|
||||
);
|
||||
}, [directory.catalog?.entries, directory.tagLabels, query, typeFilter]);
|
||||
|
||||
const tagCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const entry of typeAndQueryEntries) {
|
||||
for (const tag of entry.tags) {
|
||||
counts.set(tag, (counts.get(tag) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}, [typeAndQueryEntries]);
|
||||
|
||||
// Keep the selected tag's pill visible even when the current type/query
|
||||
// has no matches for it, so an active filter can never silently empty the
|
||||
// list while its pill is hidden. Sorted by the catalog's global tag count
|
||||
// (static) so the collapsed row surfaces the most useful categories
|
||||
// without pills reordering as filters change.
|
||||
const visibleTags = useMemo(
|
||||
() =>
|
||||
(directory.catalog?.tags ?? [])
|
||||
.filter(
|
||||
(tag) => (tagCounts.get(tag.id) ?? 0) > 0 || tag.id === selectedTag,
|
||||
)
|
||||
.sort((a, b) => b.count - a.count),
|
||||
[directory.catalog?.tags, selectedTag, tagCounts],
|
||||
);
|
||||
|
||||
// Collapsed, the pill row shows only the top categories (plus the active
|
||||
// tag if it would otherwise be hidden) and a "+N more" toggle.
|
||||
const displayedTags = useMemo(() => {
|
||||
if (tagsExpanded) return visibleTags;
|
||||
const slice = visibleTags.slice(0, COLLAPSED_TAG_COUNT);
|
||||
if (selectedTag && !slice.some((tag) => tag.id === selectedTag)) {
|
||||
const selected = visibleTags.find((tag) => tag.id === selectedTag);
|
||||
if (selected) slice.push(selected);
|
||||
}
|
||||
return slice;
|
||||
}, [selectedTag, tagsExpanded, visibleTags]);
|
||||
|
||||
const hiddenTagCount = visibleTags.length - displayedTags.length;
|
||||
|
||||
const filteredEntries = useMemo(
|
||||
() =>
|
||||
typeAndQueryEntries.filter(
|
||||
(entry) => !selectedTag || entry.tags.includes(selectedTag),
|
||||
),
|
||||
[typeAndQueryEntries, selectedTag],
|
||||
);
|
||||
|
||||
const groups = useMemo(
|
||||
() =>
|
||||
MATURITY_ORDER.map((type) => ({
|
||||
type,
|
||||
entries: filteredEntries
|
||||
.filter((entry) => entry.type === type)
|
||||
.sort(
|
||||
(a, b) => Number(Boolean(b.featured)) - Number(Boolean(a.featured)),
|
||||
),
|
||||
})).filter((group) => group.entries.length > 0),
|
||||
[filteredEntries],
|
||||
);
|
||||
|
||||
const selectedEntry = useMemo(() => {
|
||||
const flat = groups.flatMap((group) => group.entries);
|
||||
return (
|
||||
flat.find((entry) => entryKey(entry) === selectedKey) ?? flat[0] ?? null
|
||||
);
|
||||
}, [groups, selectedKey]);
|
||||
|
||||
const typeCounts = useMemo(() => {
|
||||
const counts = new Map<MarketplacePrimitiveType, number>();
|
||||
for (const entry of directory.catalog?.entries ?? []) {
|
||||
counts.set(entry.type, (counts.get(entry.type) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}, [directory.catalog?.entries]);
|
||||
|
||||
if (directory.loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
<Spinner className="mr-2" />
|
||||
Loading marketplace...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (directory.errorMessage) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
{directory.errorMessage}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 min-w-0">
|
||||
<aside className="flex w-85 shrink-0 flex-col border-r max-[900px]:w-72">
|
||||
<div className="grid gap-2.5 border-b p-3">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="Search marketplace"
|
||||
className="h-9 pl-8"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search marketplace"
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Button
|
||||
aria-pressed={typeFilter === null}
|
||||
onClick={() => setTypeFilter(null)}
|
||||
size="xs"
|
||||
type="button"
|
||||
variant={typeFilter === null ? "default" : "outline"}
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
{MATURITY_ORDER.map((type) => (
|
||||
<Button
|
||||
aria-pressed={typeFilter === type}
|
||||
key={type}
|
||||
onClick={() =>
|
||||
setTypeFilter((current) => (current === type ? null : type))
|
||||
}
|
||||
size="xs"
|
||||
type="button"
|
||||
variant={typeFilter === type ? "default" : "outline"}
|
||||
>
|
||||
{TYPE_META[type].plural}
|
||||
<span className="text-[10px] opacity-70">
|
||||
{typeCounts.get(type) ?? 0}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{visibleTags.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{displayedTags.map((tag) => {
|
||||
const active = selectedTag === tag.id;
|
||||
return (
|
||||
<button
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] transition-colors",
|
||||
active
|
||||
? "border-primary/50 bg-primary/10 text-primary"
|
||||
: "border-border/70 text-muted-foreground hover:bg-surface-hover-lighter hover:text-foreground",
|
||||
)}
|
||||
key={tag.id}
|
||||
onClick={() =>
|
||||
setSelectedTag((current) =>
|
||||
current === tag.id ? null : tag.id,
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{tag.label}
|
||||
{active ? (
|
||||
<X className="size-3" />
|
||||
) : (
|
||||
<span className="opacity-60">
|
||||
{tagCounts.get(tag.id) ?? 0}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{hiddenTagCount > 0 || tagsExpanded ? (
|
||||
<button
|
||||
className="inline-flex items-center rounded-full border border-dashed border-border/70 px-2 py-0.5 text-[11px] text-muted-foreground transition-colors hover:bg-surface-hover-lighter hover:text-foreground"
|
||||
onClick={() => setTagsExpanded((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
{tagsExpanded ? "Show less" : `+${hiddenTagCount} more`}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="grid gap-4 p-2 pb-6">
|
||||
{groups.map((group) => {
|
||||
const meta = TYPE_META[group.type];
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<div className="grid gap-1" key={group.type}>
|
||||
<div className="flex items-center gap-1.5 px-2.5 pt-1">
|
||||
<Icon className="size-3.5 text-primary" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{meta.plural}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/70">
|
||||
{group.entries.length}
|
||||
</span>
|
||||
</div>
|
||||
{group.entries.map((entry) => {
|
||||
const key = entryKey(entry);
|
||||
return (
|
||||
<ListRow
|
||||
entry={entry}
|
||||
installed={directory.installedKeys.has(key)}
|
||||
key={key}
|
||||
onSelect={() => setSelectedKey(key)}
|
||||
selected={
|
||||
selectedEntry !== null &&
|
||||
entryKey(selectedEntry) === key
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{groups.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
No entries match the current filters.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
{selectedEntry ? (
|
||||
<DetailPane
|
||||
directory={directory}
|
||||
entry={selectedEntry}
|
||||
onSelectTag={setSelectedTag}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
Select an entry to see details.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
// @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 {
|
||||
GITHUB_INSTALL_POLL_INTERVAL_MS,
|
||||
GitHubConnectStep,
|
||||
} from "./onboarding-github-step";
|
||||
|
||||
const { invoke, openExternalUrl } = vi.hoisted(() => ({
|
||||
invoke: vi.fn(),
|
||||
openExternalUrl: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/desktop-client", () => ({
|
||||
desktopClient: { invoke },
|
||||
openExternalUrl,
|
||||
}));
|
||||
|
||||
type IntegrationsMock = {
|
||||
list?: () => unknown;
|
||||
githubInstallUrl?: () => unknown;
|
||||
listGitHubRepositories?: () => unknown;
|
||||
};
|
||||
|
||||
function mockIntegrationsCommand(handlers: IntegrationsMock) {
|
||||
invoke.mockImplementation(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command !== "cline_integrations") {
|
||||
throw new Error(`unexpected command: ${command}`);
|
||||
}
|
||||
const operation = String(args?.operation);
|
||||
const handler = handlers[operation as keyof IntegrationsMock];
|
||||
if (!handler) {
|
||||
throw new Error(`unexpected operation: ${operation}`);
|
||||
}
|
||||
return handler();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe("GitHubConnectStep", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
invoke.mockReset();
|
||||
openExternalUrl.mockReset();
|
||||
openExternalUrl.mockResolvedValue(undefined);
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function buttonByText(text: string): HTMLButtonElement {
|
||||
const button = Array.from(container.querySelectorAll("button")).find(
|
||||
(candidate) => candidate.textContent?.trim() === text,
|
||||
);
|
||||
if (!button) {
|
||||
throw new Error(`button not found: ${text}`);
|
||||
}
|
||||
return button;
|
||||
}
|
||||
|
||||
async function render(onContinue = vi.fn()) {
|
||||
await act(async () => {
|
||||
root.render(<GitHubConnectStep onContinue={onContinue} />);
|
||||
});
|
||||
return onContinue;
|
||||
}
|
||||
|
||||
it("continues silently when GitHub is already connected", async () => {
|
||||
mockIntegrationsCommand({ list: () => [{ provider: "github" }] });
|
||||
const onContinue = await render();
|
||||
expect(onContinue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("continues silently when the account is signed out", async () => {
|
||||
mockIntegrationsCommand({
|
||||
list: () => ({ signedIn: false, code: "ACCOUNT_NOT_AUTHENTICATED" }),
|
||||
});
|
||||
const onContinue = await render();
|
||||
expect(onContinue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows the connect card when GitHub is not connected", async () => {
|
||||
mockIntegrationsCommand({ list: () => [] });
|
||||
const onContinue = await render();
|
||||
expect(container.textContent).toContain("Connect GitHub");
|
||||
expect(container.textContent).toContain("Not connected");
|
||||
expect(onContinue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips without connecting", async () => {
|
||||
mockIntegrationsCommand({ list: () => [] });
|
||||
const onContinue = await render();
|
||||
await act(async () => {
|
||||
buttonByText("Skip for now").click();
|
||||
});
|
||||
expect(onContinue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("opens the install URL, polls until connected, and lists repositories", async () => {
|
||||
let connected = false;
|
||||
mockIntegrationsCommand({
|
||||
list: () => (connected ? [{ provider: "github" }] : []),
|
||||
githubInstallUrl: () => ({
|
||||
url: "https://github.com/apps/cline/installations/new?state=abc",
|
||||
}),
|
||||
listGitHubRepositories: () => [
|
||||
{ id: 1, full_name: "cline/cline", private: false },
|
||||
{ id: 2, full_name: "cline/core-platform", private: true },
|
||||
],
|
||||
});
|
||||
const onContinue = await render();
|
||||
vi.useFakeTimers();
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Connect GitHub").click();
|
||||
});
|
||||
expect(openExternalUrl).toHaveBeenCalledWith(
|
||||
"https://github.com/apps/cline/installations/new?state=abc",
|
||||
);
|
||||
expect(container.textContent).toContain(
|
||||
"Finish installing the Cline GitHub App in your browser",
|
||||
);
|
||||
|
||||
// First poll: still not installed.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(GITHUB_INSTALL_POLL_INTERVAL_MS);
|
||||
});
|
||||
expect(container.textContent).not.toContain("Connected");
|
||||
|
||||
connected = true;
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(GITHUB_INSTALL_POLL_INTERVAL_MS);
|
||||
});
|
||||
expect(container.textContent).toContain("Connected");
|
||||
expect(container.textContent).toContain("Accessible repositories");
|
||||
expect(container.textContent).toContain("cline/cline");
|
||||
expect(container.textContent).toContain("cline/core-platform");
|
||||
|
||||
expect(onContinue).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
buttonByText("Continue").click();
|
||||
});
|
||||
expect(onContinue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns to the connect state with an error when the install URL fails", async () => {
|
||||
mockIntegrationsCommand({
|
||||
list: () => [],
|
||||
githubInstallUrl: () => {
|
||||
throw new Error("authentication required");
|
||||
},
|
||||
});
|
||||
await render();
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Connect GitHub").click();
|
||||
});
|
||||
expect(container.textContent).toContain(
|
||||
"Failed to start the GitHub connection",
|
||||
);
|
||||
expect(container.textContent).toContain("authentication required");
|
||||
expect(openExternalUrl).not.toHaveBeenCalled();
|
||||
expect(buttonByText("Connect GitHub")).toBeDefined();
|
||||
});
|
||||
|
||||
it("stops waiting when the browser round-trip is cancelled", async () => {
|
||||
mockIntegrationsCommand({
|
||||
list: () => [],
|
||||
githubInstallUrl: () => ({ url: "https://github.com/install" }),
|
||||
});
|
||||
await render();
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Connect GitHub").click();
|
||||
});
|
||||
expect(container.textContent).toContain("Finish installing");
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Cancel").click();
|
||||
});
|
||||
expect(container.textContent).not.toContain("Finish installing");
|
||||
expect(buttonByText("Connect GitHub")).toBeDefined();
|
||||
});
|
||||
|
||||
it("stops polling once cancelled instead of leaving the interval running", async () => {
|
||||
mockIntegrationsCommand({
|
||||
list: () => [],
|
||||
githubInstallUrl: () => ({ url: "https://github.com/install" }),
|
||||
});
|
||||
await render();
|
||||
vi.useFakeTimers();
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Connect GitHub").click();
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(GITHUB_INSTALL_POLL_INTERVAL_MS);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Cancel").click();
|
||||
});
|
||||
const callsAfterCancel = invoke.mock.calls.length;
|
||||
|
||||
// No further polls may fire after cancelling.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(GITHUB_INSTALL_POLL_INTERVAL_MS * 5);
|
||||
});
|
||||
expect(invoke.mock.calls.length).toBe(callsAfterCancel);
|
||||
});
|
||||
|
||||
it("stops polling and reports the signed-out session when the account expires", async () => {
|
||||
let signedOut = false;
|
||||
mockIntegrationsCommand({
|
||||
list: () =>
|
||||
signedOut ? { signedIn: false, code: "ACCOUNT_NOT_AUTHENTICATED" } : [],
|
||||
githubInstallUrl: () => ({ url: "https://github.com/install" }),
|
||||
});
|
||||
await render();
|
||||
vi.useFakeTimers();
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Connect GitHub").click();
|
||||
});
|
||||
expect(container.textContent).toContain("Finish installing");
|
||||
|
||||
signedOut = true;
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(GITHUB_INSTALL_POLL_INTERVAL_MS);
|
||||
});
|
||||
|
||||
// Back to the actionable connect state, not a permanent spinner.
|
||||
expect(container.textContent).not.toContain("Finish installing");
|
||||
expect(container.textContent).toContain("Your Cline account session ended");
|
||||
expect(buttonByText("Connect GitHub")).toBeDefined();
|
||||
|
||||
const callsAfterSignOut = invoke.mock.calls.length;
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(GITHUB_INSTALL_POLL_INTERVAL_MS * 5);
|
||||
});
|
||||
expect(invoke.mock.calls.length).toBe(callsAfterSignOut);
|
||||
});
|
||||
|
||||
it("checks integrations once even when the parent re-renders", async () => {
|
||||
mockIntegrationsCommand({ list: () => [] });
|
||||
const onContinue = vi.fn();
|
||||
await act(async () => {
|
||||
root.render(<GitHubConnectStep onContinue={onContinue} />);
|
||||
});
|
||||
const callsAfterMount = invoke.mock.calls.length;
|
||||
expect(callsAfterMount).toBe(1);
|
||||
|
||||
// A parent re-render passing a brand-new inline callback must not
|
||||
// re-trigger the initial check.
|
||||
await act(async () => {
|
||||
root.render(<GitHubConnectStep onContinue={() => onContinue()} />);
|
||||
});
|
||||
expect(invoke.mock.calls.length).toBe(callsAfterMount);
|
||||
});
|
||||
});
|
||||
+288
@@ -0,0 +1,288 @@
|
||||
"use client";
|
||||
|
||||
import { GitHubIcon } from "@cline/ui";
|
||||
import { Loader2, Lock } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
type ClineGitHubRepository,
|
||||
fetchGitHubInstallUrl,
|
||||
findGitHubIntegration,
|
||||
listClineGitHubRepositories,
|
||||
listClineIntegrations,
|
||||
} from "@/lib/cline-integrations";
|
||||
import { openExternalUrl } from "@/lib/desktop-client";
|
||||
|
||||
export const GITHUB_INSTALL_POLL_INTERVAL_MS = 3_000;
|
||||
|
||||
type GitHubStepPhase = "checking" | "connect" | "waiting" | "connected";
|
||||
|
||||
export function GitHubConnectStep({ onContinue }: { onContinue: () => void }) {
|
||||
const [phase, setPhase] = useState<GitHubStepPhase>("checking");
|
||||
const [connectError, setConnectError] = useState<string | null>(null);
|
||||
const [repos, setRepos] = useState<ClineGitHubRepository[] | null>(null);
|
||||
|
||||
const onContinueRef = useRef(onContinue);
|
||||
useEffect(() => {
|
||||
onContinueRef.current = onContinue;
|
||||
}, [onContinue]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await listClineIntegrations();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
result.status === "not-authenticated" ||
|
||||
findGitHubIntegration(result.integrations)
|
||||
) {
|
||||
onContinueRef.current();
|
||||
return;
|
||||
}
|
||||
setPhase("connect");
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setPhase("connect");
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// The install finishes in the external browser, which cannot navigate the
|
||||
// app back, so poll the integrations list until the installation lands.
|
||||
if (phase !== "waiting") {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
let inFlight = false;
|
||||
let interval: ReturnType<typeof setInterval> | undefined;
|
||||
const stop = () => {
|
||||
cancelled = true;
|
||||
if (interval !== undefined) {
|
||||
clearInterval(interval);
|
||||
interval = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const result = await listClineIntegrations();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
if (result.status === "not-authenticated") {
|
||||
// The account session ended mid-install. Nothing will ever
|
||||
// arrive, so stop polling instead of spinning forever.
|
||||
stop();
|
||||
setConnectError(
|
||||
"Your Cline account session ended. Sign in again to connect GitHub.",
|
||||
);
|
||||
setPhase("connect");
|
||||
return;
|
||||
}
|
||||
if (findGitHubIntegration(result.integrations)) {
|
||||
setPhase("connected");
|
||||
}
|
||||
} catch {
|
||||
// Transient failures keep polling; the user can cancel anytime.
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
interval = setInterval(() => {
|
||||
if (inFlight) {
|
||||
return;
|
||||
}
|
||||
inFlight = true;
|
||||
void poll();
|
||||
}, GITHUB_INSTALL_POLL_INTERVAL_MS);
|
||||
|
||||
// Covers unmount and every phase change, including the Cancel button.
|
||||
return stop;
|
||||
}, [phase]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase !== "connected") {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
listClineGitHubRepositories()
|
||||
.then((result) => {
|
||||
if (!cancelled) {
|
||||
setRepos(result);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setRepos([]);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [phase]);
|
||||
|
||||
const connect = useCallback(async () => {
|
||||
setConnectError(null);
|
||||
setPhase("waiting");
|
||||
try {
|
||||
const url = await fetchGitHubInstallUrl();
|
||||
await openExternalUrl(url);
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
setConnectError(`Failed to start the GitHub connection: ${reason}`);
|
||||
setPhase("connect");
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (phase === "checking") {
|
||||
return (
|
||||
<output
|
||||
aria-label="Checking GitHub connection"
|
||||
className="flex items-center justify-center py-16"
|
||||
>
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</output>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground">
|
||||
Connect GitHub
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Grant Cline access to your GitHub repositories to supercharge it with
|
||||
real-world context. You can always do this later from your dashboard.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 rounded-2xl border border-border/70 bg-background/60 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-secondary text-foreground">
|
||||
<GitHubIcon className="size-4" />
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-base font-semibold text-foreground">GitHub</p>
|
||||
{phase === "connected" ? (
|
||||
<Badge
|
||||
className="bg-primary/15 text-primary"
|
||||
variant="secondary"
|
||||
>
|
||||
Connected
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Not connected</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{phase === "connect" ? (
|
||||
<Button
|
||||
className="rounded-full"
|
||||
onClick={() => void connect()}
|
||||
type="button"
|
||||
>
|
||||
Connect GitHub
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{phase === "waiting" ? (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3">
|
||||
<p className="inline-flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Finish installing the Cline GitHub App in your browser...
|
||||
</p>
|
||||
<button
|
||||
className="text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={() => setPhase("connect")}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{connectError ? (
|
||||
<p className="mt-2 text-xs text-destructive" role="alert">
|
||||
{connectError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{phase === "connected" ? (
|
||||
<div className="mt-4 border-t border-border/70 pt-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Accessible repositories
|
||||
{repos ? (
|
||||
<span className="ml-2 font-normal normal-case">
|
||||
({repos.length})
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
{repos === null ? (
|
||||
<p className="mt-2 inline-flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading repositories...
|
||||
</p>
|
||||
) : repos.length > 0 ? (
|
||||
<ul className="mt-2 flex max-h-40 flex-col gap-1 overflow-y-auto pr-1">
|
||||
{repos.map((repo) => (
|
||||
<li
|
||||
className="flex items-center gap-2 text-sm text-foreground"
|
||||
key={repo.id ?? repo.full_name}
|
||||
>
|
||||
<Lock
|
||||
aria-hidden="true"
|
||||
className={
|
||||
repo.private
|
||||
? "size-3 shrink-0 text-muted-foreground"
|
||||
: "size-3 shrink-0 text-transparent"
|
||||
}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{repo.full_name ?? repo.name}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
No repositories found. You may need to grant access in your
|
||||
GitHub App settings.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex justify-center">
|
||||
{phase === "connected" ? (
|
||||
<Button
|
||||
className="h-11 w-full rounded-full text-base"
|
||||
onClick={onContinue}
|
||||
type="button"
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
) : (
|
||||
<button
|
||||
className="text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={onContinue}
|
||||
type="button"
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+79
-1
@@ -9,7 +9,11 @@ import {
|
||||
parseModelSelectionStorage,
|
||||
} from "@/lib/model-selection";
|
||||
import type { Provider } from "@/lib/provider-schema";
|
||||
import { OnboardingView, sortProvidersForApiKeySetup } from "./onboarding-view";
|
||||
import {
|
||||
GITHUB_ONBOARDING_FEATURE_FLAG,
|
||||
OnboardingView,
|
||||
sortProvidersForApiKeySetup,
|
||||
} from "./onboarding-view";
|
||||
|
||||
const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() }));
|
||||
vi.mock("@/lib/desktop-client", () => ({
|
||||
@@ -17,6 +21,10 @@ vi.mock("@/lib/desktop-client", () => ({
|
||||
openExternalUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
const GITHUB_STEP_ENABLED_FLAGS = {
|
||||
flags: { [GITHUB_ONBOARDING_FEATURE_FLAG]: true },
|
||||
};
|
||||
|
||||
class StorageStub implements Storage {
|
||||
readonly #values = new Map<string, string>();
|
||||
get length() {
|
||||
@@ -141,6 +149,9 @@ describe("OnboardingView", () => {
|
||||
settingsPath: "/tmp/providers.json",
|
||||
};
|
||||
}
|
||||
if (command === "get_feature_flags") {
|
||||
return GITHUB_STEP_ENABLED_FLAGS;
|
||||
}
|
||||
return {};
|
||||
});
|
||||
container = document.createElement("div");
|
||||
@@ -375,6 +386,9 @@ describe("OnboardingView", () => {
|
||||
if (command === "list_provider_catalog") {
|
||||
return { providers: [makeProvider()], settingsPath: "/tmp/p.json" };
|
||||
}
|
||||
if (command === "get_feature_flags") {
|
||||
return GITHUB_STEP_ENABLED_FLAGS;
|
||||
}
|
||||
return {};
|
||||
});
|
||||
await render();
|
||||
@@ -386,6 +400,11 @@ describe("OnboardingView", () => {
|
||||
await act(async () => {
|
||||
buttonByText("Continue").click();
|
||||
});
|
||||
// Connecting a Cline account routes through the GitHub integration step.
|
||||
expect(container.textContent).toContain("Connect GitHub");
|
||||
await act(async () => {
|
||||
buttonByText("Skip for now").click();
|
||||
});
|
||||
// The redesigned completion step places transparent content over a static,
|
||||
// wide version of the hero grid.
|
||||
expect(container.textContent).toContain("You're all set");
|
||||
@@ -404,6 +423,57 @@ describe("OnboardingView", () => {
|
||||
).toBe("cline");
|
||||
});
|
||||
|
||||
it("skips the GitHub step silently when the integration is already connected", async () => {
|
||||
invoke.mockImplementation(async (command: string) => {
|
||||
if (command === "cline_account") {
|
||||
return { email: "dev@example.com", displayName: "Dev" };
|
||||
}
|
||||
if (command === "cline_integrations") {
|
||||
return [{ provider: "github" }];
|
||||
}
|
||||
if (command === "list_provider_catalog") {
|
||||
return { providers: [makeProvider()], settingsPath: "/tmp/p.json" };
|
||||
}
|
||||
if (command === "get_feature_flags") {
|
||||
return GITHUB_STEP_ENABLED_FLAGS;
|
||||
}
|
||||
return {};
|
||||
});
|
||||
await render();
|
||||
await act(async () => {
|
||||
buttonByText("Get started").click();
|
||||
});
|
||||
await act(async () => {
|
||||
buttonByText("Continue").click();
|
||||
});
|
||||
expect(container.textContent).not.toContain("Connect GitHub");
|
||||
expect(container.textContent).toContain("You're all set");
|
||||
});
|
||||
|
||||
it("bypasses the GitHub step when the rollout flag is off", async () => {
|
||||
invoke.mockImplementation(async (command: string) => {
|
||||
if (command === "cline_account") {
|
||||
return { email: "dev@example.com", displayName: "Dev" };
|
||||
}
|
||||
if (command === "list_provider_catalog") {
|
||||
return { providers: [makeProvider()], settingsPath: "/tmp/p.json" };
|
||||
}
|
||||
// get_feature_flags falls through to the empty snapshot, which is
|
||||
// also what an unreachable sidecar resolves to: flag disabled.
|
||||
return {};
|
||||
});
|
||||
await render();
|
||||
await act(async () => {
|
||||
buttonByText("Get started").click();
|
||||
});
|
||||
await act(async () => {
|
||||
buttonByText("Continue").click();
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith("get_feature_flags");
|
||||
expect(container.textContent).not.toContain("Connect GitHub");
|
||||
expect(container.textContent).toContain("You're all set");
|
||||
});
|
||||
|
||||
it("lets the user cancel a pending browser sign-in", async () => {
|
||||
await render();
|
||||
await act(async () => {
|
||||
@@ -482,6 +552,10 @@ describe("OnboardingView", () => {
|
||||
enabled: true,
|
||||
api_key: "cline_key_123",
|
||||
});
|
||||
expect(container.textContent).toContain("Connect GitHub");
|
||||
await act(async () => {
|
||||
buttonByText("Skip for now").click();
|
||||
});
|
||||
expect(container.textContent).toContain("You're all set");
|
||||
expect(container.textContent).toContain("Your Cline account is connected");
|
||||
expect(
|
||||
@@ -583,6 +657,10 @@ describe("OnboardingView", () => {
|
||||
expect(invoke).toHaveBeenCalledWith("run_provider_oauth_login", {
|
||||
provider: "cline",
|
||||
});
|
||||
expect(container.textContent).toContain("Connect GitHub");
|
||||
await act(async () => {
|
||||
buttonByText("Skip for now").click();
|
||||
});
|
||||
expect(container.textContent).toContain("You're all set");
|
||||
expect(
|
||||
parseModelSelectionStorage(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Button, IconButton } from "@cline/ui";
|
||||
import { AgentWelcomeHero, Button, IconButton } from "@cline/ui";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
@@ -20,9 +20,10 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { WelcomeHero } from "@/components/views/chat/welcome-hero";
|
||||
import { GitHubConnectStep } from "@/components/views/onboarding/onboarding-github-step";
|
||||
import { useAccount } from "@/contexts/account-context";
|
||||
import { OAUTH_MANAGED_PROVIDERS } from "@/hooks/chat-session/constants";
|
||||
import { isFeatureEnabled, useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { isClineAccountNotAuthenticatedResult } from "@/lib/cline-account-state";
|
||||
import { desktopClient, openExternalUrl } from "@/lib/desktop-client";
|
||||
import {
|
||||
@@ -42,7 +43,9 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
const CREATE_ACCOUNT_URL = "https://app.cline.bot";
|
||||
|
||||
export type OnboardingStep = "welcome" | "connect" | "done";
|
||||
export const GITHUB_ONBOARDING_FEATURE_FLAG = "code-onboarding-github";
|
||||
|
||||
export type OnboardingStep = "welcome" | "connect" | "github" | "done";
|
||||
|
||||
type OnboardingConnection =
|
||||
| { kind: "cline" }
|
||||
@@ -268,7 +271,7 @@ function WelcomeStep({ onContinue }: { onContinue: () => void }) {
|
||||
<OnboardingContent surface="transparent">
|
||||
<div className="flex flex-col items-center py-4 text-center">
|
||||
<div className="w-full">
|
||||
<WelcomeHero variant="bot-only" />
|
||||
<AgentWelcomeHero variant="bot-only" />
|
||||
</div>
|
||||
<h1 className="mt-5 text-4xl font-semibold text-foreground">Cline</h1>
|
||||
<p className="mt-2 text-lg text-foreground">Build software your way</p>
|
||||
@@ -851,12 +854,6 @@ function DoneStep({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-screen first-run experience: welcome, connect a model provider (Cline
|
||||
* account or bring-your-own API key), done. Rendered by the app shell while
|
||||
* onboarding has not been completed (see lib/onboarding.ts); `onComplete`
|
||||
* marks it completed and returns to the chat.
|
||||
*/
|
||||
export function OnboardingView({
|
||||
onComplete,
|
||||
initialStep = "welcome",
|
||||
@@ -868,6 +865,11 @@ export function OnboardingView({
|
||||
const [connection, setConnection] = useState<OnboardingConnection | null>(
|
||||
null,
|
||||
);
|
||||
const { flags } = useFeatureFlags();
|
||||
const githubStepEnabled = isFeatureEnabled(
|
||||
flags,
|
||||
GITHUB_ONBOARDING_FEATURE_FLAG,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full overflow-y-auto bg-background">
|
||||
@@ -880,7 +882,7 @@ export function OnboardingView({
|
||||
}
|
||||
data-onboarding-grid={step}
|
||||
>
|
||||
<WelcomeHero
|
||||
<AgentWelcomeHero
|
||||
interactive={step !== "done"}
|
||||
layout={step === "done" ? "wide-grid" : "full-bleed"}
|
||||
variant="grid-only"
|
||||
@@ -893,10 +895,20 @@ export function OnboardingView({
|
||||
onBack={() => setStep("welcome")}
|
||||
onConnected={(nextConnection) => {
|
||||
setConnection(nextConnection);
|
||||
setStep("done");
|
||||
// The GitHub integration lives on the Cline account, so the
|
||||
// step only applies when one is connected
|
||||
setStep(
|
||||
nextConnection.kind === "cline" && githubStepEnabled
|
||||
? "github"
|
||||
: "done",
|
||||
);
|
||||
}}
|
||||
onSkip={onComplete}
|
||||
/>
|
||||
) : step === "github" ? (
|
||||
<OnboardingContent surface="panel">
|
||||
<GitHubConnectStep onContinue={() => setStep("done")} />
|
||||
</OnboardingContent>
|
||||
) : (
|
||||
<DoneStep connection={connection} onFinish={onComplete} />
|
||||
)}
|
||||
|
||||
@@ -33,9 +33,18 @@ Explicitly skip style nits, theoretical edge cases with no realistic trigger, an
|
||||
- Before opening a PR, check whether an open PR already fixes the same bug. If one exists, note that it is awaiting review with a link instead of duplicating it. If a previous fix was closed without merging, do not re-open one unless the relevant code has materially changed.
|
||||
- Only open a PR when you are highly confident the bug is real and the fix is correct. If PRs are not available in this workspace, commit the fix to a branch and describe it in your summary.
|
||||
|
||||
## Wrap up
|
||||
## Final report
|
||||
|
||||
Finish with a short report: what you inspected, and for each fix the bug, its impact, the root cause, and how you validated the change. If nothing clears the bar, a plain "no critical bugs found" summary is the expected outcome most runs.`;
|
||||
Your last message is the report the user reads, so write it about the code, not about your session. Do not include process narration: no environment assessment, no list of commands you ran, no "what I attempted" or "investigation summary" sections, and no restating these instructions.
|
||||
|
||||
If you fixed bugs, write one short section per bug covering:
|
||||
- **Bug**: what is wrong and where, in one line
|
||||
- **Impact**: the concrete consequence users would hit
|
||||
- **Root cause**: the change that introduced it
|
||||
- **Fix**: what you changed, with a link to the PR or branch
|
||||
- **Validation**: the test or check that proves the fix works
|
||||
|
||||
If nothing clears the bar (the expected outcome most runs), reply with a single line like "No critical bugs found in the N commits since the last run", optionally followed by up to three bullets on areas you inspected closely and why they are sound.`;
|
||||
|
||||
const SECURITY_SCAN_PROMPT = `You are a scheduled security reviewer for this repository. Find medium, high, or critical vulnerabilities with a genuine end-to-end attack path, not theoretical weaknesses.
|
||||
|
||||
@@ -55,9 +64,18 @@ A finding only counts if you can walk the entire chain: who the attacker is, wha
|
||||
|
||||
Keep a running log of past findings in a local notes file (for example \`security-findings.local.md\` in the workspace root, excluded from version control). Read it before scanning, do not re-report anything already listed, and append new validated findings after each run.
|
||||
|
||||
## Reporting
|
||||
## Final report
|
||||
|
||||
For each new validated finding, write up the severity, the affected file, the full attack path, and the highest-leverage remediation. Treat findings as sensitive: keep them in the local report, and do not open a PR or publish them elsewhere from this scan. If nothing new clears the bar, say so briefly and stop.`;
|
||||
Your last message is the report the user reads, so write it about the findings, not about your session: no environment assessment, no list of commands you ran, and no "investigation summary" sections.
|
||||
|
||||
If you validated new findings, write one short section per finding covering:
|
||||
- **Severity**: medium, high, or critical
|
||||
- **Where**: the affected file and entry point
|
||||
- **Attack path**: who the attacker is, what input they control, and how it reaches the vulnerable code
|
||||
- **Impact**: what the attacker gains
|
||||
- **Remediation**: the highest-leverage fix
|
||||
|
||||
Treat findings as sensitive: keep full details in the local notes file, and do not open a PR or publish them elsewhere from this scan. If nothing new clears the bar (the expected outcome most runs), reply with a single line like "No new vulnerabilities found", optionally noting the areas you inspected most closely.`;
|
||||
|
||||
const DAILY_DIGEST_PROMPT = `You write a daily engineering digest for this repository.
|
||||
|
||||
@@ -79,7 +97,7 @@ Review everything that landed in the last 24 hours (commits and merged PRs) and
|
||||
|
||||
## Format
|
||||
|
||||
Start with the date range covered, then 3-7 bullets of meaningful changes, then a short "Worth watching" section with 1-3 risks or pending follow-ups.`;
|
||||
Your last message is the digest itself, so send it directly: no preamble, no process narration, and no notes about how you gathered the information. Start with the date range covered, then 3-7 bullets of meaningful changes, then a short "Worth watching" section with 1-3 risks or pending follow-ups.`;
|
||||
|
||||
const UPDATE_DOCS_PROMPT = `You are a documentation maintainer that runs on a schedule. Keep this repository's docs accurate as the code evolves.
|
||||
|
||||
@@ -100,9 +118,16 @@ Compare recent code changes against the existing documentation and close the gap
|
||||
- Explain intent and usage with concrete examples and constraints, and keep pages structured for scanning.
|
||||
- Match the style, tone, and location conventions of the docs already in this repository.
|
||||
|
||||
## Output
|
||||
## Final report
|
||||
|
||||
Open a focused, docs-only PR (or commit the updates to a branch if PRs are unavailable). Summarize which docs you added or updated, the code paths they now cover, and the knowledge gaps you closed. If everything is already accurate, report that and finish.`;
|
||||
Open a focused, docs-only PR (or commit the updates to a branch if PRs are unavailable).
|
||||
|
||||
Your last message is the report the user reads, so write it about the docs, not about your session: no environment assessment or command-by-command narration. Cover:
|
||||
- **Changed**: each doc you added or updated, with a link to the PR or branch
|
||||
- **Now covers**: the code paths or behaviors the updates document
|
||||
- **Gaps closed**: what was stale, wrong, or missing before
|
||||
|
||||
If everything is already accurate, reply with a single line saying so, optionally noting the areas you verified.`;
|
||||
|
||||
export const ROUTINE_TEMPLATES: RoutineTemplate[] = [
|
||||
{
|
||||
|
||||
@@ -59,7 +59,7 @@ import {
|
||||
setStoredHubTheme,
|
||||
} from "@/lib/theme";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MarketplaceView } from "../marketplace-view";
|
||||
import { MarketplaceExplorerView } from "../marketplace-explorer-view";
|
||||
import { PageFrame, PageHeader } from "../page-layout";
|
||||
import { AccountView } from "./account-view";
|
||||
import { AddProviderContent, type AddProviderPayload } from "./add-provider";
|
||||
@@ -217,6 +217,31 @@ export function SettingsView({
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [activeNav, loadProviderCatalog]);
|
||||
|
||||
/**
|
||||
* Silently refreshes view state from the authoritative catalog after a
|
||||
* successful save, without toggling the loading screen. Optimistic
|
||||
* mutations can't know sidecar-computed fields (`configured`), so the
|
||||
* Configured badge would otherwise stay stale until a remount. Claims a
|
||||
* new generation like loadProviderCatalog, so overlapping resyncs, loads,
|
||||
* and edits always resolve to the newest snapshot: anything older still
|
||||
* in flight is discarded on arrival.
|
||||
*/
|
||||
const resyncProviderCatalog = useCallback(async () => {
|
||||
const generation = ++catalogGenerationRef.current;
|
||||
try {
|
||||
const payload = await desktopClient.invoke<ProviderCatalogResponse>(
|
||||
"list_provider_catalog",
|
||||
);
|
||||
if (generation !== catalogGenerationRef.current) {
|
||||
return;
|
||||
}
|
||||
setProvidersWithCache(payload.providers);
|
||||
} catch {
|
||||
// Background refresh only; the optimistic state remains until the
|
||||
// next full load.
|
||||
}
|
||||
}, [setProvidersWithCache]);
|
||||
|
||||
const persistProviderSettings = useCallback(
|
||||
async (
|
||||
id: string,
|
||||
@@ -237,6 +262,10 @@ export function SettingsView({
|
||||
? toSettingsPatch(updates.configValues)
|
||||
: undefined,
|
||||
});
|
||||
// Pick up sidecar-computed readiness (`configured`) for the
|
||||
// just-saved settings so the Configured badge and count update
|
||||
// without a remount.
|
||||
void resyncProviderCatalog();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
@@ -261,7 +290,7 @@ export function SettingsView({
|
||||
invalidateProviderCatalogCache();
|
||||
}
|
||||
},
|
||||
[loadProviderCatalog],
|
||||
[loadProviderCatalog, resyncProviderCatalog],
|
||||
);
|
||||
|
||||
const connectProvider = useCallback(
|
||||
@@ -428,6 +457,11 @@ export function SettingsView({
|
||||
// must learn about the new OAuth connection too, not just this
|
||||
// view's local provider state.
|
||||
invalidateProviderCatalogCache();
|
||||
// Fetch the authoritative post-login snapshot. The resync claims a
|
||||
// new generation, so an older load or resync still in flight can't
|
||||
// arrive late and overwrite the just-connected state, and its own
|
||||
// response also covers any provider saved moments earlier.
|
||||
void resyncProviderCatalog();
|
||||
setSelectedProviderId(id);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
@@ -582,10 +616,7 @@ export function SettingsView({
|
||||
onOpenMarketplace={() => onNavigateSection("Marketplace")}
|
||||
/>
|
||||
) : activeNav === "Marketplace" ? (
|
||||
<MarketplaceView
|
||||
onOpenInstalled={() => onNavigateSection("Customize")}
|
||||
variant="directory"
|
||||
/>
|
||||
<MarketplaceExplorerView />
|
||||
) : activeNav === "Channels" ? (
|
||||
<ChannelsContent />
|
||||
) : activeNav === "Schedules" ? (
|
||||
@@ -605,9 +636,8 @@ export function SettingsView({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid h-full grid-rows-[3rem_minmax(0,1fr)] overflow-hidden bg-background md:block">
|
||||
<div aria-hidden="true" className="md:hidden" />
|
||||
<div className="min-h-0 overflow-hidden md:h-full">{content}</div>
|
||||
<div className="h-full overflow-hidden bg-background">
|
||||
<div className="h-full min-h-0 overflow-hidden">{content}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+10
-5
@@ -4,7 +4,10 @@ import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Provider } from "@/lib/provider-schema";
|
||||
import { defaultTranscriptionModel, VoiceInputContent } from "./voice-input-view";
|
||||
import {
|
||||
defaultTranscriptionModel,
|
||||
VoiceInputContent,
|
||||
} from "./voice-input-view";
|
||||
|
||||
const { fetchProviderCatalogMock, invokeMock, notifyMock } = vi.hoisted(() => ({
|
||||
fetchProviderCatalogMock: vi.fn(),
|
||||
@@ -113,9 +116,9 @@ describe("VoiceInputContent", () => {
|
||||
expect(container.textContent).toContain(
|
||||
"Voice input needs a configured model provider",
|
||||
);
|
||||
const openProviders = Array.from(
|
||||
container.querySelectorAll("button"),
|
||||
).find((button) => button.textContent?.includes("Open Model Providers"));
|
||||
const openProviders = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("Open Model Providers"),
|
||||
);
|
||||
await act(async () => openProviders?.click());
|
||||
expect(onOpenModelProviders).toHaveBeenCalledOnce();
|
||||
});
|
||||
@@ -165,7 +168,9 @@ describe("VoiceInputContent", () => {
|
||||
model: "scribe_v2_realtime",
|
||||
});
|
||||
expect(notifyMock).toHaveBeenCalled();
|
||||
const selected = container.querySelector('[role="radio"][aria-checked="true"]');
|
||||
const selected = container.querySelector(
|
||||
'[role="radio"][aria-checked="true"]',
|
||||
);
|
||||
expect(selected?.textContent).toContain("Scribe v2 Realtime");
|
||||
expect(selected?.textContent).toContain("Default");
|
||||
});
|
||||
|
||||
@@ -4,6 +4,11 @@ import { AudioLines, Mic, Radio } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import { isProviderConnected } from "@/lib/provider-connection";
|
||||
import {
|
||||
@@ -306,20 +311,34 @@ export function VoiceInputContent({
|
||||
<span className="truncate text-sm text-foreground">
|
||||
{model.name}
|
||||
</span>
|
||||
{isStreamingModel(model) ? (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded bg-surface-hover px-1.5 py-px text-[0.625rem] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<Radio aria-hidden="true" className="size-3" />
|
||||
Live
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded bg-surface-hover px-1.5 py-px text-[0.625rem] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<AudioLines
|
||||
aria-hidden="true"
|
||||
className="size-3"
|
||||
/>
|
||||
After recording
|
||||
</span>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded bg-surface-hover px-1.5 py-px text-[0.625rem] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{isStreamingModel(model) ? (
|
||||
<>
|
||||
<Radio
|
||||
aria-hidden="true"
|
||||
className="size-3"
|
||||
/>
|
||||
Live
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AudioLines
|
||||
aria-hidden="true"
|
||||
className="size-3"
|
||||
/>
|
||||
After recording
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isStreamingModel(model)
|
||||
? "Streaming transcription: text appears in the chat box while you speak."
|
||||
: "Transcribes in one pass after you stop the recording."}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{isDefault ? (
|
||||
<span className="shrink-0 text-[0.625rem] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Default
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, useState } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
WindowTitleBar,
|
||||
WindowTitleBarContent,
|
||||
WindowTitleBarProvider,
|
||||
} from "@/components/window-title-bar";
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
function StatefulProjectedControl() {
|
||||
const [count, setCount] = useState(0);
|
||||
return (
|
||||
<button
|
||||
data-testid="projected-control"
|
||||
type="button"
|
||||
onClick={() => setCount((value) => value + 1)}
|
||||
>
|
||||
Count {count}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function renderShell(contentEnabled: boolean) {
|
||||
return (
|
||||
<WindowTitleBarProvider contentEnabled={contentEnabled}>
|
||||
<nav>Sidebar</nav>
|
||||
<main>
|
||||
<button data-testid="sidebar-trigger" type="button">
|
||||
Toggle Sidebar
|
||||
</button>
|
||||
<WindowTitleBar />
|
||||
<section data-testid="page">Page content</section>
|
||||
<WindowTitleBarContent>
|
||||
<StatefulProjectedControl />
|
||||
</WindowTitleBarContent>
|
||||
</main>
|
||||
</WindowTitleBarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("WindowTitleBar", () => {
|
||||
it("reserves an in-flow draggable row before page content inside main", async () => {
|
||||
await act(async () => root.render(renderShell(false)));
|
||||
|
||||
const main = container.querySelector("main");
|
||||
const titleBar = main?.querySelector('[data-slot="window-title-bar"]');
|
||||
const page = main?.querySelector('[data-testid="page"]');
|
||||
expect(titleBar?.getAttribute("data-tauri-drag-region")).toBe("deep");
|
||||
expect(titleBar?.className).toContain("h-12");
|
||||
expect(titleBar?.className).toContain("shrink-0");
|
||||
expect(titleBar?.nextElementSibling).toBe(page);
|
||||
});
|
||||
|
||||
it("projects controls into the title bar within the main landmark", async () => {
|
||||
await act(async () => root.render(renderShell(true)));
|
||||
|
||||
const titleBar = container.querySelector('[data-slot="window-title-bar"]');
|
||||
const button = titleBar?.querySelector("button");
|
||||
expect(button?.textContent).toBe("Count 0");
|
||||
expect(button?.closest("main")).not.toBeNull();
|
||||
expect(
|
||||
container
|
||||
.querySelector('[data-testid="sidebar-trigger"]')
|
||||
?.compareDocumentPosition(button!),
|
||||
).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
expect(
|
||||
container.querySelector("nav")?.compareDocumentPosition(button!),
|
||||
).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
});
|
||||
|
||||
it("hides projected controls without unmounting their state", async () => {
|
||||
await act(async () => root.render(renderShell(true)));
|
||||
const button = container.querySelector(
|
||||
'[data-testid="projected-control"]',
|
||||
) as HTMLButtonElement | null;
|
||||
await act(async () => button?.click());
|
||||
expect(
|
||||
container.querySelector('[data-testid="projected-control"]')?.textContent,
|
||||
).toBe("Count 1");
|
||||
|
||||
await act(async () => root.render(renderShell(false)));
|
||||
const hiddenHost = container.querySelector<HTMLDivElement>(
|
||||
'[data-slot="window-title-bar-content-host"]',
|
||||
);
|
||||
expect(hiddenHost?.hidden).toBe(true);
|
||||
expect(
|
||||
container.querySelector('[data-testid="projected-control"]')?.textContent,
|
||||
).toBe("Count 1");
|
||||
|
||||
await act(async () => root.render(renderShell(true)));
|
||||
expect(
|
||||
container.querySelector<HTMLDivElement>(
|
||||
'[data-slot="window-title-bar-content-host"]',
|
||||
)?.hidden,
|
||||
).toBe(false);
|
||||
expect(
|
||||
container.querySelector('[data-testid="projected-control"]')?.textContent,
|
||||
).toBe("Count 1");
|
||||
});
|
||||
|
||||
it("renders a drag-only row for a full-screen shell overlay", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WindowTitleBarProvider>
|
||||
<div className="relative" data-testid="onboarding-shell">
|
||||
<WindowTitleBar
|
||||
className="absolute inset-x-0 top-0"
|
||||
hostContent={false}
|
||||
/>
|
||||
<div data-testid="onboarding-content">Onboarding</div>
|
||||
</div>
|
||||
</WindowTitleBarProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
const shell = container.querySelector('[data-testid="onboarding-shell"]');
|
||||
const titleBar = shell?.querySelector('[data-slot="window-title-bar"]');
|
||||
expect(titleBar?.className).toContain("absolute");
|
||||
expect(titleBar?.nextElementSibling).toBe(
|
||||
shell?.querySelector('[data-testid="onboarding-content"]'),
|
||||
);
|
||||
expect(
|
||||
titleBar?.querySelector('[data-slot="window-title-bar-content-host"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, type ReactNode, useContext, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type WindowTitleBarContextValue = {
|
||||
contentEnabled: boolean;
|
||||
portalTarget: HTMLDivElement | null;
|
||||
setPortalTarget: (target: HTMLDivElement | null) => void;
|
||||
};
|
||||
|
||||
const WindowTitleBarContext = createContext<WindowTitleBarContextValue | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
/**
|
||||
* Keeps title-bar content mounted while shell views change.
|
||||
*/
|
||||
export function WindowTitleBarProvider({
|
||||
children,
|
||||
contentEnabled = true,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
contentEnabled?: boolean;
|
||||
}) {
|
||||
const [portalTarget, setPortalTarget] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
return (
|
||||
<WindowTitleBarContext.Provider
|
||||
value={{ contentEnabled, portalTarget, setPortalTarget }}
|
||||
>
|
||||
{children}
|
||||
</WindowTitleBarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserves the native title-bar row at the shell boundary. The normal app
|
||||
* shell hosts projected controls; full-screen shell overlays only need the
|
||||
* draggable surface.
|
||||
*/
|
||||
export function WindowTitleBar({
|
||||
className,
|
||||
hostContent = true,
|
||||
}: {
|
||||
className?: string;
|
||||
hostContent?: boolean;
|
||||
}) {
|
||||
const context = useContext(WindowTitleBarContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"WindowTitleBar must be used within WindowTitleBarProvider.",
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("isolate h-12 shrink-0 max-md:h-7", className)}
|
||||
data-slot="window-title-bar"
|
||||
data-tauri-drag-region="deep"
|
||||
>
|
||||
{hostContent ? (
|
||||
<div
|
||||
aria-hidden={context.contentEnabled ? undefined : true}
|
||||
className="h-full min-w-0"
|
||||
data-slot="window-title-bar-content-host"
|
||||
hidden={!context.contentEnabled}
|
||||
inert={context.contentEnabled ? undefined : true}
|
||||
ref={context.setPortalTarget}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Projects page-owned controls into the persistent shell title bar. */
|
||||
export function WindowTitleBarContent({ children }: { children: ReactNode }) {
|
||||
const context = useContext(WindowTitleBarContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"WindowTitleBarContent must be used within WindowTitleBarProvider.",
|
||||
);
|
||||
}
|
||||
|
||||
return context.portalTarget
|
||||
? createPortal(children, context.portalTarget)
|
||||
: null;
|
||||
}
|
||||
@@ -39,6 +39,22 @@ function HookHarness() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
const promise = new Promise<T>((next) => {
|
||||
resolve = next;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function handlerFor(eventName: string): (payload: unknown) => void {
|
||||
const handler = subscribeMock.mock.calls.find(
|
||||
([subscribedEvent]) => subscribedEvent === eventName,
|
||||
)?.[1];
|
||||
expect(handler).toBeDefined();
|
||||
return handler as (payload: unknown) => void;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
window.localStorage.clear();
|
||||
@@ -63,6 +79,223 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("useChatSession", () => {
|
||||
it("restores an idle parent when aborting its child fails", async () => {
|
||||
invokeMock.mockImplementation(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === "chat_session_command") {
|
||||
const request = args?.request as { action?: string } | undefined;
|
||||
if (request?.action === "start") {
|
||||
return { sessionId: "session-child-abort" };
|
||||
}
|
||||
if (request?.action === "abort") {
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => current.start(current.config));
|
||||
const statusHandler = handlerFor("chat_session_status");
|
||||
|
||||
await act(async () => {
|
||||
statusHandler({ sessionId: current.sessionId, status: "idle" });
|
||||
});
|
||||
expect(current.status).toBe("idle");
|
||||
|
||||
await act(async () => current.abort());
|
||||
|
||||
expect(current.status).toBe("idle");
|
||||
});
|
||||
|
||||
it("preserves authoritative completion across abort races", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const sessionId = "session-abort-chat-done";
|
||||
const abortResponse = deferred<unknown>();
|
||||
const pendingResponse = deferred<unknown>();
|
||||
const sendResponse = deferred<unknown>();
|
||||
invokeMock.mockImplementation(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === "chat_session_command") {
|
||||
const request = args?.request as { action?: string } | undefined;
|
||||
if (request?.action === "start") return { sessionId };
|
||||
if (request?.action === "send") {
|
||||
return await sendResponse.promise;
|
||||
}
|
||||
if (request?.action === "abort") {
|
||||
return await abortResponse.promise;
|
||||
}
|
||||
if (request?.action === "pending_prompts") {
|
||||
return await pendingResponse.promise;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => current.start(current.config));
|
||||
const chatEventHandler = handlerFor("chat_event");
|
||||
const statusHandler = handlerFor("chat_session_status");
|
||||
|
||||
await act(async () => {
|
||||
statusHandler({ sessionId, status: "running" });
|
||||
});
|
||||
let sendTask!: Promise<void>;
|
||||
await act(async () => {
|
||||
sendTask = current.sendPrompt("queued follow-up");
|
||||
for (let i = 0; i < 5; i += 1) await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
chatEventHandler({
|
||||
sessionId,
|
||||
stream: "chat_done",
|
||||
chunk: JSON.stringify({ reason: "completed" }),
|
||||
ts: Date.now(),
|
||||
index: 1,
|
||||
});
|
||||
});
|
||||
expect(current.status).toBe("running");
|
||||
|
||||
let abortTask!: Promise<void>;
|
||||
await act(async () => {
|
||||
abortTask = current.abort();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(current.status).toBe("stopping");
|
||||
|
||||
await act(async () => {
|
||||
statusHandler({ sessionId, status: "running" });
|
||||
});
|
||||
expect(current.status).toBe("stopping");
|
||||
|
||||
await act(async () => {
|
||||
pendingResponse.resolve({
|
||||
sessionId,
|
||||
ok: true,
|
||||
promptsInQueue: [],
|
||||
});
|
||||
for (let i = 0; i < 5; i += 1) await Promise.resolve();
|
||||
});
|
||||
expect(current.status).toBe("completed");
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
});
|
||||
expect(current.status).toBe("completed");
|
||||
|
||||
await act(async () => {
|
||||
sendResponse.resolve({
|
||||
sessionId,
|
||||
ok: true,
|
||||
queued: true,
|
||||
promptsInQueue: [],
|
||||
});
|
||||
await sendTask;
|
||||
});
|
||||
expect(current.status).toBe("completed");
|
||||
|
||||
await act(async () => {
|
||||
abortResponse.resolve({ ok: false });
|
||||
await abortTask;
|
||||
});
|
||||
expect(current.status).toBe("completed");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("reconciles a running tool row after an aborted send settles", async () => {
|
||||
const sessionId = "session-aborted-tool";
|
||||
const sendResponse = deferred<unknown>();
|
||||
const canonicalMessages = [
|
||||
{
|
||||
id: "history-assistant",
|
||||
sessionId,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
createdAt: 2,
|
||||
},
|
||||
{
|
||||
id: "history-tool",
|
||||
sessionId,
|
||||
role: "tool",
|
||||
content: JSON.stringify({
|
||||
toolName: "spawn_agent",
|
||||
input: { task: "sleep" },
|
||||
result: { finishReason: "aborted" },
|
||||
isError: false,
|
||||
}),
|
||||
createdAt: 3,
|
||||
meta: {
|
||||
toolName: "spawn_agent",
|
||||
toolCallId: "call-spawn",
|
||||
hookEventName: "history_tool_result",
|
||||
},
|
||||
},
|
||||
];
|
||||
invokeMock.mockImplementation(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === "read_session_messages") return canonicalMessages;
|
||||
if (command === "chat_session_command") {
|
||||
const request = args?.request as { action?: string } | undefined;
|
||||
if (request?.action === "start") {
|
||||
return { sessionId };
|
||||
}
|
||||
if (request?.action === "send") {
|
||||
return await sendResponse.promise;
|
||||
}
|
||||
if (request?.action === "abort") return { sessionId, ok: true };
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => current.start(current.config));
|
||||
const chatEventHandler = handlerFor("chat_event");
|
||||
|
||||
let sendTask!: Promise<void>;
|
||||
await act(async () => {
|
||||
sendTask = current.sendPrompt("spawn a subagent");
|
||||
await Promise.resolve();
|
||||
chatEventHandler?.({
|
||||
sessionId,
|
||||
stream: "chat_tool_call_start",
|
||||
chunk: JSON.stringify({
|
||||
toolCallId: "call-spawn",
|
||||
toolName: "spawn_agent",
|
||||
input: { task: "sleep" },
|
||||
}),
|
||||
ts: Date.now(),
|
||||
index: 1,
|
||||
});
|
||||
});
|
||||
expect(
|
||||
current.messages.find((message) => message.role === "tool")?.meta
|
||||
?.hookEventName,
|
||||
).toBe("tool_call_start");
|
||||
|
||||
await act(async () => current.abort());
|
||||
await act(async () => {
|
||||
sendResponse.resolve({
|
||||
ok: true,
|
||||
result: { finishReason: "aborted" },
|
||||
});
|
||||
await sendTask;
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
});
|
||||
|
||||
expect(current.status).toBe("cancelled");
|
||||
const toolMessage = current.messages.find(
|
||||
(message) => message.role === "tool",
|
||||
);
|
||||
expect(toolMessage?.meta?.hookEventName).toBe("history_tool_result");
|
||||
expect(JSON.parse(toolMessage?.content ?? "{}").result).toEqual({
|
||||
finishReason: "aborted",
|
||||
});
|
||||
});
|
||||
|
||||
it("caps command output while preserving the newest tail", () => {
|
||||
const result = appendCappedCommandOutput(
|
||||
"head\n",
|
||||
|
||||
@@ -428,6 +428,10 @@ export function useChatSession() {
|
||||
// trail chat_done; when no new turn has started since the turn settled
|
||||
// (epoch unchanged), such a "running" must not reopen the turn.
|
||||
const turnSettledEpochRef = useRef(-1);
|
||||
// Runtime status changes supersede local status captured by an abort.
|
||||
const authoritativeStatusRevisionRef = useRef(0);
|
||||
// Snapshot used to keep a late send response from overwriting a newer status.
|
||||
const abortStatusRevisionRef = useRef(0);
|
||||
// Last error-level core log per session, used to explain failed turns.
|
||||
const lastCoreErrorBySessionRef = useRef<Record<string, string>>({});
|
||||
const [chatTransportState, setChatTransportState] =
|
||||
@@ -733,8 +737,11 @@ export function useChatSession() {
|
||||
setPromptsInQueue(items);
|
||||
if (items.length === 0) {
|
||||
turnSettledEpochRef.current = turnEpochRef.current;
|
||||
authoritativeStatusRevisionRef.current += 1;
|
||||
setStatus((current) =>
|
||||
current === "running" ? "completed" : current,
|
||||
current === "running" || current === "stopping"
|
||||
? "completed"
|
||||
: current,
|
||||
);
|
||||
finalizeSettledTurn(sid);
|
||||
}
|
||||
@@ -1235,7 +1242,7 @@ export function useChatSession() {
|
||||
return;
|
||||
}
|
||||
lastLiveChunkAtRef.current = Date.now();
|
||||
if (abortedRef.current) {
|
||||
if (abortedRef.current && payload.stream !== "chat_done") {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1622,6 +1629,7 @@ export function useChatSession() {
|
||||
verifyQueueStillBusy(listeningSessionId);
|
||||
} else {
|
||||
turnSettledEpochRef.current = turnEpochRef.current;
|
||||
authoritativeStatusRevisionRef.current += 1;
|
||||
finalizeSettledTurn(listeningSessionId);
|
||||
}
|
||||
return;
|
||||
@@ -1773,10 +1781,12 @@ export function useChatSession() {
|
||||
// equals the settled epoch a "running" here can only be stale.
|
||||
if (
|
||||
nextStatus === "running" &&
|
||||
turnEpochRef.current === turnSettledEpochRef.current
|
||||
(abortedRef.current ||
|
||||
turnEpochRef.current === turnSettledEpochRef.current)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
authoritativeStatusRevisionRef.current += 1;
|
||||
setStatus(nextStatus as ChatSessionStatus);
|
||||
},
|
||||
);
|
||||
@@ -1801,6 +1811,7 @@ export function useChatSession() {
|
||||
setActiveAssistantMessageId(null);
|
||||
clearLiveToolRefs();
|
||||
turnSettledEpochRef.current = turnEpochRef.current;
|
||||
authoritativeStatusRevisionRef.current += 1;
|
||||
setStatus((record.reason?.trim() || "idle") as ChatSessionStatus);
|
||||
finalizeSettledTurn(targetSessionId);
|
||||
},
|
||||
@@ -1908,7 +1919,14 @@ export function useChatSession() {
|
||||
// the working indicator and disarming this poll.
|
||||
const nextStatus = record?.status?.trim();
|
||||
if (nextStatus) {
|
||||
setStatus(mapSessionRecordStatus(nextStatus as SessionHistoryStatus));
|
||||
const mappedStatus = mapSessionRecordStatus(
|
||||
nextStatus as SessionHistoryStatus,
|
||||
);
|
||||
if (abortedRef.current && mappedStatus === "running") {
|
||||
return;
|
||||
}
|
||||
authoritativeStatusRevisionRef.current += 1;
|
||||
setStatus(mappedStatus);
|
||||
}
|
||||
} finally {
|
||||
polling = false;
|
||||
@@ -2242,9 +2260,23 @@ export function useChatSession() {
|
||||
finishPromptSubmission();
|
||||
return;
|
||||
}
|
||||
let abortedReconcileEpoch: number | undefined;
|
||||
const settleAbortedSend = () => {
|
||||
if (!abortedRef.current) return false;
|
||||
if (
|
||||
authoritativeStatusRevisionRef.current ===
|
||||
abortStatusRevisionRef.current
|
||||
) {
|
||||
abortedReconcileEpoch = turnEpochRef.current;
|
||||
turnSettledEpochRef.current = turnEpochRef.current;
|
||||
setStatus("cancelled");
|
||||
}
|
||||
return true;
|
||||
};
|
||||
try {
|
||||
const payload = await sendTask;
|
||||
if (payload.ok && payload.queued) {
|
||||
if (settleAbortedSend()) return;
|
||||
if (turnEpochRef.current !== turnEpochAtDispatch) {
|
||||
// The runtime already started consuming a queued prompt
|
||||
// (chat_queued_prompt_start bumped the epoch) while this
|
||||
@@ -2258,22 +2290,13 @@ export function useChatSession() {
|
||||
return;
|
||||
}
|
||||
applyPromptsInQueue(payload.promptsInQueue);
|
||||
if (abortedRef.current) {
|
||||
turnSettledEpochRef.current = turnEpochRef.current;
|
||||
setStatus("cancelled");
|
||||
return;
|
||||
}
|
||||
setStatus("running");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = payload.result as ChatApiResult | undefined;
|
||||
applyPromptsInQueue(payload.promptsInQueue);
|
||||
if (abortedRef.current) {
|
||||
turnSettledEpochRef.current = turnEpochRef.current;
|
||||
setStatus("cancelled");
|
||||
return;
|
||||
}
|
||||
if (settleAbortedSend()) return;
|
||||
// On a failed run the runtime reports the error string in
|
||||
// result.text — it is not assistant content and must not be
|
||||
// rendered as an assistant bubble (canonical rehydration would
|
||||
@@ -2570,10 +2593,8 @@ export function useChatSession() {
|
||||
const hasQueuedFollowUps =
|
||||
Array.isArray(payload.promptsInQueue) &&
|
||||
payload.promptsInQueue.length > 0;
|
||||
if (abortedRef.current) {
|
||||
turnSettledEpochRef.current = turnEpochRef.current;
|
||||
setStatus("cancelled");
|
||||
} else if (result?.finishReason === "error") {
|
||||
if (settleAbortedSend()) return;
|
||||
if (result?.finishReason === "error") {
|
||||
// On a failed run result.text is the runtime's error string
|
||||
// (never assistant content — see isErrorResult above), so it
|
||||
// is the best failure detail available. The reporter dedupes
|
||||
@@ -2599,10 +2620,7 @@ export function useChatSession() {
|
||||
}
|
||||
void refreshSessionDiffSummary(activeSessionId);
|
||||
} catch (err) {
|
||||
if (abortedRef.current) {
|
||||
setStatus("cancelled");
|
||||
return;
|
||||
}
|
||||
if (settleAbortedSend()) return;
|
||||
if (optimisticQueuedPromptId) {
|
||||
setPromptsInQueue((prev) =>
|
||||
prev.filter((item) => item.id !== optimisticQueuedPromptId),
|
||||
@@ -2617,6 +2635,13 @@ export function useChatSession() {
|
||||
clearLiveToolRefs();
|
||||
}
|
||||
finishPromptSubmission();
|
||||
if (
|
||||
abortedReconcileEpoch !== undefined &&
|
||||
activeSessionIdRef.current === activeSessionId &&
|
||||
turnEpochRef.current === abortedReconcileEpoch
|
||||
) {
|
||||
finalizeSettledTurn(activeSessionId);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -2627,6 +2652,7 @@ export function useChatSession() {
|
||||
clearAbortFallbackTimeout,
|
||||
clearLiveToolRefs,
|
||||
config,
|
||||
finalizeSettledTurn,
|
||||
hydratedHistorySessionId,
|
||||
materializeToolMessagesFromResult,
|
||||
refreshSessionDiffSummary,
|
||||
@@ -2745,11 +2771,29 @@ export function useChatSession() {
|
||||
|
||||
const abort = useCallback(async () => {
|
||||
if (!sessionId) return;
|
||||
const fallbackStatus: ChatSessionStatus =
|
||||
status === "stopping" ? "running" : status;
|
||||
const statusRevisionAtAbort = authoritativeStatusRevisionRef.current;
|
||||
abortStatusRevisionRef.current = statusRevisionAtAbort;
|
||||
const restoreFallbackStatus = () => {
|
||||
abortedRef.current = false;
|
||||
clearAbortFallbackTimeout();
|
||||
if (
|
||||
activeSessionIdRef.current === sessionId &&
|
||||
authoritativeStatusRevisionRef.current === statusRevisionAtAbort
|
||||
) {
|
||||
setStatus(fallbackStatus);
|
||||
}
|
||||
};
|
||||
abortedRef.current = true;
|
||||
setStatus("stopping");
|
||||
clearAbortFallbackTimeout();
|
||||
abortFallbackTimeoutRef.current = setTimeout(() => {
|
||||
if (abortedRef.current) {
|
||||
if (
|
||||
abortedRef.current &&
|
||||
activeSessionIdRef.current === sessionId &&
|
||||
authoritativeStatusRevisionRef.current === statusRevisionAtAbort
|
||||
) {
|
||||
setStatus("cancelled");
|
||||
}
|
||||
abortFallbackTimeoutRef.current = null;
|
||||
@@ -2757,16 +2801,12 @@ export function useChatSession() {
|
||||
try {
|
||||
const response = await postSession({ action: "abort", sessionId });
|
||||
if (!response.ok) {
|
||||
abortedRef.current = false;
|
||||
clearAbortFallbackTimeout();
|
||||
setStatus("running");
|
||||
restoreFallbackStatus();
|
||||
}
|
||||
} catch {
|
||||
abortedRef.current = false;
|
||||
clearAbortFallbackTimeout();
|
||||
setStatus("running");
|
||||
restoreFallbackStatus();
|
||||
}
|
||||
}, [clearAbortFallbackTimeout, postSession, sessionId]);
|
||||
}, [clearAbortFallbackTimeout, postSession, sessionId, status]);
|
||||
|
||||
const proceedWhileRunning = useCallback(
|
||||
async (targetSessionId: string, toolCallId?: string) => {
|
||||
|
||||
@@ -403,6 +403,52 @@ describe("useSessionAgents", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps polling while an idle parent still has a running child", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
invokeMock.mockResolvedValue([runningRow("a", "one")]);
|
||||
await render({ sessionId: "a", sessionActive: false });
|
||||
const afterFirst = invokeMock.mock.calls.length;
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2500);
|
||||
});
|
||||
expect(invokeMock.mock.calls.length).toBeGreaterThan(afterFirst);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("lets a slow poll settle an idle parent's completed child", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
let listCount = 0;
|
||||
invokeMock.mockImplementation(async () => {
|
||||
listCount += 1;
|
||||
if (listCount === 1) return [runningRow("a", "one")];
|
||||
return await new Promise((resolve) => {
|
||||
setTimeout(() => resolve([agentRow("a", "one")]), 3000);
|
||||
});
|
||||
});
|
||||
|
||||
await render({ sessionId: "a", sessionActive: false });
|
||||
expect(current.agents[0]?.status).toBe("running");
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(5500);
|
||||
});
|
||||
expect(current.agents[0]?.status).toBe("completed");
|
||||
|
||||
const settledCallCount = invokeMock.mock.calls.length;
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
});
|
||||
expect(invokeMock).toHaveBeenCalledTimes(settledCallCount);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("skips malformed rows rather than surfacing partial agents", async () => {
|
||||
invokeMock.mockResolvedValue([
|
||||
agentRow("a", "good"),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import type { SessionAgentEntry } from "@/lib/session-agents";
|
||||
import { agentEntryState, type SessionAgentEntry } from "@/lib/session-agents";
|
||||
|
||||
/** While a turn runs, child agents come and go faster than a one-shot fetch sees. */
|
||||
const ACTIVE_POLL_INTERVAL_MS = 2500;
|
||||
@@ -72,8 +72,8 @@ function parseAgentEntries(value: unknown): SessionAgentEntry[] {
|
||||
/**
|
||||
* Roster of the child agents a session started.
|
||||
*
|
||||
* Read once per displayed session and then polled only while that session is
|
||||
* active. Deliberately *not* gated on whether the header is currently showing
|
||||
* Read once per displayed session and then polled while that session or one of
|
||||
* its children is active. Deliberately *not* gated on whether the header shows
|
||||
* any agents: that tally is derived from the newest messages only, so gating on
|
||||
* it would deadlock — a session whose spawn calls have aged out of the message
|
||||
* window would report zero agents, never query the database that still
|
||||
@@ -167,6 +167,9 @@ export function useSessionAgents({
|
||||
const agents = isCurrent ? roster.entries : NO_AGENTS;
|
||||
const loading = isCurrent && roster.loading;
|
||||
const error = isCurrent ? roster.error : null;
|
||||
const hasRunningAgents = agents.some(
|
||||
(agent) => agentEntryState(agent.status) === "running",
|
||||
);
|
||||
|
||||
// One read per displayed session, repeated when the session starts or stops
|
||||
// running — a turn can finish up to a poll interval after the last read, so
|
||||
@@ -190,16 +193,21 @@ export function useSessionAgents({
|
||||
}, [panelOpen, refresh, sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId || !sessionActive) {
|
||||
if (!sessionId || (!sessionActive && !hasRunningAgents)) {
|
||||
return;
|
||||
}
|
||||
let polling = false;
|
||||
const timer = window.setInterval(() => {
|
||||
void refresh(sessionId, { quiet: true });
|
||||
if (polling) return;
|
||||
polling = true;
|
||||
void refresh(sessionId, { quiet: true }).finally(() => {
|
||||
polling = false;
|
||||
});
|
||||
}, ACTIVE_POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [refresh, sessionActive, sessionId]);
|
||||
}, [hasRunningAgents, refresh, sessionActive, sessionId]);
|
||||
|
||||
return { agents, loading, error, refresh };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export type ClineIntegration = {
|
||||
provider?: string;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
export type ClineGitHubRepository = {
|
||||
id?: number;
|
||||
name?: string;
|
||||
full_name?: string;
|
||||
html_url?: string;
|
||||
private?: boolean;
|
||||
};
|
||||
|
||||
export const GITHUB_INTEGRATION_PROVIDER = "github";
|
||||
|
||||
export function findGitHubIntegration(
|
||||
integrations: ClineIntegration[],
|
||||
): ClineIntegration | undefined {
|
||||
return integrations.find(
|
||||
(integration) => integration?.provider === GITHUB_INTEGRATION_PROVIDER,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { isClineAccountNotAuthenticatedResult } from "@/lib/cline-account-state";
|
||||
import type {
|
||||
ClineGitHubRepository,
|
||||
ClineIntegration,
|
||||
} from "@/lib/cline-integrations-types";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
|
||||
export * from "@/lib/cline-integrations-types";
|
||||
|
||||
export type ClineIntegrationsListResult =
|
||||
| { status: "ok"; integrations: ClineIntegration[] }
|
||||
| { status: "not-authenticated" };
|
||||
|
||||
export async function listClineIntegrations(): Promise<ClineIntegrationsListResult> {
|
||||
const result = await desktopClient.invoke("cline_integrations", {
|
||||
operation: "list",
|
||||
});
|
||||
if (isClineAccountNotAuthenticatedResult(result)) {
|
||||
return { status: "not-authenticated" };
|
||||
}
|
||||
return {
|
||||
status: "ok",
|
||||
integrations: Array.isArray(result) ? (result as ClineIntegration[]) : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function listClineGitHubRepositories(): Promise<
|
||||
ClineGitHubRepository[]
|
||||
> {
|
||||
const result = await desktopClient.invoke("cline_integrations", {
|
||||
operation: "listGitHubRepositories",
|
||||
});
|
||||
return Array.isArray(result) ? (result as ClineGitHubRepository[]) : [];
|
||||
}
|
||||
|
||||
export async function fetchGitHubInstallUrl(): Promise<string> {
|
||||
const result = await desktopClient.invoke("cline_integrations", {
|
||||
operation: "githubInstallUrl",
|
||||
});
|
||||
if (isClineAccountNotAuthenticatedResult(result)) {
|
||||
throw new Error("sign in to your Cline account first");
|
||||
}
|
||||
const url = (result as { url?: unknown } | null)?.url;
|
||||
if (typeof url !== "string" || !url.trim()) {
|
||||
throw new Error("no GitHub install URL was returned");
|
||||
}
|
||||
return url;
|
||||
}
|
||||
@@ -17,6 +17,11 @@ export type MarketplaceEnvVar = {
|
||||
url?: string;
|
||||
};
|
||||
|
||||
export type MarketplaceAuthor = {
|
||||
name: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
export type MarketplaceEntry = {
|
||||
id: string;
|
||||
type: MarketplacePrimitiveType;
|
||||
@@ -25,6 +30,12 @@ export type MarketplaceEntry = {
|
||||
tagline: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
author?: MarketplaceAuthor;
|
||||
homepage?: string;
|
||||
repo?: string;
|
||||
icon?: string;
|
||||
license?: string;
|
||||
verified?: boolean;
|
||||
install: {
|
||||
args: string[];
|
||||
env?: MarketplaceEnvVar[];
|
||||
@@ -100,6 +111,20 @@ function parseEnv(value: unknown): MarketplaceEnvVar[] | undefined {
|
||||
return env.length > 0 ? env : undefined;
|
||||
}
|
||||
|
||||
function parseAuthor(value: unknown): MarketplaceAuthor | undefined {
|
||||
if (!value || typeof value !== "object") return undefined;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (typeof candidate.name !== "string") return undefined;
|
||||
return {
|
||||
name: candidate.name,
|
||||
url: typeof candidate.url === "string" ? candidate.url : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function parseOptionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
export async function fetchMarketplaceCatalog(): Promise<MarketplaceCatalog> {
|
||||
const urls = [MARKETPLACE_CATALOG_URL];
|
||||
try {
|
||||
@@ -188,6 +213,15 @@ export async function fetchMarketplaceCatalog(): Promise<MarketplaceCatalog> {
|
||||
tagline: candidate.tagline,
|
||||
description: candidate.description,
|
||||
tags: toStringArray(candidate.tags),
|
||||
author: parseAuthor(candidate.author),
|
||||
homepage: parseOptionalString(candidate.homepage),
|
||||
repo: parseOptionalString(candidate.repo),
|
||||
icon: parseOptionalString(candidate.icon),
|
||||
license: parseOptionalString(candidate.license),
|
||||
verified:
|
||||
typeof candidate.verified === "boolean"
|
||||
? candidate.verified
|
||||
: undefined,
|
||||
install: {
|
||||
args: toStringArray(install.args),
|
||||
command: install.command,
|
||||
|
||||
@@ -31,98 +31,29 @@ describe("isProviderConnected", () => {
|
||||
|
||||
it("never counts a disabled provider without credentials", () => {
|
||||
expect(
|
||||
isProviderConnected(
|
||||
makeProvider({
|
||||
enabled: false,
|
||||
configFields: [{ path: "baseUrl", label: "Base URL", type: "url" }],
|
||||
configValues: { baseUrl: "http://localhost:11434" },
|
||||
}),
|
||||
),
|
||||
isProviderConnected(makeProvider({ enabled: false, configured: true })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("counts an enabled structured-config provider with all required fields filled", () => {
|
||||
it("counts an enabled provider the sidecar reports as configured", () => {
|
||||
expect(
|
||||
isProviderConnected(
|
||||
makeProvider({
|
||||
enabled: true,
|
||||
configFields: [
|
||||
{
|
||||
path: "gcp.projectId",
|
||||
label: "Project",
|
||||
type: "text",
|
||||
required: true,
|
||||
},
|
||||
{ path: "gcp.region", label: "Region", type: "text" },
|
||||
],
|
||||
configValues: {
|
||||
"gcp.projectId": "my-project",
|
||||
"gcp.region": "us-central1",
|
||||
},
|
||||
}),
|
||||
makeProvider({ id: "ollama", enabled: true, configured: true }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an enabled structured-config provider missing a required field", () => {
|
||||
it("rejects an enabled provider without sidecar-verified credentials", () => {
|
||||
// Legacy VS Code migration can seed enabled entries (e.g. qwen-code,
|
||||
// sapaicore) holding only a default model and no credentials. Enabled
|
||||
// alone must not read as configured.
|
||||
expect(
|
||||
isProviderConnected(
|
||||
makeProvider({
|
||||
enabled: true,
|
||||
configFields: [
|
||||
{
|
||||
path: "gcp.projectId",
|
||||
label: "Project",
|
||||
type: "text",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
configValues: { "gcp.projectId": "" },
|
||||
}),
|
||||
makeProvider({ id: "qwen-code", enabled: true, configured: false }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("counts an enabled keyless provider (local endpoint)", () => {
|
||||
expect(
|
||||
isProviderConnected(
|
||||
makeProvider({
|
||||
id: "ollama",
|
||||
enabled: true,
|
||||
configFields: [{ path: "baseUrl", label: "Base URL", type: "url" }],
|
||||
configValues: { baseUrl: "http://localhost:11434" },
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("counts an enabled provider whose auth lives outside the catalog (Bedrock IAM)", () => {
|
||||
// Bedrock's catalog entry has an *optional* apiKey field ("Optional
|
||||
// Bedrock bearer token") and no required fields — IAM/profile users
|
||||
// authenticate entirely outside the catalog and must not be nagged.
|
||||
expect(
|
||||
isProviderConnected(
|
||||
makeProvider({
|
||||
id: "bedrock",
|
||||
enabled: true,
|
||||
configFields: [
|
||||
{
|
||||
path: "aws.authentication",
|
||||
label: "Authentication",
|
||||
type: "select",
|
||||
defaultValue: "iam",
|
||||
},
|
||||
{ path: "aws.region", label: "AWS Region", type: "text" },
|
||||
{
|
||||
path: "apiKey",
|
||||
label: "Bedrock API Key",
|
||||
type: "password",
|
||||
secret: true,
|
||||
},
|
||||
],
|
||||
configValues: { "aws.authentication": "iam", apiKey: "" },
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
isProviderConnected(makeProvider({ id: "sapaicore", enabled: true })),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,15 +37,15 @@ export function getProviderAuthKind(provider: Provider): ProviderAuthKind {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a provider from the catalog is usable for turns, for the purpose
|
||||
* of the first-run "connect a model" notice. Plain API keys and OAuth are
|
||||
* definitive. Beyond those, an enabled provider counts as connected unless
|
||||
* a *required* config field is unmet: `enabled` means the user deliberately
|
||||
* persisted settings for it, and its credentials may legitimately live
|
||||
* outside the catalog (Bedrock IAM/profile auth, env-var keys, keyless
|
||||
* local endpoints like Ollama), so an empty optional API-key field must not
|
||||
* disqualify it. A brand-new user has no enabled providers, so the notice
|
||||
* still shows for them.
|
||||
* Whether a provider from the catalog is usable for turns, shown as
|
||||
* "Configured" in settings and consulted by the first-run "connect a model"
|
||||
* notice. Plain API keys and OAuth are definitive. Beyond those, we rely on
|
||||
* the sidecar's `configured` flag (the same per-provider readiness check the
|
||||
* CLI uses), which requires real evidence of credentials: cloud credentials
|
||||
* for Bedrock/Vertex/SAP AI Core, a local-auth CLI, or a resolvable endpoint
|
||||
* + model for keyless local providers like Ollama. `enabled` alone is not
|
||||
* enough — legacy VS Code migration and empty saves can persist entries for
|
||||
* providers the user never actually configured.
|
||||
*/
|
||||
export function isProviderConnected(provider: Provider): boolean {
|
||||
if (provider.apiKey?.trim()) {
|
||||
@@ -54,17 +54,5 @@ export function isProviderConnected(provider: Provider): boolean {
|
||||
if (provider.oauthAccessTokenPresent) {
|
||||
return true;
|
||||
}
|
||||
if (!provider.enabled) {
|
||||
return false;
|
||||
}
|
||||
const requiredFields = (provider.configFields ?? []).filter(
|
||||
(field) => field.required,
|
||||
);
|
||||
return requiredFields.every((field) => {
|
||||
const value = provider.configValues?.[field.path];
|
||||
if (value === null || value === undefined) {
|
||||
return false;
|
||||
}
|
||||
return String(value).trim() !== "";
|
||||
});
|
||||
return provider.enabled && provider.configured === true;
|
||||
}
|
||||
|
||||
@@ -67,6 +67,12 @@ export interface Provider {
|
||||
color: string;
|
||||
letter: string;
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Sidecar-computed readiness: true when the persisted settings hold real
|
||||
* credentials or a usable keyless endpoint, unlike `enabled` which is set
|
||||
* by any persisted entry (including ones seeded by legacy migration).
|
||||
*/
|
||||
configured?: boolean;
|
||||
apiKey?: string;
|
||||
oauthAccessTokenPresent?: boolean;
|
||||
baseUrl?: string;
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<svg width="205" height="193" viewBox="0 0 205 193" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M118.894 28.2461C118.894 19.3941 111.59 12.1251 102.222 12.125C92.8539 12.125 85.5499 19.394 85.5499 28.2461C85.5499 30.3758 85.9304 33.1689 86.7003 36.542L88.3927 43.9531H49.1749C40.8046 43.9531 34.0189 50.7388 34.0187 59.1094V87.875L13.2052 112.156L34.0187 136.437V165.203C34.0187 173.574 40.8044 180.359 49.1749 180.359H155.269C163.639 180.359 170.425 173.574 170.425 165.203V136.437L191.236 112.156L170.425 87.875V59.1094C170.425 50.739 163.639 43.9533 155.269 43.9531H116.051L117.742 36.542C118.512 33.1689 118.894 30.3758 118.894 28.2461ZM131.012 28.8877C130.992 29.8428 130.928 30.8253 130.827 31.8281H155.269C170.336 31.8283 182.55 44.0428 182.55 59.1094V83.3906L202.345 106.485C205.141 109.749 205.142 114.564 202.345 117.828L182.55 140.921V165.203C182.55 180.27 170.336 192.484 155.269 192.484H49.1749C34.1079 192.484 21.8937 180.27 21.8937 165.203V140.923L2.09777 117.828C-0.699604 114.564 -0.69891 109.749 2.09777 106.485L21.8937 83.3896V59.1094C21.8939 44.0427 34.1079 31.8281 49.1749 31.8281H73.6163C73.5159 30.8253 73.4516 29.8428 73.4318 28.8877L73.4249 28.2461C73.4249 12.3814 86.478 0 102.222 0C117.965 0.00014284 131.019 12.3814 131.019 28.2461L131.012 28.8877Z" fill="black"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg width="205" height="193" viewBox="0 0 205 193" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M102.222 11.625C92.5893 11.6251 85.0497 19.1063 85.0497 28.2461L85.0546 28.6631C85.1002 30.7847 85.4871 33.4739 86.2128 36.6533L87.7655 43.4531H49.1747C40.5283 43.4532 33.5187 50.4628 33.5185 59.1094V87.6895L12.8251 111.831L12.5468 112.156L12.8251 112.481L33.5185 136.621V165.203C33.5185 173.85 40.528 180.859 49.1747 180.859H155.269C163.915 180.859 170.925 173.85 170.925 165.203V136.621L191.616 112.481L191.895 112.156L191.616 111.831L170.925 87.6895V59.1094L170.92 58.7051C170.705 50.2454 163.78 43.4533 155.269 43.4531H116.678L118.229 36.6533C119.004 33.262 119.394 30.4282 119.394 28.2461L119.388 27.8193C119.157 18.8746 111.704 11.6251 102.222 11.625ZM22.3935 140.738L22.2733 140.598L2.47742 117.503C-0.159452 114.426 -0.158827 109.887 2.47742 106.811L22.2733 83.7148L22.3935 83.5742V59.1094C22.3937 44.3189 34.3839 32.3282 49.1747 32.3281H74.1688L74.1142 31.7783C74.0148 30.7865 73.9511 29.817 73.9315 28.877L73.9247 28.2461C73.9247 12.6714 86.7399 0.50006 102.222 0.5C117.703 0.50014 130.519 12.6714 130.519 28.2461L130.512 28.877C130.492 29.817 130.429 30.7865 130.33 31.7783L130.274 32.3281H155.269C170.059 32.3283 182.05 44.3189 182.05 59.1094V83.5752L182.17 83.7158L201.965 106.811C204.601 109.887 204.602 114.426 201.965 117.503L182.17 140.596L182.05 140.736V165.203C182.05 179.994 170.059 191.984 155.269 191.984H49.1747C34.3839 191.984 22.3935 179.994 22.3935 165.203V140.738Z" stroke="black"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,5 +0,0 @@
|
||||
<svg width="400" height="400" viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0.3 0V400M40.3 0V400M80.3 0V400M120.3 0V400M160.3 0V400M200.3 0V400M240.3 0V400M280.3 0V400M320.3 0V400M360.3 0V400" stroke="black" stroke-width="0.6"/>
|
||||
<path d="M0 0.3H400M0 40.3H400M0 80.3H400M0 120.3H400M0 160.3H400M0 200.3H400M0 240.3H400M0 280.3H400M0 320.3H400M0 360.3H400" stroke="black" stroke-width="0.6"/>
|
||||
<path d="M0 0L400 400M400 0L0 400" stroke="black" stroke-width="0.6"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 504 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="205" height="193" viewBox="0 0 205 193" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M22.3935 140.738L22.2733 140.598L2.47742 117.503C-0.159452 114.426 -0.158827 109.887 2.47742 106.811L22.2733 83.7148L22.3935 83.5742V59.1094C22.3937 44.3189 34.3839 32.3282 49.1747 32.3281H74.1688L74.1142 31.7783C74.0148 30.7865 73.9511 29.817 73.9315 28.877L73.9247 28.2461C73.9247 12.6714 86.7399 0.50006 102.222 0.5C117.703 0.50014 130.519 12.6714 130.519 28.2461L130.512 28.877C130.492 29.817 130.429 30.7865 130.33 31.7783L130.274 32.3281H155.269C170.059 32.3283 182.05 44.3189 182.05 59.1094V83.5752L182.17 83.7158L201.965 106.811C204.601 109.887 204.602 114.426 201.965 117.503L182.17 140.596L182.05 140.736V165.203C182.05 179.994 170.059 191.984 155.269 191.984H49.1747C34.3839 191.984 22.3935 179.994 22.3935 165.203V140.738Z" fill="black" stroke="black"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 882 B |
@@ -538,7 +538,7 @@
|
||||
"ts-morph": "^25.0.1",
|
||||
"turndown": "^7.2.0",
|
||||
"ulid": "^2.4.0",
|
||||
"undici": "^7.26.0",
|
||||
"undici": "^7.29.0",
|
||||
"uuid": "^11.1.0",
|
||||
"vscode-uri": "^3.1.0",
|
||||
"zod": "^4.3.6"
|
||||
|
||||
@@ -110,6 +110,9 @@ message TaskItem {
|
||||
int32 cache_reads = 10;
|
||||
string model_id = 11;
|
||||
bool is_legacy = 12;
|
||||
// Provider id the task ran on. Empty for tasks recorded before this
|
||||
// field existed and for legacy imports; consumers show cost in that case.
|
||||
string api_provider = 13;
|
||||
}
|
||||
|
||||
// Request for ask response operation
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package host;
|
||||
|
||||
option go_package = "github.com/cline/grpc-go/host";
|
||||
option java_multiple_files = true;
|
||||
option java_package = "bot.cline.host.proto";
|
||||
|
||||
// Carries calls from an external host to the core over the connection that the
|
||||
// core opens to the host bridge. This avoids a second listening socket in the
|
||||
// core process while preserving unary and response-streaming ProtoBus calls.
|
||||
service CoreConnectionService {
|
||||
rpc connect(stream CoreConnectionMessage) returns (stream CoreConnectionMessage);
|
||||
}
|
||||
|
||||
message CoreConnectionMessage {
|
||||
oneof payload {
|
||||
CoreConnectionHello hello = 1;
|
||||
CoreConnectionRequest request = 2;
|
||||
CoreConnectionResponse response = 3;
|
||||
CoreConnectionCancel cancel = 4;
|
||||
CoreConnectionReady ready = 5;
|
||||
}
|
||||
}
|
||||
|
||||
// The first message from a core. The host accepts it only when token matches
|
||||
// the current spawn expectation; instance_id is the core's cross-process lock
|
||||
// owner identity and is unique for that spawn.
|
||||
message CoreConnectionHello {
|
||||
string token = 1;
|
||||
string instance_id = 2;
|
||||
}
|
||||
|
||||
message CoreConnectionRequest {
|
||||
string request_id = 1;
|
||||
string service = 2;
|
||||
string method = 3;
|
||||
string message_json = 4;
|
||||
bool is_streaming = 5;
|
||||
}
|
||||
|
||||
message CoreConnectionResponse {
|
||||
string request_id = 1;
|
||||
// Logical response payload. A legal wire encoding for small payloads,
|
||||
// though the current core always sends message_json_chunk instead and the
|
||||
// host reconstructs this field after all bounded chunks arrive. Receivers
|
||||
// must accept both encodings; a response may not mix them.
|
||||
optional string message_json = 2;
|
||||
optional string error = 3;
|
||||
// Ends this request. A streaming response may carry its final payload in one
|
||||
// message and then send a payload-free completed response.
|
||||
bool completed = 4;
|
||||
optional bytes message_json_chunk = 5;
|
||||
// Marks the final chunk of one logical response payload.
|
||||
bool message_json_complete = 6;
|
||||
}
|
||||
|
||||
message CoreConnectionCancel {
|
||||
string request_id = 1;
|
||||
}
|
||||
|
||||
// Sent by the host only after it authenticates the hello and installs this
|
||||
// stream as the current core connection.
|
||||
message CoreConnectionReady {}
|
||||
@@ -1,41 +1,54 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import chalk from "chalk"
|
||||
import * as path from "path"
|
||||
import { writeFileWithMkdirs } from "./file-utils.mjs"
|
||||
import { getFqn, loadServicesFromProtoDescriptor } from "./proto-utils.mjs"
|
||||
import chalk from "chalk";
|
||||
import * as path from "path";
|
||||
import { writeFileWithMkdirs } from "./file-utils.mjs";
|
||||
import { getFqn, loadServicesFromProtoDescriptor } from "./proto-utils.mjs";
|
||||
|
||||
// Contains the interface definitions for the host bridge clients.
|
||||
const TYPES_FILE = path.resolve("src/generated/hosts/host-bridge-client-types.ts")
|
||||
const TYPES_FILE = path.resolve(
|
||||
"src/generated/hosts/host-bridge-client-types.ts",
|
||||
);
|
||||
// Contains the ExternalHostBridgeClientManager for the external host bridge clients (using nice-grpc).
|
||||
const EXTERNAL_CLIENT_FILE = path.resolve("src/generated/hosts/standalone/host-bridge-clients.ts")
|
||||
const EXTERNAL_CLIENT_FILE = path.resolve(
|
||||
"src/generated/hosts/standalone/host-bridge-clients.ts",
|
||||
);
|
||||
// Contains the handler map for the external host bridge clients (using the custom service registry).
|
||||
const VSCODE_CLIENT_FILE = path.resolve("src/generated/hosts/vscode/hostbridge-grpc-service-config.ts")
|
||||
const VSCODE_CLIENT_FILE = path.resolve(
|
||||
"src/generated/hosts/vscode/hostbridge-grpc-service-config.ts",
|
||||
);
|
||||
|
||||
/**
|
||||
* Main function to generate the host bridge client
|
||||
*/
|
||||
export async function main() {
|
||||
const { hostServices } = await loadServicesFromProtoDescriptor()
|
||||
const { hostServices } = await loadServicesFromProtoDescriptor();
|
||||
// CoreConnectionService is an internal transport used only by external
|
||||
// cores. It is implemented directly by the Host Bridge and must not become
|
||||
// part of the public HostProvider client surface or the VS Code emulation.
|
||||
const {
|
||||
CoreConnectionService: _coreConnectionService,
|
||||
...publicHostServices
|
||||
} = hostServices;
|
||||
|
||||
await generateTypesFile(hostServices)
|
||||
await generateExternalClientFile(hostServices)
|
||||
await generateVscodeClientFile(hostServices)
|
||||
await generateTypesFile(publicHostServices);
|
||||
await generateExternalClientFile(publicHostServices);
|
||||
await generateVscodeClientFile(publicHostServices);
|
||||
|
||||
console.log(`Generated Host Bridge client files at:`)
|
||||
console.log(`- ${TYPES_FILE}`)
|
||||
console.log(`- ${EXTERNAL_CLIENT_FILE}`)
|
||||
console.log(`- ${VSCODE_CLIENT_FILE}`)
|
||||
console.log(`Generated Host Bridge client files at:`);
|
||||
console.log(`- ${TYPES_FILE}`);
|
||||
console.log(`- ${EXTERNAL_CLIENT_FILE}`);
|
||||
console.log(`- ${VSCODE_CLIENT_FILE}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the client interfaces file.
|
||||
*/
|
||||
async function generateTypesFile(hostServices) {
|
||||
const clientInterfaces = []
|
||||
const clientInterfaces = [];
|
||||
for (const [name, def] of Object.entries(hostServices)) {
|
||||
const clientInterface = generateClientInterfaceType(name, def)
|
||||
clientInterfaces.push(clientInterface)
|
||||
const clientInterface = generateClientInterfaceType(name, def);
|
||||
clientInterfaces.push(clientInterface);
|
||||
}
|
||||
const content = `// GENERATED CODE -- DO NOT EDIT!
|
||||
// Generated by scripts/generate-host-bridge-client.mjs
|
||||
@@ -43,9 +56,9 @@ import * as proto from "@shared/proto/index"
|
||||
import { StreamingCallbacks } from "@hosts/host-provider-types"
|
||||
|
||||
${clientInterfaces.join("\n\n")}
|
||||
`
|
||||
`;
|
||||
// Write output file
|
||||
await writeFileWithMkdirs(TYPES_FILE, content)
|
||||
await writeFileWithMkdirs(TYPES_FILE, content);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,17 +68,17 @@ function generateClientInterfaceType(serviceName, serviceDefinition) {
|
||||
// Get the methods from the service definition
|
||||
const methods = Object.entries(serviceDefinition.service)
|
||||
.map(([methodName, methodDef]) => {
|
||||
const requestType = getFqn(methodDef.requestType.type.name)
|
||||
const responseType = getFqn(methodDef.responseType.type.name)
|
||||
const requestType = getFqn(methodDef.requestType.type.name);
|
||||
const responseType = getFqn(methodDef.responseType.type.name);
|
||||
|
||||
if (!methodDef.responseStream) {
|
||||
// Generate unary method signature.
|
||||
return ` ${methodName}(request: ${requestType}): Promise<${responseType}>;`
|
||||
return ` ${methodName}(request: ${requestType}): Promise<${responseType}>;`;
|
||||
}
|
||||
// Generate streaming method signature.
|
||||
return ` ${methodName}(request: ${requestType}, callbacks: StreamingCallbacks<${responseType}>): () => void;`
|
||||
return ` ${methodName}(request: ${requestType}, callbacks: StreamingCallbacks<${responseType}>): () => void;`;
|
||||
})
|
||||
.join("\n\n")
|
||||
.join("\n\n");
|
||||
|
||||
// Generate the interface
|
||||
return `/**
|
||||
@@ -74,7 +87,7 @@ function generateClientInterfaceType(serviceName, serviceDefinition) {
|
||||
export interface ${serviceName}ClientInterface {
|
||||
|
||||
${methods}
|
||||
}`
|
||||
}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,14 +95,16 @@ ${methods}
|
||||
*/
|
||||
async function generateExternalClientFile(hostServices) {
|
||||
// Generate imports
|
||||
const imports = []
|
||||
const imports = [];
|
||||
// Add imports for the interfaces
|
||||
for (const [name, _def] of Object.entries(hostServices)) {
|
||||
imports.push(`import { ${name}ClientInterface } from "@generated/hosts/host-bridge-client-types"`)
|
||||
imports.push(
|
||||
`import { ${name}ClientInterface } from "@generated/hosts/host-bridge-client-types"`,
|
||||
);
|
||||
}
|
||||
const clientImplementations = []
|
||||
const clientImplementations = [];
|
||||
for (const [name, def] of Object.entries(hostServices)) {
|
||||
clientImplementations.push(generateExternalClientSetup(name, def))
|
||||
clientImplementations.push(generateExternalClientSetup(name, def));
|
||||
}
|
||||
|
||||
const content = `// GENERATED CODE -- DO NOT EDIT!
|
||||
@@ -104,9 +119,9 @@ import { BaseGrpcClient } from "@/hosts/external/grpc-types"
|
||||
${imports.join("\n")}
|
||||
|
||||
${clientImplementations.join("\n\n")}
|
||||
`
|
||||
`;
|
||||
// Write output file
|
||||
await writeFileWithMkdirs(EXTERNAL_CLIENT_FILE, content)
|
||||
await writeFileWithMkdirs(EXTERNAL_CLIENT_FILE, content);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,14 +132,14 @@ function generateExternalClientSetup(serviceName, serviceDefinition) {
|
||||
const methods = Object.entries(serviceDefinition.service)
|
||||
.map(([methodName, methodDef]) => {
|
||||
// Get fully qualified type names
|
||||
const requestType = getFqn(methodDef.requestType.type.name)
|
||||
const responseType = getFqn(methodDef.responseType.type.name)
|
||||
const isStreamingResponse = methodDef.responseStream
|
||||
const requestType = getFqn(methodDef.requestType.type.name);
|
||||
const responseType = getFqn(methodDef.responseType.type.name);
|
||||
const isStreamingResponse = methodDef.responseStream;
|
||||
|
||||
if (!isStreamingResponse) {
|
||||
return ` ${methodName}(request: ${requestType}): Promise<${responseType}> {
|
||||
return this.makeRequest((client) => client.${methodName}(request))
|
||||
}`
|
||||
}`;
|
||||
} else {
|
||||
// Generate streaming method
|
||||
return ` ${methodName}(
|
||||
@@ -149,10 +164,10 @@ function generateExternalClientSetup(serviceName, serviceDefinition) {
|
||||
return () => {
|
||||
abortController.abort()
|
||||
}
|
||||
}\n`
|
||||
}\n`;
|
||||
}
|
||||
})
|
||||
.join("\n")
|
||||
.join("\n");
|
||||
|
||||
// Generate the class
|
||||
return `/**
|
||||
@@ -167,29 +182,35 @@ export class ${serviceName}ClientImpl
|
||||
}
|
||||
|
||||
${methods}
|
||||
}`
|
||||
}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the Vscode client setup file.
|
||||
*/
|
||||
async function generateVscodeClientFile(hostServices) {
|
||||
const imports = []
|
||||
const clientImplementations = []
|
||||
const handlerMap = []
|
||||
const imports = [];
|
||||
const clientImplementations = [];
|
||||
const handlerMap = [];
|
||||
for (const [serviceName, serviceDefinition] of Object.entries(hostServices)) {
|
||||
const name = serviceName.replace(/Service$/, "").toLowerCase()
|
||||
for (const [methodName, _methodDef] of Object.entries(serviceDefinition.service)) {
|
||||
imports.push(`import { ${methodName} } from "@/hosts/vscode/hostbridge/${name}/${methodName}"`)
|
||||
const name = serviceName.replace(/Service$/, "").toLowerCase();
|
||||
for (const [methodName, _methodDef] of Object.entries(
|
||||
serviceDefinition.service,
|
||||
)) {
|
||||
imports.push(
|
||||
`import { ${methodName} } from "@/hosts/vscode/hostbridge/${name}/${methodName}"`,
|
||||
);
|
||||
}
|
||||
imports.push("")
|
||||
imports.push("");
|
||||
|
||||
clientImplementations.push(generateVscodeClientImplementation(name, serviceDefinition))
|
||||
clientImplementations.push(
|
||||
generateVscodeClientImplementation(name, serviceDefinition),
|
||||
);
|
||||
|
||||
handlerMap.push(` "host.${serviceName}": {
|
||||
requestHandler: ${name}ServiceRegistry.handleRequest,
|
||||
streamingHandler: ${name}ServiceRegistry.handleStreamingRequest,
|
||||
},`)
|
||||
},`);
|
||||
}
|
||||
|
||||
const content = `// GENERATED CODE -- DO NOT EDIT!
|
||||
@@ -206,38 +227,38 @@ ${clientImplementations.join("\n\n")}
|
||||
export const hostServiceHandlers: Record<string, HostServiceHandlerConfig> = {
|
||||
${handlerMap.join("\n")}
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
// Write output file
|
||||
await writeFileWithMkdirs(VSCODE_CLIENT_FILE, content)
|
||||
await writeFileWithMkdirs(VSCODE_CLIENT_FILE, content);
|
||||
}
|
||||
|
||||
function generateVscodeClientImplementation(serviceName, serviceDefinition) {
|
||||
// Get the methods from the service definition
|
||||
const name = serviceName.replace(/Service$/, "").toLowerCase()
|
||||
const name = serviceName.replace(/Service$/, "").toLowerCase();
|
||||
|
||||
const methods = Object.entries(serviceDefinition.service)
|
||||
.map(([methodName, methodDef]) => {
|
||||
// Get fully qualified type names
|
||||
const isStreamingResponse = methodDef.responseStream
|
||||
const isStreamingResponse = methodDef.responseStream;
|
||||
if (!isStreamingResponse) {
|
||||
return `${name}ServiceRegistry.registerMethod("${methodName}", ${methodName})`
|
||||
return `${name}ServiceRegistry.registerMethod("${methodName}", ${methodName})`;
|
||||
} else {
|
||||
return `${name}ServiceRegistry.registerMethod("${methodName}", ${methodName}, { isStreaming: true })`
|
||||
return `${name}ServiceRegistry.registerMethod("${methodName}", ${methodName}, { isStreaming: true })`;
|
||||
}
|
||||
})
|
||||
.join("\n")
|
||||
.join("\n");
|
||||
|
||||
// Generate the class
|
||||
return `// Setup ${name} service registry
|
||||
const ${name}ServiceRegistry = createServiceRegistry("${name}")
|
||||
${methods}`
|
||||
${methods}`;
|
||||
}
|
||||
|
||||
// Only run main if this script is executed directly
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main().catch((error) => {
|
||||
console.error(chalk.red("Error:"), error)
|
||||
process.exit(1)
|
||||
})
|
||||
console.error(chalk.red("Error:"), error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,57 +1,68 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { writeFileWithMkdirs } from "./file-utils.mjs"
|
||||
import { getFqn, loadServicesFromProtoDescriptor } from "./proto-utils.mjs"
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { writeFileWithMkdirs } from "./file-utils.mjs";
|
||||
import { getFqn, loadServicesFromProtoDescriptor } from "./proto-utils.mjs";
|
||||
|
||||
const WEBVIEW_CLIENTS_FILE = path.resolve("webview-ui/src/services/grpc-client.ts")
|
||||
const VSCODE_SERVICES_FILE = path.resolve("src/generated/hosts/vscode/protobus-services.ts")
|
||||
const VSCODE_SERVICE_TYPES_FILE = path.resolve("src/generated/hosts/vscode/protobus-service-types.ts")
|
||||
const STANDALONE_SERVER_SETUP_FILE = path.resolve("src/generated/hosts/standalone/protobus-server-setup.ts")
|
||||
const WEBVIEW_CLIENTS_FILE = path.resolve(
|
||||
"webview-ui/src/services/grpc-client.ts",
|
||||
);
|
||||
const VSCODE_SERVICES_FILE = path.resolve(
|
||||
"src/generated/hosts/vscode/protobus-services.ts",
|
||||
);
|
||||
const VSCODE_SERVICE_TYPES_FILE = path.resolve(
|
||||
"src/generated/hosts/vscode/protobus-service-types.ts",
|
||||
);
|
||||
const STANDALONE_SERVER_SETUP_FILE = path.resolve(
|
||||
"src/generated/hosts/standalone/protobus-server-setup.ts",
|
||||
);
|
||||
|
||||
const SCRIPT_NAME = path.relative(process.cwd(), fileURLToPath(import.meta.url))
|
||||
const SCRIPT_NAME = path.relative(
|
||||
process.cwd(),
|
||||
fileURLToPath(import.meta.url),
|
||||
);
|
||||
|
||||
export async function main() {
|
||||
const { protobusServices } = await loadServicesFromProtoDescriptor()
|
||||
await generateWebviewProtobusClients(protobusServices)
|
||||
await generateVscodeServiceTypes(protobusServices)
|
||||
await generateVscodeProtobusServers(protobusServices)
|
||||
await generateStandaloneProtobusServiceSetup(protobusServices)
|
||||
const { protobusServices } = await loadServicesFromProtoDescriptor();
|
||||
await generateWebviewProtobusClients(protobusServices);
|
||||
await generateVscodeServiceTypes(protobusServices);
|
||||
await generateVscodeProtobusServers(protobusServices);
|
||||
await generateStandaloneProtobusServiceSetup(protobusServices);
|
||||
|
||||
console.log(`Generated ProtoBus files at:`)
|
||||
console.log(`- ${WEBVIEW_CLIENTS_FILE}`)
|
||||
console.log(`- ${VSCODE_SERVICE_TYPES_FILE}`)
|
||||
console.log(`- ${VSCODE_SERVICES_FILE}`)
|
||||
console.log(`- ${STANDALONE_SERVER_SETUP_FILE}`)
|
||||
console.log(`Generated ProtoBus files at:`);
|
||||
console.log(`- ${WEBVIEW_CLIENTS_FILE}`);
|
||||
console.log(`- ${VSCODE_SERVICE_TYPES_FILE}`);
|
||||
console.log(`- ${VSCODE_SERVICES_FILE}`);
|
||||
console.log(`- ${STANDALONE_SERVER_SETUP_FILE}`);
|
||||
}
|
||||
|
||||
async function generateWebviewProtobusClients(protobusServices) {
|
||||
const clients = []
|
||||
const clients = [];
|
||||
|
||||
for (const [serviceName, def] of Object.entries(protobusServices)) {
|
||||
const rpcs = []
|
||||
const rpcs = [];
|
||||
for (const [rpcName, rpc] of Object.entries(def.service)) {
|
||||
const requestType = getFqn(rpc.requestType.type.name)
|
||||
const responseType = getFqn(rpc.responseType.type.name)
|
||||
const requestType = getFqn(rpc.requestType.type.name);
|
||||
const responseType = getFqn(rpc.responseType.type.name);
|
||||
|
||||
if (rpc.requestStream) {
|
||||
throw new Error("Request streaming is not supported")
|
||||
throw new Error("Request streaming is not supported");
|
||||
}
|
||||
if (!rpc.responseStream) {
|
||||
rpcs.push(` static async ${rpcName}(request: ${requestType}): Promise<${responseType}> {
|
||||
return this.makeUnaryRequest("${rpcName}", request, ${requestType}.toJSON, ${responseType}.fromJSON)
|
||||
}`)
|
||||
}`);
|
||||
} else {
|
||||
rpcs.push(` static ${rpcName}(request: ${requestType}, callbacks: Callbacks<${responseType}>): ()=>void {
|
||||
return this.makeStreamingRequest("${rpcName}", request, ${requestType}.toJSON, ${responseType}.fromJSON, callbacks)
|
||||
}`)
|
||||
}`);
|
||||
}
|
||||
}
|
||||
clients.push(`export class ${serviceName}Client extends ProtoBusClient {
|
||||
static override serviceName: string = "cline.${serviceName}"
|
||||
${rpcs.join("\n")}
|
||||
}`)
|
||||
}`);
|
||||
}
|
||||
|
||||
// Create output file
|
||||
@@ -61,36 +72,38 @@ import * as proto from "@shared/proto/index"
|
||||
import { ProtoBusClient, Callbacks } from "./grpc-client-base"
|
||||
|
||||
${clients.join("\n")}
|
||||
`
|
||||
`;
|
||||
// Write output file
|
||||
await writeFileWithMkdirs(WEBVIEW_CLIENTS_FILE, output)
|
||||
await writeFileWithMkdirs(WEBVIEW_CLIENTS_FILE, output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate imports and function to add all the handlers to the server for all services defined in the proto files.
|
||||
*/
|
||||
async function generateVscodeServiceTypes(protobusServices) {
|
||||
const servers = []
|
||||
const servers = [];
|
||||
|
||||
for (const [serviceName, def] of Object.entries(protobusServices)) {
|
||||
const domain = getDomainName(serviceName)
|
||||
servers.push(`// ${domain} Service Handler Types`)
|
||||
servers.push(`export type ${serviceName}Handlers = {`)
|
||||
const domain = getDomainName(serviceName);
|
||||
servers.push(`// ${domain} Service Handler Types`);
|
||||
servers.push(`export type ${serviceName}Handlers = {`);
|
||||
for (const [rpcName, rpc] of Object.entries(def.service)) {
|
||||
const requestType = getFqn(rpc.requestType.type.name)
|
||||
const responseType = getFqn(rpc.responseType.type.name)
|
||||
const requestType = getFqn(rpc.requestType.type.name);
|
||||
const responseType = getFqn(rpc.responseType.type.name);
|
||||
if (rpc.requestStream) {
|
||||
throw new Error("Request streaming is not supported")
|
||||
throw new Error("Request streaming is not supported");
|
||||
}
|
||||
if (!rpc.responseStream) {
|
||||
servers.push(` ${rpcName}:(controller: Controller, request: ${requestType}) => Promise<${responseType}>`)
|
||||
servers.push(
|
||||
` ${rpcName}:(controller: Controller, request: ${requestType}) => Promise<${responseType}>`,
|
||||
);
|
||||
} else {
|
||||
servers.push(
|
||||
` ${rpcName}:(controller: Controller, request: ${requestType}, responseStream: StreamingResponseHandler<${responseType}>, requestId?: string) => Promise<void>`,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
servers.push(`}\n`)
|
||||
servers.push(`}\n`);
|
||||
}
|
||||
|
||||
// Create output file
|
||||
@@ -101,30 +114,38 @@ import { Controller } from "@core/controller"
|
||||
import { StreamingResponseHandler } from "@/core/controller/grpc-handler"
|
||||
|
||||
${servers.join("\n")}
|
||||
`
|
||||
`;
|
||||
// Write output file
|
||||
await writeFileWithMkdirs(VSCODE_SERVICE_TYPES_FILE, output)
|
||||
await writeFileWithMkdirs(VSCODE_SERVICE_TYPES_FILE, output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate imports and function to add all the handlers to the server for all services defined in the proto files.
|
||||
*/
|
||||
async function generateVscodeProtobusServers(protobusServices) {
|
||||
const imports = []
|
||||
const servers = []
|
||||
const serviceMap = []
|
||||
const imports = [];
|
||||
const servers = [];
|
||||
const serviceMap = [];
|
||||
const streamingMethods = [];
|
||||
for (const [serviceName, def] of Object.entries(protobusServices)) {
|
||||
const domain = getDomainName(serviceName)
|
||||
const dir = getDirName(serviceName)
|
||||
imports.push(`// ${domain} Service`)
|
||||
servers.push(`const ${serviceName}Handlers: serviceTypes.${serviceName}Handlers = {`)
|
||||
for (const [rpcName, _rpc] of Object.entries(def.service)) {
|
||||
imports.push(`import { ${rpcName} } from "@core/controller/${dir}/${rpcName}"`)
|
||||
servers.push(` ${rpcName}: ${rpcName},`)
|
||||
const domain = getDomainName(serviceName);
|
||||
const dir = getDirName(serviceName);
|
||||
imports.push(`// ${domain} Service`);
|
||||
servers.push(
|
||||
`const ${serviceName}Handlers: serviceTypes.${serviceName}Handlers = {`,
|
||||
);
|
||||
for (const [rpcName, rpc] of Object.entries(def.service)) {
|
||||
imports.push(
|
||||
`import { ${rpcName} } from "@core/controller/${dir}/${rpcName}"`,
|
||||
);
|
||||
servers.push(` ${rpcName}: ${rpcName},`);
|
||||
if (rpc.responseStream) {
|
||||
streamingMethods.push(` "cline.${serviceName}.${rpcName}",`);
|
||||
}
|
||||
}
|
||||
servers.push(`} \n`)
|
||||
serviceMap.push(` "cline.${serviceName}": ${serviceName}Handlers,`)
|
||||
imports.push("")
|
||||
servers.push(`} \n`);
|
||||
serviceMap.push(` "cline.${serviceName}": ${serviceName}Handlers,`);
|
||||
imports.push("");
|
||||
}
|
||||
|
||||
// Create output file
|
||||
@@ -137,42 +158,51 @@ ${servers.join("\n")}
|
||||
export const serviceHandlers: Record<string, any> = {
|
||||
${serviceMap.join("\n")}
|
||||
}
|
||||
`
|
||||
|
||||
/** Fully-qualified response-streaming methods, derived from the proto descriptors. */
|
||||
export const responseStreamingMethods: ReadonlySet<string> = new Set([
|
||||
${streamingMethods.join("\n")}
|
||||
])
|
||||
`;
|
||||
// Write output file
|
||||
await writeFileWithMkdirs(VSCODE_SERVICES_FILE, output)
|
||||
await writeFileWithMkdirs(VSCODE_SERVICES_FILE, output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate imports and function to add all the handlers to the server for all services defined in the proto files.
|
||||
*/
|
||||
async function generateStandaloneProtobusServiceSetup(protobusServices) {
|
||||
const imports = []
|
||||
const handlerSetup = []
|
||||
const imports = [];
|
||||
const handlerSetup = [];
|
||||
|
||||
for (const [name, def] of Object.entries(protobusServices)) {
|
||||
const domain = getDomainName(name)
|
||||
const dir = getDirName(name)
|
||||
imports.push(`// ${domain} Service`)
|
||||
handlerSetup.push(` // ${domain} Service`)
|
||||
handlerSetup.push(` server.addService(cline.${name}Service, {`)
|
||||
const domain = getDomainName(name);
|
||||
const dir = getDirName(name);
|
||||
imports.push(`// ${domain} Service`);
|
||||
handlerSetup.push(` // ${domain} Service`);
|
||||
handlerSetup.push(` server.addService(cline.${name}Service, {`);
|
||||
for (const [rpcName, rpc] of Object.entries(def.service)) {
|
||||
imports.push(`import { ${rpcName} } from "@core/controller/${dir}/${rpcName}"`)
|
||||
const requestType = "cline." + rpc.requestType.type.name
|
||||
const responseType = "cline." + rpc.responseType.type.name
|
||||
imports.push(
|
||||
`import { ${rpcName} } from "@core/controller/${dir}/${rpcName}"`,
|
||||
);
|
||||
const requestType = "cline." + rpc.requestType.type.name;
|
||||
const responseType = "cline." + rpc.responseType.type.name;
|
||||
if (rpc.requestStream) {
|
||||
throw new Error("Request streaming is not supported")
|
||||
throw new Error("Request streaming is not supported");
|
||||
}
|
||||
if (rpc.responseStream) {
|
||||
handlerSetup.push(
|
||||
` ${rpcName}: wrapStreamingResponse<${requestType},${responseType}>(${rpcName}, controller),`,
|
||||
)
|
||||
);
|
||||
} else {
|
||||
handlerSetup.push(` ${rpcName}: wrapper<${requestType},${responseType}>(${rpcName}, controller),`)
|
||||
handlerSetup.push(
|
||||
` ${rpcName}: wrapper<${requestType},${responseType}>(${rpcName}, controller),`,
|
||||
);
|
||||
}
|
||||
}
|
||||
handlerSetup.push(` });`)
|
||||
imports.push("")
|
||||
handlerSetup.push("")
|
||||
handlerSetup.push(` });`);
|
||||
imports.push("");
|
||||
handlerSetup.push("");
|
||||
}
|
||||
|
||||
// Create output file
|
||||
@@ -192,23 +222,23 @@ export function addProtobusServices(
|
||||
): void {
|
||||
${handlerSetup.join("\n")}
|
||||
}
|
||||
`
|
||||
`;
|
||||
// Write output file
|
||||
await writeFileWithMkdirs(STANDALONE_SERVER_SETUP_FILE, output)
|
||||
await writeFileWithMkdirs(STANDALONE_SERVER_SETUP_FILE, output);
|
||||
}
|
||||
|
||||
function getDomainName(serviceName) {
|
||||
return serviceName.replace(/Service$/, "")
|
||||
return serviceName.replace(/Service$/, "");
|
||||
}
|
||||
function getDirName(serviceName) {
|
||||
const domain = getDomainName(serviceName)
|
||||
return domain.charAt(0).toLowerCase() + domain.slice(1)
|
||||
const domain = getDomainName(serviceName);
|
||||
return domain.charAt(0).toLowerCase() + domain.slice(1);
|
||||
}
|
||||
|
||||
// Only run main if this script is executed directly
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main().catch((error) => {
|
||||
console.error(chalk.red("Error:"), error)
|
||||
process.exit(1)
|
||||
})
|
||||
console.error(chalk.red("Error:"), error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,7 +27,11 @@ export async function openAiCodexSignIn(controller: Controller, _: EmptyRequest)
|
||||
.catch((error) => {
|
||||
Logger.error("[openAiCodexSignIn] OAuth flow failed:", error)
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
if (!errorMessage.includes("timed out")) {
|
||||
// "Missing authorization code" is the abandoned-flow shape of a
|
||||
// timeout here: the webview provides no manual code entry, so it
|
||||
// only occurs when the callback wait expires without a redirect.
|
||||
const isAbandonedFlow = errorMessage.includes("timed out") || errorMessage.includes("Missing authorization code")
|
||||
if (!isAbandonedFlow) {
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: `OpenAI Codex sign in failed: ${errorMessage}`,
|
||||
|
||||
@@ -62,7 +62,9 @@ describe("provider model catalog backend smoke", () => {
|
||||
})
|
||||
expect(models.ok).toBe(true)
|
||||
expect(models.requestId).toBe("smoke-request")
|
||||
expect(Object.keys(models.models).length).toBeGreaterThanOrEqual(4)
|
||||
// Don't pin an exact count: the generated catalog is refreshed regularly
|
||||
// and DeepSeek's lineup changes (e.g. the v4 refresh shrank it to 3).
|
||||
expect(Object.keys(models.models).length).toBeGreaterThan(0)
|
||||
|
||||
const modelId = models.defaultModelId || Object.keys(models.models)[0]
|
||||
expect(modelId).toBeTruthy()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ChildProcess, spawn } from "child_process"
|
||||
import { EventEmitter } from "events"
|
||||
import { existsSync } from "fs"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { resolveWindowsPowerShellExecutable } from "@/utils/powershell"
|
||||
import { HookProcessRegistry } from "./HookProcessRegistry"
|
||||
@@ -173,11 +174,23 @@ export class HookProcess extends EventEmitter {
|
||||
void (async () => {
|
||||
try {
|
||||
const launchConfig = await getHookLaunchConfig(this.scriptPath)
|
||||
|
||||
// A cwd that doesn't exist makes spawn fail with a misleading
|
||||
// ENOENT against the launcher binary (e.g. "spawn /bin/sh ENOENT").
|
||||
// Fail the hook with an error naming the real culprit instead.
|
||||
// Running the hook anyway (with no explicit cwd) is not safe: its
|
||||
// relative paths would resolve against the host process's own
|
||||
// working directory, not the workspace it was written for.
|
||||
if (this.cwd && !existsSync(this.cwd)) {
|
||||
throw new Error(
|
||||
`Hook working directory '${this.cwd}' does not exist; not running hook '${this.scriptPath}'`,
|
||||
)
|
||||
}
|
||||
this.childProcess = spawn(launchConfig.command, launchConfig.args, {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
shell: launchConfig.shell,
|
||||
detached: launchConfig.detached,
|
||||
cwd: this.cwd, // Execute from the determined workspace root
|
||||
cwd: this.cwd, // Execute from the determined workspace root (validated above)
|
||||
windowsHide: true,
|
||||
})
|
||||
|
||||
@@ -259,8 +272,15 @@ export class HookProcess extends EventEmitter {
|
||||
if (this.abortSignal) {
|
||||
this.abortSignal.removeEventListener("abort", abortHandler)
|
||||
}
|
||||
this.emit("error", error)
|
||||
reject(error)
|
||||
const spawnError = this.describeSpawnError(error)
|
||||
// Emitting "error" with no listener registered makes EventEmitter
|
||||
// throw, which would escape this callback as an uncaught exception
|
||||
// and kill the whole host process. Hook failures must fail open,
|
||||
// so only forward the event when someone is actually listening.
|
||||
if (this.listenerCount("error") > 0) {
|
||||
this.emit("error", spawnError)
|
||||
}
|
||||
reject(spawnError)
|
||||
})
|
||||
|
||||
// Send input to the process
|
||||
@@ -285,6 +305,21 @@ export class HookProcess extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates a child-process spawn failure into an actionable error.
|
||||
*
|
||||
* Node reports a working directory that vanished between validation and
|
||||
* spawn as an ENOENT on the launcher binary (e.g. "spawn /bin/sh ENOENT"),
|
||||
* so name the real culprit when that is what happened.
|
||||
*/
|
||||
private describeSpawnError(error: Error): Error {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code === "ENOENT" && this.cwd && !existsSync(this.cwd)) {
|
||||
return new Error(`Hook working directory '${this.cwd}' does not exist (reported by spawn as: ${error.message})`)
|
||||
}
|
||||
return error
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely unregister from the process registry.
|
||||
* This is idempotent and prevents double-unregistration issues.
|
||||
|
||||
@@ -4,6 +4,7 @@ import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import sinon from "sinon"
|
||||
import { setDistinctId } from "@/services/logging/distinctId"
|
||||
import { stubWorkspacePaths } from "../../../test/host-provider-test-utils"
|
||||
import { HookFactory, isPathWithin } from "../hook-factory"
|
||||
import { createHookTestEnv, HookTestEnv, stubHookDirs, withPlatform, writeHookScriptForPlatform } from "./test-utils"
|
||||
|
||||
@@ -94,6 +95,48 @@ console.log(JSON.stringify({
|
||||
WINDOWS_HOOK_TEST_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
it(
|
||||
"should fail open without crashing when the workspace root no longer exists",
|
||||
async () => {
|
||||
const hookPath = path.join(tempDir, ".clinerules", "hooks", "PreToolUse")
|
||||
const hookScript = `#!/usr/bin/env node
|
||||
console.log(JSON.stringify({
|
||||
cancel: false
|
||||
}))`
|
||||
|
||||
await writeHookScript(hookPath, hookScript)
|
||||
|
||||
// Point the host-reported workspace roots at a directory that no
|
||||
// longer exists on disk (deleted, renamed, or unmounted since the
|
||||
// host resolved it). Spawning with that path as cwd used to fail
|
||||
// with a misleading "spawn /bin/sh ENOENT" whose unlistened
|
||||
// "error" emit crashed the whole host process.
|
||||
sandbox.restore()
|
||||
stubWorkspacePaths(sandbox, [path.join(tempDir, "deleted-workspace-root")])
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
|
||||
try {
|
||||
await runner.run({
|
||||
taskId: "test-task",
|
||||
preToolUse: {
|
||||
toolName: "test_tool",
|
||||
parameters: {},
|
||||
},
|
||||
})
|
||||
throw new Error("Expected hook run to fail")
|
||||
} catch (error: any) {
|
||||
// The failure must be a catchable hook error that names the
|
||||
// missing directory - not an uncaught exception killing the
|
||||
// process, and not a run from an unrelated working directory.
|
||||
error.message.should.not.equal("Expected hook run to fail")
|
||||
String(error.errorInfo?.stderr ?? "").should.containEql("deleted-workspace-root")
|
||||
}
|
||||
},
|
||||
WINDOWS_HOOK_TEST_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
it("should execute hook script and parse output", async () => {
|
||||
// Create a test hook script
|
||||
const hookPath = path.join(tempDir, ".clinerules", "hooks", "PreToolUse")
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, it } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import "should"
|
||||
import { getHookLaunchConfig, resetHookLaunchConfigCacheForTesting } from "../HookProcess"
|
||||
import { withPlatform } from "./test-utils"
|
||||
import { getHookLaunchConfig, HookProcess, resetHookLaunchConfigCacheForTesting } from "../HookProcess"
|
||||
import { withPlatform, writeHookScriptForPlatform } from "./test-utils"
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void
|
||||
@@ -175,3 +178,63 @@ describe("HookProcess", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("HookProcess spawn failures", () => {
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hookprocess-test-"))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function writeTestHook(): Promise<string> {
|
||||
const hookBasePath = path.join(tempDir, "TaskStart")
|
||||
await writeHookScriptForPlatform(hookBasePath, `#!/usr/bin/env node\nconsole.log(JSON.stringify({ cancel: false }))\n`)
|
||||
return process.platform === "win32" ? `${hookBasePath}.ps1` : hookBasePath
|
||||
}
|
||||
|
||||
it("fails the hook run with an error naming the cwd when it does not exist", async () => {
|
||||
const scriptPath = await writeTestHook()
|
||||
const missingCwd = path.join(tempDir, "deleted-workspace-root")
|
||||
|
||||
// A hook assigned a working directory that no longer exists must fail
|
||||
// (open, catchably) rather than run with its relative paths resolving
|
||||
// against the host process's own working directory.
|
||||
const hookProcess = new HookProcess(scriptPath, 30000, undefined, missingCwd)
|
||||
|
||||
try {
|
||||
await hookProcess.run("{}")
|
||||
throw new Error("Expected hook run to fail")
|
||||
} catch (error: any) {
|
||||
error.message.should.containEql(missingCwd)
|
||||
error.message.should.containEql("does not exist")
|
||||
}
|
||||
}, 15000)
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"rejects instead of crashing when spawn fails with no error listener registered",
|
||||
async () => {
|
||||
const scriptPath = await writeTestHook()
|
||||
|
||||
// A regular file passes the pre-spawn cwd existence check but makes
|
||||
// spawn fail with an "error" event (ENOTDIR). No "error" listener is
|
||||
// registered here, matching StdioHookRunner: an unguarded emit would
|
||||
// escape the child's error callback as an uncaught exception and kill
|
||||
// the whole process instead of failing this one hook.
|
||||
const fileAsCwd = path.join(tempDir, "not-a-directory")
|
||||
await fs.writeFile(fileAsCwd, "")
|
||||
|
||||
const hookProcess = new HookProcess(scriptPath, 5000, undefined, fileAsCwd)
|
||||
|
||||
try {
|
||||
await hookProcess.run("{}")
|
||||
throw new Error("Expected hook run to fail")
|
||||
} catch (error: any) {
|
||||
error.message.should.not.equal("Expected hook run to fail")
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
@@ -642,7 +642,11 @@ class StdioHookRunner<Name extends HookName> extends HookRunner<Name> {
|
||||
"HookFactory.exec.catch.execution",
|
||||
)
|
||||
}
|
||||
throw HookExecutionError.execution(this.scriptPath, exitCode ?? 1, stderr, this.hookName)
|
||||
// A failure before the process produced any stderr (e.g. spawn never
|
||||
// happened) would otherwise surface as a bare "exited with code 1";
|
||||
// carry the underlying error message so the user sees the cause.
|
||||
const details = stderr || (error instanceof Error ? error.message : String(error))
|
||||
throw HookExecutionError.execution(this.scriptPath, exitCode ?? 1, details, this.hookName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,12 @@ import { Logger } from "@/shared/services/Logger"
|
||||
import type { LockRow, SqliteLockManagerOptions } from "./types"
|
||||
export class SqliteLockManager {
|
||||
private db!: Database.Database
|
||||
private instanceAddress: string
|
||||
private instanceOwner: string
|
||||
private dbPath: string
|
||||
private readonly STALE_LOCK_TIMEOUT = 1 * 60 * 1000 // 1 minute in milliseconds
|
||||
|
||||
constructor(options: SqliteLockManagerOptions) {
|
||||
this.instanceAddress = options.instanceAddress
|
||||
this.instanceOwner = options.instanceOwner
|
||||
this.dbPath = options.dbPath
|
||||
|
||||
// Ensure the directory exists before creating the database
|
||||
@@ -148,7 +148,7 @@ export class SqliteLockManager {
|
||||
VALUES (?, 'instance', ?, ?)
|
||||
`)
|
||||
|
||||
insertLock.run(this.instanceAddress, data.hostAddress, now)
|
||||
insertLock.run(this.instanceOwner, data.hostAddress, now)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -162,7 +162,7 @@ export class SqliteLockManager {
|
||||
WHERE held_by = ? AND lock_type = 'instance'
|
||||
`)
|
||||
|
||||
updateLock.run(now, this.instanceAddress)
|
||||
updateLock.run(now, this.instanceOwner)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,42 +174,19 @@ export class SqliteLockManager {
|
||||
WHERE held_by = ? AND lock_type = 'instance'
|
||||
`)
|
||||
|
||||
deleteLock.run(this.instanceAddress)
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the registry for any instance registered on the given port
|
||||
*/
|
||||
getInstanceByPort(port: number): { instanceAddress: string; hostAddress: string } | null {
|
||||
const query = this.db.prepare(`
|
||||
SELECT held_by, lock_target
|
||||
FROM locks
|
||||
WHERE lock_type = 'instance'
|
||||
AND (held_by LIKE '%:' || ? OR lock_target LIKE '%:' || ?)
|
||||
`)
|
||||
|
||||
const result = query.get(port, port) as { held_by: string; lock_target: string } | undefined
|
||||
|
||||
if (result) {
|
||||
return {
|
||||
instanceAddress: result.held_by,
|
||||
hostAddress: result.lock_target,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
deleteLock.run(this.instanceOwner)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a specific instance entry from the registry
|
||||
*/
|
||||
removeInstanceByAddress(instanceAddress: string): void {
|
||||
removeInstanceByOwner(instanceOwner: string): void {
|
||||
const deleteLock = this.db.prepare(`
|
||||
DELETE FROM locks
|
||||
WHERE held_by = ? AND lock_type = 'instance'
|
||||
`)
|
||||
|
||||
deleteLock.run(instanceAddress)
|
||||
deleteLock.run(instanceOwner)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -235,8 +212,8 @@ export class SqliteLockManager {
|
||||
WHERE held_by = ? AND lock_type = 'folder' AND lock_target = ?
|
||||
`)
|
||||
|
||||
// swap instance address in place of taskID
|
||||
heldBy = this.instanceAddress
|
||||
// swap instance owner in place of taskID
|
||||
heldBy = this.instanceOwner
|
||||
deleteLock.run(heldBy, lockTarget)
|
||||
}
|
||||
|
||||
@@ -251,20 +228,19 @@ export class SqliteLockManager {
|
||||
VALUES (?, 'folder', ?, ?)
|
||||
`)
|
||||
|
||||
// swap instance address in place of taskID
|
||||
heldBy = this.instanceAddress
|
||||
const insertedCount = insertLock.run(this.instanceAddress, lockTarget, now).changes
|
||||
// swap instance owner in place of taskID
|
||||
heldBy = this.instanceOwner
|
||||
const insertedCount = insertLock.run(this.instanceOwner, lockTarget, now).changes
|
||||
|
||||
if (insertedCount > 0) {
|
||||
return null // lock acquired
|
||||
} else {
|
||||
const existingLock = await this.getFolderLockByTarget(lockTarget)
|
||||
if (existingLock && existingLock.held_by === heldBy) {
|
||||
return null // existing lock is held by the same task
|
||||
}
|
||||
// existing lock held by other task, return the conflicting lock
|
||||
return await this.getFolderLockByTarget(lockTarget)
|
||||
}
|
||||
const existingLock = await this.getFolderLockByTarget(lockTarget)
|
||||
if (existingLock && existingLock.held_by === heldBy) {
|
||||
return null // existing lock is held by the same task
|
||||
}
|
||||
// existing lock held by other task, return the conflicting lock
|
||||
return await this.getFolderLockByTarget(lockTarget)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,5 +10,8 @@ export interface LockRow {
|
||||
|
||||
export interface SqliteLockManagerOptions {
|
||||
dbPath: string
|
||||
instanceAddress: string // cline core address
|
||||
// Opaque identity that owns this instance's rows (held_by). A spawned core
|
||||
// uses its per-spawn instance ID; the CLI-harness core uses its listener
|
||||
// address. No query may interpret it as an address.
|
||||
instanceOwner: string
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { afterEach, beforeEach, describe, it } from "mocha"
|
||||
import { FileContextTracker } from "@core/context/context-tracking/FileContextTracker"
|
||||
import * as extractTextModule from "@integrations/misc/extract-text"
|
||||
import { UrlContentFetcher } from "@services/browser/UrlContentFetcher"
|
||||
@@ -6,6 +5,7 @@ import * as gitModule from "@utils/git"
|
||||
import { expect } from "chai"
|
||||
import * as fs from "fs"
|
||||
import * as isBinaryFileModule from "isbinaryfile"
|
||||
import { afterEach, beforeEach, describe, it } from "mocha"
|
||||
import * as path from "path"
|
||||
import * as sinon from "sinon"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
|
||||
@@ -67,7 +67,8 @@ export async function parseMentions(
|
||||
mentions.add(mention)
|
||||
if (mention.startsWith("http")) {
|
||||
return `'${mention}' (see below for site content)`
|
||||
} else if (isFileMention(mention)) {
|
||||
}
|
||||
if (isFileMention(mention)) {
|
||||
const mentionPath = getFilePathFromMention(mention)
|
||||
const workspaceHint = getWorkspaceHintFromMention(mention)
|
||||
// For workspace-prefixed mentions, include the workspace name in the same format the model uses for tool calls
|
||||
@@ -79,13 +80,17 @@ export async function parseMentions(
|
||||
return mentionPath.endsWith("/")
|
||||
? `'${mentionPath}' (see below for folder content)`
|
||||
: `'${mentionPath}' (see below for file content)`
|
||||
} else if (mention === "problems") {
|
||||
}
|
||||
if (mention === "problems") {
|
||||
return `Workspace Problems (see below for diagnostics)`
|
||||
} else if (mention === "terminal") {
|
||||
}
|
||||
if (mention === "terminal") {
|
||||
return `Terminal Output (see below for output)`
|
||||
} else if (mention === "git-changes") {
|
||||
}
|
||||
if (mention === "git-changes") {
|
||||
return `Working directory changes (see below for details)`
|
||||
} else if (/^[a-f0-9]{7,40}$/.test(mention)) {
|
||||
}
|
||||
if (/^[a-f0-9]{7,40}$/.test(mention)) {
|
||||
return `Git commit '${mention}' (see below for commit info)`
|
||||
}
|
||||
return match
|
||||
@@ -339,7 +344,8 @@ async function getFileOrFolderContent(mentionPath: string, cwd: string): Promise
|
||||
}
|
||||
const content = await extractTextFromFile(absPath)
|
||||
return content
|
||||
} else if (stats.isDirectory()) {
|
||||
}
|
||||
if (stats.isDirectory()) {
|
||||
const entries = await fs.readdir(absPath, { withFileTypes: true })
|
||||
let folderContent = ""
|
||||
const fileContentPromises: Promise<string | undefined>[] = []
|
||||
@@ -374,9 +380,8 @@ async function getFileOrFolderContent(mentionPath: string, cwd: string): Promise
|
||||
})
|
||||
const fileContents = (await Promise.all(fileContentPromises)).filter((content) => content)
|
||||
return `${folderContent}\n${fileContents.join("\n\n")}`.trim()
|
||||
} else {
|
||||
return `(Failed to read contents of ${mentionPath})`
|
||||
}
|
||||
return `(Failed to read contents of ${mentionPath})`
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to access path "${mentionPath}": ${error.message}`)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { TOOL_REJECTION_SUFFIX, USER_REJECTED_TOOL_REASON } from "@cline/shared"
|
||||
import * as diff from "diff"
|
||||
import * as path from "path"
|
||||
import { Mode } from "@/shared/storage/types"
|
||||
@@ -20,7 +21,8 @@ export const formatResponse = {
|
||||
condense: () =>
|
||||
`The user has accepted the condensed conversation summary you generated. This summary covers important details of the historical conversation with the user which has been truncated.\n<explicit_instructions type="condense_response">It's crucial that you respond by ONLY asking the user what you should work on next. You should NOT take any initiative or make any assumptions about continuing with work. For example you should NOT suggest file changes or attempt to read any files.\nWhen asking the user what you should work on next, you can reference information in the summary which was just generated. However, you should NOT reference information outside of what's contained in the summary for this response. Keep this response CONCISE.</explicit_instructions>`,
|
||||
|
||||
toolDenied: () => `The user denied this operation.`,
|
||||
// Not routed through the agent runtime, so the guidance suffix is included here.
|
||||
toolDenied: () => `${USER_REJECTED_TOOL_REASON} -- ${TOOL_REJECTION_SUFFIX}`,
|
||||
|
||||
toolError: (error?: string) => `The tool execution failed with the following error:\n<error>\n${error}\n</error>`,
|
||||
|
||||
|
||||
@@ -315,7 +315,15 @@ export class Controller {
|
||||
// completion row (plan → yellow plan box, act → green completion box).
|
||||
this.messageTranslatorState = new MessageTranslatorState(
|
||||
undefined,
|
||||
() => this.getActiveProviderId(),
|
||||
// Provider backing the active turn — error reshaping branches on it
|
||||
// (BYOK credential guidance vs the cline sign-in card). Prefer the
|
||||
// active session's start metadata over the current settings
|
||||
// selection: a provider/mode switch made while a turn is in flight
|
||||
// changes the settings selection immediately, but the failing turn
|
||||
// still belongs to the session's provider. Provider switches always
|
||||
// start a new session, so start metadata never goes stale the way
|
||||
// a mid-task model-only switch does for models below.
|
||||
() => this.getSessionProviderId() ?? this.getActiveProviderId(),
|
||||
() => (this.stateManager.getGlobalSettingsKey("mode") === "plan" ? "plan" : "act"),
|
||||
() => this.lastKnownWorkspaceRoot,
|
||||
// Model backing the active turn — lets error reshaping recognize
|
||||
@@ -2042,6 +2050,7 @@ export class Controller {
|
||||
cacheWrites: metadataNumber(metadata, "cacheWrites") ?? 0,
|
||||
cacheReads: metadataNumber(metadata, "cacheReads") ?? 0,
|
||||
modelId: item.model || metadataString(metadata, "modelId") || "",
|
||||
apiProvider: item.provider ?? "",
|
||||
isLegacy:
|
||||
metadataBoolean(metadata, "legacyTask") === true ||
|
||||
metadataBoolean(metadata, "migratedFromLegacyTask") === true,
|
||||
@@ -2066,6 +2075,7 @@ export class Controller {
|
||||
cacheWrites: 0,
|
||||
cacheReads: 0,
|
||||
modelId: this.task.api?.getModel?.().id ?? "",
|
||||
apiProvider: "",
|
||||
isLegacy: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import type { ApiProvider } from "@shared/api"
|
||||
import { AuthState, UserInfo } from "@shared/proto/cline/account"
|
||||
import type { EmptyRequest, String } from "@shared/proto/cline/common"
|
||||
import { ShowMessageType } from "@shared/proto/host/window"
|
||||
import axios from "axios"
|
||||
import { ClineEnv } from "@/config"
|
||||
import type { Controller } from "@/core/controller"
|
||||
@@ -749,10 +750,24 @@ export class AuthService {
|
||||
const callbacks = createOAuthClientCallbacks({
|
||||
onPrompt: async (prompt) => prompt.defaultValue ?? "",
|
||||
openUrl: async (url: string) => {
|
||||
// E2E drives the OAuth callback itself (codex-oauth.test.ts).
|
||||
// Opening a real browser on the runner leaves an orphaned
|
||||
// process holding the Playwright<->Electron pipes, which
|
||||
// wedges the worker teardown until its 60s timeout fails
|
||||
// the job.
|
||||
if (process.env.E2E_TEST === "true") {
|
||||
return
|
||||
}
|
||||
await openExternal(url)
|
||||
},
|
||||
onOpenUrlError: ({ url, error }) => {
|
||||
// Same recovery the CLI offers ("open the URL above manually") —
|
||||
// the toast is the extension's only place to surface the URL.
|
||||
Logger.error(`[SdkAuthService] Failed to open browser for Codex: ${url}:`, error)
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: `Couldn't open your browser for OpenAI sign-in. Open this URL manually: ${url}`,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// Hook-execution telemetry only fires when the adapter passes a task id into
|
||||
// HookFactory.create (StdioHookRunner gates every captureHookExecution on it),
|
||||
// so these tests pin the id threading at every adapter call site.
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { buildAgentHooks } from "./hooks-adapter"
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/core/hooks/hook-factory", () => ({
|
||||
HookFactory: class {
|
||||
create = mocks.create
|
||||
},
|
||||
}))
|
||||
|
||||
function makeRunner() {
|
||||
return {
|
||||
isNoOp: false,
|
||||
run: vi.fn(async () => ({ cancel: false, contextModification: "", errorMessage: "" })),
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot = {
|
||||
conversationId: "conv-1",
|
||||
runId: "run-1",
|
||||
agentId: "agent-1",
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "hello" }] }],
|
||||
} as never
|
||||
|
||||
const stateManager = { getGlobalSettingsKey: vi.fn(() => true) } as never
|
||||
|
||||
describe("hooks-adapter task id threading", () => {
|
||||
let runner: ReturnType<typeof makeRunner>
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.create.mockReset()
|
||||
runner = makeRunner()
|
||||
mocks.create.mockResolvedValue(runner)
|
||||
})
|
||||
|
||||
it("passes the task id and tool name when creating the PreToolUse runner", async () => {
|
||||
const hooks = buildAgentHooks(stateManager)
|
||||
await hooks.beforeTool?.({ toolCall: { toolName: "read_file" }, input: { path: "a.ts" }, snapshot } as never)
|
||||
|
||||
expect(mocks.create).toHaveBeenCalledWith("PreToolUse", "conv-1", "read_file")
|
||||
expect(runner.run).toHaveBeenCalledWith(expect.objectContaining({ taskId: "conv-1" }))
|
||||
})
|
||||
|
||||
it("passes the task id and tool name when creating the PostToolUse runner", async () => {
|
||||
const hooks = buildAgentHooks(stateManager)
|
||||
await hooks.afterTool?.({
|
||||
toolCall: { toolName: "write_file" },
|
||||
input: {},
|
||||
result: { output: "ok", isError: false },
|
||||
durationMs: 5,
|
||||
snapshot,
|
||||
} as never)
|
||||
|
||||
expect(mocks.create).toHaveBeenCalledWith("PostToolUse", "conv-1", "write_file")
|
||||
expect(runner.run).toHaveBeenCalledWith(expect.objectContaining({ taskId: "conv-1" }))
|
||||
})
|
||||
|
||||
it("passes the task id when creating the TaskStart and UserPromptSubmit runners", async () => {
|
||||
const hooks = buildAgentHooks(stateManager)
|
||||
await hooks.beforeRun?.({ snapshot } as never)
|
||||
|
||||
expect(mocks.create).toHaveBeenCalledWith("TaskStart", "conv-1")
|
||||
expect(mocks.create).toHaveBeenCalledWith("UserPromptSubmit", "conv-1")
|
||||
})
|
||||
|
||||
it("passes the task id when creating the TaskComplete runner", async () => {
|
||||
const hooks = buildAgentHooks(stateManager)
|
||||
await hooks.afterRun?.({ snapshot, result: { status: "completed", outputText: "done" } } as never)
|
||||
|
||||
expect(mocks.create).toHaveBeenCalledWith("TaskComplete", "conv-1")
|
||||
})
|
||||
|
||||
it("passes the task id when creating the TaskCancel runner", async () => {
|
||||
const hooks = buildAgentHooks(stateManager)
|
||||
await hooks.afterRun?.({ snapshot, result: { status: "aborted", outputText: "" } } as never)
|
||||
|
||||
expect(mocks.create).toHaveBeenCalledWith("TaskCancel", "conv-1")
|
||||
})
|
||||
|
||||
it("falls back to the run id when the snapshot has no conversation id", async () => {
|
||||
const hooks = buildAgentHooks(stateManager)
|
||||
await hooks.beforeTool?.({
|
||||
toolCall: { toolName: "read_file" },
|
||||
input: {},
|
||||
snapshot: { ...(snapshot as object), conversationId: undefined },
|
||||
} as never)
|
||||
|
||||
expect(mocks.create).toHaveBeenCalledWith("PreToolUse", "run-1", "read_file")
|
||||
})
|
||||
})
|
||||
@@ -123,19 +123,20 @@ export function buildAgentHooks(
|
||||
return undefined
|
||||
}
|
||||
|
||||
const taskId = taskIdFromSnapshot(ctx.snapshot)
|
||||
const toolName = ctx.toolCall.toolName
|
||||
const factory = createFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
const runner = await factory.create("PreToolUse", taskId, toolName)
|
||||
if (runner.isNoOp) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const toolName = ctx.toolCall.toolName
|
||||
const runningMsg = buildHookStatusMessage({ hookName: "PreToolUse", toolName, status: "running" })
|
||||
runningTs = runningMsg.ts
|
||||
emitHookMessage?.(runningMsg)
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: taskIdFromSnapshot(ctx.snapshot),
|
||||
taskId,
|
||||
preToolUse: {
|
||||
toolName,
|
||||
parameters: toStringRecord(ctx.input),
|
||||
@@ -182,19 +183,20 @@ export function buildAgentHooks(
|
||||
return undefined
|
||||
}
|
||||
|
||||
const taskId = taskIdFromSnapshot(ctx.snapshot)
|
||||
const toolName = ctx.toolCall.toolName
|
||||
const factory = createFactory()
|
||||
const runner = await factory.create("PostToolUse")
|
||||
const runner = await factory.create("PostToolUse", taskId, toolName)
|
||||
if (runner.isNoOp) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const toolName = ctx.toolCall.toolName
|
||||
const runningMsg = buildHookStatusMessage({ hookName: "PostToolUse", toolName, status: "running" })
|
||||
runningTs = runningMsg.ts
|
||||
emitHookMessage?.(runningMsg)
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: taskIdFromSnapshot(ctx.snapshot),
|
||||
taskId,
|
||||
postToolUse: {
|
||||
toolName,
|
||||
parameters: toStringRecord(ctx.input),
|
||||
@@ -253,13 +255,13 @@ export function buildAgentHooks(
|
||||
return
|
||||
}
|
||||
|
||||
const taskId = taskIdFromSnapshot(ctx.snapshot)
|
||||
const factory = createFactory()
|
||||
const runner = await factory.create(hookName)
|
||||
const runner = await factory.create(hookName, taskId)
|
||||
if (runner.isNoOp) {
|
||||
return
|
||||
}
|
||||
|
||||
const taskId = taskIdFromSnapshot(ctx.snapshot)
|
||||
const runningMsg = buildHookStatusMessage({ hookName, status: "running" })
|
||||
runningTs = runningMsg.ts
|
||||
emitHookMessage?.(runningMsg)
|
||||
@@ -313,8 +315,9 @@ async function runTaskStart(
|
||||
return undefined
|
||||
}
|
||||
|
||||
const taskId = taskIdFromSnapshot(ctx.snapshot)
|
||||
const factory = createFactory()
|
||||
const runner = await factory.create("TaskStart")
|
||||
const runner = await factory.create("TaskStart", taskId)
|
||||
if (runner.isNoOp) {
|
||||
return undefined
|
||||
}
|
||||
@@ -323,7 +326,6 @@ async function runTaskStart(
|
||||
runningTs = runningMsg.ts
|
||||
emitHookMessage?.(runningMsg)
|
||||
|
||||
const taskId = taskIdFromSnapshot(ctx.snapshot)
|
||||
const result = await runner.run({
|
||||
taskId,
|
||||
taskStart: {
|
||||
@@ -362,8 +364,9 @@ async function runUserPromptSubmit(
|
||||
return undefined
|
||||
}
|
||||
|
||||
const taskId = taskIdFromSnapshot(ctx.snapshot)
|
||||
const factory = createFactory()
|
||||
const runner = await factory.create("UserPromptSubmit")
|
||||
const runner = await factory.create("UserPromptSubmit", taskId)
|
||||
if (runner.isNoOp) {
|
||||
return undefined
|
||||
}
|
||||
@@ -373,7 +376,7 @@ async function runUserPromptSubmit(
|
||||
emitHookMessage?.(runningMsg)
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: taskIdFromSnapshot(ctx.snapshot),
|
||||
taskId,
|
||||
userPromptSubmit: {
|
||||
prompt: latestUserPrompt(ctx),
|
||||
attachments: [],
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
import type { CoreSessionEvent } from "@cline/core"
|
||||
import { PATCH_MARKERS, projectSessionMessagesForDisplay } from "@cline/core"
|
||||
import type { MessageWithMetadata as SdkMessage } from "@cline/llms"
|
||||
import { type AgentEvent, formatDisplayUserInput } from "@cline/shared"
|
||||
import { type AgentEvent, formatDisplayUserInput, type ProviderErrorClass } from "@cline/shared"
|
||||
import { COMMAND_OUTPUT_STRING } from "@shared/combineCommandSequences"
|
||||
import type {
|
||||
ClineApiReqInfo,
|
||||
@@ -45,10 +45,11 @@ import type {
|
||||
} from "@shared/ExtensionMessage"
|
||||
import { Logger } from "@shared/services/Logger"
|
||||
import * as path from "path"
|
||||
import { isClineManagedProvider } from "@/shared/utils/cline"
|
||||
import { arePathsEqual, getDesktopDir } from "@/utils/path"
|
||||
import { CLINE_FREE_PROMOTION_ENDED_ERROR_CODE, isClineFreePromotionEndedMessage } from "../services/error/ClineError"
|
||||
import { MessageIdMinter } from "./message-id-minter"
|
||||
import { describeMissingCredentialError } from "./provider-credential-error"
|
||||
import { describeCredentialRejectedError, describeMissingCredentialError } from "./provider-credential-error"
|
||||
import { extractPersistedHookContextChips, isSyntheticSdkUserMessage, isSyntheticUserPrompt } from "./sdk-user-message-mapping"
|
||||
import { isDeniedToolApprovalMistake, isKnownToolApprovalDenial } from "./tool-approval-denial"
|
||||
|
||||
@@ -1931,7 +1932,12 @@ function translateAgentEvent(event: AgentEvent, state: MessageTranslatorState):
|
||||
// `code: "insufficient_credits"`). We try to reshape it into the
|
||||
// ClineError-serialized format the webview expects so that ErrorRow
|
||||
// can render the correct UI (Buy Credits button, etc.).
|
||||
const errorPayload = reshapeErrorForWebview(event.error, state.activeProviderId(), state.activeModelId())
|
||||
const errorPayload = reshapeErrorForWebview(
|
||||
event.error,
|
||||
state.activeProviderId(),
|
||||
state.activeModelId(),
|
||||
event.errorClass,
|
||||
)
|
||||
|
||||
// Emit an api_req_started with streamingFailedMessage so the
|
||||
// RequestStartRow renders the error via ErrorRow. This replaces
|
||||
@@ -2637,6 +2643,7 @@ export function reshapeErrorForWebview(
|
||||
error: { message?: string; status?: number; code?: string },
|
||||
providerId?: string,
|
||||
modelId?: string,
|
||||
errorClass?: ProviderErrorClass,
|
||||
): string {
|
||||
// The ClineError-JSON branches below are cline-provider flows (balance,
|
||||
// spend limit), so "cline" stays their fallback id. The missing-credential
|
||||
@@ -2670,6 +2677,18 @@ export function reshapeErrorForWebview(
|
||||
return vertexGlobalRegionMessage
|
||||
}
|
||||
|
||||
// A BYOK provider rejected the configured credentials (llms classified the
|
||||
// HTTP 401/403 while the typed error was still available). Raw provider
|
||||
// bodies here are dead ends — e.g. Mistral's `{"detail":"Invalid API Key"}`
|
||||
// is identical for a wrong, empty, or wrong-scope key — so point the user
|
||||
// at the key configuration instead. Cline-account providers keep the JSON
|
||||
// path below (the webview renders their auth failures as a sign-in card),
|
||||
// and so does an *unknown* provider id: rewriting without knowing the
|
||||
// provider could suppress that sign-in card for a cline-account failure.
|
||||
if (errorClass === "auth" && providerId !== undefined && !isClineManagedProvider(providerId)) {
|
||||
return describeCredentialRejectedError(rawMessage, providerId)
|
||||
}
|
||||
|
||||
// Try to extract structured error info from the error message.
|
||||
// The SDK often wraps API error JSON in the Error.message field.
|
||||
let parsed: Record<string, unknown> | undefined
|
||||
|
||||
@@ -184,6 +184,37 @@ describe("createProviderConfigStore", () => {
|
||||
expect(store.read(providerId).baseUrl).toBeUndefined()
|
||||
})
|
||||
|
||||
// Pasted API keys can carry invisible clipboard artifacts (surrounding
|
||||
// whitespace, newlines, zero-width characters). The masked key field hides
|
||||
// them from the user and the provider rejects the key with a 401 that
|
||||
// looks identical to a genuinely wrong key, so the write boundary must
|
||||
// strip them before the value reaches either backing store.
|
||||
it("sanitizes pasted API keys before writing to both stores", async () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("mistral")
|
||||
|
||||
store.write(providerId, { apiKey: " \u200b\ufeffmistral-key\u200d \n" })
|
||||
|
||||
expect(mocks.getApiConfiguration().mistralApiKey).toBe("mistral-key")
|
||||
expect(mocks.getSavedProviderSettings("mistral")).toEqual({ provider: "mistral", apiKey: "mistral-key" })
|
||||
expect(store.read(providerId).apiKey).toBe("mistral-key")
|
||||
})
|
||||
|
||||
it("treats a whitespace-only API key as a clear", async () => {
|
||||
const { createProviderConfigStore } = await import("./store")
|
||||
mocks.setProviderSettings({ mistral: { provider: "mistral", apiKey: "existing-key" } })
|
||||
mocks.setApiConfiguration({ mistralApiKey: "existing-key" })
|
||||
const store = createProviderConfigStore()
|
||||
const providerId = parseProviderId("mistral")
|
||||
|
||||
store.write(providerId, { apiKey: " \n " })
|
||||
|
||||
expect(mocks.getApiConfiguration().mistralApiKey).toBeUndefined()
|
||||
expect(mocks.getSavedProviderSettings("mistral")).toEqual({ provider: "mistral" })
|
||||
expect(store.read(providerId).apiKey).toBeUndefined()
|
||||
})
|
||||
|
||||
// Changing the regional API line in the settings UI goes through
|
||||
// store.write. It must land in providers.json (the CLI and desktop app
|
||||
// bake the regional base URL from its stored apiLine) AND mirror to the
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user