mirror of
https://github.com/cline/cline.git
synced 2026-09-12 00:50:27 +08:00
Compare commits
72
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
c0c37a1587 | ||
|
|
ebdabe65ce | ||
|
|
40c3a4dbd8 | ||
|
|
6859d00e51 | ||
|
|
6ba9b9d7b4 | ||
|
|
4c8cd98351 | ||
|
|
ebee8ca912 | ||
|
|
c0d6301884 | ||
|
|
d71f097656 | ||
|
|
6539f4deea | ||
|
|
036fc75b1f | ||
|
|
6fc40127a6 | ||
|
|
110138b540 | ||
|
|
3497391c5a | ||
|
|
9154a54a0e | ||
|
|
7d004f8dc7 | ||
|
|
432e00eaa6 | ||
|
|
095385b985 | ||
|
|
491b30b806 | ||
|
|
8b046d04f9 | ||
|
|
8a6c6f8afe | ||
|
|
4f5f238407 | ||
|
|
8f69880ac4 | ||
|
|
a0c341e93c | ||
|
|
f91af30401 | ||
|
|
83b2588c9c | ||
|
|
6e09e81a79 | ||
|
|
dc43a57fd7 | ||
|
|
833cc891b5 | ||
|
|
a2fb1d15ca | ||
|
|
8e7a55498b | ||
|
|
0cfc901589 | ||
|
|
a5c3181b78 | ||
|
|
8cb60caf0c | ||
|
|
c03e452315 | ||
|
|
83ff8fd2f5 | ||
|
|
397a6a3341 | ||
|
|
c9b75155ea | ||
|
|
09ee902639 | ||
|
|
2b7b01328a | ||
|
|
be8b984d10 | ||
|
|
c870116d1d |
@@ -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.)
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ body:
|
||||
- VSCode Extension
|
||||
- JetBrains Plugin
|
||||
- CLI
|
||||
- Desktop App
|
||||
- Cloud Platform
|
||||
default: 0
|
||||
validations:
|
||||
required: true
|
||||
@@ -62,13 +64,15 @@ body:
|
||||
- type: textarea
|
||||
id: ide-diagnostics
|
||||
attributes:
|
||||
label: IDE / CLI Diagnostics
|
||||
label: Diagnostics
|
||||
description: |
|
||||
Paste the "About" diagnostics for your Cline surface. This captures the IDE build, runtime, and host details we need.
|
||||
Paste the diagnostics for your Cline surface. This captures the build, runtime, and host details we need.
|
||||
- VSCode Extension: open `Help → About` (Windows/Linux) or `Code → About Visual Studio Code` (macOS), then copy the info.
|
||||
- JetBrains Plugin: open `Help → About` (Windows/Linux) or `<IDE name> → About` (macOS), then click `Copy` to grab build, runtime, OS, memory, and cores.
|
||||
- CLI: there is no About dialog. Run `cline --version` and paste the output.
|
||||
placeholder: Paste the copied About info or `cline --version` output here.
|
||||
- Desktop App: paste the app version from the Settings view.
|
||||
- Cloud Platform: paste your browser name and version, plus the page URL where the issue occurred.
|
||||
placeholder: Paste the copied About info, `cline --version` output, or browser/app details here.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
name: desktop-test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- desktop-experimental
|
||||
paths:
|
||||
- "apps/examples/desktop-app/package.json"
|
||||
- "apps/examples/desktop-app/scripts/dmg-background.ts"
|
||||
- "apps/examples/desktop-app/scripts/dmg-background.test.ts"
|
||||
- "apps/examples/desktop-app/src-tauri/dmg/background.png"
|
||||
- "apps/examples/desktop-app/src-tauri/dmg/background@2x.png"
|
||||
- ".github/workflows/desktop-test.yml"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- desktop-experimental
|
||||
paths:
|
||||
- "apps/examples/desktop-app/package.json"
|
||||
- "apps/examples/desktop-app/scripts/dmg-background.ts"
|
||||
- "apps/examples/desktop-app/scripts/dmg-background.test.ts"
|
||||
- "apps/examples/desktop-app/src-tauri/dmg/background.png"
|
||||
- "apps/examples/desktop-app/src-tauri/dmg/background@2x.png"
|
||||
- ".github/workflows/desktop-test.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
dmg-background:
|
||||
name: Test DMG background tooling
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/examples/desktop-app
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
# The suite only uses Bun/Node built-ins and committed artwork, so it does
|
||||
# not need a workspace dependency install or macOS runner.
|
||||
- name: Test DMG background tooling
|
||||
run: bun run test:dmg-background
|
||||
@@ -88,6 +88,7 @@ apps/vscode/tsconfig.test.generated.json
|
||||
.next/dev/static
|
||||
**/src-tauri/target/debug/.fingerprint
|
||||
apps/examples/desktop-app/src-tauri/target
|
||||
apps/examples/desktop-app/src-tauri/dmg/background.gen.tiff
|
||||
apps/examples/desktop-app/webview/.next
|
||||
|
||||
# Next.js generated type shim (churns between dev and build)
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
# Changelog
|
||||
|
||||
## [4.1.16]
|
||||
|
||||
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Cost estimates are no longer shown for providers billed by a flat-rate subscription (ClinePass, ChatGPT via Codex, and Claude Code). The task header and model pricing rows rendered API-rate dollar figures that read as real charges on top of the subscription, including a flash of them on every chat-view mount while provider listings were loading.
|
||||
- Signing back in no longer moves your last-used provider off ClinePass on credential refresh.
|
||||
- Hooks now resolve their workspace from the VS Code window instead of shared global state in `~/.cline`. With a second window open on another project, a workspace's `.clinerules/hooks` scripts were never discovered, and hook cwd and the workspace paths passed to hook scripts resolved against whatever project some other or older Cline instance last recorded.
|
||||
- New files are now created with your platform's native line endings.
|
||||
- Fixed the codebase search tool crashing on files containing a single enormous line.
|
||||
- Credentials embedded in git remote URLs are now redacted from the workspace information sent to the model, which also now carries richer workspace metadata.
|
||||
- Installing an MCP server from the marketplace no longer misreads the catalog's `--` separator as part of the server command.
|
||||
- The hub's event log can no longer grow until it fills your disk.
|
||||
|
||||
### Changed
|
||||
|
||||
- The per-tool MCP auto-approve checkboxes are hidden. MCP auto-approval is governed solely by the global "Use MCP servers" toggle — the per-tool checkboxes were no-ops that implied granularity the approval path does not have.
|
||||
|
||||
## [4.1.15]
|
||||
|
||||
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Auto-approve every MCP tool call while the "Use MCP servers" toggle is on. The toggle only took effect on tools that had also been opted in individually, so turning it on appeared to do nothing; it now governs all MCP tools on its own.
|
||||
|
||||
## [4.1.14]
|
||||
|
||||
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
|
||||
|
||||
### Added
|
||||
|
||||
- Refresh the built-in model catalog. New entries include Claude Fable 5, Grok 4.6 on Vertex, several DeepSeek V4 Flash variants (including the vision preview), MiMo v2.5, Qwen3.8 27B, Gemma 4 26B, LongCat 2.0, Nemotron 3.5 Lightning, and Thinking Machines' Inkling models.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Restore task completion telemetry for interactive sessions. A share of interactive stops routed through a teardown path that never reported completion after 4.1.11 changed how session status is tracked; every session now reports it exactly once.
|
||||
|
||||
## [4.1.13]
|
||||
|
||||
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.60
|
||||
|
||||
- Fixed the background hub process ballooning in memory during long sessions — session status updates were broadcasting a full copy of the conversation transcript to every connected client, which on a large task could grow the process to tens of gigabytes. Upgrading retires the running hub so the fix takes effect on the next command
|
||||
- New files are now created with your platform's native line endings
|
||||
- Fixed the codebase search tool crashing on files that contain a single enormous line
|
||||
- Cost estimates are no longer shown for Claude Code. Its usage is typically covered by a Claude Pro/Max subscription, but its models reuse Anthropic API pricing, so Cline was showing charges you were not being billed
|
||||
- Credentials embedded in git remote URLs are now redacted from the workspace information sent to the model
|
||||
- Installing an MCP server no longer misreads a `--` separator in the install arguments as part of the server command
|
||||
- Refreshed the model catalog. Adds seven providers (Agnes AI, Aixy, IteraCompute, LLM Tech, NeoSmith, Pendra, and Standard Compute) and updates model lists and pricing across providers. The resolved default model changes for ClinePass (now GLM 5.3), Z.ai, Hugging Face, evroc, LLM Gateway, NanoGPT, and Weights & Biases, so if you use one of those without pinning a model you will get a different default
|
||||
|
||||
## 3.0.58
|
||||
|
||||
- The first-launch "Try ClinePass" dialog no longer advertises the $4.99 first-month promo, which is ending
|
||||
- The hub's event log is now capped at 64 MiB on disk. Events carrying full session snapshots could previously grow the log to tens of gigabytes on a long-running hub, since deleting rows never shrinks the file. Oldest events are dropped first and the space is returned, and pruning runs on volume as well as on a timer
|
||||
- Refreshed the model catalog. Adds two providers (AgentRouter and Opper) and updates model lists and pricing across providers. The resolved default model changes for Aki.io and NanoGPT, so if you use one of those without pinning a model you will get a different default
|
||||
|
||||
## 3.0.57
|
||||
|
||||
- Added `cline hub drain`, which stops a hub from accepting new mutating work while it finishes what it is already running, and `cline hub drain --off` to lift it
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.57",
|
||||
"version": "3.0.60",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -71,7 +71,6 @@ export function MigrationNoticeContent(
|
||||
latest open-weight coding models with enough quota for day-to-day
|
||||
work, at a much lower cost than paying API costs directly.
|
||||
</text>
|
||||
<text selectable>Try it now with a limited-time promo for $4.99.</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg={palette.act} selectable>
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildUserInputMessage } from "./prompt";
|
||||
import { basename, join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { buildUserInputMessage, resolveSystemPrompt } from "./prompt";
|
||||
|
||||
const workspaceDirectories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of workspaceDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("buildUserInputMessage", () => {
|
||||
it("extracts image mentions into userImages", async () => {
|
||||
@@ -43,3 +52,39 @@ describe("buildUserInputMessage", () => {
|
||||
expect(result.userFiles).toEqual([filePath]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSystemPrompt workspace metadata", () => {
|
||||
it("includes git remotes and the latest commit for Cline requests", async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "cline-prompt-"));
|
||||
workspaceDirectories.push(cwd);
|
||||
execFileSync("git", ["init"], { cwd });
|
||||
execFileSync("git", ["config", "user.email", "test@cline.bot"], { cwd });
|
||||
execFileSync("git", ["config", "user.name", "Cline Test"], { cwd });
|
||||
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 });
|
||||
const commit = execFileSync("git", ["rev-parse", "HEAD"], {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
|
||||
const prompt = await resolveSystemPrompt({ cwd, providerId: "cline" });
|
||||
|
||||
expect(prompt).toContain("origin: https://example.com/cline/repo.git");
|
||||
expect(prompt).toContain(commit);
|
||||
});
|
||||
|
||||
it("includes parseable metadata outside a project", async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "cline-prompt-"));
|
||||
workspaceDirectories.push(cwd);
|
||||
|
||||
const prompt = await resolveSystemPrompt({ cwd, providerId: "cline" });
|
||||
|
||||
expect(prompt).toContain("# Workspace Configuration");
|
||||
expect(prompt).toContain(JSON.stringify(cwd));
|
||||
expect(prompt).toContain(`"hint": "${basename(cwd)}"`);
|
||||
expect(prompt).not.toContain("associatedRemoteUrls");
|
||||
expect(prompt).not.toContain("latestGitCommitHash");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
type ProviderSettingsManager,
|
||||
saveLocalProviderSettings,
|
||||
} from "@cline/core";
|
||||
import { CLI_PROMO_CODE } from "../../../utils/cline-pass-errors";
|
||||
import {
|
||||
type DialogDismissKey,
|
||||
isAnyKeyDismiss,
|
||||
@@ -77,8 +76,5 @@ export function buildClinePassSubscriptionPageUrl(
|
||||
appBaseUrl || DEFAULT_APP_BASE_URL,
|
||||
);
|
||||
url.searchParams.set("personal", "true");
|
||||
if (CLI_PROMO_CODE) {
|
||||
url.searchParams.set("code", CLI_PROMO_CODE);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -18,20 +18,11 @@ import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
|
||||
export { getClineOrgIndividualInferenceSubscriptionMessage };
|
||||
|
||||
export const CLI_PROMO_CODE = "";
|
||||
|
||||
export function getCliSubscriptionUrl(): string {
|
||||
if (!CLI_PROMO_CODE) {
|
||||
return new URL(
|
||||
`/dashboard/subscription?personal=true`,
|
||||
getClineEnvironmentConfig().appBaseUrl,
|
||||
).toString();
|
||||
}
|
||||
|
||||
return `${new URL(
|
||||
`/promo?code=${CLI_PROMO_CODE}&personal=true`,
|
||||
return new URL(
|
||||
`/dashboard/subscription?personal=true`,
|
||||
getClineEnvironmentConfig().appBaseUrl,
|
||||
).toString()}`;
|
||||
).toString();
|
||||
}
|
||||
|
||||
export function getCliNotSubscribedMessage(): string {
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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,5 +1,70 @@
|
||||
# Cline Desktop Changelog
|
||||
|
||||
## 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
|
||||
- Refreshed the model catalog. Adds seven providers (Agnes AI, Aixy, IteraCompute, LLM Tech, NeoSmith, Pendra, and Standard Compute) and updates model lists and pricing across providers. The resolved default model changes for ClinePass (now GLM 5.3), Z.ai, Hugging Face, evroc, LLM Gateway, NanoGPT, and Weights & Biases, so if you use one of those without pinning a model you will get a different default
|
||||
|
||||
## 0.0.18
|
||||
|
||||
- The sidebar is time-sorted again by default, with collapsible Pinned / Scheduled / Tasks sections and a one-click toggle to switch to project grouping (the old dropdown is gone). Scheduled sessions are marked with a clock icon, and the list starts taller and grows to fill the sidebar instead of stranding rows over empty space
|
||||
- Session rows now show a trash button on hover for quick deletion, with the same confirmation the row's context menu uses
|
||||
- Customize is now your installed inventory only. Browsing moved to a dedicated Marketplace page — one list across plugins, MCP servers, and skills with type-filter and tag chips — and the two pages link to each other from their headers and from sidebar sub-tabs
|
||||
- Schedule cards are now click targets: clicking a card anywhere outside its controls opens its details, the redundant eye button is gone, and the edit / run / pause / delete buttons are large enough to hit
|
||||
- Schedule details are one scrollable view instead of Overview/Runs tabs, showing the meta grid, the configuration, and the most recent runs with a "Show all N runs" expander
|
||||
- "Run now" now hands you into the session it starts
|
||||
- Scheduled and automation runs no longer render their internal `[SYSTEM]` steering messages as if you had typed them — a finished scheduled session reads as prompt, work summary, answer
|
||||
- Fixed opening a scheduled session while it runs leaving it stuck on the thinking shimmer until you switched away and back
|
||||
- Fixed installing plugins and MCP servers from the Marketplace failing with `Executable not found in $PATH: "cline"` — installs now run in-process and no longer require a Cline CLI on your machine
|
||||
- Fixed quitting the app beach-balling for several seconds
|
||||
- Cost estimates are no longer shown for subscription-billed providers (ClinePass, ChatGPT via Codex, and Claude Code), where an API-rate dollar figure read as a real charge on top of your subscription
|
||||
- Fixed hover cards flashing closed and reopening when clicked
|
||||
- The macOS DMG install window now has custom Cline artwork and layout
|
||||
- Credentials embedded in git remote URLs are now redacted from the workspace information sent to the model
|
||||
|
||||
## 0.0.17
|
||||
|
||||
- Plugins, MCP, Skills, Rules, Hooks, and Tools are now one Customize hub with tabbed sections and live counts. Catalog-backed tabs show what you have installed followed by an inline Browse section, so installing something from the catalog immediately appears above — the separate Marketplace page is gone
|
||||
- Redesigned the Models page: providers are grouped into Connected, Popular, and All with their auth kind and configuration status instead of per-row toggles. OAuth providers now offer a browser sign-in rather than an API key field, with a collapsed manual-key escape hatch where supported, and explicit Connect / Disconnect / Sign out actions
|
||||
- Voice input moved to its own Settings → Voice page that only offers connected transcription-capable providers and preselects a default model. The composer's microphone button now appears only once a voice model is configured
|
||||
- Sidebar sessions are always grouped by project, with pinned sessions leading each group and scheduled sessions marked by an inline clock. The Favorite action is now called Pin
|
||||
- New, Schedule, and Customize each got their own labeled row below the logo. New starts a fresh task and puts your cursor straight in the composer
|
||||
- Session search moved into a dialog behind the search icon in the logo row, and it now searches your full history instead of only the sessions already loaded in the sidebar
|
||||
- Added suggested schedule templates to the Schedule page
|
||||
- Add Provider opens a dialog instead of swapping out the page
|
||||
- Desktop notifications are now a single section under General, so the Event/Notify/Sound matrix no longer reads as a peer of settings like Dark mode
|
||||
- The agent's todo tool and the Agenda panel have been removed; scheduled tasks are unaffected
|
||||
- Fixed the provider list being unscrollable while a provider detail panel was open
|
||||
- Fixed a failed settings save leaving the Models page claiming a provider configuration that was never written to disk
|
||||
- Fixed Uninstall buttons collapsing to a broken square next to Install
|
||||
- Fixed unreadable selected text inside input fields
|
||||
- New files are now created with your platform's native line endings
|
||||
- Fixed the codebase search tool crashing the app on files containing a single enormous line
|
||||
- The hub's event log can no longer grow until it fills your disk
|
||||
|
||||
## 0.0.16
|
||||
|
||||
- The agent can now be handed off between Hub instances without losing work: a Hub that is restarting refuses new work while it finishes what it is running, and the app replays anything it missed while disconnected instead of dropping it
|
||||
|
||||
@@ -17,6 +17,38 @@ From `apps/examples/desktop-app/`:
|
||||
- `bun run package:desktop` - package the current OS desktop app into `dist/desktop/`
|
||||
- `bun run typecheck` - TypeScript check
|
||||
|
||||
## Customizing the macOS Install Window
|
||||
|
||||
The drag-to-Applications window is configured by `bundle.macOS.dmg` in
|
||||
[`src-tauri/tauri.conf.json`](./src-tauri/tauri.conf.json). Its artwork comes
|
||||
from the PNG sources in [`src-tauri/dmg/`](./src-tauri/dmg/); the
|
||||
`background.gen.tiff` Finder actually renders is a gitignored build artifact
|
||||
regenerated from them on every build.
|
||||
|
||||
1. The current source artwork is `640x400`. Export `background.png` at 1x and
|
||||
`background@2x.png` at 2x.
|
||||
2. Currently the app icons are centered at `(140, 200)` and
|
||||
the Applications folder centered at `(500, 200)`. If updating artwork, update `appPosition`
|
||||
and `applicationFolderPosition` to reposition the app icons.
|
||||
3. Build with `bun run build:binary`. Before compiling, the build validates
|
||||
both PNG dimensions, combines them with `tiffutil` into the Retina-aware
|
||||
`src-tauri/dmg/background.gen.tiff`, and verifies the TIFF contains the
|
||||
expected 1x and 2x representations. Run `bun run dmg:background` to do just
|
||||
that step, e.g. to sanity-check new artwork without a full build. The DMG
|
||||
is written beneath `src-tauri/target/release/bundle/dmg/`.
|
||||
|
||||
Run `bun run test:dmg-background` for the cross-platform checks covering the
|
||||
committed PNG dimensions and TIFF validation logic.
|
||||
|
||||
The configured `640x432` Finder window is intentionally 32 points taller than
|
||||
the `640x400` background. That extra height matches the Finder chrome in the
|
||||
currently verified packaged layout; re-check it after material macOS or Finder
|
||||
changes. The project deliberately uses a multi-resolution TIFF even though
|
||||
Tauri's documented background formats are PNG, JPG, and GIF: Finder renders
|
||||
both the 1x and 2x representations from a single background file. Re-check the
|
||||
packaged DMG after upgrading Tauri in case its background validation changes.
|
||||
|
||||
|
||||
## Login Shell PATH Resolution
|
||||
|
||||
Apps launched from Finder/the Dock inherit launchd's minimal `PATH`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/code",
|
||||
"version": "0.0.16",
|
||||
"version": "0.0.20",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:ui": "bun -F @cline/ui build",
|
||||
@@ -15,6 +15,8 @@
|
||||
"build:sidecar": "mkdir -p dist/sidecar && bun build ./sidecar/index.ts --outfile ./dist/sidecar/index.js --target bun",
|
||||
"build:sidecar:bin": "bun run scripts/build-sidecar-bin.ts",
|
||||
"build:binary": "tauri build",
|
||||
"dmg:background": "bun run scripts/dmg-background.ts",
|
||||
"test:dmg-background": "bun test scripts/dmg-background.test.ts",
|
||||
"package": "bun run package:desktop",
|
||||
"package:desktop": "bun run scripts/package-desktop.ts",
|
||||
"package:desktop:mac": "bun run scripts/package-desktop.ts --platform mac",
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import path from "node:path";
|
||||
import {
|
||||
parseTiffInfo,
|
||||
readPngDimensions,
|
||||
validateTiffRepresentations,
|
||||
} from "./dmg-background";
|
||||
|
||||
const DMG_ROOT = path.resolve(import.meta.dir, "..", "src-tauri", "dmg");
|
||||
|
||||
const EXPECTED_REPRESENTATIONS = [
|
||||
{ width: 640, height: 400, dpiX: 72, dpiY: 72 },
|
||||
{ width: 1280, height: 800, dpiX: 144, dpiY: 144 },
|
||||
];
|
||||
|
||||
const TIFF_INFO = `Directory at 0x1
|
||||
Image Width: 640 Image Length: 400
|
||||
Resolution: 72, 72
|
||||
Resolution Unit: pixels/inch
|
||||
Directory at 0x2
|
||||
Image Width: 1280 Image Length: 800
|
||||
Resolution: 144, 144
|
||||
Resolution Unit: pixels/inch
|
||||
`;
|
||||
|
||||
describe("parseTiffInfo", () => {
|
||||
test("reads the dimensions and DPI of every TIFF representation", () => {
|
||||
expect(parseTiffInfo(TIFF_INFO)).toEqual(EXPECTED_REPRESENTATIONS);
|
||||
});
|
||||
|
||||
test("rejects representations without pixel-per-inch resolution", () => {
|
||||
expect(() =>
|
||||
parseTiffInfo(TIFF_INFO.replace("pixels/inch", "pixels/cm")),
|
||||
).toThrow(/could not parse TIFF representation/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMG source artwork", () => {
|
||||
test("has the expected 1x and 2x dimensions", async () => {
|
||||
const [dimensions1x, dimensions2x] = await Promise.all([
|
||||
readPngDimensions(path.join(DMG_ROOT, "background.png")),
|
||||
readPngDimensions(path.join(DMG_ROOT, "background@2x.png")),
|
||||
]);
|
||||
|
||||
expect(dimensions1x).toEqual({ width: 640, height: 400 });
|
||||
expect(dimensions2x).toEqual({ width: 1280, height: 800 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateTiffRepresentations", () => {
|
||||
test("accepts the expected representations", () => {
|
||||
expect(() =>
|
||||
validateTiffRepresentations(EXPECTED_REPRESENTATIONS),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("rejects the wrong number of representations", () => {
|
||||
expect(() =>
|
||||
validateTiffRepresentations(EXPECTED_REPRESENTATIONS.slice(0, 1)),
|
||||
).toThrow(/exactly two image representations/);
|
||||
});
|
||||
|
||||
test("rejects incorrect representation dimensions", () => {
|
||||
expect(() =>
|
||||
validateTiffRepresentations([
|
||||
EXPECTED_REPRESENTATIONS[0],
|
||||
{ ...EXPECTED_REPRESENTATIONS[1], width: 1279 },
|
||||
]),
|
||||
).toThrow(/must be 1280x800/);
|
||||
});
|
||||
|
||||
test("rejects incorrect representation DPI", () => {
|
||||
expect(() =>
|
||||
validateTiffRepresentations([
|
||||
{ ...EXPECTED_REPRESENTATIONS[0], dpiX: 73 },
|
||||
EXPECTED_REPRESENTATIONS[1],
|
||||
]),
|
||||
).toThrow(/must be 72x72 DPI/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { copyFile, mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { $ } from "bun";
|
||||
|
||||
type Dimensions = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type TiffRepresentation = Dimensions & {
|
||||
dpiX: number;
|
||||
dpiY: number;
|
||||
};
|
||||
|
||||
const APP_ROOT = path.resolve(import.meta.dir, "..");
|
||||
const DMG_ROOT = path.join(APP_ROOT, "src-tauri", "dmg");
|
||||
const BACKGROUND_1X = path.join(DMG_ROOT, "background.png");
|
||||
const BACKGROUND_2X = path.join(DMG_ROOT, "background@2x.png");
|
||||
// Gitignored build artifact; only the PNG sources are committed.
|
||||
const BACKGROUND_TIFF = path.join(DMG_ROOT, "background.gen.tiff");
|
||||
|
||||
const EXPECTED_1X = { width: 640, height: 400 };
|
||||
const EXPECTED_2X = { width: 1280, height: 800 };
|
||||
const EXPECTED_TIFF_REPRESENTATIONS: TiffRepresentation[] = [
|
||||
{ ...EXPECTED_1X, dpiX: 72, dpiY: 72 },
|
||||
{ ...EXPECTED_2X, dpiX: 144, dpiY: 144 },
|
||||
];
|
||||
|
||||
const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
|
||||
|
||||
// PNG stores its big-endian width and height in the fixed IHDR fields at
|
||||
// byte offsets 16 and 20, so dimensions can be checked without an image library.
|
||||
export const readPngDimensions = async (
|
||||
filePath: string,
|
||||
): Promise<Dimensions> => {
|
||||
const contents = await readFile(filePath);
|
||||
const hasPngSignature = PNG_SIGNATURE.every(
|
||||
(byte, index) => contents[index] === byte,
|
||||
);
|
||||
if (
|
||||
contents.length < 24 ||
|
||||
!hasPngSignature ||
|
||||
contents.toString("ascii", 12, 16) !== "IHDR"
|
||||
) {
|
||||
throw new Error(`${filePath} is not a valid PNG with an IHDR header`);
|
||||
}
|
||||
|
||||
return {
|
||||
width: contents.readUInt32BE(16),
|
||||
height: contents.readUInt32BE(20),
|
||||
};
|
||||
};
|
||||
|
||||
const assertDimensions = (
|
||||
label: string,
|
||||
actual: Dimensions,
|
||||
expected: Dimensions,
|
||||
): void => {
|
||||
if (actual.width !== expected.width || actual.height !== expected.height) {
|
||||
throw new Error(
|
||||
`${label} must be ${expected.width}x${expected.height}, got ${actual.width}x${actual.height}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// tiffutil prints one "Directory at ..." block for each image representation
|
||||
// embedded in the TIFF.
|
||||
export const parseTiffInfo = (output: string): TiffRepresentation[] =>
|
||||
output
|
||||
.split(/(?=Directory at )/)
|
||||
.filter((block) => block.startsWith("Directory at "))
|
||||
.map((block) => {
|
||||
const dimensions = block.match(
|
||||
/Image Width:\s*(\d+)\s+Image Length:\s*(\d+)/,
|
||||
);
|
||||
const resolution = block.match(/Resolution:\s*([\d.]+),\s*([\d.]+)/);
|
||||
if (
|
||||
!dimensions ||
|
||||
!resolution ||
|
||||
!block.includes("Resolution Unit: pixels/inch")
|
||||
) {
|
||||
throw new Error(`could not parse TIFF representation:\n${block}`);
|
||||
}
|
||||
|
||||
return {
|
||||
width: Number(dimensions[1]),
|
||||
height: Number(dimensions[2]),
|
||||
dpiX: Number(resolution[1]),
|
||||
dpiY: Number(resolution[2]),
|
||||
};
|
||||
});
|
||||
|
||||
export const validateTiffRepresentations = (
|
||||
representations: TiffRepresentation[],
|
||||
label = "TIFF",
|
||||
): void => {
|
||||
const sortedRepresentations = [...representations].sort(
|
||||
(left, right) => left.width - right.width,
|
||||
);
|
||||
|
||||
if (sortedRepresentations.length !== EXPECTED_TIFF_REPRESENTATIONS.length) {
|
||||
throw new Error(
|
||||
`${label} must contain exactly two image representations, got ${sortedRepresentations.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const [index, expected] of EXPECTED_TIFF_REPRESENTATIONS.entries()) {
|
||||
const actual = sortedRepresentations[index];
|
||||
assertDimensions(`${label} representation ${index + 1}`, actual, expected);
|
||||
if (actual.dpiX !== expected.dpiX || actual.dpiY !== expected.dpiY) {
|
||||
throw new Error(
|
||||
`${label} representation ${index + 1} must be ${expected.dpiX}x${expected.dpiY} DPI, got ${actual.dpiX}x${actual.dpiY} DPI`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const assertTiffRepresentations = async (filePath: string): Promise<void> => {
|
||||
const representations = parseTiffInfo(
|
||||
await $`tiffutil -info ${filePath}`.quiet().text(),
|
||||
);
|
||||
validateTiffRepresentations(representations, filePath);
|
||||
};
|
||||
|
||||
const assertSourceDimensions = async (): Promise<void> => {
|
||||
const [dimensions1x, dimensions2x] = await Promise.all([
|
||||
readPngDimensions(BACKGROUND_1X),
|
||||
readPngDimensions(BACKGROUND_2X),
|
||||
]);
|
||||
assertDimensions("background.png", dimensions1x, EXPECTED_1X);
|
||||
assertDimensions("background@2x.png", dimensions2x, EXPECTED_2X);
|
||||
};
|
||||
|
||||
const generateTiff = async (outputPath: string): Promise<void> => {
|
||||
// Finder's .DS_Store references one background file. A multi-representation
|
||||
// TIFF lets AppKit select the 1x or 2x bitmap without relying on it to discover
|
||||
// a separate @2x companion beside that referenced file.
|
||||
await $`tiffutil -cathidpicheck ${BACKGROUND_1X} ${BACKGROUND_2X} -out ${outputPath}`.quiet();
|
||||
await assertTiffRepresentations(outputPath);
|
||||
};
|
||||
|
||||
const main = async (): Promise<void> => {
|
||||
if (process.argv.length > 2) {
|
||||
throw new Error("usage: bun run dmg:background");
|
||||
}
|
||||
if (process.platform !== "darwin") {
|
||||
// Runs from beforeBuildCommand on every platform, but only macOS builds
|
||||
// bundle a DMG and only macOS ships tiffutil.
|
||||
console.log("Skipping DMG background generation on non-macOS host.");
|
||||
return;
|
||||
}
|
||||
|
||||
await assertSourceDimensions();
|
||||
// Generate and validate in scratch space so the configured build artifact is
|
||||
// replaced only after tiffutil has produced a complete, verified TIFF.
|
||||
const scratchRoot = await mkdtemp(
|
||||
path.join(tmpdir(), "cline-dmg-background-"),
|
||||
);
|
||||
const generatedTiff = path.join(scratchRoot, "background.tiff");
|
||||
try {
|
||||
await generateTiff(generatedTiff);
|
||||
await copyFile(generatedTiff, BACKGROUND_TIFF);
|
||||
console.log(`Generated ${path.relative(APP_ROOT, BACKGROUND_TIFF)}.`);
|
||||
} finally {
|
||||
await rm(scratchRoot, { force: true, recursive: true });
|
||||
}
|
||||
};
|
||||
|
||||
if (import.meta.main) {
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
@@ -1434,7 +1553,7 @@ export async function handleCommand(
|
||||
const result = await backend.updateSession({ sessionId, metadata: merged });
|
||||
if (!result.updated) throw new Error(`Session ${sessionId} not found`);
|
||||
// Annotating a session is not session activity. updateSession stamps
|
||||
// updated_at, which clients sort and label rows by, so a favorite would
|
||||
// updated_at, which clients sort and label rows by, so a pin would
|
||||
// otherwise make an old session look like it just ran.
|
||||
if (existing?.updatedAt) {
|
||||
store.run("UPDATE sessions SET updated_at = ? WHERE session_id = ?", [
|
||||
@@ -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,84 @@ 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("forwards raw hub tool updates to attached desktop sessions", async () => {
|
||||
const { createSidecarContext, handleHubLiveEvent } = await import(
|
||||
"./context"
|
||||
@@ -929,6 +1007,7 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
schedule: { scheduleId: "schedule-1", enabled: false },
|
||||
});
|
||||
expect(hubCommandMock).toHaveBeenCalledWith("schedule.disable", {
|
||||
allWorkspaces: true,
|
||||
scheduleId: "schedule-1",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { installPlugin } from "@cline/core";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getOfficialPluginInstallPath,
|
||||
@@ -9,6 +10,15 @@ import {
|
||||
} from "./marketplace";
|
||||
import type { JsonRecord } from "./types";
|
||||
|
||||
// Marketplace plugin installs run in-process through @cline/core (spawning a
|
||||
// `cline` binary fails with 'Executable not found in $PATH: "cline"' in the
|
||||
// packaged app). Stub only installPlugin; everything else stays real.
|
||||
vi.mock(import("@cline/core"), async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
installPlugin: vi.fn(),
|
||||
}));
|
||||
const installPluginMock = vi.mocked(installPlugin);
|
||||
|
||||
const GOAL_ENTRY = {
|
||||
id: "goal",
|
||||
type: "plugin",
|
||||
@@ -23,6 +33,13 @@ beforeEach(async () => {
|
||||
tempClineDir = await mkdtemp(join(tmpdir(), "desktop-marketplace-"));
|
||||
previousClineDir = process.env.CLINE_DIR;
|
||||
process.env.CLINE_DIR = tempClineDir;
|
||||
installPluginMock.mockReset().mockImplementation(async (options) => ({
|
||||
source: options.source,
|
||||
installPath: goalInstallDir(),
|
||||
entryPaths: [],
|
||||
mcpSyncFailures: [],
|
||||
mcpOAuthCandidates: [],
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -43,57 +60,49 @@ function goalInstallDir(): string {
|
||||
}
|
||||
|
||||
describe("official plugin install detection", () => {
|
||||
it("does not treat a leftover empty install directory as installed", async () => {
|
||||
// Regression: a failed or interrupted install can leave the directory
|
||||
// behind with nothing in it. The next install attempt then returned
|
||||
// "already installed" without running the CLI, so the UI flipped the
|
||||
// entry to Uninstall with no error while nothing actually worked.
|
||||
await mkdir(goalInstallDir(), { recursive: true });
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 1,
|
||||
stdout: "",
|
||||
stderr: "install exploded",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
installMarketplaceEntry({ entry: GOAL_ENTRY }, { spawnCommand }),
|
||||
).rejects.toThrow(/Plugin install failed/);
|
||||
expect(spawnCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes --force so a retry can reclaim the leftover directory", async () => {
|
||||
// Without --force the CLI refuses to replace the existing path
|
||||
// ("Plugin is already installed at ... Use --force to replace it."),
|
||||
// so every retry from the UI would fail against the stale directory.
|
||||
await mkdir(goalInstallDir(), { recursive: true });
|
||||
const spawnCommand = vi.fn(async (_command: string, _args: string[]) => ({
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
const result = await installMarketplaceEntry(
|
||||
{ entry: GOAL_ENTRY },
|
||||
{ spawnCommand },
|
||||
);
|
||||
it("installs plugins in-process through @cline/core", async () => {
|
||||
const result = await installMarketplaceEntry({ entry: GOAL_ENTRY });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "installed",
|
||||
message: "Installed Goal.",
|
||||
});
|
||||
expect(spawnCommand.mock.calls[0]?.[1]).toContain("--force");
|
||||
expect(installPluginMock).toHaveBeenCalledWith({
|
||||
source: "goal",
|
||||
force: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not pass --force for a clean first install", async () => {
|
||||
const spawnCommand = vi.fn(async (_command: string, _args: string[]) => ({
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}));
|
||||
it("does not treat a leftover empty install directory as installed", async () => {
|
||||
// Regression: a failed or interrupted install can leave the directory
|
||||
// behind with nothing in it. The next install attempt then returned
|
||||
// "already installed" without running the installer, so the UI flipped
|
||||
// the entry to Uninstall with no error while nothing actually worked.
|
||||
await mkdir(goalInstallDir(), { recursive: true });
|
||||
installPluginMock.mockRejectedValueOnce(new Error("install exploded"));
|
||||
|
||||
await installMarketplaceEntry({ entry: GOAL_ENTRY }, { spawnCommand });
|
||||
await expect(
|
||||
installMarketplaceEntry({ entry: GOAL_ENTRY }),
|
||||
).rejects.toThrow(/install exploded/);
|
||||
expect(installPluginMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(spawnCommand.mock.calls[0]?.[1]).not.toContain("--force");
|
||||
it("passes force so a retry can reclaim the leftover directory", async () => {
|
||||
// Without force the installer refuses to replace the existing path
|
||||
// ("Plugin is already installed at ... Use --force to replace it."),
|
||||
// so every retry from the UI would fail against the stale directory.
|
||||
await mkdir(goalInstallDir(), { recursive: true });
|
||||
|
||||
const result = await installMarketplaceEntry({ entry: GOAL_ENTRY });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "installed",
|
||||
message: "Installed Goal.",
|
||||
});
|
||||
expect(installPluginMock).toHaveBeenCalledWith({
|
||||
source: "goal",
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("still short-circuits when the directory contains a plugin module", async () => {
|
||||
@@ -111,22 +120,51 @@ describe("official plugin install detection", () => {
|
||||
join(installDir, "package", "index.ts"),
|
||||
"export default {};",
|
||||
);
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
const result = await installMarketplaceEntry(
|
||||
{ entry: GOAL_ENTRY },
|
||||
{ spawnCommand },
|
||||
);
|
||||
const result = await installMarketplaceEntry({ entry: GOAL_ENTRY });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "installed",
|
||||
message: "Goal is already installed.",
|
||||
});
|
||||
expect(spawnCommand).not.toHaveBeenCalled();
|
||||
expect(installPluginMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("registers MCP servers in-process, honoring the -- args separator", async () => {
|
||||
const settingsPath = join(tempClineDir, "cline_mcp_settings.json");
|
||||
const previousSettingsPath = process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
|
||||
try {
|
||||
const result = await installMarketplaceEntry({
|
||||
entry: {
|
||||
id: "aikido",
|
||||
type: "mcp",
|
||||
name: "Aikido",
|
||||
install: {
|
||||
args: ["aikido", "--", "npx", "-y", "@aikidosec/mcp@1.0.9"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "installed",
|
||||
message: "Installed Aikido.",
|
||||
});
|
||||
const settings = JSON.parse(await readFile(settingsPath, "utf8")) as {
|
||||
mcpServers: Record<string, { transport?: unknown }>;
|
||||
};
|
||||
expect(settings.mcpServers.aikido?.transport).toEqual({
|
||||
type: "stdio",
|
||||
command: "npx",
|
||||
args: ["-y", "@aikidosec/mcp@1.0.9"],
|
||||
});
|
||||
} finally {
|
||||
if (previousSettingsPath === undefined) {
|
||||
delete process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = previousSettingsPath;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("excludes partial install directories from the installed entries list", async () => {
|
||||
|
||||
@@ -18,8 +18,11 @@ import {
|
||||
resolve,
|
||||
} from "node:path";
|
||||
import {
|
||||
installPlugin as installCorePlugin,
|
||||
installMcpServer,
|
||||
type MarketplaceActionResult,
|
||||
type MarketplaceEntryInput,
|
||||
parseMcpInstallArgs,
|
||||
resolveSkillsConfigSearchPaths,
|
||||
resolveWorkflowsConfigSearchPaths,
|
||||
uninstallMarketplaceEntry as uninstallCoreMarketplaceEntry,
|
||||
@@ -457,18 +460,6 @@ export function buildMarketplaceMcpInput(args: string[]): JsonRecord {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveClineInvocation(): { command: string; argsPrefix: string[] } {
|
||||
const wrapperPath = process.env.CLINE_WRAPPER_PATH?.trim();
|
||||
if (wrapperPath) {
|
||||
return { command: wrapperPath, argsPrefix: [] };
|
||||
}
|
||||
const entry = process.argv[1]?.trim();
|
||||
if (entry && /(?:^|[/\\])apps[/\\]cli[/\\]src[/\\]index\.ts$/.test(entry)) {
|
||||
return { command: process.execPath, argsPrefix: [entry] };
|
||||
}
|
||||
return { command: "cline", argsPrefix: [] };
|
||||
}
|
||||
|
||||
function isInsidePath(childPath: string, parentPath: string): boolean {
|
||||
const relativePath = relative(resolve(parentPath), resolve(childPath));
|
||||
return (
|
||||
@@ -823,7 +814,6 @@ async function installSkill(
|
||||
|
||||
async function installPlugin(
|
||||
entry: MarketplaceInstallInput,
|
||||
spawnCommand: SpawnCommand,
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
const installArgs = entry.install.args ?? [];
|
||||
if (installArgs.length !== 1) {
|
||||
@@ -840,41 +830,36 @@ async function installPlugin(
|
||||
message: `${entry.name ?? entry.id} is already installed.`,
|
||||
};
|
||||
}
|
||||
const { command, argsPrefix } = resolveClineInvocation();
|
||||
const result = await spawnCommand(command, [
|
||||
...argsPrefix,
|
||||
"plugin",
|
||||
"install",
|
||||
installArgs[0] ?? "",
|
||||
// Reclaim a leftover directory from a failed or interrupted install:
|
||||
// without --force the CLI refuses to replace the existing path and
|
||||
// every retry from the UI would fail the same way. This is safe
|
||||
// because the state check just confirmed the directory contains no
|
||||
// loadable plugin module.
|
||||
...(installState === "partial" ? ["--force"] : []),
|
||||
"--json",
|
||||
]);
|
||||
if (result.exitCode !== 0) {
|
||||
const output = commandOutput(result);
|
||||
throw new Error(
|
||||
`Plugin install failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
|
||||
);
|
||||
}
|
||||
let details: JsonRecord | undefined;
|
||||
try {
|
||||
details = result.stdout.trim()
|
||||
? (JSON.parse(result.stdout.trim()) as JsonRecord)
|
||||
: undefined;
|
||||
} catch {
|
||||
details = undefined;
|
||||
}
|
||||
// Install in-process instead of shelling out to a `cline` binary: the
|
||||
// packaged desktop app cannot assume a CLI install exists on the user's
|
||||
// PATH (GUI apps inherit launchd's minimal PATH on macOS), which surfaced
|
||||
// as 'Executable not found in $PATH: "cline"' in the marketplace UI.
|
||||
//
|
||||
// force reclaims a leftover directory from a failed or interrupted
|
||||
// install: without it the installer refuses to replace the existing path
|
||||
// and every retry from the UI would fail the same way. This is safe
|
||||
// because the state check just confirmed the directory contains no
|
||||
// loadable plugin module.
|
||||
const result = await installCorePlugin({
|
||||
source: installArgs[0] ?? "",
|
||||
force: installState === "partial",
|
||||
});
|
||||
const warnings = result.mcpSyncFailures.map(
|
||||
(failure) =>
|
||||
`Failed to sync plugin MCP servers for ${failure.pluginName ?? failure.pluginPath}: ${failure.message}`,
|
||||
);
|
||||
return {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
status: "installed",
|
||||
message: `Installed ${entry.name ?? entry.id}.`,
|
||||
details,
|
||||
output: commandOutput(result),
|
||||
details: {
|
||||
source: result.source,
|
||||
installPath: result.installPath,
|
||||
entryPaths: result.entryPaths,
|
||||
mcpSyncFailures: result.mcpSyncFailures,
|
||||
} as JsonRecord,
|
||||
output: [`Path: ${result.installPath}`, ...warnings].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -885,45 +870,26 @@ export async function installMarketplaceEntry(
|
||||
const entry = readInstallInput(args);
|
||||
const spawnCommand = options.spawnCommand ?? defaultSpawnCommand;
|
||||
if (entry.type === "mcp") {
|
||||
// Validate marketplace args before handing them to the CLI-backed installer.
|
||||
buildMarketplaceMcpInput(entry.install.args ?? []);
|
||||
const { command, argsPrefix } = resolveClineInvocation();
|
||||
const result = await spawnCommand(command, [
|
||||
...argsPrefix,
|
||||
"mcp",
|
||||
"install",
|
||||
"--yes",
|
||||
"--json",
|
||||
...(entry.install.args ?? []),
|
||||
]);
|
||||
if (result.exitCode !== 0) {
|
||||
const output = commandOutput(result);
|
||||
throw new Error(
|
||||
`MCP install failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
|
||||
);
|
||||
}
|
||||
let details: JsonRecord | undefined;
|
||||
try {
|
||||
details = result.stdout.trim()
|
||||
? (JSON.parse(result.stdout.trim()) as JsonRecord)
|
||||
: undefined;
|
||||
} catch {
|
||||
details = undefined;
|
||||
}
|
||||
// Register the server in-process; this only writes MCP settings, so
|
||||
// there is no reason to depend on a `cline` binary being on PATH.
|
||||
const result = installMcpServer(
|
||||
parseMcpInstallArgs(entry.install.args ?? []),
|
||||
);
|
||||
return {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
status: "installed",
|
||||
message: `Installed ${entry.name ?? entry.id}.`,
|
||||
details,
|
||||
output: commandOutput(result),
|
||||
details: result as unknown as JsonRecord,
|
||||
output:
|
||||
result.warnings.length > 0 ? result.warnings.join("\n") : undefined,
|
||||
};
|
||||
}
|
||||
if (entry.type === "skill") {
|
||||
return installSkill(entry, spawnCommand);
|
||||
}
|
||||
if (entry.type === "plugin") {
|
||||
return installPlugin(entry, spawnCommand);
|
||||
return installPlugin(entry);
|
||||
}
|
||||
throw new Error(`Unsupported marketplace entry type: ${entry.type}`);
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
@@ -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() {
|
||||
@@ -275,35 +299,24 @@ impl DesktopBackendState {
|
||||
*guard = true;
|
||||
}
|
||||
|
||||
if let Ok(endpoint_guard) = self.ws_endpoint.lock() {
|
||||
if let Some(endpoint) = endpoint_guard.as_ref() {
|
||||
request_desktop_backend_shutdown(endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(mut process_guard) = self.process.lock() {
|
||||
if let Some(child) = process_guard.as_mut() {
|
||||
// The sidecar bounds its own graceful shutdown with
|
||||
// SHUTDOWN_TIMEOUT_MS (5s in sidecar/index.ts) and then exits
|
||||
// itself; wait past that window before escalating to kill so
|
||||
// an active session can finish persisting.
|
||||
for _ in 0..70 {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => break,
|
||||
Ok(None) => thread::sleep(Duration::from_millis(100)),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
// Quit runs this on the main thread (on macOS inside
|
||||
// applicationWillTerminate:, where blocking beach-balls the
|
||||
// app), so signal the sidecar and return without waiting.
|
||||
// SIGTERM triggers its own bounded graceful shutdown
|
||||
// (SHUTDOWN_TIMEOUT_MS in sidecar/index.ts), after which it
|
||||
// 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.wait();
|
||||
}
|
||||
}
|
||||
*process_guard = None;
|
||||
@@ -332,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() => {
|
||||
@@ -353,51 +382,6 @@ fn resolve_workspace_root(launch_cwd: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn request_desktop_backend_shutdown(endpoint: &str) {
|
||||
let trimmed = endpoint.trim();
|
||||
if trimmed.is_empty() {
|
||||
return;
|
||||
}
|
||||
let base = trimmed.strip_suffix('/').unwrap_or(trimmed);
|
||||
let url = format!("{base}/shutdown");
|
||||
let timeout_seconds = "2";
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = Command::new("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
&format!(
|
||||
"try {{ Invoke-WebRequest -UseBasicParsing -Method Post -Uri '{}' -TimeoutSec {} | Out-Null }} catch {{ }}",
|
||||
url.replace('\'', "''"),
|
||||
timeout_seconds
|
||||
),
|
||||
])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = Command::new("curl")
|
||||
.args([
|
||||
"-fsS",
|
||||
"--connect-timeout",
|
||||
timeout_seconds,
|
||||
"--max-time",
|
||||
timeout_seconds,
|
||||
"-X",
|
||||
"POST",
|
||||
&url,
|
||||
])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_desktop_backend_script_path(context: &AppContext) -> Option<PathBuf> {
|
||||
let launch_cwd = PathBuf::from(&context.launch_cwd);
|
||||
let candidates = [
|
||||
@@ -501,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}"))
|
||||
}
|
||||
@@ -624,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")
|
||||
@@ -648,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() {
|
||||
@@ -736,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,12 +1,12 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Cline",
|
||||
"version": "0.0.16",
|
||||
"version": "0.0.20",
|
||||
"identifier": "bot.cline.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web",
|
||||
"devUrl": "http://localhost:3125",
|
||||
"beforeBuildCommand": "bun run build",
|
||||
"beforeBuildCommand": "bun run dmg:background && bun run build",
|
||||
"frontendDist": "../webview/out"
|
||||
},
|
||||
"plugins": {
|
||||
@@ -48,7 +48,22 @@
|
||||
],
|
||||
"macOS": {
|
||||
"entitlements": "entitlements.plist",
|
||||
"hardenedRuntime": true
|
||||
"hardenedRuntime": true,
|
||||
"dmg": {
|
||||
"background": "dmg/background.gen.tiff",
|
||||
"windowSize": {
|
||||
"width": 640,
|
||||
"height": 432
|
||||
},
|
||||
"appPosition": {
|
||||
"x": 140,
|
||||
"y": 200
|
||||
},
|
||||
"applicationFolderPosition": {
|
||||
"x": 500,
|
||||
"y": 200
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,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 +37,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";
|
||||
@@ -65,6 +71,7 @@ import {
|
||||
markOnboardingCompleted,
|
||||
ONBOARDING_RESET_EVENT,
|
||||
} from "@/lib/onboarding";
|
||||
import { requestPromptInputFocus } from "@/lib/prompt-input-focus";
|
||||
import { isProviderConnected } from "@/lib/provider-connection";
|
||||
import {
|
||||
fetchProviderCatalog,
|
||||
@@ -164,6 +171,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.
|
||||
@@ -220,6 +230,7 @@ export default function Home() {
|
||||
|
||||
const handleNewThread = useCallback(() => {
|
||||
dispatchApp({ type: "new-thread", threadId: makeThreadId() });
|
||||
requestPromptInputFocus();
|
||||
}, []);
|
||||
|
||||
const completeOnboarding = useCallback(() => {
|
||||
@@ -289,6 +300,7 @@ export default function Home() {
|
||||
return;
|
||||
}
|
||||
navigateWith({ view: "chat" });
|
||||
requestPromptInputFocus();
|
||||
}, [activeThread, handleNewThread, navigateWith]);
|
||||
const handleViewChange = useCallback(
|
||||
(nextView: DesktopAppView) => {
|
||||
@@ -296,14 +308,21 @@ export default function Home() {
|
||||
},
|
||||
[navigateWith],
|
||||
);
|
||||
// The sidebar's New row reads as selected while the fresh, not-yet-started
|
||||
// task page is showing; once the task starts the session row takes over.
|
||||
const newTaskActive =
|
||||
view === "chat" &&
|
||||
activeThread !== undefined &&
|
||||
!activeThread.hasStarted &&
|
||||
!activeThread.historySession;
|
||||
const handleSettingsSectionChange = useCallback(
|
||||
(section: SettingsSection) => {
|
||||
navigateWith({ settingsSection: section, view: "settings" });
|
||||
},
|
||||
[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) {
|
||||
@@ -315,6 +334,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");
|
||||
@@ -410,104 +432,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}
|
||||
onHome={handleHome}
|
||||
onNavigateBack={handleNavigateBack}
|
||||
onNavigateForward={handleNavigateForward}
|
||||
onNewThread={handleNewThread}
|
||||
onOpenSessionById={handleOpenSessionById}
|
||||
onSettingsSectionChange={handleSettingsSectionChange}
|
||||
sessionHistory={sessionHistory}
|
||||
setView={handleViewChange}
|
||||
settingsSection={settingsSection}
|
||||
view={view}
|
||||
workspaceRoot={
|
||||
activeThread?.historySession?.workspaceRoot ||
|
||||
activeThread?.historySession?.cwd ||
|
||||
historyWorkspacePaths[0]
|
||||
}
|
||||
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("Models")
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1559,7 +1594,7 @@ function ChatThreadPane({
|
||||
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"
|
||||
: "relative grid h-full min-h-0 flex-1 grid-rows-[minmax(0,1fr)_auto] overflow-hidden"
|
||||
}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
@@ -1580,29 +1615,31 @@ function ChatThreadPane({
|
||||
</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}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
// Covers the shipped state of the Agenda feature: the sidebar has no Agenda
|
||||
// UI at all, and with AGENDA_UI_ENABLED false (the real flag value) the
|
||||
// welcome quick actions stay hidden and no agenda commands are issued. The
|
||||
// feature-flag mock in welcome-chat.test.tsx forces the flag on to keep
|
||||
// exercising the dormant welcome-screen UI.
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AgentSidebar } from "@/components/agent-sidebar";
|
||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { WelcomeScreen } from "@/components/views/chat/welcome-chat";
|
||||
import { WorkspaceProvider } from "@/contexts/workspace-context";
|
||||
import type { UseSessionHistoryResult } from "@/hooks/use-session-history";
|
||||
|
||||
const desktopMocks = vi.hoisted(() => ({
|
||||
invoke: vi.fn(),
|
||||
listAgendaTasks: vi.fn(),
|
||||
getAgendaAutomationPolicy: vi.fn(),
|
||||
subscribe: vi.fn(() => () => undefined),
|
||||
subscribeTransportState: vi.fn(() => () => undefined),
|
||||
}));
|
||||
vi.mock("@/lib/desktop-client", () => ({ desktopClient: desktopMocks }));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
desktopMocks.invoke.mockRejectedValue(new Error("not available in test"));
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => ({
|
||||
matches: false,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
})),
|
||||
});
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function makeSessionHistory(): UseSessionHistoryResult {
|
||||
return {
|
||||
deleteThread: vi.fn(),
|
||||
forkThread: vi.fn(),
|
||||
hasLoadedHistory: true,
|
||||
isLoadingMore: false,
|
||||
loadAllSessions: vi.fn(async () => true),
|
||||
loadOlderSessions: vi.fn(),
|
||||
loadMoreSessions: vi.fn(),
|
||||
mayHaveMoreSessions: false,
|
||||
openThread: vi.fn(),
|
||||
pendingAction: null,
|
||||
renameThread: vi.fn(),
|
||||
threads: [],
|
||||
unreadSessionIds: new Set<string>(),
|
||||
} as unknown as UseSessionHistoryResult;
|
||||
}
|
||||
|
||||
describe("Agenda UI hidden by default", () => {
|
||||
it("renders the sidebar without any Agenda UI and issues no agenda commands", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
onHome={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory()}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
/>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.querySelector('[aria-label="Show Agenda"]')).toBeNull();
|
||||
expect(container.querySelector('[aria-label="Agenda"]')).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[aria-label="Search sessions"]'),
|
||||
).not.toBeNull();
|
||||
expect(desktopMocks.listAgendaTasks).not.toHaveBeenCalled();
|
||||
expect(desktopMocks.getAgendaAutomationPolicy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders the welcome screen without agenda quick actions or agenda fetches", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WorkspaceProvider
|
||||
value={{
|
||||
workspaceRoot: "/projects/project-1",
|
||||
workspaces: ["/projects/project-1"],
|
||||
listWorkspaces: vi.fn(async () => ["/projects/project-1"]),
|
||||
refreshWorkspaces: vi.fn(async () => undefined),
|
||||
switchWorkspace: vi.fn(async () => true),
|
||||
pickWorkspaceDirectory: vi.fn(async () => null),
|
||||
selectChat: vi.fn(async () => true),
|
||||
}}
|
||||
>
|
||||
<WelcomeScreen
|
||||
active
|
||||
body={null}
|
||||
composer={null}
|
||||
gitBranch="main"
|
||||
onListGitBranches={vi.fn(async () => ({
|
||||
current: "main",
|
||||
branches: ["main"],
|
||||
}))}
|
||||
onSwitchGitBranch={vi.fn(async () => true)}
|
||||
/>
|
||||
</WorkspaceProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.querySelector("[data-welcome-hero]")).not.toBeNull();
|
||||
expect(desktopMocks.listAgendaTasks).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -24,13 +24,15 @@ const buttonVariants = cva(
|
||||
sidebarItem:
|
||||
"!h-auto w-full justify-start text-left gap-2 rounded-md !px-2 py-2 !text-sm font-medium text-muted-foreground hover:bg-surface-hover hover:text-sidebar-foreground",
|
||||
sidebarText:
|
||||
"!h-auto justify-start gap-1 px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-sidebar-foreground",
|
||||
"!h-auto justify-start gap-1 px-3 py-1.5 !text-sm font-normal text-muted-foreground hover:text-sidebar-foreground",
|
||||
text: "bg-transparent text-sm font-medium text-muted-foreground hover:text-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:pl-2 has-[>svg]:pr-2.5 text-base",
|
||||
sm: "h-8 gap-1.5 px-2.5 py-1.5 has-[>svg]:pl-2.5 has-[>svg]:pr-3 text-sm",
|
||||
xs: "h-7 gap-1.5 px-2.5 py-1.5 has-[>svg]:size-3 text-xs",
|
||||
// (has-[>svg]:size-3 was a leftover from when xs was a 12px micro
|
||||
// button; it collapsed any xs button containing an icon.)
|
||||
xs: "h-7 gap-1.5 px-2.5 py-1.5 has-[>svg]:px-2 text-xs",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4 text-lg",
|
||||
icon: "size-5",
|
||||
"icon-sm": "size-3 p-1",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"file:text-foreground placeholder:text-muted-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
|
||||
@@ -33,7 +33,9 @@ const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||
const SIDEBAR_WIDTH_ICON = "3rem";
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||
const SIDEBAR_WIDTH_COOKIE_NAME = "sidebar_width";
|
||||
const SIDEBAR_MIN_WIDTH = 224;
|
||||
// Floor keeps session rows legible: below this the thread titles are fully
|
||||
// truncated away and row metadata (timestamps, pins) starts clipping.
|
||||
const SIDEBAR_MIN_WIDTH = 260;
|
||||
const SIDEBAR_MAX_WIDTH = 560;
|
||||
|
||||
function clampSidebarWidth(value: number): number {
|
||||
|
||||
@@ -903,14 +903,11 @@ describe("ChatInputBar", () => {
|
||||
expect(promptInput?.parentElement?.className).toContain("items-start");
|
||||
expect(promptInput?.parentElement?.contains(sendTrigger)).toBe(true);
|
||||
expect(promptInput?.parentElement?.contains(stopTrigger)).toBe(true);
|
||||
expect(promptInput?.parentElement?.contains(speechTrigger)).toBe(true);
|
||||
// The mic button is hidden for now (SHOW_VOICE_INPUT_BUTTON).
|
||||
expect(speechTrigger).toBeNull();
|
||||
expect(sendTrigger?.parentElement?.className).toContain("self-end");
|
||||
expect(rightControls?.contains(sendTrigger)).toBe(false);
|
||||
expect(rightControls?.contains(speechTrigger ?? null)).toBe(false);
|
||||
expect(speechTrigger?.parentElement?.nextElementSibling).toBe(sendTrigger);
|
||||
expect(leftControls?.parentElement).toBe(rightControls?.parentElement);
|
||||
await act(async () => speechTrigger?.click());
|
||||
expect(onOpenVoiceInputSettings).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("selects High from the supported model thinking menu", async () => {
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
readModelSelectionStorageFromWindow,
|
||||
writeModelSelectionStorageToWindow,
|
||||
} from "@/lib/model-selection";
|
||||
import { subscribeToPromptInputFocus } from "@/lib/prompt-input-focus";
|
||||
import { normalizeProviderId } from "@/lib/provider-id";
|
||||
import {
|
||||
loadProviderModelCatalog,
|
||||
@@ -50,6 +51,7 @@ import {
|
||||
} from "@/lib/provider-model-catalog";
|
||||
import type { ProviderModel } from "@/lib/provider-schema";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { startVercelStreamingTranscription } from "@/lib/vercel-streaming-transcription";
|
||||
import { MAX_RECORDED_AUDIO_BYTES } from "@/lib/voice-input-limits";
|
||||
import { WorkspaceSelector as WorkspaceSelectorImpl } from "./workspace-selector";
|
||||
@@ -344,7 +346,6 @@ function ChatInputBarImpl({
|
||||
onSteerPromptInQueue,
|
||||
onEditPromptInQueue,
|
||||
onRemovePromptInQueue,
|
||||
onOpenVoiceInputSettings,
|
||||
summary,
|
||||
}: ChatInputBarProps) {
|
||||
const {
|
||||
@@ -362,6 +363,15 @@ function ChatInputBarImpl({
|
||||
const latestDraftVersionRef = useRef(promptDraft.version);
|
||||
latestDraftVersionRef.current = promptDraft.version;
|
||||
const promptInputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
// The sidebar's "New" action asks the prompt input to grab focus through a
|
||||
// window event (see lib/prompt-input-focus.ts).
|
||||
useEffect(
|
||||
() =>
|
||||
subscribeToPromptInputFocus(() => {
|
||||
promptInputRef.current?.focus();
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const batchTranscriptSessionRef = useRef<{
|
||||
start: number;
|
||||
end: number;
|
||||
@@ -1293,44 +1303,34 @@ function ChatInputBarImpl({
|
||||
<CircleStop className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
<SpeechInput
|
||||
allowUnavailableClick={!transcriptionTarget}
|
||||
key={
|
||||
transcriptionTarget
|
||||
? `${transcriptionTarget.providerId}:${transcriptionTarget.modelId}:${transcriptionTarget.supportsStreaming ? "streaming" : "auto"}`
|
||||
: "unconfigured"
|
||||
}
|
||||
onActiveChange={handleSpeechInputActiveChange}
|
||||
onAudioRecorded={handleAudioRecorded}
|
||||
onClick={(event) => {
|
||||
if (!transcriptionTarget) {
|
||||
event.preventDefault();
|
||||
onOpenVoiceInputSettings?.();
|
||||
{/* The mic button only appears once a voice model is
|
||||
configured in Settings → Voice; unconfigured users
|
||||
don't get a dead control. */}
|
||||
{transcriptionTarget ? (
|
||||
<SpeechInput
|
||||
key={`${transcriptionTarget.providerId}:${transcriptionTarget.modelId}:${transcriptionTarget.supportsStreaming ? "streaming" : "auto"}`}
|
||||
onActiveChange={handleSpeechInputActiveChange}
|
||||
onAudioRecorded={handleAudioRecorded}
|
||||
onError={handleSpeechInputError}
|
||||
onProcessingChange={setSpeechInputProcessing}
|
||||
onStartStreaming={
|
||||
transcriptionTarget.supportsStreaming
|
||||
? handleStartStreamingTranscription
|
||||
: undefined
|
||||
}
|
||||
}}
|
||||
onError={handleSpeechInputError}
|
||||
onProcessingChange={setSpeechInputProcessing}
|
||||
onStartStreaming={
|
||||
transcriptionTarget?.supportsStreaming
|
||||
? handleStartStreamingTranscription
|
||||
: undefined
|
||||
}
|
||||
onStreamingEnd={handleStreamingTranscriptionEnd}
|
||||
onStreamingStart={handleStreamingTranscriptionStart}
|
||||
onTranscriptionChange={
|
||||
transcriptionTarget?.supportsStreaming
|
||||
? undefined
|
||||
: handleTranscriptionChange
|
||||
}
|
||||
recordingMode={
|
||||
transcriptionTarget?.supportsStreaming ? "streaming" : "auto"
|
||||
}
|
||||
title={
|
||||
transcriptionTarget
|
||||
? `${transcriptionTarget.supportsStreaming ? "Transcribe live" : "Transcribe"} with ${transcriptionTarget.providerName} / ${transcriptionTarget.modelName}`
|
||||
: "Configure voice input in Settings → Models"
|
||||
}
|
||||
/>
|
||||
onStreamingEnd={handleStreamingTranscriptionEnd}
|
||||
onStreamingStart={handleStreamingTranscriptionStart}
|
||||
onTranscriptionChange={
|
||||
transcriptionTarget.supportsStreaming
|
||||
? undefined
|
||||
: handleTranscriptionChange
|
||||
}
|
||||
recordingMode={
|
||||
transcriptionTarget.supportsStreaming ? "streaming" : "auto"
|
||||
}
|
||||
title={`${transcriptionTarget.supportsStreaming ? "Transcribe live" : "Transcribe"} with ${transcriptionTarget.providerName} / ${transcriptionTarget.modelName}`}
|
||||
/>
|
||||
) : null}
|
||||
{(!isBusy || canSend) && (
|
||||
<button
|
||||
aria-label="Send message"
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
@@ -823,6 +963,107 @@ describe("ChatMessages tool disclosures", () => {
|
||||
expect(writeText).toHaveBeenCalledWith("Original prompt");
|
||||
});
|
||||
|
||||
it("hides runtime steering notes from the transcript", async () => {
|
||||
await renderMessages([
|
||||
{
|
||||
id: "user-prompt",
|
||||
sessionId: "session-1",
|
||||
role: "user",
|
||||
content: "tell me the current time",
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: "steer-1",
|
||||
sessionId: "session-1",
|
||||
role: "user",
|
||||
content:
|
||||
"[SYSTEM] This run is not complete until you call one of these terminal completion tools: submit_and_exit.",
|
||||
createdAt: 2,
|
||||
meta: { userRunSpan: 0 },
|
||||
},
|
||||
]);
|
||||
|
||||
// Steering nudges are machinery talking to the model — not rendered
|
||||
// at all, and never as a user bubble.
|
||||
expect(container.textContent).toContain("tell me the current time");
|
||||
expect(container.textContent).not.toContain("[SYSTEM]");
|
||||
expect(container.textContent).not.toContain(
|
||||
"This run is not complete until you call",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a genuine user prompt that happens to start with [SYSTEM]", async () => {
|
||||
await renderMessages([
|
||||
{
|
||||
id: "user-prompt",
|
||||
sessionId: "session-1",
|
||||
role: "user",
|
||||
content: "[SYSTEM] is a prefix I typed myself, explain it",
|
||||
createdAt: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
// Only injected reminders (userRunSpan 0) are steering; a person's
|
||||
// own prompt stays visible.
|
||||
expect(container.textContent).toContain(
|
||||
"is a prefix I typed myself, explain it",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps steering notes hidden inside the expanded work block", async () => {
|
||||
await renderMessages([
|
||||
{
|
||||
id: "user-prompt",
|
||||
sessionId: "session-1",
|
||||
role: "user",
|
||||
content: "tell me the current time",
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: "steer-1",
|
||||
sessionId: "session-1",
|
||||
role: "user",
|
||||
content: "[SYSTEM] This run is not complete until you finish.",
|
||||
createdAt: 2,
|
||||
meta: { userRunSpan: 0 },
|
||||
},
|
||||
{
|
||||
id: "tool-1",
|
||||
sessionId: "session-1",
|
||||
role: "tool",
|
||||
content: JSON.stringify({
|
||||
toolName: "run_commands",
|
||||
input: {},
|
||||
result: {},
|
||||
}),
|
||||
createdAt: 3,
|
||||
},
|
||||
{
|
||||
id: "answer",
|
||||
sessionId: "session-1",
|
||||
role: "assistant",
|
||||
content: "It is 12:28 PM PT.",
|
||||
createdAt: 4,
|
||||
},
|
||||
]);
|
||||
|
||||
// The steering note is working-rows machinery grouped with the run,
|
||||
// and stays hidden even when the work block is expanded.
|
||||
expect(container.textContent).toContain("It is 12:28 PM PT.");
|
||||
expect(container.textContent).not.toContain(
|
||||
"This run is not complete until you finish.",
|
||||
);
|
||||
|
||||
const trigger = [
|
||||
...container.querySelectorAll<HTMLButtonElement>("button"),
|
||||
].find((button) => button.textContent?.includes("Worked"));
|
||||
expect(trigger).toBeDefined();
|
||||
await act(async () => trigger?.click());
|
||||
expect(container.textContent).not.toContain(
|
||||
"This run is not complete until you finish.",
|
||||
);
|
||||
});
|
||||
|
||||
it("counts folded system-displayed runs before an editable user message", async () => {
|
||||
const onEditMessage = vi.fn(async () => undefined);
|
||||
await renderMessages(
|
||||
@@ -1641,17 +1882,18 @@ describe("ChatMessages work collapse", () => {
|
||||
expect(container.querySelectorAll(".cline-chat-tool")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each(["cancelled", "failed", "error"] as const)(
|
||||
"keeps an interrupted run's rows visible even with partial trailing text (%s)",
|
||||
async (status) => {
|
||||
// Stop can land mid-answer, leaving partial assistant text after the
|
||||
// tool calls; the run still must not fold into a summary.
|
||||
await renderMessages(completedRun, { status });
|
||||
it.each([
|
||||
"cancelled",
|
||||
"failed",
|
||||
"error",
|
||||
] as const)("keeps an interrupted run's rows visible even with partial trailing text (%s)", async (status) => {
|
||||
// Stop can land mid-answer, leaving partial assistant text after the
|
||||
// tool calls; the run still must not fold into a summary.
|
||||
await renderMessages(completedRun, { status });
|
||||
|
||||
expect(container.querySelector(".cline-chat-work")).toBeNull();
|
||||
expect(container.querySelectorAll(".cline-chat-tool")).toHaveLength(2);
|
||||
},
|
||||
);
|
||||
expect(container.querySelector(".cline-chat-work")).toBeNull();
|
||||
expect(container.querySelectorAll(".cline-chat-tool")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatMessages thinking indicator", () => {
|
||||
|
||||
@@ -545,6 +545,7 @@ function ChatMessagesImpl({
|
||||
<ToolMessageBlock
|
||||
key={`tools_${child.messages[0]?.id ?? "empty"}`}
|
||||
messages={child.messages}
|
||||
onExpandImage={handleExpandImage}
|
||||
onProceedWhileRunning={onProceedWhileRunning}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -29,6 +29,24 @@ export type ChatRenderItem =
|
||||
items: ChatRenderItem[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Runtime steering notes injected into the conversation as user-role
|
||||
* messages (completion-tool reminders, team-obligation nudges). They are
|
||||
* machinery talking to the model, not the person talking, so the transcript
|
||||
* renders them as subtle system rows and folds them into the run's working
|
||||
* span instead of showing user bubbles.
|
||||
*/
|
||||
export function isSystemSteeringMessage(message: ChatMessage): boolean {
|
||||
return (
|
||||
message.role === "user" &&
|
||||
// Injected reminders carry userRunSpan 0 (they are not user turns);
|
||||
// requiring it keeps a person's genuine prompt that happens to start
|
||||
// with "[SYSTEM]" visible and turn-counted.
|
||||
message.meta?.userRunSpan === 0 &&
|
||||
message.content.trimStart().startsWith("[SYSTEM]")
|
||||
);
|
||||
}
|
||||
|
||||
export function hasMessageReasoning(message: ChatMessage): boolean {
|
||||
return Boolean(message.reasoning?.trim() || message.reasoningRedacted);
|
||||
}
|
||||
@@ -66,7 +84,8 @@ export function buildUserRunCountMap(
|
||||
|
||||
for (const message of messages) {
|
||||
const userRunSpan =
|
||||
message.meta?.userRunSpan ?? (message.role === "user" ? 1 : 0);
|
||||
message.meta?.userRunSpan ??
|
||||
(message.role === "user" && !isSystemSteeringMessage(message) ? 1 : 0);
|
||||
const storedRunCount =
|
||||
message.meta?.runCount ?? message.meta?.checkpoint?.runCount;
|
||||
if (storedRunCount !== undefined) {
|
||||
@@ -119,8 +138,9 @@ export type CollapseWorkOptions = {
|
||||
*/
|
||||
function isCollapsibleWorkItem(item: ChatRenderItem): boolean {
|
||||
if (item.type === "tools") return true;
|
||||
if (item.type !== "message") return false;
|
||||
if (isSystemSteeringMessage(item.message)) return true;
|
||||
return (
|
||||
item.type === "message" &&
|
||||
item.message.role === "assistant" &&
|
||||
!item.message.images?.length &&
|
||||
!item.message.media?.length
|
||||
@@ -177,7 +197,11 @@ export function collapseCompletedWork(
|
||||
let lastUserIndex = -1;
|
||||
for (let index = items.length - 1; index >= 0; index--) {
|
||||
const item = items[index];
|
||||
if (item.type === "message" && item.message.role === "user") {
|
||||
if (
|
||||
item.type === "message" &&
|
||||
item.message.role === "user" &&
|
||||
!isSystemSteeringMessage(item.message)
|
||||
) {
|
||||
lastUserIndex = index;
|
||||
break;
|
||||
}
|
||||
@@ -195,7 +219,9 @@ export function collapseCompletedWork(
|
||||
// message is the run's answer and stays visible below the summary.
|
||||
const last = span.at(-1);
|
||||
const answer =
|
||||
last?.type === "message" && last.message.content.trim()
|
||||
last?.type === "message" &&
|
||||
last.message.role === "assistant" &&
|
||||
last.message.content.trim()
|
||||
? last
|
||||
: undefined;
|
||||
// A span is settled once a later user message exists. The trailing span
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+12
-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,
|
||||
@@ -27,72 +25,10 @@ import type {
|
||||
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,
|
||||
@@ -104,7 +40,7 @@ function MessageImages({
|
||||
}) {
|
||||
if (!isUser) {
|
||||
return (
|
||||
<AssistantImageCarousel images={images} onExpandImage={onExpandImage} />
|
||||
<MessageImageCarousel images={images} onExpandImage={onExpandImage} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -217,6 +153,14 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
const isUser = message.role === "user";
|
||||
const isError = message.role === "error";
|
||||
const checkpoint = message.meta?.checkpoint;
|
||||
// Runtime steering notes (completion nudges in scheduled/automation runs,
|
||||
// team-obligation reminders) are user-role messages the machinery sends to
|
||||
// the model, not something the person said or needs to read — hide them
|
||||
// from the transcript entirely. Grouping still treats them as working-row
|
||||
// machinery (never a turn boundary, an answer, or a run-count increment).
|
||||
if (isSystemSteeringMessage(message)) {
|
||||
return null;
|
||||
}
|
||||
const displayContent = formatChatMessageContent(
|
||||
message.role,
|
||||
message.content,
|
||||
|
||||
+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;
|
||||
|
||||
@@ -21,6 +21,10 @@ vi.mock("@/lib/desktop-client", () => ({
|
||||
subscribeTransportState: vi.fn(() => () => undefined),
|
||||
},
|
||||
}));
|
||||
// The Agenda UI ships hidden for now; these tests force the flag on so they
|
||||
// keep guarding the dormant feature. agenda-ui-hidden.test.tsx covers the
|
||||
// shipped (hidden) state.
|
||||
vi.mock("@/lib/feature-flags", () => ({ AGENDA_UI_ENABLED: true }));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
"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";
|
||||
import { useWorkspace } from "@/contexts/workspace-context";
|
||||
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({
|
||||
@@ -49,7 +53,7 @@ export function WelcomeScreen({
|
||||
statuses: ["pending_approval", "approved", "in_progress", "failed"],
|
||||
limit: 8,
|
||||
},
|
||||
active && workspaceRoot.trim().length > 0,
|
||||
AGENDA_UI_ENABLED && active && workspaceRoot.trim().length > 0,
|
||||
);
|
||||
const [runningTaskId, setRunningTaskId] = useState<string | null>(null);
|
||||
const [reviewTask, setReviewTask] = useState<AgendaTaskRecord | null>(null);
|
||||
@@ -130,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
|
||||
@@ -168,7 +172,7 @@ export function WelcomeScreen({
|
||||
{active ? composer : <SessionContent>{composer}</SessionContent>}
|
||||
</div>
|
||||
|
||||
{active ? (
|
||||
{active && AGENDA_UI_ENABLED ? (
|
||||
<>
|
||||
<AgentQuickActions
|
||||
actions={actions}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+4
-4
@@ -12,8 +12,8 @@ import {
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { scrollCurrentOptionIntoView } from "@/lib/scroll-current-option";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
looksLikeFolderPath,
|
||||
normalizeWorkspacePath,
|
||||
@@ -399,9 +399,9 @@ function BranchPicker({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex max-h-56 flex-col gap-0.5 overflow-y-auto"
|
||||
ref={branchListRef}
|
||||
>
|
||||
className="flex max-h-56 flex-col gap-0.5 overflow-y-auto"
|
||||
ref={branchListRef}
|
||||
>
|
||||
{filteredBranches.length === 0 ? (
|
||||
<div className="px-2 py-2 text-xs text-muted-foreground">
|
||||
No branches found
|
||||
|
||||
@@ -358,7 +358,10 @@ export function WorkspaceSelector({
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
<div ref={workspaceListRef} className="flex flex-col gap-0.5 max-h-28 overflow-y-auto">
|
||||
<div
|
||||
ref={workspaceListRef}
|
||||
className="flex flex-col gap-0.5 max-h-28 overflow-y-auto"
|
||||
>
|
||||
{filteredWorkspaces.length === 0 ? (
|
||||
<div className="px-2 py-2 text-xs text-muted-foreground">
|
||||
{looksLikeFolderPath(search)
|
||||
@@ -459,7 +462,10 @@ export function WorkspaceSelector({
|
||||
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Branches
|
||||
</div>
|
||||
<div ref={branchListRef} className="flex flex-col gap-0.5 max-h-36 overflow-y-auto">
|
||||
<div
|
||||
ref={branchListRef}
|
||||
className="flex flex-col gap-0.5 max-h-36 overflow-y-auto"
|
||||
>
|
||||
{filteredBranches.length === 0 ? (
|
||||
<div className="px-2 py-2 text-xs text-muted-foreground">
|
||||
No branches found
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
Blocks,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Puzzle,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
Star,
|
||||
Store,
|
||||
Trash2,
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
@@ -107,7 +109,7 @@ const primitivePageDetails = {
|
||||
const directoryPageDetails: MarketplacePageDetails = {
|
||||
title: "Marketplace",
|
||||
description:
|
||||
"Browse and install plugins, MCP servers, and skills from the Cline marketplace.",
|
||||
"A curated set of plugins, MCP servers, and skills from the Cline community.",
|
||||
emptyInstalled: "Nothing installed yet.",
|
||||
emptyCatalog: "No marketplace entries match the current filters.",
|
||||
icon: Store,
|
||||
@@ -643,6 +645,7 @@ export function MarketplaceView({
|
||||
defaultTypeFilter,
|
||||
installedItems,
|
||||
onInstalledItemsChanged,
|
||||
onOpenInstalled,
|
||||
primitive,
|
||||
variant = "full",
|
||||
}: {
|
||||
@@ -651,6 +654,8 @@ export function MarketplaceView({
|
||||
defaultTypeFilter?: MarketplacePrimitiveType;
|
||||
installedItems?: MarketplaceLocalInstalledItem[];
|
||||
onInstalledItemsChanged?: () => void | Promise<void>;
|
||||
/** Renders an Installed button in the directory page header. */
|
||||
onOpenInstalled?: () => void;
|
||||
/** When omitted, the view spans every catalog type (directory variant). */
|
||||
primitive?: MarketplacePrimitiveType;
|
||||
variant?: MarketplaceViewVariant;
|
||||
@@ -956,7 +961,7 @@ export function MarketplaceView({
|
||||
|
||||
const typeFilterChips =
|
||||
variant === "directory" && !primitive ? (
|
||||
<div className="flex min-w-0 gap-2 overflow-x-auto pb-1">
|
||||
<div className="flex min-w-0 flex-wrap gap-2">
|
||||
<Button
|
||||
aria-pressed={typeFilter === null}
|
||||
onClick={() => setTypeFilter(null)}
|
||||
@@ -991,38 +996,32 @@ export function MarketplaceView({
|
||||
|
||||
const marketplaceTagFilters =
|
||||
primitiveTags.length > 0 ? (
|
||||
<div className="flex min-w-0 flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex min-w-0 gap-2 overflow-x-auto pb-1">
|
||||
{primitiveTags.map((tag) => (
|
||||
<TagButton
|
||||
active={selectedTag === tag.id}
|
||||
count={tagCounts.get(tag.id) ?? 0}
|
||||
key={tag.id}
|
||||
onClick={() =>
|
||||
setSelectedTag((current) =>
|
||||
current === tag.id ? null : tag.id,
|
||||
)
|
||||
}
|
||||
tag={tag}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex min-h-8 shrink-0 items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{catalogEntries.length}
|
||||
</span>
|
||||
<span>{catalogEntries.length === 1 ? "result" : "results"}</span>
|
||||
{selectedTag ? (
|
||||
<Button
|
||||
onClick={() => setSelectedTag(null)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
{primitiveTags.map((tag) => (
|
||||
<TagButton
|
||||
active={selectedTag === tag.id}
|
||||
count={tagCounts.get(tag.id) ?? 0}
|
||||
key={tag.id}
|
||||
onClick={() =>
|
||||
setSelectedTag((current) => (current === tag.id ? null : tag.id))
|
||||
}
|
||||
tag={tag}
|
||||
/>
|
||||
))}
|
||||
{/* Clearing belongs with what it clears: the control appears at
|
||||
the end of the chip row only while a tag is active. */}
|
||||
{selectedTag ? (
|
||||
<Button
|
||||
className="text-muted-foreground"
|
||||
onClick={() => setSelectedTag(null)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
Clear
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
@@ -1128,16 +1127,17 @@ export function MarketplaceView({
|
||||
) : undefined
|
||||
}
|
||||
actions={
|
||||
catalog?.generatedAt ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Updated{" "}
|
||||
{new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).format(new Date(catalog.generatedAt))}
|
||||
</p>
|
||||
) : null
|
||||
onOpenInstalled ? (
|
||||
<Button
|
||||
onClick={onOpenInstalled}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Blocks className="size-4" />
|
||||
Installed
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1207,10 +1207,28 @@ export function MarketplaceView({
|
||||
expandedEntryKey={expandedEntryKey}
|
||||
headerContent={
|
||||
typeFilterChips || marketplaceTagFilters ? (
|
||||
<div className="grid min-w-0 gap-2">
|
||||
{typeFilterChips}
|
||||
{marketplaceTagFilters}
|
||||
</div>
|
||||
variant === "directory" ? (
|
||||
// Light rules separate the filter tiers from each
|
||||
// other and from the results below.
|
||||
<div className="grid min-w-0 gap-3">
|
||||
{typeFilterChips}
|
||||
{marketplaceTagFilters ? (
|
||||
<>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="h-px bg-border/70"
|
||||
/>
|
||||
{marketplaceTagFilters}
|
||||
</>
|
||||
) : null}
|
||||
<div aria-hidden="true" className="h-px bg-border/70" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid min-w-0 gap-2">
|
||||
{typeFilterChips}
|
||||
{marketplaceTagFilters}
|
||||
</div>
|
||||
)
|
||||
) : null
|
||||
}
|
||||
installedEntryKeys={installedEntryKeys}
|
||||
@@ -1219,7 +1237,7 @@ export function MarketplaceView({
|
||||
onToggleExpanded={toggleExpanded}
|
||||
onUninstall={uninstallEntry}
|
||||
tagLabels={tagLabels}
|
||||
title={variant === "directory" ? undefined : "Marketplace"}
|
||||
title={variant === "directory" ? undefined : "Browse"}
|
||||
/>
|
||||
) : null}
|
||||
</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} />
|
||||
)}
|
||||
|
||||
@@ -152,19 +152,19 @@ describe("SessionsView table", () => {
|
||||
expect(row?.parentElement?.className).not.toContain("min-h-14");
|
||||
});
|
||||
|
||||
it("marks favorited sessions with a star", async () => {
|
||||
it("marks pinned sessions with a pin icon", async () => {
|
||||
const plain = renderView();
|
||||
await plain.render();
|
||||
expect(container.querySelector('[aria-label="Favorited"]')).toBeNull();
|
||||
expect(container.querySelector('[aria-label="Pinned"]')).toBeNull();
|
||||
|
||||
await act(async () => root.unmount());
|
||||
root = createRoot(container);
|
||||
|
||||
const favorited = renderView({
|
||||
const pinned = renderView({
|
||||
threads: [{ ...thread, pinned: true }],
|
||||
});
|
||||
await favorited.render();
|
||||
expect(container.querySelector('[aria-label="Favorited"]')).not.toBeNull();
|
||||
await pinned.render();
|
||||
expect(container.querySelector('[aria-label="Pinned"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("opens a session on click but not while text is selected", async () => {
|
||||
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Pin,
|
||||
Search,
|
||||
Star,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
@@ -131,7 +131,7 @@ function sessionFilterDetails(
|
||||
const workspacePath = session?.workspaceRoot || session?.cwd || "";
|
||||
const workspace = workspacePath ? basenamePath(workspacePath) : "";
|
||||
return [
|
||||
thread.pinned ? "favorite:yes" : undefined,
|
||||
thread.pinned ? "pinned:yes" : undefined,
|
||||
workspace ? `workspace:${workspace}` : undefined,
|
||||
thread.status ? `status:${thread.status}` : undefined,
|
||||
thread.provider ? `provider:${thread.provider}` : undefined,
|
||||
@@ -572,8 +572,8 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
/>
|
||||
<span className="truncate">{thread.title}</span>
|
||||
{thread.pinned ? (
|
||||
<Star
|
||||
aria-label="Favorited"
|
||||
<Pin
|
||||
aria-label="Pinned"
|
||||
className="size-3.5 shrink-0 fill-current text-muted-foreground"
|
||||
/>
|
||||
) : null}
|
||||
@@ -626,13 +626,13 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
)
|
||||
}
|
||||
>
|
||||
<Star
|
||||
<Pin
|
||||
className={cn(
|
||||
"size-4",
|
||||
thread.pinned && "fill-current",
|
||||
)}
|
||||
/>
|
||||
{thread.pinned ? "Unfavorite" : "Favorite"}
|
||||
{thread.pinned ? "Unpin" : "Pin"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => startRename(thread)}>
|
||||
<Pencil className="size-4" />
|
||||
|
||||
@@ -24,12 +24,12 @@ import {
|
||||
UserCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useAccount } from "@/contexts/account-context";
|
||||
import { isClineAccountNotAuthenticatedResult } from "@/lib/cline-account-state";
|
||||
import { desktopClient, openExternalUrl } from "@/lib/desktop-client";
|
||||
import { invalidateProviderCatalogCache } from "@/lib/provider-model-catalog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageFrame, PageHeader } from "../page-layout";
|
||||
|
||||
const DASHBOARD_URL = "https://app.cline.bot/dashboard";
|
||||
const USAGE_DASHBOARD_URL = "https://app.cline.bot/dashboard/usage";
|
||||
@@ -554,12 +554,11 @@ export function AccountView() {
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="mx-auto max-w-3xl px-8 py-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="text-2xl font-semibold text-foreground">Account</h2>
|
||||
{user && (
|
||||
<PageFrame contentClassName="max-w-3xl">
|
||||
<PageHeader
|
||||
title="Account"
|
||||
actions={
|
||||
user ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={accountActionPending !== null}
|
||||
@@ -567,284 +566,284 @@ export function AccountView() {
|
||||
className="flex items-center gap-2 rounded-lg border border-border px-3.5 py-2 text-sm font-medium text-muted-foreground hover:bg-surface-hover hover:text-foreground disabled:opacity-60"
|
||||
>
|
||||
{accountActionPending === "sign-out" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<LogOut className="h-4 w-4" />
|
||||
<LogOut className="size-4" />
|
||||
)}
|
||||
{accountActionPending === "sign-out" ? "Signing Out" : "Sign Out"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-6 flex items-center gap-0 border-b border-border">
|
||||
{tabs.map((tab) => {
|
||||
const disabled = !user && tab !== "overview";
|
||||
return (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={cn(
|
||||
"relative px-4 py-2.5 text-sm font-medium capitalize transition-colors",
|
||||
activeTab === tab
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
disabled &&
|
||||
"cursor-not-allowed opacity-45 hover:text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{tab}
|
||||
{activeTab === tab && (
|
||||
<span className="absolute inset-x-0 -bottom-px h-0.5 bg-foreground" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Tabs */}
|
||||
<div className="mb-6 flex items-center gap-0 border-b border-border">
|
||||
{tabs.map((tab) => {
|
||||
const disabled = !user && tab !== "overview";
|
||||
return (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={cn(
|
||||
"relative px-4 py-2.5 text-sm font-medium capitalize transition-colors",
|
||||
activeTab === tab
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
disabled &&
|
||||
"cursor-not-allowed opacity-45 hover:text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{tab}
|
||||
{activeTab === tab && (
|
||||
<span className="absolute inset-x-0 -bottom-px h-0.5 bg-foreground" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Overview Tab */}
|
||||
{activeTab === "overview" && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{overviewLoading && renderLoading()}
|
||||
{!overviewLoading && signedOut && renderSignedOut()}
|
||||
{overviewError && renderError(overviewError, loadOverview)}
|
||||
{!overviewLoading && !signedOut && !overviewError && user && (
|
||||
<>
|
||||
{/* User Profile Card */}
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-(--accent-a3) text-2xl font-bold text-primary">
|
||||
{user.displayName?.charAt(0) ??
|
||||
user.email?.charAt(0) ??
|
||||
"?"}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
{user.displayName || user.email}
|
||||
</h3>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
{user.email}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Member since {formatDate(user.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
title="Open dashboard"
|
||||
onClick={() => void openExternalUrl(DASHBOARD_URL)}
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-surface-hover hover:text-foreground"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</button>
|
||||
{/* Overview Tab */}
|
||||
{activeTab === "overview" && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{overviewLoading && renderLoading()}
|
||||
{!overviewLoading && signedOut && renderSignedOut()}
|
||||
{overviewError && renderError(overviewError, loadOverview)}
|
||||
{!overviewLoading && !signedOut && !overviewError && user && (
|
||||
<>
|
||||
{/* User Profile Card */}
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-(--accent-a3) text-2xl font-bold text-primary">
|
||||
{user.displayName?.charAt(0) ??
|
||||
user.email?.charAt(0) ??
|
||||
"?"}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
{user.displayName || user.email}
|
||||
</h3>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
{user.email}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Member since {formatDate(user.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
title="Open dashboard"
|
||||
onClick={() => void openExternalUrl(DASHBOARD_URL)}
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-surface-hover hover:text-foreground"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Balance Card */}
|
||||
{displayedBalance !== null && (
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<CreditCard className="h-5 w-5 text-primary" />
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{activeOrganization
|
||||
? `${activeOrganization.name} Balance`
|
||||
: "Credits Balance"}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void openExternalUrl(
|
||||
activeOrganization
|
||||
? ORGANIZATION_CREDITS_URL
|
||||
: USER_CREDITS_URL,
|
||||
)
|
||||
}
|
||||
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-surface-hover hover:text-foreground transition-colors"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Credit
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold text-foreground">
|
||||
${formatCreditBalance(displayedBalance)}
|
||||
</span>
|
||||
</div>
|
||||
{activeOrganization && balance && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Personal account: {formatCreditBalance(balance.balance)}{" "}
|
||||
credits
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Organizations */}
|
||||
{/* Balance Card */}
|
||||
{displayedBalance !== null && (
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Building className="h-5 w-5 text-muted-foreground" />
|
||||
<CreditCard className="h-5 w-5 text-primary" />
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
Organizations
|
||||
{activeOrganization
|
||||
? `${activeOrganization.name} Balance`
|
||||
: "Credits Balance"}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void openExternalUrl(CREATE_ORGANIZATION_URL)
|
||||
void openExternalUrl(
|
||||
activeOrganization
|
||||
? ORGANIZATION_CREDITS_URL
|
||||
: USER_CREDITS_URL,
|
||||
)
|
||||
}
|
||||
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-surface-hover hover:text-foreground "
|
||||
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-surface-hover hover:text-foreground transition-colors"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Create
|
||||
Credit
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{renderAccountRow({
|
||||
key: "personal",
|
||||
name: "Personal",
|
||||
subtitle: user.email ?? "Personal account",
|
||||
icon: <User className="h-4 w-4" />,
|
||||
active: !activeOrganization,
|
||||
switching: switchTargetId === "",
|
||||
onSelect: () => void switchAccount(null),
|
||||
})}
|
||||
{organizations.map((org) =>
|
||||
renderAccountRow({
|
||||
key: org.organizationId,
|
||||
name: org.name,
|
||||
subtitle: org.roles.join(", "),
|
||||
icon: org.name.charAt(0),
|
||||
active: org.active,
|
||||
switching: switchTargetId === org.organizationId,
|
||||
onSelect: () => void switchAccount(org.organizationId),
|
||||
}),
|
||||
)}
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold text-foreground">
|
||||
${formatCreditBalance(displayedBalance)}
|
||||
</span>
|
||||
</div>
|
||||
{activeOrganization && balance && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Personal account: {formatCreditBalance(balance.balance)}{" "}
|
||||
credits
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Usage Tab */}
|
||||
{activeTab === "usage" && (
|
||||
<div>
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
{activeOrganization
|
||||
? `Recent API usage and token consumption for ${activeOrganization.name}.`
|
||||
: "Recent API usage and token consumption across all providers."}
|
||||
</p>
|
||||
{usageLoading && renderLoading()}
|
||||
{usageError && renderError(usageError, loadUsage)}
|
||||
{!usageLoading && !usageError && usageLoaded && (
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_5.5rem_4.5rem_5.5rem] gap-4 border-b border-border bg-secondary/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
|
||||
<span>Model</span>
|
||||
<span className="text-right">Tokens</span>
|
||||
<span className="text-right">Credits</span>
|
||||
<span className="text-right">Time</span>
|
||||
</div>
|
||||
{usageTransactions.length === 0 ? (
|
||||
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
No usage transactions yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{usageTransactions.map((tx) => (
|
||||
<div
|
||||
key={tx.id}
|
||||
className="grid grid-cols-[minmax(0,1fr)_5.5rem_4.5rem_5.5rem] gap-4 px-4 py-3 text-sm hover:bg-surface-hover"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground truncate">
|
||||
{tx.aiModelName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tx.aiInferenceProviderName}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right text-muted-foreground">
|
||||
{tx.totalTokens.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-right text-foreground font-medium">
|
||||
{formatCreditBalance(tx.creditsUsed)}
|
||||
</div>
|
||||
<div className="text-right text-xs text-muted-foreground">
|
||||
<p>{formatDate(tx.createdAt)}</p>
|
||||
<p>{formatTime(tx.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Organizations */}
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Building className="h-5 w-5 text-muted-foreground" />
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
Organizations
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-center border-t border-border px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openExternalUrl(USAGE_DASHBOARD_URL)}
|
||||
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-surface-hover hover:text-foreground"
|
||||
onClick={() =>
|
||||
void openExternalUrl(CREATE_ORGANIZATION_URL)
|
||||
}
|
||||
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-surface-hover hover:text-foreground "
|
||||
>
|
||||
See More
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{renderAccountRow({
|
||||
key: "personal",
|
||||
name: "Personal",
|
||||
subtitle: user.email ?? "Personal account",
|
||||
icon: <User className="h-4 w-4" />,
|
||||
active: !activeOrganization,
|
||||
switching: switchTargetId === "",
|
||||
onSelect: () => void switchAccount(null),
|
||||
})}
|
||||
{organizations.map((org) =>
|
||||
renderAccountRow({
|
||||
key: org.organizationId,
|
||||
name: org.name,
|
||||
subtitle: org.roles.join(", "),
|
||||
icon: org.name.charAt(0),
|
||||
active: org.active,
|
||||
switching: switchTargetId === org.organizationId,
|
||||
onSelect: () => void switchAccount(org.organizationId),
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Billing Tab */}
|
||||
{activeTab === "billing" && (
|
||||
<div>
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
Payment history and credit purchases.
|
||||
</p>
|
||||
{billingLoading && renderLoading()}
|
||||
{billingError && renderError(billingError, loadBilling)}
|
||||
{!billingLoading &&
|
||||
!billingError &&
|
||||
billingLoaded &&
|
||||
(paymentTransactions.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
No payment transactions yet.
|
||||
{/* Usage Tab */}
|
||||
{activeTab === "usage" && (
|
||||
<div>
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
{activeOrganization
|
||||
? `Recent API usage and token consumption for ${activeOrganization.name}.`
|
||||
: "Recent API usage and token consumption across all providers."}
|
||||
</p>
|
||||
{usageLoading && renderLoading()}
|
||||
{usageError && renderError(usageError, loadUsage)}
|
||||
{!usageLoading && !usageError && usageLoaded && (
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_5.5rem_4.5rem_5.5rem] gap-4 border-b border-border bg-secondary/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
|
||||
<span>Model</span>
|
||||
<span className="text-right">Tokens</span>
|
||||
<span className="text-right">Credits</span>
|
||||
<span className="text-right">Time</span>
|
||||
</div>
|
||||
{usageTransactions.length === 0 ? (
|
||||
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
No usage transactions yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<div className="grid grid-cols-[1fr_auto_auto] gap-4 border-b border-border bg-secondary/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
|
||||
<span>Date</span>
|
||||
<span className="text-right">Amount</span>
|
||||
<span className="text-right">Credits</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{paymentTransactions.map((tx) => (
|
||||
<div
|
||||
key={`${tx.paidAt}-${tx.amountCents}-${tx.credits}`}
|
||||
className="grid grid-cols-[1fr_auto_auto] gap-4 px-4 py-3 text-sm hover:bg-surface-hover"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Receipt className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-foreground">
|
||||
{formatDate(tx.paidAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right text-foreground font-medium">
|
||||
${(tx.amountCents / 100).toFixed(2)}
|
||||
</div>
|
||||
<div className="text-right text-primary font-medium">
|
||||
+{formatCreditBalance(tx.credits)}
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{usageTransactions.map((tx) => (
|
||||
<div
|
||||
key={tx.id}
|
||||
className="grid grid-cols-[minmax(0,1fr)_5.5rem_4.5rem_5.5rem] gap-4 px-4 py-3 text-sm hover:bg-surface-hover"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground truncate">
|
||||
{tx.aiModelName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tx.aiInferenceProviderName}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-right text-muted-foreground">
|
||||
{tx.totalTokens.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-right text-foreground font-medium">
|
||||
{formatCreditBalance(tx.creditsUsed)}
|
||||
</div>
|
||||
<div className="text-right text-xs text-muted-foreground">
|
||||
<p>{formatDate(tx.createdAt)}</p>
|
||||
<p>{formatTime(tx.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
<div className="flex justify-center border-t border-border px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openExternalUrl(USAGE_DASHBOARD_URL)}
|
||||
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-surface-hover hover:text-foreground"
|
||||
>
|
||||
See More
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Billing Tab */}
|
||||
{activeTab === "billing" && (
|
||||
<div>
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
Payment history and credit purchases.
|
||||
</p>
|
||||
{billingLoading && renderLoading()}
|
||||
{billingError && renderError(billingError, loadBilling)}
|
||||
{!billingLoading &&
|
||||
!billingError &&
|
||||
billingLoaded &&
|
||||
(paymentTransactions.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
No payment transactions yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<div className="grid grid-cols-[1fr_auto_auto] gap-4 border-b border-border bg-secondary/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
|
||||
<span>Date</span>
|
||||
<span className="text-right">Amount</span>
|
||||
<span className="text-right">Credits</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{paymentTransactions.map((tx) => (
|
||||
<div
|
||||
key={`${tx.paidAt}-${tx.amountCents}-${tx.credits}`}
|
||||
className="grid grid-cols-[1fr_auto_auto] gap-4 px-4 py-3 text-sm hover:bg-surface-hover"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Receipt className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-foreground">
|
||||
{formatDate(tx.paidAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right text-foreground font-medium">
|
||||
${(tx.amountCents / 100).toFixed(2)}
|
||||
</div>
|
||||
<div className="text-right text-primary font-medium">
|
||||
+{formatCreditBalance(tx.credits)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,10 +53,13 @@ interface NewProviderForm {
|
||||
}
|
||||
|
||||
export function AddProviderContent({
|
||||
variant = "page",
|
||||
onBack,
|
||||
onSave,
|
||||
existingProviderIds,
|
||||
}: {
|
||||
/** "dialog" renders only the form body for use inside a Dialog. */
|
||||
variant?: "page" | "dialog";
|
||||
onBack: () => void;
|
||||
onSave: (payload: AddProviderPayload) => Promise<void>;
|
||||
existingProviderIds: string[];
|
||||
@@ -180,6 +183,290 @@ export function AddProviderContent({
|
||||
}
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<h3 className="mb-4 text-sm font-semibold text-foreground">
|
||||
OpenAI-Compatible Provider
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Provider ID
|
||||
</Label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.providerId}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, providerId: e.target.value }))
|
||||
}
|
||||
placeholder="my-provider"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Lowercase ID used in provider registry.
|
||||
</p>
|
||||
{duplicateProviderId ? (
|
||||
<p className="mt-1 text-xs text-destructive">
|
||||
This provider ID already exists.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Provider Name
|
||||
</Label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, name: e.target.value }))
|
||||
}
|
||||
placeholder="My Provider"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Base URL
|
||||
</Label>
|
||||
<input
|
||||
type="url"
|
||||
value={form.baseUrl}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, baseUrl: e.target.value }))
|
||||
}
|
||||
placeholder="https://api.example.com/v1"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Model Source URL (Optional)
|
||||
</Label>
|
||||
<input
|
||||
type="url"
|
||||
value={form.modelsSourceUrl}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
modelsSourceUrl: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="https://api.example.com/v1/models"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Supported JSON: OpenAI `/models` shape with a `data` array, or a
|
||||
direct model array.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Models
|
||||
</Label>
|
||||
<ModelIdInput models={form.models} onChange={updateModels} />
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Add at least one model or set a Model Source URL.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{form.models.length > 1 ? (
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Default Model
|
||||
</Label>
|
||||
<select
|
||||
value={form.defaultModel}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, defaultModel: e.target.value }))
|
||||
}
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{form.models.map((model) => (
|
||||
<option key={model} value={model}>
|
||||
{model}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
API Key (Optional)
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showApiKey ? "text" : "password"}
|
||||
value={form.apiKey}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, apiKey: e.target.value }))
|
||||
}
|
||||
placeholder="sk-..."
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 pr-20 font-mono text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
|
||||
<Button
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
variant="ghost"
|
||||
className="rounded-md p-1 transition-colors"
|
||||
aria-label={showApiKey ? "Hide API key" : "Show API key"}
|
||||
>
|
||||
{showApiKey ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => navigator.clipboard.writeText(form.apiKey)}
|
||||
variant="ghost"
|
||||
className="rounded-md p-1 transition-colors"
|
||||
aria-label="Copy API key"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-3 block text-xs font-medium text-muted-foreground">
|
||||
Capabilities
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CAPABILITY_OPTIONS.map((cap) => (
|
||||
<Button
|
||||
key={cap}
|
||||
onClick={() => toggleCapability(cap)}
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-1.5 text-xs font-medium transition-all",
|
||||
form.capabilities.includes(cap)
|
||||
? "border-primary/40 bg-primary/10 text-primary"
|
||||
: "border-border bg-card text-muted-foreground hover:border-muted-foreground/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{cap.replace(/-/g, " ")}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<Button
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="flex w-full items-center justify-between px-5 py-4 text-sm font-medium transition-colors text-foreground/40"
|
||||
variant="ghost"
|
||||
>
|
||||
Advanced Settings
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 text-muted-foreground transition-transform",
|
||||
showAdvanced && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{showAdvanced ? (
|
||||
<div className="border-t border-border px-5 py-5 flex flex-col gap-5">
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Timeout (ms)
|
||||
</Label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.timeoutMs}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
timeoutMs: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="30000"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Custom Headers
|
||||
</Label>
|
||||
<div className="flex flex-col gap-2">
|
||||
{Object.entries(form.headers).map(([key, value], idx) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={key}
|
||||
onChange={(e) =>
|
||||
updateHeaderKey(key, e.target.value, idx)
|
||||
}
|
||||
placeholder="Header name"
|
||||
className="flex-1 rounded-lg border border-border bg-input px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => updateHeaderValue(key, e.target.value)}
|
||||
placeholder="Value"
|
||||
className="flex-1 rounded-lg border border-border bg-input px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => removeHeader(key)}
|
||||
className="rounded-md p-2 text-muted-foreground hover:text-destructive transition-colors"
|
||||
aria-label="Remove header"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
onClick={addHeader}
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium hover:text-foreground transition-colors w-fit"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add Header
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<Button
|
||||
onClick={onBack}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm font-medium hover:bg-surface-hover hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleSave()}
|
||||
disabled={!canSave || saving}
|
||||
className={cn(
|
||||
"rounded-lg px-4 py-2 text-sm font-medium",
|
||||
canSave && !saving
|
||||
? "bg-primary hover:bg-primary/90"
|
||||
: "bg-muted cursor-not-allowed text-foreground",
|
||||
)}
|
||||
>
|
||||
{saving ? "Saving..." : "Add Provider"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (variant === "dialog") {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageFrame contentClassName="max-w-4xl">
|
||||
<PageHeader
|
||||
@@ -192,289 +479,12 @@ export function AddProviderContent({
|
||||
className="rounded-md p-1.5"
|
||||
aria-label="Back to providers"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<ArrowLeft className="size-4" />
|
||||
Providers
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<h3 className="mb-4 text-sm font-semibold text-foreground">
|
||||
OpenAI-Compatible Provider
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Provider ID
|
||||
</Label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.providerId}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, providerId: e.target.value }))
|
||||
}
|
||||
placeholder="my-provider"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Lowercase ID used in provider registry.
|
||||
</p>
|
||||
{duplicateProviderId ? (
|
||||
<p className="mt-1 text-xs text-destructive">
|
||||
This provider ID already exists.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Provider Name
|
||||
</Label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, name: e.target.value }))
|
||||
}
|
||||
placeholder="My Provider"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Base URL
|
||||
</Label>
|
||||
<input
|
||||
type="url"
|
||||
value={form.baseUrl}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, baseUrl: e.target.value }))
|
||||
}
|
||||
placeholder="https://api.example.com/v1"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Model Source URL (Optional)
|
||||
</Label>
|
||||
<input
|
||||
type="url"
|
||||
value={form.modelsSourceUrl}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
modelsSourceUrl: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="https://api.example.com/v1/models"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Supported JSON: OpenAI `/models` shape with a `data` array, or a
|
||||
direct model array.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Models
|
||||
</Label>
|
||||
<ModelIdInput models={form.models} onChange={updateModels} />
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Add at least one model or set a Model Source URL.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{form.models.length > 1 ? (
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Default Model
|
||||
</Label>
|
||||
<select
|
||||
value={form.defaultModel}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, defaultModel: e.target.value }))
|
||||
}
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{form.models.map((model) => (
|
||||
<option key={model} value={model}>
|
||||
{model}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
API Key (Optional)
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showApiKey ? "text" : "password"}
|
||||
value={form.apiKey}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, apiKey: e.target.value }))
|
||||
}
|
||||
placeholder="sk-..."
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 pr-20 font-mono text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
|
||||
<Button
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
variant="ghost"
|
||||
className="rounded-md p-1 transition-colors"
|
||||
aria-label={showApiKey ? "Hide API key" : "Show API key"}
|
||||
>
|
||||
{showApiKey ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => navigator.clipboard.writeText(form.apiKey)}
|
||||
variant="ghost"
|
||||
className="rounded-md p-1 transition-colors"
|
||||
aria-label="Copy API key"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-3 block text-xs font-medium text-muted-foreground">
|
||||
Capabilities
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CAPABILITY_OPTIONS.map((cap) => (
|
||||
<Button
|
||||
key={cap}
|
||||
onClick={() => toggleCapability(cap)}
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-1.5 text-xs font-medium transition-all",
|
||||
form.capabilities.includes(cap)
|
||||
? "border-primary/40 bg-primary/10 text-primary"
|
||||
: "border-border bg-card text-muted-foreground hover:border-muted-foreground/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{cap.replace(/-/g, " ")}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<Button
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="flex w-full items-center justify-between px-5 py-4 text-sm font-medium transition-colors text-foreground/40"
|
||||
variant="ghost"
|
||||
>
|
||||
Advanced Settings
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 text-muted-foreground transition-transform",
|
||||
showAdvanced && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{showAdvanced ? (
|
||||
<div className="border-t border-border px-5 py-5 flex flex-col gap-5">
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Timeout (ms)
|
||||
</Label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.timeoutMs}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
timeoutMs: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="30000"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Custom Headers
|
||||
</Label>
|
||||
<div className="flex flex-col gap-2">
|
||||
{Object.entries(form.headers).map(([key, value], idx) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={key}
|
||||
onChange={(e) =>
|
||||
updateHeaderKey(key, e.target.value, idx)
|
||||
}
|
||||
placeholder="Header name"
|
||||
className="flex-1 rounded-lg border border-border bg-input px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => updateHeaderValue(key, e.target.value)}
|
||||
placeholder="Value"
|
||||
className="flex-1 rounded-lg border border-border bg-input px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => removeHeader(key)}
|
||||
className="rounded-md p-2 text-muted-foreground hover:text-destructive transition-colors"
|
||||
aria-label="Remove header"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
onClick={addHeader}
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium hover:text-foreground transition-colors w-fit"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add Header
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<Button
|
||||
onClick={onBack}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm font-medium hover:bg-surface-hover hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleSave()}
|
||||
disabled={!canSave || saving}
|
||||
className={cn(
|
||||
"rounded-lg px-4 py-2 text-sm font-medium",
|
||||
canSave && !saving
|
||||
? "bg-primary hover:bg-primary/90"
|
||||
: "bg-muted cursor-not-allowed text-foreground",
|
||||
)}
|
||||
>
|
||||
{saving ? "Saving..." : "Add Provider"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{content}
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
+70
-36
@@ -10,25 +10,32 @@ import { CustomizationSectionView } from "./extensions-view";
|
||||
import { McpServersContent } from "./mcp-view";
|
||||
|
||||
/**
|
||||
* Unified Plugins hub: one page for everything installed (plugins, MCP
|
||||
* servers, skills) with sub-tabs and live counts. The "Browse Marketplace"
|
||||
* button navigates to the dedicated Marketplace settings page.
|
||||
* Unified Customize hub: the installed inventory of everything that extends
|
||||
* Cline — skills, MCP servers, plugins, rules, hooks, and tools — as sub-tabs
|
||||
* with live counts. Browsing happens on the dedicated Marketplace page,
|
||||
* reached from the sidebar or the header button here.
|
||||
*/
|
||||
|
||||
type PluginsHubTab = "plugins" | "mcp" | "skills";
|
||||
type CustomizeTab = "skills" | "mcp" | "plugins" | "rules" | "hooks" | "tools";
|
||||
|
||||
const HUB_TABS: { id: PluginsHubTab; label: string }[] = [
|
||||
{ id: "plugins", label: "Plugins" },
|
||||
{ id: "mcp", label: "MCP" },
|
||||
const CUSTOMIZE_TABS: { id: CustomizeTab; label: string }[] = [
|
||||
{ id: "skills", label: "Skills" },
|
||||
{ id: "mcp", label: "MCP" },
|
||||
{ id: "plugins", label: "Plugins" },
|
||||
{ id: "rules", label: "Rules" },
|
||||
{ id: "hooks", label: "Hooks" },
|
||||
{ id: "tools", label: "Tools" },
|
||||
];
|
||||
|
||||
type HubCounts = Partial<Record<PluginsHubTab, number>>;
|
||||
type TabCounts = Partial<Record<CustomizeTab, number>>;
|
||||
|
||||
type HubInventoryResponse = {
|
||||
plugins?: unknown[];
|
||||
skills?: unknown[];
|
||||
workflows?: unknown[];
|
||||
rules?: unknown[];
|
||||
hooks?: unknown[];
|
||||
tools?: unknown[];
|
||||
mcp?: { servers?: unknown[] };
|
||||
};
|
||||
|
||||
@@ -36,13 +43,13 @@ function asCount(value: unknown): number {
|
||||
return Array.isArray(value) ? value.length : 0;
|
||||
}
|
||||
|
||||
export function PluginsHubView({
|
||||
export function CustomizeView({
|
||||
onOpenMarketplace,
|
||||
}: {
|
||||
onOpenMarketplace?: () => void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<PluginsHubTab>("plugins");
|
||||
const [counts, setCounts] = useState<HubCounts>({});
|
||||
const [tab, setTab] = useState<CustomizeTab>("skills");
|
||||
const [counts, setCounts] = useState<TabCounts>({});
|
||||
|
||||
const refreshCounts = useCallback(async () => {
|
||||
const inventory = await desktopClient
|
||||
@@ -52,9 +59,12 @@ export function PluginsHubView({
|
||||
return;
|
||||
}
|
||||
setCounts({
|
||||
plugins: asCount(inventory.plugins),
|
||||
skills: asCount(inventory.skills) + asCount(inventory.workflows),
|
||||
mcp: asCount(inventory.mcp?.servers),
|
||||
plugins: asCount(inventory.plugins),
|
||||
rules: asCount(inventory.rules),
|
||||
hooks: asCount(inventory.hooks),
|
||||
tools: asCount(inventory.tools),
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -72,22 +82,27 @@ export function PluginsHubView({
|
||||
return (
|
||||
<PageFrame>
|
||||
<PageHeader
|
||||
description="Manage installed plugins, MCP servers, and skills. Browse the marketplace to install more."
|
||||
title="Plugins"
|
||||
actions={
|
||||
onOpenMarketplace ? (
|
||||
<Button onClick={onOpenMarketplace} type="button" variant="outline">
|
||||
<Button
|
||||
onClick={onOpenMarketplace}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Store className="size-4" />
|
||||
Browse Marketplace
|
||||
Marketplace
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
description="Extend what Cline can do and change how it works. Manage what's installed, or browse the marketplace for more options."
|
||||
title="Customize"
|
||||
/>
|
||||
|
||||
<div className="mb-6 flex items-center gap-0 border-b border-border">
|
||||
{HUB_TABS.map((hubTab) => {
|
||||
const count = counts[hubTab.id];
|
||||
const active = tab === hubTab.id;
|
||||
{CUSTOMIZE_TABS.map((customizeTab) => {
|
||||
const count = counts[customizeTab.id];
|
||||
const active = tab === customizeTab.id;
|
||||
return (
|
||||
<Button
|
||||
aria-current={active ? "page" : undefined}
|
||||
@@ -97,12 +112,12 @@ export function PluginsHubView({
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
key={hubTab.id}
|
||||
onClick={() => setTab(hubTab.id)}
|
||||
key={customizeTab.id}
|
||||
onClick={() => setTab(customizeTab.id)}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{hubTab.label}
|
||||
{customizeTab.label}
|
||||
{typeof count === "number" ? (
|
||||
<span
|
||||
className={cn(
|
||||
@@ -123,20 +138,7 @@ export function PluginsHubView({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{tab === "plugins" ? (
|
||||
<CustomizationSectionView
|
||||
catalogPrimitive="plugin"
|
||||
chrome="embedded"
|
||||
marketplaceVariant="installed"
|
||||
onInventoryChanged={handleInventoryChanged}
|
||||
section="Plugins"
|
||||
/>
|
||||
) : tab === "mcp" ? (
|
||||
<McpServersContent
|
||||
chrome="embedded"
|
||||
onInventoryChanged={handleInventoryChanged}
|
||||
/>
|
||||
) : (
|
||||
{tab === "skills" ? (
|
||||
<CustomizationSectionView
|
||||
catalogPrimitive="skill"
|
||||
chrome="embedded"
|
||||
@@ -144,6 +146,38 @@ export function PluginsHubView({
|
||||
onInventoryChanged={handleInventoryChanged}
|
||||
section="Skills"
|
||||
/>
|
||||
) : tab === "mcp" ? (
|
||||
<McpServersContent
|
||||
chrome="embedded"
|
||||
marketplaceVariant="installed"
|
||||
onInventoryChanged={handleInventoryChanged}
|
||||
/>
|
||||
) : tab === "plugins" ? (
|
||||
<CustomizationSectionView
|
||||
catalogPrimitive="plugin"
|
||||
chrome="embedded"
|
||||
marketplaceVariant="installed"
|
||||
onInventoryChanged={handleInventoryChanged}
|
||||
section="Plugins"
|
||||
/>
|
||||
) : tab === "rules" ? (
|
||||
<CustomizationSectionView
|
||||
chrome="embedded"
|
||||
onInventoryChanged={handleInventoryChanged}
|
||||
section="Rules"
|
||||
/>
|
||||
) : tab === "hooks" ? (
|
||||
<CustomizationSectionView
|
||||
chrome="embedded"
|
||||
onInventoryChanged={handleInventoryChanged}
|
||||
section="Hooks"
|
||||
/>
|
||||
) : (
|
||||
<CustomizationSectionView
|
||||
chrome="embedded"
|
||||
onInventoryChanged={handleInventoryChanged}
|
||||
section="Tools"
|
||||
/>
|
||||
)}
|
||||
</PageFrame>
|
||||
);
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
FileText,
|
||||
MoreVertical,
|
||||
Play,
|
||||
Puzzle,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Trash2,
|
||||
@@ -907,25 +908,23 @@ export function CustomizationSectionView({
|
||||
);
|
||||
};
|
||||
|
||||
const renderLocalActionRow = (target: LocalUninstallTarget) => {
|
||||
// Sized and styled to match the Install/Uninstall button on marketplace
|
||||
// entry cards so installed rows and browse rows read as one list.
|
||||
const renderLocalActionButton = (target: LocalUninstallTarget) => {
|
||||
const uninstalling = localUninstallingKeys.has(target.key);
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-h-5 text-xs text-muted-foreground">
|
||||
{renderLocalActionMessage(target.key)}
|
||||
</div>
|
||||
<Button
|
||||
disabled={uninstalling}
|
||||
onClick={() => {
|
||||
void uninstallLocalPrimitive(target);
|
||||
}}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
{uninstalling ? <Spinner /> : <Trash2 className="size-4" />}
|
||||
{uninstalling ? "Uninstalling..." : "Uninstall"}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
disabled={uninstalling}
|
||||
onClick={() => {
|
||||
void uninstallLocalPrimitive(target);
|
||||
}}
|
||||
size="xs"
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
{uninstalling ? <Spinner /> : <Trash2 className="size-4" />}
|
||||
{uninstalling ? "Uninstalling..." : "Uninstall"}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -973,14 +972,26 @@ export function CustomizationSectionView({
|
||||
) => {
|
||||
const key = `${item.type}:${item.path}`;
|
||||
return (
|
||||
<div key={key} className="rounded-lg border border-border px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
key={key}
|
||||
className="relative grid min-w-0 gap-2 rounded-lg border bg-card p-4"
|
||||
>
|
||||
<div className="absolute top-4 right-4">
|
||||
{renderLocalActionButton({
|
||||
key,
|
||||
type: item.type,
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
})}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2 pr-28">
|
||||
{item.type === "workflow" ? (
|
||||
<Play className="h-4 w-4 shrink-0 text-primary" />
|
||||
) : (
|
||||
<Zap className="h-4 w-4 shrink-0 text-primary" />
|
||||
)}
|
||||
<h3 className="min-w-0 flex-1 text-sm font-semibold text-foreground">
|
||||
<h3 className="min-w-0 truncate text-sm font-semibold text-foreground">
|
||||
{item.name}
|
||||
</h3>
|
||||
<ScopeBadge scope={item.scope} />
|
||||
@@ -993,26 +1004,16 @@ export function CustomizationSectionView({
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-2 ml-7 text-xs text-muted-foreground">
|
||||
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
|
||||
{item.description?.trim() || previewText(item.instructions)}
|
||||
</p>
|
||||
<p className="mt-1 ml-7 text-xs font-mono text-muted-foreground">
|
||||
<p className="truncate text-xs font-mono text-muted-foreground">
|
||||
{item.path}
|
||||
</p>
|
||||
{context?.matchedEntries?.length ? (
|
||||
<div className="mt-2 ml-7">
|
||||
<MarketplaceEntrySetupDetails entries={context.matchedEntries} />
|
||||
</div>
|
||||
<MarketplaceEntrySetupDetails entries={context.matchedEntries} />
|
||||
) : null}
|
||||
<div className="mt-3">
|
||||
{renderLocalActionRow({
|
||||
key,
|
||||
type: item.type,
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
})}
|
||||
</div>
|
||||
{renderLocalActionMessage(key)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1049,12 +1050,10 @@ export function CustomizationSectionView({
|
||||
},
|
||||
].filter((group) => group.items.length > 0);
|
||||
return (
|
||||
<details
|
||||
key={plugin.path}
|
||||
className="rounded-lg border border-border px-5 py-4"
|
||||
>
|
||||
<summary className="flex cursor-pointer list-none items-center gap-3">
|
||||
<h3 className="min-w-0 flex-1 text-sm font-semibold text-foreground">
|
||||
<details key={plugin.path} className="rounded-lg border bg-card p-4">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-2">
|
||||
<Puzzle className="h-4 w-4 shrink-0 text-primary" />
|
||||
<h3 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">
|
||||
{plugin.name}
|
||||
</h3>
|
||||
<ScopeBadge scope={scope} />
|
||||
@@ -1142,11 +1141,19 @@ export function CustomizationSectionView({
|
||||
return (
|
||||
<div
|
||||
key={server.name}
|
||||
className="rounded-lg border border-border px-5 py-4"
|
||||
className="relative grid min-w-0 gap-2 rounded-lg border bg-card p-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="absolute top-4 right-4">
|
||||
{renderLocalActionButton({
|
||||
key,
|
||||
type: "mcp",
|
||||
id: server.name,
|
||||
name: server.name,
|
||||
})}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2 pr-28">
|
||||
<Server className="h-4 w-4 shrink-0 text-primary" />
|
||||
<h3 className="min-w-0 flex-1 text-sm font-semibold text-foreground">
|
||||
<h3 className="min-w-0 truncate text-sm font-semibold text-foreground">
|
||||
{server.name}
|
||||
</h3>
|
||||
<ScopeBadge scope="Global" />
|
||||
@@ -1158,11 +1165,13 @@ export function CustomizationSectionView({
|
||||
Marketplace
|
||||
</Badge>
|
||||
) : null}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{server.disabled ? "Disabled" : "Enabled"}
|
||||
</span>
|
||||
{server.disabled ? (
|
||||
<Badge variant="outline" className="shrink-0 text-muted-foreground">
|
||||
Disabled
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-2 ml-7 text-xs text-muted-foreground">
|
||||
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
|
||||
{server.url ??
|
||||
([server.command, ...(server.args ?? [])]
|
||||
.filter(Boolean)
|
||||
@@ -1170,23 +1179,14 @@ export function CustomizationSectionView({
|
||||
"No launch command configured.")}
|
||||
</p>
|
||||
{mcp.settingsPath ? (
|
||||
<p className="mt-1 ml-7 text-xs font-mono text-muted-foreground">
|
||||
<p className="truncate text-xs font-mono text-muted-foreground">
|
||||
{mcp.settingsPath}
|
||||
</p>
|
||||
) : null}
|
||||
{context?.matchedEntries?.length ? (
|
||||
<div className="mt-2 ml-7">
|
||||
<MarketplaceEntrySetupDetails entries={context.matchedEntries} />
|
||||
</div>
|
||||
<MarketplaceEntrySetupDetails entries={context.matchedEntries} />
|
||||
) : null}
|
||||
<div className="mt-3">
|
||||
{renderLocalActionRow({
|
||||
key,
|
||||
type: "mcp",
|
||||
id: server.name,
|
||||
name: server.name,
|
||||
})}
|
||||
</div>
|
||||
{renderLocalActionMessage(key)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1313,11 +1313,6 @@ export function CustomizationSectionView({
|
||||
|
||||
{activeTab === "Rules" && (
|
||||
<div>
|
||||
<p className="mb-6 text-sm leading-relaxed text-muted-foreground">
|
||||
Enabled rules discovered from configured workspace/global
|
||||
directories.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
@@ -1331,19 +1326,19 @@ export function CustomizationSectionView({
|
||||
{scopedRules.map(({ rule, scope }) => (
|
||||
<div
|
||||
key={rule.path}
|
||||
className="rounded-lg border border-border px-4 py-3"
|
||||
className="grid min-w-0 gap-2 rounded-lg border bg-card p-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 text-sm font-medium text-foreground">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<FileText className="h-4 w-4 shrink-0 text-primary" />
|
||||
<span className="min-w-0 truncate text-sm font-semibold text-foreground">
|
||||
{rule.name}
|
||||
</span>
|
||||
<ScopeBadge scope={scope} />
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
|
||||
{previewText(rule.instructions)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-mono text-muted-foreground">
|
||||
<p className="truncate text-xs font-mono text-muted-foreground">
|
||||
{rule.path}
|
||||
</p>
|
||||
</div>
|
||||
@@ -1360,9 +1355,6 @@ export function CustomizationSectionView({
|
||||
|
||||
{activeTab === "Hooks" && (
|
||||
<div>
|
||||
<p className="mb-6 text-sm leading-relaxed text-muted-foreground">
|
||||
Hook config files from workspace and global hook directories.
|
||||
</p>
|
||||
{hookExecutionLoading && hookExecutionSessionId && (
|
||||
<p className="mb-4 text-xs text-muted-foreground">
|
||||
Execution status is based on hook events in session{" "}
|
||||
@@ -1383,43 +1375,47 @@ export function CustomizationSectionView({
|
||||
{scopedHooks.map(({ hook, scope }) => (
|
||||
<div
|
||||
key={hook.path}
|
||||
className="rounded-lg border border-border px-4 py-3"
|
||||
className="grid min-w-0 gap-2 rounded-lg border bg-card p-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Code className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 text-sm font-mono text-foreground">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Code className="h-4 w-4 shrink-0 text-primary" />
|
||||
<span className="min-w-0 truncate text-sm font-semibold text-foreground">
|
||||
{hook.fileName}
|
||||
</span>
|
||||
<ScopeBadge scope={scope} />
|
||||
{hook.hookEventName && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
<>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
>
|
||||
{hook.hookEventName}
|
||||
</span>
|
||||
</Badge>
|
||||
{(() => {
|
||||
const stats =
|
||||
hookExecutionByEvent[hook.hookEventName];
|
||||
const executed = (stats?.count ?? 0) > 0;
|
||||
return (
|
||||
<span
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"rounded border px-2 py-0.5 text-xs",
|
||||
"shrink-0",
|
||||
executed
|
||||
? "border-emerald-400/50 text-emerald-600 dark:text-emerald-400"
|
||||
: "border-border text-muted-foreground",
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{executed
|
||||
? `${stats?.count ?? 0} executed`
|
||||
: "never executed"}
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{hook.hookEventName ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
Last run:{" "}
|
||||
{formatExecutionTs(
|
||||
hookExecutionByEvent[hook.hookEventName]?.lastTs ??
|
||||
@@ -1427,7 +1423,7 @@ export function CustomizationSectionView({
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-1 text-xs font-mono text-muted-foreground">
|
||||
<p className="truncate text-xs font-mono text-muted-foreground">
|
||||
{hook.path}
|
||||
</p>
|
||||
</div>
|
||||
@@ -1659,27 +1655,27 @@ export function CustomizationSectionView({
|
||||
|
||||
{activeTab === "Tools" && (
|
||||
<div>
|
||||
<p className="mb-6 text-sm leading-relaxed text-muted-foreground">
|
||||
Builtin tool groups and plugin-contributed tools available to the
|
||||
runtime.
|
||||
</p>
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Builtin Tools
|
||||
</h3>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="mb-6 grid gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
Builtin Tools
|
||||
</h3>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{builtinTools.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{builtinTools.map((tool) =>
|
||||
(() => {
|
||||
const isToggling = togglingToolIds.has(tool.id);
|
||||
return (
|
||||
<div
|
||||
key={tool.id}
|
||||
className="rounded-lg border border-border px-5 py-4"
|
||||
className="grid min-w-0 gap-2 rounded-lg border bg-card p-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Wrench className="h-4 w-4 shrink-0 text-primary" />
|
||||
<h3 className="min-w-0 flex-1 text-sm font-semibold text-foreground">
|
||||
<h3 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">
|
||||
{tool.name}
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@@ -1694,12 +1690,12 @@ export function CustomizationSectionView({
|
||||
aria-label={`Toggle ${tool.name}`}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 ml-7 text-xs text-muted-foreground">
|
||||
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
|
||||
{tool.description?.trim() ||
|
||||
"No description available."}
|
||||
</p>
|
||||
{!!tool.headlessToolNames?.length && (
|
||||
<p className="mt-1 ml-7 text-xs font-mono text-muted-foreground">
|
||||
<p className="truncate text-xs font-mono text-muted-foreground">
|
||||
{tool.headlessToolNames.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
@@ -1715,28 +1711,36 @@ export function CustomizationSectionView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Plugin Tools
|
||||
</h3>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
Plugin Tools
|
||||
</h3>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{pluginTools.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{pluginTools.map((tool) =>
|
||||
(() => {
|
||||
const isToggling = togglingToolIds.has(tool.id);
|
||||
return (
|
||||
<div
|
||||
key={tool.id}
|
||||
className="rounded-lg border border-border px-5 py-4"
|
||||
className="grid min-w-0 gap-2 rounded-lg border bg-card p-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Wrench className="h-4 w-4 shrink-0 text-primary" />
|
||||
<h3 className="min-w-0 flex-1 text-sm font-semibold text-foreground">
|
||||
<h3 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">
|
||||
{tool.name}
|
||||
</h3>
|
||||
{tool.pluginName && (
|
||||
<span className="rounded border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
plugin: {tool.pluginName}
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
>
|
||||
{tool.pluginName}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{tool.enabled ? "Enabled" : "Disabled"}
|
||||
@@ -1750,12 +1754,12 @@ export function CustomizationSectionView({
|
||||
aria-label={`Toggle ${tool.name}`}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 ml-7 text-xs text-muted-foreground">
|
||||
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
|
||||
{tool.description?.trim() ||
|
||||
"No description available."}
|
||||
</p>
|
||||
{tool.path && (
|
||||
<p className="mt-1 ml-7 text-xs font-mono text-muted-foreground">
|
||||
<p className="truncate text-xs font-mono text-muted-foreground">
|
||||
{tool.path}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
@@ -208,10 +209,13 @@ function createServerFormState(existing?: McpServer): McpServerFormState {
|
||||
|
||||
export function McpServersContent({
|
||||
chrome = "page",
|
||||
marketplaceVariant = "full",
|
||||
onInventoryChanged,
|
||||
}: {
|
||||
/** "embedded" renders without the page frame/header for use inside the Plugins hub. */
|
||||
chrome?: "page" | "embedded";
|
||||
/** Which marketplace sections the embedded MarketplaceView shows. */
|
||||
marketplaceVariant?: "full" | "installed";
|
||||
/** Invoked whenever the server list is (re)loaded or mutated. */
|
||||
onInventoryChanged?: () => void;
|
||||
} = {}) {
|
||||
@@ -623,9 +627,9 @@ export function McpServersContent({
|
||||
return (
|
||||
<div
|
||||
key={server.name}
|
||||
className="group relative rounded-lg border border-border px-5 py-4 hover:bg-surface-hover"
|
||||
className="group relative rounded-lg border bg-card p-4 transition-colors hover:bg-surface-hover-lighter"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Circle
|
||||
className={cn(
|
||||
"h-2.5 w-2.5 shrink-0",
|
||||
@@ -634,17 +638,17 @@ export function McpServersContent({
|
||||
: "fill-primary text-primary",
|
||||
)}
|
||||
/>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
<h3 className="min-w-0 truncate text-sm font-semibold text-foreground">
|
||||
{server.name}
|
||||
</h3>
|
||||
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
<Badge variant="outline" className="shrink-0 text-muted-foreground">
|
||||
{TRANSPORT_TYPE_LABELS[server.transportType] ??
|
||||
server.transportType}
|
||||
</span>
|
||||
</Badge>
|
||||
{context?.matchedEntries?.length ? (
|
||||
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
<Badge variant="outline" className="shrink-0 text-muted-foreground">
|
||||
Marketplace
|
||||
</span>
|
||||
</Badge>
|
||||
) : null}
|
||||
<div className="flex-1" />
|
||||
{renderServerToggle(server)}
|
||||
@@ -813,7 +817,7 @@ export function McpServersContent({
|
||||
installedItems={installedItems}
|
||||
onInstalledItemsChanged={() => refreshServers()}
|
||||
primitive="mcp"
|
||||
variant={chrome === "embedded" ? "installed" : "full"}
|
||||
variant={marketplaceVariant}
|
||||
/>
|
||||
<Dialog
|
||||
open={editorOpen}
|
||||
|
||||
+9
-9
@@ -83,11 +83,7 @@ export function NotificationSettings() {
|
||||
<span className="shrink-0 text-xs font-medium text-muted-foreground">
|
||||
Allowed by system
|
||||
</span>
|
||||
) : permission === "unsupported" ? (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
Available in the desktop app
|
||||
</span>
|
||||
) : permission === null ? (
|
||||
) : permission === "unsupported" ? null : permission === null ? (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">Checking…</span>
|
||||
) : (
|
||||
<Button
|
||||
@@ -101,9 +97,13 @@ export function NotificationSettings() {
|
||||
</Button>
|
||||
);
|
||||
|
||||
// One settings section: a top-level header row like the other General
|
||||
// settings, with the per-event matrix nested in a card so its rows read
|
||||
// as children of "Desktop notifications" rather than as siblings of
|
||||
// top-level settings like Dark mode.
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-5 border-b py-4 max-[720px]:flex-col max-[720px]:items-stretch">
|
||||
<div className="border-b py-4">
|
||||
<div className="flex items-center justify-between gap-5 max-[720px]:flex-col max-[720px]:items-stretch">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
Desktop notifications
|
||||
@@ -120,7 +120,7 @@ export function NotificationSettings() {
|
||||
</div>
|
||||
{permissionControl}
|
||||
</div>
|
||||
<div className="border-b">
|
||||
<div className="mt-4 rounded-lg border bg-card px-4">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_5rem_4rem] items-center gap-3 border-b py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<span>Event</span>
|
||||
<span className="text-center">Notify</span>
|
||||
@@ -165,6 +165,6 @@ export function NotificationSettings() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+219
-100
@@ -3,7 +3,7 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Provider, VoiceInputSelection } from "@/lib/provider-schema";
|
||||
import type { Provider } from "@/lib/provider-schema";
|
||||
import {
|
||||
ProviderDetailContent,
|
||||
ProviderListContent,
|
||||
@@ -107,57 +107,40 @@ describe("ProviderDetailContent models", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const voiceProviders: Provider[] = [
|
||||
const catalogProviders: Provider[] = [
|
||||
{
|
||||
id: "anthropic",
|
||||
name: "Anthropic",
|
||||
models: 4,
|
||||
color: "#000000",
|
||||
letter: "AN",
|
||||
enabled: true,
|
||||
apiKey: "sk-test",
|
||||
capabilities: ["popular"],
|
||||
modelList: [],
|
||||
},
|
||||
{
|
||||
id: "cline",
|
||||
name: "Cline",
|
||||
models: 12,
|
||||
color: "#000000",
|
||||
letter: "CL",
|
||||
enabled: false,
|
||||
capabilities: ["oauth", "popular"],
|
||||
modelList: [],
|
||||
},
|
||||
{
|
||||
id: "elevenlabs",
|
||||
name: "ElevenLabs",
|
||||
models: 1,
|
||||
color: "#000000",
|
||||
letter: "EL",
|
||||
enabled: true,
|
||||
modelList: [
|
||||
{
|
||||
id: "scribe_v2",
|
||||
name: "Scribe v2",
|
||||
operation: "transcription",
|
||||
inputModalities: ["audio"],
|
||||
outputModalities: ["text"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "groq",
|
||||
name: "Groq",
|
||||
models: 3,
|
||||
color: "#000000",
|
||||
letter: "GR",
|
||||
enabled: true,
|
||||
modelList: [
|
||||
{
|
||||
id: "whisper-large-v3",
|
||||
name: "Whisper Large v3",
|
||||
operation: "transcription",
|
||||
inputModalities: ["audio"],
|
||||
outputModalities: ["text"],
|
||||
},
|
||||
{
|
||||
id: "whisper-large-v3-turbo",
|
||||
name: "Whisper Large v3 Turbo",
|
||||
operation: "transcription",
|
||||
inputModalities: ["audio"],
|
||||
outputModalities: ["text"],
|
||||
},
|
||||
{
|
||||
id: "llama-chat",
|
||||
name: "Llama Chat",
|
||||
inputModalities: ["text"],
|
||||
outputModalities: ["text"],
|
||||
},
|
||||
],
|
||||
enabled: false,
|
||||
modelList: [],
|
||||
},
|
||||
];
|
||||
|
||||
describe("ProviderListContent voice input settings", () => {
|
||||
describe("ProviderListContent", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
@@ -174,74 +157,210 @@ describe("ProviderListContent voice input settings", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("lets the user choose and clear the voice provider and model", async () => {
|
||||
const onVoiceInputChange = vi.fn();
|
||||
let selection: VoiceInputSelection | undefined = {
|
||||
providerId: "elevenlabs",
|
||||
modelId: "scribe_v2",
|
||||
};
|
||||
const render = async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ProviderListContent
|
||||
onAddProvider={vi.fn()}
|
||||
onConfigure={vi.fn()}
|
||||
onToggle={vi.fn()}
|
||||
onVoiceInputChange={onVoiceInputChange}
|
||||
providers={voiceProviders}
|
||||
voiceInput={selection}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
await render();
|
||||
const providerSelect = container.querySelector<HTMLSelectElement>(
|
||||
'[aria-label="Voice input provider"]',
|
||||
);
|
||||
const modelSelect = container.querySelector<HTMLSelectElement>(
|
||||
'[aria-label="Voice input model"]',
|
||||
);
|
||||
expect(providerSelect?.value).toBe("elevenlabs");
|
||||
expect(modelSelect?.value).toBe("scribe_v2");
|
||||
|
||||
it("groups providers into Configured, Popular, and All providers", async () => {
|
||||
const onConfigure = vi.fn();
|
||||
await act(async () => {
|
||||
if (!providerSelect) return;
|
||||
providerSelect.value = "groq";
|
||||
providerSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
expect(onVoiceInputChange).toHaveBeenLastCalledWith({
|
||||
providerId: "groq",
|
||||
modelId: "whisper-large-v3",
|
||||
root.render(
|
||||
<ProviderListContent
|
||||
onAddProvider={vi.fn()}
|
||||
onConfigure={onConfigure}
|
||||
providers={catalogProviders}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
selection = {
|
||||
providerId: "groq",
|
||||
modelId: "whisper-large-v3",
|
||||
};
|
||||
await render();
|
||||
const groqModelSelect = container.querySelector<HTMLSelectElement>(
|
||||
'[aria-label="Voice input model"]',
|
||||
const headings = Array.from(container.querySelectorAll("h2")).map(
|
||||
(heading) => heading.textContent,
|
||||
);
|
||||
expect(headings).toEqual(["Configured", "Popular", "All providers"]);
|
||||
// No per-provider enable toggles anymore.
|
||||
expect(container.querySelector('[role="switch"]')).toBeNull();
|
||||
expect(container.textContent).toContain("1 configured");
|
||||
|
||||
const rows = Array.from(container.querySelectorAll("button")).filter(
|
||||
(button) => button.textContent?.includes("Anthropic"),
|
||||
);
|
||||
await act(async () => rows[0]?.click());
|
||||
expect(onConfigure).toHaveBeenCalledWith("anthropic");
|
||||
});
|
||||
|
||||
it("shows connection status and auth kind per row", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ProviderListContent
|
||||
onAddProvider={vi.fn()}
|
||||
onConfigure={vi.fn()}
|
||||
providers={catalogProviders}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const rowFor = (name: string) =>
|
||||
Array.from(container.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes(name),
|
||||
);
|
||||
expect(rowFor("Anthropic")?.textContent).toContain("Configured");
|
||||
expect(rowFor("Cline")?.textContent).toContain("Sign in");
|
||||
expect(rowFor("ElevenLabs")?.textContent).toContain("API key");
|
||||
});
|
||||
|
||||
it("filters providers by search across every group", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ProviderListContent
|
||||
onAddProvider={vi.fn()}
|
||||
onConfigure={vi.fn()}
|
||||
providers={catalogProviders}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const search = container.querySelector<HTMLInputElement>(
|
||||
'[aria-label="Search model providers"]',
|
||||
);
|
||||
await act(async () => {
|
||||
if (!groqModelSelect) return;
|
||||
groqModelSelect.value = "whisper-large-v3-turbo";
|
||||
groqModelSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
setter?.call(search, "eleven");
|
||||
search?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
expect(onVoiceInputChange).toHaveBeenLastCalledWith({
|
||||
providerId: "groq",
|
||||
modelId: "whisper-large-v3-turbo",
|
||||
expect(container.textContent).toContain("ElevenLabs");
|
||||
expect(container.textContent).not.toContain("Anthropic");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProviderDetailContent auth flows", () => {
|
||||
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);
|
||||
// "cline" is a featured provider, so mounting its detail refreshes the
|
||||
// model list; resolve it to keep the auth assertions deterministic.
|
||||
loadProviderModelsMock.mockReset().mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const signInProvider: Provider = {
|
||||
id: "cline",
|
||||
name: "Cline",
|
||||
models: 0,
|
||||
color: "#000",
|
||||
letter: "CL",
|
||||
enabled: false,
|
||||
capabilities: ["oauth"],
|
||||
configFields: [
|
||||
{
|
||||
path: "apiKey",
|
||||
label: "API Key",
|
||||
type: "password",
|
||||
secret: true,
|
||||
},
|
||||
],
|
||||
modelList: [],
|
||||
};
|
||||
|
||||
it("shows browser sign-in instead of an API key field for OAuth providers", async () => {
|
||||
const onOAuthLogin = vi.fn();
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ProviderDetailContent
|
||||
onBack={vi.fn()}
|
||||
onOAuthLogin={onOAuthLogin}
|
||||
onUpdate={vi.fn()}
|
||||
provider={signInProvider}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const groqProviderSelect = container.querySelector<HTMLSelectElement>(
|
||||
'[aria-label="Voice input provider"]',
|
||||
expect(container.textContent).toContain("Sign in with browser");
|
||||
expect(container.textContent).toContain("Not configured");
|
||||
// The API key input stays collapsed until explicitly requested.
|
||||
expect(container.querySelector('input[type="password"]')).toBeNull();
|
||||
|
||||
const signIn = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("Sign in with browser"),
|
||||
);
|
||||
await act(async () => signIn?.click());
|
||||
expect(onOAuthLogin).toHaveBeenCalledOnce();
|
||||
|
||||
const manualKey = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("Use an API key instead"),
|
||||
);
|
||||
await act(async () => manualKey?.click());
|
||||
expect(container.querySelector('input[type="password"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("offers sign out when OAuth is connected", async () => {
|
||||
const onDisconnect = vi.fn();
|
||||
await act(async () => {
|
||||
if (!groqProviderSelect) return;
|
||||
groqProviderSelect.value = "";
|
||||
groqProviderSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
root.render(
|
||||
<ProviderDetailContent
|
||||
onBack={vi.fn()}
|
||||
onDisconnect={onDisconnect}
|
||||
onUpdate={vi.fn()}
|
||||
provider={{
|
||||
...signInProvider,
|
||||
enabled: true,
|
||||
oauthAccessTokenPresent: true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
expect(onVoiceInputChange).toHaveBeenLastCalledWith(undefined);
|
||||
|
||||
expect(container.textContent).toContain("Signed in via browser");
|
||||
expect(container.textContent).toContain("Configured");
|
||||
const signOut = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Sign out",
|
||||
);
|
||||
await act(async () => signOut?.click());
|
||||
expect(onDisconnect).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("offers connect and disconnect for API-key providers", async () => {
|
||||
const onConnect = vi.fn();
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ProviderDetailContent
|
||||
onBack={vi.fn()}
|
||||
onConnect={onConnect}
|
||||
onUpdate={vi.fn()}
|
||||
provider={{ ...provider, enabled: false }}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const connect = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Connect",
|
||||
);
|
||||
await act(async () => connect?.click());
|
||||
expect(onConnect).toHaveBeenCalledOnce();
|
||||
|
||||
const onDisconnect = vi.fn();
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ProviderDetailContent
|
||||
onBack={vi.fn()}
|
||||
onDisconnect={onDisconnect}
|
||||
onUpdate={vi.fn()}
|
||||
provider={{ ...provider, apiKey: "sk-test" }}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const disconnect = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Disconnect",
|
||||
);
|
||||
await act(async () => disconnect?.click());
|
||||
expect(onDisconnect).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+563
-377
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,185 @@
|
||||
import { BookOpen, Bug, Newspaper, ShieldAlert } from "lucide-react";
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
export interface RoutineTemplate {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
name: string;
|
||||
prompt: string;
|
||||
scheduleType: "daily" | "weekly";
|
||||
scheduleDays: string[];
|
||||
scheduleHour: string;
|
||||
scheduleMinute: string;
|
||||
}
|
||||
|
||||
const FIND_CRITICAL_BUGS_PROMPT = `You are an automated bug hunter that runs on a schedule. Dig through recent changes in this repository and catch severe correctness bugs before users hit them.
|
||||
|
||||
## Scope
|
||||
|
||||
Review the commits landed since your last run (roughly the past day). Only chase problems with serious consequences: data loss or corruption, crashes in critical paths, security regressions, races that drop writes, unbounded loops, resource leaks, and silent truncation of user data.
|
||||
|
||||
Explicitly skip style nits, theoretical edge cases with no realistic trigger, and minor issues that would merely degrade UX.
|
||||
|
||||
## How to investigate
|
||||
|
||||
- Read beyond the diff. Follow the caller chain and downstream consumers so you understand the real blast radius of each change instead of pattern-matching on the patch.
|
||||
- For every suspect, construct the concrete sequence of events that makes it misbehave. If you cannot describe a plausible trigger, drop the finding.
|
||||
|
||||
## When you find something
|
||||
|
||||
- Apply the smallest fix you are confident in, and add or update a test to lock in the behavior when practical. No drive-by refactors in the same change.
|
||||
- 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.
|
||||
|
||||
## Final report
|
||||
|
||||
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.
|
||||
|
||||
## Where to look
|
||||
|
||||
- Authentication, session handling, and permission checks
|
||||
- Request handlers, RPC endpoints, webhook receivers, and other entry points
|
||||
- Raw SQL, shell execution, file-system access, and template rendering
|
||||
- Deserialization and parsing of untrusted input
|
||||
- Secrets handling and anything that logs sensitive values
|
||||
|
||||
## Validation bar
|
||||
|
||||
A finding only counts if you can walk the entire chain: who the attacker is, what input they control, how that input reaches the vulnerable code, and what impact they gain. Trace the code to confirm every step. Skip lint-level "unsafe API" observations that lack a real route in, and skip best-practice notes without concrete impact.
|
||||
|
||||
## Avoiding repeats
|
||||
|
||||
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.
|
||||
|
||||
## Final report
|
||||
|
||||
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.
|
||||
|
||||
## Task
|
||||
|
||||
Review everything that landed in the last 24 hours (commits and merged PRs) and distill it into a brief a teammate could read in under a minute.
|
||||
|
||||
## Cover
|
||||
|
||||
- Changes that matter: new behavior, user-facing impact, and notable bug fixes
|
||||
- Risky territory: large diffs, sensitive subsystems touched, migrations, dependency or security updates
|
||||
- Loose ends: missing tests, TODOs introduced, rollout risks, likely follow-ups
|
||||
|
||||
## Style
|
||||
|
||||
- Group related changes into themes instead of listing every commit.
|
||||
- Tie every claim to a concrete commit or PR; never guess at intent or invent details.
|
||||
- Prioritize signal over completeness, and keep the whole digest easy to skim. On quiet days a two-line digest is fine.
|
||||
|
||||
## Format
|
||||
|
||||
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.
|
||||
|
||||
## Task
|
||||
|
||||
Compare recent code changes against the existing documentation and close the gaps.
|
||||
|
||||
## Priorities
|
||||
|
||||
- Docs that recent changes made stale or wrong. Fix these first.
|
||||
- Recently touched subsystems with thin or missing coverage.
|
||||
- Public interfaces, developer setup and troubleshooting guides, and operational runbooks.
|
||||
|
||||
## Standards
|
||||
|
||||
- Verify every statement against the source code; never document behavior you have not confirmed.
|
||||
- Prefer updating existing pages over creating redundant new ones.
|
||||
- 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.
|
||||
|
||||
## Final report
|
||||
|
||||
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[] = [
|
||||
{
|
||||
id: "find-critical-bugs",
|
||||
title: "Find critical bugs",
|
||||
description:
|
||||
"Sweep recent commits for high-severity bugs that slipped past review, and fix the ones with a concrete trigger.",
|
||||
icon: Bug,
|
||||
name: "Find critical bugs",
|
||||
prompt: FIND_CRITICAL_BUGS_PROMPT,
|
||||
scheduleType: "daily",
|
||||
scheduleDays: ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"],
|
||||
scheduleHour: "9",
|
||||
scheduleMinute: "0",
|
||||
},
|
||||
{
|
||||
id: "security-scan",
|
||||
title: "Scan for vulnerabilities",
|
||||
description:
|
||||
"Audit the codebase for exploitable security issues with a validated end-to-end attack path.",
|
||||
icon: ShieldAlert,
|
||||
name: "Security scan",
|
||||
prompt: SECURITY_SCAN_PROMPT,
|
||||
scheduleType: "weekly",
|
||||
scheduleDays: ["MON"],
|
||||
scheduleHour: "8",
|
||||
scheduleMinute: "0",
|
||||
},
|
||||
{
|
||||
id: "daily-digest",
|
||||
title: "Summarize changes daily",
|
||||
description:
|
||||
"Get a skimmable digest of everything that landed in the last 24 hours, plus risks worth watching.",
|
||||
icon: Newspaper,
|
||||
name: "Daily change digest",
|
||||
prompt: DAILY_DIGEST_PROMPT,
|
||||
scheduleType: "daily",
|
||||
scheduleDays: ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"],
|
||||
scheduleHour: "17",
|
||||
scheduleMinute: "0",
|
||||
},
|
||||
{
|
||||
id: "update-docs",
|
||||
title: "Keep docs updated",
|
||||
description:
|
||||
"Refresh documentation whenever the code drifts away from it, verified against the source.",
|
||||
icon: BookOpen,
|
||||
name: "Update docs",
|
||||
prompt: UPDATE_DOCS_PROMPT,
|
||||
scheduleType: "weekly",
|
||||
scheduleDays: ["FRI"],
|
||||
scheduleHour: "10",
|
||||
scheduleMinute: "0",
|
||||
},
|
||||
];
|
||||
@@ -7,10 +7,10 @@ import {
|
||||
} from "@cline/shared/browser";
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Circle,
|
||||
Clock3,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
Pause,
|
||||
Pencil,
|
||||
Play,
|
||||
@@ -64,7 +64,6 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -86,6 +85,7 @@ import {
|
||||
PageFrame,
|
||||
PageHeader,
|
||||
} from "../page-layout";
|
||||
import { ROUTINE_TEMPLATES, type RoutineTemplate } from "./routine-templates";
|
||||
|
||||
type DateTimeValue = number | string;
|
||||
|
||||
@@ -527,6 +527,15 @@ export function RoutineSchedulesContent({
|
||||
// rejected synchronously — two rapid clicks can both fire before React
|
||||
// re-renders the disabled state, and state alone can't distinguish them.
|
||||
const busyScheduleIdsRef = useRef<Set<string>>(new Set());
|
||||
// Guards the run-now follow-up: an auto-navigation into the started
|
||||
// session should not fire from a page the user already left.
|
||||
const mountedRef = useRef(true);
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
const beginScheduleAction = (scheduleId: string): boolean => {
|
||||
if (busyScheduleIdsRef.current.has(scheduleId)) {
|
||||
return false;
|
||||
@@ -550,6 +559,7 @@ export function RoutineSchedulesContent({
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const [showAllViewingRuns, setShowAllViewingRuns] = useState(false);
|
||||
const [viewingSchedule, setViewingSchedule] =
|
||||
useState<RoutineSchedule | null>(null);
|
||||
const [schedulePendingDelete, setSchedulePendingDelete] =
|
||||
@@ -773,6 +783,7 @@ export function RoutineSchedulesContent({
|
||||
lastExecutions,
|
||||
fetchedAt: now,
|
||||
};
|
||||
return response;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setErrorMessage(message);
|
||||
@@ -824,17 +835,62 @@ export function RoutineSchedulesContent({
|
||||
setScheduleTriggering(schedule.scheduleId, true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
await desktopClient.invoke("trigger_routine_schedule", {
|
||||
const reply = await desktopClient.invoke<{
|
||||
execution?: RoutineExecution | null;
|
||||
}>("trigger_routine_schedule", {
|
||||
schedule_id: schedule.scheduleId,
|
||||
});
|
||||
// A reply without an execution means no run was enqueued (the
|
||||
// schedule may have been disabled or deleted since the page
|
||||
// loaded) — say so instead of confirming a start.
|
||||
if (!reply?.execution) {
|
||||
toast({
|
||||
title: "Run not started",
|
||||
description: `"${schedule.name}" did not queue a run — the schedule may be disabled or deleted.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
await refreshSchedules({ force: true, showLoading: false });
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: "Run started",
|
||||
description: `"${schedule.name}" was queued to run now.`,
|
||||
});
|
||||
await refreshSchedules({ force: true, showLoading: false });
|
||||
window.setTimeout(() => {
|
||||
void refreshSchedules({ force: true, showLoading: false });
|
||||
}, 1_000);
|
||||
// The trigger queues the run and returns before the runner starts
|
||||
// the agent session, so the session id usually is not attached
|
||||
// yet. Poll the overview (which also keeps the page's run status
|
||||
// fresh) until it appears, then jump into the session.
|
||||
// Only ever follow the execution the trigger itself named; matching
|
||||
// "the schedule's newest execution" could open a previous run's
|
||||
// session when the trigger failed to enqueue one.
|
||||
const executionId = reply.execution.executionId ?? null;
|
||||
let sessionId = reply.execution.sessionId?.trim() || null;
|
||||
const deadline = Date.now() + 15_000;
|
||||
while (
|
||||
!sessionId &&
|
||||
executionId &&
|
||||
mountedRef.current &&
|
||||
Date.now() < deadline
|
||||
) {
|
||||
const overview = await refreshSchedules({
|
||||
force: true,
|
||||
showLoading: false,
|
||||
});
|
||||
const executions = [
|
||||
...(overview?.activeExecutions ?? []),
|
||||
...(overview?.lastExecutions ?? []),
|
||||
];
|
||||
const match = executions.find(
|
||||
(execution) => execution.executionId === executionId,
|
||||
);
|
||||
sessionId = match?.sessionId?.trim() || null;
|
||||
if (!sessionId) {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 1_000));
|
||||
}
|
||||
}
|
||||
if (sessionId && mountedRef.current) {
|
||||
await onOpenSession?.(sessionId);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setErrorMessage(message);
|
||||
@@ -868,7 +924,7 @@ export function RoutineSchedulesContent({
|
||||
}
|
||||
};
|
||||
|
||||
const openCreateDialog = async () => {
|
||||
const openCreateDialog = async (template?: RoutineTemplate) => {
|
||||
setEditingSchedule(null);
|
||||
setErrorMessage(null);
|
||||
setCreateFormError(null);
|
||||
@@ -893,13 +949,20 @@ export function RoutineSchedulesContent({
|
||||
? rememberedModel
|
||||
: (modelsForProvider[0] ?? createForm.model);
|
||||
setCreateForm({
|
||||
name: "",
|
||||
scheduleType: "once",
|
||||
name: template?.name ?? "",
|
||||
scheduleType: template?.scheduleType ?? "once",
|
||||
scheduleDate: defaultScheduleDate(),
|
||||
scheduleHour: "9",
|
||||
scheduleMinute: "0",
|
||||
scheduleDays: ["MON", "TUE", "WED", "THU", "FRI"],
|
||||
prompt: "Review PRs opened yesterday and summarize issues.",
|
||||
scheduleHour: template?.scheduleHour ?? "9",
|
||||
scheduleMinute: template?.scheduleMinute ?? "0",
|
||||
scheduleDays: template?.scheduleDays ?? [
|
||||
"MON",
|
||||
"TUE",
|
||||
"WED",
|
||||
"THU",
|
||||
"FRI",
|
||||
],
|
||||
prompt:
|
||||
template?.prompt ?? "Review PRs opened yesterday and summarize issues.",
|
||||
provider: preferredProvider,
|
||||
model: preferredModel,
|
||||
workspaceRoot: context.workspaceRoot || context.cwd,
|
||||
@@ -1075,6 +1138,23 @@ export function RoutineSchedulesContent({
|
||||
[schedules],
|
||||
);
|
||||
|
||||
// Hide suggestions the user has already created (matched by schedule name).
|
||||
const visibleTemplates = useMemo(() => {
|
||||
const existingNames = new Set(
|
||||
schedules.map((schedule) => schedule.name.trim().toLowerCase()),
|
||||
);
|
||||
return ROUTINE_TEMPLATES.filter(
|
||||
(template) => !existingNames.has(template.name.trim().toLowerCase()),
|
||||
);
|
||||
}, [schedules]);
|
||||
|
||||
// Collapse the runs list back to the recent-three preview whenever a
|
||||
// different schedule's details are opened.
|
||||
const viewingScheduleId = viewingSchedule?.scheduleId ?? null;
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: viewingScheduleId is the reset trigger, not a value the effect reads
|
||||
useEffect(() => {
|
||||
setShowAllViewingRuns(false);
|
||||
}, [viewingScheduleId]);
|
||||
const viewingExecutions = useMemo(() => {
|
||||
if (!viewingSchedule) {
|
||||
return [];
|
||||
@@ -1097,8 +1177,8 @@ export function RoutineSchedulesContent({
|
||||
return (
|
||||
<PageFrame>
|
||||
<PageHeader
|
||||
description="Scheduled jobs are run through the hub."
|
||||
title="Schedules"
|
||||
description="Run agents on cron schedules for recurring automations like daily summaries and code reviews."
|
||||
title="Schedule"
|
||||
meta={<CommandBadge>cline schedule</CommandBadge>}
|
||||
actions={
|
||||
<>
|
||||
@@ -1130,8 +1210,8 @@ export function RoutineSchedulesContent({
|
||||
<PageEmptyState>Loading schedules...</PageEmptyState>
|
||||
) : sortedSchedules.length === 0 ? (
|
||||
<PageEmptyState>
|
||||
No schedules found. Create a schedule to run routines on a recurring
|
||||
basis.
|
||||
No schedules yet. Start from a suggestion below, or create your own
|
||||
with New Schedule.
|
||||
</PageEmptyState>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -1146,10 +1226,35 @@ export function RoutineSchedulesContent({
|
||||
const upcoming = upcomingRuns.find(
|
||||
(item) => item.scheduleId === schedule.scheduleId,
|
||||
);
|
||||
// The whole card opens the details dialog; clicks on the row's
|
||||
// own controls (all button elements, including the Radix
|
||||
// switch) are excluded via closest().
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: The card contains nested action buttons and a switch, so the wrapper cannot be a native button.
|
||||
<div
|
||||
key={schedule.scheduleId}
|
||||
className="rounded-lg border border-border px-5 py-4 transition-colors hover:bg-surface-hover-lighter"
|
||||
className="cursor-pointer rounded-lg border border-border px-5 py-4 transition-colors hover:bg-surface-hover-lighter"
|
||||
onClick={(event) => {
|
||||
if (
|
||||
(event.target as HTMLElement).closest(
|
||||
"button,a,input,textarea,[role='menuitem']",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setViewingSchedule(schedule);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target !== event.currentTarget) {
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setViewingSchedule(schedule);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Circle
|
||||
@@ -1175,25 +1280,13 @@ export function RoutineSchedulesContent({
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`View ${schedule.name}`}
|
||||
onClick={() => setViewingSchedule(schedule)}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>View details</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
aria-label={`Edit ${schedule.name}`}
|
||||
onClick={() => openEditDialog(schedule)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit schedule</TooltipContent>
|
||||
@@ -1202,15 +1295,16 @@ export function RoutineSchedulesContent({
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
aria-label={`Run ${schedule.name} now`}
|
||||
onClick={() => void triggerSchedule(schedule)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{triggeringScheduleIds.has(schedule.scheduleId) ? (
|
||||
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
|
||||
<RefreshCw className="size-4 animate-spin" />
|
||||
) : (
|
||||
<PlayIcon className="h-3.5 w-3.5" />
|
||||
<PlayIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
@@ -1220,7 +1314,8 @@ export function RoutineSchedulesContent({
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
aria-label={
|
||||
schedule.enabled
|
||||
? `Pause ${schedule.name}`
|
||||
@@ -1235,9 +1330,9 @@ export function RoutineSchedulesContent({
|
||||
disabled={isBusy}
|
||||
>
|
||||
{schedule.enabled ? (
|
||||
<Pause className="h-3.5 w-3.5" />
|
||||
<Pause className="size-4" />
|
||||
) : (
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
<Play className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
@@ -1251,12 +1346,13 @@ export function RoutineSchedulesContent({
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
aria-label={`Delete ${schedule.name}`}
|
||||
onClick={() => setSchedulePendingDelete(schedule)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete schedule</TooltipContent>
|
||||
@@ -1343,6 +1439,45 @@ export function RoutineSchedulesContent({
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && visibleTemplates.length > 0 && (
|
||||
<section className="mt-10">
|
||||
<h2 className="text-xs font-medium tracking-wider text-muted-foreground uppercase">
|
||||
Suggested
|
||||
</h2>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
{visibleTemplates.map((template) => {
|
||||
const Icon = template.icon;
|
||||
return (
|
||||
<button
|
||||
className="group flex items-start gap-3 rounded-lg border border-border bg-card p-4 text-left transition-colors hover:bg-surface-hover-lighter"
|
||||
key={template.id}
|
||||
onClick={() => void openCreateDialog(template)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-md border border-border bg-background text-muted-foreground transition-colors group-hover:text-primary">
|
||||
<Icon className="size-4.5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="truncate text-sm font-semibold text-foreground">
|
||||
{template.title}
|
||||
</h3>
|
||||
<span className="shrink-0 rounded-md border border-border px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
{template.scheduleType === "daily" ? "Daily" : "Weekly"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
{template.description}
|
||||
</p>
|
||||
</div>
|
||||
<Plus className="mt-0.5 size-4 shrink-0 text-muted-foreground/60 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<Dialog
|
||||
open={Boolean(viewingSchedule)}
|
||||
onOpenChange={(open) => {
|
||||
@@ -1351,124 +1486,121 @@ export function RoutineSchedulesContent({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogContent
|
||||
aria-describedby={undefined}
|
||||
className="flex max-h-[85vh] flex-col sm:max-w-2xl"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{viewingSchedule?.name ?? "Schedule"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Full configuration for this schedule.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{viewingSchedule && (
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="runs">
|
||||
Runs
|
||||
{viewingExecutions.length > 0 && (
|
||||
<span className="ml-1 text-xs text-muted-foreground">
|
||||
{viewingExecutions.length}
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent
|
||||
className="mt-4 flex flex-col gap-3"
|
||||
value="overview"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-1.5 text-xs sm:grid-cols-2">
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Schedule:</span>{" "}
|
||||
{formatScheduleTrigger(viewingSchedule)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Mode:</span>{" "}
|
||||
{viewingSchedule.mode}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Model:</span>{" "}
|
||||
{formatScheduleModel(viewingSchedule)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Enabled:</span>{" "}
|
||||
{viewingSchedule.enabled ? "yes" : "no"}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Last run:</span>{" "}
|
||||
{formatDateTime(viewingSchedule.lastRunAt)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Next run:</span>{" "}
|
||||
{formatDateTime(viewingSchedule.nextRunAt)}
|
||||
</p>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto">
|
||||
<div className="grid grid-cols-1 gap-1.5 text-xs sm:grid-cols-2">
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Schedule:</span>{" "}
|
||||
{formatScheduleTrigger(viewingSchedule)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Mode:</span>{" "}
|
||||
{viewingSchedule.mode}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Model:</span>{" "}
|
||||
{formatScheduleModel(viewingSchedule)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Enabled:</span>{" "}
|
||||
{viewingSchedule.enabled ? "yes" : "no"}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Last run:</span>{" "}
|
||||
{formatDateTime(viewingSchedule.lastRunAt)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Next run:</span>{" "}
|
||||
{formatDateTime(viewingSchedule.nextRunAt)}
|
||||
</p>
|
||||
</div>
|
||||
{/* The JSON block scrolls internally past its cap so it
|
||||
cannot push the runs below it out of easy reach. */}
|
||||
<pre className="max-h-64 shrink-0 overflow-auto rounded-md border border-border bg-muted/30 p-3 text-xs">
|
||||
{JSON.stringify(viewingSchedule, null, 2)}
|
||||
</pre>
|
||||
<div className="mt-1 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">Runs</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{viewingExecutions.length} result
|
||||
{viewingExecutions.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
{viewingExecutions.length === 0 ? (
|
||||
<div className="rounded-lg border border-border px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
No runs yet.
|
||||
</div>
|
||||
<pre className="max-h-80 overflow-auto rounded-md border border-border bg-muted/30 p-3 text-xs">
|
||||
{JSON.stringify(viewingSchedule, null, 2)}
|
||||
</pre>
|
||||
</TabsContent>
|
||||
<TabsContent className="mt-4" value="runs">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">Runs</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{viewingExecutions.length} result
|
||||
{viewingExecutions.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
{viewingExecutions.length === 0 ? (
|
||||
<div className="rounded-lg border border-border px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
No runs yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
{viewingExecutions.map((execution) => {
|
||||
const status = execution.status?.toLowerCase() ?? "";
|
||||
const succeeded = ["success", "completed"].includes(
|
||||
status,
|
||||
);
|
||||
const failed = ["failed", "timeout", "aborted"].includes(
|
||||
status,
|
||||
);
|
||||
return (
|
||||
<button
|
||||
className="group flex w-full items-center gap-3 border-b border-border px-3 py-3 text-left text-sm transition-colors last:border-b-0 hover:bg-surface-hover disabled:cursor-default disabled:hover:bg-transparent"
|
||||
disabled={!execution.sessionId || !onOpenSession}
|
||||
key={execution.executionId}
|
||||
onClick={() => {
|
||||
if (execution.sessionId) {
|
||||
void onOpenSession?.(execution.sessionId);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{succeeded ? (
|
||||
<CheckCircle2 className="size-4 shrink-0 text-emerald-500" />
|
||||
) : failed ? (
|
||||
<XCircle className="size-4 shrink-0 text-destructive" />
|
||||
) : (
|
||||
<Clock3 className="size-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium capitalize">
|
||||
{execution.status || "Unknown result"}
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
{(showAllViewingRuns
|
||||
? viewingExecutions
|
||||
: viewingExecutions.slice(0, 3)
|
||||
).map((execution) => {
|
||||
const status = execution.status?.toLowerCase() ?? "";
|
||||
const succeeded = ["success", "completed"].includes(status);
|
||||
const failed = ["failed", "timeout", "aborted"].includes(
|
||||
status,
|
||||
);
|
||||
return (
|
||||
<button
|
||||
className="group flex w-full items-center gap-3 border-b border-border px-3 py-3 text-left text-sm transition-colors last:border-b-0 hover:bg-surface-hover disabled:cursor-default disabled:hover:bg-transparent"
|
||||
disabled={!execution.sessionId || !onOpenSession}
|
||||
key={execution.executionId}
|
||||
onClick={() => {
|
||||
if (execution.sessionId) {
|
||||
void onOpenSession?.(execution.sessionId);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{succeeded ? (
|
||||
<CheckCircle2 className="size-4 shrink-0 text-emerald-500" />
|
||||
) : failed ? (
|
||||
<XCircle className="size-4 shrink-0 text-destructive" />
|
||||
) : (
|
||||
<Clock3 className="size-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium capitalize">
|
||||
{execution.status || "Unknown result"}
|
||||
</span>
|
||||
{execution.errorMessage && (
|
||||
<span className="block truncate text-xs text-destructive">
|
||||
{execution.errorMessage}
|
||||
</span>
|
||||
{execution.errorMessage && (
|
||||
<span className="block truncate text-xs text-destructive">
|
||||
{execution.errorMessage}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{formatExecutionTimestamp(execution)}
|
||||
</span>
|
||||
{execution.sessionId && onOpenSession && (
|
||||
<ExternalLink className="size-3.5 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{formatExecutionTimestamp(execution)}
|
||||
</span>
|
||||
{execution.sessionId && onOpenSession && (
|
||||
<ExternalLink className="size-3.5 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!showAllViewingRuns && viewingExecutions.length > 3 ? (
|
||||
<Button
|
||||
className="self-start text-muted-foreground"
|
||||
onClick={() => setShowAllViewingRuns(true)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Show all {viewingExecutions.length} runs
|
||||
<ChevronDown className="size-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -1536,7 +1668,7 @@ export function RoutineSchedulesContent({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<div className="sm:col-span-2 space-y-2">
|
||||
<Label htmlFor="routine-name">Name</Label>
|
||||
<Input
|
||||
id="routine-name"
|
||||
@@ -1554,7 +1686,7 @@ export function RoutineSchedulesContent({
|
||||
<div className="sm:col-span-2 space-y-3">
|
||||
<Label>Schedule</Label>
|
||||
<div className="flex flex-wrap items-end gap-3 rounded-xl border border-border p-3">
|
||||
<div className="min-w-32 flex-1">
|
||||
<div className="min-w-32 flex-1 space-y-2">
|
||||
<Label htmlFor="routine-schedule-type">Frequency</Label>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
@@ -1582,7 +1714,7 @@ export function RoutineSchedulesContent({
|
||||
</Select>
|
||||
</div>
|
||||
{createForm.scheduleType === "once" && (
|
||||
<div className="min-w-40 flex-1">
|
||||
<div className="min-w-40 flex-1 space-y-2">
|
||||
<Label htmlFor="routine-date">Date</Label>
|
||||
<Input
|
||||
id="routine-date"
|
||||
@@ -1618,7 +1750,7 @@ export function RoutineSchedulesContent({
|
||||
</div>
|
||||
)}
|
||||
{createForm.scheduleType === "weekly" && (
|
||||
<div className="min-w-44 flex-[1.4]">
|
||||
<div className="min-w-44 flex-[1.4] space-y-2">
|
||||
<Label>Days</Label>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -1661,7 +1793,7 @@ export function RoutineSchedulesContent({
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-32 flex-1">
|
||||
<div className="min-w-32 flex-1 space-y-2">
|
||||
<Label htmlFor="routine-time">Time</Label>
|
||||
<Input
|
||||
id="routine-time"
|
||||
@@ -1694,7 +1826,7 @@ export function RoutineSchedulesContent({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<div className="sm:col-span-2 space-y-2">
|
||||
<Label htmlFor="routine-prompt">Prompt</Label>
|
||||
<Textarea
|
||||
id="routine-prompt"
|
||||
@@ -1709,7 +1841,7 @@ export function RoutineSchedulesContent({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<Label>Provider</Label>
|
||||
<Combobox
|
||||
items={availableProviders}
|
||||
@@ -1753,7 +1885,7 @@ export function RoutineSchedulesContent({
|
||||
</Combobox>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<Label>Model</Label>
|
||||
<Combobox
|
||||
items={availableModelsForProvider}
|
||||
@@ -1784,7 +1916,7 @@ export function RoutineSchedulesContent({
|
||||
</Combobox>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<div className="sm:col-span-2 space-y-2">
|
||||
<Label htmlFor="routine-workspace">Workspace</Label>
|
||||
<Input
|
||||
id="routine-workspace"
|
||||
@@ -1798,7 +1930,7 @@ export function RoutineSchedulesContent({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<div className="sm:col-span-2 space-y-2">
|
||||
<Label htmlFor="routine-system-prompt">
|
||||
System prompt (optional)
|
||||
</Label>
|
||||
@@ -1815,7 +1947,7 @@ export function RoutineSchedulesContent({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="routine-timeout">
|
||||
Timeout seconds (optional)
|
||||
</Label>
|
||||
@@ -1832,7 +1964,7 @@ export function RoutineSchedulesContent({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="routine-tags">
|
||||
Tags (comma-separated, optional)
|
||||
</Label>
|
||||
|
||||
@@ -5,26 +5,43 @@
|
||||
* initial chat bundle. The heavy views load on demand via next/dynamic.
|
||||
*/
|
||||
|
||||
export const SETTINGS_SECTIONS = [
|
||||
const ALL_SETTINGS_SECTIONS = [
|
||||
"General",
|
||||
"Models",
|
||||
"Voice",
|
||||
"Channels",
|
||||
"Schedules",
|
||||
"Account",
|
||||
] as const;
|
||||
|
||||
// Mirrors the Cline Hub dashboard's Customizations nav group. Plugins is the
|
||||
// unified hub for installed plugins, MCP servers, and skills; Marketplace is
|
||||
// the full catalog page for installing more.
|
||||
export const CUSTOMIZATION_SECTIONS = [
|
||||
"Plugins",
|
||||
"Marketplace",
|
||||
"Hooks",
|
||||
"Rules",
|
||||
"Agents",
|
||||
"Tools",
|
||||
] as const;
|
||||
// Customize is the unified hub for everything that extends Cline — skills,
|
||||
// MCP servers, plugins, rules, hooks, and tools. "Customize" is the installed
|
||||
// inventory (labeled "Installed" in the sidebar group); "Marketplace" is the
|
||||
// dedicated browse-and-install directory.
|
||||
const ALL_CUSTOMIZATION_SECTIONS = ["Customize", "Marketplace"] as const;
|
||||
|
||||
// Sidebar labels for the Customize group: the Customize section shows what is
|
||||
// installed, so its row reads "Installed" next to the Marketplace row.
|
||||
export const CUSTOMIZATION_SECTION_LABELS: Record<
|
||||
(typeof ALL_CUSTOMIZATION_SECTIONS)[number],
|
||||
string
|
||||
> = {
|
||||
Customize: "Installed",
|
||||
Marketplace: "Marketplace",
|
||||
};
|
||||
|
||||
export type SettingsSection =
|
||||
| (typeof SETTINGS_SECTIONS)[number]
|
||||
| (typeof CUSTOMIZATION_SECTIONS)[number];
|
||||
| (typeof ALL_SETTINGS_SECTIONS)[number]
|
||||
| (typeof ALL_CUSTOMIZATION_SECTIONS)[number];
|
||||
|
||||
// Temporarily hidden from the sidebar. The views and routes still exist —
|
||||
// remove a section from this set to surface it again.
|
||||
const HIDDEN_SECTIONS: ReadonlySet<SettingsSection> = new Set(["Channels"]);
|
||||
|
||||
export const SETTINGS_SECTIONS = ALL_SETTINGS_SECTIONS.filter(
|
||||
(section) => !HIDDEN_SECTIONS.has(section),
|
||||
);
|
||||
|
||||
export const CUSTOMIZATION_SECTIONS = ALL_CUSTOMIZATION_SECTIONS.filter(
|
||||
(section) => !HIDDEN_SECTIONS.has(section),
|
||||
);
|
||||
|
||||
@@ -3,6 +3,13 @@ import { Minus, Plus, RotateCcw } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { isBetaVersion, productNameForVersion } from "@/lib/app-channel";
|
||||
@@ -25,6 +32,10 @@ import {
|
||||
} from "@/lib/app-icon";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import { resetOnboarding } from "@/lib/onboarding";
|
||||
import {
|
||||
getProviderAuthKind,
|
||||
isProviderConnected,
|
||||
} from "@/lib/provider-connection";
|
||||
import {
|
||||
fetchProviderCatalog,
|
||||
invalidateProviderCatalogCache,
|
||||
@@ -37,7 +48,6 @@ import type {
|
||||
ProviderCatalogResponse,
|
||||
ProviderModelsResponse,
|
||||
ProviderSettingsUpdate,
|
||||
VoiceInputSelection,
|
||||
} from "@/lib/provider-schema";
|
||||
import {
|
||||
type HubAccent,
|
||||
@@ -54,12 +64,8 @@ import { PageFrame, PageHeader } from "../page-layout";
|
||||
import { AccountView } from "./account-view";
|
||||
import { AddProviderContent, type AddProviderPayload } from "./add-provider";
|
||||
import { ChannelsContent } from "./channels-view";
|
||||
import {
|
||||
CustomizationSectionView,
|
||||
invalidateExtensionInventoryCache,
|
||||
} from "./extensions-view";
|
||||
import { CustomizeView } from "./customize-view";
|
||||
import { NotificationSettings } from "./notification-settings";
|
||||
import { PluginsHubView } from "./plugins-hub-view";
|
||||
import {
|
||||
ProviderDetailContent,
|
||||
ProviderListContent,
|
||||
@@ -67,6 +73,7 @@ import {
|
||||
import { RoutineSchedulesContent } from "./routine-view";
|
||||
import type { SettingsSection } from "./sections";
|
||||
import { toSettingsPatch } from "./settings-patch";
|
||||
import { VoiceInputContent } from "./voice-input-view";
|
||||
|
||||
// Nav categories live in ./sections so the always-mounted sidebar can import
|
||||
// them without pulling this module graph into the initial bundle.
|
||||
@@ -88,7 +95,6 @@ let providerCatalogCache: {
|
||||
providers: Provider[];
|
||||
fetchedAt: number;
|
||||
} | null = null;
|
||||
let voiceInputCache: VoiceInputSelection | undefined;
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// Component
|
||||
@@ -126,10 +132,14 @@ export function SettingsView({
|
||||
null,
|
||||
);
|
||||
const [addingProvider, setAddingProvider] = useState(false);
|
||||
const [voiceInput, setVoiceInput] = useState<VoiceInputSelection | undefined>(
|
||||
() => voiceInputCache,
|
||||
);
|
||||
const [voiceInputSaving, setVoiceInputSaving] = useState(false);
|
||||
// Bumped by every optimistic provider mutation and catalog load. An
|
||||
// in-flight catalog response is discarded when the generation moved on,
|
||||
// so an older disk snapshot can never overwrite a newer edit.
|
||||
const catalogGenerationRef = useRef(0);
|
||||
// Bumped when a failed save resyncs the catalog from disk; keys the
|
||||
// detail panel so its local field drafts remount from the reloaded
|
||||
// props instead of keeping unpersisted values.
|
||||
const [detailResetToken, setDetailResetToken] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (section !== "Models") {
|
||||
@@ -155,35 +165,46 @@ export function SettingsView({
|
||||
[],
|
||||
);
|
||||
|
||||
const loadProviderCatalog = useCallback(async () => {
|
||||
/**
|
||||
* Loads the catalog into view state. Resolves to false when the response
|
||||
* was discarded because a newer mutation or load superseded it while in
|
||||
* flight (so an older disk snapshot never overwrites a newer edit);
|
||||
* callers needing an authoritative resync should retry on false.
|
||||
*/
|
||||
const loadProviderCatalog = useCallback(async (): Promise<boolean> => {
|
||||
const now = Date.now();
|
||||
if (
|
||||
providerCatalogCache &&
|
||||
now - providerCatalogCache.fetchedAt < PROVIDER_CATALOG_CACHE_TTL_MS
|
||||
) {
|
||||
setProviders(providerCatalogCache.providers);
|
||||
setVoiceInput(voiceInputCache);
|
||||
setProvidersLoading(false);
|
||||
setProviderCatalogError(null);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
const generation = ++catalogGenerationRef.current;
|
||||
setProvidersLoading(true);
|
||||
setProviderCatalogError(null);
|
||||
try {
|
||||
const payload = await desktopClient.invoke<ProviderCatalogResponse>(
|
||||
"list_provider_catalog",
|
||||
);
|
||||
if (generation !== catalogGenerationRef.current) {
|
||||
return false;
|
||||
}
|
||||
setProvidersWithCache(payload.providers);
|
||||
voiceInputCache = payload.voiceInput;
|
||||
setVoiceInput(payload.voiceInput);
|
||||
} catch (error) {
|
||||
if (generation !== catalogGenerationRef.current) {
|
||||
return false;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setProviderCatalogError(message);
|
||||
setProviders([]);
|
||||
} finally {
|
||||
setProvidersLoading(false);
|
||||
}
|
||||
return true;
|
||||
}, [setProvidersWithCache]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -196,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,
|
||||
@@ -216,10 +262,27 @@ 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);
|
||||
window.alert(`Failed to save provider settings for ${id}: ${message}`);
|
||||
// The optimistic list update no longer matches disk: resync from
|
||||
// the authoritative catalog. Retry when a concurrent edit
|
||||
// superseded the in-flight response (that edit performs no
|
||||
// reload of its own), then remount the detail panel so its
|
||||
// field drafts re-seed from the reloaded state — not before,
|
||||
// or they would re-capture the unpersisted optimistic values.
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
providerCatalogCache = null;
|
||||
if (await loadProviderCatalog()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
setDetailResetToken((token) => token + 1);
|
||||
return false;
|
||||
} finally {
|
||||
// Keep the shared short-lived catalog cache (composer model
|
||||
@@ -227,66 +290,63 @@ export function SettingsView({
|
||||
invalidateProviderCatalogCache();
|
||||
}
|
||||
},
|
||||
[],
|
||||
[loadProviderCatalog, resyncProviderCatalog],
|
||||
);
|
||||
|
||||
const toggleProvider = useCallback(
|
||||
const connectProvider = useCallback(
|
||||
(id: string) => {
|
||||
// Persist an (empty) settings entry so the provider is enabled with
|
||||
// whatever credentials it resolves at runtime (env vars, local CLI,
|
||||
// keyless endpoints).
|
||||
catalogGenerationRef.current++;
|
||||
setProvidersWithCache((prev) =>
|
||||
prev.map((p) => {
|
||||
if (p.id !== id) {
|
||||
return p;
|
||||
}
|
||||
const nextEnabled = !p.enabled;
|
||||
const clearsVoiceInput =
|
||||
!nextEnabled && voiceInput?.providerId === id;
|
||||
void persistProviderSettings(id, { enabled: nextEnabled }).then(
|
||||
(saved) => {
|
||||
if (saved && clearsVoiceInput) {
|
||||
voiceInputCache = undefined;
|
||||
setVoiceInput(undefined);
|
||||
notifyVoiceInputSettingsChanged();
|
||||
}
|
||||
},
|
||||
);
|
||||
return { ...p, enabled: nextEnabled };
|
||||
}),
|
||||
prev.map((p) => (p.id === id ? { ...p, enabled: true } : p)),
|
||||
);
|
||||
void persistProviderSettings(id, { enabled: true });
|
||||
},
|
||||
[persistProviderSettings, setProvidersWithCache, voiceInput],
|
||||
[persistProviderSettings, setProvidersWithCache],
|
||||
);
|
||||
|
||||
const updateVoiceInput = useCallback(
|
||||
async (selection: VoiceInputSelection | undefined) => {
|
||||
setVoiceInputSaving(true);
|
||||
try {
|
||||
const result = await desktopClient.invoke<{
|
||||
voiceInput?: VoiceInputSelection;
|
||||
}>("save_voice_input_settings", {
|
||||
provider: selection?.providerId,
|
||||
model: selection?.modelId,
|
||||
});
|
||||
voiceInputCache = result.voiceInput;
|
||||
setVoiceInput(result.voiceInput);
|
||||
const disconnectProvider = useCallback(
|
||||
async (id: string) => {
|
||||
catalogGenerationRef.current++;
|
||||
setProvidersWithCache((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === id
|
||||
? {
|
||||
...p,
|
||||
enabled: false,
|
||||
apiKey: undefined,
|
||||
oauthAccessTokenPresent: false,
|
||||
}
|
||||
: p,
|
||||
),
|
||||
);
|
||||
const saved = await persistProviderSettings(id, { enabled: false });
|
||||
if (saved) {
|
||||
// Disconnecting removes the persisted entry (and the sidecar drops
|
||||
// a voice-input selection pointing at it); reload so the view and
|
||||
// the chat microphone reflect the real on-disk state.
|
||||
providerCatalogCache = null;
|
||||
notifyVoiceInputSettingsChanged();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
window.alert(`Failed to save voice input settings: ${message}`);
|
||||
} finally {
|
||||
setVoiceInputSaving(false);
|
||||
await loadProviderCatalog();
|
||||
}
|
||||
},
|
||||
[],
|
||||
[loadProviderCatalog, persistProviderSettings, setProvidersWithCache],
|
||||
);
|
||||
|
||||
const updateProvider = useCallback(
|
||||
(id: string, updates: ProviderSettingsUpdate) => {
|
||||
// Saving settings creates the provider's persisted entry, which is
|
||||
// what "connected" means for keyless providers — reflect it locally.
|
||||
catalogGenerationRef.current++;
|
||||
setProvidersWithCache((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === id
|
||||
? {
|
||||
...p,
|
||||
...updates,
|
||||
enabled: true,
|
||||
configValues: updates.configValues
|
||||
? {
|
||||
...(p.configValues ?? {}),
|
||||
@@ -359,12 +419,19 @@ export function SettingsView({
|
||||
[loadProviderModels],
|
||||
);
|
||||
|
||||
const selectedProvider = selectedProviderId
|
||||
? (providers.find((p) => p.id === selectedProviderId) ?? null)
|
||||
// The detail panel is always open: with no explicit selection, default to
|
||||
// the first connected provider (the one in use), then the first provider.
|
||||
const effectiveSelectedProviderId =
|
||||
selectedProviderId ??
|
||||
providers.find(isProviderConnected)?.id ??
|
||||
providers[0]?.id ??
|
||||
null;
|
||||
const selectedProvider = effectiveSelectedProviderId
|
||||
? (providers.find((p) => p.id === effectiveSelectedProviderId) ?? null)
|
||||
: null;
|
||||
|
||||
const usesOAuth = (provider: Provider) =>
|
||||
provider.capabilities?.includes("oauth") ?? false;
|
||||
getProviderAuthKind(provider) === "oauth";
|
||||
|
||||
const runOAuthProviderLogin = async (id: string) => {
|
||||
setOauthSigningProviderId(id);
|
||||
@@ -390,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);
|
||||
@@ -405,14 +477,14 @@ export function SettingsView({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProviderId) {
|
||||
if (!effectiveSelectedProviderId) {
|
||||
return;
|
||||
}
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void loadProviderModels(selectedProviderId);
|
||||
void loadProviderModels(effectiveSelectedProviderId);
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [loadProviderModels, selectedProviderId]);
|
||||
}, [loadProviderModels, effectiveSelectedProviderId]);
|
||||
|
||||
const backToProviderList = () => {
|
||||
onNavigateSection("Models");
|
||||
@@ -444,17 +516,36 @@ export function SettingsView({
|
||||
|
||||
const openAddProvider = () => {
|
||||
onNavigateSection("Models");
|
||||
setSelectedProviderId(null);
|
||||
setAddingProvider(true);
|
||||
};
|
||||
|
||||
const providerContent = addingProvider ? (
|
||||
<AddProviderContent
|
||||
existingProviderIds={providers.map((provider) => provider.id)}
|
||||
onBack={backToProviderList}
|
||||
onSave={saveNewProvider}
|
||||
/>
|
||||
) : providersLoading ? (
|
||||
const addProviderDialog = (
|
||||
<Dialog
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setAddingProvider(false);
|
||||
}
|
||||
}}
|
||||
open={addingProvider}
|
||||
>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Provider</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add an OpenAI-compatible provider and choose its available models.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<AddProviderContent
|
||||
existingProviderIds={providers.map((provider) => provider.id)}
|
||||
onBack={() => setAddingProvider(false)}
|
||||
onSave={saveNewProvider}
|
||||
variant="dialog"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
const providerContent = providersLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">Loading providers...</p>
|
||||
</div>
|
||||
@@ -466,23 +557,27 @@ export function SettingsView({
|
||||
</div>
|
||||
) : selectedProvider ? (
|
||||
<div className="grid h-full grid-cols-[minmax(24rem,0.95fr)_minmax(28rem,1.05fr)] overflow-hidden max-[1100px]:grid-cols-1 max-[1100px]:grid-rows-[minmax(24rem,0.9fr)_minmax(26rem,1fr)]">
|
||||
<ProviderListContent
|
||||
onAddProvider={openAddProvider}
|
||||
onConfigure={openProviderDetail}
|
||||
onToggle={toggleProvider}
|
||||
onVoiceInputChange={(selection) => void updateVoiceInput(selection)}
|
||||
providers={providers}
|
||||
selectedProviderId={selectedProvider.id}
|
||||
variant="panel"
|
||||
voiceInput={voiceInput}
|
||||
voiceInputSaving={voiceInputSaving}
|
||||
/>
|
||||
{/* min-h-0/min-w-0: grid items default to min-size auto, which lets
|
||||
the pane grow past its track and leaves the inner ScrollArea with
|
||||
nothing to scroll. */}
|
||||
<div className="min-h-0 min-w-0 overflow-hidden">
|
||||
<ProviderListContent
|
||||
onAddProvider={openAddProvider}
|
||||
onConfigure={openProviderDetail}
|
||||
providers={providers}
|
||||
selectedProviderId={selectedProvider.id}
|
||||
variant="panel"
|
||||
/>
|
||||
</div>
|
||||
<aside className="min-h-0 overflow-hidden border-l bg-background max-[1100px]:border-l-0 max-[1100px]:border-t">
|
||||
<ProviderDetailContent
|
||||
key={`${selectedProvider.id}:${detailResetToken}`}
|
||||
modelsError={modelsErrorByProvider[selectedProvider.id] ?? null}
|
||||
modelsLoading={modelsLoadingByProvider[selectedProvider.id] ?? false}
|
||||
oauthLoginPending={oauthSigningProviderId === selectedProvider.id}
|
||||
onBack={backToProviderList}
|
||||
onConnect={() => connectProvider(selectedProvider.id)}
|
||||
onDisconnect={() => void disconnectProvider(selectedProvider.id)}
|
||||
onLoadModels={() => void loadProviderModels(selectedProvider.id)}
|
||||
onUpdateModels={(models) =>
|
||||
void updateProviderModels(selectedProvider.id, models)
|
||||
@@ -502,34 +597,29 @@ export function SettingsView({
|
||||
<ProviderListContent
|
||||
onAddProvider={openAddProvider}
|
||||
onConfigure={openProviderDetail}
|
||||
onToggle={toggleProvider}
|
||||
onVoiceInputChange={(selection) => void updateVoiceInput(selection)}
|
||||
providers={providers}
|
||||
voiceInput={voiceInput}
|
||||
voiceInputSaving={voiceInputSaving}
|
||||
/>
|
||||
);
|
||||
|
||||
const content =
|
||||
activeNav === "Models" ? (
|
||||
providerContent
|
||||
) : activeNav === "Plugins" ? (
|
||||
<PluginsHubView
|
||||
<>
|
||||
{providerContent}
|
||||
{addProviderDialog}
|
||||
</>
|
||||
) : activeNav === "Voice" ? (
|
||||
<VoiceInputContent
|
||||
onOpenModelProviders={() => onNavigateSection("Models")}
|
||||
/>
|
||||
) : activeNav === "Customize" ? (
|
||||
<CustomizeView
|
||||
onOpenMarketplace={() => onNavigateSection("Marketplace")}
|
||||
/>
|
||||
) : activeNav === "Marketplace" ? (
|
||||
<MarketplaceView
|
||||
onInstalledItemsChanged={invalidateExtensionInventoryCache}
|
||||
onOpenInstalled={() => onNavigateSection("Customize")}
|
||||
variant="directory"
|
||||
/>
|
||||
) : activeNav === "Hooks" ? (
|
||||
<CustomizationSectionView section="Hooks" />
|
||||
) : activeNav === "Rules" ? (
|
||||
<CustomizationSectionView section="Rules" />
|
||||
) : activeNav === "Agents" ? (
|
||||
<CustomizationSectionView section="Agents" />
|
||||
) : activeNav === "Tools" ? (
|
||||
<CustomizationSectionView section="Tools" />
|
||||
) : activeNav === "Channels" ? (
|
||||
<ChannelsContent />
|
||||
) : activeNav === "Schedules" ? (
|
||||
@@ -549,9 +639,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>
|
||||
);
|
||||
}
|
||||
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Provider } from "@/lib/provider-schema";
|
||||
import { defaultTranscriptionModel, VoiceInputContent } from "./voice-input-view";
|
||||
|
||||
const { fetchProviderCatalogMock, invokeMock, notifyMock } = vi.hoisted(() => ({
|
||||
fetchProviderCatalogMock: vi.fn(),
|
||||
invokeMock: vi.fn(),
|
||||
notifyMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/provider-model-catalog", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/provider-model-catalog")>();
|
||||
return {
|
||||
...actual,
|
||||
fetchProviderCatalog: fetchProviderCatalogMock,
|
||||
notifyVoiceInputSettingsChanged: notifyMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/desktop-client", () => ({
|
||||
desktopClient: { invoke: invokeMock },
|
||||
openExternalUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
const transcriptionProvider: Provider = {
|
||||
id: "elevenlabs",
|
||||
name: "ElevenLabs",
|
||||
models: 2,
|
||||
color: "#000000",
|
||||
letter: "EL",
|
||||
enabled: true,
|
||||
apiKey: "sk-test",
|
||||
modelList: [
|
||||
{
|
||||
id: "scribe_v1",
|
||||
name: "Scribe v1",
|
||||
operation: "transcription",
|
||||
inputModalities: ["audio"],
|
||||
outputModalities: ["text"],
|
||||
},
|
||||
{
|
||||
id: "scribe_v2_realtime",
|
||||
name: "Scribe v2 Realtime",
|
||||
operation: "transcription",
|
||||
operationModes: ["streaming"],
|
||||
inputModalities: ["audio"],
|
||||
outputModalities: ["text"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const unconnectedProvider: Provider = {
|
||||
...transcriptionProvider,
|
||||
id: "groq",
|
||||
name: "Groq",
|
||||
enabled: false,
|
||||
apiKey: undefined,
|
||||
};
|
||||
|
||||
describe("defaultTranscriptionModel", () => {
|
||||
it("prefers streaming models, then the first transcription model", () => {
|
||||
expect(
|
||||
defaultTranscriptionModel(transcriptionProvider.modelList ?? [])?.id,
|
||||
).toBe("scribe_v2_realtime");
|
||||
expect(
|
||||
defaultTranscriptionModel([
|
||||
{ id: "batch-only", name: "Batch", operation: "transcription" },
|
||||
])?.id,
|
||||
).toBe("batch-only");
|
||||
});
|
||||
});
|
||||
|
||||
describe("VoiceInputContent", () => {
|
||||
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);
|
||||
fetchProviderCatalogMock.mockReset();
|
||||
invokeMock.mockReset();
|
||||
notifyMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const render = async (onOpenModelProviders = vi.fn()) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<VoiceInputContent onOpenModelProviders={onOpenModelProviders} />,
|
||||
);
|
||||
});
|
||||
return onOpenModelProviders;
|
||||
};
|
||||
|
||||
it("locks the page until a voice-capable provider is connected", async () => {
|
||||
fetchProviderCatalogMock.mockResolvedValue({
|
||||
providers: [unconnectedProvider],
|
||||
settingsPath: "/tmp/providers.json",
|
||||
});
|
||||
const onOpenModelProviders = await render();
|
||||
|
||||
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"));
|
||||
await act(async () => openProviders?.click());
|
||||
expect(onOpenModelProviders).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("explains when connected providers offer no transcription models", async () => {
|
||||
fetchProviderCatalogMock.mockResolvedValue({
|
||||
providers: [
|
||||
{
|
||||
...transcriptionProvider,
|
||||
id: "anthropic",
|
||||
name: "Anthropic",
|
||||
modelList: [{ id: "claude", name: "Claude" }],
|
||||
},
|
||||
unconnectedProvider,
|
||||
],
|
||||
settingsPath: "/tmp/providers.json",
|
||||
});
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain(
|
||||
"None of your configured providers offer speech-to-text models",
|
||||
);
|
||||
expect(container.textContent).toContain("Groq");
|
||||
});
|
||||
|
||||
it("enables voice input with the default (streaming) model preselected", async () => {
|
||||
fetchProviderCatalogMock.mockResolvedValue({
|
||||
providers: [transcriptionProvider],
|
||||
settingsPath: "/tmp/providers.json",
|
||||
});
|
||||
invokeMock.mockResolvedValue({
|
||||
voiceInput: {
|
||||
providerId: "elevenlabs",
|
||||
modelId: "scribe_v2_realtime",
|
||||
},
|
||||
});
|
||||
await render();
|
||||
|
||||
const toggle = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Enable voice input"]',
|
||||
);
|
||||
expect(toggle?.getAttribute("aria-checked")).toBe("false");
|
||||
await act(async () => toggle?.click());
|
||||
|
||||
expect(invokeMock).toHaveBeenCalledWith("save_voice_input_settings", {
|
||||
provider: "elevenlabs",
|
||||
model: "scribe_v2_realtime",
|
||||
});
|
||||
expect(notifyMock).toHaveBeenCalled();
|
||||
const selected = container.querySelector('[role="radio"][aria-checked="true"]');
|
||||
expect(selected?.textContent).toContain("Scribe v2 Realtime");
|
||||
expect(selected?.textContent).toContain("Default");
|
||||
});
|
||||
|
||||
it("saves model changes and clears the selection when disabled", async () => {
|
||||
fetchProviderCatalogMock.mockResolvedValue({
|
||||
providers: [transcriptionProvider],
|
||||
settingsPath: "/tmp/providers.json",
|
||||
voiceInput: { providerId: "elevenlabs", modelId: "scribe_v2_realtime" },
|
||||
});
|
||||
invokeMock.mockResolvedValue({
|
||||
voiceInput: { providerId: "elevenlabs", modelId: "scribe_v1" },
|
||||
});
|
||||
await render();
|
||||
|
||||
const batchModel = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>('[role="radio"]'),
|
||||
).find((button) => button.textContent?.includes("Scribe v1"));
|
||||
await act(async () => batchModel?.click());
|
||||
expect(invokeMock).toHaveBeenLastCalledWith("save_voice_input_settings", {
|
||||
provider: "elevenlabs",
|
||||
model: "scribe_v1",
|
||||
});
|
||||
|
||||
invokeMock.mockResolvedValue({});
|
||||
const toggle = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Enable voice input"]',
|
||||
);
|
||||
await act(async () => toggle?.click());
|
||||
expect(invokeMock).toHaveBeenLastCalledWith("save_voice_input_settings", {
|
||||
provider: undefined,
|
||||
model: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,363 @@
|
||||
"use client";
|
||||
|
||||
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 {
|
||||
fetchProviderCatalog,
|
||||
isDedicatedTranscriptionModel,
|
||||
notifyVoiceInputSettingsChanged,
|
||||
} from "@/lib/provider-model-catalog";
|
||||
import type {
|
||||
Provider,
|
||||
ProviderModel,
|
||||
VoiceInputSelection,
|
||||
} from "@/lib/provider-schema";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageFrame, PageHeader } from "../page-layout";
|
||||
|
||||
type VoiceProviderEntry = {
|
||||
provider: Provider;
|
||||
models: ProviderModel[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The model preselected when the user enables voice input or switches
|
||||
* provider: streaming (live) transcription when available, else the first
|
||||
* transcription model the provider offers.
|
||||
*/
|
||||
export function defaultTranscriptionModel(
|
||||
models: ProviderModel[],
|
||||
): ProviderModel | undefined {
|
||||
return (
|
||||
models.find((model) => model.operationModes?.includes("streaming")) ??
|
||||
models[0]
|
||||
);
|
||||
}
|
||||
|
||||
function isStreamingModel(model: ProviderModel): boolean {
|
||||
return model.operationModes?.includes("streaming") === true;
|
||||
}
|
||||
|
||||
export function VoiceInputContent({
|
||||
onOpenModelProviders,
|
||||
}: {
|
||||
onOpenModelProviders: () => void;
|
||||
}) {
|
||||
const [providers, setProviders] = useState<Provider[] | null>(null);
|
||||
const [voiceInput, setVoiceInput] = useState<VoiceInputSelection | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void fetchProviderCatalog()
|
||||
.then((payload) => {
|
||||
if (cancelled) return;
|
||||
setProviders(payload.providers ?? []);
|
||||
setVoiceInput(payload.voiceInput);
|
||||
setLoadError(null);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
setLoadError(error instanceof Error ? error.message : String(error));
|
||||
setProviders([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const connectedProviders = (providers ?? []).filter(isProviderConnected);
|
||||
const voiceProviders: VoiceProviderEntry[] = connectedProviders
|
||||
.map((provider) => ({
|
||||
provider,
|
||||
models: (provider.modelList ?? []).filter(isDedicatedTranscriptionModel),
|
||||
}))
|
||||
.filter((entry) => entry.models.length > 0);
|
||||
// Providers that would qualify once connected, so the empty states can
|
||||
// point the user at something actionable.
|
||||
const voiceCapableProviderNames = (providers ?? [])
|
||||
.filter((provider) =>
|
||||
(provider.modelList ?? []).some(isDedicatedTranscriptionModel),
|
||||
)
|
||||
.map((provider) => provider.name);
|
||||
|
||||
const selectedEntry = voiceProviders.find(
|
||||
(entry) => entry.provider.id === voiceInput?.providerId,
|
||||
);
|
||||
|
||||
const save = useCallback(
|
||||
async (selection: VoiceInputSelection | undefined) => {
|
||||
const previous = voiceInput;
|
||||
setVoiceInput(selection);
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const result = await desktopClient.invoke<{
|
||||
voiceInput?: VoiceInputSelection;
|
||||
}>("save_voice_input_settings", {
|
||||
provider: selection?.providerId,
|
||||
model: selection?.modelId,
|
||||
});
|
||||
setVoiceInput(result.voiceInput);
|
||||
notifyVoiceInputSettingsChanged();
|
||||
} catch (error) {
|
||||
setVoiceInput(previous);
|
||||
setSaveError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[voiceInput],
|
||||
);
|
||||
|
||||
const enableWithDefaults = useCallback(() => {
|
||||
const entry = voiceProviders[0];
|
||||
const model = entry ? defaultTranscriptionModel(entry.models) : undefined;
|
||||
if (!entry || !model) return;
|
||||
void save({ providerId: entry.provider.id, modelId: model.id });
|
||||
}, [save, voiceProviders]);
|
||||
|
||||
const selectProvider = useCallback(
|
||||
(providerId: string) => {
|
||||
const entry = voiceProviders.find(
|
||||
(candidate) => candidate.provider.id === providerId,
|
||||
);
|
||||
const model = entry ? defaultTranscriptionModel(entry.models) : undefined;
|
||||
if (!entry || !model) return;
|
||||
void save({ providerId: entry.provider.id, modelId: model.id });
|
||||
},
|
||||
[save, voiceProviders],
|
||||
);
|
||||
|
||||
const header = (
|
||||
<PageHeader
|
||||
description="Speak instead of typing: the microphone in chat transcribes your voice with the model chosen here. Live models show text as you speak; others transcribe when the recording stops."
|
||||
title="Voice input"
|
||||
/>
|
||||
);
|
||||
|
||||
if (providers === null) {
|
||||
return (
|
||||
<PageFrame>
|
||||
{header}
|
||||
<p className="text-sm text-muted-foreground">Loading providers...</p>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<PageFrame>
|
||||
{header}
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to load providers: {loadError}
|
||||
</p>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
if (voiceProviders.length === 0) {
|
||||
const hasConnected = connectedProviders.length > 0;
|
||||
return (
|
||||
<PageFrame>
|
||||
{header}
|
||||
<div className="flex max-w-2xl flex-col items-start gap-3 rounded-lg border border-dashed px-6 py-8">
|
||||
<Mic aria-hidden="true" className="size-6 text-muted-foreground" />
|
||||
<p className="text-base font-medium text-foreground">
|
||||
{hasConnected
|
||||
? "None of your configured providers offer speech-to-text models"
|
||||
: "Voice input needs a configured model provider"}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{voiceCapableProviderNames.length > 0
|
||||
? `Connect a provider with transcription models — for example ${voiceCapableProviderNames
|
||||
.slice(0, 4)
|
||||
.join(", ")} — and this page unlocks automatically.`
|
||||
: "Connect a provider with transcription models and this page unlocks automatically."}
|
||||
</p>
|
||||
<Button onClick={onOpenModelProviders} size="sm" type="button">
|
||||
Open Model Providers
|
||||
</Button>
|
||||
</div>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
const enabled = Boolean(voiceInput);
|
||||
|
||||
return (
|
||||
<PageFrame>
|
||||
{header}
|
||||
<section className="max-w-2xl">
|
||||
<div className="flex items-center justify-between gap-5 border-y py-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
Enable voice input
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Turns on the microphone button in chat. A default model is
|
||||
preselected — adjust it below.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
aria-label="Enable voice input"
|
||||
checked={enabled}
|
||||
disabled={saving}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) enableWithDefaults();
|
||||
else void save(undefined);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{saveError ? (
|
||||
<p className="mt-3 text-xs text-destructive" role="alert">
|
||||
Failed to save voice input settings: {saveError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{enabled ? (
|
||||
<>
|
||||
<div className="mt-6">
|
||||
<p className="mb-2 text-sm font-semibold text-foreground">
|
||||
Provider
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{voiceProviders.map(({ provider }) => {
|
||||
const isSelected = voiceInput?.providerId === provider.id;
|
||||
return (
|
||||
<button
|
||||
aria-pressed={isSelected}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
isSelected
|
||||
? "border-primary/40 bg-primary/8 text-foreground"
|
||||
: "text-muted-foreground hover:bg-surface-hover-lighter hover:text-foreground",
|
||||
)}
|
||||
disabled={saving}
|
||||
key={provider.id}
|
||||
onClick={() => selectProvider(provider.id)}
|
||||
type="button"
|
||||
>
|
||||
{provider.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedEntry ? (
|
||||
<div className="mt-6">
|
||||
<p className="mb-2 text-sm font-semibold text-foreground">
|
||||
Model
|
||||
</p>
|
||||
<div
|
||||
aria-label="Voice input model"
|
||||
className="overflow-hidden rounded-lg border"
|
||||
role="radiogroup"
|
||||
>
|
||||
{selectedEntry.models.map((model) => {
|
||||
const isSelected = voiceInput?.modelId === model.id;
|
||||
const isDefault =
|
||||
defaultTranscriptionModel(selectedEntry.models)?.id ===
|
||||
model.id;
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: the model picker is a styled radiogroup of buttons; aria-checked + role convey the semantics, and an <input type="radio"> would need a full restyle.
|
||||
<button
|
||||
aria-checked={isSelected}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 border-b px-4 py-3 text-left last:border-b-0 hover:bg-surface-hover-lighter focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
isSelected && "bg-surface-hover",
|
||||
)}
|
||||
disabled={saving}
|
||||
key={model.id}
|
||||
onClick={() =>
|
||||
void save({
|
||||
providerId: selectedEntry.provider.id,
|
||||
modelId: model.id,
|
||||
})
|
||||
}
|
||||
role="radio"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"grid size-4 shrink-0 place-items-center rounded-full border",
|
||||
isSelected
|
||||
? "border-primary"
|
||||
: "border-muted-foreground/40",
|
||||
)}
|
||||
>
|
||||
{isSelected ? (
|
||||
<span className="size-2 rounded-full bg-primary" />
|
||||
) : null}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-sm text-foreground">
|
||||
{model.name}
|
||||
</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
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-0.5 truncate font-mono text-xs text-muted-foreground">
|
||||
{model.id}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -11,14 +11,7 @@ export const CHAT_WS_ENDPOINT_RETRY_DELAY_MS = 100;
|
||||
export const CHAT_WS_RECONNECT_BASE_DELAY_MS = 300;
|
||||
export const CHAT_WS_RECONNECT_MAX_DELAY_MS = 3000;
|
||||
export const CHAT_WS_REQUEST_TIMEOUT_MS = 120000;
|
||||
export const OAUTH_MANAGED_PROVIDERS = new Set([
|
||||
"cline",
|
||||
// ClinePass shares the Cline account OAuth credentials (its auth handler
|
||||
// stores under the "cline" provider), so it never has its own API key.
|
||||
"cline-pass",
|
||||
"oca",
|
||||
"openai-codex",
|
||||
]);
|
||||
export { OAUTH_PROVIDER_IDS as OAUTH_MANAGED_PROVIDERS } from "@/lib/provider-connection";
|
||||
|
||||
export const DEFAULT_CHAT_CONFIG: ChatSessionConfig = {
|
||||
sessionId: undefined,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ChatSessionConfig } from "@/lib/chat-schema";
|
||||
import { resolveCredentialError } from "./helpers";
|
||||
import { inferHydratedChatStatus, resolveCredentialError } from "./helpers";
|
||||
|
||||
function makeConfig(overrides: Partial<ChatSessionConfig>): ChatSessionConfig {
|
||||
return {
|
||||
@@ -53,3 +53,30 @@ describe("resolveCredentialError", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("inferHydratedChatStatus", () => {
|
||||
it("treats an assistant-answered running record as completed", () => {
|
||||
// The stale-record heuristic: a "running" record whose transcript
|
||||
// ends on an assistant answer is read as a session that died without
|
||||
// a status flip. (The stale-stream poll deliberately bypasses this
|
||||
// via mapSessionRecordStatus — see use-chat-session.)
|
||||
expect(
|
||||
inferHydratedChatStatus("running", [
|
||||
{
|
||||
id: "u",
|
||||
sessionId: "s",
|
||||
role: "user",
|
||||
content: "prompt",
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: "a",
|
||||
sessionId: "s",
|
||||
role: "assistant",
|
||||
content: "answer",
|
||||
createdAt: 2,
|
||||
},
|
||||
]),
|
||||
).toBe("completed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -221,3 +221,16 @@ export function inferHydratedChatStatus(
|
||||
}
|
||||
return mapHistoryStatusToChatStatus(fallback);
|
||||
}
|
||||
|
||||
/**
|
||||
* The session record's status mapped verbatim — no transcript inference. For
|
||||
* callers observing a session whose record is actively maintained by the
|
||||
* executing host (the stale-stream poll), the record is the authority;
|
||||
* inferHydratedChatStatus's stale-record heuristic would misread a mid-run
|
||||
* snapshot that happens to end on assistant narration as a finished session.
|
||||
*/
|
||||
export function mapSessionRecordStatus(
|
||||
status: SessionHistoryStatus,
|
||||
): ChatSessionStatus {
|
||||
return mapHistoryStatusToChatStatus(status);
|
||||
}
|
||||
|
||||
@@ -166,6 +166,197 @@ describe("useChatSession", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("heals a running attached session with a dead event stream by polling history", async () => {
|
||||
// Scheduled runs can execute on a host whose live events never reach
|
||||
// this client; the transcript must still settle without a remount.
|
||||
const hydratedSessionId = "session-dead-stream";
|
||||
let readCount = 0;
|
||||
let recordReads = 0;
|
||||
invokeMock.mockImplementation(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === "get_process_context") {
|
||||
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
|
||||
}
|
||||
if (command === "read_session_messages") {
|
||||
readCount += 1;
|
||||
const base = [
|
||||
{
|
||||
id: "history-user",
|
||||
sessionId: hydratedSessionId,
|
||||
role: "user",
|
||||
content: "tell me the current time",
|
||||
createdAt: 1,
|
||||
},
|
||||
];
|
||||
return readCount === 1
|
||||
? base
|
||||
: [
|
||||
...base,
|
||||
{
|
||||
id: "history-answer",
|
||||
sessionId: hydratedSessionId,
|
||||
role: "assistant",
|
||||
content: "It is 12:28 PM PT.",
|
||||
createdAt: 2,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (command === "get_discovered_session") {
|
||||
recordReads += 1;
|
||||
// Still running on the first poll — the snapshot already
|
||||
// ends on assistant narration, which must NOT read as
|
||||
// finished while the record says running.
|
||||
return {
|
||||
sessionId: hydratedSessionId,
|
||||
status: recordReads === 1 ? "running" : "completed",
|
||||
};
|
||||
}
|
||||
if (command === "read_session_hooks") return [];
|
||||
if (command === "chat_session_command") {
|
||||
const request = args?.request as { action?: string } | undefined;
|
||||
if (request?.action === "attach") {
|
||||
return {
|
||||
sessionId: hydratedSessionId,
|
||||
status: "running",
|
||||
provider: "cline",
|
||||
model: "test-model",
|
||||
cwd: "/workspace/cline",
|
||||
workspaceRoot: "/workspace/cline",
|
||||
};
|
||||
}
|
||||
return { promptsInQueue: [] };
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
// Fake timers must be active before hydration so the fallback's
|
||||
// interval registers on the fake clock.
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
await act(async () => {
|
||||
await current.hydrateSession({
|
||||
sessionId: hydratedSessionId,
|
||||
status: "running",
|
||||
provider: "cline",
|
||||
model: "test-model",
|
||||
cwd: "/workspace/cline",
|
||||
workspaceRoot: "/workspace/cline",
|
||||
startedAt: "2026-08-12T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
expect(current.status).toBe("running");
|
||||
expect(current.messages).toHaveLength(1);
|
||||
|
||||
// No chat_event chunks arrive. The first poll surfaces the
|
||||
// narration mid-run; the record still says running, and the
|
||||
// record — not transcript shape — decides the status.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(3_100);
|
||||
});
|
||||
expect(current.messages).toHaveLength(2);
|
||||
expect(current.messages[1]?.content).toBe("It is 12:28 PM PT.");
|
||||
expect(current.status).toBe("running");
|
||||
|
||||
// The record flips to completed; the next poll mirrors it.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(3_100);
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
||||
expect(current.status).toBe("completed");
|
||||
});
|
||||
|
||||
it("keeps the stale-stream poll inert while a local turn is in flight", async () => {
|
||||
// Regression: the fallback poll replaced an optimistic user bubble
|
||||
// (raw prompt) with its canonical envelope-wrapped twin, desyncing
|
||||
// the rekey bookkeeping so the stream appended a duplicate bubble.
|
||||
const hydratedSessionId = "session-local-turn";
|
||||
let readCount = 0;
|
||||
invokeMock.mockImplementation(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === "get_process_context") {
|
||||
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
|
||||
}
|
||||
if (command === "read_session_messages") {
|
||||
readCount += 1;
|
||||
return [
|
||||
{
|
||||
id: "history-user",
|
||||
sessionId: hydratedSessionId,
|
||||
role: "user",
|
||||
content: "earlier prompt",
|
||||
createdAt: 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (command === "get_discovered_session") {
|
||||
return { sessionId: hydratedSessionId, status: "running" };
|
||||
}
|
||||
if (command === "read_session_hooks") return [];
|
||||
if (command === "chat_session_command") {
|
||||
const request = args?.request as { action?: string } | undefined;
|
||||
if (request?.action === "attach" || request?.action === "start") {
|
||||
return {
|
||||
sessionId: hydratedSessionId,
|
||||
status: "idle",
|
||||
provider: "cline",
|
||||
model: "test-model",
|
||||
cwd: "/workspace/cline",
|
||||
workspaceRoot: "/workspace/cline",
|
||||
};
|
||||
}
|
||||
if (request?.action === "send") {
|
||||
// Keep the send unresolved: the local turn stays in
|
||||
// flight for the whole test.
|
||||
return await new Promise(() => {});
|
||||
}
|
||||
return { promptsInQueue: [] };
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
await act(async () => {
|
||||
await current.hydrateSession({
|
||||
sessionId: hydratedSessionId,
|
||||
status: "idle",
|
||||
provider: "cline",
|
||||
model: "test-model",
|
||||
cwd: "/workspace/cline",
|
||||
workspaceRoot: "/workspace/cline",
|
||||
startedAt: "2026-08-12T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
const readsAfterHydration = readCount;
|
||||
|
||||
await act(async () => {
|
||||
void current.sendPrompt("what time is it");
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(current.status).toBe("starting");
|
||||
expect(
|
||||
current.messages.filter((m) => m.content === "what time is it"),
|
||||
).toHaveLength(1);
|
||||
|
||||
// Model produces nothing for a long quiet window; the poll must
|
||||
// not fire while the local turn is unsettled.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
});
|
||||
expect(readCount).toBe(readsAfterHydration);
|
||||
expect(
|
||||
current.messages.filter((m) => m.content === "what time is it"),
|
||||
).toHaveLength(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("routes command updates after attaching to an in-flight tool call", async () => {
|
||||
const hydratedSessionId = "session-in-flight-command";
|
||||
invokeMock.mockImplementation(
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
extractAssistantTurnDataFromRpcMessages,
|
||||
inferHydratedChatStatus,
|
||||
makeId,
|
||||
mapSessionRecordStatus,
|
||||
normalizeRuntimeConfig,
|
||||
resolveCredentialError,
|
||||
} from "@/hooks/chat-session/helpers";
|
||||
@@ -91,6 +92,12 @@ const BUSY_STATUSES = new Set<ChatSessionStatus>([
|
||||
"stopping",
|
||||
]);
|
||||
|
||||
// Stale-stream fallback cadence for attached sessions (see the polling
|
||||
// effect below): only poll after the live stream has been quiet this long,
|
||||
// and re-check at this interval while it stays quiet.
|
||||
const STALE_STREAM_QUIET_MS = 5_000;
|
||||
const STALE_STREAM_POLL_INTERVAL_MS = 3_000;
|
||||
|
||||
type PendingToolOutput = {
|
||||
text: string;
|
||||
truncated: boolean;
|
||||
@@ -382,6 +389,9 @@ export function useChatSession() {
|
||||
>([]);
|
||||
const [promptsInQueue, setPromptsInQueue] = useState<PromptInQueue[]>([]);
|
||||
const messagesRef = useRef<ChatMessage[]>([]);
|
||||
// When the last chat_event chunk for the active session arrived. The
|
||||
// stale-stream fallback below only polls while this stays quiet.
|
||||
const lastLiveChunkAtRef = useRef(0);
|
||||
const promptsInQueueRef = useRef<PromptInQueue[]>([]);
|
||||
const liveToolMessageIdsRef = useRef<Record<string, string>>({});
|
||||
const pendingToolOutputRef = useRef(new Map<string, PendingToolOutput>());
|
||||
@@ -1224,6 +1234,7 @@ export function useChatSession() {
|
||||
if (!listeningSessionId || payload.sessionId !== listeningSessionId) {
|
||||
return;
|
||||
}
|
||||
lastLiveChunkAtRef.current = Date.now();
|
||||
if (abortedRef.current) {
|
||||
return;
|
||||
}
|
||||
@@ -1800,6 +1811,119 @@ export function useChatSession() {
|
||||
};
|
||||
}, [clearLiveToolRefs, finalizeSettledTurn]);
|
||||
|
||||
// ---- Stale-stream fallback for attached sessions ----
|
||||
// Scheduled/automation runs execute on a session host whose events are
|
||||
// not projected through the hub's live pipeline (and with several hub
|
||||
// daemons sharing cron.db, a different daemon can claim the run
|
||||
// entirely), so an attached session can sit at "running" with a dead
|
||||
// event stream — stuck on the thinking shimmer until a remount re-reads
|
||||
// history. While an attached session is busy and the stream is quiet,
|
||||
// poll canonical history and the session record so the transcript and
|
||||
// status heal in place. A locally driven turn keeps chunks flowing, so
|
||||
// the quiet-window guard keeps this fallback out of the way there.
|
||||
useEffect(() => {
|
||||
if (!sessionId || hydratedHistorySessionId !== sessionId) {
|
||||
return;
|
||||
}
|
||||
if (!BUSY_STATUSES.has(status)) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
let polling = false;
|
||||
const poll = async () => {
|
||||
if (cancelled || polling) {
|
||||
return;
|
||||
}
|
||||
if (Date.now() - lastLiveChunkAtRef.current < STALE_STREAM_QUIET_MS) {
|
||||
return;
|
||||
}
|
||||
// An assistant bubble mid-stream means the live pipeline works;
|
||||
// canonical history could lag behind it.
|
||||
if (activeAssistantMessageIdRef.current) {
|
||||
return;
|
||||
}
|
||||
// A locally driven turn is in flight (submit/queue bumps the epoch;
|
||||
// settling closes it). Its optimistic user bubble carries the raw
|
||||
// prompt while canonical history stores it wrapped in a
|
||||
// user_input envelope, so replacing state mid-turn desyncs the
|
||||
// rekey bookkeeping and the stream then appends a duplicate
|
||||
// bubble. The fallback exists for externally driven runs
|
||||
// (schedules, other clients) — stay inert until the local turn
|
||||
// settles.
|
||||
if (
|
||||
turnEpochRef.current !== turnSettledEpochRef.current ||
|
||||
outstandingOptimisticUserIdsRef.current.size > 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
polling = true;
|
||||
try {
|
||||
const pollStartedAt = Date.now();
|
||||
const [historyMessages, record] = await Promise.all([
|
||||
desktopClient
|
||||
.invoke<ChatMessage[]>("read_session_messages", {
|
||||
sessionId,
|
||||
maxMessages: MAX_MESSAGES,
|
||||
})
|
||||
.catch(() => null),
|
||||
desktopClient
|
||||
.invoke<{ status?: string } | null>("get_discovered_session", {
|
||||
sessionId,
|
||||
})
|
||||
.catch(() => null),
|
||||
]);
|
||||
if (
|
||||
cancelled ||
|
||||
activeSessionIdRef.current !== sessionId ||
|
||||
// The live stream resumed (or a local turn started) while
|
||||
// the poll was in flight; live state is fresher than the
|
||||
// snapshot we just read.
|
||||
Date.now() - lastLiveChunkAtRef.current < STALE_STREAM_QUIET_MS ||
|
||||
activeAssistantMessageIdRef.current ||
|
||||
turnEpochRef.current !== turnSettledEpochRef.current ||
|
||||
outstandingOptimisticUserIdsRef.current.size > 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(historyMessages) && historyMessages.length > 0) {
|
||||
const mergedMessages = mergeHydratedMessagesWithLive({
|
||||
hydrated: historyMessages,
|
||||
current: messagesRef.current,
|
||||
sessionId,
|
||||
hydrationStartedAt: pollStartedAt,
|
||||
});
|
||||
// Same as hydration: canonical rows may have replaced live
|
||||
// tool rows, so rebuild the tool routing keys or later
|
||||
// tool events would append instead of updating in place.
|
||||
const liveToolState = deriveLiveToolState(mergedMessages);
|
||||
liveToolMessageIdsRef.current = liveToolState.messageIds;
|
||||
liveToolInputsRef.current = liveToolState.inputs;
|
||||
setMessages(mergedMessages);
|
||||
}
|
||||
// The record is the authority here: the sessions this poll
|
||||
// serves have a live host maintaining their record, and it
|
||||
// flips to a terminal status when the run ends. Transcript
|
||||
// inference (inferHydratedChatStatus) would misread a mid-run
|
||||
// snapshot ending on assistant narration as finished, hiding
|
||||
// the working indicator and disarming this poll.
|
||||
const nextStatus = record?.status?.trim();
|
||||
if (nextStatus) {
|
||||
setStatus(mapSessionRecordStatus(nextStatus as SessionHistoryStatus));
|
||||
}
|
||||
} finally {
|
||||
polling = false;
|
||||
}
|
||||
};
|
||||
const interval = window.setInterval(
|
||||
() => void poll(),
|
||||
STALE_STREAM_POLL_INTERVAL_MS,
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [hydratedHistorySessionId, sessionId, status]);
|
||||
|
||||
// ---- Shared: start a new session via RPC ----
|
||||
|
||||
const startSession = useCallback(
|
||||
@@ -2742,6 +2866,10 @@ export function useChatSession() {
|
||||
activeSessionIdRef.current = session.sessionId;
|
||||
activeAssistantMessageIdRef.current = null;
|
||||
setActiveAssistantMessageId(null);
|
||||
// A freshly hydrated session has no local turn in flight; without
|
||||
// this the mount defaults (epoch 0, settled -1) read as an open
|
||||
// turn and keep the stale-stream fallback inert forever.
|
||||
turnSettledEpochRef.current = turnEpochRef.current;
|
||||
setHydratedHistorySessionId(session.sessionId);
|
||||
setPendingToolApprovals([]);
|
||||
setPendingAskQuestions([]);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { isProviderConnected } from "@/lib/provider-connection";
|
||||
import {
|
||||
fetchProviderCatalog,
|
||||
subscribeToProviderCatalogInvalidation,
|
||||
} from "@/lib/provider-model-catalog";
|
||||
|
||||
/**
|
||||
* Whether at least one model provider is connected (usable for turns).
|
||||
* Returns null while unknown so callers can avoid flashing a disabled state
|
||||
* before the catalog loads. Stays current across credential changes via the
|
||||
* shared catalog invalidation channel.
|
||||
*/
|
||||
export function useHasConnectedProvider(): boolean | null {
|
||||
const [hasConnectedProvider, setHasConnectedProvider] = useState<
|
||||
boolean | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = () => {
|
||||
// Failures (including an unavailable transport) keep the last known
|
||||
// value; the catalog fetch is retried on the next invalidation.
|
||||
try {
|
||||
void Promise.resolve(fetchProviderCatalog())
|
||||
.then((payload) => {
|
||||
if (cancelled) return;
|
||||
setHasConnectedProvider(
|
||||
(payload?.providers ?? []).some(isProviderConnected),
|
||||
);
|
||||
})
|
||||
.catch(() => {});
|
||||
} catch {}
|
||||
};
|
||||
load();
|
||||
const unsubscribe = subscribeToProviderCatalogInvalidation(load);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return hasConnectedProvider;
|
||||
}
|
||||
@@ -118,6 +118,56 @@ describe("useSessionHistory session mapping", () => {
|
||||
current.threads.find((thread) => thread.id === "regular-session"),
|
||||
).toMatchObject({ source: "core", isScheduled: false });
|
||||
});
|
||||
|
||||
it("marks sessions scheduled when a schedule execution names them", async () => {
|
||||
// Scheduled runs executed by the local hub don't reliably stamp the
|
||||
// hub-schedule trigger into session metadata, so the executions list
|
||||
// is the fallback signal.
|
||||
invokeMock.mockImplementation(
|
||||
async (command: string, args?: { limit?: number }) => {
|
||||
if (command === "list_discovered_sessions") {
|
||||
return await new Promise<unknown[]>((resolve, reject) => {
|
||||
pendingLists.push({ limit: args?.limit ?? 0, resolve, reject });
|
||||
});
|
||||
}
|
||||
if (command === "list_routine_schedules") {
|
||||
return {
|
||||
activeExecutions: [{ sessionId: "cron-active" }],
|
||||
lastExecutions: [{ sessionId: "cron-session" }, {}],
|
||||
};
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<HookHarness />);
|
||||
});
|
||||
await flush();
|
||||
|
||||
await act(async () => {
|
||||
pendingLists[0].resolve([
|
||||
{
|
||||
...sessionRow("cron-session"),
|
||||
source: "core",
|
||||
metadata: { sessionHistoryOrigin: { mode: "user" } },
|
||||
},
|
||||
{
|
||||
...sessionRow("regular-session"),
|
||||
source: "core",
|
||||
metadata: { sessionHistoryOrigin: { mode: "user" } },
|
||||
},
|
||||
]);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(
|
||||
current.threads.find((thread) => thread.id === "cron-session"),
|
||||
).toMatchObject({ isScheduled: true });
|
||||
expect(
|
||||
current.threads.find((thread) => thread.id === "regular-session"),
|
||||
).toMatchObject({ isScheduled: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSessionHistory initial load", () => {
|
||||
|
||||
@@ -504,6 +504,14 @@ export function useSessionHistory({
|
||||
const [unreadSessionIds, setUnreadSessionIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
// Session ids that schedule executions report as their own. Scheduled runs
|
||||
// executed by the local hub do not reliably carry the "hub-schedule"
|
||||
// origin trigger in their session metadata (the runtime that claims the
|
||||
// run doesn't always stamp provenance), so the metadata check alone would
|
||||
// miss them; the executions list is the authoritative link.
|
||||
const [scheduledSessionIds, setScheduledSessionIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const fetchLimitRef = useRef(INITIAL_HISTORY_FETCH_LIMIT);
|
||||
// Limit of the most recent refresh that actually returned sessions. Failed
|
||||
// attempts roll back to this rather than to a caller-local snapshot, which
|
||||
@@ -553,6 +561,52 @@ export function useSessionHistory({
|
||||
});
|
||||
}, [activeSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const collectScheduledSessionIds = async () => {
|
||||
const response = await desktopClient
|
||||
.invoke<{
|
||||
activeExecutions?: Array<{ sessionId?: unknown }>;
|
||||
lastExecutions?: Array<{ sessionId?: unknown }>;
|
||||
}>("list_routine_schedules")
|
||||
.catch(() => null);
|
||||
if (cancelled || !response) {
|
||||
return;
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
for (const execution of [
|
||||
...(response.activeExecutions ?? []),
|
||||
...(response.lastExecutions ?? []),
|
||||
]) {
|
||||
const sessionId =
|
||||
typeof execution?.sessionId === "string"
|
||||
? execution.sessionId.trim()
|
||||
: "";
|
||||
if (sessionId) {
|
||||
ids.add(sessionId);
|
||||
}
|
||||
}
|
||||
setScheduledSessionIds((current) => {
|
||||
// Merge instead of replace: the executions list is a rolling
|
||||
// window, so ids that fell out of it are still scheduled runs.
|
||||
const next = new Set(current);
|
||||
for (const id of ids) {
|
||||
next.add(id);
|
||||
}
|
||||
return next.size === current.size ? current : next;
|
||||
});
|
||||
};
|
||||
void collectScheduledSessionIds();
|
||||
const interval = window.setInterval(
|
||||
() => void collectScheduledSessionIds(),
|
||||
2 * 60 * 1000,
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refreshSessions = useCallback(async () => {
|
||||
// Reuse an in-flight refresh only when it already asked for at least as
|
||||
// many sessions as we need now. "Load more" raises the limit and then
|
||||
@@ -1209,7 +1263,7 @@ export function useSessionHistory({
|
||||
);
|
||||
};
|
||||
|
||||
// Favoriting is a single click, so apply it locally first and roll back
|
||||
// Pinning is a single click, so apply it locally first and roll back
|
||||
// if the write fails rather than blocking the row on a round trip.
|
||||
applyPinned(pinned);
|
||||
try {
|
||||
@@ -1228,7 +1282,7 @@ export function useSessionHistory({
|
||||
applyPinned(!pinned);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: pinned ? "Favorite failed" : "Unfavorite failed",
|
||||
title: pinned ? "Pin failed" : "Unpin failed",
|
||||
description:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
@@ -1429,6 +1483,17 @@ export function useSessionHistory({
|
||||
[sessions],
|
||||
);
|
||||
|
||||
const threadsWithScheduled = useMemo(() => {
|
||||
if (scheduledSessionIds.size === 0) {
|
||||
return threads;
|
||||
}
|
||||
return threads.map((thread) =>
|
||||
!thread.isScheduled && scheduledSessionIds.has(thread.id)
|
||||
? { ...thread, isScheduled: true }
|
||||
: thread,
|
||||
);
|
||||
}, [scheduledSessionIds, threads]);
|
||||
|
||||
return {
|
||||
getSessionByThreadId,
|
||||
hasLoadedHistory,
|
||||
@@ -1446,7 +1511,7 @@ export function useSessionHistory({
|
||||
forkThread,
|
||||
sessionById,
|
||||
sessions,
|
||||
threads,
|
||||
threads: threadsWithScheduled,
|
||||
unreadSessionIds,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user