mirror of
https://github.com/cline/cline.git
synced 2026-09-05 05:02:27 +08:00
Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c2faf38d72 | |||
| 4dab17769c | |||
| 396032cd3b | |||
| f33ab3a872 | |||
| 2ca8364ffc | |||
| 2ef81be703 | |||
| 359445ae0c | |||
| d9e2e9c76b | |||
| d859a86a6f | |||
| 0b7b9c1b3d | |||
| 557d725690 | |||
| 7274d8badc | |||
| d1837366c0 | |||
| c380daf4a3 | |||
| c564045d81 | |||
| 9a5e1751b2 | |||
| 131e25e1a1 | |||
| a7ff007af9 | |||
| ef27f45080 | |||
| e3c6d51072 | |||
| 48bac25548 | |||
| 37f5f104f3 | |||
| 3577b52404 | |||
| 1843bc8ed0 | |||
| fead00ec57 | |||
| 238107d21c | |||
| 2063a661bd | |||
| ec02d5862e | |||
| 8452084842 | |||
| a41129a5db | |||
| 1ea34be611 | |||
| e72bc3cd14 | |||
| 9c907af826 | |||
| e8d3d82522 | |||
| 7f9d2e96d9 | |||
| d618f8073a | |||
| eb21ba583c | |||
| f29c25395c | |||
| 84c9b587a6 | |||
| 6dca234d8e | |||
| 9217eacbbd | |||
| adbb42a99c | |||
| 50d1578a7e | |||
| 5ef3b81369 | |||
| ec3a57771d | |||
| a695dab23a | |||
| 77af52661c | |||
| 0f4acccd08 | |||
| f8c73cd8cc | |||
| 55a31a0d8a | |||
| 04438c0d54 | |||
| f053ec48e4 | |||
| 0df406723c | |||
| 12703bf407 | |||
| 4a97b46f5f | |||
| 36fc3327ac | |||
| 2b48dc411f | |||
| da6fe718d0 | |||
| 3515333e23 | |||
| bd9ac5872b | |||
| fb15324ad2 | |||
| 7a27c04ffa | |||
| c37b252f65 | |||
| 2872138900 | |||
| dc4620c529 | |||
| 2ac5c85e69 | |||
| ab68fd7f34 |
@@ -0,0 +1,158 @@
|
||||
---
|
||||
name: publish-ui
|
||||
description: Prepare, validate, and publish standalone @cline/ui npm releases. Use when bumping the UI package version, publishing latest or next through ui-publish.yml, checking UI release readiness, or completing the one-time npm trusted-publishing bootstrap.
|
||||
---
|
||||
|
||||
# Publish UI
|
||||
|
||||
Release `@cline/ui` independently from the Cline SDK runtime packages.
|
||||
|
||||
## Release contract
|
||||
|
||||
- Version source: `sdk/packages/ui/package.json`.
|
||||
- Workflow: `.github/workflows/ui-publish.yml`.
|
||||
- The package keeps `internal: true` only to stay out of the SDK's shared
|
||||
version/publish scripts. It is still a public npm package because
|
||||
`private: false` and `publishConfig.access: public` control npm publication.
|
||||
- `latest` is the production channel. `next` is an opt-in preview channel.
|
||||
- Use prerelease versions such as `0.2.0-next.0` for `next`; do not publish a
|
||||
version intended for `latest` under the preview tag because npm versions
|
||||
cannot be republished.
|
||||
- There is no UI Git tag, GitHub release, schedule, or Slack announcement.
|
||||
- The workflow runs only by manual dispatch. Every release attempt runs the UI
|
||||
quality checks before publishing and requires `confirm_publish=publish` from
|
||||
`main`.
|
||||
- The publish job and npm trust relationship use the protected `Publish`
|
||||
environment.
|
||||
- Every npm publication needs a new semver version; npm versions are immutable.
|
||||
- Always ask before pushing commits, triggering the publish workflow, changing
|
||||
npm trust settings, or running a local publish command.
|
||||
|
||||
## Normal release
|
||||
|
||||
1. Inspect the branch, current version, npm state, and UI changes.
|
||||
|
||||
```sh
|
||||
git status --short --branch
|
||||
node -p "require('./sdk/packages/ui/package.json').version"
|
||||
npm view @cline/ui dist-tags versions --json
|
||||
git log --oneline --no-merges -- \
|
||||
sdk/packages/ui apps/examples/desktop-app/webview/components/views/chat \
|
||||
.github/workflows/ui-publish.yml
|
||||
```
|
||||
|
||||
2. Ask for the npm channel and version together. For `latest`, ask for patch,
|
||||
minor, major, or an explicit version. For `next`, require an explicit
|
||||
prerelease version such as `0.2.0-next.0`. Do not guess. Update only
|
||||
`sdk/packages/ui/package.json` and its workspace version in `bun.lock`. Do
|
||||
not run the SDK version command.
|
||||
|
||||
3. Validate the release candidate.
|
||||
|
||||
```sh
|
||||
bun install --filter @cline/ui --filter @cline/code --frozen-lockfile
|
||||
bun -F @cline/ui typecheck
|
||||
bun -F @cline/ui test
|
||||
bun -F @cline/ui test:package
|
||||
bun -F @cline/ui build-storybook
|
||||
bun -F @cline/code test:chat-ui
|
||||
```
|
||||
|
||||
The packed-package test installs the tarball with Bun/React 19 and with
|
||||
npm/Node/React 18.
|
||||
Inspect `bun pm pack --dry-run` when the exported file set changed.
|
||||
|
||||
4. Commit the version bump separately from feature work. Ask before pushing.
|
||||
|
||||
```sh
|
||||
git add sdk/packages/ui/package.json bun.lock
|
||||
git commit -m "chore(ui): release vX.Y.Z"
|
||||
git push origin HEAD
|
||||
```
|
||||
|
||||
5. After the release commit reaches `main`, restate the selected npm tag and ask
|
||||
for explicit publish approval. Then trigger and watch the standalone
|
||||
workflow:
|
||||
|
||||
```sh
|
||||
run_url=$(gh workflow run ui-publish.yml --ref main \
|
||||
-f npm_tag=latest \
|
||||
-f confirm_publish=publish)
|
||||
test -n "$run_url"
|
||||
run_id=${run_url##*/}
|
||||
gh run watch "$run_id" --exit-status
|
||||
```
|
||||
|
||||
Use `npm_tag=next` only for a deliberate preview. Do not report success until
|
||||
the workflow succeeds and npm shows the exact version under the selected tag.
|
||||
|
||||
```sh
|
||||
npm view @cline/ui dist-tags versions --json
|
||||
```
|
||||
|
||||
## One-time npm bootstrap
|
||||
|
||||
Use this only while `npm view @cline/ui` returns `E404`. npm requires the
|
||||
package to exist before its GitHub trusted publisher can be configured.
|
||||
|
||||
1. Merge the package and `ui-publish.yml` to `main`. Start from a clean,
|
||||
reviewed `main` checkout. Verify authentication, account 2FA, and write
|
||||
access to the `@cline` npm organization. The `npm trust` command in step 4
|
||||
requires npm CLI 11.15 or newer; the automated trusted-publishing workflow
|
||||
itself enforces npm 11.5.1 or newer.
|
||||
|
||||
```sh
|
||||
npm --version
|
||||
npm whoami
|
||||
npm view @cline/ui version
|
||||
```
|
||||
|
||||
If npm is older than 11.15, ask before upgrading with
|
||||
`npm install -g npm@^11.15.0`.
|
||||
|
||||
2. Run the normal release validation in step 3 above. Then build, pack, test,
|
||||
and inspect the exact initial tarball. Record the absolute archive path
|
||||
printed by the final command.
|
||||
|
||||
```sh
|
||||
bun -F @cline/ui build
|
||||
pack_dir=$(mktemp -d)
|
||||
(cd sdk/packages/ui && bun pm pack --ignore-scripts --destination "$pack_dir" --quiet)
|
||||
tarball=$(find "$pack_dir" -maxdepth 1 -name '*.tgz' -print -quit)
|
||||
test -n "$tarball"
|
||||
bun sdk/packages/ui/scripts/smoke-package.ts "$tarball"
|
||||
tar -tzf "$tarball"
|
||||
printf 'Bootstrap archive: %s\n' "$tarball"
|
||||
```
|
||||
|
||||
3. Ask for explicit approval, then publish the initial version publicly under
|
||||
`latest`:
|
||||
|
||||
```sh
|
||||
npm publish /absolute/path/from-step-2.tgz --access public --tag latest
|
||||
```
|
||||
|
||||
4. Ask separately before configuring the standalone workflow as the trusted
|
||||
publisher:
|
||||
|
||||
```sh
|
||||
npm trust github @cline/ui \
|
||||
--repo cline/cline \
|
||||
--file ui-publish.yml \
|
||||
--env Publish \
|
||||
--allow-publish
|
||||
```
|
||||
|
||||
5. Verify both package state and trust. Every later release uses the workflow;
|
||||
do not add a long-lived npm token.
|
||||
|
||||
```sh
|
||||
npm view @cline/ui dist-tags versions --json
|
||||
npm trust list @cline/ui
|
||||
```
|
||||
|
||||
## Final report
|
||||
|
||||
Report the version and npm tag, release commit, whether anything was pushed,
|
||||
workflow URL or bootstrap result, npm verification, and tests/builds run. If
|
||||
the package still returns `E404`, state that bootstrap remains required.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Publish UI"
|
||||
short_description: "Prepare and publish the Cline UI package"
|
||||
default_prompt: "Use $publish-ui to prepare and publish a new @cline/ui npm release."
|
||||
@@ -8,8 +8,9 @@ HTTP-controlled debugger for the VSCode extension at `src/dev/debug-harness/serv
|
||||
# Build extension first if needed (protos + esbuild):
|
||||
bun run protos && IS_DEV=true bun esbuild.mjs
|
||||
|
||||
# Launch (skip-build if already built):
|
||||
bun src/dev/debug-harness/server.ts --skip-build --auto-launch
|
||||
# Launch (skip-build if already built). Run with node, NOT bun — Playwright's
|
||||
# Electron launch times out under bun:
|
||||
node src/dev/debug-harness/server.ts --skip-build --auto-launch
|
||||
|
||||
# In another terminal:
|
||||
curl localhost:19229/api -d '{"method":"status"}'
|
||||
|
||||
@@ -16,6 +16,7 @@ This file is the secret sauce for working effectively in this codebase. It captu
|
||||
- The whole repo (including `apps/vscode`) uses **bun** for package management and task running. Emit `bun run X` / `bun install` / `bunx <bin>` / `bun file.ts`, never npm/npx. Node remains the *runtime* (VS Code's extension host and the standalone cline-core are Node), so Node-runtime tokens are legitimate and must not be "fixed" to bun — see @.clinerules/bun-and-node.md for the keep-list vs rewrite-list.
|
||||
- Avoid provider-specific string matching / hardcoded provider branches when fixing provider/config plumbing. Prefer provider metadata, shared catalog/defaults, explicit protocol/client capabilities, or centralized normalization utilities that apply by data shape rather than `providerId === "..."`. If a provider exception seems necessary, stop and explain why instead of adding ad-hoc string matching.
|
||||
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `bun run compile`, not `bun run build`).
|
||||
- When reading a configuration files that users may edit, use `readFileStrippingUtf8Bom`, `readFileSyncStrippingUtf8Bom`, or `stripUtf8Bom` from `@cline/shared/node`. DON'T strip byte order marks of user files handled by tools/passed to models.
|
||||
- When creating PRs, contributors should not create changelog-entry files. Maintainers handle release versioning and changelog curation during the release process.
|
||||
- When adding new feature flags, see this PR as a reference https://github.com/cline/cline/pull/7566
|
||||
- Additional instructions about making requests: @.clinerules/network.md
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
name: ext-vscode-ab-package
|
||||
|
||||
# Build (and optionally publish) the combined A/B VSIX: a tiny loader plus two
|
||||
# complete extension bundles — `next/` from the SDK-based apps/vscode on main,
|
||||
# `legacy/` from the legacy-extension branch. Cohort selection happens at
|
||||
# runtime via PostHog flags; see apps/vscode-rollout/README.md for the design
|
||||
# and the rollout runbook.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Combined VSIX version — must exceed every previously published version (e.g. 4.1.0)"
|
||||
required: true
|
||||
type: string
|
||||
next-ref:
|
||||
description: "Ref to build the next (SDK) bundle from"
|
||||
required: true
|
||||
default: "main"
|
||||
type: string
|
||||
legacy-ref:
|
||||
description: "Ref to build the legacy bundle from"
|
||||
required: true
|
||||
default: "legacy-extension"
|
||||
type: string
|
||||
publish:
|
||||
description: "Publish to the VS Code Marketplace (unchecked: just build the .vsix artifact)"
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ext-vscode-ab-package-${{ github.event.inputs.version }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
package:
|
||||
name: Build combined (legacy + next) VSIX
|
||||
runs-on: ubuntu-latest
|
||||
environment: publish
|
||||
steps:
|
||||
- name: Checkout next (SDK) source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.next-ref }}
|
||||
path: next-src
|
||||
lfs: true
|
||||
|
||||
- name: Checkout legacy source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.legacy-ref }}
|
||||
path: legacy-src
|
||||
lfs: true
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install next workspace dependencies
|
||||
working-directory: next-src
|
||||
run: bun install
|
||||
|
||||
# @cline/* are local workspace symlinks to source packages; apps/vscode's
|
||||
# `package` script does NOT build them, so without this the esbuild step
|
||||
# fails on a fresh checkout. (The nightly workflow already does this.)
|
||||
- name: Build SDK packages
|
||||
working-directory: next-src
|
||||
run: bun run build:sdk
|
||||
|
||||
# Stamp the combined version into each bundle's package.json AFTER
|
||||
# install and BEFORE its build: the About tab and telemetry
|
||||
# extension_version read the bundle's own manifest, so without this
|
||||
# the VSIX reports three different versions depending on where you
|
||||
# look. (The nightly workflow gets the same alignment via nightlify.mjs.)
|
||||
- name: Align next bundle version
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/set-version.mjs --dir "$GITHUB_WORKSPACE/next-src/apps/vscode" --version "${{ github.event.inputs.version }}"
|
||||
|
||||
- name: Build next bundle
|
||||
working-directory: next-src/apps/vscode
|
||||
env:
|
||||
CLINE_ENVIRONMENT: production
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
# Inlined by esbuild: attributes every telemetry event with
|
||||
# extension_variant and unlocks the bundle's authoritative
|
||||
# extension.rollout.bundle_activated capture. Rollout builds only.
|
||||
CLINE_ROLLOUT_VARIANT: next
|
||||
# Match the stable publish workflow's OpenTelemetry production defaults.
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
run: bun run package
|
||||
|
||||
- name: Install legacy dependencies
|
||||
working-directory: legacy-src
|
||||
run: |
|
||||
npm --prefix apps/vscode install --include=optional
|
||||
npm --prefix apps/vscode/webview-ui install --include=optional
|
||||
|
||||
- name: Align legacy bundle version
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/set-version.mjs --dir "$GITHUB_WORKSPACE/legacy-src/apps/vscode" --version "${{ github.event.inputs.version }}"
|
||||
|
||||
- name: Build legacy bundle
|
||||
working-directory: legacy-src/apps/vscode
|
||||
env:
|
||||
CLINE_ENVIRONMENT: production
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
CLINE_ROLLOUT_VARIANT: legacy
|
||||
# Match the stable publish workflow's OpenTelemetry production defaults.
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
run: npm run package
|
||||
|
||||
- name: Build loader and run rollout tests
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
run: |
|
||||
bun run typecheck
|
||||
bun run test
|
||||
bun run build:production
|
||||
|
||||
- name: Stitch combined VSIX staging
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: |
|
||||
node scripts/stitch.mjs \
|
||||
--next "$GITHUB_WORKSPACE/next-src/apps/vscode" \
|
||||
--legacy "$GITHUB_WORKSPACE/legacy-src/apps/vscode" \
|
||||
--loader dist/extension.js \
|
||||
--version "${{ github.event.inputs.version }}" \
|
||||
--out "$GITHUB_WORKSPACE/staging"
|
||||
|
||||
- name: Smoke-test loader against staging
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/smoke-loader.mjs "$GITHUB_WORKSPACE/staging"
|
||||
|
||||
# This workflow publishes the STABLE identity. If nightlify ever leaks
|
||||
# into this path the union manifest would ship under the wrong name.
|
||||
# The bundle sub-manifest checks guard the set-version.mjs stamping:
|
||||
# the About tab and telemetry extension_version read those files.
|
||||
- name: Assert stable manifest identity
|
||||
working-directory: staging
|
||||
env:
|
||||
EXPECTED_VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
node -e '
|
||||
const assert = require("node:assert");
|
||||
const expected = process.env.EXPECTED_VERSION;
|
||||
const pkg = require("./package.json");
|
||||
assert.equal(pkg.name, "claude-dev", `unexpected name ${pkg.name}`);
|
||||
assert.equal(pkg.publisher, "saoudrizwan", `unexpected publisher ${pkg.publisher}`);
|
||||
assert.equal(pkg.version, expected, `unexpected union version ${pkg.version}`);
|
||||
for (const bundle of ["next", "legacy"]) {
|
||||
const sub = require(`./${bundle}/package.json`);
|
||||
assert.equal(sub.version, expected, `unexpected ${bundle} bundle version ${sub.version}`);
|
||||
}
|
||||
console.log(`stable identity ok: ${pkg.publisher}.${pkg.name}@${pkg.version} (bundle versions aligned)`);
|
||||
'
|
||||
|
||||
- name: Package VSIX
|
||||
working-directory: staging
|
||||
run: |
|
||||
npm install -g @vscode/vsce
|
||||
# Preserve the narrowly scoped VSCE `sendgrid` scanner exemption used by
|
||||
# both standalone bundle workflows. No SendGrid credential is intentionally
|
||||
# supplied here; inspect the reported artifact before widening the exemption.
|
||||
vsce package --no-dependencies --allow-package-secrets sendgrid --out "claude-dev-${{ github.event.inputs.version }}.vsix"
|
||||
|
||||
- name: Upload VSIX artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: claude-dev-${{ github.event.inputs.version }}
|
||||
path: staging/claude-dev-${{ github.event.inputs.version }}.vsix
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Publish to Marketplace
|
||||
if: ${{ github.event.inputs.publish == 'true' }}
|
||||
working-directory: staging
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
run: |
|
||||
if [[ -z "$VSCE_PAT" ]]; then
|
||||
echo "Error: VSCE_PAT is required to publish."
|
||||
exit 1
|
||||
fi
|
||||
vsce publish --no-dependencies --packagePath "claude-dev-${{ github.event.inputs.version }}.vsix"
|
||||
@@ -1,17 +1,40 @@
|
||||
name: ext-vscode-publish-nightly
|
||||
|
||||
# Publishes saoudrizwan.cline-nightly as the COMBINED A/B VSIX: the rollout
|
||||
# loader plus two complete extension bundles — `next/` from this ref's
|
||||
# apps/vscode (SDK-based) and `legacy/` from the legacy-extension branch.
|
||||
# Cohort selection happens at runtime via PostHog flags; see
|
||||
# apps/vscode-rollout/README.md for the design and rollout runbook.
|
||||
#
|
||||
# The stable-identity equivalent of this pipeline is ext-vscode-ab-package.yml
|
||||
# (manual dispatch, publishes claude-dev). Shared logic lives in
|
||||
# apps/vscode-rollout/scripts (nightlify/gen-manifest/stitch/smoke) so both
|
||||
# workflows stay thin. The single-bundle nightly path this replaced
|
||||
# (apps/vscode/scripts/publish-nightly.mjs) remains for manual feature-branch
|
||||
# pre-release publishes.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every day at 4:00 AM PST (12:00 UTC)
|
||||
- cron: "0 12 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
legacy-ref:
|
||||
description: "Ref to build the legacy bundle from"
|
||||
required: false
|
||||
default: "legacy-extension"
|
||||
type: string
|
||||
dry-run:
|
||||
description: "Build and upload the .vsix artifact without publishing or tagging"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
run-name: "Publish Nightly from ${{ github.ref_name }} @ ${{ github.sha }}"
|
||||
run-name: "Publish Combined Nightly from ${{ github.ref_name }} @ ${{ github.sha }}"
|
||||
|
||||
# Prevent concurrent publish runs on the same branch. The nightly publish script
|
||||
# generates the extension version from a seconds-resolution timestamp, so parallel
|
||||
# runs on the same ref can collide on the same version and cause publish failures
|
||||
# or inconsistent tagging. Runs on different branches proceed independently.
|
||||
# Prevent concurrent publish runs on the same branch: the version is generated
|
||||
# from a seconds-resolution timestamp, so parallel runs on the same ref can
|
||||
# collide on the same version and cause publish failures or inconsistent tagging.
|
||||
concurrency:
|
||||
group: ext-vscode-publish-nightly-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
@@ -20,7 +43,7 @@ permissions: {}
|
||||
|
||||
jobs:
|
||||
test:
|
||||
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
|
||||
if: github.repository == 'cline/cline'
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
@@ -30,60 +53,79 @@ jobs:
|
||||
needs: test
|
||||
permissions:
|
||||
contents: write
|
||||
name: Publish Cline (Nightly) Extension
|
||||
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
|
||||
name: Publish Cline (Nightly) Combined Extension
|
||||
# Defense in depth: only protected main may enter the publishing environment.
|
||||
# This `if` is advisory because a dispatched branch runs its own copy of this
|
||||
# file; the enforced gate is the PublishNightly environment's deployment-branch
|
||||
# policy, which must also allow only main.
|
||||
if: github.repository == 'cline/cline' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
environment: PublishNightly
|
||||
# The VS Code extension's package.json and lockfiles live under apps/vscode/
|
||||
# (the repo root has no package.json). Mirror ext-vscode-test.yml so install
|
||||
# and publish steps run in the correct workspace.
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
|
||||
steps:
|
||||
- name: Checkout selected branch
|
||||
- name: Checkout next (SDK) source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
path: next-src
|
||||
lfs: true
|
||||
persist-credentials: false
|
||||
|
||||
- name: Show build source
|
||||
working-directory: ${{ github.workspace }}
|
||||
- name: Checkout legacy source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# NOTE: inputs are empty strings on `schedule` events, so the ||
|
||||
# fallback (not the input's declared default) is what the cron uses.
|
||||
ref: ${{ inputs.legacy-ref || 'legacy-extension' }}
|
||||
path: legacy-src
|
||||
lfs: true
|
||||
persist-credentials: false
|
||||
|
||||
- name: Show build sources
|
||||
env:
|
||||
# Routed through env rather than interpolated into the script body so
|
||||
# a crafted dispatch input can't inject shell (hygiene: dispatchers
|
||||
# need write access anyway, but keep the pattern clean).
|
||||
LEGACY_REF: ${{ inputs.legacy-ref || 'legacy-extension' }}
|
||||
run: |
|
||||
echo "Building ref: $GITHUB_REF"
|
||||
echo "Building sha: $GITHUB_SHA"
|
||||
git --no-pager log -1 --oneline
|
||||
echo "next: $(git -C next-src rev-parse HEAD)"
|
||||
echo "legacy: $(git -C legacy-src rev-parse HEAD) ($LEGACY_REF)"
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
|
||||
# Node is still REQUIRED in the publish job (not just for install): the publish
|
||||
# scripts run as `node ./scripts/publish-*.mjs` and shell out to `npx ovsx`.
|
||||
# setup-bun does not provide a Node runtime, so keep setup-node here.
|
||||
# Pinned to Node 22 because newer LTS (Node 24 / npm 11) can make vsce's
|
||||
# `npm list` dependency detection fail with ELSPROBLEMS during packaging.
|
||||
# Node is required beyond install: the rollout scripts run under node and
|
||||
# publishing shells out to vsce/ovsx. Pinned to Node 22 because newer LTS
|
||||
# (Node 24 / npm 11) can make vsce's dependency detection fail.
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
# Single root install resolves the whole bun workspace at once (replaces the
|
||||
# per-package `npm ci` steps for apps/vscode + webview-ui).
|
||||
- name: Install workspace dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
# ONE version for the next bundle, the legacy bundle, and the union
|
||||
# manifest: gen-manifest hard-fails if the bundle identities diverge.
|
||||
# Same scheme as the standalone nightly: <major>.<minor>.<unix-seconds>
|
||||
# from next's base version, so it keeps outranking earlier nightlies.
|
||||
- name: Compute nightly version
|
||||
id: version
|
||||
run: |
|
||||
BASE=$(node -p "require('./next-src/apps/vscode/package.json').version")
|
||||
VERSION="$(echo "$BASE" | cut -d. -f1,2).$(date +%s)"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Combined nightly version: $VERSION (base $BASE)"
|
||||
|
||||
- name: Install next workspace dependencies
|
||||
working-directory: next-src
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
# @cline/* are local workspace symlinks to source packages; build dist/ before
|
||||
# packaging/publishing the extension.
|
||||
- name: Build SDK packages
|
||||
working-directory: ${{ github.workspace }}
|
||||
working-directory: next-src
|
||||
run: bun run build:sdk
|
||||
|
||||
- name: Assert better-sqlite3 native binary present
|
||||
working-directory: next-src/apps/vscode
|
||||
run: |
|
||||
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
|
||||
if [ ! -f "$NODE_FILE" ]; then
|
||||
@@ -93,20 +135,24 @@ jobs:
|
||||
fi
|
||||
echo "Found better-sqlite3 native binary: $NODE_FILE"
|
||||
|
||||
# vsce is now a workspace devDependency (on node_modules/.bin), but ovsx is not
|
||||
# vendored and the publish script invokes it via `npx ovsx`, so install ovsx
|
||||
# globally here (npm is available via setup-node). vsce is installed globally
|
||||
# too to preserve the script's existing PATH expectations.
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
# Rewrite each bundle's package.json to the cline-nightly identity BEFORE
|
||||
# its build (runtime command/config IDs derive from the manifest) and
|
||||
# AFTER dependency install (workspace self-links key off the original
|
||||
# package name).
|
||||
- name: Nightlify next bundle manifest
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/nightlify.mjs --dir "$GITHUB_WORKSPACE/next-src/apps/vscode" --version "${{ steps.version.outputs.version }}"
|
||||
|
||||
- name: Publish Nightly Extension
|
||||
- name: Build next bundle
|
||||
working-directory: next-src/apps/vscode
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
CLINE_ENVIRONMENT: production
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
CLINE_ENVIRONMENT: production
|
||||
# Inlined by esbuild: attributes every telemetry event with
|
||||
# extension_variant and unlocks the bundle's authoritative
|
||||
# extension.rollout.bundle_activated capture. Rollout builds only.
|
||||
CLINE_ROLLOUT_VARIANT: next
|
||||
# OpenTelemetry production defaults (can be overridden at runtime)
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
@@ -114,12 +160,129 @@ jobs:
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
# The script itself runs under `node ./scripts/publish-nightly.mjs`; bun run
|
||||
# just launches it. Node + npm (for `npx ovsx`) are provided by setup-node above.
|
||||
run: bun run publish:marketplace:nightly
|
||||
run: bun run package
|
||||
|
||||
- name: Install legacy dependencies
|
||||
working-directory: legacy-src
|
||||
run: |
|
||||
npm --prefix apps/vscode install --include=optional
|
||||
npm --prefix apps/vscode/webview-ui install --include=optional
|
||||
|
||||
- name: Nightlify legacy bundle manifest
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/nightlify.mjs --dir "$GITHUB_WORKSPACE/legacy-src/apps/vscode" --version "${{ steps.version.outputs.version }}"
|
||||
|
||||
- name: Build legacy bundle
|
||||
working-directory: legacy-src/apps/vscode
|
||||
env:
|
||||
CLINE_ENVIRONMENT: production
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
CLINE_ROLLOUT_VARIANT: legacy
|
||||
# Legacy's esbuild inlines these too (its own publish workflow passes
|
||||
# them) — omitting them here would ship the legacy bundle with the
|
||||
# OTel pipeline dead, unlike what legacy users get today.
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
run: npm run package
|
||||
|
||||
- name: Build loader and run rollout tests
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
run: |
|
||||
bun run typecheck
|
||||
bun run test
|
||||
bun run build:production
|
||||
|
||||
- name: Stitch combined VSIX staging
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: |
|
||||
node scripts/stitch.mjs \
|
||||
--next "$GITHUB_WORKSPACE/next-src/apps/vscode" \
|
||||
--legacy "$GITHUB_WORKSPACE/legacy-src/apps/vscode" \
|
||||
--loader dist/extension.js \
|
||||
--version "${{ steps.version.outputs.version }}" \
|
||||
--out "$GITHUB_WORKSPACE/staging"
|
||||
|
||||
- name: Smoke-test loader against staging
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/smoke-loader.mjs "$GITHUB_WORKSPACE/staging"
|
||||
|
||||
# The nightly identity must have fully propagated (nightlify -> both
|
||||
# bundle manifests -> union manifest) or we'd publish over the stable
|
||||
# extension ID. The bundle sub-manifest checks guard the version
|
||||
# stamping: the About tab and telemetry extension_version read those.
|
||||
- name: Assert nightly manifest identity
|
||||
working-directory: staging
|
||||
env:
|
||||
EXPECTED_VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
node -e '
|
||||
const assert = require("node:assert");
|
||||
const expected = process.env.EXPECTED_VERSION;
|
||||
const pkg = require("./package.json");
|
||||
assert.equal(pkg.name, "cline-nightly", `unexpected name ${pkg.name}`);
|
||||
assert.equal(pkg.publisher, "saoudrizwan", `unexpected publisher ${pkg.publisher}`);
|
||||
assert.equal(pkg.version, expected, `unexpected union version ${pkg.version}`);
|
||||
for (const bundle of ["next", "legacy"]) {
|
||||
const sub = require(`./${bundle}/package.json`);
|
||||
assert.equal(sub.name, "cline-nightly", `unexpected ${bundle} bundle name ${sub.name}`);
|
||||
assert.equal(sub.version, expected, `unexpected ${bundle} bundle version ${sub.version}`);
|
||||
}
|
||||
console.log(`nightly identity ok: ${pkg.publisher}.${pkg.name}@${pkg.version} (bundle identities aligned)`);
|
||||
'
|
||||
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
- name: Package VSIX
|
||||
working-directory: staging
|
||||
# Preserve the narrowly scoped VSCE `sendgrid` scanner exemption used by
|
||||
# both standalone bundle workflows. No SendGrid credential is intentionally
|
||||
# supplied here; inspect the reported artifact before widening the exemption.
|
||||
run: vsce package --no-dependencies --allow-package-secrets sendgrid --out "cline-nightly-${{ steps.version.outputs.version }}.vsix"
|
||||
|
||||
- name: Upload VSIX artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cline-nightly-${{ steps.version.outputs.version }}
|
||||
path: staging/cline-nightly-${{ steps.version.outputs.version }}.vsix
|
||||
if-no-files-found: error
|
||||
|
||||
# The job is main-only; step-level dry-run gating still permits a build-only
|
||||
# rehearsal without publishing or tagging.
|
||||
- name: Publish to VS Code Marketplace and Open VSX
|
||||
if: github.ref == 'refs/heads/main' && inputs.dry-run != true
|
||||
working-directory: staging
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
run: |
|
||||
if [[ -z "$VSCE_PAT" ]]; then
|
||||
echo "Error: VSCE_PAT is required to publish."
|
||||
exit 1
|
||||
fi
|
||||
vsce publish --no-dependencies --packagePath "cline-nightly-${{ steps.version.outputs.version }}.vsix"
|
||||
if [[ -n "$OVSX_PAT" ]]; then
|
||||
npx ovsx publish --packagePath "cline-nightly-${{ steps.version.outputs.version }}.vsix" --pat "$OVSX_PAT"
|
||||
else
|
||||
echo "WARNING: OVSX_PAT not set; skipping Open VSX publish."
|
||||
fi
|
||||
|
||||
- name: Tag published commit
|
||||
working-directory: ${{ github.workspace }}
|
||||
if: github.ref == 'refs/heads/main' && inputs.dry-run != true
|
||||
# Best-effort bookkeeping: the default GITHUB_TOKEN cannot create a ref
|
||||
# whose commit modifies workflow files (no workflows permission exists
|
||||
# for it), so this step fails whenever HEAD touched .github/workflows.
|
||||
# The publish already succeeded by this point — don't mark the run red;
|
||||
# push the tag manually with user credentials when it matters.
|
||||
continue-on-error: true
|
||||
working-directory: next-src
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
@@ -127,10 +290,11 @@ jobs:
|
||||
SHORT_SHA=$(git rev-parse --short=12 HEAD)
|
||||
TIMESTAMP=$(date -u +"%Y%m%d%H%M%S")
|
||||
TAG="nightly-${SAFE_REF}-${TIMESTAMP}-${SHORT_SHA}"
|
||||
LEGACY_SHA=$(git -C "$GITHUB_WORKSPACE/legacy-src" rev-parse HEAD)
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag -a "$TAG" -m "Cline Nightly published from ${GITHUB_REF_NAME} at ${GITHUB_SHA}"
|
||||
git tag -a "$TAG" -m "Cline Nightly (combined A/B) published from ${GITHUB_REF_NAME} at ${GITHUB_SHA} (legacy bundle: ${LEGACY_SHA})"
|
||||
# Use an explicit HTTPS remote with GH_TOKEN because checkout was run with
|
||||
# persist-credentials: false, so actions/checkout did not persist a git credential helper.
|
||||
git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "refs/tags/${TAG}"
|
||||
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
needs: [detect-changes, quality-checks]
|
||||
if: needs.detect-changes.outputs.vscode == 'true'
|
||||
env:
|
||||
VSCODE_TEST_VERSION: 1.103.0
|
||||
VSCODE_TEST_VERSION: 1.101.0
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
||||
@@ -260,6 +260,41 @@ jobs:
|
||||
git push origin "refs/tags/${TAG}"
|
||||
done
|
||||
|
||||
- name: Get Previous SDK Tag
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
|
||||
id: prev_tag
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
# The checkout is shallow and tagless, so fetch the release tags explicitly.
|
||||
git fetch origin "+refs/tags/sdk/sdk/v*:refs/tags/sdk/sdk/v*"
|
||||
PREV_TAG=$(git tag -l 'sdk/sdk/v*' | grep -vx "sdk/sdk/v${VERSION}" | sort -V | tail -1 || echo "")
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get Changelog Entry
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
|
||||
id: changelog
|
||||
run: |
|
||||
# Grab content between the first "## " header and the next one in sdk/CHANGELOG.md
|
||||
CONTENT=$(awk '/^## [0-9]/{if(found) exit; found=1; next} found{print}' sdk/CHANGELOG.md)
|
||||
DELIMITER=$(openssl rand -hex 8)
|
||||
echo "content<<${DELIMITER}" >> $GITHUB_OUTPUT
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "${DELIMITER}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: sdk/sdk/v${{ steps.version.outputs.version }}
|
||||
name: "SDK v${{ steps.version.outputs.version }}"
|
||||
body: |
|
||||
${{ steps.changelog.outputs.content }}
|
||||
|
||||
${{ steps.prev_tag.outputs.prev_tag != '' && format('**Full Changelog**: https://github.com/{0}/compare/{1}...sdk/sdk/v{2}', github.repository, steps.prev_tag.outputs.prev_tag, steps.version.outputs.version) || '' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Summary
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
@@ -280,3 +315,26 @@ jobs:
|
||||
echo " - sdk/core/v${VERSION}"
|
||||
echo " - sdk/sdk/v${VERSION}"
|
||||
fi
|
||||
|
||||
- name: Post release to Slack
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
|
||||
uses: slackapi/slack-github-action@v3.0.1
|
||||
with:
|
||||
method: chat.postMessage
|
||||
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: "C0APVKGGZFC"
|
||||
text: "Cline SDK v${{ steps.version.outputs.version }}"
|
||||
blocks:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: "Cline SDK v${{ steps.version.outputs.version }}"
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
text: "<https://www.npmjs.com/package/@cline/sdk/v/${{ steps.version.outputs.version }}|View on npm>${{ steps.prev_tag.outputs.prev_tag != '' && format(' | Full Changelog: https://github.com/{0}/compare/{1}...sdk/sdk/v{2}', github.repository, steps.prev_tag.outputs.prev_tag, steps.version.outputs.version) || '' }}"
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
name: ui-publish
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
npm_tag:
|
||||
description: "npm distribution tag"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- next
|
||||
- latest
|
||||
default: next
|
||||
confirm_publish:
|
||||
description: 'Type "publish" to publish @cline/ui to npm'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
name: UI quality and package checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24.x"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --filter @cline/ui --filter @cline/code --frozen-lockfile
|
||||
|
||||
- name: Typecheck UI
|
||||
run: bun -F @cline/ui typecheck
|
||||
|
||||
- name: Test UI
|
||||
run: bun -F @cline/ui test
|
||||
|
||||
- name: Build Storybook
|
||||
run: bun -F @cline/ui build-storybook
|
||||
|
||||
- name: Build UI package
|
||||
run: bun -F @cline/ui build
|
||||
|
||||
- name: Test desktop chat integration
|
||||
run: bun -F @cline/code test:chat-ui
|
||||
|
||||
- name: Pack publish artifact
|
||||
id: pack
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pack_dir="$RUNNER_TEMP/ui-npm-pack"
|
||||
mkdir -p "$pack_dir"
|
||||
cd sdk/packages/ui
|
||||
bun pm pack --ignore-scripts --destination "$pack_dir" --quiet
|
||||
archive=$(find "$pack_dir" -maxdepth 1 -name '*.tgz' -print -quit)
|
||||
test -n "$archive"
|
||||
echo "archive=$archive" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Test packed package
|
||||
env:
|
||||
UI_PACKAGE_ARCHIVE: ${{ steps.pack.outputs.archive }}
|
||||
run: bun sdk/packages/ui/scripts/smoke-package.ts "$UI_PACKAGE_ARCHIVE"
|
||||
|
||||
- name: Upload publish artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ui-npm-package
|
||||
path: ${{ runner.temp }}/ui-npm-pack/*.tgz
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
publish:
|
||||
name: Publish @cline/ui
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' &&
|
||||
github.repository == 'cline/cline' &&
|
||||
github.ref == 'refs/heads/main' &&
|
||||
inputs.confirm_publish == 'publish' &&
|
||||
!endsWith(github.actor, '[bot]')
|
||||
needs: quality
|
||||
runs-on: ubuntu-latest
|
||||
environment: Publish
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24.x"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Download publish artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ui-npm-package
|
||||
path: ${{ runner.temp }}/ui-npm-pack
|
||||
|
||||
- name: Verify publish tooling
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm_version=$(npm --version)
|
||||
echo "npm ${npm_version}"
|
||||
node -e 'const [major, minor, patch] = process.argv[1].split(".").map(Number); if (major < 11 || (major === 11 && (minor < 5 || (minor === 5 && patch < 1)))) { console.error("npm 11.5.1 or newer is required for trusted publishing"); process.exit(1); }' "$npm_version"
|
||||
|
||||
- name: Publish package
|
||||
shell: bash
|
||||
env:
|
||||
NPM_CONFIG_PROVENANCE: "true"
|
||||
NPM_TAG: ${{ inputs.npm_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
archive=$(find "$RUNNER_TEMP/ui-npm-pack" -maxdepth 1 -name '*.tgz' -print -quit)
|
||||
if [ -z "$archive" ]; then
|
||||
echo "UI package archive was not downloaded"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version=$(tar -xOf "$archive" package/package.json | node -e 'let input=""; process.stdin.on("data", chunk => input += chunk); process.stdin.on("end", () => process.stdout.write(JSON.parse(input).version))')
|
||||
if npm view "@cline/ui@${version}" version >/dev/null 2>&1; then
|
||||
echo "@cline/ui@${version} already exists; bump sdk/packages/ui/package.json before publishing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
npm publish "$archive" --tag "$NPM_TAG" --access public
|
||||
echo "Published @cline/ui@${version} with npm tag '${NPM_TAG}'"
|
||||
@@ -42,6 +42,8 @@ event names. It exports:
|
||||
"emits X as a normal opt-out-respecting event"). Events that intentionally bypass
|
||||
opt-out must use `captureRequired` and assert that explicitly.
|
||||
|
||||
**All events should be named using snake_case and so should their properties**
|
||||
|
||||
## The Activation Funnel
|
||||
|
||||
The canonical funnel that downstream analytics depends on:
|
||||
|
||||
@@ -1,5 +1,41 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.46
|
||||
|
||||
- Fixed out-of-credits detection so the CLI reliably recognizes the Cline API's real `insufficient_credits` (402) error and shows the "add credits" card instead of a generic error
|
||||
|
||||
## 3.0.45
|
||||
|
||||
- Smaller install: the Claude Code and Codex providers are now optional and loaded on demand, cutting `npm i -g cline` from ~640MB to ~285MB (from SDK v0.0.65)
|
||||
- Kimi K3 is now available as a ClinePass model (from SDK v0.0.65)
|
||||
- Runs now retry once after refreshing expired OAuth credentials (from SDK v0.0.65)
|
||||
- Team runs: the spawn tool is no longer exposed to teammates, and errored teammate runs now report as failed instead of completed (from SDK v0.0.65)
|
||||
- Hub status output now includes version numbers
|
||||
- Updated the bundled model catalog (from SDK v0.0.65)
|
||||
|
||||
## 3.0.44
|
||||
|
||||
- Improved max output token handling across providers (gateway routing, OpenAI vendor, and reasoning models) (from SDK v0.0.64)
|
||||
- Frontmatter and configuration files that start with a UTF-8 byte order mark (e.g. saved by Windows editors) now parse correctly (from SDK v0.0.64)
|
||||
|
||||
## 3.0.43
|
||||
|
||||
- The CLI now automatically trusts your operating system's certificate store, so it works behind corporate proxies and TLS-inspecting firewalls without manually setting `NODE_EXTRA_CA_CERTS` (fixes "unable to get local issuer certificate" errors, including Windows intermediate CA stores)
|
||||
|
||||
## 3.0.42
|
||||
|
||||
- Fixed Ollama native API routing so context window and timeout settings work again
|
||||
|
||||
## 3.0.41
|
||||
|
||||
- Compaction now shows progress status in the TUI
|
||||
- Model IDs are now suggested from OpenAI-compatible endpoints when configuring a provider
|
||||
- Workspace git info (branch/remote) is now persisted and refreshed across sessions
|
||||
- Compaction no longer runs during an active turn
|
||||
- Fixed a crash when the terminal title was updated during TUI teardown
|
||||
- The API key fallback hint is now highlighted for better visibility
|
||||
- Benign git states are no longer reported as workspace initialization errors
|
||||
|
||||
## 3.0.40
|
||||
|
||||
- Added a manual API key escape hatch for Cline OAuth providers, so you can enter a key by hand from settings
|
||||
|
||||
@@ -346,9 +346,24 @@ Desktop-integrated approval mode is also supported via env wiring (`CLINE_TOOL_A
|
||||
- `CLINE_LOG_LEVEL` - Runtime log level (`trace|debug|info|warn|error|fatal|silent`, default `info`)
|
||||
- `CLINE_LOG_PATH` - Runtime log file path (default `<CLINE_DATA_DIR>/logs/cline.log`)
|
||||
- `CLINE_LOG_NAME` - Logger name embedded in runtime log records
|
||||
- `CLINE_DEBUG` - Set to `1`/`true` to print wrapper diagnostics (e.g. the CA bundle summary)
|
||||
|
||||
`--key` takes precedence over environment variables.
|
||||
|
||||
## Certificate trust
|
||||
|
||||
The CLI automatically trusts your operating system's certificate store, so it
|
||||
works behind corporate TLS-inspecting proxies and with self-signed/internal
|
||||
endpoints without any setup. On launch the `cline` wrapper harvests the OS trust
|
||||
anchors and writes them to `~/.cline/cli-node-extra-ca-certs.pem`, then points
|
||||
the runtime's `NODE_EXTRA_CA_CERTS` at that bundle. The file is regenerated when
|
||||
it changes and is safe to delete (it is rebuilt on the next run).
|
||||
|
||||
If you set `NODE_EXTRA_CA_CERTS` yourself, your certificates are **merged** into
|
||||
that bundle alongside the system store rather than replacing it. Run with
|
||||
`CLINE_DEBUG=1` to see how many OS and user CAs were loaded and where the bundle
|
||||
was written.
|
||||
|
||||
## Contributing
|
||||
|
||||
See [DEVELOPMENT.md](./DEVELOPMENT.md) for local development setup, monorepo structure, and TUI architecture. See [DISTRIBUTION.md](./DISTRIBUTION.md) for how the CLI is packaged and distributed.
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
// Auto-discovery of OS trust anchors for the Cline CLI.
|
||||
//
|
||||
// Bun does not read the OS trust store, so the 3.x CLI cannot see corporate
|
||||
// MITM / self-signed CAs out of the box. This runs in the Node `bin/cline`
|
||||
// wrapper (not Bun), reads the full OS store via tls.getCACertificates("system")
|
||||
// (Node >= 22, no --use-system-ca flag), and hands the certs to the Bun child
|
||||
// via NODE_EXTRA_CA_CERTS, which both runtimes honor. Mirrors the JetBrains
|
||||
// plugin's configureCertificates(), sourcing from the OS instead of the IDE.
|
||||
//
|
||||
// Dependency-free CommonJS with injectable modules so it is unit-testable and
|
||||
// ships verbatim in the published wrapper package.
|
||||
|
||||
const PEM_MARKER = "-----BEGIN CERTIFICATE-----";
|
||||
const CERT_BLOCK =
|
||||
/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g;
|
||||
|
||||
/**
|
||||
* Returns only the complete certificate blocks from PEM text, or null when
|
||||
* there are none. User files may also hold private keys (combined cert+key
|
||||
* PEMs) or other sections, which must never be copied into the managed
|
||||
* bundle. Files that contain nothing but certificates pass through verbatim
|
||||
* so unchanged bundles keep hash-skipping the rewrite.
|
||||
*/
|
||||
function sanitizePem(text) {
|
||||
const blocks = text.match(CERT_BLOCK) ?? [];
|
||||
if (blocks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const rest = text.replace(CERT_BLOCK, "");
|
||||
if (/^\s*$/.test(rest)) {
|
||||
return text;
|
||||
}
|
||||
return `${blocks.join("\n")}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns OS-trusted certificates as PEM strings, or [] when unavailable.
|
||||
* tls.getCACertificates("system") requires Node >= 22.
|
||||
*/
|
||||
function harvestSystemCerts(tlsModule) {
|
||||
try {
|
||||
const tls = tlsModule || require("node:tls");
|
||||
if (typeof tls.getCACertificates !== "function") {
|
||||
return [];
|
||||
}
|
||||
const certs = tls.getCACertificates("system");
|
||||
if (!Array.isArray(certs)) {
|
||||
return [];
|
||||
}
|
||||
return certs.filter(
|
||||
(cert) => typeof cert === "string" && cert.includes(PEM_MARKER),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the file's certificate blocks as PEM text, or null when missing,
|
||||
* unreadable, or holding no complete certificate block.
|
||||
*/
|
||||
function readUserBundle(fsModule, userPath) {
|
||||
if (!userPath) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const fs = fsModule || require("node:fs");
|
||||
const stat = fs.statSync(userPath, { throwIfNoEntry: false });
|
||||
if (!stat || !stat.isFile()) {
|
||||
return null;
|
||||
}
|
||||
// Binary DER would not have loaded in the runtime either; require PEM.
|
||||
return sanitizePem(fs.readFileSync(userPath, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the user's NODE_EXTRA_CA_CERTS value into PEM strings. Node treats the
|
||||
* value as a single file, but some users set an OS-path-delimited list; the
|
||||
* whole value is tried as one file first, then split.
|
||||
* The managed bundle is excluded so reading it back never re-appends its certs.
|
||||
*/
|
||||
function readUserCerts(fsModule, pathModule, value, managedPath) {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
const fs = fsModule || require("node:fs");
|
||||
const path = pathModule || require("node:path");
|
||||
const candidates = [];
|
||||
const whole = readUserBundle(fs, value);
|
||||
if (whole) {
|
||||
candidates.push({ filePath: value, pem: whole });
|
||||
} else if (value.includes(path.delimiter)) {
|
||||
for (const segment of value.split(path.delimiter)) {
|
||||
const trimmed = segment.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
const pem = readUserBundle(fs, trimmed);
|
||||
if (pem) {
|
||||
candidates.push({ filePath: trimmed, pem });
|
||||
}
|
||||
}
|
||||
}
|
||||
const pems = [];
|
||||
for (const candidate of candidates) {
|
||||
const isManaged =
|
||||
managedPath &&
|
||||
path.resolve(candidate.filePath) === path.resolve(managedPath);
|
||||
if (!isManaged) {
|
||||
pems.push(candidate.pem);
|
||||
}
|
||||
}
|
||||
return pems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenates the user PEMs (if any) and the system certificates into one
|
||||
* bundle. A separating newline is inserted between parts so adjacent END/BEGIN
|
||||
* markers cannot fuse into one invalid line.
|
||||
*/
|
||||
function buildBundle({ systemCerts, userPems }) {
|
||||
const parts = [...(userPems ?? []), ...systemCerts];
|
||||
return parts
|
||||
.map((part) => (part.endsWith("\n") ? part : `${part}\n`))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** Counts individual PEM certificates across the given bundle strings. */
|
||||
function countCerts(pems) {
|
||||
let count = 0;
|
||||
for (const pem of pems) {
|
||||
count += pem.split(PEM_MARKER).length - 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function readFileIfExists(fs, filePath) {
|
||||
try {
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveClineDir(env, os, path) {
|
||||
return env.CLINE_DIR?.trim() || path.join(os.homedir(), ".cline");
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the api-unavailable warning should print. Stamped per Node version
|
||||
* in the cline dir so the nudge shows once rather than on every command; a
|
||||
* version change (upgrade that still falls short, or downgrade) re-arms it.
|
||||
* When the stamp cannot be read or written, warn — bookkeeping failures must
|
||||
* never suppress a real diagnostic.
|
||||
*/
|
||||
function shouldWarnApiUnavailable(env, deps = {}) {
|
||||
const fs = deps.fs || require("node:fs");
|
||||
const os = deps.os || require("node:os");
|
||||
const path = deps.path || require("node:path");
|
||||
const version = deps.nodeVersion || process.versions.node;
|
||||
const dir = resolveClineDir(env, os, path);
|
||||
const stamp = path.join(dir, `.ca-api-warned-${version}`);
|
||||
try {
|
||||
if (fs.existsSync(stamp)) {
|
||||
return false;
|
||||
}
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(stamp, "", { mode: 0o600 });
|
||||
return true;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Atomically writes [content] to [target]; returns true on success. */
|
||||
function writeBundle(fs, dir, target, content) {
|
||||
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
// Owner read/write: the bundle holds public CA material, not secrets,
|
||||
// but there is no reason to make it world-writable.
|
||||
fs.writeFileSync(tmp, content, { mode: 0o600 });
|
||||
try {
|
||||
fs.renameSync(tmp, target);
|
||||
} catch {
|
||||
// Windows can reject rename over a file a concurrent child holds open.
|
||||
fs.rmSync(target, { force: true });
|
||||
fs.renameSync(tmp, target);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
// Never leave a partial temp file behind (e.g. ENOSPC mid-write).
|
||||
try {
|
||||
fs.rmSync(tmp, { force: true });
|
||||
} catch {
|
||||
// Ignore: best-effort cleanup.
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Harvests OS trust anchors, merges them with any user NODE_EXTRA_CA_CERTS, and
|
||||
* points env.NODE_EXTRA_CA_CERTS at a single managed PEM bundle. Mutates `env`
|
||||
* in place. Returns an outcome the caller can log; `action` is one of
|
||||
* "unchanged" | "written" | "write-failed-reused" | "write-failed" |
|
||||
* "no-system-certs" | "api-unavailable".
|
||||
*/
|
||||
function configureNodeExtraCaCerts(env, deps = {}) {
|
||||
const fs = deps.fs || require("node:fs");
|
||||
const os = deps.os || require("node:os");
|
||||
const path = deps.path || require("node:path");
|
||||
const tls = deps.tls || require("node:tls");
|
||||
|
||||
// tls.getCACertificates("system") needs Node >= 22.15; on older Nodes the
|
||||
// harvest cannot run at all, which the caller should surface to the user.
|
||||
if (typeof tls.getCACertificates !== "function") {
|
||||
return {
|
||||
action: "api-unavailable",
|
||||
path: null,
|
||||
systemCertCount: 0,
|
||||
userCertCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const systemCerts = harvestSystemCerts(tls);
|
||||
if (systemCerts.length === 0) {
|
||||
// Nothing to add: leave any user-provided NODE_EXTRA_CA_CERTS untouched
|
||||
// and let the runtime fall back to its bundled CAs.
|
||||
return {
|
||||
action: "no-system-certs",
|
||||
path: null,
|
||||
systemCertCount: 0,
|
||||
userCertCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const managedDir = resolveClineDir(env, os, path);
|
||||
const managedPath = path.join(managedDir, "cli-node-extra-ca-certs.pem");
|
||||
const userValue = (env.NODE_EXTRA_CA_CERTS || "").trim() || null;
|
||||
const userPems = readUserCerts(fs, path, userValue, managedPath);
|
||||
const bundle = buildBundle({ systemCerts, userPems });
|
||||
const base = {
|
||||
path: managedPath,
|
||||
systemCertCount: systemCerts.length,
|
||||
userCertCount: countCerts(userPems),
|
||||
};
|
||||
|
||||
// Skip the rewrite when the bundle is already current. Avoids per-launch I/O
|
||||
// and the concurrent-rename race in the steady state.
|
||||
if (readFileIfExists(fs, managedPath) === bundle) {
|
||||
env.NODE_EXTRA_CA_CERTS = managedPath;
|
||||
return { ...base, action: "unchanged" };
|
||||
}
|
||||
|
||||
if (writeBundle(fs, managedDir, managedPath, bundle)) {
|
||||
env.NODE_EXTRA_CA_CERTS = managedPath;
|
||||
return { ...base, action: "written" };
|
||||
}
|
||||
|
||||
// Write failed: fall back to a previously-written bundle if one exists.
|
||||
if (readFileIfExists(fs, managedPath)) {
|
||||
env.NODE_EXTRA_CA_CERTS = managedPath;
|
||||
return { ...base, action: "write-failed-reused" };
|
||||
}
|
||||
return { ...base, path: null, action: "write-failed" };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
harvestSystemCerts,
|
||||
sanitizePem,
|
||||
readUserBundle,
|
||||
readUserCerts,
|
||||
buildBundle,
|
||||
countCerts,
|
||||
configureNodeExtraCaCerts,
|
||||
shouldWarnApiUnavailable,
|
||||
};
|
||||
@@ -23,6 +23,48 @@ const childEnv = {
|
||||
CLINE_WRAPPER_PATH: scriptPath,
|
||||
};
|
||||
|
||||
// Auto-discover OS trust anchors and pass them to the Bun child via
|
||||
// NODE_EXTRA_CA_CERTS. The Bun runtime does not read the OS store on its own,
|
||||
// so corporate/self-signed CAs would otherwise fail. This wrapper runs on
|
||||
// Node, which can read the full store here.
|
||||
try {
|
||||
const caCerts = require("./ca-certs.cjs");
|
||||
const outcome = caCerts.configureNodeExtraCaCerts(childEnv);
|
||||
const debug =
|
||||
process.env.CLINE_DEBUG === "1" || process.env.CLINE_DEBUG === "true";
|
||||
// Not debug-gated: on old Nodes the harvest silently doing nothing is
|
||||
// indistinguishable from a broken corporate proxy. Stamped per Node
|
||||
// version so the nudge shows once, not on every command.
|
||||
if (
|
||||
outcome &&
|
||||
outcome.action === "api-unavailable" &&
|
||||
!childEnv.NODE_EXTRA_CA_CERTS &&
|
||||
caCerts.shouldWarnApiUnavailable(childEnv)
|
||||
) {
|
||||
console.warn(
|
||||
`[cline] Node ${process.versions.node} cannot read the OS trust store (needs >= 22.15); ` +
|
||||
"corporate or self-signed CAs may fail TLS. Upgrade Node or set NODE_EXTRA_CA_CERTS.",
|
||||
);
|
||||
}
|
||||
if (debug && outcome) {
|
||||
if (outcome.action === "no-system-certs") {
|
||||
console.warn(
|
||||
"[cline] No OS trust anchors found; relying on the runtime's bundled CAs.",
|
||||
);
|
||||
} else if (outcome.action === "write-failed") {
|
||||
console.warn(
|
||||
"[cline] Could not write the managed CA bundle; relying on the runtime's bundled CAs.",
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
`[cline] Trust: ${outcome.systemCertCount} OS + ${outcome.userCertCount} user CAs (${outcome.action}) -> ${outcome.path}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best effort: fall back to the runtime's default trust on any failure.
|
||||
}
|
||||
|
||||
function run(target) {
|
||||
const result = childProcess.spawnSync(target, process.argv.slice(2), {
|
||||
stdio: "inherit",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.40",
|
||||
"version": "3.0.46",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { delimiter, join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
// The helper ships as CommonJS in the published wrapper package, so it is
|
||||
// loaded via require rather than an ESM import.
|
||||
const caCerts = require("../../bin/ca-certs.cjs") as {
|
||||
harvestSystemCerts: (tls?: unknown) => string[];
|
||||
readUserBundle: (fs: unknown, p: string | null) => string | null;
|
||||
readUserCerts: (
|
||||
fs: unknown,
|
||||
path: unknown,
|
||||
value: string | null,
|
||||
managedPath: string | null,
|
||||
) => string[];
|
||||
buildBundle: (input: {
|
||||
systemCerts: string[];
|
||||
userPems?: string[];
|
||||
}) => string;
|
||||
countCerts: (pems: string[]) => number;
|
||||
configureNodeExtraCaCerts: (
|
||||
env: Record<string, string>,
|
||||
deps?: { tls?: unknown; fs?: unknown },
|
||||
) => {
|
||||
action: string;
|
||||
path: string | null;
|
||||
systemCertCount: number;
|
||||
userCertCount: number;
|
||||
};
|
||||
shouldWarnApiUnavailable: (
|
||||
env: Record<string, string>,
|
||||
deps?: { fs?: unknown; nodeVersion?: string },
|
||||
) => boolean;
|
||||
};
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const certSystem =
|
||||
"-----BEGIN CERTIFICATE-----\nSYSTEM\n-----END CERTIFICATE-----\n";
|
||||
const certUser = "-----BEGIN CERTIFICATE-----\nUSER\n-----END CERTIFICATE-----";
|
||||
|
||||
function fakeTls(certs: unknown) {
|
||||
return { getCACertificates: () => certs };
|
||||
}
|
||||
|
||||
describe("ca-certs", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "cline-ca-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("harvestSystemCerts", () => {
|
||||
it("returns only PEM strings from the system store", () => {
|
||||
expect(
|
||||
caCerts.harvestSystemCerts(fakeTls([certSystem, "not-a-cert", 42])),
|
||||
).toEqual([certSystem]);
|
||||
});
|
||||
|
||||
it("returns [] when getCACertificates is unavailable", () => {
|
||||
expect(caCerts.harvestSystemCerts({})).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] when getCACertificates throws", () => {
|
||||
expect(
|
||||
caCerts.harvestSystemCerts({
|
||||
getCACertificates: () => {
|
||||
throw new Error("nope");
|
||||
},
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readUserBundle", () => {
|
||||
it("returns PEM contents for a PEM file", () => {
|
||||
const p = join(dir, "user.pem");
|
||||
writeFileSync(p, certUser);
|
||||
expect(caCerts.readUserBundle(fs, p)).toBe(certUser);
|
||||
});
|
||||
|
||||
it("returns null for a non-PEM (DER) file", () => {
|
||||
const p = join(dir, "user.der");
|
||||
writeFileSync(p, Buffer.from([0x30, 0x82, 0x01, 0x02]));
|
||||
expect(caCerts.readUserBundle(fs, p)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a missing file and for null path", () => {
|
||||
expect(caCerts.readUserBundle(fs, join(dir, "nope.pem"))).toBeNull();
|
||||
expect(caCerts.readUserBundle(fs, null)).toBeNull();
|
||||
});
|
||||
|
||||
it("strips non-certificate sections such as private keys", () => {
|
||||
// Combined cert+key files (nginx/haproxy style) are common; the key
|
||||
// must never reach the managed bundle.
|
||||
const p = join(dir, "combined.pem");
|
||||
writeFileSync(
|
||||
p,
|
||||
`${certUser}\n-----BEGIN PRIVATE KEY-----\nSECRET\n-----END PRIVATE KEY-----\n`,
|
||||
);
|
||||
const out = caCerts.readUserBundle(fs, p);
|
||||
expect(out).toContain("USER");
|
||||
expect(out).not.toContain("PRIVATE KEY");
|
||||
expect(out).not.toContain("SECRET");
|
||||
});
|
||||
|
||||
it("keeps certificates-only files verbatim", () => {
|
||||
// Byte-identical passthrough keeps the unchanged-skip hash stable.
|
||||
const p = join(dir, "clean.pem");
|
||||
writeFileSync(p, `${certUser}\n${certSystem}`);
|
||||
expect(caCerts.readUserBundle(fs, p)).toBe(`${certUser}\n${certSystem}`);
|
||||
});
|
||||
|
||||
it("returns null for a BEGIN marker without a complete block", () => {
|
||||
const p = join(dir, "truncated.pem");
|
||||
writeFileSync(p, "-----BEGIN CERTIFICATE-----\ntruncated");
|
||||
expect(caCerts.readUserBundle(fs, p)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("readUserCerts", () => {
|
||||
it("reads a single PEM file path", () => {
|
||||
const p = join(dir, "corp.pem");
|
||||
writeFileSync(p, certUser);
|
||||
expect(caCerts.readUserCerts(fs, path, p, null)).toEqual([certUser]);
|
||||
});
|
||||
|
||||
it("splits a legacy OS-path-delimited value and reads each PEM", () => {
|
||||
// Legacy footgun: NODE_EXTRA_CA_CERTS="a.pem;b.pem".
|
||||
const a = join(dir, "a.pem");
|
||||
const b = join(dir, "b.pem");
|
||||
writeFileSync(a, certUser);
|
||||
writeFileSync(b, certSystem);
|
||||
expect(
|
||||
caCerts.readUserCerts(fs, path, [a, b].join(delimiter), null),
|
||||
).toEqual([certUser, certSystem]);
|
||||
});
|
||||
|
||||
it("skips missing segments in a delimited value", () => {
|
||||
const a = join(dir, "a.pem");
|
||||
writeFileSync(a, certUser);
|
||||
const value = [a, join(dir, "missing.pem")].join(delimiter);
|
||||
expect(caCerts.readUserCerts(fs, path, value, null)).toEqual([certUser]);
|
||||
});
|
||||
|
||||
it("excludes the managed bundle from user certs", () => {
|
||||
const managed = join(dir, "cli-node-extra-ca-certs.pem");
|
||||
writeFileSync(managed, certUser);
|
||||
expect(caCerts.readUserCerts(fs, path, managed, managed)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] for empty value", () => {
|
||||
expect(caCerts.readUserCerts(fs, path, null, null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildBundle", () => {
|
||||
it("merges user PEMs before system certs", () => {
|
||||
expect(
|
||||
caCerts.buildBundle({
|
||||
systemCerts: [certSystem],
|
||||
userPems: [certUser],
|
||||
}),
|
||||
).toBe(`${certUser}\n${certSystem}`);
|
||||
});
|
||||
|
||||
it("inserts a separating newline so END/BEGIN markers do not fuse", () => {
|
||||
// certUser has no trailing newline, so this proves the boundary fix.
|
||||
const merged = caCerts.buildBundle({
|
||||
systemCerts: [certSystem],
|
||||
userPems: [certUser],
|
||||
});
|
||||
expect(merged).not.toContain(
|
||||
"-----END CERTIFICATE----------BEGIN CERTIFICATE-----",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles no user PEMs", () => {
|
||||
expect(caCerts.buildBundle({ systemCerts: [certSystem] })).toBe(
|
||||
certSystem,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("configureNodeExtraCaCerts", () => {
|
||||
it("writes a managed bundle and points the env var at it", () => {
|
||||
const env: Record<string, string> = { CLINE_DIR: dir };
|
||||
const out = caCerts.configureNodeExtraCaCerts(env, {
|
||||
tls: fakeTls([certSystem]),
|
||||
});
|
||||
expect(out.action).toBe("written");
|
||||
expect(out.path).toBe(join(dir, "cli-node-extra-ca-certs.pem"));
|
||||
expect(env.NODE_EXTRA_CA_CERTS).toBe(out.path);
|
||||
expect(readFileSync(out.path as string, "utf8")).toContain("SYSTEM");
|
||||
});
|
||||
|
||||
it("merges a user-supplied NODE_EXTRA_CA_CERTS with system certs", () => {
|
||||
const userPath = join(dir, "corp.pem");
|
||||
writeFileSync(userPath, certUser);
|
||||
const env: Record<string, string> = {
|
||||
CLINE_DIR: dir,
|
||||
NODE_EXTRA_CA_CERTS: userPath,
|
||||
};
|
||||
const out = caCerts.configureNodeExtraCaCerts(env, {
|
||||
tls: fakeTls([certSystem]),
|
||||
});
|
||||
expect(out.userCertCount).toBe(1);
|
||||
const written = readFileSync(env.NODE_EXTRA_CA_CERTS, "utf8");
|
||||
expect(written).toContain("USER");
|
||||
expect(written).toContain("SYSTEM");
|
||||
});
|
||||
|
||||
it("reports unchanged and skips rewrite on the second run", () => {
|
||||
const env: Record<string, string> = { CLINE_DIR: dir };
|
||||
expect(
|
||||
caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([certSystem]) })
|
||||
.action,
|
||||
).toBe("written");
|
||||
expect(
|
||||
caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([certSystem]) })
|
||||
.action,
|
||||
).toBe("unchanged");
|
||||
});
|
||||
|
||||
it("does not re-append when the user already points at the managed bundle", () => {
|
||||
const env: Record<string, string> = { CLINE_DIR: dir };
|
||||
const first = caCerts.configureNodeExtraCaCerts(env, {
|
||||
tls: fakeTls([certSystem]),
|
||||
}).path as string;
|
||||
const env2: Record<string, string> = {
|
||||
CLINE_DIR: dir,
|
||||
NODE_EXTRA_CA_CERTS: first,
|
||||
};
|
||||
caCerts.configureNodeExtraCaCerts(env2, { tls: fakeTls([certSystem]) });
|
||||
const written = readFileSync(env2.NODE_EXTRA_CA_CERTS, "utf8");
|
||||
expect(written.match(/SYSTEM/g)?.length).toBe(1);
|
||||
});
|
||||
|
||||
it("no-ops when no system certs are available", () => {
|
||||
const env: Record<string, string> = {
|
||||
CLINE_DIR: dir,
|
||||
NODE_EXTRA_CA_CERTS: "/user/corp.pem",
|
||||
};
|
||||
const out = caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([]) });
|
||||
expect(out.action).toBe("no-system-certs");
|
||||
expect(out.path).toBeNull();
|
||||
expect(env.NODE_EXTRA_CA_CERTS).toBe("/user/corp.pem");
|
||||
});
|
||||
|
||||
it("reports api-unavailable on Nodes without getCACertificates", () => {
|
||||
const env: Record<string, string> = {
|
||||
CLINE_DIR: dir,
|
||||
NODE_EXTRA_CA_CERTS: "/user/corp.pem",
|
||||
};
|
||||
const out = caCerts.configureNodeExtraCaCerts(env, { tls: {} });
|
||||
expect(out.action).toBe("api-unavailable");
|
||||
expect(out.path).toBeNull();
|
||||
expect(env.NODE_EXTRA_CA_CERTS).toBe("/user/corp.pem");
|
||||
});
|
||||
|
||||
it("reports write-failed when the bundle cannot be written", () => {
|
||||
const realFs = require("node:fs");
|
||||
const failingFs = {
|
||||
...realFs,
|
||||
mkdirSync: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
writeFileSync: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
};
|
||||
const env: Record<string, string> = { CLINE_DIR: dir };
|
||||
const out = caCerts.configureNodeExtraCaCerts(env, {
|
||||
tls: fakeTls([certSystem]),
|
||||
fs: failingFs,
|
||||
});
|
||||
expect(out.action).toBe("write-failed");
|
||||
expect(out.path).toBeNull();
|
||||
expect(env.NODE_EXTRA_CA_CERTS).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reuses a stale bundle when the rewrite fails", () => {
|
||||
// First run writes the bundle normally.
|
||||
const env: Record<string, string> = { CLINE_DIR: dir };
|
||||
const managedPath = caCerts.configureNodeExtraCaCerts(env, {
|
||||
tls: fakeTls([certSystem]),
|
||||
}).path as string;
|
||||
|
||||
// Second run: writes fail, but the stale bundle is still readable.
|
||||
const realFs = require("node:fs");
|
||||
const failingFs = {
|
||||
...realFs,
|
||||
mkdirSync: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
writeFileSync: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
};
|
||||
const env2: Record<string, string> = { CLINE_DIR: dir };
|
||||
const out = caCerts.configureNodeExtraCaCerts(env2, {
|
||||
// A different system cert forces a rewrite attempt (not "unchanged").
|
||||
tls: fakeTls([certUser]),
|
||||
fs: failingFs,
|
||||
});
|
||||
|
||||
expect(out.action).toBe("write-failed-reused");
|
||||
expect(env2.NODE_EXTRA_CA_CERTS).toBe(managedPath);
|
||||
});
|
||||
});
|
||||
|
||||
describe("countCerts", () => {
|
||||
it("counts individual certificates, not files", () => {
|
||||
// One file holding two certs must report 2, not 1.
|
||||
const twoInOne = `${certUser}\n${certSystem}`;
|
||||
expect(caCerts.countCerts([twoInOne])).toBe(2);
|
||||
expect(caCerts.countCerts([certUser, certSystem])).toBe(2);
|
||||
expect(caCerts.countCerts([])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldWarnApiUnavailable", () => {
|
||||
it("warns once per Node version, then stays quiet", () => {
|
||||
const env = { CLINE_DIR: dir };
|
||||
const deps = { nodeVersion: "22.1.0" };
|
||||
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
|
||||
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(false);
|
||||
});
|
||||
|
||||
it("re-arms when the Node version changes", () => {
|
||||
const env = { CLINE_DIR: dir };
|
||||
expect(
|
||||
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.1.0" }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.14.0" }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.1.0" }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("still warns when the stamp cannot be written", () => {
|
||||
const realFs = require("node:fs");
|
||||
const failingFs = {
|
||||
...realFs,
|
||||
mkdirSync: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
writeFileSync: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
};
|
||||
const env = { CLINE_DIR: dir };
|
||||
const deps = { fs: failingFs, nodeVersion: "22.1.0" };
|
||||
// Bookkeeping failure must never suppress the diagnostic.
|
||||
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
|
||||
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, extname, join } from "node:path";
|
||||
import {
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type SkillConfig,
|
||||
type WorkflowConfig,
|
||||
} from "@cline/core";
|
||||
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
|
||||
import { Command } from "commander";
|
||||
import { getToolCatalog } from "../runtime/tools";
|
||||
import { loadInteractiveConfigData } from "../tui/interactive-config";
|
||||
@@ -209,7 +210,7 @@ async function runAgentsConfigCommand(
|
||||
continue;
|
||||
}
|
||||
const filePath = join(directory, entry.name);
|
||||
const raw = readFileSync(filePath, "utf8");
|
||||
const raw = readFileSyncStrippingUtf8Bom(filePath);
|
||||
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
const frontmatter = frontmatterMatch?.[1] ?? "";
|
||||
const nameMatch = frontmatter.match(/^\s*name:\s*(.+?)\s*$/m);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { version as cliVersion } from "../../package.json";
|
||||
import { getCliBuildInfo } from "../utils/common";
|
||||
|
||||
const {
|
||||
@@ -174,6 +175,40 @@ describe("runDoctorCommand", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("reports CLI and running hub Core versions", async () => {
|
||||
const cwd = "/workspace";
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
port: 25463,
|
||||
pid: 50174,
|
||||
coreVersion: "0.0.63",
|
||||
});
|
||||
mockProbeHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
port: 25463,
|
||||
pid: 50174,
|
||||
coreVersion: "0.0.64",
|
||||
});
|
||||
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
|
||||
|
||||
const output: string[] = [];
|
||||
const code = await runDoctorCommand(
|
||||
{ cwd, json: true },
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(JSON.parse(output[0] || "")).toMatchObject({
|
||||
cliVersion,
|
||||
coreVersion: "0.0.64",
|
||||
});
|
||||
});
|
||||
|
||||
it("doctor --fix clears wedged hub startup artifacts when no server is actually running", async () => {
|
||||
const cwd = mkdtempSync(path.join(os.tmpdir(), "doctor-hub-fix-"));
|
||||
tempDirs.push(cwd);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import open from "open";
|
||||
import { version as cliVersion } from "../../package.json";
|
||||
import { isProcessRunning } from "../connectors/common";
|
||||
import {
|
||||
type ActiveConnectorRecord,
|
||||
@@ -49,6 +50,8 @@ type SpawnedProcessRecord = {
|
||||
|
||||
type DoctorStatus = {
|
||||
cwd: string;
|
||||
cliVersion: string;
|
||||
coreVersion?: string;
|
||||
hubUrl?: string;
|
||||
hubHealthy: boolean;
|
||||
hubPid?: number;
|
||||
@@ -337,6 +340,8 @@ async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
|
||||
];
|
||||
return {
|
||||
cwd,
|
||||
cliVersion,
|
||||
coreVersion: health?.coreVersion ?? discovery?.coreVersion,
|
||||
hubUrl: current?.url,
|
||||
hubHealthy: !!health?.url,
|
||||
hubPid: current?.pid,
|
||||
@@ -419,6 +424,8 @@ export async function runDoctorCommand(
|
||||
io.writeln(JSON.stringify(before));
|
||||
return 0;
|
||||
}
|
||||
writeln(`cli version ${c.dim}${before.cliVersion}${c.reset}`);
|
||||
writeln(`core version ${c.dim}${before.coreVersion ?? "n/a"}${c.reset}`);
|
||||
writeln(`hub url ${c.dim}${before.hubUrl ?? "none"}${c.reset}`);
|
||||
writeln(
|
||||
`hub healthy ${c.dim}${before.hubHealthy ? "yes" : "no"}${before.hubPid ? ` (pid=${before.hubPid})` : ""}${c.reset}`,
|
||||
|
||||
@@ -34,6 +34,7 @@ vi.mock("@cline/core", () => ({
|
||||
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
|
||||
}));
|
||||
|
||||
import { version as cliVersion } from "../../package.json";
|
||||
import { createHubCommand } from "./hub";
|
||||
|
||||
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
|
||||
@@ -63,6 +64,7 @@ describe("createHubCommand", () => {
|
||||
port: 25463,
|
||||
pid: 50174,
|
||||
startedAt: "2026-01-01T00:00:00.000Z",
|
||||
coreVersion: "0.0.62",
|
||||
});
|
||||
|
||||
const output: string[] = [];
|
||||
@@ -88,6 +90,8 @@ describe("createHubCommand", () => {
|
||||
pid: 50174,
|
||||
startedAt: "2026-01-01T00:00:00.000Z",
|
||||
uptime: "1m 5s",
|
||||
cliVersion,
|
||||
coreVersion: "0.0.62",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "@cline/core";
|
||||
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import { version as cliVersion } from "../../package.json";
|
||||
|
||||
interface HubCommandIo {
|
||||
writeln: (text?: string) => void;
|
||||
@@ -134,6 +135,8 @@ export function createHubCommand(
|
||||
pid: health?.pid,
|
||||
startedAt: health?.startedAt,
|
||||
uptime,
|
||||
cliVersion,
|
||||
coreVersion: health?.coreVersion ?? discovery?.coreVersion,
|
||||
}),
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -982,9 +982,15 @@ export async function runCli(): Promise<void> {
|
||||
// and cannot be retroactively updated; this is by design for
|
||||
// lightweight subcommand and pre-auth CLI flows. See CLINE-2406.
|
||||
if (provider === "cline") {
|
||||
const savedAccountId = selectedProviderSettings?.auth?.accountId;
|
||||
if (savedAccountId) {
|
||||
identifyTelemetryAccount({ id: savedAccountId, provider: "cline" });
|
||||
const savedAuth = selectedProviderSettings?.auth;
|
||||
if (savedAuth?.accountId) {
|
||||
identifyTelemetryAccount({
|
||||
id: savedAuth.accountId,
|
||||
provider: "cline",
|
||||
organizationId: savedAuth.organizationId,
|
||||
organizationName: savedAuth.organizationName,
|
||||
memberId: savedAuth.memberId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ describe("compactInteractiveMessages", () => {
|
||||
}));
|
||||
const config = createConfig();
|
||||
const compact = vi.fn((context: CoreCompactionContext) => {
|
||||
expect(context.maxInputTokens).toBe(400_000);
|
||||
expect(context.budget.request.maxInputTokens).toBe(400_000);
|
||||
return { messages: [messages[0]] };
|
||||
});
|
||||
config.knownModels = {
|
||||
@@ -130,7 +130,7 @@ describe("compactInteractiveMessages", () => {
|
||||
expect(result.compactionState?.messages).toEqual([messages[0]]);
|
||||
});
|
||||
|
||||
it("falls back to legacy contextWindow for manual compaction", async () => {
|
||||
it("uses 90 percent of legacy contextWindow for manual compaction", async () => {
|
||||
const longText = "x".repeat(16_000);
|
||||
const messages = Array.from({ length: 10 }, (_, index) => ({
|
||||
role: index % 2 === 0 ? ("user" as const) : ("assistant" as const),
|
||||
@@ -138,7 +138,7 @@ describe("compactInteractiveMessages", () => {
|
||||
}));
|
||||
const config = createConfig();
|
||||
const compact = vi.fn((context: CoreCompactionContext) => {
|
||||
expect(context.maxInputTokens).toBe(400_000);
|
||||
expect(context.budget.request.maxInputTokens).toBe(360_000);
|
||||
return { messages: [messages[0]] };
|
||||
});
|
||||
config.knownModels = {
|
||||
|
||||
@@ -61,11 +61,15 @@ export async function compactInteractiveMessages(input: {
|
||||
compactionState?: SessionCompactionState;
|
||||
}> {
|
||||
const modelInfo = input.config.knownModels?.[input.config.modelId];
|
||||
const maxInputTokens =
|
||||
input.config.compaction?.maxInputTokens ??
|
||||
modelInfo?.maxInputTokens ??
|
||||
modelInfo?.contextWindow ??
|
||||
FALLBACK_MANUAL_COMPACTION_MAX_INPUT_TOKENS;
|
||||
const compactionModelInfo = modelInfo
|
||||
? {
|
||||
...modelInfo,
|
||||
id: modelInfo.id ?? input.config.modelId,
|
||||
}
|
||||
: {
|
||||
id: input.config.modelId,
|
||||
maxInputTokens: FALLBACK_MANUAL_COMPACTION_MAX_INPUT_TOKENS,
|
||||
};
|
||||
const compact = createContextCompactionPrepareTurn(
|
||||
{
|
||||
providerConfig: resolveCompactionProviderConfig(
|
||||
@@ -106,11 +110,7 @@ export async function compactInteractiveMessages(input: {
|
||||
model: {
|
||||
id: input.config.modelId,
|
||||
provider: input.config.providerId,
|
||||
info: {
|
||||
...(modelInfo ?? {}),
|
||||
id: modelInfo?.id ?? input.config.modelId,
|
||||
maxInputTokens: maxInputTokens,
|
||||
},
|
||||
info: compactionModelInfo,
|
||||
},
|
||||
});
|
||||
if (!result?.messages) {
|
||||
|
||||
@@ -107,38 +107,13 @@ export async function sendTurnWithActModeContinuation<
|
||||
};
|
||||
}
|
||||
|
||||
export type ModeSwitchNotice = {
|
||||
from: InteractiveUiMode;
|
||||
to: InteractiveUiMode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tracks a user-initiated mode switch so the next user message can carry a
|
||||
* <mode_notice> marking it. Only UI toggles are recorded: the model-initiated
|
||||
* switch_to_act_mode path already announces itself via the continuation
|
||||
* prompt. A round trip (plan -> act -> plan before sending anything) cancels
|
||||
* out, since the mode the model last saw never effectively changed.
|
||||
*/
|
||||
export function createModeSwitchNoticeTracker() {
|
||||
let pending: ModeSwitchNotice | null = null;
|
||||
return {
|
||||
record(from: InteractiveUiMode, to: InteractiveUiMode): void {
|
||||
if (from === to) {
|
||||
return;
|
||||
}
|
||||
if (pending) {
|
||||
pending = pending.from === to ? null : { from: pending.from, to };
|
||||
return;
|
||||
}
|
||||
pending = { from, to };
|
||||
},
|
||||
consume(): ModeSwitchNotice | null {
|
||||
const notice = pending;
|
||||
pending = null;
|
||||
return notice;
|
||||
},
|
||||
};
|
||||
}
|
||||
// The tracker moved to @cline/shared so the VSCode extension can share the
|
||||
// exact round-trip-cancelling semantics; re-exported here to keep the CLI's
|
||||
// import surface stable.
|
||||
export {
|
||||
createModeSwitchNoticeTracker,
|
||||
type ModeSwitchNotice,
|
||||
} from "@cline/shared";
|
||||
|
||||
export async function applyInteractiveModeConfig(input: {
|
||||
config: Config;
|
||||
|
||||
@@ -641,7 +641,22 @@ export function createInteractiveSessionRuntime(input: {
|
||||
})
|
||||
: undefined,
|
||||
);
|
||||
return { forkedFromSessionId, newSessionId: activeSessionId };
|
||||
// Report carried context from what the new session actually accepted:
|
||||
// the host can reject the inherited state (e.g. stale anchor), and the
|
||||
// UI must not claim a carry-over that did not happen.
|
||||
const acceptedState = projectedMessages
|
||||
? await readCompactionState(activeSessionId)
|
||||
: undefined;
|
||||
return {
|
||||
forkedFromSessionId,
|
||||
newSessionId: activeSessionId,
|
||||
carriedWorkingContext: acceptedState
|
||||
? {
|
||||
workingContextMessages: acceptedState.messages.length,
|
||||
canonicalMessages: messages.length,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const resumeSession = async (sessionId: string): Promise<Message[]> => {
|
||||
|
||||
@@ -9,23 +9,6 @@ import {
|
||||
import { type AgentMode, buildClineSystemPrompt } from "@cline/shared";
|
||||
import { isImagePath, loadImageAsDataUrl } from "../utils/image-attachments";
|
||||
|
||||
const MODE_TAG_INSTRUCTIONS = `# Plan / Act Modes
|
||||
|
||||
User messages arrive wrapped in a <user_input mode="..."> tag. The mode attribute is the interaction mode the user was in when they sent that message: "plan" means plan-mode constraints applied (explore, analyze, and align on a plan -- no edits or state-changing commands), while "act" (or "yolo") means implementation was allowed. If the mode attribute changes between messages, the user switched modes -- the newest message's mode is what governs right now, regardless of what earlier messages allowed. A <mode_notice> block inside a message marks exactly when such a switch happened.`;
|
||||
|
||||
const PLAN_MODE_INSTRUCTIONS = `# Plan Mode
|
||||
|
||||
You are in Plan mode. Your role is to explore, analyze, and plan -- not to execute.
|
||||
|
||||
- Read files, search the codebase, and gather context to understand the problem
|
||||
- Ask clarifying questions when requirements are ambiguous
|
||||
- Present your plan as a structured outline with clear steps
|
||||
- Explain tradeoffs between different approaches when they exist
|
||||
- Do NOT edit files, write code, run destructive commands, or make any changes
|
||||
- Do NOT implement anything -- focus on understanding and alignment first
|
||||
|
||||
Once the user has reviewed your plan and explicitly approved it in a follow-up message, use the switch_to_act_mode tool to switch to act mode and begin implementation. Calling switch_to_act_mode immediately starts execution, so never call it in the same turn you present a plan and never treat the original task request as approval -- end your turn after presenting the plan and wait for the user's response.`;
|
||||
|
||||
export async function resolveSystemPrompt(input: {
|
||||
cwd: string;
|
||||
explicitSystemPrompt?: string;
|
||||
@@ -34,15 +17,10 @@ export async function resolveSystemPrompt(input: {
|
||||
mode?: AgentMode;
|
||||
}): Promise<string> {
|
||||
const metadata = await buildWorkspaceMetadata(input.cwd);
|
||||
let rules = mergeRulesForSystemPrompt(undefined, input.rules);
|
||||
// Both modes get the mode-tag explanation: after a switch, the transcript
|
||||
// still contains messages tagged with the other mode.
|
||||
rules = rules
|
||||
? `${rules}\n\n${MODE_TAG_INSTRUCTIONS}`
|
||||
: MODE_TAG_INSTRUCTIONS;
|
||||
if (input.mode === "plan") {
|
||||
rules = `${rules}\n\n${PLAN_MODE_INSTRUCTIONS}`;
|
||||
}
|
||||
// Mode-tag and plan-mode instructions are appended by the shared prompt
|
||||
// builder itself (see MODE_TAG_INSTRUCTIONS / PLAN_MODE_INSTRUCTIONS in
|
||||
// @cline/shared), so only the caller-specific rules are merged here.
|
||||
const rules = mergeRulesForSystemPrompt(undefined, input.rules);
|
||||
return buildClineSystemPrompt({
|
||||
ide: "Terminal Shell",
|
||||
workspaceRoot: input.cwd,
|
||||
|
||||
@@ -309,3 +309,62 @@ describe("loadIndividualSubscriptionPlans", () => {
|
||||
expect(result).toEqual(plans);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isClineAccountCreditsErrorMessage", () => {
|
||||
it("matches the raw insufficient_credits JSON payload from the Cline API 402", async () => {
|
||||
const { isClineAccountCreditsErrorMessage } = await import(
|
||||
"./cline-account"
|
||||
);
|
||||
expect(
|
||||
isClineAccountCreditsErrorMessage(
|
||||
'{"code":"insufficient_credits","current_balance":-0.14,"message":"Not enough credits available"}',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches the insufficient_credits payload wrapped in an error prefix", async () => {
|
||||
const { isClineAccountCreditsErrorMessage } = await import(
|
||||
"./cline-account"
|
||||
);
|
||||
expect(
|
||||
isClineAccountCreditsErrorMessage(
|
||||
'Error: {"code":"insufficient_credits","current_balance":0,"message":"Not enough credits available"}',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches the plain human-readable Cline API message", async () => {
|
||||
const { isClineAccountCreditsErrorMessage } = await import(
|
||||
"./cline-account"
|
||||
);
|
||||
expect(
|
||||
isClineAccountCreditsErrorMessage("Not enough credits available"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches the legacy insufficient balance phrasing", async () => {
|
||||
const { isClineAccountCreditsErrorMessage } = await import(
|
||||
"./cline-account"
|
||||
);
|
||||
expect(
|
||||
isClineAccountCreditsErrorMessage(
|
||||
"Insufficient balance. Your Cline credits balance is $0.00.",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match unrelated errors", async () => {
|
||||
const { isClineAccountCreditsErrorMessage } = await import(
|
||||
"./cline-account"
|
||||
);
|
||||
expect(isClineAccountCreditsErrorMessage("Payment Required")).toBe(false);
|
||||
expect(
|
||||
isClineAccountCreditsErrorMessage(
|
||||
"Your credit balance is too low to access the Anthropic API.",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isClineAccountCreditsErrorMessage("insufficient balance on gateway"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,9 +51,16 @@ export function isClineAccountAuthErrorMessage(message: string): boolean {
|
||||
|
||||
export function isClineAccountCreditsErrorMessage(message: string): boolean {
|
||||
const normalized = message.trim().toLowerCase();
|
||||
// The Cline API's 402 response carries `code: "insufficient_credits"` and
|
||||
// the message "Not enough credits available". Depending on how much of the
|
||||
// payload survives error extraction, the CLI may see the raw JSON blob or
|
||||
// just the human-readable message, so match both. The
|
||||
// "insufficient balance" pair is an older backend phrasing kept for safety.
|
||||
return (
|
||||
normalized.includes("insufficient balance") &&
|
||||
normalized.includes("cline credits balance")
|
||||
normalized.includes("insufficient_credits") ||
|
||||
normalized.includes("not enough credits") ||
|
||||
(normalized.includes("insufficient balance") &&
|
||||
normalized.includes("cline credits balance"))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -151,6 +158,38 @@ export async function createClineAccountService(input: {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the active organization so headless runs and the hub daemon can
|
||||
* attach it to telemetry identity. Personal account clears stale org fields.
|
||||
*/
|
||||
function persistClineOrganizationContext(
|
||||
activeOrganization: ClineAccountOrganization | null,
|
||||
userId: string,
|
||||
): void {
|
||||
try {
|
||||
const manager = new ProviderSettingsManager();
|
||||
const persisted = manager.getProviderSettings("cline");
|
||||
if (!persisted) {
|
||||
return;
|
||||
}
|
||||
manager.saveProviderSettings(
|
||||
{
|
||||
...persisted,
|
||||
auth: {
|
||||
...persisted.auth,
|
||||
accountId: persisted.auth?.accountId ?? userId,
|
||||
organizationId: activeOrganization?.organizationId,
|
||||
organizationName: activeOrganization?.name,
|
||||
memberId: activeOrganization?.memberId,
|
||||
},
|
||||
},
|
||||
{ setLastUsed: false },
|
||||
);
|
||||
} catch {
|
||||
// Best-effort only.
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadClineAccountSnapshot(input: {
|
||||
config: ClineAccountConfig;
|
||||
clineApiBaseUrl?: string;
|
||||
@@ -183,6 +222,7 @@ export async function loadClineAccountSnapshot(input: {
|
||||
memberId: activeOrganization?.memberId,
|
||||
};
|
||||
identifyTelemetryAccount(accountContext, input.config.logger);
|
||||
persistClineOrganizationContext(activeOrganization, user.id);
|
||||
|
||||
return {
|
||||
user,
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type TerminalTheme,
|
||||
} from "../palette";
|
||||
import type { ChatEntry } from "../types";
|
||||
import { formatCompactionDividerLabel } from "../utils/compaction-status";
|
||||
import { getSyntaxStyle, type SyntaxAccentMode } from "../utils/syntax-style";
|
||||
import { isWarningToolError } from "../utils/tool-errors";
|
||||
import {
|
||||
@@ -133,7 +134,7 @@ function formatToolParams(
|
||||
const el = f.endLine != null ? String(f.endLine) : "undefined";
|
||||
const sep = i > 0 ? "; " : "";
|
||||
return (
|
||||
<span key={f.path}>
|
||||
<span key={`${i}:${f.path}`}>
|
||||
{sep}
|
||||
{shortenPath(f.path)}
|
||||
<span fg="gray">
|
||||
@@ -421,6 +422,38 @@ function ClineOrgIndividualInferenceSubscriptionErrorView(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function CompactionDividerRow(props: {
|
||||
entry: Extract<ChatEntry, { kind: "compaction" }>;
|
||||
}) {
|
||||
const { entry } = props;
|
||||
const { width: terminalWidth } = useTerminalDimensions();
|
||||
const inProgress = entry.status === "started";
|
||||
const labelColor = inProgress
|
||||
? "cyan"
|
||||
: entry.status === "failed"
|
||||
? "red"
|
||||
: entry.status === "cancelled" || entry.status === "skipped"
|
||||
? "gray"
|
||||
: "cyan";
|
||||
const label = `✻ ${formatCompactionDividerLabel(entry)} ✻`;
|
||||
// Fill the remaining line with a plain rule instead of a flexGrow bordered
|
||||
// box: a single fixed-content text row keeps the renderer's diffing stable.
|
||||
const ruleWidth = Math.max(2, Math.min(40, terminalWidth - label.length - 8));
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
{inProgress ? (
|
||||
<box width={2}>
|
||||
<spinner name="dots" color={labelColor} />
|
||||
</box>
|
||||
) : (
|
||||
<text fg="gray" content="── " />
|
||||
)}
|
||||
<text fg={labelColor} selectable content={label} />
|
||||
<text fg="gray" content={` ${"─".repeat(ruleWidth)}`} />
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function ClinePassLimitErrorView(props: {
|
||||
message: string;
|
||||
defaultFg?: string;
|
||||
@@ -616,6 +649,9 @@ export function ChatEntryView(props: {
|
||||
</box>
|
||||
);
|
||||
|
||||
case "compaction":
|
||||
return <CompactionDividerRow entry={entry} />;
|
||||
|
||||
case "done": {
|
||||
const parts: string[] = [];
|
||||
if (entry.elapsed) parts.push(`${entry.elapsed}s`);
|
||||
|
||||
@@ -891,6 +891,7 @@ export function OAuthLoginContent(
|
||||
const escapeHint = allowApiKeyFallback
|
||||
? "K to enter an API key instead, Esc to cancel"
|
||||
: "Esc to cancel";
|
||||
const escapeHintColor = allowApiKeyFallback ? "white" : "gray";
|
||||
|
||||
if (mode === "device") {
|
||||
return (
|
||||
@@ -918,7 +919,7 @@ export function OAuthLoginContent(
|
||||
|
||||
{deviceError && <text fg="red">{deviceError}</text>}
|
||||
|
||||
<text fg="gray">
|
||||
<text fg={escapeHintColor}>
|
||||
<em>{escapeHint}</em>
|
||||
</text>
|
||||
</box>
|
||||
@@ -941,7 +942,7 @@ export function OAuthLoginContent(
|
||||
|
||||
{error && <text fg="red">{error}</text>}
|
||||
|
||||
<text fg="gray">
|
||||
<text fg={escapeHintColor}>
|
||||
<em>{escapeHint}</em>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { OpenConfigOptions } from "./use-config-panel";
|
||||
|
||||
export interface LocalSlashCommandActionInput {
|
||||
name: string;
|
||||
isRunning: boolean;
|
||||
openAccount: () => void;
|
||||
openConfig: (options?: OpenConfigOptions) => void;
|
||||
openMcpManager: () => Promise<boolean>;
|
||||
@@ -46,7 +47,12 @@ export function runLocalSlashCommandAction(
|
||||
return true;
|
||||
}
|
||||
if (normalized === "compact") {
|
||||
input.runCompact();
|
||||
// Autocomplete can invoke local commands while a turn is running. Keep
|
||||
// /compact handled, but do not let it take ownership of the active turn's
|
||||
// shared running state.
|
||||
if (!input.isRunning) {
|
||||
input.runCompact();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (normalized === "fork") {
|
||||
|
||||
@@ -6,13 +6,14 @@ import type {
|
||||
PendingPromptSubmittedEvent,
|
||||
} from "../../runtime/session-events";
|
||||
import { formatCliErrorMessage } from "../../utils/cline-pass-errors";
|
||||
import { resolveStatusNoticeLabel } from "../../utils/events";
|
||||
import { resolveNonCompactionStatusLabel } from "../../utils/events";
|
||||
import {
|
||||
formatToolInput,
|
||||
formatToolOutput,
|
||||
truncate,
|
||||
} from "../../utils/helpers";
|
||||
import type { ChatEntry, InlineStream, TuiProps } from "../types";
|
||||
import { parseCompactionNoticeMetadata } from "../utils/compaction-status";
|
||||
|
||||
interface AgentEventDeps {
|
||||
appendEntry: (entry: ChatEntry) => void;
|
||||
@@ -32,6 +33,7 @@ interface AgentEventDeps {
|
||||
}
|
||||
|
||||
export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
const openCompactionEntryRef = useRef(false);
|
||||
const {
|
||||
appendEntry,
|
||||
updateLastEntry,
|
||||
@@ -45,6 +47,47 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
verbose,
|
||||
} = deps;
|
||||
|
||||
// Compaction dividers that arrived while an assistant message was still
|
||||
// streaming. Appending them immediately would split the message in two, so
|
||||
// they are held until the active content block closes (or the turn ends).
|
||||
const pendingCompactionEntriesRef = useRef<
|
||||
Extract<ChatEntry, { kind: "compaction" }>[]
|
||||
>([]);
|
||||
|
||||
const flushPendingCompactionEntries = useCallback(() => {
|
||||
const pending = pendingCompactionEntriesRef.current;
|
||||
if (pending.length === 0) return;
|
||||
pendingCompactionEntriesRef.current = [];
|
||||
for (const entry of pending) {
|
||||
if (entry.status !== "started" && openCompactionEntryRef.current) {
|
||||
updateEntry((current) =>
|
||||
current.kind === "compaction" && current.status === "started"
|
||||
? { ...current, ...entry }
|
||||
: current,
|
||||
);
|
||||
openCompactionEntryRef.current = false;
|
||||
} else {
|
||||
appendEntry(entry);
|
||||
if (entry.status === "started") {
|
||||
openCompactionEntryRef.current = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [appendEntry, updateEntry]);
|
||||
|
||||
const finalizeDanglingCompactionEntry = useCallback(
|
||||
(status: "failed" | "cancelled") => {
|
||||
if (!openCompactionEntryRef.current) return;
|
||||
openCompactionEntryRef.current = false;
|
||||
updateEntry((entry) =>
|
||||
entry.kind === "compaction" && entry.status === "started"
|
||||
? { ...entry, status }
|
||||
: entry,
|
||||
);
|
||||
},
|
||||
[updateEntry],
|
||||
);
|
||||
|
||||
const closeToolEntry = useCallback(
|
||||
(event: AgentEvent & { type: "content_end" }) => {
|
||||
const error = event.error ?? undefined;
|
||||
@@ -84,9 +127,11 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
setIsRunning(true);
|
||||
setIsStreaming(true);
|
||||
closeInlineStream();
|
||||
flushPendingCompactionEntries();
|
||||
break;
|
||||
case "iteration_end":
|
||||
closeInlineStream();
|
||||
flushPendingCompactionEntries();
|
||||
break;
|
||||
case "content_start": {
|
||||
setIsStreaming(false);
|
||||
@@ -165,11 +210,15 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
setIsRunning(false);
|
||||
setIsStreaming(false);
|
||||
closeInlineStream();
|
||||
flushPendingCompactionEntries();
|
||||
finalizeDanglingCompactionEntry("cancelled");
|
||||
break;
|
||||
case "error":
|
||||
setIsRunning(false);
|
||||
setIsStreaming(false);
|
||||
closeInlineStream();
|
||||
flushPendingCompactionEntries();
|
||||
finalizeDanglingCompactionEntry("failed");
|
||||
turnErrorReportedRef.current = true;
|
||||
onTurnErrorReported(true);
|
||||
if (!event.recoverable || verbose) {
|
||||
@@ -181,8 +230,40 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
break;
|
||||
case "notice":
|
||||
if (event.displayRole === "status") {
|
||||
closeInlineStream();
|
||||
const label = resolveStatusNoticeLabel(event);
|
||||
const compaction = parseCompactionNoticeMetadata(event.metadata);
|
||||
if (!compaction) {
|
||||
closeInlineStream();
|
||||
}
|
||||
if (compaction) {
|
||||
if (activeInlineStreamRef.current) {
|
||||
// An assistant message is still streaming; appending now
|
||||
// would split it around the divider. Hold the divider (final
|
||||
// state until the content block closes, then reconcile it
|
||||
// with the same open divider atomically.
|
||||
pendingCompactionEntriesRef.current.push({
|
||||
kind: "compaction",
|
||||
...compaction,
|
||||
});
|
||||
break;
|
||||
}
|
||||
if (compaction.status === "started") {
|
||||
appendEntry({ kind: "compaction", ...compaction });
|
||||
openCompactionEntryRef.current = true;
|
||||
} else if (openCompactionEntryRef.current) {
|
||||
// Finalize the in-progress divider in place, wherever it
|
||||
// sits in the transcript.
|
||||
updateEntry((entry) =>
|
||||
entry.kind === "compaction" && entry.status === "started"
|
||||
? { ...entry, ...compaction }
|
||||
: entry,
|
||||
);
|
||||
openCompactionEntryRef.current = false;
|
||||
} else {
|
||||
appendEntry({ kind: "compaction", ...compaction });
|
||||
}
|
||||
break;
|
||||
}
|
||||
const label = resolveNonCompactionStatusLabel(event);
|
||||
if (label) {
|
||||
appendEntry({ kind: "status", text: label });
|
||||
}
|
||||
@@ -200,6 +281,7 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
[
|
||||
appendEntry,
|
||||
updateLastEntry,
|
||||
updateEntry,
|
||||
closeInlineStream,
|
||||
activeInlineStreamRef,
|
||||
setIsRunning,
|
||||
@@ -208,6 +290,8 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
onTurnErrorReported,
|
||||
verbose,
|
||||
closeToolEntry,
|
||||
finalizeDanglingCompactionEntry,
|
||||
flushPendingCompactionEntries,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ function makeActions(
|
||||
overrides: Partial<Omit<LocalSlashCommandActionInput, "name">> = {},
|
||||
): Omit<LocalSlashCommandActionInput, "name"> {
|
||||
return {
|
||||
isRunning: false,
|
||||
openAccount: vi.fn(),
|
||||
openConfig: vi.fn(),
|
||||
openMcpManager: vi.fn(async () => false),
|
||||
@@ -58,6 +59,32 @@ describe("runLocalSlashCommandAction", () => {
|
||||
expect(openConfig).toHaveBeenCalledWith({ initialTab: "plugins" });
|
||||
});
|
||||
|
||||
it("does not start compaction while a turn is running", () => {
|
||||
const runCompact = vi.fn();
|
||||
const actions = makeActions({ isRunning: true, runCompact });
|
||||
|
||||
const handled = runLocalSlashCommandAction({
|
||||
name: "compact",
|
||||
...actions,
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(runCompact).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts compaction while the session is idle", () => {
|
||||
const runCompact = vi.fn();
|
||||
const actions = makeActions({ runCompact });
|
||||
|
||||
const handled = runLocalSlashCommandAction({
|
||||
name: "compact",
|
||||
...actions,
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(runCompact).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("waits for clear to reset the runtime session", async () => {
|
||||
let resolveClear: (() => void) | undefined;
|
||||
const clearConversation = vi.fn(
|
||||
|
||||
@@ -9,7 +9,6 @@ import { HelpDialogContent } from "../components/dialogs/help-dialog";
|
||||
import { withLoadingDialog } from "../components/dialogs/loading-dialog";
|
||||
import { useSession } from "../contexts/session-context";
|
||||
import type { AppView, TuiProps } from "../types";
|
||||
import { formatCompactionStatus } from "../utils/compaction-status";
|
||||
import { hydrateSessionMessages } from "../utils/hydrate-messages";
|
||||
import type { LocalSlashCommandInvocation } from "../utils/skill-command-input";
|
||||
import { HistoryDialogContent } from "../views/history-view";
|
||||
@@ -116,21 +115,42 @@ export function useLocalCommandActions(input: {
|
||||
}, [dialog, refocusTextarea, termHeight]);
|
||||
|
||||
const runCompact = useCallback(async () => {
|
||||
session.setIsRunning(true);
|
||||
session.appendEntry({
|
||||
kind: "status",
|
||||
text: "Compacting context...",
|
||||
kind: "compaction",
|
||||
compactionMode: "manual",
|
||||
status: "started",
|
||||
});
|
||||
try {
|
||||
const result = await onCompact();
|
||||
session.updateLastEntry(() => ({
|
||||
kind: "status",
|
||||
text: formatCompactionStatus(result),
|
||||
}));
|
||||
session.updateLastEntry((entry) =>
|
||||
entry.kind === "compaction" && entry.status === "started"
|
||||
? {
|
||||
...entry,
|
||||
status: result.compacted ? "completed" : "skipped",
|
||||
messagesBefore: result.messagesBefore,
|
||||
messagesAfter:
|
||||
result.workingContextMessagesAfter ?? result.messagesAfter,
|
||||
}
|
||||
: entry,
|
||||
);
|
||||
} catch (error) {
|
||||
session.appendEntry({
|
||||
kind: "error",
|
||||
text: `Compaction failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
});
|
||||
const cancelled =
|
||||
error instanceof Error &&
|
||||
(error.name === "AbortError" || /abort/i.test(error.message));
|
||||
session.updateLastEntry((entry) =>
|
||||
entry.kind === "compaction" && entry.status === "started"
|
||||
? { ...entry, status: cancelled ? "cancelled" : "failed" }
|
||||
: entry,
|
||||
);
|
||||
if (!cancelled) {
|
||||
session.appendEntry({
|
||||
kind: "error",
|
||||
text: `Compaction failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
session.setIsRunning(false);
|
||||
}
|
||||
}, [onCompact, session]);
|
||||
|
||||
@@ -159,6 +179,15 @@ export function useLocalCommandActions(input: {
|
||||
kind: "status",
|
||||
text: `Forked into new session ${result.newSessionId}. This is now the active session. Use /history to switch sessions.`,
|
||||
}));
|
||||
if (result.carriedWorkingContext) {
|
||||
session.appendEntry({
|
||||
kind: "compaction",
|
||||
compactionMode: "inherited",
|
||||
status: "completed",
|
||||
messagesBefore: result.carriedWorkingContext.canonicalMessages,
|
||||
messagesAfter: result.carriedWorkingContext.workingContextMessages,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
session.updateLastEntry(() => ({
|
||||
kind: "error",
|
||||
@@ -181,6 +210,7 @@ export function useLocalCommandActions(input: {
|
||||
}
|
||||
return runLocalSlashCommandAction({
|
||||
name: resolved.name,
|
||||
isRunning: session.isRunning,
|
||||
invocation,
|
||||
openAccount,
|
||||
openConfig,
|
||||
@@ -209,6 +239,7 @@ export function useLocalCommandActions(input: {
|
||||
openSkills,
|
||||
runCompact,
|
||||
runFork,
|
||||
session.isRunning,
|
||||
slashCommandRegistry,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -82,6 +82,51 @@ function usesModelIdInput(providerId: string): boolean {
|
||||
return providerId === "openai-compatible";
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask an OpenAI-compatible endpoint for its model list (`GET <baseUrl>/models`)
|
||||
* using the provider's stored API key and headers, mirroring the extension's
|
||||
* refreshOpenAiModels handler. Returns [] on any failure so callers fall back
|
||||
* to manual model-id entry.
|
||||
*/
|
||||
async function fetchOpenAiCompatibleModelIds(
|
||||
providerId: string,
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const manager = new ProviderSettingsManager();
|
||||
const config = manager.getProviderConfig(providerId, {
|
||||
includeKnownModels: false,
|
||||
});
|
||||
const baseUrl = config?.baseUrl?.trim().replace(/\/+$/, "");
|
||||
if (!baseUrl || !URL.canParse(baseUrl)) return [];
|
||||
|
||||
const headers: Record<string, string> = { ...(config?.headers ?? {}) };
|
||||
const apiKey = config?.apiKey?.trim();
|
||||
if (
|
||||
apiKey &&
|
||||
!Object.keys(headers).some((h) => h.toLowerCase() === "authorization")
|
||||
) {
|
||||
headers.Authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/models`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const payload = (await response.json()) as { data?: unknown };
|
||||
const list = Array.isArray(payload?.data) ? payload.data : [];
|
||||
const ids = list
|
||||
.map((model) => {
|
||||
const id = (model as { id?: unknown } | null)?.id;
|
||||
return typeof id === "string" ? id.trim() : "";
|
||||
})
|
||||
.filter(Boolean);
|
||||
return [...new Set(ids)];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function providerToExistingProviderOptions(input: {
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
@@ -300,12 +345,28 @@ export function useModelSelector(opts: {
|
||||
config.knownModels as Record<string, Llms.ModelInfo>,
|
||||
);
|
||||
let providerDisplayName = config.providerId;
|
||||
let endpointModelOptions: ModelOption[] = [];
|
||||
|
||||
const refreshProviderContext = async () => {
|
||||
modelOptions = buildModelOptions(
|
||||
config.knownModels as Record<string, Llms.ModelInfo>,
|
||||
);
|
||||
providerDisplayName = await getProviderDisplayName(config.providerId);
|
||||
// Free-text providers (openai-compatible) can still suggest model
|
||||
// ids when their endpoint answers /models; otherwise they keep the
|
||||
// manual input.
|
||||
endpointModelOptions = usesModelIdInput(config.providerId)
|
||||
? buildModelOptions(
|
||||
Object.fromEntries(
|
||||
(await fetchOpenAiCompatibleModelIds(config.providerId)).map(
|
||||
(id) => [id, { id, name: id }],
|
||||
),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
if (endpointModelOptions.length > 0) {
|
||||
modelOptions = endpointModelOptions;
|
||||
}
|
||||
};
|
||||
|
||||
if (!options?.startWithProviderChange) {
|
||||
@@ -341,7 +402,10 @@ export function useModelSelector(opts: {
|
||||
let pickingModel = true;
|
||||
|
||||
while (pickingModel) {
|
||||
if (usesModelIdInput(config.providerId)) {
|
||||
if (
|
||||
usesModelIdInput(config.providerId) &&
|
||||
endpointModelOptions.length === 0
|
||||
) {
|
||||
const modelId = await dialog.choice<string>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
content: (ctx: ChoiceContext<string>) => (
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type TerminalTitleRenderer,
|
||||
useTerminalTitle,
|
||||
} from "./use-terminal-title";
|
||||
|
||||
const reactMock = vi.hoisted(() => {
|
||||
const cleanups: Array<() => void> = [];
|
||||
return {
|
||||
cleanups,
|
||||
// Run effect bodies now, but retain their cleanups so each test can move
|
||||
// the renderer across the native destruction boundary before unmount.
|
||||
useEffect: vi.fn((effect: () => undefined | (() => void)) => {
|
||||
const cleanup = effect();
|
||||
if (cleanup) {
|
||||
cleanups.push(cleanup);
|
||||
}
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react", () => ({
|
||||
useEffect: reactMock.useEffect,
|
||||
}));
|
||||
|
||||
function createTitleRenderer() {
|
||||
let destroyed = false;
|
||||
const setTerminalTitle = vi.fn(() => {
|
||||
if (destroyed) {
|
||||
throw new Error("setTerminalTitle called after renderer destruction");
|
||||
}
|
||||
});
|
||||
const renderer: TerminalTitleRenderer = {
|
||||
get isDestroyed() {
|
||||
return destroyed;
|
||||
},
|
||||
setTerminalTitle,
|
||||
};
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
destroyed = true;
|
||||
},
|
||||
renderer,
|
||||
setTerminalTitle,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
reactMock.cleanups.length = 0;
|
||||
reactMock.useEffect.mockClear();
|
||||
});
|
||||
|
||||
describe("useTerminalTitle", () => {
|
||||
it("sets and resets the title while the renderer is active", () => {
|
||||
const titleRenderer = createTitleRenderer();
|
||||
|
||||
useTerminalTitle(titleRenderer.renderer, "Cline");
|
||||
expect(titleRenderer.setTerminalTitle).toHaveBeenNthCalledWith(1, "Cline");
|
||||
|
||||
for (const cleanup of reactMock.cleanups) {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
expect(titleRenderer.setTerminalTitle).toHaveBeenCalledTimes(2);
|
||||
expect(titleRenderer.setTerminalTitle).toHaveBeenNthCalledWith(2, "");
|
||||
});
|
||||
|
||||
it("does not set the title when its effect runs after renderer destruction", () => {
|
||||
const titleRenderer = createTitleRenderer();
|
||||
titleRenderer.destroy();
|
||||
|
||||
useTerminalTitle(titleRenderer.renderer, "Cline");
|
||||
|
||||
expect(titleRenderer.setTerminalTitle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not reset the title when cleanup runs after renderer destruction", () => {
|
||||
const titleRenderer = createTitleRenderer();
|
||||
|
||||
useTerminalTitle(titleRenderer.renderer, "Cline");
|
||||
expect(titleRenderer.setTerminalTitle).toHaveBeenCalledOnce();
|
||||
|
||||
titleRenderer.destroy();
|
||||
for (const cleanup of reactMock.cleanups) {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
expect(titleRenderer.setTerminalTitle).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
export interface TerminalTitleRenderer {
|
||||
readonly isDestroyed: boolean;
|
||||
setTerminalTitle(title: string): void;
|
||||
}
|
||||
|
||||
export function useTerminalTitle(
|
||||
renderer: TerminalTitleRenderer,
|
||||
terminalTitle: string,
|
||||
): void {
|
||||
// setTerminalTitle writes into memory owned by the native renderer, so it
|
||||
// must never run after destroy. React can flush passive effects after the
|
||||
// renderer's memory has been freed.
|
||||
useEffect(() => {
|
||||
if (renderer.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
renderer.setTerminalTitle(terminalTitle);
|
||||
}, [renderer, terminalTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (!renderer.isDestroyed) {
|
||||
renderer.setTerminalTitle("");
|
||||
}
|
||||
};
|
||||
}, [renderer]);
|
||||
}
|
||||
@@ -8,7 +8,9 @@ const rendererMock = vi.hoisted(() => ({
|
||||
defaultBackground: null,
|
||||
defaultForeground: null,
|
||||
})),
|
||||
isDestroyed: false,
|
||||
on: vi.fn(),
|
||||
setTerminalTitle: vi.fn(),
|
||||
}));
|
||||
|
||||
const rootMock = vi.hoisted(() => ({
|
||||
@@ -37,7 +39,9 @@ describe("renderOpenTui", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
destroyHandlers.length = 0;
|
||||
rendererMock.isDestroyed = false;
|
||||
rendererMock.destroy.mockReset();
|
||||
rendererMock.setTerminalTitle.mockReset();
|
||||
rendererMock.on.mockReset();
|
||||
rendererMock.on.mockImplementation((event: string, handler: () => void) => {
|
||||
if (event === "destroy") {
|
||||
@@ -96,4 +100,37 @@ describe("renderOpenTui", () => {
|
||||
expect(rendererMock.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(rootMock.unmount).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("resets the terminal title before destroying the renderer", async () => {
|
||||
const { renderOpenTui } = await import("./index");
|
||||
const tui = await renderOpenTui({} as TuiProps);
|
||||
|
||||
tui.destroy();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(rendererMock.setTerminalTitle).toHaveBeenCalledWith("");
|
||||
expect(rendererMock.destroy).toHaveBeenCalledTimes(1);
|
||||
const titleCallOrder =
|
||||
rendererMock.setTerminalTitle.mock.invocationCallOrder[0];
|
||||
const destroyCallOrder = rendererMock.destroy.mock.invocationCallOrder[0];
|
||||
expect(titleCallOrder).toBeLessThan(destroyCallOrder);
|
||||
});
|
||||
|
||||
it("skips the title reset when the renderer is destroyed before the teardown microtask runs", async () => {
|
||||
const { renderOpenTui } = await import("./index");
|
||||
const tui = await renderOpenTui({} as TuiProps);
|
||||
|
||||
tui.destroy();
|
||||
// Simulate OpenTUI's own signal handler destroying the renderer in the
|
||||
// same dispatch (e.g. an idle SIGTERM fires both our handler and
|
||||
// OpenTUI's exitHandler before microtasks drain).
|
||||
rendererMock.isDestroyed = true;
|
||||
for (const handler of destroyHandlers) {
|
||||
handler();
|
||||
}
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(rendererMock.setTerminalTitle).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,6 +67,14 @@ export async function renderOpenTui(
|
||||
unmountRoot();
|
||||
// Let OpenTUI finish parsing the current stdin batch before teardown.
|
||||
queueMicrotask(() => {
|
||||
// Reset the title while the native renderer is still alive; the
|
||||
// unmount cleanup in root.tsx skips it once the renderer is destroyed.
|
||||
// Re-check here: OpenTUI's own signal handlers can destroy the
|
||||
// renderer between destroy() queuing this microtask and it running
|
||||
// (e.g. an idle SIGTERM dispatches to both our handler and OpenTUI's).
|
||||
if (!renderer.isDestroyed) {
|
||||
renderer.setTerminalTitle("");
|
||||
}
|
||||
renderer.destroy();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
type UserInstructionConfigService,
|
||||
type WorkflowConfig,
|
||||
} from "@cline/core";
|
||||
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
|
||||
import { getToolCatalog } from "../runtime/tools";
|
||||
import {
|
||||
type InteractiveSlashCommand,
|
||||
@@ -195,7 +196,7 @@ function loadAgentConfigItems(workspaceRoot: string): InteractiveConfigItem[] {
|
||||
continue;
|
||||
}
|
||||
const filePath = join(directory, entry.name);
|
||||
const raw = readFileSync(filePath, "utf8");
|
||||
const raw = readFileSyncStrippingUtf8Bom(filePath);
|
||||
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
const frontmatter = frontmatterMatch?.[1] ?? "";
|
||||
const nameMatch = frontmatter.match(/^\s*name:\s*(.+?)\s*$/m);
|
||||
|
||||
@@ -53,6 +53,7 @@ import { useRootKeyboard } from "./hooks/use-root-keyboard";
|
||||
import { useRuntimeDialogBridge } from "./hooks/use-runtime-dialog-bridge";
|
||||
import { useSlashCommands } from "./hooks/use-slash-commands";
|
||||
import { TerminalColorsContext } from "./hooks/use-terminal-background";
|
||||
import { useTerminalTitle } from "./hooks/use-terminal-title";
|
||||
import type { AppView, TuiProps } from "./types";
|
||||
import { hydrateSessionMessages } from "./utils/hydrate-messages";
|
||||
import { isProviderConfigured } from "./utils/provider-configured";
|
||||
@@ -472,15 +473,7 @@ function App(props: TuiProps) {
|
||||
};
|
||||
}, [renderer, showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
renderer.setTerminalTitle(terminalTitle);
|
||||
}, [renderer, terminalTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
renderer.setTerminalTitle("");
|
||||
};
|
||||
}, [renderer]);
|
||||
useTerminalTitle(renderer, terminalTitle);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -44,6 +44,15 @@ export type ChatEntry = (
|
||||
}
|
||||
| { kind: "error"; text: string }
|
||||
| { kind: "status"; text: string }
|
||||
| {
|
||||
kind: "compaction";
|
||||
compactionMode: "auto" | "manual" | "inherited";
|
||||
status: "started" | "completed" | "skipped" | "failed" | "cancelled";
|
||||
tokensBefore?: number;
|
||||
tokensAfter?: number;
|
||||
messagesBefore?: number;
|
||||
messagesAfter?: number;
|
||||
}
|
||||
| { kind: "team"; text: string }
|
||||
| { kind: "user_submitted"; text: string; delivery?: "queue" | "steer" }
|
||||
| {
|
||||
@@ -186,7 +195,15 @@ export interface TuiProps {
|
||||
onResumeSession: (sessionId: string) => Promise<ResumedSessionResult>;
|
||||
onCompact: () => Promise<InteractiveCompactionResult>;
|
||||
onFork: () => Promise<
|
||||
{ forkedFromSessionId: string; newSessionId: string } | undefined
|
||||
| {
|
||||
forkedFromSessionId: string;
|
||||
newSessionId: string;
|
||||
carriedWorkingContext?: {
|
||||
workingContextMessages: number;
|
||||
canonicalMessages: number;
|
||||
};
|
||||
}
|
||||
| undefined
|
||||
>;
|
||||
getCheckpointData: () => Promise<
|
||||
{ messages: Message[]; checkpointHistory: CheckpointEntry[] } | undefined
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatCompactionDividerLabel,
|
||||
formatTokenCount,
|
||||
parseCompactionNoticeMetadata,
|
||||
} from "./compaction-status";
|
||||
|
||||
describe("parseCompactionNoticeMetadata", () => {
|
||||
it("extracts a divider entry from a completed auto-compaction notice", () => {
|
||||
expect(
|
||||
parseCompactionNoticeMetadata({
|
||||
kind: "auto_compaction",
|
||||
reason: "auto_compaction",
|
||||
phase: "completed",
|
||||
tokensBefore: 25_101,
|
||||
tokensAfter: 6_300,
|
||||
messagesBefore: 142,
|
||||
messagesAfter: 9,
|
||||
}),
|
||||
).toEqual({
|
||||
compactionMode: "auto",
|
||||
status: "completed",
|
||||
tokensBefore: 25_101,
|
||||
tokensAfter: 6_300,
|
||||
messagesBefore: 142,
|
||||
messagesAfter: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts a streaming divider entry from a started notice", () => {
|
||||
expect(
|
||||
parseCompactionNoticeMetadata({
|
||||
kind: "auto_compaction",
|
||||
phase: "started",
|
||||
}),
|
||||
).toEqual({ compactionMode: "auto", status: "started" });
|
||||
});
|
||||
|
||||
it("maps manual compaction notices to manual mode", () => {
|
||||
expect(
|
||||
parseCompactionNoticeMetadata({
|
||||
kind: "manual_compaction",
|
||||
phase: "completed",
|
||||
})?.compactionMode,
|
||||
).toBe("manual");
|
||||
});
|
||||
|
||||
it("maps a benign no-result terminal notice to skipped", () => {
|
||||
expect(
|
||||
parseCompactionNoticeMetadata({
|
||||
kind: "auto_compaction",
|
||||
phase: "skipped",
|
||||
}),
|
||||
).toEqual({ compactionMode: "auto", status: "skipped" });
|
||||
});
|
||||
|
||||
it("ignores non-compaction metadata", () => {
|
||||
expect(
|
||||
parseCompactionNoticeMetadata({ kind: "recovery", phase: "completed" }),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
parseCompactionNoticeMetadata({ kind: "auto_compaction" }),
|
||||
).toBeUndefined();
|
||||
expect(parseCompactionNoticeMetadata(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops non-numeric counters instead of rendering garbage", () => {
|
||||
const parsed = parseCompactionNoticeMetadata({
|
||||
kind: "auto_compaction",
|
||||
phase: "completed",
|
||||
tokensBefore: "25000",
|
||||
tokensAfter: Number.NaN,
|
||||
});
|
||||
expect(parsed?.tokensBefore).toBeUndefined();
|
||||
expect(parsed?.tokensAfter).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTokenCount", () => {
|
||||
it("formats counts into compact units", () => {
|
||||
expect(formatTokenCount(999)).toBe("999");
|
||||
expect(formatTokenCount(6_300)).toBe("6.3k");
|
||||
expect(formatTokenCount(25_000)).toBe("25k");
|
||||
expect(formatTokenCount(1_200_000)).toBe("1.2M");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCompactionDividerLabel", () => {
|
||||
it("includes token and message deltas when present", () => {
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "auto",
|
||||
status: "completed",
|
||||
tokensBefore: 25_101,
|
||||
tokensAfter: 6_300,
|
||||
messagesBefore: 142,
|
||||
messagesAfter: 9,
|
||||
}),
|
||||
).toBe("Context compacted · 25.1k → 6.3k tokens · 142 → 9 messages");
|
||||
});
|
||||
|
||||
it("labels in-progress compaction", () => {
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "auto",
|
||||
status: "started",
|
||||
}),
|
||||
).toBe("Auto compacting messages");
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "manual",
|
||||
status: "started",
|
||||
}),
|
||||
).toBe("Compacting messages");
|
||||
});
|
||||
|
||||
it("labels failed and cancelled compaction", () => {
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "auto",
|
||||
status: "failed",
|
||||
}),
|
||||
).toBe("Compaction failed");
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "auto",
|
||||
status: "cancelled",
|
||||
}),
|
||||
).toBe("Compaction cancelled");
|
||||
});
|
||||
|
||||
it("labels skipped compaction without calling it cancelled", () => {
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "auto",
|
||||
status: "skipped",
|
||||
}),
|
||||
).toBe("Compaction skipped");
|
||||
});
|
||||
|
||||
it("labels inherited working context from forks and restarts", () => {
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "inherited",
|
||||
status: "completed",
|
||||
messagesBefore: 60,
|
||||
messagesAfter: 15,
|
||||
}),
|
||||
).toBe("Compacted working context carried over · 60 → 15 messages");
|
||||
});
|
||||
|
||||
it("labels manual compaction and omits missing counters", () => {
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "manual",
|
||||
status: "completed",
|
||||
}),
|
||||
).toBe("Context compacted (manual)");
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,106 @@
|
||||
import type { InteractiveCompactionResult } from "../types";
|
||||
import type { ChatEntry, InteractiveCompactionResult } from "../types";
|
||||
|
||||
export type CompactionDividerEntry = Extract<ChatEntry, { kind: "compaction" }>;
|
||||
|
||||
function formatMessageCount(count: number): string {
|
||||
return `${count} ${count === 1 ? "message" : "messages"}`;
|
||||
}
|
||||
|
||||
function asFiniteNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a compaction divider entry from a status notice's metadata.
|
||||
* "started" notices produce a streaming (in-progress) divider; "completed"
|
||||
* notices produce the final divider with counters. Returns undefined for
|
||||
* non-compaction notices.
|
||||
*/
|
||||
export function parseCompactionNoticeMetadata(
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
): Omit<CompactionDividerEntry, "kind"> | undefined {
|
||||
if (
|
||||
!metadata ||
|
||||
(metadata.phase !== "started" &&
|
||||
metadata.phase !== "completed" &&
|
||||
metadata.phase !== "skipped")
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const kind = metadata.kind ?? metadata.reason;
|
||||
if (kind !== "auto_compaction" && kind !== "manual_compaction") {
|
||||
return undefined;
|
||||
}
|
||||
const compactionMode = kind === "manual_compaction" ? "manual" : "auto";
|
||||
if (metadata.phase === "started") {
|
||||
return { compactionMode, status: "started" };
|
||||
}
|
||||
if (metadata.phase === "skipped") {
|
||||
return { compactionMode, status: "skipped" };
|
||||
}
|
||||
return {
|
||||
compactionMode,
|
||||
status: "completed",
|
||||
tokensBefore: asFiniteNumber(metadata.tokensBefore),
|
||||
tokensAfter: asFiniteNumber(metadata.tokensAfter),
|
||||
messagesBefore: asFiniteNumber(metadata.messagesBefore),
|
||||
messagesAfter: asFiniteNumber(metadata.messagesAfter),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatTokenCount(count: number): string {
|
||||
if (count < 1_000) {
|
||||
return `${count}`;
|
||||
}
|
||||
if (count < 1_000_000) {
|
||||
return `${(count / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
|
||||
}
|
||||
return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
|
||||
}
|
||||
|
||||
export function formatCompactionDividerLabel(
|
||||
entry: CompactionDividerEntry,
|
||||
): string {
|
||||
if (entry.status === "started") {
|
||||
return entry.compactionMode === "manual"
|
||||
? "Compacting messages"
|
||||
: "Auto compacting messages";
|
||||
}
|
||||
if (entry.status === "failed") {
|
||||
return "Compaction failed";
|
||||
}
|
||||
if (entry.status === "cancelled") {
|
||||
return "Compaction cancelled";
|
||||
}
|
||||
if (entry.status === "skipped") {
|
||||
return "Compaction skipped";
|
||||
}
|
||||
const parts: string[] = [
|
||||
entry.compactionMode === "manual"
|
||||
? "Context compacted (manual)"
|
||||
: entry.compactionMode === "inherited"
|
||||
? "Compacted working context carried over"
|
||||
: "Context compacted",
|
||||
];
|
||||
if (
|
||||
typeof entry.tokensBefore === "number" &&
|
||||
typeof entry.tokensAfter === "number"
|
||||
) {
|
||||
parts.push(
|
||||
`${formatTokenCount(entry.tokensBefore)} → ${formatTokenCount(entry.tokensAfter)} tokens`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof entry.messagesBefore === "number" &&
|
||||
typeof entry.messagesAfter === "number"
|
||||
) {
|
||||
parts.push(`${entry.messagesBefore} → ${entry.messagesAfter} messages`);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
export function formatCompactionStatus(
|
||||
result: InteractiveCompactionResult,
|
||||
): string {
|
||||
|
||||
@@ -14,6 +14,15 @@ export type ChatCommandState = {
|
||||
export type ForkSessionResult = {
|
||||
forkedFromSessionId: string;
|
||||
newSessionId: string;
|
||||
/**
|
||||
* Present when the source session had valid compaction state that was
|
||||
* re-anchored onto the forked session, so the UI can surface why the
|
||||
* next request is smaller than the canonical history.
|
||||
*/
|
||||
carriedWorkingContext?: {
|
||||
workingContextMessages: number;
|
||||
canonicalMessages: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type MuteCommandInput = {
|
||||
|
||||
@@ -26,20 +26,20 @@ describe("CLI compaction mode helpers", () => {
|
||||
});
|
||||
|
||||
it("maps basic and off modes to core compaction config", () => {
|
||||
const config = createConfig({ enabled: true, maxInputTokens: 123 });
|
||||
const config = createConfig({ enabled: true, preserveRecentTokens: 123 });
|
||||
|
||||
applyCliCompactionMode(config, "basic");
|
||||
expect(config.compaction).toEqual({
|
||||
enabled: true,
|
||||
strategy: "basic",
|
||||
maxInputTokens: 123,
|
||||
preserveRecentTokens: 123,
|
||||
});
|
||||
expect(getCliCompactionMode(config)).toBe("basic");
|
||||
|
||||
applyCliCompactionMode(config, "off");
|
||||
expect(config.compaction).toEqual({
|
||||
enabled: false,
|
||||
maxInputTokens: 123,
|
||||
preserveRecentTokens: 123,
|
||||
});
|
||||
expect(getCliCompactionMode(config)).toBe("off");
|
||||
});
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { AgentEvent, TeamEvent } from "@cline/core";
|
||||
import {
|
||||
formatCompactionDividerLabel,
|
||||
parseCompactionNoticeMetadata,
|
||||
} from "../tui/utils/compaction-status";
|
||||
import { formatCliErrorMessage } from "./cline-pass-errors";
|
||||
import { formatToolInput, formatToolOutput, truncate } from "./helpers";
|
||||
import {
|
||||
@@ -24,6 +28,24 @@ const TEAM_RUN_ACTIVE_SUFFIX = `${c.dim} ...${c.reset}`;
|
||||
|
||||
export function resolveStatusNoticeLabel(
|
||||
event: AgentEvent,
|
||||
): string | undefined {
|
||||
if (event.type !== "notice" || event.displayRole !== "status") {
|
||||
return undefined;
|
||||
}
|
||||
const compaction = parseCompactionNoticeMetadata(event.metadata);
|
||||
if (compaction) {
|
||||
return formatCompactionDividerLabel({ kind: "compaction", ...compaction });
|
||||
}
|
||||
return resolveNonCompactionStatusLabel(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Label for a status notice already known not to be a compaction notice.
|
||||
* Callers that have parsed the compaction metadata themselves use this to
|
||||
* avoid re-parsing.
|
||||
*/
|
||||
export function resolveNonCompactionStatusLabel(
|
||||
event: AgentEvent,
|
||||
): string | undefined {
|
||||
if (event.type !== "notice" || event.displayRole !== "status") {
|
||||
return undefined;
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
resolvePluginConfigSearchPaths,
|
||||
resolveAgentConfigSearchPaths as resolveSharedAgentConfigSearchPaths,
|
||||
} from "@cline/core";
|
||||
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
|
||||
import { readMcpServersResponse } from "./mcp";
|
||||
import type { JsonRecord } from "./types";
|
||||
|
||||
@@ -114,7 +115,7 @@ export async function listUserInstructionConfigs(
|
||||
const ext = extname(entry.name).toLowerCase();
|
||||
if (ext !== ".yml" && ext !== ".yaml") continue;
|
||||
const filePath = join(directory, entry.name);
|
||||
const raw = readFileSync(filePath, "utf8");
|
||||
const raw = readFileSyncStrippingUtf8Bom(filePath);
|
||||
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
const fm = fmMatch?.[1] ?? "";
|
||||
const nameMatch = fm.match(/^\s*name:\s*(.+?)\s*$/m);
|
||||
|
||||
@@ -16,6 +16,15 @@ From `apps/examples/desktop-app/`:
|
||||
- `bun run package:desktop` - package the current OS desktop app into `dist/desktop/`
|
||||
- `bun run typecheck` - TypeScript check
|
||||
|
||||
## Web Visual System
|
||||
|
||||
The framework-neutral color, typography, radius, and navigation contract lives
|
||||
in the internal [`@cline/ui`](../../../sdk/packages/ui/README.md) workspace
|
||||
package. Other Cline web surfaces can take only its tokens or opt into the
|
||||
Tailwind adapter and shared base styles without depending on the desktop
|
||||
runtime. See [`webview/styles/README.md`](./webview/styles/README.md) for the
|
||||
desktop integration notes.
|
||||
|
||||
## Shareable Desktop Packages
|
||||
|
||||
Tauri desktop bundles are OS-specific, so build each package on the target OS:
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:ui": "bun -F @cline/ui build",
|
||||
"predev:web": "bun run build:ui",
|
||||
"dev:web": "next dev webview -p 3125 --turbo",
|
||||
"dev:sidecar": "bun run sidecar/index.ts",
|
||||
"dev": "tauri dev",
|
||||
"prebuild": "bun run build:ui",
|
||||
"build": "bun run bun.mts",
|
||||
"build:sidecar": "mkdir -p dist/sidecar && bun build ./sidecar/index.ts --outfile ./dist/sidecar/index.js --target bun",
|
||||
"build:sidecar:bin": "bun run scripts/build-sidecar-bin.ts",
|
||||
@@ -16,7 +19,10 @@
|
||||
"package:desktop:windows": "bun run scripts/package-desktop.ts --platform windows",
|
||||
"package:desktop:linux": "bun run scripts/package-desktop.ts --platform linux",
|
||||
"start": "next start webview",
|
||||
"pretypecheck": "bun run build:ui",
|
||||
"typecheck": "tsc -p tsconfig.dev.json --noEmit",
|
||||
"pretest:chat-ui": "bun run build:ui",
|
||||
"test:chat-ui": "vitest run webview/components/views/chat/chat-messages.test.tsx --config vitest.config.ts",
|
||||
"clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -24,6 +30,7 @@
|
||||
"@cline/core": "workspace:*",
|
||||
"@cline/llms": "workspace:*",
|
||||
"@cline/shared": "workspace:*",
|
||||
"@cline/ui": "workspace:*",
|
||||
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
|
||||
"@fontsource/azeret-mono": "^5.2.9",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
@@ -54,6 +61,9 @@
|
||||
"@radix-ui/react-toggle": "1.1.10",
|
||||
"@radix-ui/react-toggle-group": "1.1.11",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@shikijs/langs": "^4.2.0",
|
||||
"@shikijs/themes": "^4.2.0",
|
||||
"@streamdown/cjk": "^1.0.3",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@vercel/analytics": "1.6.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
@@ -64,7 +74,6 @@
|
||||
"embla-carousel-react": "8.6.0",
|
||||
"input-otp": "1.4.2",
|
||||
"lucide-react": "^0.564.0",
|
||||
"marked": "^17.0.3",
|
||||
"next": "16.2.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
@@ -72,11 +81,11 @@
|
||||
"react-day-picker": "9.13.2",
|
||||
"react-dom": "19.2.4",
|
||||
"react-hook-form": "^7.54.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"recharts": "2.15.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shiki": "^4.0.2",
|
||||
"sonner": "^1.7.1",
|
||||
"streamdown": "^2.5.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^3.24.1"
|
||||
@@ -86,6 +95,7 @@
|
||||
"@tailwindcss/postcss": "^4.2.0",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"jsdom": "^26.0.0",
|
||||
"postcss": "^8.5",
|
||||
"tailwindcss": "^4.2.0",
|
||||
"tw-animate-css": "1.3.3",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSessionConnectionUpdate } from "./chat-session";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildSessionConnectionUpdate,
|
||||
consumeWorkspaceMetadata,
|
||||
handleChatSessionCommand,
|
||||
prewarmWorkspaceMetadata,
|
||||
shouldUpdateSessionConnection,
|
||||
WORKSPACE_METADATA_PREWARM_TTL_MS,
|
||||
} from "./chat-session";
|
||||
import type { SidecarContext } from "./types";
|
||||
|
||||
describe("buildSessionConnectionUpdate", () => {
|
||||
it("does not clear reasoning settings when config omits reasoning fields", () => {
|
||||
@@ -49,3 +57,188 @@ describe("buildSessionConnectionUpdate", () => {
|
||||
expect(Object.hasOwn(update, "thinkingBudgetTokens")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldUpdateSessionConnection", () => {
|
||||
it("skips the redundant connection update on the first send", () => {
|
||||
const config = {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
thinking: true,
|
||||
reasoningEffort: "high",
|
||||
};
|
||||
|
||||
expect(shouldUpdateSessionConnection(config, { ...config })).toBe(false);
|
||||
});
|
||||
|
||||
it("updates the connection when the selected reasoning level changes", () => {
|
||||
const current = {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
thinking: true,
|
||||
reasoningEffort: "low",
|
||||
};
|
||||
|
||||
expect(
|
||||
shouldUpdateSessionConnection(current, {
|
||||
...current,
|
||||
reasoningEffort: "high",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("first-send connection updates", () => {
|
||||
const baseConfig = {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
thinking: true,
|
||||
reasoningEffort: "high",
|
||||
};
|
||||
|
||||
function createContext(options?: {
|
||||
attachedViaHub?: boolean;
|
||||
config?: Record<string, unknown>;
|
||||
}) {
|
||||
const updateSessionConnection = vi.fn(async () => undefined);
|
||||
const send = vi.fn(async () => ({
|
||||
text: "done",
|
||||
finishReason: "completed",
|
||||
messages: [],
|
||||
}));
|
||||
const sessionId = "session-connection-test";
|
||||
const ctx = {
|
||||
liveSessions: new Map([
|
||||
[
|
||||
sessionId,
|
||||
{
|
||||
config: options?.config ?? baseConfig,
|
||||
messages: [],
|
||||
promptsInQueue: [],
|
||||
busy: false,
|
||||
startedAt: Date.now(),
|
||||
status: "idle",
|
||||
attachedViaHub: options?.attachedViaHub ?? false,
|
||||
},
|
||||
],
|
||||
]),
|
||||
sessionManager: { send, updateSessionConnection },
|
||||
} as unknown as SidecarContext;
|
||||
return { ctx, send, sessionId, updateSessionConnection };
|
||||
}
|
||||
|
||||
it("skips an identical update for a locally-created session", async () => {
|
||||
const { ctx, send, sessionId, updateSessionConnection } = createContext();
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "hello",
|
||||
config: { ...baseConfig },
|
||||
});
|
||||
|
||||
expect(updateSessionConnection).not.toHaveBeenCalled();
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("updates a changed connection before sending", async () => {
|
||||
const { ctx, send, sessionId, updateSessionConnection } = createContext({
|
||||
config: { ...baseConfig, reasoningEffort: "low" },
|
||||
});
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "hello",
|
||||
config: { ...baseConfig },
|
||||
});
|
||||
|
||||
expect(updateSessionConnection).toHaveBeenCalledTimes(1);
|
||||
expect(updateSessionConnection.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
send.mock.invocationCallOrder[0] ?? 0,
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes hub-attached sessions even when the cached config matches", async () => {
|
||||
const { ctx, sessionId, updateSessionConnection } = createContext({
|
||||
attachedViaHub: true,
|
||||
});
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "hello",
|
||||
config: { ...baseConfig },
|
||||
});
|
||||
|
||||
expect(updateSessionConnection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace metadata prewarming", () => {
|
||||
it("reuses one in-flight scan and consumes it only once", async () => {
|
||||
let resolveFirst: ((value: string) => void) | undefined;
|
||||
const firstResult = new Promise<string>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
});
|
||||
const load = vi
|
||||
.fn<(cwd: string) => Promise<string>>()
|
||||
.mockImplementationOnce(async () => await firstResult)
|
||||
.mockResolvedValueOnce("fresh metadata");
|
||||
const cwd = "/tmp/cline-desktop-prewarm-reuse";
|
||||
|
||||
prewarmWorkspaceMetadata(cwd, load);
|
||||
const consumed = consumeWorkspaceMetadata(cwd, load);
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
resolveFirst?.("prewarmed metadata");
|
||||
|
||||
await expect(consumed).resolves.toBe("prewarmed metadata");
|
||||
await expect(consumeWorkspaceMetadata(cwd, load)).resolves.toBe(
|
||||
"fresh metadata",
|
||||
);
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("evicts failed scans so the next session can retry", async () => {
|
||||
const load = vi
|
||||
.fn<(cwd: string) => Promise<string>>()
|
||||
.mockRejectedValueOnce(new Error("git unavailable"))
|
||||
.mockResolvedValueOnce("recovered metadata");
|
||||
const cwd = "/tmp/cline-desktop-prewarm-retry";
|
||||
|
||||
prewarmWorkspaceMetadata(cwd, load);
|
||||
await expect(consumeWorkspaceMetadata(cwd, load)).rejects.toThrow(
|
||||
"git unavailable",
|
||||
);
|
||||
await expect(consumeWorkspaceMetadata(cwd, load)).resolves.toBe(
|
||||
"recovered metadata",
|
||||
);
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps different workspaces in separate single-flight entries", () => {
|
||||
const load = vi.fn(async (cwd: string) => `metadata for ${cwd}`);
|
||||
|
||||
prewarmWorkspaceMetadata("/tmp/cline-desktop-prewarm-a", load);
|
||||
prewarmWorkspaceMetadata("/tmp/cline-desktop-prewarm-b", load);
|
||||
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("refreshes a prewarm that is older than the startup window", async () => {
|
||||
const load = vi
|
||||
.fn<(cwd: string) => Promise<string>>()
|
||||
.mockResolvedValueOnce("startup metadata")
|
||||
.mockResolvedValueOnce("current metadata");
|
||||
const cwd = "/tmp/cline-desktop-prewarm-expired";
|
||||
|
||||
prewarmWorkspaceMetadata(cwd, load, () => 0);
|
||||
await expect(
|
||||
consumeWorkspaceMetadata(
|
||||
cwd,
|
||||
load,
|
||||
() => WORKSPACE_METADATA_PREWARM_TTL_MS + 1,
|
||||
),
|
||||
).resolves.toBe("current metadata");
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import {
|
||||
buildConnectionUpdate,
|
||||
buildWorkspaceMetadata,
|
||||
@@ -25,6 +26,69 @@ type SessionConnectionUpdate = Parameters<
|
||||
ClineCore["updateSessionConnection"]
|
||||
>[1];
|
||||
|
||||
type WorkspaceMetadataLoader = (cwd: string) => Promise<string>;
|
||||
type WorkspaceMetadataCacheEntry = {
|
||||
createdAt: number;
|
||||
promise: Promise<string>;
|
||||
};
|
||||
export const WORKSPACE_METADATA_PREWARM_TTL_MS = 60_000;
|
||||
const workspaceMetadataPromises = new Map<
|
||||
string,
|
||||
WorkspaceMetadataCacheEntry
|
||||
>();
|
||||
|
||||
function getWorkspaceMetadataPromise(
|
||||
cwd: string,
|
||||
load: WorkspaceMetadataLoader,
|
||||
now: () => number,
|
||||
): { key: string; promise: Promise<string> } {
|
||||
const key = resolve(cwd);
|
||||
const existing = workspaceMetadataPromises.get(key);
|
||||
const createdAt = now();
|
||||
if (
|
||||
existing &&
|
||||
createdAt - existing.createdAt <= WORKSPACE_METADATA_PREWARM_TTL_MS
|
||||
) {
|
||||
return { key, promise: existing.promise };
|
||||
}
|
||||
const promise = load(key);
|
||||
workspaceMetadataPromises.set(key, { createdAt, promise });
|
||||
void promise.catch(() => {
|
||||
if (workspaceMetadataPromises.get(key)?.promise === promise) {
|
||||
workspaceMetadataPromises.delete(key);
|
||||
}
|
||||
});
|
||||
return { key, promise };
|
||||
}
|
||||
|
||||
export function prewarmWorkspaceMetadata(
|
||||
cwd: string,
|
||||
load: WorkspaceMetadataLoader = buildWorkspaceMetadata,
|
||||
now: () => number = Date.now,
|
||||
): void {
|
||||
void getWorkspaceMetadataPromise(cwd, load, now).promise.catch(() => {});
|
||||
}
|
||||
|
||||
export async function consumeWorkspaceMetadata(
|
||||
cwd: string,
|
||||
load: WorkspaceMetadataLoader = buildWorkspaceMetadata,
|
||||
now: () => number = Date.now,
|
||||
): Promise<string> {
|
||||
const { key, promise } = getWorkspaceMetadataPromise(cwd, load, now);
|
||||
try {
|
||||
return await promise;
|
||||
} finally {
|
||||
if (workspaceMetadataPromises.get(key)?.promise === promise) {
|
||||
workspaceMetadataPromises.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function refreshWorkspaceMetadata(cwd: string): void {
|
||||
workspaceMetadataPromises.delete(resolve(cwd));
|
||||
prewarmWorkspaceMetadata(cwd);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session data helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -219,6 +283,16 @@ export function buildSessionConnectionUpdate(
|
||||
});
|
||||
}
|
||||
|
||||
export function shouldUpdateSessionConnection(
|
||||
currentConfig: JsonRecord,
|
||||
nextConfig: JsonRecord,
|
||||
): boolean {
|
||||
return !isDeepStrictEqual(
|
||||
buildSessionConnectionUpdate(currentConfig),
|
||||
buildSessionConnectionUpdate(nextConfig),
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveSystemPrompt(config: JsonRecord): Promise<string> {
|
||||
const cwd = String(
|
||||
config.cwd ?? config.workspaceRoot ?? config.workspace_root ?? "",
|
||||
@@ -232,7 +306,7 @@ async function resolveSystemPrompt(config: JsonRecord): Promise<string> {
|
||||
: config.mode === "plan"
|
||||
? "plan"
|
||||
: "act";
|
||||
const metadata = await buildWorkspaceMetadata(cwd);
|
||||
const metadata = await consumeWorkspaceMetadata(cwd);
|
||||
const inlineRules =
|
||||
typeof config.rules === "string" && config.rules.trim().length > 0
|
||||
? config.rules
|
||||
@@ -447,8 +521,14 @@ async function handleSend(
|
||||
const manager = getSessionManager(ctx);
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (request.config) {
|
||||
const connectionUpdate = buildSessionConnectionUpdate(request.config);
|
||||
await manager.updateSessionConnection(sessionId, connectionUpdate);
|
||||
if (
|
||||
!session ||
|
||||
session.attachedViaHub ||
|
||||
shouldUpdateSessionConnection(session.config, request.config)
|
||||
) {
|
||||
const connectionUpdate = buildSessionConnectionUpdate(request.config);
|
||||
await manager.updateSessionConnection(sessionId, connectionUpdate);
|
||||
}
|
||||
if (session) {
|
||||
session.config = { ...session.config, ...request.config };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
||||
import {
|
||||
existsSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { basename, dirname, extname, join } from "node:path";
|
||||
import type {
|
||||
ClineAccountActionRequest,
|
||||
@@ -44,6 +50,7 @@ import {
|
||||
updateMcpSettingsFileSync,
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
|
||||
import {
|
||||
connectorChannelsPayload,
|
||||
startConnectorChannel,
|
||||
@@ -552,7 +559,7 @@ async function listUserInstructionConfigs(
|
||||
const ext = extname(entry.name).toLowerCase();
|
||||
if (ext !== ".yml" && ext !== ".yaml") continue;
|
||||
const filePath = join(directory, entry.name);
|
||||
const raw = readFileSync(filePath, "utf8");
|
||||
const raw = readFileSyncStrippingUtf8Bom(filePath);
|
||||
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
const fm = fmMatch?.[1] ?? "";
|
||||
const nameMatch = fm.match(/^\s*name:\s*(.+?)\s*$/m);
|
||||
@@ -1163,10 +1170,13 @@ export async function handleCommand(
|
||||
|
||||
// ── Git operations ─────────────────────────────────────────────────
|
||||
if (command === "get_git_branch") {
|
||||
const branches = listGitBranches(
|
||||
ctx,
|
||||
typeof args?.cwd === "string" ? args.cwd : undefined,
|
||||
);
|
||||
const cwd =
|
||||
typeof args?.cwd === "string" && args.cwd.trim()
|
||||
? args.cwd.trim()
|
||||
: ctx.workspaceRoot;
|
||||
const branches = listGitBranches(ctx, cwd);
|
||||
const { prewarmWorkspaceMetadata } = await import("./chat-session");
|
||||
prewarmWorkspaceMetadata(cwd);
|
||||
return { branch: branches.current };
|
||||
}
|
||||
if (command === "list_git_branches") {
|
||||
@@ -1179,11 +1189,14 @@ export async function handleCommand(
|
||||
const cwd = typeof args?.cwd === "string" ? args.cwd : undefined;
|
||||
const branch = String(args?.branch ?? "").trim();
|
||||
if (!branch) throw new Error("branch is required");
|
||||
const targetCwd = cwd?.trim() || ctx.workspaceRoot;
|
||||
execFileSync("git", ["checkout", branch], {
|
||||
cwd: cwd?.trim() || ctx.workspaceRoot,
|
||||
cwd: targetCwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const { refreshWorkspaceMetadata } = await import("./chat-session");
|
||||
refreshWorkspaceMetadata(targetCwd);
|
||||
return { branch };
|
||||
}
|
||||
|
||||
@@ -1252,6 +1265,15 @@ export async function handleCommand(
|
||||
}
|
||||
|
||||
// ── Native OS commands ────────────────────────────────────────────
|
||||
if (command === "validate_workspace_directory") {
|
||||
const workspacePath = String(args?.path ?? "").trim();
|
||||
if (!workspacePath) return { valid: false };
|
||||
try {
|
||||
return { valid: statSync(workspacePath).isDirectory() };
|
||||
} catch {
|
||||
return { valid: false };
|
||||
}
|
||||
}
|
||||
if (command === "pick_workspace_directory") {
|
||||
return pickWorkspaceDirectory();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { prewarmWorkspaceMetadata } from "./chat-session";
|
||||
import {
|
||||
createSidecarContext,
|
||||
disposeSidecarContext,
|
||||
@@ -34,6 +35,7 @@ async function main() {
|
||||
const workspaceRoot = resolveWorkspaceRoot(process.cwd());
|
||||
const ctx = createSidecarContext(workspaceRoot);
|
||||
|
||||
prewarmWorkspaceMetadata(workspaceRoot);
|
||||
await initializeSessionManager(ctx);
|
||||
|
||||
let shuttingDown = false;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": fileURLToPath(new URL("./webview", import.meta.url)),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
|
||||
@@ -2,181 +2,10 @@
|
||||
@import "@fontsource/azeret-mono/latin.css";
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "@cline/ui/theme/index.css";
|
||||
@import "@cline/ui/components/agent-chat.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--font-desktop-sans: "Schibsted Grotesk Variable";
|
||||
--font-desktop-mono: "Azeret Mono";
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.75 0.12 165);
|
||||
--primary-foreground: oklch(0.962 0.018 272.314);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--radius: 0.5rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.75 0.12 165);
|
||||
--primary-foreground: oklch(0.962 0.018 272.314);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.585 0.233 277.117);
|
||||
--sidebar-primary-foreground: oklch(0.962 0.018 272.314);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: var(--font-desktop-sans), sans-serif;
|
||||
--font-mono:
|
||||
var(--font-desktop-mono), ui-monospace, "SFMono-Regular", Menlo, Consolas,
|
||||
"Liberation Mono", monospace;
|
||||
--font-weight-normal: 480;
|
||||
--font-weight-medium: 560;
|
||||
--font-weight-semibold: 640;
|
||||
--font-weight-bold: 640;
|
||||
--text-step-1: 12px;
|
||||
--text-step-1--line-height: 16px;
|
||||
--text-step-1--letter-spacing: 0.0025em;
|
||||
--text-step-2: 14px;
|
||||
--text-step-2--line-height: 20px;
|
||||
--text-step-2--letter-spacing: 0em;
|
||||
--text-step-3: 16px;
|
||||
--text-step-3--line-height: 24px;
|
||||
--text-step-3--letter-spacing: 0em;
|
||||
--text-step-4: 18px;
|
||||
--text-step-4--line-height: 26px;
|
||||
--text-step-4--letter-spacing: -0.0025em;
|
||||
--text-step-5: 20px;
|
||||
--text-step-5--line-height: 28px;
|
||||
--text-step-5--letter-spacing: -0.005em;
|
||||
--text-step-6: 24px;
|
||||
--text-step-6--line-height: 30px;
|
||||
--text-step-6--letter-spacing: -0.00625em;
|
||||
--text-step-7: 28px;
|
||||
--text-step-7--line-height: 36px;
|
||||
--text-step-7--letter-spacing: -0.0075em;
|
||||
--text-step-8: 35px;
|
||||
--text-step-8--line-height: 40px;
|
||||
--text-step-8--letter-spacing: -0.01em;
|
||||
--text-step-9: 60px;
|
||||
--text-step-9--line-height: 60px;
|
||||
--text-step-9--letter-spacing: -0.025em;
|
||||
--text-xs: var(--text-step-1);
|
||||
--text-xs--line-height: var(--text-step-1--line-height);
|
||||
--text-xs--letter-spacing: var(--text-step-1--letter-spacing);
|
||||
--text-sm: var(--text-step-2);
|
||||
--text-sm--line-height: var(--text-step-2--line-height);
|
||||
--text-sm--letter-spacing: var(--text-step-2--letter-spacing);
|
||||
--text-base: var(--text-step-3);
|
||||
--text-base--line-height: var(--text-step-3--line-height);
|
||||
--text-base--letter-spacing: var(--text-step-3--letter-spacing);
|
||||
--text-lg: var(--text-step-4);
|
||||
--text-lg--line-height: var(--text-step-4--line-height);
|
||||
--text-lg--letter-spacing: var(--text-step-4--letter-spacing);
|
||||
--text-xl: var(--text-step-5);
|
||||
--text-xl--line-height: var(--text-step-5--line-height);
|
||||
--text-xl--letter-spacing: var(--text-step-5--letter-spacing);
|
||||
--text-2xl: var(--text-step-6);
|
||||
--text-2xl--line-height: var(--text-step-6--line-height);
|
||||
--text-2xl--letter-spacing: var(--text-step-6--letter-spacing);
|
||||
--text-3xl: var(--text-step-7);
|
||||
--text-3xl--line-height: var(--text-step-7--line-height);
|
||||
--text-3xl--letter-spacing: var(--text-step-7--letter-spacing);
|
||||
--text-4xl: var(--text-step-8);
|
||||
--text-4xl--line-height: var(--text-step-8--line-height);
|
||||
--text-4xl--letter-spacing: var(--text-step-8--letter-spacing);
|
||||
--text-6xl: var(--text-step-9);
|
||||
--text-6xl--line-height: var(--text-step-9--line-height);
|
||||
--text-6xl--letter-spacing: var(--text-step-9--letter-spacing);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
@source "../../node_modules/streamdown/dist";
|
||||
|
||||
@layer base {
|
||||
html,
|
||||
@@ -185,144 +14,70 @@
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#__next {
|
||||
height: 100%;
|
||||
}
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply m-0 bg-background text-base font-normal text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.markdown {
|
||||
@apply leading-relaxed;
|
||||
}
|
||||
.markdown * {
|
||||
@apply text-sm leading-relaxed;
|
||||
}
|
||||
.markdown + .markdown {
|
||||
@apply mt-2;
|
||||
}
|
||||
.markdown p {
|
||||
@apply my-2 first:mt-0 last:mb-0;
|
||||
}
|
||||
.markdown a {
|
||||
@apply underline;
|
||||
}
|
||||
.markdown blockquote {
|
||||
@apply border-l-2 border-border pl-3;
|
||||
}
|
||||
.markdown code {
|
||||
@apply space-y-1 rounded bg-muted px-1 py-0.5;
|
||||
}
|
||||
.markdown h1 {
|
||||
@apply text-lg font-bold;
|
||||
}
|
||||
.markdown h2,
|
||||
.markdown h3 {
|
||||
@apply text-lg font-semibold;
|
||||
}
|
||||
.markdown ul {
|
||||
@apply list-disc space-y-1;
|
||||
}
|
||||
.markdown ol {
|
||||
@apply list-decimal space-y-1;
|
||||
}
|
||||
.markdown li {
|
||||
@apply ml-5;
|
||||
}
|
||||
.markdown pre {
|
||||
@apply space-y-1 overflow-x-auto rounded bg-muted p-3;
|
||||
}
|
||||
.markdown div {
|
||||
@apply space-y-1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar for dark theme */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.3 0.005 260);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0.4 0.005 260);
|
||||
}
|
||||
|
||||
/* Selection color */
|
||||
::selection {
|
||||
background: oklch(0.75 0.12 165 / 0.25);
|
||||
}
|
||||
|
||||
/* Aurora background (components/ui/aurora-bg.tsx) */
|
||||
@keyframes aurora-drift {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0) rotate(0deg) scale(1);
|
||||
}
|
||||
25% {
|
||||
transform: translate(14%, -10%) rotate(18deg) scale(1.25);
|
||||
}
|
||||
50% {
|
||||
transform: translate(-4%, 6%) rotate(-6deg) scale(1.05);
|
||||
}
|
||||
75% {
|
||||
transform: translate(-12%, -4%) rotate(-16deg) scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
/* Curtain ribbons: sway side-to-side while skewing and stretching, like an
|
||||
aurora curtain rippling. Ribbons are bottom-anchored (transform-origin
|
||||
bottom), so skew/scale fan out from the horizon. */
|
||||
@keyframes aurora-wave {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0) skewX(0deg) scaleY(1);
|
||||
opacity: 0.7;
|
||||
}
|
||||
20% {
|
||||
transform: translateX(4%) skewX(8deg) scaleY(1.15);
|
||||
opacity: 1;
|
||||
}
|
||||
45% {
|
||||
transform: translateX(-3%) skewX(-10deg) scaleY(0.9);
|
||||
opacity: 0.55;
|
||||
}
|
||||
70% {
|
||||
transform: translateX(5%) skewX(12deg) scaleY(1.25);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
/* Traveling wave: a 200%-wide striped sheet slides left by half its width,
|
||||
looping seamlessly, while bobbing vertically — bands visibly roll across. */
|
||||
@keyframes aurora-flow {
|
||||
0% {
|
||||
transform: translateX(0) translateY(0) skewX(-6deg);
|
||||
}
|
||||
25% {
|
||||
transform: translateX(-12.5%) translateY(-4%) skewX(4deg);
|
||||
opacity: 0.55;
|
||||
transform: translate3d(-8%, 5%, 0) rotate(-5deg) scale(0.94);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(-25%) translateY(2%) skewX(-3deg);
|
||||
}
|
||||
75% {
|
||||
transform: translateX(-37.5%) translateY(-5%) skewX(6deg);
|
||||
opacity: 0.92;
|
||||
transform: translate3d(13%, -10%, 0) rotate(6deg) scale(1.11);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-50%) translateY(0) skewX(-6deg);
|
||||
opacity: 0.62;
|
||||
transform: translate3d(-5%, -2%, 0) rotate(-3deg) scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes aurora-drift-reverse {
|
||||
0% {
|
||||
opacity: 0.62;
|
||||
transform: translate3d(10%, -6%, 0) rotate(5deg) scale(1.08);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.9;
|
||||
transform: translate3d(-12%, -12%, 0) rotate(-6deg) scale(0.96);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.58;
|
||||
transform: translate3d(6%, 2%, 0) rotate(3deg) scale(1.04);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes aurora-horizon-breathe {
|
||||
0% {
|
||||
opacity: 0.48;
|
||||
transform: translate3d(-3%, 9%, 0) scale(0.96, 0.84);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.88;
|
||||
transform: translate3d(3%, -5%, 0) scale(1.08, 1.16);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.58;
|
||||
transform: translate3d(-1%, 2%, 0) scale(1.02, 0.96);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes aurora-current-sweep {
|
||||
0% {
|
||||
opacity: 0.28;
|
||||
transform: translate3d(-16%, 8%, 0) rotate(-8deg) scaleX(0.84);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.72;
|
||||
transform: translate3d(17%, -8%, 0) rotate(4deg) scaleX(1.08);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.38;
|
||||
transform: translate3d(28%, 4%, 0) rotate(-2deg) scaleX(0.94);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,8 +85,78 @@
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.15;
|
||||
transform: translate3d(0, 4px, 0) rotate(0deg) scale(0.78);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
opacity: 0.78;
|
||||
transform: translate3d(var(--aurora-star-x, 5px), -8px, 0) rotate(35deg)
|
||||
scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
.aurora-horizon {
|
||||
animation: aurora-horizon-breathe 8s ease-in-out -3s infinite alternate;
|
||||
transform-origin: center bottom;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.aurora-current {
|
||||
animation-name: aurora-current-sweep;
|
||||
animation-timing-function: ease-in-out;
|
||||
animation-iteration-count: infinite;
|
||||
animation-direction: alternate;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.aurora-current-reverse {
|
||||
animation-direction: alternate-reverse;
|
||||
}
|
||||
|
||||
.aurora-motion {
|
||||
animation-name: aurora-drift;
|
||||
animation-timing-function: ease-in-out;
|
||||
animation-iteration-count: infinite;
|
||||
animation-direction: alternate;
|
||||
}
|
||||
|
||||
.aurora-motion-reverse {
|
||||
animation-name: aurora-drift-reverse;
|
||||
}
|
||||
|
||||
.aurora-star {
|
||||
--aurora-star-x: 5px;
|
||||
animation-name: aurora-twinkle;
|
||||
animation-timing-function: ease-in-out;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
.aurora-star:nth-of-type(2n) {
|
||||
--aurora-star-x: -6px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.aurora-current,
|
||||
.aurora-horizon,
|
||||
.aurora-motion,
|
||||
.aurora-star {
|
||||
/* biome-ignore lint/complexity/noImportantStyles: reduced motion must override inline animation timing */
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.aurora-horizon {
|
||||
opacity: 0.68;
|
||||
transform: translate3d(0, -1%, 0) scale(1.05);
|
||||
}
|
||||
|
||||
.aurora-current,
|
||||
.aurora-motion {
|
||||
opacity: 0.58;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.aurora-current,
|
||||
.aurora-horizon,
|
||||
.aurora-motion {
|
||||
will-change: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,8 @@ import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Agent Desktop",
|
||||
description: "AI coding agent interface",
|
||||
generator: "v0.app",
|
||||
title: "Cline",
|
||||
description: "Build software with Cline.",
|
||||
icons: {
|
||||
icon: [
|
||||
{
|
||||
|
||||
@@ -18,12 +18,17 @@ import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarTrigger,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { ChatInputBar } from "@/components/views/chat/chat-input-bar";
|
||||
import { ChatMessages } from "@/components/views/chat/chat-messages";
|
||||
import { DiffView } from "@/components/views/chat/diff-view";
|
||||
import { WelcomeScreen } from "@/components/views/chat/welcome-chat";
|
||||
import { SessionsView } from "@/components/views/sessions/sessions-view";
|
||||
import { SettingsView } from "@/components/views/settings/settings-view";
|
||||
import {
|
||||
type SettingsSection,
|
||||
SettingsView,
|
||||
} from "@/components/views/settings/settings-view";
|
||||
import { WorkspaceProvider } from "@/contexts/workspace-context";
|
||||
import type { PromptInQueue } from "@/hooks/chat-session/types";
|
||||
import { useChatSession } from "@/hooks/use-chat-session";
|
||||
@@ -37,6 +42,13 @@ import {
|
||||
type SessionMetadata,
|
||||
} from "@/lib/session-history";
|
||||
import { syncHubTheme, watchSystemHubTheme } from "@/lib/theme";
|
||||
import {
|
||||
mergeWorkspacePaths,
|
||||
normalizeWorkspacePath,
|
||||
readWorkspaceSelectionFromWindow,
|
||||
workspacePathsFromSessions,
|
||||
writeWorkspaceSelectionToWindow,
|
||||
} from "@/lib/workspace-paths";
|
||||
|
||||
function makeThreadId(): string {
|
||||
return `thread_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
@@ -45,24 +57,9 @@ function makeThreadId(): string {
|
||||
type Thread = {
|
||||
id: string;
|
||||
historySession?: SessionHistoryItem;
|
||||
hasStarted?: boolean;
|
||||
};
|
||||
|
||||
type WorkspaceSessionItem = {
|
||||
cwd?: string;
|
||||
workspaceRoot?: string;
|
||||
};
|
||||
|
||||
function normalizeWorkspacePath(path: string): string {
|
||||
const normalized = path.trim().replace(/[\\/]+$/, "");
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
if (/^[A-Za-z]:/.test(normalized)) {
|
||||
return normalized.toLowerCase();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function toThreadTitle(options: { title?: string; prompt?: string }): string {
|
||||
const preferredTitle = options.title?.trim();
|
||||
if (preferredTitle) {
|
||||
@@ -75,6 +72,8 @@ function toThreadTitle(options: { title?: string; prompt?: string }): string {
|
||||
|
||||
export default function Home() {
|
||||
const [view, setView] = useState<"chat" | "sessions" | "settings">("chat");
|
||||
const [settingsSection, setSettingsSection] =
|
||||
useState<SettingsSection>("General");
|
||||
const [threads, setThreads] = useState<Thread[]>(() => [
|
||||
{ id: makeThreadId() },
|
||||
]);
|
||||
@@ -102,11 +101,15 @@ export default function Home() {
|
||||
const next = [...prev];
|
||||
next[existingIdx] = {
|
||||
...next[existingIdx],
|
||||
hasStarted: true,
|
||||
historySession: session,
|
||||
};
|
||||
return next;
|
||||
}
|
||||
return [...prev, { id: threadId, historySession: session }];
|
||||
return [
|
||||
...prev,
|
||||
{ id: threadId, hasStarted: true, historySession: session },
|
||||
];
|
||||
});
|
||||
setActiveThreadId(threadId);
|
||||
setView("chat");
|
||||
@@ -188,70 +191,107 @@ export default function Home() {
|
||||
?.sessionId ?? null;
|
||||
const activeThread =
|
||||
threads.find((thread) => thread.id === activeThreadId) ?? threads[0];
|
||||
const handleHome = useCallback(() => {
|
||||
if (activeThread?.historySession || activeThread?.hasStarted) {
|
||||
handleNewThread();
|
||||
return;
|
||||
}
|
||||
setView("chat");
|
||||
}, [activeThread, handleNewThread]);
|
||||
const handleThreadStarted = useCallback((threadId: string) => {
|
||||
setThreads((current) =>
|
||||
current.map((thread) =>
|
||||
thread.id === threadId && !thread.hasStarted
|
||||
? { ...thread, hasStarted: true }
|
||||
: thread,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
const sessionHistory = useSessionHistory({
|
||||
activeSessionId: activeHistorySessionId,
|
||||
onDeleteSession: handleDeleteSession,
|
||||
onOpenSession: handleOpenSession,
|
||||
onUpdateSessionMetadata: handleUpdateSessionMetadata,
|
||||
});
|
||||
const historyWorkspacePaths = useMemo(
|
||||
() => workspacePathsFromSessions(sessionHistory.sessions),
|
||||
[sessionHistory.sessions],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarProvider>
|
||||
<div className="flex h-screen w-full overflow-hidden bg-background text-foreground">
|
||||
<Sidebar
|
||||
className="border-r border-sidebar-border"
|
||||
collapsible="icon"
|
||||
>
|
||||
<AgentSidebar
|
||||
<SidebarProvider>
|
||||
<div className="flex h-screen w-full overflow-hidden bg-background text-foreground">
|
||||
<Sidebar className="border-r border-sidebar-border" collapsible="icon">
|
||||
<AgentSidebar
|
||||
activeSessionId={activeHistorySessionId}
|
||||
isHomeActive={
|
||||
view === "chat" &&
|
||||
!activeThread?.historySession &&
|
||||
!activeThread?.hasStarted
|
||||
}
|
||||
onHome={handleHome}
|
||||
onNewThread={handleNewThread}
|
||||
onSettingsSectionChange={setSettingsSection}
|
||||
sessionHistory={sessionHistory}
|
||||
setView={setView}
|
||||
settingsSection={settingsSection}
|
||||
view={view}
|
||||
/>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
|
||||
<SidebarTrigger className="absolute left-3 top-3 z-40 md:hidden" />
|
||||
{view === "sessions" ? (
|
||||
<SessionsView
|
||||
activeSessionId={activeHistorySessionId}
|
||||
onNewThread={handleNewThread}
|
||||
sessionHistory={sessionHistory}
|
||||
setView={setView}
|
||||
history={sessionHistory}
|
||||
/>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
<SidebarInset className="min-h-0 min-w-0 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}
|
||||
knownWorkspacePaths={historyWorkspacePaths}
|
||||
onUpdateSessionMetadata={handleUpdateSessionMetadata}
|
||||
threadId={activeThread.id}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
onNewThread={handleNewThread}
|
||||
onOpenSession={handleOpenSession}
|
||||
onThreadStarted={handleThreadStarted}
|
||||
/>
|
||||
) : activeThread ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<ChatThreadPane
|
||||
key={activeThread.id}
|
||||
historySession={activeThread.historySession}
|
||||
onUpdateSessionMetadata={handleUpdateSessionMetadata}
|
||||
threadId={activeThread.id}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
onNewThread={handleNewThread}
|
||||
onOpenSession={handleOpenSession}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
{view === "settings" ? (
|
||||
<div className="fixed inset-0 z-50 bg-background text-foreground">
|
||||
<SettingsView onClose={() => setView("chat")} />
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
</div>
|
||||
) : null}
|
||||
{view === "settings" ? (
|
||||
<div className="absolute inset-0 z-30 bg-background text-foreground">
|
||||
<SettingsView
|
||||
onNavigateSection={setSettingsSection}
|
||||
section={settingsSection}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatThreadPane({
|
||||
threadId,
|
||||
historySession,
|
||||
knownWorkspacePaths,
|
||||
onUpdateSessionMetadata,
|
||||
onDeleteSession,
|
||||
onNewThread,
|
||||
onOpenSession,
|
||||
onThreadStarted,
|
||||
}: {
|
||||
threadId: string;
|
||||
historySession?: SessionHistoryItem;
|
||||
knownWorkspacePaths: string[];
|
||||
onUpdateSessionMetadata?: (
|
||||
sessionId: string,
|
||||
metadata: SessionMetadata,
|
||||
@@ -259,6 +299,7 @@ function ChatThreadPane({
|
||||
onDeleteSession?: (sessionId: string, threadId?: string) => void;
|
||||
onNewThread?: () => void;
|
||||
onOpenSession?: (session: SessionHistoryItem) => void;
|
||||
onThreadStarted?: (threadId: string) => void;
|
||||
}) {
|
||||
const {
|
||||
sessionId,
|
||||
@@ -304,7 +345,12 @@ function ChatThreadPane({
|
||||
Record<string, { apiKey: string }>
|
||||
>({});
|
||||
const [providersLoaded, setProvidersLoaded] = useState(false);
|
||||
const [workspaces, setWorkspaces] = useState<string[]>([]);
|
||||
const [workspaces, setWorkspaces] = useState<string[]>(() =>
|
||||
mergeWorkspacePaths(
|
||||
readWorkspaceSelectionFromWindow().workspaces,
|
||||
knownWorkspacePaths,
|
||||
),
|
||||
);
|
||||
const [workspacesLoaded, setWorkspacesLoaded] = useState(false);
|
||||
const hydratedSessionRef = useRef<string | null>(null);
|
||||
const resetThreadRef = useRef<string | null>(null);
|
||||
@@ -318,6 +364,24 @@ function ChatThreadPane({
|
||||
workspaceRoot: config.workspaceRoot,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setWorkspaces((current) => {
|
||||
const merged = mergeWorkspacePaths(current, knownWorkspacePaths);
|
||||
return current.length === merged.length &&
|
||||
current.every((workspace, index) => workspace === merged[index])
|
||||
? current
|
||||
: merged;
|
||||
});
|
||||
}, [knownWorkspacePaths]);
|
||||
|
||||
useEffect(() => {
|
||||
const lastWorkspace = (config.workspaceRoot || config.cwd || "").trim();
|
||||
writeWorkspaceSelectionToWindow({
|
||||
lastWorkspace,
|
||||
workspaces: mergeWorkspacePaths(workspaces, [lastWorkspace]),
|
||||
});
|
||||
}, [config.cwd, config.workspaceRoot, workspaces]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@@ -438,52 +502,28 @@ function ChatThreadPane({
|
||||
|
||||
const listWorkspaces = useCallback(
|
||||
async (preferredWorkspace?: string): Promise<string[]> => {
|
||||
const roots = new Set<string>();
|
||||
const preferred = (preferredWorkspace || "").trim();
|
||||
if (preferred) {
|
||||
roots.add(preferred);
|
||||
}
|
||||
const current = (
|
||||
workspaceRef.current.workspaceRoot ||
|
||||
workspaceRef.current.cwd ||
|
||||
""
|
||||
).trim();
|
||||
if (current) {
|
||||
roots.add(current);
|
||||
}
|
||||
|
||||
try {
|
||||
const discovered = await desktopClient
|
||||
.invoke<WorkspaceSessionItem[]>("list_discovered_sessions", {
|
||||
limit: 20,
|
||||
})
|
||||
.catch(() => []);
|
||||
|
||||
for (const session of discovered) {
|
||||
const candidate = (session.workspaceRoot || session.cwd || "").trim();
|
||||
if (candidate) {
|
||||
roots.add(candidate);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Keep fallback to current workspace when history is unavailable.
|
||||
}
|
||||
|
||||
return [...roots].sort((a, b) => a.localeCompare(b));
|
||||
return mergeWorkspacePaths(knownWorkspacePaths, [preferred, current]);
|
||||
},
|
||||
[],
|
||||
[knownWorkspacePaths],
|
||||
);
|
||||
|
||||
const refreshWorkspaces = useCallback(
|
||||
async (preferredWorkspace?: string) => {
|
||||
try {
|
||||
const results = await listWorkspaces(preferredWorkspace);
|
||||
setWorkspaces((current) =>
|
||||
current.length === results.length &&
|
||||
current.every((workspace, index) => workspace === results[index])
|
||||
setWorkspaces((current) => {
|
||||
const merged = mergeWorkspacePaths(current, results);
|
||||
return current.length === merged.length &&
|
||||
current.every((workspace, index) => workspace === merged[index])
|
||||
? current
|
||||
: results,
|
||||
);
|
||||
: merged;
|
||||
});
|
||||
} finally {
|
||||
setWorkspacesLoaded(true);
|
||||
}
|
||||
@@ -508,17 +548,21 @@ function ChatThreadPane({
|
||||
if (normalizedNext === normalizedCurrent) {
|
||||
return true;
|
||||
}
|
||||
const validation = await desktopClient
|
||||
.invoke<{ valid?: boolean }>("validate_workspace_directory", {
|
||||
path: nextWorkspace,
|
||||
})
|
||||
.catch(() => ({ valid: false }));
|
||||
if (validation.valid !== true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
workspaceRoot: nextWorkspace,
|
||||
cwd: nextWorkspace,
|
||||
}));
|
||||
setWorkspaces((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(nextWorkspace);
|
||||
return [...next].sort((a, b) => a.localeCompare(b));
|
||||
});
|
||||
setWorkspaces((prev) => mergeWorkspacePaths(prev, [nextWorkspace]));
|
||||
|
||||
// Fire git branch + workspace list refresh in the background
|
||||
desktopClient
|
||||
@@ -533,7 +577,7 @@ function ChatThreadPane({
|
||||
setGitBranch("no-git");
|
||||
});
|
||||
|
||||
// Re-fetch workspace list so the new root appears
|
||||
// Refresh the merged history, stored, and current workspace catalog.
|
||||
void refreshWorkspaces(nextWorkspace);
|
||||
|
||||
return true;
|
||||
@@ -618,11 +662,12 @@ function ChatThreadPane({
|
||||
if (!trimmed && pendingAttachments.length === 0) {
|
||||
return;
|
||||
}
|
||||
onThreadStarted?.(threadId);
|
||||
setPromptInput("");
|
||||
const toSend = [...pendingAttachments];
|
||||
setPendingAttachments([]);
|
||||
await sendPrompt(trimmed, toSend);
|
||||
}, [pendingAttachments, promptInput, sendPrompt]);
|
||||
}, [onThreadStarted, pendingAttachments, promptInput, sendPrompt, threadId]);
|
||||
|
||||
const handleReasoningChange = useCallback(
|
||||
(next: Pick<ChatSessionConfig, "thinking" | "reasoningEffort">) => {
|
||||
@@ -827,7 +872,7 @@ function ChatThreadPane({
|
||||
? false
|
||||
: isHydratingSession;
|
||||
const isWelcomeState =
|
||||
displayedMessages.length === 0 && !displayedIsSwitching;
|
||||
displayedMessages.length === 0 && !displayedIsSwitching && !displayedError;
|
||||
|
||||
const handleRenameTitle = useCallback(
|
||||
async (nextTitle: string) => {
|
||||
@@ -915,153 +960,153 @@ function ChatThreadPane({
|
||||
);
|
||||
}
|
||||
|
||||
const composer = (
|
||||
<ChatInputBar
|
||||
attachments={attachmentList}
|
||||
onAbort={() => void abort()}
|
||||
onAttachFiles={(files) => {
|
||||
setPendingAttachments((prev) => {
|
||||
const existing = new Set(
|
||||
prev.map(
|
||||
(file) => `${file.name}:${file.size}:${file.lastModified}`,
|
||||
),
|
||||
);
|
||||
const next = [...prev];
|
||||
for (const file of files) {
|
||||
const key = `${file.name}:${file.size}:${file.lastModified}`;
|
||||
if (!existing.has(key)) {
|
||||
existing.add(key);
|
||||
next.push(file);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onListGitBranches={listGitBranches}
|
||||
onRemoveAttachment={(id) => {
|
||||
setPendingAttachments((prev) =>
|
||||
prev.filter((file, index) => {
|
||||
const fileId = `${file.name}:${file.size}:${file.lastModified}:${index}`;
|
||||
return fileId !== id;
|
||||
}),
|
||||
);
|
||||
}}
|
||||
onSwitchGitBranch={switchGitBranch}
|
||||
onModelChange={(nextModel) =>
|
||||
setConfig((prev) =>
|
||||
prev.model === nextModel ? prev : { ...prev, model: nextModel },
|
||||
)
|
||||
}
|
||||
onModeToggle={() =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
mode: prev.mode === "plan" ? "act" : "plan",
|
||||
}))
|
||||
}
|
||||
onPromptInputChange={setPromptInput}
|
||||
onReasoningChange={handleReasoningChange}
|
||||
onSteerPromptInQueue={(promptId) => {
|
||||
void steerPromptInQueue(promptId);
|
||||
}}
|
||||
onEditPromptInQueue={(promptId, prompt) => {
|
||||
void updatePromptInQueue(promptId, prompt);
|
||||
}}
|
||||
onUndoPromptInQueue={(item) => {
|
||||
void handleUndoQueuedPrompt(item);
|
||||
}}
|
||||
onProviderChange={(nextProvider) =>
|
||||
setConfig((prev) => {
|
||||
const selected = providerCredentials[nextProvider];
|
||||
const nextApiKey = selected?.apiKey ?? "";
|
||||
if (prev.provider === nextProvider && prev.apiKey === nextApiKey) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
provider: nextProvider,
|
||||
apiKey: nextApiKey,
|
||||
};
|
||||
})
|
||||
}
|
||||
onSend={() => void handleSend()}
|
||||
gitBranch={gitBranch}
|
||||
model={config.model}
|
||||
mode={config.mode}
|
||||
promptsInQueue={promptsInQueue}
|
||||
promptInput={promptInput}
|
||||
provider={config.provider}
|
||||
reasoningEffort={config.reasoningEffort}
|
||||
status={status}
|
||||
summary={summary}
|
||||
thinking={config.thinking}
|
||||
variant={isWelcomeState ? "welcome" : "conversation"}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<WorkspaceProvider value={workspaceContextValue}>
|
||||
<div className="grid h-full min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden">
|
||||
<div className="z-20">
|
||||
<AgentHeader
|
||||
canEditTitle={Boolean(activeSessionForTitle)}
|
||||
canDeleteSession={Boolean(activeSessionToDelete)}
|
||||
deletingSession={deletingSession}
|
||||
diff={{
|
||||
additions: summary.additions,
|
||||
deletions: summary.deletions,
|
||||
}}
|
||||
onDeleteSession={requestDeleteSession}
|
||||
onNewThread={onNewThread}
|
||||
onOpenDiff={() => {
|
||||
if (hasDiffChanges) {
|
||||
setShowDiffView(true);
|
||||
}
|
||||
}}
|
||||
onRenameTitle={handleRenameTitle}
|
||||
renamingTitle={renamingSession}
|
||||
showSessionActions={!isWelcomeState}
|
||||
status={status}
|
||||
title={threadTitle}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-full min-h-0 overflow-hidden">
|
||||
{showDiffView ? (
|
||||
<DiffView
|
||||
fileDiffs={fileDiffs}
|
||||
onClose={() => setShowDiffView(false)}
|
||||
/>
|
||||
) : (
|
||||
<ChatMessages
|
||||
onAnswerAskQuestion={handleAnswerAskQuestion}
|
||||
onApproveToolApproval={handleApproveToolApproval}
|
||||
onRejectToolApproval={handleRejectToolApproval}
|
||||
onStartChat={(prompt) => {
|
||||
setPromptInput(prompt);
|
||||
<div
|
||||
className={
|
||||
isWelcomeState
|
||||
? "grid h-full min-h-0 flex-1 grid-rows-[minmax(0,1fr)] overflow-hidden"
|
||||
: "grid h-full min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden"
|
||||
}
|
||||
>
|
||||
{!isWelcomeState ? (
|
||||
<div className="z-20 border-b border-border/70 bg-background/85 backdrop-blur-sm">
|
||||
<AgentHeader
|
||||
canEditTitle={Boolean(activeSessionForTitle)}
|
||||
canDeleteSession={Boolean(activeSessionToDelete)}
|
||||
deletingSession={deletingSession}
|
||||
diff={{
|
||||
additions: summary.additions,
|
||||
deletions: summary.deletions,
|
||||
}}
|
||||
chatTransportState={chatTransportState}
|
||||
error={displayedError}
|
||||
messages={displayedMessages}
|
||||
model={config.model}
|
||||
onRestoreCheckpoint={(runCount) =>
|
||||
void restoreCheckpoint(runCount)
|
||||
}
|
||||
onForkSession={handleForkSession}
|
||||
pendingToolApprovals={pendingToolApprovals}
|
||||
pendingAskQuestions={pendingAskQuestions}
|
||||
provider={config.provider}
|
||||
sessionId={displayedSessionId}
|
||||
streamingMessageId={activeAssistantMessageId}
|
||||
isSessionSwitching={displayedIsSwitching}
|
||||
status={displayedStatus}
|
||||
onDeleteSession={requestDeleteSession}
|
||||
onNewThread={onNewThread}
|
||||
onOpenDiff={() => {
|
||||
if (hasDiffChanges) setShowDiffView(true);
|
||||
}}
|
||||
onRenameTitle={handleRenameTitle}
|
||||
renamingTitle={renamingSession}
|
||||
status={status}
|
||||
title={threadTitle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="z-20 shrink-0">
|
||||
<ChatInputBar
|
||||
attachments={attachmentList}
|
||||
onAbort={() => void abort()}
|
||||
onAttachFiles={(files) => {
|
||||
setPendingAttachments((prev) => {
|
||||
const existing = new Set(
|
||||
prev.map(
|
||||
(file) => `${file.name}:${file.size}:${file.lastModified}`,
|
||||
),
|
||||
);
|
||||
const next = [...prev];
|
||||
for (const file of files) {
|
||||
const key = `${file.name}:${file.size}:${file.lastModified}`;
|
||||
if (!existing.has(key)) {
|
||||
existing.add(key);
|
||||
next.push(file);
|
||||
}
|
||||
</div>
|
||||
) : null}
|
||||
<WelcomeScreen
|
||||
active={isWelcomeState}
|
||||
body={
|
||||
showDiffView ? (
|
||||
<DiffView
|
||||
fileDiffs={fileDiffs}
|
||||
onClose={() => setShowDiffView(false)}
|
||||
/>
|
||||
) : (
|
||||
<ChatMessages
|
||||
onAnswerAskQuestion={handleAnswerAskQuestion}
|
||||
onApproveToolApproval={handleApproveToolApproval}
|
||||
onRejectToolApproval={handleRejectToolApproval}
|
||||
chatTransportState={chatTransportState}
|
||||
error={displayedError}
|
||||
messages={displayedMessages}
|
||||
onRestoreCheckpoint={(runCount) =>
|
||||
void restoreCheckpoint(runCount)
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onListGitBranches={listGitBranches}
|
||||
onRemoveAttachment={(id) => {
|
||||
setPendingAttachments((prev) =>
|
||||
prev.filter((file, index) => {
|
||||
const fileId = `${file.name}:${file.size}:${file.lastModified}:${index}`;
|
||||
return fileId !== id;
|
||||
}),
|
||||
);
|
||||
}}
|
||||
onSwitchGitBranch={switchGitBranch}
|
||||
onRefreshGitBranch={() => void refreshGitBranch()}
|
||||
onModelChange={(nextModel) =>
|
||||
setConfig((prev) =>
|
||||
prev.model === nextModel ? prev : { ...prev, model: nextModel },
|
||||
)
|
||||
}
|
||||
onModeToggle={() =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
mode: prev.mode === "plan" ? "act" : "plan",
|
||||
}))
|
||||
}
|
||||
onPromptInputChange={setPromptInput}
|
||||
onReasoningChange={handleReasoningChange}
|
||||
onSteerPromptInQueue={(promptId) => {
|
||||
void steerPromptInQueue(promptId);
|
||||
}}
|
||||
onEditPromptInQueue={(promptId, prompt) => {
|
||||
void updatePromptInQueue(promptId, prompt);
|
||||
}}
|
||||
onUndoPromptInQueue={(item) => {
|
||||
void handleUndoQueuedPrompt(item);
|
||||
}}
|
||||
onProviderChange={(nextProvider) =>
|
||||
setConfig((prev) => {
|
||||
const selected = providerCredentials[nextProvider];
|
||||
const nextApiKey = selected?.apiKey ?? "";
|
||||
if (
|
||||
prev.provider === nextProvider &&
|
||||
prev.apiKey === nextApiKey
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
provider: nextProvider,
|
||||
apiKey: nextApiKey,
|
||||
};
|
||||
})
|
||||
}
|
||||
onReset={() => {
|
||||
setPendingAttachments([]);
|
||||
void reset();
|
||||
}}
|
||||
onSend={() => void handleSend()}
|
||||
gitBranch={gitBranch}
|
||||
model={config.model}
|
||||
mode={config.mode}
|
||||
promptsInQueue={promptsInQueue}
|
||||
promptInput={promptInput}
|
||||
provider={config.provider}
|
||||
reasoningEffort={config.reasoningEffort}
|
||||
status={status}
|
||||
summary={summary}
|
||||
thinking={config.thinking}
|
||||
/>
|
||||
</div>
|
||||
onForkSession={handleForkSession}
|
||||
pendingToolApprovals={pendingToolApprovals}
|
||||
pendingAskQuestions={pendingAskQuestions}
|
||||
sessionId={displayedSessionId}
|
||||
streamingMessageId={activeAssistantMessageId}
|
||||
isSessionSwitching={displayedIsSwitching}
|
||||
status={displayedStatus}
|
||||
/>
|
||||
)
|
||||
}
|
||||
composer={composer}
|
||||
onStartChat={setPromptInput}
|
||||
quickActions={[]}
|
||||
/>
|
||||
</div>
|
||||
<AlertDialog
|
||||
open={deleteConfirmOpen}
|
||||
|
||||
@@ -82,12 +82,13 @@ export function AgentHeader({
|
||||
const triggerDeleteSession = () => onDeleteSession?.();
|
||||
|
||||
return (
|
||||
<header className="flex h-12 items-center justify-between px-4">
|
||||
<header className="flex h-12 items-center justify-between gap-2 px-4 max-md:pl-12">
|
||||
{/* Left: thread title */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<output
|
||||
aria-label={`Session status: ${status}`}
|
||||
className={cn(
|
||||
"rounded w-2 h-2 font-mono",
|
||||
"size-2 shrink-0 rounded font-mono",
|
||||
status === "running"
|
||||
? "bg-green-500"
|
||||
: status === "failed"
|
||||
@@ -97,7 +98,7 @@ export function AgentHeader({
|
||||
/>
|
||||
{isEditingTitle ? (
|
||||
<form
|
||||
className="m-0"
|
||||
className="m-0 min-w-0 flex-1"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void submitTitle();
|
||||
@@ -105,7 +106,7 @@ export function AgentHeader({
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-7 w-64 text-sm"
|
||||
className="h-7 w-64 max-w-full text-sm"
|
||||
disabled={renamingTitle}
|
||||
onBlur={() => {
|
||||
void submitTitle();
|
||||
@@ -124,7 +125,7 @@ export function AgentHeader({
|
||||
) : (
|
||||
<button
|
||||
className={cn(
|
||||
"text-sm font-medium text-foreground",
|
||||
"min-w-0 truncate text-sm font-medium text-foreground",
|
||||
canEditTitle &&
|
||||
"rounded px-1 py-0.5 transition-colors hover:bg-accent",
|
||||
)}
|
||||
@@ -137,6 +138,7 @@ export function AgentHeader({
|
||||
setIsEditingTitle(true);
|
||||
}}
|
||||
type="button"
|
||||
title={threadTitle}
|
||||
>
|
||||
{threadTitle}
|
||||
</button>
|
||||
@@ -144,7 +146,8 @@ export function AgentHeader({
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Session actions"
|
||||
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
|
||||
id="show-more-btn"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
@@ -167,8 +170,9 @@ export function AgentHeader({
|
||||
</div>
|
||||
|
||||
{showSessionActions ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
aria-label={`Open diff: ${additions} additions, ${deletions} deletions`}
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-md bg-secondary px-2 py-1 text-xs font-mono transition-colors",
|
||||
hasChanges
|
||||
@@ -186,6 +190,7 @@ export function AgentHeader({
|
||||
<span className="text-destructive">-{deletions}</span>
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="New session"
|
||||
className="flex items-center gap-1 rounded-md text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
onClick={() => onNewThread?.()}
|
||||
size="icon-sm"
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
// @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 { AgentSidebar } from "@/components/agent-sidebar";
|
||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
import type {
|
||||
SessionThread,
|
||||
UseSessionHistoryResult,
|
||||
} from "@/hooks/use-session-history";
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
function makeThread(project: string, index: number): SessionThread {
|
||||
return {
|
||||
id: `${project}-${index}`,
|
||||
title: `${project} session ${index}`,
|
||||
codebase: project,
|
||||
workspacePath: `/projects/${project}`,
|
||||
time: `${index}m`,
|
||||
provider: "cline",
|
||||
model: "test-model",
|
||||
status: "completed",
|
||||
};
|
||||
}
|
||||
|
||||
function makeSessionHistory(
|
||||
threads: SessionThread[],
|
||||
loadMoreSessions: ReturnType<typeof vi.fn>,
|
||||
options: {
|
||||
loadOlderSessions?: ReturnType<typeof vi.fn>;
|
||||
mayHaveMoreSessions?: boolean;
|
||||
} = {},
|
||||
): UseSessionHistoryResult {
|
||||
return {
|
||||
deleteThread: vi.fn(),
|
||||
forkThread: vi.fn(),
|
||||
isLoadingHistory: false,
|
||||
isLoadingMore: false,
|
||||
loadOlderSessions: options.loadOlderSessions ?? vi.fn(),
|
||||
loadMoreSessions,
|
||||
mayHaveMoreSessions: options.mayHaveMoreSessions ?? false,
|
||||
openThread: vi.fn(),
|
||||
pendingAction: null,
|
||||
renameThread: vi.fn(),
|
||||
threads,
|
||||
unreadSessionIds: new Set<string>(),
|
||||
} as unknown as UseSessionHistoryResult;
|
||||
}
|
||||
|
||||
async function click(element: Element): Promise<void> {
|
||||
await act(async () => {
|
||||
element.dispatchEvent(
|
||||
new MouseEvent("pointerdown", { bubbles: true, cancelable: true }),
|
||||
);
|
||||
element.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true, cancelable: true }),
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function buttonWithText(text: string, rootNode: ParentNode = container) {
|
||||
const button = [
|
||||
...rootNode.querySelectorAll<HTMLButtonElement>("button"),
|
||||
].find((candidate) => candidate.textContent?.includes(text));
|
||||
expect(button).toBeDefined();
|
||||
return button as HTMLButtonElement;
|
||||
}
|
||||
|
||||
function sessionIsVisible(title: string): boolean {
|
||||
return [...container.querySelectorAll<HTMLButtonElement>("button")].some(
|
||||
(button) => button.querySelector("span")?.textContent === title,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => ({
|
||||
matches: false,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
})),
|
||||
});
|
||||
HTMLElement.prototype.hasPointerCapture = vi.fn(() => false);
|
||||
HTMLElement.prototype.setPointerCapture = vi.fn();
|
||||
HTMLElement.prototype.releasePointerCapture = vi.fn();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("AgentSidebar session organization", () => {
|
||||
it("defaults to time and keeps project expansion scoped to one project", async () => {
|
||||
const threads = [
|
||||
...Array.from({ length: 12 }, (_, index) =>
|
||||
makeThread("alpha", index + 1),
|
||||
),
|
||||
...Array.from({ length: 12 }, (_, index) =>
|
||||
makeThread("beta", index + 1),
|
||||
),
|
||||
];
|
||||
const loadMoreSessions = vi.fn(async () => undefined);
|
||||
const loadOlderSessions = vi.fn(async () => undefined);
|
||||
const sessionHistory = makeSessionHistory(threads, loadMoreSessions, {
|
||||
loadOlderSessions,
|
||||
mayHaveMoreSessions: true,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
isHomeActive
|
||||
onHome={vi.fn()}
|
||||
onNewThread={vi.fn()}
|
||||
sessionHistory={sessionHistory}
|
||||
setView={vi.fn()}
|
||||
view="chat"
|
||||
/>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
container.querySelector('[aria-label="Sort sessions: Time"]'),
|
||||
).not.toBeNull();
|
||||
expect(sessionIsVisible("alpha session 10")).toBe(true);
|
||||
expect(sessionIsVisible("alpha session 11")).toBe(false);
|
||||
expect(sessionIsVisible("beta session 1")).toBe(false);
|
||||
|
||||
await click(buttonWithText("Show more"));
|
||||
expect(sessionIsVisible("alpha session 11")).toBe(true);
|
||||
expect(loadMoreSessions).toHaveBeenCalledWith(20);
|
||||
|
||||
await click(
|
||||
container.querySelector('[aria-label="Sort sessions: Time"]') as Element,
|
||||
);
|
||||
const projectOption = await vi.waitFor(() => {
|
||||
const option = [
|
||||
...document.querySelectorAll<HTMLElement>('[role="menuitemradio"]'),
|
||||
].find((candidate) => candidate.textContent?.includes("Sort by project"));
|
||||
expect(option).toBeDefined();
|
||||
return option as HTMLElement;
|
||||
});
|
||||
await click(projectOption);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
container.querySelector('[aria-label="Sort sessions: Project"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
expect(container.textContent).toContain("alpha");
|
||||
expect(container.textContent).toContain("beta");
|
||||
expect(sessionIsVisible("alpha session 11")).toBe(false);
|
||||
expect(sessionIsVisible("beta session 11")).toBe(false);
|
||||
|
||||
await click(buttonWithText("Show more in alpha"));
|
||||
expect(sessionIsVisible("alpha session 11")).toBe(true);
|
||||
expect(sessionIsVisible("beta session 11")).toBe(false);
|
||||
|
||||
await click(buttonWithText("Load older projects"));
|
||||
expect(loadOlderSessions).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowDownUp,
|
||||
Blocks,
|
||||
Bot,
|
||||
ChevronDown,
|
||||
CircleUserRound,
|
||||
Clock3,
|
||||
Filter,
|
||||
FolderTree,
|
||||
GitFork,
|
||||
Home,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
PanelLeftOpen,
|
||||
Pencil,
|
||||
Pin,
|
||||
Plus,
|
||||
Radio,
|
||||
Search,
|
||||
Server,
|
||||
Settings,
|
||||
SlidersHorizontal,
|
||||
Store,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
@@ -22,6 +33,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ClineLogo } from "@/components/cline-logo";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -55,37 +67,117 @@ import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { normalizeTitle } from "@/components/utils";
|
||||
import {
|
||||
SETTINGS_SECTIONS,
|
||||
type SettingsSection,
|
||||
} from "@/components/views/settings/settings-view";
|
||||
import type {
|
||||
SessionThread,
|
||||
UseSessionHistoryResult,
|
||||
} from "@/hooks/use-session-history";
|
||||
import { formatCostUsd, formatTokenCount } from "@/hooks/use-session-history";
|
||||
import {
|
||||
groupThreadsByProject,
|
||||
INITIAL_VISIBLE_THREAD_COUNT,
|
||||
workspaceDisplayName,
|
||||
} from "@/lib/sidebar-session-organization";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Thread = SessionThread;
|
||||
type AppView = "chat" | "sessions" | "settings";
|
||||
|
||||
const filterOptions = ["All", "Running", "Recent", "Pinned"] as const;
|
||||
type FilterOption = (typeof filterOptions)[number];
|
||||
const INITIAL_VISIBLE_THREAD_COUNT = 10;
|
||||
type SidebarSortMode = "time" | "project";
|
||||
const SETTINGS_SECTION_ICONS = {
|
||||
General: SlidersHorizontal,
|
||||
Models: Bot,
|
||||
"MCP Servers": Server,
|
||||
"MCP Marketplace": Store,
|
||||
Customizations: Blocks,
|
||||
Channels: Radio,
|
||||
Schedules: Clock3,
|
||||
Account: CircleUserRound,
|
||||
} satisfies Record<SettingsSection, typeof Settings>;
|
||||
|
||||
function SettingsSectionNavigation({
|
||||
activeSection,
|
||||
collapsed,
|
||||
onSelect,
|
||||
}: {
|
||||
activeSection: SettingsSection;
|
||||
collapsed: boolean;
|
||||
onSelect: (section: SettingsSection) => void;
|
||||
}) {
|
||||
return (
|
||||
<nav
|
||||
aria-label="Settings sections"
|
||||
className={cn(
|
||||
"flex h-full min-h-0 flex-col gap-0.5 overflow-y-auto",
|
||||
collapsed ? "w-full items-center" : "w-full",
|
||||
)}
|
||||
>
|
||||
{!collapsed ? (
|
||||
<p className="px-2 pb-2 text-sm font-medium text-muted-foreground">
|
||||
Settings
|
||||
</p>
|
||||
) : null}
|
||||
{SETTINGS_SECTIONS.map((section) => {
|
||||
const Icon = SETTINGS_SECTION_ICONS[section];
|
||||
return (
|
||||
<Button
|
||||
aria-current={activeSection === section ? "page" : undefined}
|
||||
aria-label={section}
|
||||
className={cn(
|
||||
"min-w-0 justify-start",
|
||||
activeSection === section &&
|
||||
"bg-sidebar-accent text-sidebar-accent-foreground",
|
||||
collapsed && "mx-auto size-9 justify-center px-0",
|
||||
)}
|
||||
key={section}
|
||||
onClick={() => onSelect(section)}
|
||||
title={section}
|
||||
type="button"
|
||||
variant="sidebarItem"
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
{!collapsed ? <span className="truncate">{section}</span> : null}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentSidebar({
|
||||
isHomeActive,
|
||||
onHome,
|
||||
onNewThread,
|
||||
onSettingsSectionChange,
|
||||
setView,
|
||||
settingsSection,
|
||||
view,
|
||||
activeSessionId,
|
||||
sessionHistory,
|
||||
}: {
|
||||
isHomeActive: boolean;
|
||||
onHome: () => void;
|
||||
onNewThread?: () => void;
|
||||
setView: (view: "chat" | "sessions" | "settings") => void;
|
||||
onSettingsSectionChange: (section: SettingsSection) => void;
|
||||
setView: (view: AppView) => void;
|
||||
settingsSection: SettingsSection;
|
||||
view: AppView;
|
||||
activeSessionId?: string | null;
|
||||
sessionHistory: UseSessionHistoryResult;
|
||||
}) {
|
||||
const { isMobile, setOpen, state } = useSidebar();
|
||||
const { isMobile, setOpen, setOpenMobile, state } = useSidebar();
|
||||
const isCollapsed = !isMobile && state === "collapsed";
|
||||
const {
|
||||
deleteThread: deleteHistoryThread,
|
||||
forkThread: forkHistoryThread,
|
||||
isLoadingHistory,
|
||||
isLoadingMore,
|
||||
loadOlderSessions,
|
||||
loadMoreSessions,
|
||||
mayHaveMoreSessions,
|
||||
openThread: openHistoryThread,
|
||||
@@ -96,6 +188,7 @@ export function AgentSidebar({
|
||||
} = sessionHistory;
|
||||
const activeThread = activeSessionId ?? "";
|
||||
const [filter, setFilter] = useState<FilterOption>("All");
|
||||
const [sortMode, setSortMode] = useState<SidebarSortMode>("time");
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [showMoreCount, setShowMoreCount] = useState(
|
||||
@@ -106,6 +199,12 @@ export function AgentSidebar({
|
||||
const [deleteConfirmThread, setDeleteConfirmThread] = useState<Thread | null>(
|
||||
null,
|
||||
);
|
||||
const [collapsedProjects, setCollapsedProjects] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [projectVisibleCounts, setProjectVisibleCounts] = useState<
|
||||
Record<string, number>
|
||||
>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (isCollapsed && searchOpen) {
|
||||
@@ -120,7 +219,8 @@ export function AgentSidebar({
|
||||
filtered = filtered.filter(
|
||||
(t) =>
|
||||
t.title.toLowerCase().includes(q) ||
|
||||
t.codebase.toLowerCase().includes(q),
|
||||
t.codebase.toLowerCase().includes(q) ||
|
||||
t.workspacePath.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
switch (filter) {
|
||||
@@ -134,19 +234,44 @@ export function AgentSidebar({
|
||||
return filtered;
|
||||
}
|
||||
}, [filter, searchQuery, threads]);
|
||||
const closeMobileSidebar = useCallback(() => {
|
||||
if (isMobile) setOpenMobile(false);
|
||||
}, [isMobile, setOpenMobile]);
|
||||
|
||||
const openThread = useCallback(
|
||||
(threadId: string) => {
|
||||
setView("chat");
|
||||
openHistoryThread(threadId);
|
||||
closeMobileSidebar();
|
||||
},
|
||||
[openHistoryThread, setView],
|
||||
[closeMobileSidebar, openHistoryThread, setView],
|
||||
);
|
||||
|
||||
const openNewThread = useCallback(() => {
|
||||
setView("chat");
|
||||
onNewThread?.();
|
||||
}, [onNewThread, setView]);
|
||||
closeMobileSidebar();
|
||||
}, [closeMobileSidebar, onNewThread, setView]);
|
||||
const openHome = useCallback(() => {
|
||||
onHome();
|
||||
closeMobileSidebar();
|
||||
}, [closeMobileSidebar, onHome]);
|
||||
const openSessions = useCallback(() => {
|
||||
setView("sessions");
|
||||
closeMobileSidebar();
|
||||
}, [closeMobileSidebar, setView]);
|
||||
const openSettings = useCallback(() => {
|
||||
setView("settings");
|
||||
closeMobileSidebar();
|
||||
}, [closeMobileSidebar, setView]);
|
||||
const openSettingsSection = useCallback(
|
||||
(section: SettingsSection) => {
|
||||
onSettingsSectionChange(section);
|
||||
setView("settings");
|
||||
closeMobileSidebar();
|
||||
},
|
||||
[closeMobileSidebar, onSettingsSectionChange, setView],
|
||||
);
|
||||
|
||||
const startRenameThread = useCallback((thread: Thread) => {
|
||||
setEditingSessionId(thread.id);
|
||||
@@ -202,8 +327,30 @@ export function AgentSidebar({
|
||||
: [...pinnedThreads, ...sessionThreads].slice(0, showMoreCount),
|
||||
[filter, pinnedThreads, sessionThreads, showMoreCount],
|
||||
);
|
||||
const showShowMore =
|
||||
sessionThreads.length > showMoreCount || mayHaveMoreSessions;
|
||||
const showTimeShowMore =
|
||||
sessionThreads.length > showMoreCount ||
|
||||
(filter === "All" && !searchQuery && mayHaveMoreSessions);
|
||||
const projectGroups = useMemo(
|
||||
() => groupThreadsByProject([...pinnedThreads, ...sessionThreads]),
|
||||
[pinnedThreads, sessionThreads],
|
||||
);
|
||||
|
||||
const toggleProject = useCallback((project: string) => {
|
||||
setCollapsedProjects((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(project)) next.delete(project);
|
||||
else next.add(project);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const showMoreForProject = useCallback((project: string) => {
|
||||
setProjectVisibleCounts((current) => ({
|
||||
...current,
|
||||
[project]:
|
||||
(current[project] ?? INITIAL_VISIBLE_THREAD_COUNT) +
|
||||
INITIAL_VISIBLE_THREAD_COUNT,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const filterMenu = (
|
||||
<DropdownMenu>
|
||||
@@ -222,6 +369,7 @@ export function AgentSidebar({
|
||||
onValueChange={(value) => {
|
||||
setFilter(value as FilterOption);
|
||||
setShowMoreCount(INITIAL_VISIBLE_THREAD_COUNT);
|
||||
setProjectVisibleCounts({});
|
||||
}}
|
||||
value={filter}
|
||||
>
|
||||
@@ -234,29 +382,119 @@ export function AgentSidebar({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
const sortMenu = (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={`Sort sessions: ${sortMode === "time" ? "Time" : "Project"}`}
|
||||
className="m-0! inline-flex size-8 items-center justify-center rounded-md p-0! text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||
size="icon"
|
||||
title={sortMode === "time" ? "Sort by time" : "Sort by project"}
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowDownUp className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuRadioGroup
|
||||
onValueChange={(value) => {
|
||||
if (value === "time" || value === "project") {
|
||||
setSortMode(value);
|
||||
}
|
||||
}}
|
||||
value={sortMode}
|
||||
>
|
||||
<DropdownMenuRadioItem value="time">
|
||||
<Clock3 className="size-4" />
|
||||
Sort by time
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="project">
|
||||
<FolderTree className="size-4" />
|
||||
Sort by project
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
const threadItem = (thread: Thread) => (
|
||||
<ThreadItem
|
||||
editTitle={editingTitle}
|
||||
editing={editingSessionId === thread.id}
|
||||
isActive={activeThread === thread.id}
|
||||
key={thread.id}
|
||||
onCancelRename={cancelRenameThread}
|
||||
onClick={() => openThread(thread.id)}
|
||||
onCommitRename={() => void commitRenameThread(thread)}
|
||||
onDelete={() => requestDeleteThread(thread)}
|
||||
onEditTitleChange={setEditingTitle}
|
||||
onFork={() => void forkThread(thread)}
|
||||
onRename={() => startRenameThread(thread)}
|
||||
pendingAction={
|
||||
pendingAction?.sessionId === thread.id ? pendingAction.action : null
|
||||
}
|
||||
thread={thread}
|
||||
unread={unreadSessionIds.has(thread.id)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full min-h-0 w-full min-w-0 shrink-0 flex-col overflow-hidden bg-sidebar text-sidebar-foreground">
|
||||
<div className="mt-2 flex w-full min-w-0 flex-col gap-1">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-16 shrink-0 items-center px-4",
|
||||
isCollapsed && "justify-center px-0",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
aria-label="Cline home"
|
||||
className="rounded-md p-1 text-sidebar-foreground transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
|
||||
onClick={openHome}
|
||||
type="button"
|
||||
>
|
||||
<ClineLogo className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={cn("shrink-0 px-3", isCollapsed && "px-1.5")}>
|
||||
<Button
|
||||
className={cn(
|
||||
"justify-start min-w-0",
|
||||
"min-w-0 justify-start",
|
||||
view === "chat" &&
|
||||
isHomeActive &&
|
||||
"bg-sidebar-accent text-sidebar-accent-foreground",
|
||||
isCollapsed && "mx-auto size-9 justify-center px-0",
|
||||
)}
|
||||
aria-label="New Session"
|
||||
onClick={openNewThread}
|
||||
title="New Session"
|
||||
variant="sidebar"
|
||||
aria-label="Home"
|
||||
onClick={openHome}
|
||||
title="Home"
|
||||
variant="sidebarItem"
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<MessageSquare className="size-4" />
|
||||
) : (
|
||||
<Plus className="size-4" />
|
||||
)}
|
||||
{!isCollapsed ? "New Session" : null}
|
||||
<Home className="size-4" />
|
||||
{!isCollapsed ? "Home" : null}
|
||||
</Button>
|
||||
{isCollapsed ? (
|
||||
</div>
|
||||
|
||||
{isCollapsed ? (
|
||||
<div className="mt-2 flex min-h-0 flex-1 flex-col items-center gap-1 px-1.5">
|
||||
{view === "settings" ? (
|
||||
<SettingsSectionNavigation
|
||||
activeSection={settingsSection}
|
||||
collapsed
|
||||
onSelect={openSettingsSection}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
aria-label="New session"
|
||||
className="mx-auto size-9 justify-center px-0"
|
||||
onClick={openNewThread}
|
||||
title="New session"
|
||||
type="button"
|
||||
variant="sidebarItem"
|
||||
>
|
||||
<MessageSquare className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
aria-label="Expand sidebar"
|
||||
className="mx-auto size-9 justify-center px-0"
|
||||
@@ -267,139 +505,211 @@ export function AgentSidebar({
|
||||
>
|
||||
<PanelLeftOpen className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!isCollapsed ? (
|
||||
<div className="flex w-full min-w-0 flex-col gap-1">
|
||||
{searchOpen ? (
|
||||
<div className="flex min-w-0 items-center gap-2 overflow-hidden rounded-md bg-sidebar-accent px-2 py-1.5">
|
||||
<Search className="size-4 shrink-0" />
|
||||
<Input
|
||||
className="min-w-0 flex-1 bg-transparent text-sm text-sidebar-foreground outline-none placeholder:text-muted-foreground"
|
||||
onBlur={() => {
|
||||
if (!searchQuery) setSearchOpen(false);
|
||||
}}
|
||||
autoFocus={true}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search sessions..."
|
||||
value={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
className="py-1.5 min-w-0"
|
||||
onClick={() => setSearchOpen(true)}
|
||||
title="Search sessions"
|
||||
type="button"
|
||||
variant="sidebarItem"
|
||||
>
|
||||
<Search className="size-4 shrink-0" />
|
||||
<span>Search</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isCollapsed ? (
|
||||
<div className="mt-2 min-h-0 w-full flex-1">
|
||||
<ScrollArea className="h-full min-h-0 w-full min-w-0">
|
||||
<div className="flex min-w-0 flex-col gap-0.5 pb-3 px-3">
|
||||
{isLoadingHistory && threads.length === 0 ? (
|
||||
<div className="p-4 text-xs text-muted-foreground">
|
||||
Loading session history...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{displayedThreads.length > 0 && (
|
||||
<ThreadSection
|
||||
action={filterMenu}
|
||||
label={filter === "All" ? "Sessions" : filter}
|
||||
onClick={() => setView("sessions")}
|
||||
>
|
||||
{displayedThreads.map((thread) => (
|
||||
<ThreadItem
|
||||
editTitle={editingTitle}
|
||||
editing={editingSessionId === thread.id}
|
||||
isActive={activeThread === thread.id}
|
||||
key={thread.id}
|
||||
onCancelRename={cancelRenameThread}
|
||||
onClick={() => openThread(thread.id)}
|
||||
onCommitRename={() =>
|
||||
void commitRenameThread(thread)
|
||||
}
|
||||
onDelete={() => requestDeleteThread(thread)}
|
||||
onEditTitleChange={setEditingTitle}
|
||||
onFork={() => void forkThread(thread)}
|
||||
onRename={() => startRenameThread(thread)}
|
||||
pendingAction={
|
||||
pendingAction?.sessionId === thread.id
|
||||
? pendingAction.action
|
||||
: null
|
||||
}
|
||||
thread={thread}
|
||||
unread={unreadSessionIds.has(thread.id)}
|
||||
/>
|
||||
))}
|
||||
</ThreadSection>
|
||||
)}
|
||||
|
||||
{displayedThreads.length === 0 && (
|
||||
<div className="p-4 text-xs text-muted-foreground">
|
||||
{searchQuery
|
||||
? "No sessions match your search."
|
||||
: "No sessions found in history."}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{showShowMore && (
|
||||
<Button
|
||||
className="pl-0"
|
||||
disabled={isLoadingMore}
|
||||
onClick={() => {
|
||||
const nextCount =
|
||||
showMoreCount + INITIAL_VISIBLE_THREAD_COUNT;
|
||||
setShowMoreCount(nextCount);
|
||||
void loadMoreSessions(nextCount);
|
||||
}}
|
||||
type="button"
|
||||
variant="sidebarText"
|
||||
>
|
||||
{isLoadingMore ? (
|
||||
<>
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Show more
|
||||
<ChevronDown className="size-3" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : view === "settings" ? (
|
||||
<div className="mt-5 min-h-0 flex-1 px-3">
|
||||
<SettingsSectionNavigation
|
||||
activeSection={settingsSection}
|
||||
collapsed={false}
|
||||
onSelect={openSettingsSection}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-0 w-full flex-1" />
|
||||
<>
|
||||
<div className="mt-5 shrink-0 px-3">
|
||||
<div className="flex h-8 items-center justify-between gap-2">
|
||||
<button
|
||||
className={cn(
|
||||
"min-w-0 truncate text-sm font-medium text-muted-foreground transition-colors hover:text-sidebar-foreground",
|
||||
view === "sessions" && "text-sidebar-foreground",
|
||||
)}
|
||||
onClick={openSessions}
|
||||
type="button"
|
||||
>
|
||||
{sortMode === "time" ? "Sessions" : "Projects"}
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<Button
|
||||
aria-label="Search sessions"
|
||||
className="m-0! size-8 p-0! text-muted-foreground hover:text-sidebar-foreground"
|
||||
onClick={() => setSearchOpen((current) => !current)}
|
||||
size="icon"
|
||||
title="Search sessions"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Search className="size-3.5" />
|
||||
</Button>
|
||||
{sortMenu}
|
||||
{filterMenu}
|
||||
<Button
|
||||
aria-label="New session"
|
||||
className="m-0! size-8 p-0! text-muted-foreground hover:text-sidebar-foreground"
|
||||
onClick={openNewThread}
|
||||
size="icon"
|
||||
title="New session"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{searchOpen ? (
|
||||
<div className="mt-1 flex min-w-0 items-center gap-2 overflow-hidden rounded-md border border-sidebar-border bg-background/70 px-2 py-1">
|
||||
<Search className="size-4 shrink-0" />
|
||||
<Input
|
||||
className="h-7 min-w-0 flex-1 border-0 bg-transparent px-0 text-sm text-sidebar-foreground shadow-none outline-none placeholder:text-muted-foreground focus-visible:ring-0"
|
||||
autoFocus={true}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search sessions..."
|
||||
value={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 min-h-0 w-full flex-1">
|
||||
<ScrollArea className="h-full min-h-0 w-full min-w-0">
|
||||
<div className="flex min-w-0 flex-col gap-0.5 pb-3 px-3">
|
||||
{isLoadingHistory && threads.length === 0 ? (
|
||||
<div className="p-4 text-xs text-muted-foreground">
|
||||
Loading session history...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{sortMode === "time"
|
||||
? displayedThreads.map(threadItem)
|
||||
: projectGroups.map((project) => {
|
||||
const visibleCount =
|
||||
projectVisibleCounts[project.id] ??
|
||||
INITIAL_VISIBLE_THREAD_COUNT;
|
||||
return (
|
||||
<ProjectSection
|
||||
collapsed={collapsedProjects.has(project.id)}
|
||||
key={project.id}
|
||||
label={project.label}
|
||||
onToggle={() => toggleProject(project.id)}
|
||||
>
|
||||
{project.threads
|
||||
.slice(0, visibleCount)
|
||||
.map(threadItem)}
|
||||
{project.threads.length > visibleCount ? (
|
||||
<Button
|
||||
className="pl-2"
|
||||
onClick={() =>
|
||||
showMoreForProject(project.id)
|
||||
}
|
||||
type="button"
|
||||
variant="sidebarText"
|
||||
>
|
||||
Show more in {project.label}
|
||||
<ChevronDown className="size-3" />
|
||||
</Button>
|
||||
) : null}
|
||||
</ProjectSection>
|
||||
);
|
||||
})}
|
||||
|
||||
{(sortMode === "time"
|
||||
? displayedThreads.length === 0
|
||||
: projectGroups.length === 0) && (
|
||||
<div className="px-2 py-4 text-xs text-muted-foreground">
|
||||
{searchQuery
|
||||
? "No sessions match your search."
|
||||
: "No sessions found in history."}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{sortMode === "time" && showTimeShowMore && (
|
||||
<Button
|
||||
className="pl-0"
|
||||
disabled={isLoadingMore}
|
||||
onClick={() => {
|
||||
const nextCount =
|
||||
showMoreCount + INITIAL_VISIBLE_THREAD_COUNT;
|
||||
setShowMoreCount(nextCount);
|
||||
void loadMoreSessions(nextCount);
|
||||
}}
|
||||
type="button"
|
||||
variant="sidebarText"
|
||||
>
|
||||
{isLoadingMore ? (
|
||||
<>
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Show more
|
||||
<ChevronDown className="size-3" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{sortMode === "project" &&
|
||||
filter === "All" &&
|
||||
!searchQuery &&
|
||||
mayHaveMoreSessions && (
|
||||
<Button
|
||||
className="pl-0"
|
||||
disabled={isLoadingMore}
|
||||
onClick={() => void loadOlderSessions()}
|
||||
type="button"
|
||||
variant="sidebarText"
|
||||
>
|
||||
{isLoadingMore ? (
|
||||
<>
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Loading older projects...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Load older projects
|
||||
<ChevronDown className="size-3" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="shrink-0 px-2 py-3">
|
||||
<div className="shrink-0 border-t border-sidebar-border/70 px-2 py-3">
|
||||
<Button
|
||||
aria-label="Settings"
|
||||
type="button"
|
||||
variant="sidebarItem"
|
||||
className={cn(
|
||||
"justify-start min-w-0",
|
||||
"min-w-0 justify-start",
|
||||
view === "settings" &&
|
||||
"bg-sidebar-accent text-sidebar-accent-foreground",
|
||||
isCollapsed && "mx-auto size-9 justify-center px-0",
|
||||
)}
|
||||
onClick={() => setView("settings")}
|
||||
onClick={openSettings}
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
{!isCollapsed ? "Settings" : null}
|
||||
</Button>
|
||||
{!isCollapsed ? (
|
||||
<div className="mt-2 flex items-center gap-2 rounded-md px-3 py-2 text-sidebar-foreground">
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-primary text-[11px] font-semibold text-primary-foreground">
|
||||
C
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
Cline Desktop
|
||||
</span>
|
||||
<span className="block text-[11px] text-muted-foreground">
|
||||
Local
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog
|
||||
@@ -451,33 +761,35 @@ export function AgentSidebar({
|
||||
);
|
||||
}
|
||||
|
||||
function ThreadSection({
|
||||
function ProjectSection({
|
||||
label,
|
||||
action,
|
||||
onClick,
|
||||
collapsed,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
action?: ReactNode;
|
||||
onClick?: () => void;
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("mb-1 min-w-0")}>
|
||||
<div className="flex h-9 w-full min-w-0 flex-nowrap items-center gap-1 text-sm font-medium text-muted-foreground">
|
||||
<button
|
||||
aria-label={`Open ${label} sessions view`}
|
||||
className="flex min-w-0 flex-1 items-center self-stretch rounded-md pl-0 pr-2 text-left transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<span className="block min-w-0 shrink truncate">{label}</span>
|
||||
</button>
|
||||
{action ? (
|
||||
<div className="flex shrink-0 items-center">{action}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{children}
|
||||
<div className="mb-1 min-w-0">
|
||||
<button
|
||||
aria-expanded={!collapsed}
|
||||
className="flex h-8 w-full min-w-0 items-center gap-1.5 rounded-md px-1 text-left text-sm font-medium text-sidebar-foreground transition-colors hover:bg-sidebar-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
|
||||
onClick={onToggle}
|
||||
title={label}
|
||||
type="button"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-3.5 shrink-0 transition-transform",
|
||||
collapsed && "-rotate-90",
|
||||
)}
|
||||
/>
|
||||
<span className="block min-w-0 truncate">{label}</span>
|
||||
</button>
|
||||
{!collapsed ? <div className="pl-3">{children}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -515,6 +827,7 @@ function ThreadItem({
|
||||
const costLabel = formatCostUsd(thread.totalCostUsd);
|
||||
const title = normalizeTitle(thread.title);
|
||||
const pending = pendingAction !== null;
|
||||
const workspacePath = thread.workspacePath || thread.codebase;
|
||||
const statusDotClass = pending
|
||||
? "bg-yellow-400"
|
||||
: thread.status === "running"
|
||||
@@ -522,16 +835,20 @@ function ThreadItem({
|
||||
: unread
|
||||
? "bg-blue-500"
|
||||
: "";
|
||||
const infoItems: Array<[string, string | null | undefined]> = [
|
||||
const infoItems: Array<[string, string | null | undefined, string?]> = [
|
||||
["ID", thread.id],
|
||||
["Workspace", thread.codebase],
|
||||
[
|
||||
"Workspace",
|
||||
workspaceDisplayName(workspacePath),
|
||||
workspacePath || undefined,
|
||||
],
|
||||
["Status", thread.status],
|
||||
["Updated", thread.time],
|
||||
["Provider", thread.provider],
|
||||
["Model", thread.model],
|
||||
["Tokens", tokenLabel],
|
||||
["Cost", costLabel],
|
||||
].filter((item): item is [string, string] => Boolean(item[1]));
|
||||
].filter((item): item is [string, string, string?] => Boolean(item[1]));
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
@@ -573,20 +890,20 @@ function ThreadItem({
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<span className="block max-w-full min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm font-semibold leading-tight">
|
||||
<span className="block max-w-full min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm font-normal leading-tight">
|
||||
{title}
|
||||
</span>
|
||||
{thread.pinned ? (
|
||||
<Pin
|
||||
aria-label="Pinned"
|
||||
className="size-3 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
) : statusDotClass ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn("size-2 rounded-full", statusDotClass)}
|
||||
/>
|
||||
) : null}
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
{thread.pinned ? (
|
||||
<Pin aria-label="Pinned" className="size-3" />
|
||||
) : statusDotClass ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn("size-1.5 rounded-full", statusDotClass)}
|
||||
/>
|
||||
) : null}
|
||||
<span>{thread.time}</span>
|
||||
</span>
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
</ContextMenuTrigger>
|
||||
@@ -600,10 +917,15 @@ function ThreadItem({
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="truncate text-sm font-medium">{title}</div>
|
||||
<div className="grid grid-cols-[72px_minmax(0,1fr)] gap-x-2 gap-y-1 text-xs">
|
||||
{infoItems.map(([label, value]) => (
|
||||
{infoItems.map(([label, value, fullValue]) => (
|
||||
<div className="contents" key={label}>
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="min-w-0 truncate font-mono">{value}</span>
|
||||
<span
|
||||
className="min-w-0 truncate font-mono"
|
||||
title={fullValue}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ClineLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn("inline-block shrink-0 bg-current", className)}
|
||||
style={{
|
||||
maskImage: "url('/cline-logo-filled.svg')",
|
||||
maskPosition: "center",
|
||||
maskRepeat: "no-repeat",
|
||||
maskSize: "contain",
|
||||
WebkitMaskImage: "url('/cline-logo-filled.svg')",
|
||||
WebkitMaskPosition: "center",
|
||||
WebkitMaskRepeat: "no-repeat",
|
||||
WebkitMaskSize: "contain",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -9,36 +9,41 @@ interface Star {
|
||||
delay: string;
|
||||
duration: string;
|
||||
opacity: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
// Big blurred gradient blobs that slowly drift/rotate to fake an aurora.
|
||||
// Each entry is [positionClasses, gradient, animationDuration, animationDelay].
|
||||
const BLOBS: Array<[string, string, string, string]> = [
|
||||
[
|
||||
"left-[-20%] bottom-[-40%] w-[70%] h-[80%]",
|
||||
"radial-gradient(ellipse at center, oklch(0.55 0.2 278 / 0.55), transparent 70%)",
|
||||
"16s",
|
||||
"0s",
|
||||
],
|
||||
[
|
||||
"left-[25%] bottom-[-50%] w-[60%] h-[90%]",
|
||||
"radial-gradient(ellipse at center, oklch(0.65 0.19 200 / 0.4), transparent 70%)",
|
||||
"22s",
|
||||
"-6s",
|
||||
],
|
||||
[
|
||||
"right-[-15%] bottom-[-40%] w-[65%] h-[85%]",
|
||||
"radial-gradient(ellipse at center, oklch(0.6 0.18 310 / 0.5), transparent 70%)",
|
||||
"19s",
|
||||
"-12s",
|
||||
],
|
||||
[
|
||||
"left-[10%] bottom-[-30%] w-[80%] h-[60%]",
|
||||
"radial-gradient(ellipse at center, oklch(0.75 0.13 340 / 0.35), transparent 70%)",
|
||||
"26s",
|
||||
"-3s",
|
||||
],
|
||||
];
|
||||
const BLOBS = [
|
||||
{
|
||||
id: "periwinkle-left",
|
||||
position: "left-[-20%] bottom-[-40%] w-[70%] h-[80%]",
|
||||
gradient:
|
||||
"radial-gradient(ellipse at center, color-mix(in oklab, var(--brand-periwinkle) 64%, transparent), transparent 70%)",
|
||||
duration: "11s",
|
||||
delay: "0s",
|
||||
reverse: false,
|
||||
},
|
||||
{
|
||||
id: "violet-right",
|
||||
position: "right-[-15%] bottom-[-40%] w-[65%] h-[85%]",
|
||||
gradient:
|
||||
"radial-gradient(ellipse at center, color-mix(in oklab, var(--brand-violet) 58%, transparent), transparent 70%)",
|
||||
duration: "12.5s",
|
||||
delay: "-12s",
|
||||
reverse: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
function seededUnit(index: number, salt: number): number {
|
||||
let value =
|
||||
Math.imul(index + 1, 0x9e3779b1) ^ Math.imul(salt + 1, 0x85ebca6b);
|
||||
value ^= value >>> 16;
|
||||
value = Math.imul(value, 0x7feb352d);
|
||||
value ^= value >>> 15;
|
||||
value = Math.imul(value, 0x846ca68b);
|
||||
value ^= value >>> 16;
|
||||
return (value >>> 0) / 0x1_0000_0000;
|
||||
}
|
||||
|
||||
/**
|
||||
* A decorative aurora background built entirely from CSS: blurred gradient
|
||||
@@ -48,47 +53,81 @@ const BLOBS: Array<[string, string, string, string]> = [
|
||||
*
|
||||
* Keyframes (`aurora-drift`, `aurora-twinkle`) live in app/globals.css.
|
||||
*/
|
||||
export function AuroraBackground({ starCount = 90 }: { starCount?: number }) {
|
||||
// Random star field, generated once per mount.
|
||||
export function AuroraBackground({ starCount = 48 }: { starCount?: number }) {
|
||||
// The field is deterministic so server and browser markup always agree.
|
||||
const stars = useMemo<Star[]>(
|
||||
() =>
|
||||
Array.from({ length: starCount }, () => {
|
||||
Array.from({ length: starCount }, (_, index) => {
|
||||
// Squared skew biases stars toward the bottom, where the glow lives.
|
||||
const r = Math.random();
|
||||
const r = seededUnit(index, 1);
|
||||
const sizeRoll = seededUnit(index, 3);
|
||||
return {
|
||||
left: `${Math.random() * 100}%`,
|
||||
left: `${seededUnit(index, 2) * 100}%`,
|
||||
top: `${100 - (1 - r * r) * 45}%`,
|
||||
size: Math.random() < 0.15 ? 3 : Math.random() < 0.5 ? 2 : 1,
|
||||
delay: `${Math.random() * 4}s`,
|
||||
duration: `${1.5 + Math.random() * 3.5}s`,
|
||||
opacity: 0.3 + Math.random() * 0.6,
|
||||
size: sizeRoll < 0.14 ? 4 : sizeRoll < 0.52 ? 3 : 2,
|
||||
delay: `${seededUnit(index, 4) * -5}s`,
|
||||
duration: `${3.5 + seededUnit(index, 5) * 3.5}s`,
|
||||
opacity: 0.35 + seededUnit(index, 6) * 0.6,
|
||||
color:
|
||||
seededUnit(index, 7) > 0.78
|
||||
? "var(--brand-cyan)"
|
||||
: "color-mix(in oklab, white 92%, var(--brand-lilac))",
|
||||
};
|
||||
}),
|
||||
[starCount],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
{BLOBS.map(([position, gradient, duration, delay], idx) => (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="aurora-horizon absolute inset-x-[-8%] bottom-[-3%] h-[40%] opacity-60 blur-[64px]"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(90deg, color-mix(in oklab, var(--brand-lilac) 58%, transparent), color-mix(in oklab, var(--brand-magenta) 62%, transparent) 42%, color-mix(in oklab, var(--brand-periwinkle) 72%, transparent) 78%, color-mix(in oklab, var(--brand-cyan) 58%, transparent))",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="aurora-current absolute bottom-[3%] left-[-45%] h-[30%] w-[125%] opacity-50 blur-[46px]"
|
||||
style={{
|
||||
animationDelay: "-2s",
|
||||
animationDuration: "9s",
|
||||
background:
|
||||
"linear-gradient(105deg, transparent 12%, color-mix(in oklab, var(--brand-magenta) 66%, transparent) 38%, color-mix(in oklab, var(--brand-periwinkle) 72%, transparent) 58%, transparent 82%)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="aurora-current aurora-current-reverse absolute bottom-[-5%] right-[-42%] h-[34%] w-[120%] opacity-45 blur-[52px]"
|
||||
style={{
|
||||
animationDelay: "-6s",
|
||||
animationDuration: "12s",
|
||||
background:
|
||||
"linear-gradient(75deg, transparent 10%, color-mix(in oklab, var(--brand-cyan) 62%, transparent) 42%, color-mix(in oklab, var(--brand-violet) 70%, transparent) 64%, transparent 88%)",
|
||||
}}
|
||||
/>
|
||||
{BLOBS.map((blob) => (
|
||||
<div
|
||||
key={`blob${idx}`}
|
||||
className={`absolute blur-3xl animate-[aurora-drift_linear_infinite] ${position}`}
|
||||
key={blob.id}
|
||||
className={`aurora-motion absolute blur-[64px] ${blob.reverse ? "aurora-motion-reverse" : ""} ${blob.position}`}
|
||||
style={{
|
||||
background: gradient,
|
||||
animationDuration: duration,
|
||||
animationDelay: delay,
|
||||
background: blob.gradient,
|
||||
animationDuration: blob.duration,
|
||||
animationDelay: blob.delay,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{stars.map((s, idx) => (
|
||||
{stars.map((s) => (
|
||||
<span
|
||||
key={`star${idx}`}
|
||||
className="absolute rounded-none bg-[#b8f3ee] animate-[aurora-twinkle_ease-in-out_infinite]"
|
||||
key={`${s.left}-${s.top}`}
|
||||
className="aurora-star absolute rounded-[1px]"
|
||||
style={{
|
||||
left: s.left,
|
||||
top: s.top,
|
||||
width: s.size,
|
||||
height: s.size,
|
||||
background: s.color,
|
||||
opacity: s.opacity,
|
||||
animationDelay: s.delay,
|
||||
animationDuration: s.duration,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
markdownCodeHighlighter,
|
||||
SUPPORTED_MARKDOWN_LANGUAGES,
|
||||
} from "./markdown-highlighter";
|
||||
|
||||
function highlight(code: string, language: "typescript") {
|
||||
return new Promise<
|
||||
NonNullable<ReturnType<typeof markdownCodeHighlighter.highlight>>
|
||||
>((resolve) => {
|
||||
const immediate = markdownCodeHighlighter.highlight(
|
||||
{ code, language, themes: ["github-light", "github-dark"] },
|
||||
resolve,
|
||||
);
|
||||
if (immediate) resolve(immediate);
|
||||
});
|
||||
}
|
||||
|
||||
describe("markdownCodeHighlighter", () => {
|
||||
test("keeps the syntax bundle to the shared supported language set", () => {
|
||||
expect(SUPPORTED_MARKDOWN_LANGUAGES).toEqual([
|
||||
"bash",
|
||||
"css",
|
||||
"diff",
|
||||
"html",
|
||||
"javascript",
|
||||
"json",
|
||||
"jsonc",
|
||||
"jsx",
|
||||
"markdown",
|
||||
"python",
|
||||
"shellscript",
|
||||
"tsx",
|
||||
"typescript",
|
||||
"yaml",
|
||||
]);
|
||||
expect(markdownCodeHighlighter.supportsLanguage("ts")).toBe(true);
|
||||
expect(markdownCodeHighlighter.supportsLanguage("rust")).toBe(false);
|
||||
});
|
||||
|
||||
test("loads a supported grammar and returns themed tokens", async () => {
|
||||
const result = await highlight("const answer: number = 42;", "typescript");
|
||||
|
||||
expect(
|
||||
result.tokens
|
||||
.flat()
|
||||
.map((token) => token.content)
|
||||
.join(""),
|
||||
).toBe("const answer: number = 42;");
|
||||
expect(result.tokens.flat().some((token) => token.htmlStyle?.color)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import type {
|
||||
HighlighterCore,
|
||||
LanguageRegistration,
|
||||
ThemeRegistration,
|
||||
} from "shiki/core";
|
||||
import type { CodeHighlighterPlugin } from "streamdown";
|
||||
|
||||
type HighlightResult = NonNullable<
|
||||
ReturnType<CodeHighlighterPlugin["highlight"]>
|
||||
>;
|
||||
|
||||
export const SUPPORTED_MARKDOWN_LANGUAGES = [
|
||||
"bash",
|
||||
"css",
|
||||
"diff",
|
||||
"html",
|
||||
"javascript",
|
||||
"json",
|
||||
"jsonc",
|
||||
"jsx",
|
||||
"markdown",
|
||||
"python",
|
||||
"shellscript",
|
||||
"tsx",
|
||||
"typescript",
|
||||
"yaml",
|
||||
] as const;
|
||||
|
||||
type SupportedMarkdownLanguage = (typeof SUPPORTED_MARKDOWN_LANGUAGES)[number];
|
||||
|
||||
const SUPPORTED_LANGUAGE_SET = new Set<string>(SUPPORTED_MARKDOWN_LANGUAGES);
|
||||
|
||||
const LANGUAGE_ALIASES: Record<string, SupportedMarkdownLanguage> = {
|
||||
cjs: "javascript",
|
||||
console: "shellscript",
|
||||
htm: "html",
|
||||
js: "javascript",
|
||||
json5: "jsonc",
|
||||
md: "markdown",
|
||||
mjs: "javascript",
|
||||
py: "python",
|
||||
sh: "shellscript",
|
||||
shell: "shellscript",
|
||||
ts: "typescript",
|
||||
yml: "yaml",
|
||||
};
|
||||
|
||||
const LANGUAGE_LOADERS: Record<
|
||||
SupportedMarkdownLanguage,
|
||||
() => Promise<LanguageRegistration[]>
|
||||
> = {
|
||||
bash: () => import("@shikijs/langs/bash").then((module) => module.default),
|
||||
css: () => import("@shikijs/langs/css").then((module) => module.default),
|
||||
diff: () => import("@shikijs/langs/diff").then((module) => module.default),
|
||||
html: () => import("@shikijs/langs/html").then((module) => module.default),
|
||||
javascript: () =>
|
||||
import("@shikijs/langs/javascript").then((module) => module.default),
|
||||
json: () => import("@shikijs/langs/json").then((module) => module.default),
|
||||
jsonc: () => import("@shikijs/langs/jsonc").then((module) => module.default),
|
||||
jsx: () => import("@shikijs/langs/jsx").then((module) => module.default),
|
||||
markdown: () =>
|
||||
import("@shikijs/langs/markdown").then((module) => module.default),
|
||||
python: () =>
|
||||
import("@shikijs/langs/python").then((module) => module.default),
|
||||
shellscript: () =>
|
||||
import("@shikijs/langs/shellscript").then((module) => module.default),
|
||||
tsx: () => import("@shikijs/langs/tsx").then((module) => module.default),
|
||||
typescript: () =>
|
||||
import("@shikijs/langs/typescript").then((module) => module.default),
|
||||
yaml: () => import("@shikijs/langs/yaml").then((module) => module.default),
|
||||
};
|
||||
|
||||
const LIGHT_THEME = "github-light";
|
||||
const DARK_THEME = "github-dark";
|
||||
const MAX_CACHED_RESULTS = 256;
|
||||
|
||||
let highlighterPromise: Promise<HighlighterCore> | undefined;
|
||||
let themesPromise: Promise<void> | undefined;
|
||||
const languagePromises = new Map<SupportedMarkdownLanguage, Promise<void>>();
|
||||
const resultCache = new Map<string, HighlightResult>();
|
||||
const pendingHighlights = new Map<string, Promise<HighlightResult>>();
|
||||
const loggedFailures = new Set<string>();
|
||||
|
||||
function normalizeLanguage(language: string): SupportedMarkdownLanguage | null {
|
||||
const normalized = language.trim().toLowerCase();
|
||||
if (!normalized) return null;
|
||||
const aliased = LANGUAGE_ALIASES[normalized] ?? normalized;
|
||||
return SUPPORTED_LANGUAGE_SET.has(aliased)
|
||||
? (aliased as SupportedMarkdownLanguage)
|
||||
: null;
|
||||
}
|
||||
|
||||
function getHighlighter(): Promise<HighlighterCore> {
|
||||
if (!highlighterPromise) {
|
||||
highlighterPromise = Promise.all([
|
||||
import("shiki/core"),
|
||||
import("shiki/engine/javascript"),
|
||||
]).then(([core, engine]) =>
|
||||
core.createHighlighterCore({
|
||||
engine: engine.createJavaScriptRegexEngine({ forgiving: true }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return highlighterPromise;
|
||||
}
|
||||
|
||||
function ensureThemes(highlighter: HighlighterCore): Promise<void> {
|
||||
if (!themesPromise) {
|
||||
themesPromise = Promise.all([
|
||||
import("@shikijs/themes/github-light").then((module) => module.default),
|
||||
import("@shikijs/themes/github-dark").then((module) => module.default),
|
||||
]).then((themes: ThemeRegistration[]) => highlighter.loadTheme(...themes));
|
||||
}
|
||||
return themesPromise;
|
||||
}
|
||||
|
||||
function ensureLanguage(
|
||||
highlighter: HighlighterCore,
|
||||
language: SupportedMarkdownLanguage,
|
||||
): Promise<void> {
|
||||
const existing = languagePromises.get(language);
|
||||
if (existing) return existing;
|
||||
|
||||
const loading = LANGUAGE_LOADERS[language]().then((registrations) =>
|
||||
highlighter.loadLanguage(...registrations),
|
||||
);
|
||||
languagePromises.set(language, loading);
|
||||
return loading;
|
||||
}
|
||||
|
||||
function rawHighlight(code: string): HighlightResult {
|
||||
return {
|
||||
bg: "transparent",
|
||||
fg: "inherit",
|
||||
tokens: code.split("\n").map((line) =>
|
||||
line
|
||||
? [
|
||||
{
|
||||
bgColor: "transparent",
|
||||
color: "inherit",
|
||||
content: line,
|
||||
htmlStyle: {},
|
||||
offset: 0,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function cacheResult(key: string, result: HighlightResult): void {
|
||||
resultCache.delete(key);
|
||||
resultCache.set(key, result);
|
||||
if (resultCache.size <= MAX_CACHED_RESULTS) return;
|
||||
|
||||
const oldestKey = resultCache.keys().next().value;
|
||||
if (oldestKey !== undefined) resultCache.delete(oldestKey);
|
||||
}
|
||||
|
||||
function reportHighlightFailure(
|
||||
language: SupportedMarkdownLanguage,
|
||||
error: unknown,
|
||||
): void {
|
||||
if (loggedFailures.has(language)) return;
|
||||
loggedFailures.add(language);
|
||||
console.warn(
|
||||
`Syntax highlighting unavailable for ${language}; rendering plain code.`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
function loadHighlight(
|
||||
code: string,
|
||||
language: SupportedMarkdownLanguage,
|
||||
): Promise<HighlightResult> {
|
||||
const cacheKey = `${language}\0${code}`;
|
||||
const cached = resultCache.get(cacheKey);
|
||||
if (cached) return Promise.resolve(cached);
|
||||
|
||||
const pending = pendingHighlights.get(cacheKey);
|
||||
if (pending) return pending;
|
||||
|
||||
const loading = getHighlighter()
|
||||
.then(async (highlighter) => {
|
||||
await ensureThemes(highlighter);
|
||||
await ensureLanguage(highlighter, language);
|
||||
const result = highlighter.codeToTokens(code, {
|
||||
lang: language,
|
||||
themes: {
|
||||
dark: DARK_THEME,
|
||||
light: LIGHT_THEME,
|
||||
},
|
||||
});
|
||||
return {
|
||||
bg: result.bg,
|
||||
fg: result.fg,
|
||||
rootStyle: result.rootStyle,
|
||||
tokens: result.tokens,
|
||||
} satisfies HighlightResult;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
reportHighlightFailure(language, error);
|
||||
return rawHighlight(code);
|
||||
})
|
||||
.then((result) => {
|
||||
cacheResult(cacheKey, result);
|
||||
return result;
|
||||
})
|
||||
.finally(() => {
|
||||
pendingHighlights.delete(cacheKey);
|
||||
});
|
||||
|
||||
pendingHighlights.set(cacheKey, loading);
|
||||
return loading;
|
||||
}
|
||||
|
||||
export const markdownCodeHighlighter = {
|
||||
getSupportedLanguages: () => [...SUPPORTED_MARKDOWN_LANGUAGES],
|
||||
getThemes: () => [LIGHT_THEME, DARK_THEME],
|
||||
highlight: ({ code, language }, callback) => {
|
||||
const normalizedLanguage = normalizeLanguage(language);
|
||||
if (!normalizedLanguage) return rawHighlight(code);
|
||||
|
||||
const cacheKey = `${normalizedLanguage}\0${code}`;
|
||||
const cached = resultCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const loading = loadHighlight(code, normalizedLanguage);
|
||||
if (callback) {
|
||||
void loading.then((result) => callback(result));
|
||||
}
|
||||
return null;
|
||||
},
|
||||
name: "shiki",
|
||||
supportsLanguage: (language) => normalizeLanguage(language) !== null,
|
||||
type: "code-highlighter",
|
||||
} satisfies CodeHighlighterPlugin;
|
||||
@@ -0,0 +1,208 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { MarkdownLinkSafetyModal, MemoizedMarkdown } from "./markdown";
|
||||
|
||||
const originalClipboard = Object.getOwnPropertyDescriptor(
|
||||
navigator,
|
||||
"clipboard",
|
||||
);
|
||||
|
||||
let writeText: ReturnType<typeof vi.fn>;
|
||||
let openWindow: ReturnType<typeof vi.fn<typeof window.open>>;
|
||||
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);
|
||||
writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
openWindow = vi.fn<typeof window.open>(() => null);
|
||||
vi.spyOn(window, "open").mockImplementation(openWindow);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
if (originalClipboard) {
|
||||
Object.defineProperty(navigator, "clipboard", originalClipboard);
|
||||
} else {
|
||||
Reflect.deleteProperty(navigator, "clipboard");
|
||||
}
|
||||
});
|
||||
|
||||
async function renderMarkdown(
|
||||
props: Parameters<typeof MemoizedMarkdown>[0],
|
||||
): Promise<void> {
|
||||
await act(async () => root.render(<MemoizedMarkdown {...props} />));
|
||||
}
|
||||
|
||||
async function click(element: Element): Promise<void> {
|
||||
await act(async () => {
|
||||
element.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true, cancelable: true }),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
async function dispatchMouseEvent(
|
||||
element: Element,
|
||||
type: "auxclick" | "contextmenu",
|
||||
button: number,
|
||||
): Promise<void> {
|
||||
await act(async () => {
|
||||
element.dispatchEvent(
|
||||
new MouseEvent(type, { bubbles: true, button, cancelable: true }),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
function getButton(label: string): HTMLButtonElement {
|
||||
const button = [
|
||||
...document.querySelectorAll<HTMLButtonElement>("button"),
|
||||
].find((candidate) => candidate.textContent?.trim() === label);
|
||||
expect(button).toBeDefined();
|
||||
return button as HTMLButtonElement;
|
||||
}
|
||||
|
||||
describe("MemoizedMarkdown interactions", () => {
|
||||
test("confirms and closes an external link dialog exactly once", async () => {
|
||||
const onClose = vi.fn();
|
||||
const onConfirm = vi.fn();
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MarkdownLinkSafetyModal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
onConfirm={onConfirm}
|
||||
url="https://example.com/review"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await click(getButton("Open link"));
|
||||
await vi.waitFor(() => {
|
||||
expect(onConfirm).toHaveBeenCalledOnce();
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
test("requires confirmation before opening an external link", async () => {
|
||||
const url = "https://example.com/review?source=cline";
|
||||
await renderMarkdown({ content: `[Review docs](${url})` });
|
||||
const link = await vi.waitFor(() => {
|
||||
const renderedLink = container.querySelector<HTMLElement>(
|
||||
'[data-streamdown="link"]',
|
||||
);
|
||||
expect(renderedLink).not.toBeNull();
|
||||
return renderedLink as HTMLElement;
|
||||
});
|
||||
expect(link.tagName).toBe("A");
|
||||
expect(link.getAttribute("href")).toBe("#confirm-external-link");
|
||||
|
||||
await dispatchMouseEvent(link, "contextmenu", 2);
|
||||
expect(openWindow).not.toHaveBeenCalled();
|
||||
expect(document.querySelector('[role="alertdialog"]')).toBeNull();
|
||||
|
||||
await dispatchMouseEvent(link, "auxclick", 1);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('[role="alertdialog"]')).not.toBeNull();
|
||||
});
|
||||
await click(getButton("Cancel"));
|
||||
|
||||
await click(link);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('[role="alertdialog"]')).not.toBeNull();
|
||||
expect(document.body.textContent).toContain(url);
|
||||
});
|
||||
|
||||
await click(getButton("Cancel"));
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('[role="alertdialog"]')).toBeNull();
|
||||
});
|
||||
expect(openWindow).not.toHaveBeenCalled();
|
||||
|
||||
await click(link);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('[role="alertdialog"]')).not.toBeNull();
|
||||
});
|
||||
await click(getButton("Open link"));
|
||||
|
||||
expect(openWindow).toHaveBeenCalledTimes(1);
|
||||
expect(openWindow).toHaveBeenCalledWith(url, "_blank", "noreferrer");
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('[role="alertdialog"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps same-document links navigable without a confirmation", async () => {
|
||||
await renderMarkdown({ content: "[Details](#details)" });
|
||||
const link = container.querySelector<HTMLAnchorElement>(
|
||||
'[data-streamdown="link"]',
|
||||
);
|
||||
|
||||
expect(link?.getAttribute("href")).toBe("#details");
|
||||
await click(link as HTMLAnchorElement);
|
||||
expect(document.querySelector('[role="alertdialog"]')).toBeNull();
|
||||
expect(openWindow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("copies fenced code through the Clipboard API", async () => {
|
||||
const source = "const answer = 42;";
|
||||
await renderMarkdown({
|
||||
content: `\`\`\`text\n${source}\n\`\`\``,
|
||||
});
|
||||
const copyButton = await vi.waitFor(() => {
|
||||
const button = container.querySelector<HTMLButtonElement>(
|
||||
'[data-streamdown="code-block-copy-button"]',
|
||||
);
|
||||
expect(button).not.toBeNull();
|
||||
return button as HTMLButtonElement;
|
||||
});
|
||||
|
||||
await click(copyButton);
|
||||
await vi.waitFor(() => {
|
||||
expect(writeText).toHaveBeenCalledWith(`${source}\n`);
|
||||
});
|
||||
});
|
||||
|
||||
test("rerenders incomplete streaming Markdown as completed static Markdown", async () => {
|
||||
await renderMarkdown({
|
||||
content: "```text\nconst answer =",
|
||||
streaming: true,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const codeBlock = container.querySelector(
|
||||
'[data-streamdown="code-block"]',
|
||||
);
|
||||
expect(codeBlock?.getAttribute("data-incomplete")).toBe("true");
|
||||
});
|
||||
|
||||
await renderMarkdown({
|
||||
content: "```text\nconst answer = 42;\n```\n\nCompleted.",
|
||||
streaming: false,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const codeBlock = container.querySelector(
|
||||
'[data-streamdown="code-block"]',
|
||||
);
|
||||
expect(codeBlock).not.toBeNull();
|
||||
expect(codeBlock?.getAttribute("data-incomplete")).toBeNull();
|
||||
expect(container.textContent).toContain("const answer = 42;");
|
||||
expect(container.textContent).toContain("Completed.");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { MemoizedMarkdown } from "./markdown";
|
||||
|
||||
describe("MemoizedMarkdown", () => {
|
||||
test("renders structured GFM content and blocks remote images", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<MemoizedMarkdown
|
||||
content={`# Review
|
||||
|
||||
| Surface | Status |
|
||||
| --- | --- |
|
||||
| Code | Ready |
|
||||
|
||||
\`\`\`typescript
|
||||
const ready = true;
|
||||
\`\`\`
|
||||
|
||||
`}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-streamdown="heading-1"');
|
||||
expect(html).toContain('data-streamdown="table-wrapper"');
|
||||
expect(html).toContain('data-streamdown="code-block"');
|
||||
expect(html).toContain('data-streamdown="blocked-image"');
|
||||
expect(html).toContain("External image blocked for privacy");
|
||||
expect(html).not.toContain("<img");
|
||||
});
|
||||
|
||||
test("renders app-local images", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<MemoizedMarkdown
|
||||
content={`
|
||||
|
||||
`}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html.match(/<img/g)).toHaveLength(2);
|
||||
expect(html).toContain('src="/images/local.png"');
|
||||
expect(html).toContain('src="/images/second-local.png"');
|
||||
expect(html).not.toContain('data-streamdown="blocked-image"');
|
||||
});
|
||||
|
||||
test("repairs an unfinished code fence while streaming", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<MemoizedMarkdown
|
||||
content={"```typescript\nconst stillStreaming = true;"}
|
||||
streaming
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-streamdown="code-block"');
|
||||
expect(html).toContain("stillStreaming");
|
||||
});
|
||||
|
||||
test("routes external links through confirmation controls", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<MemoizedMarkdown content="[Review](https://example.com/review)" />,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-streamdown="link"');
|
||||
expect(html).toContain("Review");
|
||||
expect(html).toContain('href="#confirm-external-link"');
|
||||
expect(html).toContain('aria-haspopup="dialog"');
|
||||
expect(html).not.toContain('href="https://example.com/review"');
|
||||
});
|
||||
|
||||
test("leaves app-local and fragment links navigable", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<MemoizedMarkdown content="[Details](#details) [Home](/)" />,
|
||||
);
|
||||
|
||||
expect(html).toContain('href="#details"');
|
||||
expect(html).toContain('href="/"');
|
||||
expect(html).not.toContain('aria-haspopup="dialog"');
|
||||
});
|
||||
|
||||
test("blocks scheme-less hostnames before they reach link rendering", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<MemoizedMarkdown content="[Review](example.com/path)" />,
|
||||
);
|
||||
|
||||
expect(html).toContain("Review");
|
||||
expect(html).toContain("blocked");
|
||||
expect(html).not.toContain("<a");
|
||||
expect(html).not.toContain('data-streamdown="link"');
|
||||
});
|
||||
|
||||
test("does not expose unsafe script URLs or raw scripts", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<MemoizedMarkdown
|
||||
content={
|
||||
'[unsafe](javascript:alert("no"))\n\n\n\n\n\n<script>window.pwned = true</script>'
|
||||
}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).not.toContain("javascript:");
|
||||
expect(html).not.toContain("data:image");
|
||||
expect(html).not.toContain("file:///etc/passwd");
|
||||
expect(html).not.toContain("<script");
|
||||
});
|
||||
});
|
||||
@@ -1,45 +1,216 @@
|
||||
import { marked } from "marked";
|
||||
import { memo, useMemo } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { cjk } from "@streamdown/cjk";
|
||||
import type { ComponentProps, MouseEvent } from "react";
|
||||
import { memo, useState } from "react";
|
||||
import {
|
||||
type Components,
|
||||
type ControlsConfig,
|
||||
type ExtraProps,
|
||||
type LinkSafetyModalProps,
|
||||
Streamdown,
|
||||
} from "streamdown";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "./alert-dialog";
|
||||
import { markdownCodeHighlighter } from "./markdown-highlighter";
|
||||
|
||||
const MemoizedMarkdownBlock = memo(
|
||||
({ content }: { content: string }) => {
|
||||
return (
|
||||
<div className="markdown">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
if (prevProps.content !== nextProps.content) return false;
|
||||
return true;
|
||||
},
|
||||
);
|
||||
const streamdownPlugins = { cjk, code: markdownCodeHighlighter };
|
||||
const streamdownControls = {
|
||||
code: { copy: true, download: false },
|
||||
mermaid: false,
|
||||
table: false,
|
||||
} satisfies ControlsConfig;
|
||||
|
||||
MemoizedMarkdownBlock.displayName = "MemoizedMarkdownBlock";
|
||||
|
||||
export function parseMarkdownIntoBlocks(markdown: string): string[] {
|
||||
const tokens = marked.lexer(markdown);
|
||||
return tokens.map((token) => token.raw);
|
||||
export function MarkdownLinkSafetyModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
url,
|
||||
}: LinkSafetyModalProps) {
|
||||
return (
|
||||
<AlertDialog
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
open={isOpen}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Open external link?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
You are about to leave Cline and visit this address.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="max-h-32 overflow-y-auto wrap-break-word rounded-md bg-muted p-3 font-mono text-sm">
|
||||
{url}
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onConfirm}>Open link</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export const MemoizedMarkdown = memo(
|
||||
({ content, id }: { content: string; id: string }) => {
|
||||
const blocks = useMemo(() => parseMarkdownIntoBlocks(content), [content]);
|
||||
const occurrences = new Map<string, number>();
|
||||
type MarkdownLinkProps = ComponentProps<"a"> & ExtraProps;
|
||||
|
||||
return blocks.map((block) => {
|
||||
const occurrence = (occurrences.get(block) ?? 0) + 1;
|
||||
occurrences.set(block, occurrence);
|
||||
return (
|
||||
<MemoizedMarkdownBlock
|
||||
content={block}
|
||||
key={`${id}-block_${occurrence}-${block}`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
},
|
||||
function SafeMarkdownLink({
|
||||
children,
|
||||
className,
|
||||
href,
|
||||
node: _node,
|
||||
rel: _rel,
|
||||
target: _target,
|
||||
title,
|
||||
...props
|
||||
}: MarkdownLinkProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const isIncomplete = href === "streamdown:incomplete-link";
|
||||
const url = isIncomplete ? undefined : href;
|
||||
|
||||
if (!url) {
|
||||
return (
|
||||
<span
|
||||
className={className}
|
||||
data-incomplete={isIncomplete}
|
||||
data-streamdown="link"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const isAppLink =
|
||||
url.startsWith("#") ||
|
||||
(url.startsWith("/") && !url.startsWith("//")) ||
|
||||
url.startsWith("./") ||
|
||||
url.startsWith("../") ||
|
||||
(!/^[a-z][a-z\d+.-]*:/i.test(url) && !url.startsWith("//"));
|
||||
|
||||
if (isAppLink) {
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
className={`wrap-anywhere font-medium text-primary underline ${className ?? ""}`}
|
||||
data-streamdown="link"
|
||||
href={url}
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const openConfirmation = (event: MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault();
|
||||
setIsOpen(true);
|
||||
};
|
||||
const confirmMiddleClick = (event: MouseEvent<HTMLAnchorElement>) => {
|
||||
if (event.button === 1) openConfirmation(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* biome-ignore lint/a11y/useValidAnchor: External Markdown retains native link semantics while confirmation withholds the live destination. */}
|
||||
<a
|
||||
{...props}
|
||||
aria-haspopup="dialog"
|
||||
className={`wrap-anywhere font-medium text-primary underline ${className ?? ""}`}
|
||||
data-streamdown="link"
|
||||
href="#confirm-external-link"
|
||||
onAuxClick={confirmMiddleClick}
|
||||
onClick={openConfirmation}
|
||||
title={title ?? url}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
<MarkdownLinkSafetyModal
|
||||
isOpen={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
onConfirm={() => window.open(url, "_blank", "noreferrer")}
|
||||
url={url}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type MarkdownImageProps =
|
||||
| (ComponentProps<"img"> & ExtraProps)
|
||||
| (Record<string, unknown> & ExtraProps);
|
||||
|
||||
const remoteImagePattern = /^(?:https?:)?[\\/]{2}/i;
|
||||
|
||||
function isSafeMarkdownImageSource(source: string): boolean {
|
||||
const normalized = source.trim();
|
||||
if (!normalized || remoteImagePattern.test(normalized)) return false;
|
||||
|
||||
// Streamdown's hardened URL policy accepts app-root paths. Keeping the rule
|
||||
// this narrow prevents model-authored Markdown from making hidden requests.
|
||||
return normalized.startsWith("/");
|
||||
}
|
||||
|
||||
function MarkdownImage({ alt, height, src, title, width }: MarkdownImageProps) {
|
||||
const label = typeof alt === "string" ? alt.trim() : "";
|
||||
const source = typeof src === "string" ? src.trim() : "";
|
||||
|
||||
if (source && isSafeMarkdownImageSource(source)) {
|
||||
return (
|
||||
// biome-ignore lint/performance/noImgElement: Markdown can reference runtime app assets that Next Image cannot statically optimize.
|
||||
<img
|
||||
alt={label}
|
||||
className="my-4 max-w-full rounded-lg"
|
||||
data-streamdown="image"
|
||||
height={typeof height === "number" ? height : undefined}
|
||||
loading="lazy"
|
||||
src={source}
|
||||
title={typeof title === "string" ? title : undefined}
|
||||
width={typeof width === "number" ? width : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span data-streamdown="blocked-image" role="note">
|
||||
External image blocked for privacy{label ? `: ${label}` : ""}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const markdownComponents = {
|
||||
a: SafeMarkdownLink,
|
||||
img: MarkdownImage,
|
||||
} satisfies Components;
|
||||
|
||||
export const MemoizedMarkdown = memo(
|
||||
({
|
||||
content,
|
||||
streaming = false,
|
||||
}: {
|
||||
content: string;
|
||||
streaming?: boolean;
|
||||
}) => (
|
||||
<Streamdown
|
||||
className="cline-markdown"
|
||||
components={markdownComponents}
|
||||
controls={streamdownControls}
|
||||
dir="auto"
|
||||
isAnimating={streaming}
|
||||
lineNumbers
|
||||
mode={streaming ? "streaming" : "static"}
|
||||
normalizeHtmlIndentation
|
||||
parseIncompleteMarkdown={streaming}
|
||||
plugins={streamdownPlugins}
|
||||
>
|
||||
{content}
|
||||
</Streamdown>
|
||||
),
|
||||
);
|
||||
|
||||
MemoizedMarkdown.displayName = "MemoizedMarkdown";
|
||||
|
||||
@@ -26,7 +26,7 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
const SIDEBAR_WIDTH = 256;
|
||||
const SIDEBAR_WIDTH = 240;
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||
const SIDEBAR_WIDTH_ICON = "3rem";
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||
|
||||
@@ -26,6 +26,7 @@ function Slider({
|
||||
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
data-interactive=""
|
||||
data-slot="slider"
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { WorkspaceProvider } from "@/contexts/workspace-context";
|
||||
import type { ChatSessionStatus } from "@/lib/chat-schema";
|
||||
import { ChatInputBar } from "./chat-input-bar";
|
||||
|
||||
const { loadProviderModelCatalogMock, loadProviderModelsMock } = vi.hoisted(
|
||||
() => ({
|
||||
loadProviderModelCatalogMock: vi.fn(),
|
||||
loadProviderModelsMock: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/lib/provider-model-catalog", () => ({
|
||||
loadProviderModelCatalog: loadProviderModelCatalogMock,
|
||||
loadProviderModels: loadProviderModelsMock,
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
loadProviderModelCatalogMock.mockReset().mockResolvedValue({
|
||||
providers: [],
|
||||
enabledProviderIds: ["cline"],
|
||||
providerModels: { cline: ["test-model"] },
|
||||
providerReasoningModels: { cline: [] },
|
||||
});
|
||||
loadProviderModelsMock.mockReset().mockResolvedValue([]);
|
||||
HTMLElement.prototype.scrollIntoView = vi.fn();
|
||||
HTMLElement.prototype.hasPointerCapture = vi.fn(() => false);
|
||||
HTMLElement.prototype.setPointerCapture = vi.fn();
|
||||
HTMLElement.prototype.releasePointerCapture = vi.fn();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("ChatInputBar", () => {
|
||||
it("preserves an explicit High selection across capability and status updates", async () => {
|
||||
const onReasoningChange = vi.fn();
|
||||
const render = async (status: ChatSessionStatus) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WorkspaceProvider
|
||||
value={{
|
||||
workspaceRoot: "/workspace/cline",
|
||||
workspaces: ["/workspace/cline"],
|
||||
listWorkspaces: vi.fn(async () => ["/workspace/cline"]),
|
||||
refreshWorkspaces: vi.fn(async () => undefined),
|
||||
switchWorkspace: vi.fn(async () => true),
|
||||
pickWorkspaceDirectory: vi.fn(async () => null),
|
||||
}}
|
||||
>
|
||||
<ChatInputBar
|
||||
attachments={[]}
|
||||
gitBranch="main"
|
||||
mode="act"
|
||||
model="test-model"
|
||||
onAbort={vi.fn()}
|
||||
onAttachFiles={vi.fn()}
|
||||
onEditPromptInQueue={vi.fn()}
|
||||
onListGitBranches={vi.fn(async () => ({
|
||||
current: "main",
|
||||
branches: ["main"],
|
||||
}))}
|
||||
onModeToggle={vi.fn()}
|
||||
onModelChange={vi.fn()}
|
||||
onPromptInputChange={vi.fn()}
|
||||
onProviderChange={vi.fn()}
|
||||
onReasoningChange={onReasoningChange}
|
||||
onRemoveAttachment={vi.fn()}
|
||||
onSend={vi.fn()}
|
||||
onSteerPromptInQueue={vi.fn()}
|
||||
onSwitchGitBranch={vi.fn(async () => true)}
|
||||
onUndoPromptInQueue={vi.fn()}
|
||||
promptInput=""
|
||||
promptsInQueue={[]}
|
||||
provider="cline"
|
||||
reasoningEffort="high"
|
||||
status={status}
|
||||
summary={{ toolCalls: 0, tokensIn: 0, tokensOut: 0 }}
|
||||
thinking
|
||||
/>
|
||||
</WorkspaceProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
};
|
||||
|
||||
await render("idle");
|
||||
await vi.waitFor(() => {
|
||||
const trigger = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Thinking level"]',
|
||||
);
|
||||
expect(trigger?.textContent).toContain("High");
|
||||
expect(trigger?.disabled).toBe(true);
|
||||
});
|
||||
await render("starting");
|
||||
expect(container.querySelector('[aria-label="Stop agent"]')).toBeNull();
|
||||
await render("running");
|
||||
expect(container.querySelector('[aria-label="Stop agent"]')).not.toBeNull();
|
||||
|
||||
expect(onReasoningChange).not.toHaveBeenCalled();
|
||||
const workspaceTrigger = container.querySelector("#git-branch-btn");
|
||||
expect(workspaceTrigger?.parentElement?.parentElement?.className).toContain(
|
||||
"overflow-visible",
|
||||
);
|
||||
expect(
|
||||
workspaceTrigger?.parentElement?.parentElement?.className,
|
||||
).not.toContain("truncate");
|
||||
});
|
||||
|
||||
it("selects High from the supported model thinking menu", async () => {
|
||||
loadProviderModelCatalogMock.mockResolvedValue({
|
||||
providers: [],
|
||||
enabledProviderIds: ["cline"],
|
||||
providerModels: { cline: ["test-model"] },
|
||||
providerReasoningModels: { cline: ["test-model"] },
|
||||
});
|
||||
const onReasoningChange = vi.fn();
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WorkspaceProvider
|
||||
value={{
|
||||
workspaceRoot: "/workspace/cline",
|
||||
workspaces: ["/workspace/cline"],
|
||||
listWorkspaces: vi.fn(async () => ["/workspace/cline"]),
|
||||
refreshWorkspaces: vi.fn(async () => undefined),
|
||||
switchWorkspace: vi.fn(async () => true),
|
||||
pickWorkspaceDirectory: vi.fn(async () => null),
|
||||
}}
|
||||
>
|
||||
<ChatInputBar
|
||||
attachments={[]}
|
||||
gitBranch="main"
|
||||
mode="act"
|
||||
model="test-model"
|
||||
onAbort={vi.fn()}
|
||||
onAttachFiles={vi.fn()}
|
||||
onEditPromptInQueue={vi.fn()}
|
||||
onListGitBranches={vi.fn(async () => ({
|
||||
current: "main",
|
||||
branches: ["main"],
|
||||
}))}
|
||||
onModeToggle={vi.fn()}
|
||||
onModelChange={vi.fn()}
|
||||
onPromptInputChange={vi.fn()}
|
||||
onProviderChange={vi.fn()}
|
||||
onReasoningChange={onReasoningChange}
|
||||
onRemoveAttachment={vi.fn()}
|
||||
onSend={vi.fn()}
|
||||
onSteerPromptInQueue={vi.fn()}
|
||||
onSwitchGitBranch={vi.fn(async () => true)}
|
||||
onUndoPromptInQueue={vi.fn()}
|
||||
promptInput=""
|
||||
promptsInQueue={[]}
|
||||
provider="cline"
|
||||
reasoningEffort="low"
|
||||
status="idle"
|
||||
summary={{ toolCalls: 0, tokensIn: 0, tokensOut: 0 }}
|
||||
thinking
|
||||
/>
|
||||
</WorkspaceProvider>,
|
||||
);
|
||||
});
|
||||
const trigger = await vi.waitFor(() => {
|
||||
const element = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Thinking level"]',
|
||||
);
|
||||
expect(element?.disabled).toBe(false);
|
||||
return element as HTMLButtonElement;
|
||||
});
|
||||
await act(async () => {
|
||||
trigger.dispatchEvent(
|
||||
new MouseEvent("pointerdown", { bubbles: true, cancelable: true }),
|
||||
);
|
||||
trigger.click();
|
||||
});
|
||||
const highOption = await vi.waitFor(() => {
|
||||
const element = [
|
||||
...document.querySelectorAll<HTMLElement>('[role="option"]'),
|
||||
].find((option) => option.textContent?.includes("High"));
|
||||
expect(element).toBeDefined();
|
||||
return element as HTMLElement;
|
||||
});
|
||||
await act(async () => {
|
||||
highOption.dispatchEvent(
|
||||
new MouseEvent("pointerup", { bubbles: true, cancelable: true }),
|
||||
);
|
||||
highOption.click();
|
||||
});
|
||||
|
||||
expect(onReasoningChange).toHaveBeenCalledWith({
|
||||
thinking: true,
|
||||
reasoningEffort: "high",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,10 +7,8 @@ import {
|
||||
ChevronDown,
|
||||
CircleStop,
|
||||
Coins,
|
||||
Mic,
|
||||
Paperclip,
|
||||
Pencil,
|
||||
RotateCcw,
|
||||
Undo2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
@@ -23,6 +21,13 @@ import {
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useWorkspace } from "@/contexts/workspace-context";
|
||||
import type { PromptInQueue } from "@/hooks/chat-session/types";
|
||||
import type { ChatSessionConfig, ChatSessionStatus } from "@/lib/chat-schema";
|
||||
@@ -188,6 +193,7 @@ function getActiveSlash(input: string, cursor: number): ActiveSlash | null {
|
||||
}
|
||||
|
||||
type ChatInputBarProps = {
|
||||
variant?: "conversation" | "welcome";
|
||||
status: ChatSessionStatus;
|
||||
provider: string;
|
||||
model: string;
|
||||
@@ -203,12 +209,10 @@ type ChatInputBarProps = {
|
||||
onReasoningChange: (
|
||||
next: Pick<ChatSessionConfig, "thinking" | "reasoningEffort">,
|
||||
) => void;
|
||||
onRefreshGitBranch: () => void;
|
||||
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
|
||||
onSwitchGitBranch: (branch: string) => Promise<boolean>;
|
||||
onSend: () => void;
|
||||
onAbort: () => void;
|
||||
onReset: () => void;
|
||||
promptsInQueue: PromptInQueue[];
|
||||
attachments: Array<{ id: string; name: string; isImage: boolean }>;
|
||||
onAttachFiles: (files: File[]) => void;
|
||||
@@ -227,6 +231,7 @@ type ChatInputBarProps = {
|
||||
};
|
||||
|
||||
export function ChatInputBar({
|
||||
variant = "conversation",
|
||||
status,
|
||||
provider,
|
||||
model,
|
||||
@@ -240,12 +245,10 @@ export function ChatInputBar({
|
||||
onModelChange,
|
||||
onModeToggle,
|
||||
onReasoningChange,
|
||||
onRefreshGitBranch,
|
||||
onListGitBranches,
|
||||
onSwitchGitBranch,
|
||||
onSend,
|
||||
onAbort,
|
||||
onReset,
|
||||
promptsInQueue,
|
||||
attachments,
|
||||
onAttachFiles,
|
||||
@@ -264,10 +267,33 @@ export function ChatInputBar({
|
||||
} = useWorkspace();
|
||||
const isBusy =
|
||||
status === "starting" || status === "running" || status === "stopping";
|
||||
const canAbort = status === "running" || status === "stopping";
|
||||
const hasDraft = promptInput.trim().length > 0 || attachments.length > 0;
|
||||
|
||||
const [modelSupportsReasoning, setModelSupportsReasoning] = useState(() =>
|
||||
hasReasoningCapability(FALLBACK_PROVIDER_REASONING_MODELS, provider, model),
|
||||
const [reasoningCapability, setReasoningCapability] = useState<{
|
||||
provider: string;
|
||||
model: string;
|
||||
supported: boolean | null;
|
||||
} | null>(null);
|
||||
const modelSupportsReasoning =
|
||||
reasoningCapability?.provider === provider &&
|
||||
reasoningCapability.model === model
|
||||
? reasoningCapability.supported
|
||||
: null;
|
||||
const handleModelSupportsReasoningChange = useCallback(
|
||||
(supported: boolean | null) => {
|
||||
setReasoningCapability((current) => {
|
||||
if (
|
||||
current?.provider === provider &&
|
||||
current.model === model &&
|
||||
current.supported === supported
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
return { provider, model, supported };
|
||||
});
|
||||
},
|
||||
[model, provider],
|
||||
);
|
||||
const canSend = hasDraft;
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
@@ -312,32 +338,50 @@ export function ChatInputBar({
|
||||
() => resolveEffortIndex(thinking, reasoningEffort),
|
||||
[reasoningEffort, thinking],
|
||||
);
|
||||
const effortLabel = modelSupportsReasoning
|
||||
? (EFFORT_LEVELS[effortIndex]?.label ?? "Low")
|
||||
: "None";
|
||||
const handleEffortCycle = useCallback(() => {
|
||||
if (!modelSupportsReasoning) {
|
||||
return;
|
||||
}
|
||||
const nextOption = EFFORT_LEVELS[(effortIndex + 1) % EFFORT_LEVELS.length];
|
||||
if (!nextOption) {
|
||||
return;
|
||||
}
|
||||
onReasoningChange(buildReasoningConfig(nextOption));
|
||||
}, [effortIndex, modelSupportsReasoning, onReasoningChange]);
|
||||
const hasExplicitReasoningSelection =
|
||||
thinking !== undefined || reasoningEffort !== undefined;
|
||||
const effortLabel =
|
||||
!hasExplicitReasoningSelection && modelSupportsReasoning === null
|
||||
? "Reasoning"
|
||||
: !hasExplicitReasoningSelection && modelSupportsReasoning === false
|
||||
? "None"
|
||||
: (EFFORT_LEVELS[effortIndex]?.label ?? "Reasoning");
|
||||
const handleEffortChange = useCallback(
|
||||
(value: string) => {
|
||||
if (modelSupportsReasoning !== true) {
|
||||
return;
|
||||
}
|
||||
const nextOption = EFFORT_LEVELS.find((option) => option.value === value);
|
||||
if (nextOption) {
|
||||
onReasoningChange(buildReasoningConfig(nextOption));
|
||||
}
|
||||
},
|
||||
[modelSupportsReasoning, onReasoningChange],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!modelSupportsReasoning) {
|
||||
if (thinking !== false || reasoningEffort !== undefined) {
|
||||
onReasoningChange({ thinking: false, reasoningEffort: undefined });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (thinking === undefined && reasoningEffort === undefined) {
|
||||
if (
|
||||
modelSupportsReasoning === true &&
|
||||
thinking === undefined &&
|
||||
reasoningEffort === undefined
|
||||
) {
|
||||
onReasoningChange(buildReasoningConfig(DEFAULT_REASONING_EFFORT));
|
||||
}
|
||||
}, [modelSupportsReasoning, onReasoningChange, reasoningEffort, thinking]);
|
||||
|
||||
useEffect(() => {
|
||||
const input = promptInputRef.current;
|
||||
if (!input) return;
|
||||
if (
|
||||
variant === "conversation" ||
|
||||
(variant === "welcome" &&
|
||||
promptInput.trim().length > 0 &&
|
||||
document.activeElement !== input)
|
||||
) {
|
||||
input.focus();
|
||||
}
|
||||
}, [promptInput, variant]);
|
||||
|
||||
const startQueuedPromptEdit = useCallback((item: PromptInQueue) => {
|
||||
setEditingQueuedPromptId(item.id);
|
||||
setEditingQueuedPromptValue(item.prompt);
|
||||
@@ -589,9 +633,16 @@ export function ChatInputBar({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border-t border-border bg-card">
|
||||
<div
|
||||
className={cn(
|
||||
"bg-card",
|
||||
variant === "welcome"
|
||||
? "overflow-visible rounded-xl border border-border/90 bg-card/90 shadow-[0_24px_80px_-56px_color-mix(in_oklab,var(--primary)_72%,transparent)] backdrop-blur-md"
|
||||
: "border-t border-border bg-card/95 backdrop-blur-sm",
|
||||
)}
|
||||
>
|
||||
{/* Input area */}
|
||||
<div className="px-4 py-3">
|
||||
<div className={cn("px-4 py-3", variant === "welcome" && "pb-2 pt-4")}>
|
||||
{promptsInQueue.length > 0 && (
|
||||
<div className="mb-3 rounded-lg border border-border bg-background/70 p-2">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
@@ -734,7 +785,11 @@ export function ChatInputBar({
|
||||
)}
|
||||
<div className="relative">
|
||||
{slashOpen && (
|
||||
<div className="absolute inset-x-0 bottom-full z-50 mb-1 max-h-56 overflow-y-auto rounded-lg border border-border bg-popover p-1 shadow-xl">
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-full z-50 mb-1 max-h-56 overflow-y-auto rounded-lg border border-border bg-popover p-1 shadow-xl"
|
||||
id="slash-command-suggestions"
|
||||
role="listbox"
|
||||
>
|
||||
{filteredSlashCommands.length === 0 ? (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground">
|
||||
{slashLoading
|
||||
@@ -745,6 +800,7 @@ export function ChatInputBar({
|
||||
<>
|
||||
{filteredSlashCommands.map((cmd, index) => (
|
||||
<button
|
||||
aria-selected={index === slashSelectedIndex}
|
||||
className={cn(
|
||||
"flex w-full flex-col rounded-md px-3 py-2 text-left text-xs transition-colors",
|
||||
index === slashSelectedIndex
|
||||
@@ -752,7 +808,9 @@ export function ChatInputBar({
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
)}
|
||||
key={cmd.name}
|
||||
id={`slash-command-option-${index}`}
|
||||
onClick={() => insertSlashCommandItem(cmd.name)}
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
<span className="font-medium">/{cmd.name}</span>
|
||||
@@ -773,7 +831,11 @@ export function ChatInputBar({
|
||||
</div>
|
||||
)}
|
||||
{mentionOpen && (
|
||||
<div className="absolute inset-x-0 bottom-full z-50 mb-1 max-h-56 overflow-y-auto rounded-lg border border-border bg-popover p-1 shadow-xl">
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-full z-50 mb-1 max-h-56 overflow-y-auto rounded-lg border border-border bg-popover p-1 shadow-xl"
|
||||
id="mention-file-suggestions"
|
||||
role="listbox"
|
||||
>
|
||||
{mentionFiles.length === 0 ? (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground">
|
||||
{mentionLoading ? "Searching files..." : "No matching files"}
|
||||
@@ -782,6 +844,7 @@ export function ChatInputBar({
|
||||
<>
|
||||
{mentionFiles.map((filePath, index) => (
|
||||
<button
|
||||
aria-selected={index === mentionSelectedIndex}
|
||||
className={cn(
|
||||
"block w-full rounded-md px-3 py-2 text-left text-xs transition-colors",
|
||||
index === mentionSelectedIndex
|
||||
@@ -789,7 +852,9 @@ export function ChatInputBar({
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
)}
|
||||
key={filePath}
|
||||
id={`mention-file-option-${index}`}
|
||||
onClick={() => insertMentionFile(filePath)}
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
{filePath}
|
||||
@@ -804,8 +869,31 @@ export function ChatInputBar({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-end gap-2 rounded-lg border border-border bg-background px-3 py-2.5 transition-all focus-within:border-primary/50 focus-within:ring-1 focus-within:ring-primary/20">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-end gap-2 rounded-lg border border-border bg-background px-3 py-2.5 transition-all focus-within:border-primary/50 focus-within:ring-1 focus-within:ring-primary/20",
|
||||
variant === "welcome" &&
|
||||
"min-h-16 items-start rounded-none border-0 bg-transparent px-0 py-0 focus-within:ring-0",
|
||||
)}
|
||||
>
|
||||
<textarea
|
||||
aria-activedescendant={
|
||||
slashOpen && filteredSlashCommands.length > 0
|
||||
? `slash-command-option-${slashSelectedIndex}`
|
||||
: mentionOpen && mentionFiles.length > 0
|
||||
? `mention-file-option-${mentionSelectedIndex}`
|
||||
: undefined
|
||||
}
|
||||
aria-autocomplete="list"
|
||||
aria-controls={
|
||||
slashOpen
|
||||
? "slash-command-suggestions"
|
||||
: mentionOpen
|
||||
? "mention-file-suggestions"
|
||||
: undefined
|
||||
}
|
||||
aria-expanded={slashOpen || mentionOpen}
|
||||
aria-haspopup="listbox"
|
||||
className="max-h-60 min-h-5 flex-1 resize-none overflow-y-auto bg-transparent text-sm leading-5 text-foreground placeholder:text-muted-foreground outline-none"
|
||||
onChange={(e) => {
|
||||
onPromptInputChange(e.target.value);
|
||||
@@ -893,15 +981,20 @@ export function ChatInputBar({
|
||||
)
|
||||
}
|
||||
placeholder={
|
||||
isBusy
|
||||
? "Agent is working... submit to queue another message"
|
||||
: "Enter your question or type / for commands or @ for context"
|
||||
variant === "welcome"
|
||||
? "Ask to make changes, @mention files, reference #PRs, or run /commands."
|
||||
: isBusy
|
||||
? "Agent is working... submit to queue another message"
|
||||
: "Enter your question or type / for commands or @ for context"
|
||||
}
|
||||
ref={promptInputRef}
|
||||
role="combobox"
|
||||
rows={
|
||||
promptInputFocused
|
||||
? PROMPT_INPUT_FOCUSED_ROWS
|
||||
: PROMPT_INPUT_COLLAPSED_ROWS
|
||||
variant === "welcome"
|
||||
? 2
|
||||
: promptInputFocused
|
||||
? PROMPT_INPUT_FOCUSED_ROWS
|
||||
: PROMPT_INPUT_COLLAPSED_ROWS
|
||||
}
|
||||
value={promptInput}
|
||||
/>
|
||||
@@ -916,6 +1009,7 @@ export function ChatInputBar({
|
||||
>
|
||||
{attachment.isImage ? "image:" : "file:"} {attachment.name}
|
||||
<button
|
||||
aria-label={`Remove ${attachment.name}`}
|
||||
className="rounded-sm p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
onClick={() => onRemoveAttachment(attachment.id)}
|
||||
type="button"
|
||||
@@ -928,11 +1022,12 @@ export function ChatInputBar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls row */}
|
||||
<div className="flex items-center justify-between px-4 pb-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Composer settings and submit */}
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-x-3 gap-y-2 border-t border-border px-3 py-2 text-[11px] text-muted-foreground max-[560px]:grid max-[560px]:grid-cols-[auto_auto_minmax(0,1fr)_auto] max-[560px]:items-center">
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2 max-[560px]:contents">
|
||||
<button
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
aria-label="Attach files"
|
||||
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground max-[560px]:col-start-1 max-[560px]:row-start-1"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
type="button"
|
||||
>
|
||||
@@ -944,97 +1039,138 @@ export function ChatInputBar({
|
||||
multiple
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
if (files.length > 0) {
|
||||
onAttachFiles(files);
|
||||
}
|
||||
if (files.length > 0) onAttachFiles(files);
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
/>
|
||||
|
||||
<ModelSelector
|
||||
isBusy={isBusy}
|
||||
model={model}
|
||||
onModelChange={onModelChange}
|
||||
onModelSupportsReasoningChange={setModelSupportsReasoning}
|
||||
onProviderChange={onProviderChange}
|
||||
provider={provider}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className="hidden rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
type="button"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
</button>
|
||||
{isBusy && (
|
||||
<div className="flex shrink-0 items-center rounded-md bg-muted p-0.5 max-[560px]:col-start-2 max-[560px]:row-start-1">
|
||||
<button
|
||||
className="rounded-full bg-foreground p-1.5 text-background hover:bg-foreground/80 transition-colors"
|
||||
onClick={onAbort}
|
||||
aria-pressed={mode === "plan"}
|
||||
className={cn(
|
||||
"rounded px-2 py-1 transition-colors",
|
||||
mode === "plan"
|
||||
? "bg-background text-foreground shadow-xs"
|
||||
: "hover:text-foreground",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (mode !== "plan") onModeToggle();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<CircleStop className="h-4 w-4" />
|
||||
Plan
|
||||
</button>
|
||||
)}
|
||||
{(!isBusy || canSend) && (
|
||||
<button
|
||||
className="rounded-full bg-foreground p-1.5 text-background hover:bg-foreground/80 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={!canSend}
|
||||
onClick={onSend}
|
||||
aria-pressed={mode === "act"}
|
||||
className={cn(
|
||||
"rounded px-2 py-1 transition-colors",
|
||||
mode === "act"
|
||||
? "bg-background text-foreground shadow-xs"
|
||||
: "hover:text-foreground",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (mode !== "act") onModeToggle();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
Act
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 shrink-0 max-[560px]:col-start-3 max-[560px]:col-end-5 max-[560px]:row-start-1">
|
||||
<ModelSelector
|
||||
isBusy={isBusy}
|
||||
model={model}
|
||||
onModelChange={onModelChange}
|
||||
onModelSupportsReasoningChange={
|
||||
handleModelSupportsReasoningChange
|
||||
}
|
||||
onProviderChange={onProviderChange}
|
||||
provider={provider}
|
||||
variant={variant}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
disabled={modelSupportsReasoning !== true}
|
||||
onValueChange={handleEffortChange}
|
||||
value={EFFORT_LEVELS[effortIndex]?.value ?? "low"}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label="Thinking level"
|
||||
className="h-7 min-w-[5.75rem] gap-1.5 border-0 bg-muted px-2 text-[11px] shadow-none data-[size=sm]:h-7 max-[560px]:col-span-2 max-[560px]:col-start-1 max-[560px]:row-start-2"
|
||||
size="sm"
|
||||
title={
|
||||
modelSupportsReasoning === false
|
||||
? "The selected model does not report reasoning support"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Brain className="size-3" />
|
||||
<SelectValue>{effortLabel}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{EFFORT_LEVELS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{tokensSummary ? (
|
||||
<span className="max-[900px]:hidden">
|
||||
<StatusItem
|
||||
icon={Coins}
|
||||
label={tokensSummary}
|
||||
hasOption={false}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status bar */}
|
||||
<div className="flex items-center justify-between border-t border-border px-4 py-1.5 text-[11px] text-muted-foreground">
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusItem
|
||||
label={mode === "act" ? "Act" : "Plan"}
|
||||
onClick={onModeToggle}
|
||||
/>
|
||||
<StatusItem
|
||||
disabled={!modelSupportsReasoning}
|
||||
icon={Brain}
|
||||
label={effortLabel}
|
||||
onClick={handleEffortCycle}
|
||||
/>
|
||||
{tokensSummary && (
|
||||
<StatusItem icon={Coins} label={tokensSummary} hasOption={false} />
|
||||
)}
|
||||
</div>
|
||||
{/* GIT BRANCH */}
|
||||
<div className="flex items-center gap-3">
|
||||
<WorkspaceSelector
|
||||
currentBranch={gitBranch}
|
||||
onListGitBranches={onListGitBranches}
|
||||
onRefreshWorkspaces={onRefreshWorkspaces}
|
||||
onPickWorkspaceDirectory={onPickWorkspaceDirectory}
|
||||
onSwitchGitBranch={onSwitchGitBranch}
|
||||
onSwitchWorkspace={onSwitchWorkspace}
|
||||
workspaces={workspaces}
|
||||
workspaceRoot={workspaceRoot}
|
||||
/>
|
||||
<button
|
||||
className="hidden items-center gap-1 hover:text-foreground transition-colors"
|
||||
onClick={onRefreshGitBranch}
|
||||
type="button"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
className="hidden items-center gap-1 hover:text-foreground transition-colors"
|
||||
onClick={onReset}
|
||||
type="button"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="ml-auto flex min-w-0 shrink-0 items-center gap-2 max-[560px]:contents">
|
||||
<div className="max-w-48 overflow-visible max-[720px]:max-w-36 max-[560px]:col-start-3 max-[560px]:row-start-2">
|
||||
<WorkspaceSelector
|
||||
currentBranch={gitBranch}
|
||||
onListGitBranches={onListGitBranches}
|
||||
onRefreshWorkspaces={onRefreshWorkspaces}
|
||||
onPickWorkspaceDirectory={onPickWorkspaceDirectory}
|
||||
onSwitchGitBranch={onSwitchGitBranch}
|
||||
onSwitchWorkspace={onSwitchWorkspace}
|
||||
workspaces={workspaces}
|
||||
workspaceRoot={workspaceRoot}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 max-[560px]:col-start-4 max-[560px]:row-start-2">
|
||||
{canAbort && (
|
||||
<button
|
||||
aria-label="Stop agent"
|
||||
className={cn(
|
||||
"bg-foreground p-1.5 text-background transition-colors hover:bg-foreground/80",
|
||||
variant === "welcome" ? "rounded-md" : "rounded-full",
|
||||
)}
|
||||
onClick={onAbort}
|
||||
type="button"
|
||||
>
|
||||
<CircleStop className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{(!isBusy || canSend) && (
|
||||
<button
|
||||
aria-label="Send message"
|
||||
className={cn(
|
||||
"p-1.5 transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||
variant === "welcome"
|
||||
? "rounded-md bg-[linear-gradient(145deg,var(--primary-emphasis),var(--primary))] text-white shadow-sm hover:brightness-110"
|
||||
: "rounded-full bg-foreground text-background hover:bg-foreground/80",
|
||||
)}
|
||||
disabled={!canSend}
|
||||
onClick={onSend}
|
||||
type="button"
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1045,6 +1181,7 @@ function ModelSelector({
|
||||
provider,
|
||||
model,
|
||||
isBusy,
|
||||
variant,
|
||||
onProviderChange,
|
||||
onModelChange,
|
||||
onModelSupportsReasoningChange,
|
||||
@@ -1052,9 +1189,10 @@ function ModelSelector({
|
||||
provider: string;
|
||||
model: string;
|
||||
isBusy: boolean;
|
||||
variant: "conversation" | "welcome";
|
||||
onProviderChange: (provider: string) => void;
|
||||
onModelChange: (model: string) => void;
|
||||
onModelSupportsReasoningChange: (supportsReasoning: boolean) => void;
|
||||
onModelSupportsReasoningChange: (supportsReasoning: boolean | null) => void;
|
||||
}) {
|
||||
const normalizedProvider = normalizeProviderId(provider);
|
||||
const [providerModels, setProviderModels] = useState<
|
||||
@@ -1063,6 +1201,9 @@ function ModelSelector({
|
||||
const [providerReasoningModels, setProviderReasoningModels] = useState<
|
||||
Record<string, string[]>
|
||||
>(FALLBACK_PROVIDER_REASONING_MODELS);
|
||||
const [reasoningCapabilitySource, setReasoningCapabilitySource] = useState<
|
||||
"loading" | "catalog" | "fallback"
|
||||
>("loading");
|
||||
const [enabledProviderIds, setEnabledProviderIds] = useState<string[]>([]);
|
||||
const [lastSelection, setLastSelection] = useState(() =>
|
||||
readModelSelectionStorageFromWindow(),
|
||||
@@ -1120,6 +1261,7 @@ function ModelSelector({
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setReasoningCapabilitySource("loading");
|
||||
|
||||
async function loadCatalog() {
|
||||
try {
|
||||
@@ -1129,6 +1271,7 @@ function ModelSelector({
|
||||
}
|
||||
setProviderModels(payload.providerModels);
|
||||
setProviderReasoningModels(payload.providerReasoningModels);
|
||||
setReasoningCapabilitySource("catalog");
|
||||
setEnabledProviderIds((current) => {
|
||||
const nextProviderIds = new Set(payload.enabledProviderIds);
|
||||
if (normalizedProvider) {
|
||||
@@ -1142,7 +1285,7 @@ function ModelSelector({
|
||||
return Array.from(nextProviderIds);
|
||||
});
|
||||
} catch {
|
||||
// Keep local fallback values when provider catalog is unavailable.
|
||||
if (!cancelled) setReasoningCapabilitySource("fallback");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1180,6 +1323,7 @@ function ModelSelector({
|
||||
...current,
|
||||
[normalizedProvider]: reasoningModelIds,
|
||||
}));
|
||||
setReasoningCapabilitySource("catalog");
|
||||
setEnabledProviderIds((current) =>
|
||||
current.includes(normalizedProvider)
|
||||
? current
|
||||
@@ -1246,6 +1390,16 @@ function ModelSelector({
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (reasoningCapabilitySource === "loading") {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
reasoningCapabilitySource === "fallback" &&
|
||||
!(FALLBACK_PROVIDER_MODELS[normalizedProvider] ?? []).includes(model)
|
||||
) {
|
||||
onModelSupportsReasoningChange(null);
|
||||
return;
|
||||
}
|
||||
onModelSupportsReasoningChange(
|
||||
hasReasoningCapability(
|
||||
providerReasoningModels,
|
||||
@@ -1258,10 +1412,11 @@ function ModelSelector({
|
||||
onModelSupportsReasoningChange,
|
||||
normalizedProvider,
|
||||
providerReasoningModels,
|
||||
reasoningCapabilitySource,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-xxs">
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-1 text-[11px]">
|
||||
<Combobox
|
||||
items={providers}
|
||||
onValueChange={(value) => {
|
||||
@@ -1287,7 +1442,11 @@ function ModelSelector({
|
||||
value={resolvedProvider}
|
||||
>
|
||||
<ComboboxInput
|
||||
className="h-7 text-xxs"
|
||||
aria-label="Provider"
|
||||
className={cn(
|
||||
"h-7 text-[11px] max-[560px]:w-20",
|
||||
variant === "welcome" && "w-24 border-0 bg-transparent shadow-none",
|
||||
)}
|
||||
disabled={isBusy || providers.length === 0}
|
||||
readOnly
|
||||
showClear={false}
|
||||
@@ -1297,7 +1456,7 @@ function ModelSelector({
|
||||
<ComboboxEmpty>No providers found.</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(item) => (
|
||||
<ComboboxItem className="text-xxs" key={item} value={item}>
|
||||
<ComboboxItem className="text-[11px]" key={item} value={item}>
|
||||
{item}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
@@ -1316,7 +1475,11 @@ function ModelSelector({
|
||||
value={resolvedModel}
|
||||
>
|
||||
<ComboboxInput
|
||||
className="h-7"
|
||||
aria-label="Model"
|
||||
className={cn(
|
||||
"h-7 text-[11px] max-[560px]:w-32",
|
||||
variant === "welcome" && "w-52 border-0 bg-transparent shadow-none",
|
||||
)}
|
||||
disabled={isBusy || modelsForProvider.length === 0}
|
||||
readOnly
|
||||
showClear={false}
|
||||
@@ -1326,7 +1489,7 @@ function ModelSelector({
|
||||
<ComboboxEmpty>No models found.</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(item) => (
|
||||
<ComboboxItem className="text-xxs" key={item} value={item}>
|
||||
<ComboboxItem className="text-[11px]" key={item} value={item}>
|
||||
{item}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
@@ -1350,6 +1513,16 @@ function StatusItem({
|
||||
disabled?: boolean;
|
||||
hasOption?: boolean;
|
||||
}) {
|
||||
const content = (
|
||||
<>
|
||||
{Icon ? <Icon className="h-3 w-3" /> : null}
|
||||
<span className="max-[560px]:sr-only">{label}</span>
|
||||
{hasOption ? <ChevronDown className="h-2.5 w-2.5" /> : null}
|
||||
</>
|
||||
);
|
||||
if (!onClick) {
|
||||
return <span className="flex items-center gap-1">{content}</span>;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
@@ -1360,9 +1533,7 @@ function StatusItem({
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
{Icon ? <Icon className="h-3 w-3" /> : null}
|
||||
<span>{label}</span>
|
||||
{hasOption ? <ChevronDown className="h-2.5 w-2.5" /> : null}
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// @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 { ChatMessage } from "@/lib/chat-schema";
|
||||
import { ChatMessages } from "./chat-messages";
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
HTMLElement.prototype.scrollTo = vi.fn();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function renderMessages(messages: ChatMessage[]) {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ChatMessages
|
||||
chatTransportState="connected"
|
||||
error={null}
|
||||
messages={messages}
|
||||
onAnswerAskQuestion={vi.fn()}
|
||||
onApproveToolApproval={vi.fn()}
|
||||
onRejectToolApproval={vi.fn()}
|
||||
pendingAskQuestions={[]}
|
||||
pendingToolApprovals={[]}
|
||||
sessionId="session-1"
|
||||
status="completed"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe("ChatMessages tool disclosures", () => {
|
||||
it("renders a detail-less tool summary as static text", async () => {
|
||||
await renderMessages([
|
||||
{
|
||||
id: "tool-static",
|
||||
sessionId: "session-1",
|
||||
role: "tool",
|
||||
content: "not-json",
|
||||
createdAt: 1,
|
||||
meta: { toolName: "search" },
|
||||
},
|
||||
]);
|
||||
|
||||
const summary = [...container.querySelectorAll("span")].find((element) =>
|
||||
element.textContent?.includes("Explored"),
|
||||
);
|
||||
expect(summary).toBeDefined();
|
||||
expect(summary?.closest("button")).toBeNull();
|
||||
});
|
||||
|
||||
it("exposes and toggles expandable tool details", async () => {
|
||||
await renderMessages([
|
||||
{
|
||||
id: "tool-expandable",
|
||||
sessionId: "session-1",
|
||||
role: "tool",
|
||||
content: JSON.stringify({
|
||||
toolName: "search",
|
||||
input: { queries: ["workspace selector"] },
|
||||
result: {},
|
||||
}),
|
||||
createdAt: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
const trigger = [...container.querySelectorAll("button")].find((element) =>
|
||||
element.textContent?.includes("Explored 1 search"),
|
||||
);
|
||||
expect(trigger?.getAttribute("aria-expanded")).toBe("false");
|
||||
const panelId = trigger?.getAttribute("aria-controls");
|
||||
expect(panelId).toBeTruthy();
|
||||
|
||||
await act(async () => trigger?.click());
|
||||
expect(trigger?.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(document.getElementById(panelId ?? "")?.textContent).toContain(
|
||||
"workspace selector",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Message as AgentMessage,
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
ConversationViewport,
|
||||
MessageAction,
|
||||
MessageActions,
|
||||
MessageContent,
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
ToolActivity,
|
||||
ToolActivityCode,
|
||||
ToolActivityContent,
|
||||
ToolActivityDetails,
|
||||
ToolActivityTrigger,
|
||||
} from "@cline/ui/components/agent-chat";
|
||||
import {
|
||||
AlertCircle,
|
||||
Bot,
|
||||
BrainIcon,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
Copy,
|
||||
FileEdit,
|
||||
@@ -20,22 +35,14 @@ import {
|
||||
SquareTerminalIcon,
|
||||
UndoIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { memo, useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import type { ChatMessage, ChatSessionStatus } from "@/lib/chat-schema";
|
||||
import { parseApplyPatchInput } from "@/lib/session-diff";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MemoizedMarkdown } from "../../ui/markdown";
|
||||
import { normalizeTitle } from "../../utils";
|
||||
import { WelcomeScreen } from "./welcome-chat";
|
||||
import { formatChatMessageContent } from "./message-content";
|
||||
|
||||
type ChatMessagesProps = {
|
||||
sessionId: string | null;
|
||||
@@ -46,8 +53,6 @@ type ChatMessagesProps = {
|
||||
| "connected"
|
||||
| "unavailable";
|
||||
isSessionSwitching?: boolean;
|
||||
provider: string;
|
||||
model: string;
|
||||
messages: ChatMessage[];
|
||||
error: string | null;
|
||||
streamingMessageId?: string | null;
|
||||
@@ -61,7 +66,6 @@ type ChatMessagesProps = {
|
||||
) => void | Promise<void>;
|
||||
onRestoreCheckpoint?: (runCount: number) => void | Promise<void>;
|
||||
onForkSession?: () => void | Promise<void>;
|
||||
onStartChat?: (prompt: string) => void;
|
||||
};
|
||||
|
||||
type ToolApprovalRequestItem = {
|
||||
@@ -89,16 +93,12 @@ type AskQuestionRequestItem = {
|
||||
};
|
||||
|
||||
const IS_DEBUG = process.env.NODE_ENV === "test";
|
||||
const STICKY_BOTTOM_THRESHOLD_PX = 24;
|
||||
const SCROLL_TO_BOTTOM_BUTTON_THRESHOLD_PX = 120;
|
||||
|
||||
function ChatMessagesImpl({
|
||||
sessionId: _sessionId,
|
||||
sessionId,
|
||||
status,
|
||||
chatTransportState = "connecting",
|
||||
isSessionSwitching = false,
|
||||
provider,
|
||||
model,
|
||||
messages,
|
||||
error,
|
||||
streamingMessageId = null,
|
||||
@@ -109,10 +109,7 @@ function ChatMessagesImpl({
|
||||
onAnswerAskQuestion,
|
||||
onRestoreCheckpoint,
|
||||
onForkSession,
|
||||
onStartChat,
|
||||
}: ChatMessagesProps) {
|
||||
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
|
||||
const shouldStickToBottomRef = useRef(true);
|
||||
const hasMessages = messages.length > 0;
|
||||
const lastErrorMessage = [...messages]
|
||||
.reverse()
|
||||
@@ -120,7 +117,6 @@ function ChatMessagesImpl({
|
||||
const shouldShowErrorBanner =
|
||||
Boolean(error) && (!lastErrorMessage || lastErrorMessage.content !== error);
|
||||
const [showSwitchTransition, setShowSwitchTransition] = useState(false);
|
||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
||||
const [toolApprovalActions, setToolApprovalActions] = useState<
|
||||
Record<string, "approving" | "rejecting">
|
||||
>({});
|
||||
@@ -145,23 +141,6 @@ function ChatMessagesImpl({
|
||||
const showIdleDetails =
|
||||
!hasMessages && !isSessionSwitching && !showSwitchTransition;
|
||||
|
||||
const getViewport = useCallback(() => {
|
||||
return scrollAreaRef.current;
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback(
|
||||
(behavior: ScrollBehavior = "smooth") => {
|
||||
const viewport = getViewport();
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
shouldStickToBottomRef.current = true;
|
||||
viewport.scrollTo({ top: viewport.scrollHeight, behavior });
|
||||
setShowScrollToBottom((prev) => (prev ? false : prev));
|
||||
},
|
||||
[getViewport],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSessionSwitching) {
|
||||
setShowSwitchTransition((prev) => (prev ? false : prev));
|
||||
@@ -175,39 +154,6 @@ function ChatMessagesImpl({
|
||||
};
|
||||
}, [isSessionSwitching]);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = getViewport();
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateScrollToBottomVisibility = () => {
|
||||
const distanceFromBottom =
|
||||
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
|
||||
shouldStickToBottomRef.current =
|
||||
distanceFromBottom <= STICKY_BOTTOM_THRESHOLD_PX;
|
||||
const shouldShow =
|
||||
distanceFromBottom > SCROLL_TO_BOTTOM_BUTTON_THRESHOLD_PX;
|
||||
setShowScrollToBottom((prev) =>
|
||||
prev === shouldShow ? prev : shouldShow,
|
||||
);
|
||||
};
|
||||
|
||||
updateScrollToBottomVisibility();
|
||||
viewport.addEventListener("scroll", updateScrollToBottomVisibility);
|
||||
|
||||
return () => {
|
||||
viewport.removeEventListener("scroll", updateScrollToBottomVisibility);
|
||||
};
|
||||
}, [getViewport]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!shouldStickToBottomRef.current) {
|
||||
return;
|
||||
}
|
||||
scrollToBottom("auto");
|
||||
}, [scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
const activeRequestIds = new Set(
|
||||
pendingToolApprovals.map((item) => item.requestId),
|
||||
@@ -371,21 +317,22 @@ function ChatMessagesImpl({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative h-full min-h-0 min-w-0">
|
||||
<div
|
||||
className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto"
|
||||
ref={scrollAreaRef}
|
||||
<Conversation
|
||||
className="h-full min-h-0 min-w-0"
|
||||
key={sessionId ?? "new-chat"}
|
||||
>
|
||||
<ConversationViewport
|
||||
aria-label="Agent conversation"
|
||||
className="h-full min-h-0 min-w-0"
|
||||
>
|
||||
<div className="relative mx-auto w-full h-full min-w-0 max-w-full overflow-x-hidden px-6 py-6">
|
||||
{showIdleDetails ? (
|
||||
<WelcomeScreen
|
||||
provider={provider}
|
||||
model={model}
|
||||
onStartChat={onStartChat ?? (() => {})}
|
||||
quickActions={[]}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full min-w-0 flex-col gap-2 overflow-x-hidden">
|
||||
<ConversationContent
|
||||
className={cn(
|
||||
"relative mx-auto min-h-full w-full min-w-0 max-w-full overflow-x-hidden",
|
||||
showIdleDetails ? "p-0" : "px-6 py-6",
|
||||
)}
|
||||
>
|
||||
{showIdleDetails ? null : (
|
||||
<div className="flex min-h-full w-full min-w-0 flex-col gap-2 overflow-x-hidden">
|
||||
{pendingToolApprovals.length > 0 ? (
|
||||
<ToolApprovalPanel
|
||||
items={pendingToolApprovals}
|
||||
@@ -492,21 +439,10 @@ function ChatMessagesImpl({
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{showScrollToBottom ? (
|
||||
<Button
|
||||
className="absolute bottom-4 right-4 z-20 size-9 rounded-full shadow-sm"
|
||||
onClick={() => scrollToBottom("smooth")}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
<span className="sr-only">Scroll to bottom</span>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</ConversationContent>
|
||||
</ConversationViewport>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -741,218 +677,138 @@ function MessageBubble({
|
||||
isUser && Boolean(onCopyRawText || checkpoint);
|
||||
const keepUserActionsVisible = restorePending || Boolean(restoreError);
|
||||
const keepAssistantActionsVisible = forkPending || Boolean(forkError);
|
||||
const hiddenActionButtonsClassName =
|
||||
"pointer-events-none opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100";
|
||||
|
||||
if (message.role === "tool") {
|
||||
return <ToolMessageBlock message={message} />;
|
||||
}
|
||||
|
||||
const normalizedContent = normalizeTitle(message.content);
|
||||
const displayContent = formatChatMessageContent(
|
||||
message.role,
|
||||
message.content,
|
||||
);
|
||||
const reasoningContent = message.reasoning?.trim() || "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0",
|
||||
isUser ? "justify-end" : "w-full justify-start",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"group max-w-full min-w-0 wrap-break-word text-sm",
|
||||
isUser && "flex max-w-[50%] flex-col items-end gap-1",
|
||||
!isUser && "flex flex-col items-start gap-2 overflow-hidden",
|
||||
!isUser && !isError && "text-foreground",
|
||||
isError &&
|
||||
"bg-destructive/10 border border-destructive/40 text-destructive",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-full min-w-0 space-y-2 overflow-hidden wrap-break-word",
|
||||
isUser && "rounded-sm bg-card p-2 text-foreground/80",
|
||||
)}
|
||||
>
|
||||
{isStreaming && message.role === "assistant" ? (
|
||||
<>
|
||||
{reasoningContent || message.reasoningRedacted ? (
|
||||
<ReasoningBlock
|
||||
content={reasoningContent}
|
||||
redacted={message.reasoningRedacted === true}
|
||||
/>
|
||||
) : null}
|
||||
<div className="whitespace-pre-wrap wrap-break-word leading-relaxed">
|
||||
{normalizedContent || " "}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{reasoningContent || message.reasoningRedacted ? (
|
||||
<ReasoningBlock
|
||||
content={reasoningContent}
|
||||
redacted={message.reasoningRedacted === true}
|
||||
/>
|
||||
) : null}
|
||||
<AgentMessage from={message.role}>
|
||||
<MessageContent className="space-y-2 wrap-break-word">
|
||||
{reasoningContent || message.reasoningRedacted ? (
|
||||
<ReasoningBlock
|
||||
content={reasoningContent}
|
||||
redacted={message.reasoningRedacted === true}
|
||||
streaming={isStreaming}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="my-1 ml-3 min-w-0 max-w-full overflow-x-hidden wrap-break-word **:max-w-full [&_code]:whitespace-pre-wrap [&_code]:wrap-break-word [&_pre]:overflow-x-hidden [&_pre]:whitespace-pre-wrap [&_pre]:wrap-break-word">
|
||||
<MemoizedMarkdown
|
||||
content={normalizedContent || " "}
|
||||
id={message.id}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="my-1 min-w-0 max-w-full wrap-break-word">
|
||||
<MemoizedMarkdown
|
||||
content={displayContent || " "}
|
||||
streaming={isStreaming && message.role === "assistant"}
|
||||
/>
|
||||
</div>
|
||||
{shouldRenderUserActions ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex h-6 items-center justify-end">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-end gap-2",
|
||||
keepUserActionsVisible
|
||||
? "pointer-events-auto opacity-100"
|
||||
: hiddenActionButtonsClassName,
|
||||
)}
|
||||
</MessageContent>
|
||||
|
||||
{shouldRenderUserActions ? (
|
||||
<>
|
||||
<MessageActions visible={keepUserActionsVisible}>
|
||||
{onCopyRawText ? (
|
||||
<MessageAction
|
||||
label={wasCopied ? "Copied user message" : "Copy user message"}
|
||||
onClick={onCopyRawText}
|
||||
title={wasCopied ? "Copied" : "Copy message"}
|
||||
>
|
||||
{onCopyRawText ? (
|
||||
<Button
|
||||
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
aria-label={
|
||||
wasCopied ? "Copied user message" : "Copy user message"
|
||||
}
|
||||
onClick={onCopyRawText}
|
||||
size="sm"
|
||||
title={wasCopied ? "Copied" : "Copy message"}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{wasCopied ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
{checkpoint ? (
|
||||
<Button
|
||||
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
aria-label="Restore checkpoint"
|
||||
disabled={restoreDisabled || restorePending}
|
||||
onClick={() => onRestoreCheckpoint?.(checkpoint.runCount)}
|
||||
size="sm"
|
||||
title="Restore checkpoint"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{restorePending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<UndoIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{restoreError ? (
|
||||
<div className="text-right text-xs text-destructive">
|
||||
{restoreError}
|
||||
</div>
|
||||
{wasCopied ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</MessageAction>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{shouldRenderAssistantActions ? (
|
||||
<div className="flex h-6 items-center hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-0",
|
||||
keepAssistantActionsVisible
|
||||
? "pointer-events-auto opacity-100"
|
||||
: hiddenActionButtonsClassName,
|
||||
)}
|
||||
>
|
||||
{onCopyRawText ? (
|
||||
<Button
|
||||
className="h-6 gap-1.5 px-2 text-[11px] text-muted-foreground hover:text-foreground"
|
||||
aria-label={
|
||||
wasCopied
|
||||
? "Copied assistant message"
|
||||
: "Copy assistant message"
|
||||
}
|
||||
onClick={onCopyRawText}
|
||||
size="sm"
|
||||
title={wasCopied ? "Copied" : "Copy raw assistant output"}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{wasCopied ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
{onForkSession ? (
|
||||
<Button
|
||||
className="h-6 gap-1.5 px-2 text-[11px] text-muted-foreground hover:text-foreground"
|
||||
aria-label="Fork session"
|
||||
disabled={forkPending}
|
||||
onClick={onForkSession}
|
||||
size="sm"
|
||||
title="Fork session - copy full message history into a new session"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{forkPending ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<SplitIcon className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
{forkError ? (
|
||||
<span className="text-[11px] text-destructive">
|
||||
{forkError}
|
||||
</span>
|
||||
) : null}
|
||||
{checkpoint ? (
|
||||
<MessageAction
|
||||
disabled={restoreDisabled || restorePending}
|
||||
label="Restore checkpoint"
|
||||
onClick={() => onRestoreCheckpoint?.(checkpoint.runCount)}
|
||||
title="Restore checkpoint"
|
||||
>
|
||||
{restorePending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<UndoIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</MessageAction>
|
||||
) : null}
|
||||
</MessageActions>
|
||||
{restoreError ? (
|
||||
<div className="text-right text-xs text-destructive">
|
||||
{restoreError}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{shouldRenderAssistantActions ? (
|
||||
<MessageActions visible={keepAssistantActionsVisible}>
|
||||
{onCopyRawText ? (
|
||||
<MessageAction
|
||||
label={
|
||||
wasCopied
|
||||
? "Copied assistant message"
|
||||
: "Copy assistant message"
|
||||
}
|
||||
onClick={onCopyRawText}
|
||||
title={wasCopied ? "Copied" : "Copy raw assistant output"}
|
||||
>
|
||||
{wasCopied ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</MessageAction>
|
||||
) : null}
|
||||
{onForkSession ? (
|
||||
<MessageAction
|
||||
disabled={forkPending}
|
||||
label="Fork session"
|
||||
onClick={onForkSession}
|
||||
title="Fork session - copy full message history into a new session"
|
||||
>
|
||||
{forkPending ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<SplitIcon className="h-3 w-3" />
|
||||
)}
|
||||
</MessageAction>
|
||||
) : null}
|
||||
{forkError ? (
|
||||
<span className="text-[11px] text-destructive">{forkError}</span>
|
||||
) : null}
|
||||
</MessageActions>
|
||||
) : null}
|
||||
</AgentMessage>
|
||||
);
|
||||
}
|
||||
|
||||
function ReasoningBlock({
|
||||
content,
|
||||
redacted,
|
||||
streaming = false,
|
||||
}: {
|
||||
content: string;
|
||||
redacted: boolean;
|
||||
streaming?: boolean;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const displayContent = content || (redacted ? "[redacted]" : "");
|
||||
if (!displayContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-2">
|
||||
<Button
|
||||
className="h-auto min-h-0 max-w-full justify-start gap-2 whitespace-normal px-0 py-1 text-left text-sm font-medium text-foreground/70 hover:bg-transparent hover:text-foreground dark:hover:bg-transparent dark:hover:text-foreground"
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<BrainIcon className="size-4" />
|
||||
Thinking
|
||||
</Button>
|
||||
{expanded ? (
|
||||
<div className="mt-1.5 whitespace-pre-wrap rounded-lg border border-border/70 bg-muted/30 p-3 text-sm leading-relaxed text-muted-foreground">
|
||||
{displayContent}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Reasoning isStreaming={streaming}>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>
|
||||
<MemoizedMarkdown content={displayContent} streaming={streaming} />
|
||||
</ReasoningContent>
|
||||
</Reasoning>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1333,7 +1189,6 @@ function buildToolSummaryFromMeta(
|
||||
}
|
||||
|
||||
function ToolMessageBlock({ message }: { message: ChatMessage }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const payload = parseToolPayload(message.content);
|
||||
const toolName = message.meta?.toolName || payload?.toolName || "tool";
|
||||
const hookEventName = message.meta?.hookEventName;
|
||||
@@ -1367,80 +1222,48 @@ function ToolMessageBlock({ message }: { message: ChatMessage }) {
|
||||
details.length > 0 || Boolean(inputPreview || resultPreview);
|
||||
|
||||
return (
|
||||
<div className="my-2 flex w-full min-w-0 justify-start">
|
||||
<div
|
||||
className={cn("min-w-0 max-w-full overflow-hidden rounded-xl text-sm")}
|
||||
>
|
||||
<Button
|
||||
className="h-auto min-h-0 max-w-full justify-start gap-2 whitespace-normal px-0 py-1 text-left text-sm font-medium text-primary hover:bg-transparent hover:text-primary/80 dark:hover:bg-transparent dark:hover:text-primary/80"
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{payload?.isError ? (
|
||||
<ToolActivity expandable={hasExpandedSections}>
|
||||
<ToolActivityTrigger
|
||||
additions={summary.diff?.additions}
|
||||
deletions={summary.diff?.deletions}
|
||||
icon={
|
||||
payload?.isError ? (
|
||||
<AlertCircle className="size-4 text-destructive/80" />
|
||||
) : (
|
||||
<Icon className="size-4" />
|
||||
)}
|
||||
<span className="min-w-0 wrap-break-word">{summary.label}</span>
|
||||
{summary.diff ? (
|
||||
<span className="shrink-0 font-mono text-xs">
|
||||
<span className="text-chart-2">+{summary.diff.additions}</span>{" "}
|
||||
<span className="text-destructive">
|
||||
-{summary.diff.deletions}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
{hasExpandedSections ? (
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{expanded ? (
|
||||
<ChevronDown className="size-4" />
|
||||
) : (
|
||||
<ChevronRight className="size-4" />
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
{expanded ? (
|
||||
<div className="mt-1.5 min-w-0 max-w-full overflow-x-hidden pl-8 text-sm text-muted-foreground">
|
||||
{hasExpandedSections ? (
|
||||
<div className="space-y-1">
|
||||
{details.map((detail) => (
|
||||
<div
|
||||
className="wrap-break-word"
|
||||
key={`${message.id}_${detail}`}
|
||||
>
|
||||
{detail}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{inputPreview ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xxs uppercase tracking-wide text-muted-foreground/80">
|
||||
Input
|
||||
</div>
|
||||
<pre className="max-h-52 max-w-full overflow-x-hidden overflow-y-auto whitespace-pre-wrap wrap-break-word rounded-md border border-border/70 bg-background/60 p-2 text-sm leading-relaxed text-foreground">
|
||||
{inputPreview}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
{resultPreview ? (
|
||||
payload?.isError ? (
|
||||
<div className="mt-1">
|
||||
<span className="text-destructive">{resultPreview}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<pre className="max-h-64 max-w-full overflow-x-hidden overflow-y-auto whitespace-pre-wrap wrap-break-word rounded-md border border-border/70 bg-background/60 p-2 text-sm leading-relaxed text-foreground">
|
||||
{resultPreview}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
)
|
||||
}
|
||||
label={summary.label}
|
||||
status={payload?.isError ? "error" : inProgress ? "running" : "success"}
|
||||
/>
|
||||
<ToolActivityContent>
|
||||
{details.length > 0 ? (
|
||||
<ToolActivityDetails>
|
||||
{details.map((detail) => (
|
||||
<div key={`${message.id}_${detail}`}>{detail}</div>
|
||||
))}
|
||||
</ToolActivityDetails>
|
||||
) : null}
|
||||
{inputPreview ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-[11px] uppercase tracking-wide text-muted-foreground/80">
|
||||
Input
|
||||
</div>
|
||||
<ToolActivityCode className="text-sm">
|
||||
{inputPreview}
|
||||
</ToolActivityCode>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{resultPreview ? (
|
||||
payload?.isError ? (
|
||||
<div className="mt-1 text-destructive">{resultPreview}</div>
|
||||
) : (
|
||||
<ToolActivityCode className="max-h-64 text-sm">
|
||||
{resultPreview}
|
||||
</ToolActivityCode>
|
||||
)
|
||||
) : null}
|
||||
</ToolActivityContent>
|
||||
</ToolActivity>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { formatChatMessageContent } from "./message-content";
|
||||
|
||||
describe("formatChatMessageContent", () => {
|
||||
test("unwraps transport envelopes only for user messages", () => {
|
||||
expect(
|
||||
formatChatMessageContent(
|
||||
"user",
|
||||
" <user_input>\nPlease fix the tests\n</user_input> ",
|
||||
),
|
||||
).toBe("Please fix the tests");
|
||||
});
|
||||
|
||||
test("preserves assistant examples that contain transport tags", () => {
|
||||
const content =
|
||||
"<user_input>\nThis tag is part of the explanation.\n</user_input>";
|
||||
expect(formatChatMessageContent("assistant", content)).toBe(content);
|
||||
});
|
||||
|
||||
test("preserves assistant mode notices instead of stripping them", () => {
|
||||
const content = "<mode_notice>\nPlan mode details\n</mode_notice>";
|
||||
expect(formatChatMessageContent("assistant", content)).toBe(content);
|
||||
});
|
||||
|
||||
test("trims outer whitespace for non-user roles", () => {
|
||||
expect(formatChatMessageContent("error", " Request failed \n")).toBe(
|
||||
"Request failed",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { formatDisplayUserInput } from "@cline/shared/browser";
|
||||
import type { ChatMessage } from "@/lib/chat-schema";
|
||||
|
||||
export function formatChatMessageContent(
|
||||
role: ChatMessage["role"],
|
||||
content: string,
|
||||
): string {
|
||||
const trimmed = content.trim();
|
||||
return role === "user" ? formatDisplayUserInput(trimmed) : trimmed;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { WorkspaceProvider } from "@/contexts/workspace-context";
|
||||
import { WelcomeScreen } from "./welcome-chat";
|
||||
|
||||
describe("WelcomeScreen", () => {
|
||||
it("renders every known project instead of capping the project strip", () => {
|
||||
const workspaces = Array.from(
|
||||
{ length: 6 },
|
||||
(_, index) => `/projects/project-${index + 1}`,
|
||||
);
|
||||
const html = renderToStaticMarkup(
|
||||
<WorkspaceProvider
|
||||
value={{
|
||||
workspaceRoot: workspaces[0] ?? "",
|
||||
workspaces,
|
||||
listWorkspaces: vi.fn(async () => workspaces),
|
||||
refreshWorkspaces: vi.fn(async () => undefined),
|
||||
switchWorkspace: vi.fn(async () => true),
|
||||
pickWorkspaceDirectory: vi.fn(async () => null),
|
||||
}}
|
||||
>
|
||||
<WelcomeScreen
|
||||
active
|
||||
body={null}
|
||||
composer={null}
|
||||
onStartChat={vi.fn()}
|
||||
quickActions={[]}
|
||||
/>
|
||||
</WorkspaceProvider>,
|
||||
);
|
||||
|
||||
for (let index = 1; index <= workspaces.length; index += 1) {
|
||||
expect(html).toContain(`project-${index}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { Check, FolderOpen } from "lucide-react";
|
||||
import { ArrowRight, FolderPlus, Plus } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { AuroraBackground } from "@/components/ui/aurora-bg";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useWorkspace } from "@/contexts/workspace-context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { normalizeWorkspacePath } from "@/lib/workspace-paths";
|
||||
|
||||
interface QuickAction {
|
||||
id: string;
|
||||
@@ -21,68 +15,95 @@ interface QuickAction {
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
function normalizeWorkspacePath(path: string): string {
|
||||
const normalized = path.trim().replace(/[\\/]+$/, "");
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
if (/^[A-Za-z]:/.test(normalized)) {
|
||||
return normalized.toLowerCase();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
|
||||
{
|
||||
id: "review-changes",
|
||||
label: "Review changes",
|
||||
description: "Review the current changes and call out anything risky.",
|
||||
prompt: "Review the current changes and call out anything risky.",
|
||||
},
|
||||
{
|
||||
id: "check-build",
|
||||
label: "Check for build errors",
|
||||
description: "Run the relevant checks and help me fix any failures.",
|
||||
prompt: "Check this project for build errors and help me fix any failures.",
|
||||
},
|
||||
];
|
||||
|
||||
function toWorkspaceName(path: string): string {
|
||||
const trimmed = path.trim().replace(/[\\/]+$/, "");
|
||||
if (!trimmed) return "workspace";
|
||||
if (!trimmed) return "Workspace";
|
||||
const parts = trimmed.split(/[\\/]/);
|
||||
return parts[parts.length - 1] || "workspace";
|
||||
return parts[parts.length - 1] || "Workspace";
|
||||
}
|
||||
|
||||
function formatWorkspaceLabel(workspacePath: string): string {
|
||||
const trimmed = workspacePath.trim();
|
||||
if (!trimmed) return workspacePath;
|
||||
const unixHome = trimmed.match(/^\/Users\/[^/]+\/(.*)$/);
|
||||
if (unixHome) return unixHome[1] ? `~/${unixHome[1]}` : "~";
|
||||
const linuxHome = trimmed.match(/^\/home\/[^/]+\/(.*)$/);
|
||||
if (linuxHome) return linuxHome[1] ? `~/${linuxHome[1]}` : "~";
|
||||
const windowsHome = trimmed.match(/^[A-Za-z]:\\Users\\[^\\]+\\(.*)$/);
|
||||
if (windowsHome) {
|
||||
const tail = windowsHome[1]?.replaceAll("\\", "/") || "";
|
||||
return tail ? `~/${tail}` : "~";
|
||||
}
|
||||
return workspacePath;
|
||||
function workspaceLabels(paths: string[]): Map<string, string> {
|
||||
const segments = paths.map((path) =>
|
||||
path
|
||||
.trim()
|
||||
.replace(/[\\/]+$/, "")
|
||||
.split(/[\\/]/)
|
||||
.filter(Boolean),
|
||||
);
|
||||
return new Map(
|
||||
paths.map((path, index) => {
|
||||
const parts = segments[index] ?? [];
|
||||
for (let depth = 1; depth <= parts.length; depth += 1) {
|
||||
const candidate = parts.slice(-depth).join("/");
|
||||
const matches = segments.filter(
|
||||
(other) => other.slice(-depth).join("/") === candidate,
|
||||
).length;
|
||||
if (matches === 1) return [path, candidate];
|
||||
}
|
||||
return [path, toWorkspaceName(path)];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function WelcomeScreen({
|
||||
active,
|
||||
body,
|
||||
composer,
|
||||
onStartChat,
|
||||
quickActions,
|
||||
}: {
|
||||
provider: string;
|
||||
model: string;
|
||||
active: boolean;
|
||||
body: ReactNode;
|
||||
composer: ReactNode;
|
||||
onStartChat: (prompt: string) => void;
|
||||
quickActions: QuickAction[];
|
||||
}) {
|
||||
const { workspaceRoot, workspaces, refreshWorkspaces, switchWorkspace } =
|
||||
useWorkspace();
|
||||
const [switchingWorkspace, setSwitchingWorkspace] = useState(false);
|
||||
const {
|
||||
workspaceRoot,
|
||||
workspaces,
|
||||
refreshWorkspaces,
|
||||
switchWorkspace,
|
||||
pickWorkspaceDirectory,
|
||||
} = useWorkspace();
|
||||
const [switchingWorkspace, setSwitchingWorkspace] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [addingWorkspace, setAddingWorkspace] = useState(false);
|
||||
const availableWorkspaces = useMemo(() => {
|
||||
const next = new Map<string, string>();
|
||||
const register = (path: string) => {
|
||||
const trimmed = path.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
next.set(normalizeWorkspacePath(trimmed), trimmed);
|
||||
if (trimmed) next.set(normalizeWorkspacePath(trimmed), trimmed);
|
||||
};
|
||||
register(workspaceRoot);
|
||||
for (const workspacePath of workspaces) {
|
||||
register(workspacePath);
|
||||
}
|
||||
for (const workspacePath of workspaces) register(workspacePath);
|
||||
return [...next.values()];
|
||||
}, [workspaceRoot, workspaces]);
|
||||
const actions =
|
||||
quickActions.length > 0 ? quickActions : DEFAULT_QUICK_ACTIONS;
|
||||
const labelsByWorkspace = useMemo(
|
||||
() => workspaceLabels(availableWorkspaces),
|
||||
[availableWorkspaces],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshWorkspaces();
|
||||
}, [refreshWorkspaces]);
|
||||
if (active) void refreshWorkspaces();
|
||||
}, [active, refreshWorkspaces]);
|
||||
|
||||
const handleSelectWorkspace = useCallback(
|
||||
async (path: string) => {
|
||||
@@ -90,109 +111,145 @@ export function WelcomeScreen({
|
||||
normalizeWorkspacePath(path) ===
|
||||
normalizeWorkspacePath(workspaceRoot) ||
|
||||
switchingWorkspace
|
||||
)
|
||||
) {
|
||||
return;
|
||||
setSwitchingWorkspace(true);
|
||||
await switchWorkspace(path);
|
||||
setSwitchingWorkspace(false);
|
||||
}
|
||||
setSwitchingWorkspace(path);
|
||||
try {
|
||||
await switchWorkspace(path);
|
||||
} finally {
|
||||
setSwitchingWorkspace(null);
|
||||
}
|
||||
},
|
||||
[workspaceRoot, switchWorkspace, switchingWorkspace],
|
||||
[switchWorkspace, switchingWorkspace, workspaceRoot],
|
||||
);
|
||||
|
||||
const handleQuickAction = (action: QuickAction) => {
|
||||
// TODO: wire up quick action prompt to chat input
|
||||
void action;
|
||||
};
|
||||
const handleAddWorkspace = useCallback(async () => {
|
||||
if (addingWorkspace) return;
|
||||
setAddingWorkspace(true);
|
||||
try {
|
||||
const selected = await pickWorkspaceDirectory(workspaceRoot || undefined);
|
||||
if (selected) await switchWorkspace(selected);
|
||||
} finally {
|
||||
setAddingWorkspace(false);
|
||||
}
|
||||
}, [addingWorkspace, pickWorkspaceDirectory, switchWorkspace, workspaceRoot]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center overflow-hidden bg-background">
|
||||
<AuroraBackground />
|
||||
<div className="relative z-10 flex w-full max-w-3xl flex-1 flex-col items-center px-6 py-12">
|
||||
<div className="mb-8 flex flex-col items-center">
|
||||
<h1 className="text-balance text-center text-3xl font-bold tracking-tight text-foreground">
|
||||
What can I do for you?
|
||||
</h1>
|
||||
<p className="mt-2 text-balance text-center text-muted-foreground">
|
||||
Let's explore, edit, and ship code together!
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
active
|
||||
? "relative h-full min-h-0 overflow-hidden bg-background"
|
||||
: "contents",
|
||||
)}
|
||||
>
|
||||
{active ? <AuroraBackground /> : null}
|
||||
<div
|
||||
className={cn(
|
||||
active
|
||||
? "relative z-10 h-full w-full overflow-x-hidden overflow-y-auto"
|
||||
: "contents",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
active
|
||||
? "mx-auto flex w-full max-w-[960px] flex-col px-6 pb-32 pt-[clamp(8rem,26vh,17rem)] max-[720px]:px-4 max-[720px]:pb-20 max-[720px]:pt-16"
|
||||
: "contents",
|
||||
)}
|
||||
>
|
||||
{active ? (
|
||||
<>
|
||||
<h1 className="text-balance text-center text-[clamp(2rem,3vw,2.6rem)] font-semibold leading-[1.12] tracking-[-0.025em] text-foreground">
|
||||
What would you like to build?
|
||||
</h1>
|
||||
|
||||
{/* Workspace selector */}
|
||||
<div className="mb-8 w-full max-w-md">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Workspace
|
||||
</Label>
|
||||
<Command className="rounded-xl border border-border bg-card">
|
||||
<CommandInput placeholder="Search workspaces..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No workspaces found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableWorkspaces.map((wsPath) => {
|
||||
const isActive =
|
||||
normalizeWorkspacePath(wsPath) ===
|
||||
normalizeWorkspacePath(workspaceRoot);
|
||||
return (
|
||||
<CommandItem
|
||||
key={wsPath}
|
||||
value={wsPath}
|
||||
onSelect={() => {
|
||||
void handleSelectWorkspace(wsPath);
|
||||
}}
|
||||
disabled={switchingWorkspace}
|
||||
className="gap-3 py-2.5"
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-secondary">
|
||||
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{toWorkspaceName(wsPath)}
|
||||
</p>
|
||||
{isActive && (
|
||||
<span className="rounded-full bg-primary/20 px-1.5 py-0.5 text-[10px] font-medium text-primary">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{formatWorkspaceLabel(wsPath)}
|
||||
</p>
|
||||
</div>
|
||||
{isActive && (
|
||||
<Check className="ml-auto h-4 w-4 text-primary" />
|
||||
)}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
|
||||
{/* Quick actions */}
|
||||
<div className="mb-8 w-full">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{quickActions?.map((action) => {
|
||||
return (
|
||||
<div className="mt-11 flex min-w-0 items-center gap-1.5 text-sm">
|
||||
<fieldset className="flex min-h-8 min-w-0 flex-1 items-center gap-1.5 overflow-x-auto pb-1">
|
||||
<legend className="sr-only">Workspaces</legend>
|
||||
{availableWorkspaces.map((path) => {
|
||||
const isActive =
|
||||
normalizeWorkspacePath(path) ===
|
||||
normalizeWorkspacePath(workspaceRoot);
|
||||
const isSwitching = switchingWorkspace === path;
|
||||
return (
|
||||
<button
|
||||
aria-pressed={isActive}
|
||||
className={cn(
|
||||
"shrink-0 rounded-md px-3 py-1.5 font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
isActive
|
||||
? "bg-foreground text-background"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
)}
|
||||
disabled={Boolean(switchingWorkspace)}
|
||||
key={path}
|
||||
onClick={() => void handleSelectWorkspace(path)}
|
||||
title={path}
|
||||
type="button"
|
||||
>
|
||||
{isSwitching
|
||||
? "Switching..."
|
||||
: (labelsByWorkspace.get(path) ??
|
||||
toWorkspaceName(path))}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</fieldset>
|
||||
<button
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-1.5 font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring max-[480px]:px-2"
|
||||
disabled={addingWorkspace}
|
||||
onClick={() => void handleAddWorkspace()}
|
||||
type="button"
|
||||
key={action.id}
|
||||
onClick={() => handleQuickAction(action)}
|
||||
className="group flex flex-col items-start gap-2 rounded-xl border border-border bg-card/50 p-4 text-left transition-all hover:border-primary/30 hover:bg-card"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{action.label}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground line-clamp-2">
|
||||
{action.description}
|
||||
</p>
|
||||
</div>
|
||||
{addingWorkspace ? (
|
||||
<FolderPlus className="size-4 animate-pulse" />
|
||||
) : (
|
||||
<Plus className="size-4" />
|
||||
)}
|
||||
New project
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={active ? "hidden" : "h-full min-h-0 overflow-hidden"}
|
||||
key="conversation-body"
|
||||
>
|
||||
{body}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={active ? "mt-4 w-full" : "z-20 shrink-0"}
|
||||
key="persistent-composer"
|
||||
>
|
||||
{composer}
|
||||
</div>
|
||||
|
||||
{active ? (
|
||||
<div className="mt-11 w-full divide-y divide-border/80 overflow-hidden rounded-xl border border-border/60 bg-background/95 px-2 shadow-sm">
|
||||
{actions.map((action) => (
|
||||
<button
|
||||
className="group flex w-full items-center justify-between gap-5 px-3 py-3 text-left transition-colors hover:bg-background/55 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
key={action.id}
|
||||
onClick={() => onStartChat(action.prompt)}
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-[15px] font-medium text-foreground">
|
||||
{action.label}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-sm text-muted-foreground">
|
||||
{action.description}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
|
||||
<ArrowRight className="size-3" />
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// @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 { WorkspaceSelector } from "./workspace-selector";
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function click(element: Element): Promise<void> {
|
||||
await act(async () => {
|
||||
element.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true, cancelable: true }),
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function buttonWithText(text: string): HTMLButtonElement {
|
||||
const button = [
|
||||
...container.querySelectorAll<HTMLButtonElement>("button"),
|
||||
].find((candidate) => candidate.textContent?.includes(text));
|
||||
expect(button).toBeDefined();
|
||||
return button as HTMLButtonElement;
|
||||
}
|
||||
|
||||
describe("WorkspaceSelector", () => {
|
||||
it("switches both workspace and branch choices from the opened menu", async () => {
|
||||
const onSwitchWorkspace = vi.fn(async () => true);
|
||||
const onSwitchGitBranch = vi.fn(async () => true);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WorkspaceSelector
|
||||
currentBranch="main"
|
||||
onListGitBranches={vi.fn(async () => ({
|
||||
current: "main",
|
||||
branches: ["main", "feature/review"],
|
||||
}))}
|
||||
onPickWorkspaceDirectory={vi.fn(async () => null)}
|
||||
onRefreshWorkspaces={vi.fn(async () => undefined)}
|
||||
onSwitchGitBranch={onSwitchGitBranch}
|
||||
onSwitchWorkspace={onSwitchWorkspace}
|
||||
workspaceRoot="/workspace/one"
|
||||
workspaces={["/workspace/one", "/workspace/two"]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await click(container.querySelector("#git-branch-btn") as Element);
|
||||
await vi.waitFor(() => {
|
||||
expect(container.textContent).toContain("/workspace/two");
|
||||
expect(container.textContent).toContain("feature/review");
|
||||
});
|
||||
await click(buttonWithText("/workspace/two"));
|
||||
await vi.waitFor(() => {
|
||||
expect(onSwitchWorkspace).toHaveBeenCalledWith("/workspace/two");
|
||||
});
|
||||
|
||||
await click(container.querySelector("#git-branch-btn") as Element);
|
||||
await vi.waitFor(() => {
|
||||
expect(container.textContent).toContain("feature/review");
|
||||
});
|
||||
await click(buttonWithText("feature/review"));
|
||||
await vi.waitFor(() => {
|
||||
expect(onSwitchGitBranch).toHaveBeenCalledWith("feature/review");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -215,6 +215,7 @@ export function WorkspaceSelector({
|
||||
variant="ghost"
|
||||
aria-label="Close menu"
|
||||
className="fixed inset-0 z-40 cursor-default h-auto rounded-none opacity-0"
|
||||
data-cursor="default"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setShowWorkspacePathInput(false);
|
||||
|
||||
@@ -195,10 +195,12 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background text-foreground">
|
||||
<header className="flex shrink-0 items-center justify-between gap-4 border-b px-6 py-4">
|
||||
<header className="flex shrink-0 items-end justify-between gap-6 px-18 pb-7 pt-10 max-[1200px]:px-8 max-md:pl-12 max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:pr-4 max-[720px]:pt-5">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-semibold leading-tight">Sessions</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
<h1 className="text-[32px] font-semibold leading-[1.15] tracking-normal">
|
||||
Sessions
|
||||
</h1>
|
||||
<p className="mt-3 text-[15px] leading-6 text-muted-foreground">
|
||||
Recent sessions across clients and workspaces.
|
||||
</p>
|
||||
</div>
|
||||
@@ -228,10 +230,10 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={6}>
|
||||
<DropdownMenuItem onClick={() => setSortDirection("newest")}>
|
||||
{sortDirection === "newest" ? "Newest first" : "Newest first"}
|
||||
Newest first
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setSortDirection("oldest")}>
|
||||
{sortDirection === "oldest" ? "Oldest first" : "Oldest first"}
|
||||
Oldest first
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -284,7 +286,7 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="min-h-0 flex-1 overflow-auto px-6 py-5">
|
||||
<section className="min-h-0 flex-1 overflow-auto px-18 pb-10 max-[1200px]:px-8 max-[720px]:px-4">
|
||||
<div className="min-w-240 overflow-hidden rounded-lg border bg-card">
|
||||
<div className="grid grid-cols-[minmax(14rem,1.35fr)_minmax(9rem,0.8fr)_minmax(12rem,1fr)_7rem_5rem_6rem_2.5rem] gap-x-4 bg-muted/40 px-4 py-3 text-sm font-medium text-muted-foreground">
|
||||
<span>Session</span>
|
||||
@@ -293,7 +295,7 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
<span>Tokens</span>
|
||||
<span>Cost</span>
|
||||
<span>Updated</span>
|
||||
<span />
|
||||
<span className="sr-only">Actions</span>
|
||||
</div>
|
||||
<div>
|
||||
{history.isLoadingHistory && history.threads.length === 0 ? (
|
||||
@@ -412,12 +414,15 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
type="button"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-3 font-semibold">
|
||||
<span className="sr-only">Open session: </span>
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 shrink-0 rounded-full",
|
||||
statusTone(thread.status),
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="sr-only">{thread.status}: </span>
|
||||
<span className="truncate">{thread.title}</span>
|
||||
</span>
|
||||
<span className="flex min-w-0 items-center gap-2 text-muted-foreground">
|
||||
@@ -440,42 +445,44 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-label={`Session actions for ${thread.title}`}
|
||||
className="grid size-7 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
disabled={Boolean(pendingKind)}
|
||||
type="button"
|
||||
>
|
||||
{pendingKind ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<MoreHorizontal className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={6}>
|
||||
<DropdownMenuItem onClick={() => startRename(thread)}>
|
||||
<Pencil className="size-4" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => void history.forkThread(thread.id)}
|
||||
>
|
||||
<GitFork className="size-4" />
|
||||
Fork
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDeleteCandidate(thread)}
|
||||
variant="destructive"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-label={`Session actions for ${thread.title}`}
|
||||
className="grid size-7 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
disabled={Boolean(pendingKind)}
|
||||
type="button"
|
||||
>
|
||||
{pendingKind ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<MoreHorizontal className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={6}>
|
||||
<DropdownMenuItem onClick={() => startRename(thread)}>
|
||||
<Pencil className="size-4" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => void history.forkThread(thread.id)}
|
||||
>
|
||||
<GitFork className="size-4" />
|
||||
Fork
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDeleteCandidate(thread)}
|
||||
variant="destructive"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { X } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import type {
|
||||
@@ -16,7 +13,6 @@ import {
|
||||
readSystemHubTheme,
|
||||
setStoredHubTheme,
|
||||
} from "@/lib/theme";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageFrame, PageHeader } from "../page-layout";
|
||||
import { AccountView } from "./account-view";
|
||||
import { AddProviderContent, type AddProviderPayload } from "./add-provider";
|
||||
@@ -34,18 +30,22 @@ import { toSettingsPatch } from "./settings-patch";
|
||||
// Settings nav categories
|
||||
// -----------------------------------------------------------
|
||||
|
||||
const navCategories = [
|
||||
export const SETTINGS_SECTIONS = [
|
||||
"General",
|
||||
"Providers",
|
||||
"MCP",
|
||||
"Marketplace",
|
||||
"Extensions",
|
||||
"Models",
|
||||
"MCP Servers",
|
||||
"MCP Marketplace",
|
||||
"Customizations",
|
||||
"Channels",
|
||||
"Schedules",
|
||||
"Account",
|
||||
] as const;
|
||||
|
||||
export type SettingsSection = (typeof navCategories)[number];
|
||||
export type SettingsSection = (typeof SETTINGS_SECTIONS)[number];
|
||||
type GlobalSettingsResponse = {
|
||||
telemetryOptOut: boolean;
|
||||
autoUpdateEnabled: boolean;
|
||||
};
|
||||
|
||||
const PROVIDER_CATALOG_CACHE_TTL_MS = 60_000;
|
||||
|
||||
@@ -59,17 +59,13 @@ let providerCatalogCache: {
|
||||
// -----------------------------------------------------------
|
||||
|
||||
export function SettingsView({
|
||||
chrome = "full",
|
||||
initialSection = "General",
|
||||
onClose,
|
||||
section,
|
||||
onNavigateSection,
|
||||
}: {
|
||||
chrome?: "full" | "content";
|
||||
initialSection?: SettingsSection;
|
||||
onClose: () => void;
|
||||
onNavigateSection?: (section: SettingsSection) => void;
|
||||
section: SettingsSection;
|
||||
onNavigateSection: (section: SettingsSection) => void;
|
||||
}) {
|
||||
const [activeNav, setActiveNav] = useState<SettingsSection>(initialSection);
|
||||
const activeNav = section;
|
||||
const [providers, setProviders] = useState<Provider[]>(
|
||||
() => providerCatalogCache?.providers ?? [],
|
||||
);
|
||||
@@ -93,6 +89,13 @@ export function SettingsView({
|
||||
);
|
||||
const [addingProvider, setAddingProvider] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (section !== "Models") {
|
||||
setSelectedProviderId(null);
|
||||
setAddingProvider(false);
|
||||
}
|
||||
}, [section]);
|
||||
|
||||
const setProvidersWithCache = useCallback(
|
||||
(next: Provider[] | ((prev: Provider[]) => Provider[])) => {
|
||||
setProviders((prev) => {
|
||||
@@ -139,7 +142,7 @@ export function SettingsView({
|
||||
}, [setProvidersWithCache]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeNav !== "Providers") {
|
||||
if (activeNav !== "Models") {
|
||||
return;
|
||||
}
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
@@ -288,8 +291,7 @@ export function SettingsView({
|
||||
};
|
||||
|
||||
const openProviderDetail = (id: string) => {
|
||||
setActiveNav("Providers");
|
||||
onNavigateSection?.("Providers");
|
||||
onNavigateSection("Models");
|
||||
setSelectedProviderId(id);
|
||||
};
|
||||
|
||||
@@ -310,7 +312,7 @@ export function SettingsView({
|
||||
}, [loadProviderModels, providers, selectedProviderId]);
|
||||
|
||||
const backToProviderList = () => {
|
||||
onNavigateSection?.("Providers");
|
||||
onNavigateSection("Models");
|
||||
setSelectedProviderId(null);
|
||||
setAddingProvider(false);
|
||||
};
|
||||
@@ -337,18 +339,11 @@ export function SettingsView({
|
||||
);
|
||||
|
||||
const openAddProvider = () => {
|
||||
onNavigateSection?.("Providers");
|
||||
onNavigateSection("Models");
|
||||
setSelectedProviderId(null);
|
||||
setAddingProvider(true);
|
||||
};
|
||||
|
||||
const selectSection = (section: SettingsSection) => {
|
||||
setActiveNav(section);
|
||||
onNavigateSection?.(section);
|
||||
setSelectedProviderId(null);
|
||||
setAddingProvider(false);
|
||||
};
|
||||
|
||||
const providerContent = addingProvider ? (
|
||||
<AddProviderContent
|
||||
existingProviderIds={providers.map((provider) => provider.id)}
|
||||
@@ -403,13 +398,13 @@ export function SettingsView({
|
||||
);
|
||||
|
||||
const content =
|
||||
activeNav === "Providers" ? (
|
||||
activeNav === "Models" ? (
|
||||
providerContent
|
||||
) : activeNav === "MCP" ? (
|
||||
) : activeNav === "MCP Servers" ? (
|
||||
<McpServersContent />
|
||||
) : activeNav === "Marketplace" ? (
|
||||
) : activeNav === "MCP Marketplace" ? (
|
||||
<CustomizationSectionView catalogPrimitive="mcp" section="MCP" />
|
||||
) : activeNav === "Extensions" ? (
|
||||
) : activeNav === "Customizations" ? (
|
||||
<RulesView />
|
||||
) : activeNav === "Channels" ? (
|
||||
<ChannelsContent />
|
||||
@@ -427,57 +422,10 @@ export function SettingsView({
|
||||
</div>
|
||||
);
|
||||
|
||||
if (chrome === "content") {
|
||||
return (
|
||||
<div className="h-full overflow-hidden bg-background">{content}</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background">
|
||||
{/* Header bar */}
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border px-6 py-3">
|
||||
<h1 className="text-lg font-semibold text-foreground">Settings</h1>
|
||||
<Button
|
||||
aria-label="Close settings"
|
||||
className="justify-start"
|
||||
onClick={onClose}
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Settings sidebar nav */}
|
||||
<nav className="w-56 shrink-0 border-r border-border">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="flex flex-col gap-0.5 p-3">
|
||||
{navCategories.map((cat) => (
|
||||
<Button
|
||||
className={cn(
|
||||
"justify-start",
|
||||
activeNav === cat
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
key={cat}
|
||||
onClick={() => {
|
||||
selectSection(cat);
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
{cat}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</nav>
|
||||
|
||||
{/* Content area */}
|
||||
<div className="flex-1 overflow-hidden">{content}</div>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -487,6 +435,82 @@ function GeneralSettingsContent() {
|
||||
if (typeof window === "undefined") return "light";
|
||||
return readStoredHubTheme() ?? readSystemHubTheme();
|
||||
});
|
||||
const [telemetryOptOut, setTelemetryOptOut] = useState(false);
|
||||
const [telemetryLoading, setTelemetryLoading] = useState(true);
|
||||
const [telemetrySaving, setTelemetrySaving] = useState(false);
|
||||
const [telemetryError, setTelemetryError] = useState<string | null>(null);
|
||||
const [autoUpdateEnabled, setAutoUpdateEnabled] = useState(true);
|
||||
const [autoUpdateLoading, setAutoUpdateLoading] = useState(true);
|
||||
const [autoUpdateSaving, setAutoUpdateSaving] = useState(false);
|
||||
const [autoUpdateError, setAutoUpdateError] = useState<string | null>(null);
|
||||
|
||||
const loadGlobalSettings = useCallback(async () => {
|
||||
setTelemetryLoading(true);
|
||||
setTelemetryError(null);
|
||||
setAutoUpdateLoading(true);
|
||||
setAutoUpdateError(null);
|
||||
try {
|
||||
const settings = await desktopClient.invoke<GlobalSettingsResponse>(
|
||||
"get_global_settings",
|
||||
);
|
||||
setTelemetryOptOut(settings.telemetryOptOut);
|
||||
setAutoUpdateEnabled(settings.autoUpdateEnabled);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setTelemetryError(message);
|
||||
setAutoUpdateError(message);
|
||||
} finally {
|
||||
setTelemetryLoading(false);
|
||||
setAutoUpdateLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void loadGlobalSettings();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [loadGlobalSettings]);
|
||||
|
||||
const updateTelemetryOptOut = async (nextValue: boolean) => {
|
||||
const previousValue = telemetryOptOut;
|
||||
setTelemetryOptOut(nextValue);
|
||||
setTelemetrySaving(true);
|
||||
setTelemetryError(null);
|
||||
try {
|
||||
const settings = await desktopClient.invoke<GlobalSettingsResponse>(
|
||||
"set_telemetry_opt_out",
|
||||
{ telemetry_opt_out: nextValue },
|
||||
);
|
||||
setTelemetryOptOut(settings.telemetryOptOut);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setTelemetryOptOut(previousValue);
|
||||
setTelemetryError(message);
|
||||
} finally {
|
||||
setTelemetrySaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateAutoUpdateEnabled = async (nextValue: boolean) => {
|
||||
const previousValue = autoUpdateEnabled;
|
||||
setAutoUpdateEnabled(nextValue);
|
||||
setAutoUpdateSaving(true);
|
||||
setAutoUpdateError(null);
|
||||
try {
|
||||
const settings = await desktopClient.invoke<GlobalSettingsResponse>(
|
||||
"set_auto_update_enabled",
|
||||
{ auto_update_enabled: nextValue },
|
||||
);
|
||||
setAutoUpdateEnabled(settings.autoUpdateEnabled);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setAutoUpdateEnabled(previousValue);
|
||||
setAutoUpdateError(message);
|
||||
} finally {
|
||||
setAutoUpdateSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateTheme = (darkModeEnabled: boolean) => {
|
||||
const nextTheme = darkModeEnabled ? "dark" : "light";
|
||||
@@ -515,6 +539,48 @@ function GeneralSettingsContent() {
|
||||
onCheckedChange={updateTheme}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-h-20 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
|
||||
<div>
|
||||
<p className="text-[17px] font-semibold text-foreground">
|
||||
Auto update
|
||||
</p>
|
||||
<p className="mt-1 text-[15px] text-muted-foreground">
|
||||
Automatically install Cline CLI updates on startup.
|
||||
</p>
|
||||
{autoUpdateError ? (
|
||||
<p className="mt-2 text-xs text-destructive" role="alert">
|
||||
Failed to update auto update setting: {autoUpdateError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Switch
|
||||
aria-label="Auto update"
|
||||
checked={autoUpdateEnabled}
|
||||
disabled={autoUpdateLoading || autoUpdateSaving}
|
||||
onCheckedChange={(checked) => void updateAutoUpdateEnabled(checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-h-20 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
|
||||
<div>
|
||||
<p className="text-[17px] font-semibold text-foreground">
|
||||
Telemetry
|
||||
</p>
|
||||
<p className="mt-1 text-[15px] text-muted-foreground">
|
||||
Enable error and usage reports to help improve Cline.
|
||||
</p>
|
||||
{telemetryError ? (
|
||||
<p className="mt-2 text-xs text-destructive" role="alert">
|
||||
Failed to update telemetry setting: {telemetryError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Switch
|
||||
aria-label="Telemetry"
|
||||
checked={!telemetryOptOut}
|
||||
disabled={telemetryLoading || telemetrySaving}
|
||||
onCheckedChange={(checked) => void updateTelemetryOptOut(!checked)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</PageFrame>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ChatSessionConfig } from "@/lib/chat-schema";
|
||||
import { readModelSelectionStorageFromWindow } from "@/lib/model-selection";
|
||||
import { normalizeProviderId } from "@/lib/provider-id";
|
||||
import { readWorkspaceSelectionFromWindow } from "@/lib/workspace-paths";
|
||||
|
||||
export const CHAT_TRANSPORT_UNAVAILABLE_MESSAGE =
|
||||
"Chat connection is unavailable. Reopen the app window to restore realtime chat.";
|
||||
@@ -40,6 +41,7 @@ export const DEFAULT_CHAT_CONFIG: ChatSessionConfig = {
|
||||
|
||||
export function getInitialChatConfig(): ChatSessionConfig {
|
||||
const selection = readModelSelectionStorageFromWindow();
|
||||
const workspaceSelection = readWorkspaceSelectionFromWindow();
|
||||
const rememberedProvider = normalizeProviderId(selection.lastProvider);
|
||||
const rememberedModelForProvider = rememberedProvider
|
||||
? (selection.lastModelByProvider[rememberedProvider] ??
|
||||
@@ -59,5 +61,7 @@ export function getInitialChatConfig(): ChatSessionConfig {
|
||||
...DEFAULT_CHAT_CONFIG,
|
||||
provider,
|
||||
model,
|
||||
workspaceRoot: workspaceSelection.lastWorkspace,
|
||||
cwd: workspaceSelection.lastWorkspace,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createSessionId } from "@cline/shared/browser";
|
||||
import type {
|
||||
ChatMessage,
|
||||
ChatSessionConfig,
|
||||
@@ -12,7 +13,7 @@ type RpcMessageLike = {
|
||||
};
|
||||
|
||||
export function makeId(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
return createSessionId(`${prefix}_`);
|
||||
}
|
||||
|
||||
function stringifyRpcMessageContent(content: unknown): string {
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
// @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 { useChatSession } from "./use-chat-session";
|
||||
|
||||
const { invokeMock } = vi.hoisted(() => ({
|
||||
invokeMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/desktop-client", () => ({
|
||||
desktopClient: {
|
||||
getTransportError: vi.fn(() => null),
|
||||
getTransportState: vi.fn(() => "connected"),
|
||||
invoke: invokeMock,
|
||||
subscribe: vi.fn(() => () => undefined),
|
||||
subscribeTransportState: vi.fn(() => () => undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
type ChatSessionHook = ReturnType<typeof useChatSession>;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let current: ChatSessionHook;
|
||||
|
||||
function HookHarness() {
|
||||
current = useChatSession();
|
||||
return null;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
window.localStorage.clear();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
invokeMock.mockReset();
|
||||
invokeMock.mockImplementation(async (command: string) => {
|
||||
if (command === "get_process_context") {
|
||||
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
|
||||
}
|
||||
return [];
|
||||
});
|
||||
await act(async () => root.render(<HookHarness />));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("useChatSession", () => {
|
||||
it("publishes the first user message before cold session startup resolves", async () => {
|
||||
let resolveStart: ((value: { sessionId: string }) => void) | undefined;
|
||||
const startResponse = new Promise<{ sessionId: string }>((resolve) => {
|
||||
resolveStart = resolve;
|
||||
});
|
||||
let plannedSessionId = "";
|
||||
let startConfig:
|
||||
| { thinking?: boolean; reasoningEffort?: string; sessionId?: string }
|
||||
| undefined;
|
||||
invokeMock.mockImplementation(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === "get_process_context") {
|
||||
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
|
||||
}
|
||||
if (command === "chat_session_command") {
|
||||
const request = args?.request as
|
||||
| {
|
||||
action?: string;
|
||||
config?: {
|
||||
sessionId?: string;
|
||||
thinking?: boolean;
|
||||
reasoningEffort?: string;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
if (request?.action === "start") {
|
||||
plannedSessionId = request.config?.sessionId ?? "";
|
||||
startConfig = request.config;
|
||||
return await startResponse;
|
||||
}
|
||||
if (request?.action === "send") {
|
||||
return {
|
||||
ok: true,
|
||||
result: { text: "Ready", finishReason: "completed" },
|
||||
};
|
||||
}
|
||||
return { promptsInQueue: [] };
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
current.setConfig((previous) => ({
|
||||
...previous,
|
||||
thinking: true,
|
||||
reasoningEffort: "high",
|
||||
}));
|
||||
});
|
||||
let sendPromise: Promise<void> | undefined;
|
||||
await act(async () => {
|
||||
sendPromise = current.sendPrompt("Start the task");
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(current.status).toBe("starting");
|
||||
expect(current.messages).toHaveLength(1);
|
||||
expect(current.messages[0]).toMatchObject({
|
||||
role: "user",
|
||||
content: "Start the task",
|
||||
});
|
||||
expect(current.messages[0]?.sessionId).toMatch(/^session_/);
|
||||
expect(plannedSessionId).toBe(current.messages[0]?.sessionId);
|
||||
expect(startConfig).toMatchObject({
|
||||
thinking: true,
|
||||
reasoningEffort: "high",
|
||||
sessionId: plannedSessionId,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveStart?.({ sessionId: plannedSessionId });
|
||||
await sendPromise;
|
||||
});
|
||||
expect(
|
||||
current.messages.some((message) => message.content === "Ready"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("overlaps attachment serialization with cold session startup", async () => {
|
||||
let resolveStart: ((value: { sessionId: string }) => void) | undefined;
|
||||
let resolveFile: ((value: string) => void) | undefined;
|
||||
const startResponse = new Promise<{ sessionId: string }>((resolve) => {
|
||||
resolveStart = resolve;
|
||||
});
|
||||
const fileContent = new Promise<string>((resolve) => {
|
||||
resolveFile = resolve;
|
||||
});
|
||||
const text = vi.fn(async () => await fileContent);
|
||||
const attachment = {
|
||||
name: "notes.txt",
|
||||
type: "text/plain",
|
||||
size: 5,
|
||||
lastModified: 1,
|
||||
text,
|
||||
} as unknown as File;
|
||||
let plannedSessionId = "";
|
||||
let sentAttachments: unknown;
|
||||
invokeMock.mockImplementation(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === "get_process_context") {
|
||||
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
|
||||
}
|
||||
if (command === "chat_session_command") {
|
||||
const request = args?.request as
|
||||
| {
|
||||
action?: string;
|
||||
config?: { sessionId?: string };
|
||||
attachments?: unknown;
|
||||
}
|
||||
| undefined;
|
||||
if (request?.action === "start") {
|
||||
plannedSessionId = request.config?.sessionId ?? "";
|
||||
return await startResponse;
|
||||
}
|
||||
if (request?.action === "send") {
|
||||
sentAttachments = request.attachments;
|
||||
return {
|
||||
ok: true,
|
||||
result: { text: "Done", finishReason: "completed" },
|
||||
};
|
||||
}
|
||||
return { promptsInQueue: [] };
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
let sendPromise: Promise<void> | undefined;
|
||||
await act(async () => {
|
||||
sendPromise = current.sendPrompt("Read this", [attachment]);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(text).toHaveBeenCalledTimes(1);
|
||||
expect(plannedSessionId).toMatch(/^session_/);
|
||||
|
||||
await act(async () => {
|
||||
resolveStart?.({ sessionId: plannedSessionId });
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(sentAttachments).toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
resolveFile?.("hello");
|
||||
await sendPromise;
|
||||
});
|
||||
expect(sentAttachments).toEqual({
|
||||
userImages: [],
|
||||
userFiles: [{ name: "notes.txt", content: "hello" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("shares one cold start and queues a second prompt behind it", async () => {
|
||||
let resolveStart: ((value: { sessionId: string }) => void) | undefined;
|
||||
const startResponse = new Promise<{ sessionId: string }>((resolve) => {
|
||||
resolveStart = resolve;
|
||||
});
|
||||
const actions: Array<{
|
||||
action?: string;
|
||||
delivery?: string;
|
||||
sessionId?: string;
|
||||
}> = [];
|
||||
let plannedSessionId = "";
|
||||
invokeMock.mockImplementation(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === "get_process_context") {
|
||||
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
|
||||
}
|
||||
if (command === "chat_session_command") {
|
||||
const request = args?.request as
|
||||
| {
|
||||
action?: string;
|
||||
delivery?: string;
|
||||
sessionId?: string;
|
||||
config?: { sessionId?: string };
|
||||
}
|
||||
| undefined;
|
||||
actions.push(request ?? {});
|
||||
if (request?.action === "start") {
|
||||
plannedSessionId = request.config?.sessionId ?? "";
|
||||
return await startResponse;
|
||||
}
|
||||
if (request?.action === "send" && request.delivery === "queue") {
|
||||
return { ok: true, queued: true, promptsInQueue: [] };
|
||||
}
|
||||
if (request?.action === "send") {
|
||||
return {
|
||||
ok: true,
|
||||
result: { text: "First done", finishReason: "completed" },
|
||||
};
|
||||
}
|
||||
return { promptsInQueue: [] };
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
let firstSend: Promise<void> | undefined;
|
||||
let secondSend: Promise<void> | undefined;
|
||||
await act(async () => {
|
||||
firstSend = current.sendPrompt("First prompt");
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
secondSend = current.sendPrompt("Second prompt");
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(
|
||||
actions.filter((request) => request.action === "start"),
|
||||
).toHaveLength(1);
|
||||
expect(current.promptsInQueue.map((item) => item.prompt)).toContain(
|
||||
"Second prompt",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
resolveStart?.({ sessionId: plannedSessionId });
|
||||
await Promise.all([firstSend, secondSend]);
|
||||
});
|
||||
const sends = actions.filter((request) => request.action === "send");
|
||||
expect(sends).toHaveLength(2);
|
||||
expect(sends.map((request) => request.sessionId)).toEqual([
|
||||
plannedSessionId,
|
||||
plannedSessionId,
|
||||
]);
|
||||
expect(sends.map((request) => request.delivery)).toEqual([
|
||||
undefined,
|
||||
"queue",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves prompt order when the first prompt has a slow attachment", async () => {
|
||||
let resolveFile: ((value: string) => void) | undefined;
|
||||
const fileContent = new Promise<string>((resolve) => {
|
||||
resolveFile = resolve;
|
||||
});
|
||||
const attachment = {
|
||||
name: "slow.txt",
|
||||
type: "text/plain",
|
||||
size: 5,
|
||||
lastModified: 1,
|
||||
text: vi.fn(async () => await fileContent),
|
||||
} as unknown as File;
|
||||
const sends: Array<{
|
||||
prompt?: string;
|
||||
delivery?: string;
|
||||
sessionId?: string;
|
||||
}> = [];
|
||||
invokeMock.mockImplementation(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === "get_process_context") {
|
||||
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
|
||||
}
|
||||
if (command === "chat_session_command") {
|
||||
const request = args?.request as
|
||||
| {
|
||||
action?: string;
|
||||
prompt?: string;
|
||||
delivery?: string;
|
||||
sessionId?: string;
|
||||
config?: { sessionId?: string };
|
||||
}
|
||||
| undefined;
|
||||
if (request?.action === "start") {
|
||||
return { sessionId: request.config?.sessionId };
|
||||
}
|
||||
if (request?.action === "send") {
|
||||
sends.push(request);
|
||||
return request.delivery === "queue"
|
||||
? { ok: true, queued: true, promptsInQueue: [] }
|
||||
: {
|
||||
ok: true,
|
||||
result: { text: "Done", finishReason: "completed" },
|
||||
};
|
||||
}
|
||||
return { promptsInQueue: [] };
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
let firstSend: Promise<void> | undefined;
|
||||
let secondSend: Promise<void> | undefined;
|
||||
await act(async () => {
|
||||
firstSend = current.sendPrompt("First prompt", [attachment]);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
secondSend = current.sendPrompt("Second prompt");
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(sends).toHaveLength(0);
|
||||
|
||||
await act(async () => {
|
||||
resolveFile?.("hello");
|
||||
await Promise.all([firstSend, secondSend]);
|
||||
});
|
||||
expect(sends.map(({ prompt, delivery }) => ({ prompt, delivery }))).toEqual(
|
||||
[
|
||||
{ prompt: "First prompt", delivery: undefined },
|
||||
{ prompt: "Second prompt", delivery: "queue" },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it("starts a fresh session when a cold start fails and the user retries", async () => {
|
||||
let startAttempts = 0;
|
||||
const actions: string[] = [];
|
||||
invokeMock.mockImplementation(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === "get_process_context") {
|
||||
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
|
||||
}
|
||||
if (command === "chat_session_command") {
|
||||
const request = args?.request as
|
||||
| { action?: string; config?: { sessionId?: string } }
|
||||
| undefined;
|
||||
actions.push(request?.action ?? "unknown");
|
||||
if (request?.action === "start") {
|
||||
startAttempts += 1;
|
||||
if (startAttempts === 1) throw new Error("start failed");
|
||||
return { sessionId: request.config?.sessionId };
|
||||
}
|
||||
if (request?.action === "send") {
|
||||
return {
|
||||
ok: true,
|
||||
result: { text: "Recovered", finishReason: "completed" },
|
||||
};
|
||||
}
|
||||
return { promptsInQueue: [] };
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => current.sendPrompt("First attempt"));
|
||||
expect(current.status).toBe("error");
|
||||
await act(async () => current.sendPrompt("Retry"));
|
||||
|
||||
expect(actions.filter((action) => action !== "pending_prompts")).toEqual([
|
||||
"start",
|
||||
"start",
|
||||
"send",
|
||||
]);
|
||||
expect(
|
||||
current.messages.some((message) => message.content === "Recovered"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to process context when the remembered workspace is stale", async () => {
|
||||
await act(async () => root.unmount());
|
||||
window.localStorage.setItem(
|
||||
"cline.code.workspace-selection.v1",
|
||||
JSON.stringify({
|
||||
lastWorkspace: "/workspace/deleted",
|
||||
workspaces: ["/workspace/deleted"],
|
||||
}),
|
||||
);
|
||||
invokeMock.mockImplementation(async (command: string) => {
|
||||
if (command === "get_process_context") {
|
||||
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
|
||||
}
|
||||
if (command === "validate_workspace_directory") {
|
||||
return { valid: false };
|
||||
}
|
||||
return [];
|
||||
});
|
||||
root = createRoot(container);
|
||||
await act(async () => root.render(<HookHarness />));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(current.config.workspaceRoot).toBe("/workspace/cline");
|
||||
expect(current.config.cwd).toBe("/workspace/cline");
|
||||
});
|
||||
expect(invokeMock).toHaveBeenCalledWith("validate_workspace_directory", {
|
||||
path: "/workspace/deleted",
|
||||
});
|
||||
});
|
||||
|
||||
it("applies a remembered workspace that becomes available while process context is loading", async () => {
|
||||
await act(async () => root.unmount());
|
||||
let resolveContext:
|
||||
| ((value: { cwd: string; workspaceRoot: string }) => void)
|
||||
| undefined;
|
||||
const contextResponse = new Promise<{
|
||||
cwd: string;
|
||||
workspaceRoot: string;
|
||||
}>((resolve) => {
|
||||
resolveContext = resolve;
|
||||
});
|
||||
invokeMock.mockImplementation(async (command: string) => {
|
||||
if (command === "get_process_context") {
|
||||
return await contextResponse;
|
||||
}
|
||||
if (command === "validate_workspace_directory") {
|
||||
return { valid: true };
|
||||
}
|
||||
return [];
|
||||
});
|
||||
root = createRoot(container);
|
||||
await act(async () => root.render(<HookHarness />));
|
||||
await vi.waitFor(() => {
|
||||
expect(invokeMock).toHaveBeenCalledWith("get_process_context");
|
||||
});
|
||||
window.localStorage.setItem(
|
||||
"cline.code.workspace-selection.v1",
|
||||
JSON.stringify({
|
||||
lastWorkspace: "/workspace/remembered",
|
||||
workspaces: ["/workspace/remembered"],
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
resolveContext?.({
|
||||
cwd: "/workspace/default",
|
||||
workspaceRoot: "/workspace/default",
|
||||
});
|
||||
await contextResponse;
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(current.config.workspaceRoot).toBe("/workspace/remembered");
|
||||
expect(current.config.cwd).toBe("/workspace/remembered");
|
||||
});
|
||||
expect(invokeMock).toHaveBeenCalledWith("validate_workspace_directory", {
|
||||
path: "/workspace/remembered",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves a workspace selected while process context is loading", async () => {
|
||||
await act(async () => root.unmount());
|
||||
let resolveContext:
|
||||
| ((value: { cwd: string; workspaceRoot: string }) => void)
|
||||
| undefined;
|
||||
const contextResponse = new Promise<{
|
||||
cwd: string;
|
||||
workspaceRoot: string;
|
||||
}>((resolve) => {
|
||||
resolveContext = resolve;
|
||||
});
|
||||
invokeMock.mockImplementation(async (command: string) => {
|
||||
if (command === "get_process_context") return await contextResponse;
|
||||
return [];
|
||||
});
|
||||
root = createRoot(container);
|
||||
await act(async () => root.render(<HookHarness />));
|
||||
await act(async () => {
|
||||
current.setConfig((previous) => ({
|
||||
...previous,
|
||||
workspaceRoot: "/workspace/selected",
|
||||
cwd: "/workspace/selected",
|
||||
}));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveContext?.({
|
||||
cwd: "/workspace/default",
|
||||
workspaceRoot: "/workspace/default",
|
||||
});
|
||||
await contextResponse;
|
||||
});
|
||||
expect(current.config.workspaceRoot).toBe("/workspace/selected");
|
||||
expect(current.config.cwd).toBe("/workspace/selected");
|
||||
});
|
||||
});
|
||||
@@ -34,15 +34,19 @@ import {
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import {
|
||||
buildSessionDiffState,
|
||||
type SessionHookEvent,
|
||||
EMPTY_DIFF_SUMMARY,
|
||||
type SessionDiffSummary,
|
||||
type SessionFileDiff,
|
||||
type SessionHookEvent,
|
||||
} from "@/lib/session-diff";
|
||||
import type {
|
||||
SessionHistoryItem,
|
||||
SessionHistoryStatus,
|
||||
} from "@/lib/session-history";
|
||||
import {
|
||||
normalizeWorkspacePath,
|
||||
readWorkspaceSelectionFromWindow,
|
||||
} from "@/lib/workspace-paths";
|
||||
|
||||
export { DEFAULT_CHAT_CONFIG } from "@/hooks/chat-session/constants";
|
||||
|
||||
@@ -240,6 +244,9 @@ export function useChatSession() {
|
||||
null,
|
||||
);
|
||||
const hydrationRequestIdRef = useRef(0);
|
||||
const sessionStartPromiseRef = useRef<Promise<string> | null>(null);
|
||||
const promptDispatchTailRef = useRef<Promise<void>>(Promise.resolve());
|
||||
const activePromptSubmissionsRef = useRef(0);
|
||||
const [chatTransportState, setChatTransportState] =
|
||||
useState<ChatTransportState>(desktopClient.getTransportState());
|
||||
const [chatTransportError, setChatTransportError] = useState<string | null>(
|
||||
@@ -468,11 +475,33 @@ export function useChatSession() {
|
||||
const ctx = await desktopClient.invoke<ProcessContext>(
|
||||
"get_process_context",
|
||||
);
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
workspaceRoot: ctx.workspaceRoot || ctx.cwd,
|
||||
cwd: ctx.workspaceRoot || ctx.cwd,
|
||||
}));
|
||||
const rememberedWorkspace =
|
||||
readWorkspaceSelectionFromWindow().lastWorkspace;
|
||||
const validation = rememberedWorkspace
|
||||
? await desktopClient
|
||||
.invoke<{ valid?: boolean }>("validate_workspace_directory", {
|
||||
path: rememberedWorkspace,
|
||||
})
|
||||
.catch(() => ({ valid: false }))
|
||||
: { valid: false };
|
||||
setConfig((prev) => {
|
||||
const currentWorkspace = (prev.workspaceRoot || prev.cwd || "").trim();
|
||||
const selectionChangedWhileLoading = Boolean(
|
||||
currentWorkspace &&
|
||||
normalizeWorkspacePath(currentWorkspace) !==
|
||||
normalizeWorkspacePath(rememberedWorkspace),
|
||||
);
|
||||
const workspace = selectionChangedWhileLoading
|
||||
? currentWorkspace
|
||||
: validation.valid === true
|
||||
? rememberedWorkspace
|
||||
: ctx.workspaceRoot || ctx.cwd;
|
||||
return {
|
||||
...prev,
|
||||
workspaceRoot: workspace,
|
||||
cwd: workspace,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
// Ignore in non-Tauri mode.
|
||||
}
|
||||
@@ -914,7 +943,10 @@ export function useChatSession() {
|
||||
// ---- Shared: start a new session via RPC ----
|
||||
|
||||
const startSession = useCallback(
|
||||
async (validatedConfig: ChatSessionConfig): Promise<string> => {
|
||||
async (
|
||||
validatedConfig: ChatSessionConfig,
|
||||
options: { preserveStatus?: boolean } = {},
|
||||
): Promise<string> => {
|
||||
const payload = await postSession({
|
||||
action: "start",
|
||||
config: validatedConfig,
|
||||
@@ -925,7 +957,9 @@ export function useChatSession() {
|
||||
// Mark idle — not running — so the first sendPrompt is not queued.
|
||||
// The status transitions to "starting"/"running" once a prompt is
|
||||
// actually dispatched.
|
||||
setStatus("idle");
|
||||
if (!options.preserveStatus) {
|
||||
setStatus("idle");
|
||||
}
|
||||
setConfig(validatedConfig);
|
||||
setHydratedHistorySessionId(null);
|
||||
return id;
|
||||
@@ -987,7 +1021,8 @@ export function useChatSession() {
|
||||
setIsHydratingSession(false);
|
||||
abortedRef.current = false;
|
||||
clearAbortFallbackTimeout();
|
||||
let activeSessionId = sessionId;
|
||||
const pendingSessionStart = sessionStartPromiseRef.current;
|
||||
let activeSessionId = sessionId ?? activeSessionIdRef.current;
|
||||
|
||||
const validation = validateConfig(config);
|
||||
if (!validation.parsed) {
|
||||
@@ -995,51 +1030,67 @@ export function useChatSession() {
|
||||
return;
|
||||
}
|
||||
const parsed = validation.parsed;
|
||||
|
||||
if (activeSessionId && hydratedHistorySessionId === activeSessionId) {
|
||||
try {
|
||||
activeSessionId = await startSession({
|
||||
...parsed,
|
||||
sessionId: activeSessionId,
|
||||
});
|
||||
} catch (err) {
|
||||
setErrorState(errorMessage(err), activeSessionId);
|
||||
return;
|
||||
const hasEarlierPromptSubmission = activePromptSubmissionsRef.current > 0;
|
||||
activePromptSubmissionsRef.current += 1;
|
||||
let promptSubmissionFinished = false;
|
||||
const finishPromptSubmission = () => {
|
||||
if (promptSubmissionFinished) return;
|
||||
promptSubmissionFinished = true;
|
||||
activePromptSubmissionsRef.current = Math.max(
|
||||
0,
|
||||
activePromptSubmissionsRef.current - 1,
|
||||
);
|
||||
};
|
||||
const precedingPromptDispatch = promptDispatchTailRef.current;
|
||||
let resolvePromptDispatch: (() => void) | undefined;
|
||||
const ownPromptDispatch = new Promise<void>((resolve) => {
|
||||
resolvePromptDispatch = resolve;
|
||||
});
|
||||
const promptDispatchTail = precedingPromptDispatch.then(
|
||||
() => ownPromptDispatch,
|
||||
);
|
||||
promptDispatchTailRef.current = promptDispatchTail;
|
||||
let promptDispatchReleased = false;
|
||||
const releasePromptDispatch = () => {
|
||||
if (promptDispatchReleased) return;
|
||||
promptDispatchReleased = true;
|
||||
resolvePromptDispatch?.();
|
||||
};
|
||||
void promptDispatchTail.then(() => {
|
||||
if (promptDispatchTailRef.current === promptDispatchTail) {
|
||||
promptDispatchTailRef.current = Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeSessionId) {
|
||||
try {
|
||||
activeSessionId = await startSession(parsed);
|
||||
} catch (err) {
|
||||
setErrorState(errorMessage(err));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
const now = Date.now();
|
||||
const shouldQueue = Boolean(activeSessionId) && BUSY_STATUSES.has(status);
|
||||
const serializedAttachments = await serializeAttachments(attachedFiles);
|
||||
const hasAttachments =
|
||||
serializedAttachments.userImages.length > 0 ||
|
||||
serializedAttachments.userFiles.length > 0;
|
||||
|
||||
const userLabel = hasAttachments
|
||||
? `${trimmed}${trimmed.length > 0 ? "\n\n" : ""}[attached ${attachedFiles.length} file${attachedFiles.length === 1 ? "" : "s"}]`
|
||||
: trimmed;
|
||||
const serializedAttachmentsTask = serializeAttachments(
|
||||
attachedFiles,
|
||||
).then(
|
||||
(attachments) => ({ ok: true as const, attachments }),
|
||||
(error: unknown) => ({ ok: false as const, error }),
|
||||
);
|
||||
const userLabel =
|
||||
attachedFiles.length > 0
|
||||
? `${trimmed}${trimmed.length > 0 ? "\n\n" : ""}[attached ${attachedFiles.length} file${attachedFiles.length === 1 ? "" : "s"}]`
|
||||
: trimmed;
|
||||
const shouldQueue =
|
||||
Boolean(activeSessionId) &&
|
||||
(hasEarlierPromptSubmission ||
|
||||
Boolean(pendingSessionStart) ||
|
||||
BUSY_STATUSES.has(status));
|
||||
const optimisticQueuedPromptId = shouldQueue
|
||||
? makeId("queued_prompt")
|
||||
: null;
|
||||
const plannedSessionId = activeSessionId ?? makeId("session");
|
||||
|
||||
if (!shouldQueue) {
|
||||
addMessage({
|
||||
id: makeId("user"),
|
||||
sessionId: activeSessionId,
|
||||
sessionId: plannedSessionId,
|
||||
role: "user",
|
||||
content: userLabel,
|
||||
createdAt: now,
|
||||
});
|
||||
activeSessionIdRef.current = activeSessionId;
|
||||
activeSessionIdRef.current = plannedSessionId;
|
||||
activeAssistantMessageIdRef.current = null;
|
||||
setActiveAssistantMessageId(null);
|
||||
clearLiveToolRefs();
|
||||
@@ -1054,8 +1105,90 @@ export function useChatSession() {
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
let sendTask: ReturnType<typeof postSession> | null = null;
|
||||
try {
|
||||
const payload = await postSession({
|
||||
if (pendingSessionStart) {
|
||||
try {
|
||||
activeSessionId = await pendingSessionStart;
|
||||
} catch (err) {
|
||||
if (optimisticQueuedPromptId) {
|
||||
setPromptsInQueue((prev) =>
|
||||
prev.filter((item) => item.id !== optimisticQueuedPromptId),
|
||||
);
|
||||
}
|
||||
setErrorState(errorMessage(err), activeSessionId);
|
||||
finishPromptSubmission();
|
||||
return;
|
||||
}
|
||||
} else if (
|
||||
activeSessionId &&
|
||||
hydratedHistorySessionId === activeSessionId
|
||||
) {
|
||||
const startPromise = startSession(
|
||||
{
|
||||
...parsed,
|
||||
sessionId: activeSessionId,
|
||||
},
|
||||
{ preserveStatus: true },
|
||||
);
|
||||
sessionStartPromiseRef.current = startPromise;
|
||||
try {
|
||||
activeSessionId = await startPromise;
|
||||
} catch (err) {
|
||||
setErrorState(errorMessage(err), activeSessionId);
|
||||
finishPromptSubmission();
|
||||
return;
|
||||
} finally {
|
||||
if (sessionStartPromiseRef.current === startPromise) {
|
||||
sessionStartPromiseRef.current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeSessionId) {
|
||||
const startPromise = startSession(
|
||||
{
|
||||
...parsed,
|
||||
sessionId: plannedSessionId,
|
||||
},
|
||||
{ preserveStatus: true },
|
||||
);
|
||||
sessionStartPromiseRef.current = startPromise;
|
||||
try {
|
||||
activeSessionId = await startPromise;
|
||||
} catch (err) {
|
||||
if (activeSessionIdRef.current === plannedSessionId) {
|
||||
activeSessionIdRef.current = null;
|
||||
}
|
||||
setErrorState(errorMessage(err));
|
||||
finishPromptSubmission();
|
||||
return;
|
||||
} finally {
|
||||
if (sessionStartPromiseRef.current === startPromise) {
|
||||
sessionStartPromiseRef.current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
const serializedAttachmentsResult = await serializedAttachmentsTask;
|
||||
if (!serializedAttachmentsResult.ok) {
|
||||
setErrorState(
|
||||
errorMessage(serializedAttachmentsResult.error),
|
||||
activeSessionId,
|
||||
);
|
||||
finishPromptSubmission();
|
||||
return;
|
||||
}
|
||||
const serializedAttachments = serializedAttachmentsResult.attachments;
|
||||
const hasAttachments =
|
||||
serializedAttachments.userImages.length > 0 ||
|
||||
serializedAttachments.userFiles.length > 0;
|
||||
if (!shouldQueue) {
|
||||
activeSessionIdRef.current = activeSessionId;
|
||||
setStatus("starting");
|
||||
}
|
||||
await precedingPromptDispatch;
|
||||
sendTask = postSession({
|
||||
action: "send",
|
||||
sessionId: activeSessionId,
|
||||
prompt: trimmed,
|
||||
@@ -1063,6 +1196,15 @@ export function useChatSession() {
|
||||
config: parsed,
|
||||
attachments: hasAttachments ? serializedAttachments : undefined,
|
||||
});
|
||||
} finally {
|
||||
releasePromptDispatch();
|
||||
}
|
||||
if (!sendTask) {
|
||||
finishPromptSubmission();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = await sendTask;
|
||||
if (payload.ok && payload.queued) {
|
||||
applyPromptsInQueue(payload.promptsInQueue);
|
||||
setStatus("running");
|
||||
@@ -1266,6 +1408,7 @@ export function useChatSession() {
|
||||
setActiveAssistantMessageId(null);
|
||||
clearLiveToolRefs();
|
||||
}
|
||||
finishPromptSubmission();
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -1431,6 +1574,7 @@ export function useChatSession() {
|
||||
sessionId: undefined,
|
||||
}));
|
||||
activeSessionIdRef.current = null;
|
||||
sessionStartPromiseRef.current = null;
|
||||
activeAssistantMessageIdRef.current = null;
|
||||
setActiveAssistantMessageId(null);
|
||||
setHydratedHistorySessionId(null);
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface SessionThread {
|
||||
id: string;
|
||||
title: string;
|
||||
codebase: string;
|
||||
workspacePath: string;
|
||||
time: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
@@ -224,10 +225,12 @@ function inferStatusFromMessages(
|
||||
}
|
||||
|
||||
function toThread(session: SessionHistoryItem): SessionThread {
|
||||
const workspacePath = (session.workspaceRoot || session.cwd).trim();
|
||||
return {
|
||||
id: session.sessionId,
|
||||
title: toTitle(session),
|
||||
codebase: basenamePath(session.workspaceRoot || session.cwd),
|
||||
codebase: basenamePath(workspacePath),
|
||||
workspacePath,
|
||||
time: formatRelativeTime(session.endedAt || session.startedAt),
|
||||
provider: session.provider || "",
|
||||
model: session.model || "",
|
||||
@@ -355,6 +358,7 @@ function areThreadsEquivalent(
|
||||
a.id !== b.id ||
|
||||
a.title !== b.title ||
|
||||
a.codebase !== b.codebase ||
|
||||
a.workspacePath !== b.workspacePath ||
|
||||
a.time !== b.time ||
|
||||
a.provider !== b.provider ||
|
||||
a.model !== b.model ||
|
||||
@@ -1222,6 +1226,10 @@ export function useSessionHistory({
|
||||
},
|
||||
[refreshSessions],
|
||||
);
|
||||
const loadOlderSessions = useCallback(
|
||||
() => loadMoreSessions(fetchLimitRef.current + INITIAL_HISTORY_FETCH_LIMIT),
|
||||
[loadMoreSessions],
|
||||
);
|
||||
|
||||
const mayHaveMoreSessions = sessions.length >= fetchLimitRef.current;
|
||||
const sessionById = useMemo(
|
||||
@@ -1233,6 +1241,7 @@ export function useSessionHistory({
|
||||
getSessionByThreadId,
|
||||
isLoadingHistory,
|
||||
isLoadingMore,
|
||||
loadOlderSessions,
|
||||
loadMoreSessions,
|
||||
mayHaveMoreSessions,
|
||||
openThread,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SessionThread } from "@/hooks/use-session-history";
|
||||
import {
|
||||
groupThreadsByProject,
|
||||
workspaceDisplayName,
|
||||
} from "./sidebar-session-organization";
|
||||
|
||||
function thread(
|
||||
id: string,
|
||||
workspacePath: string,
|
||||
overrides: Partial<SessionThread> = {},
|
||||
): SessionThread {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
codebase: workspaceDisplayName(workspacePath),
|
||||
workspacePath,
|
||||
time: "now",
|
||||
provider: "cline",
|
||||
model: "test-model",
|
||||
status: "completed",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("sidebar session organization", () => {
|
||||
it("groups every loaded thread before applying per-project visibility", () => {
|
||||
const threads = [
|
||||
...Array.from({ length: 12 }, (_, index) =>
|
||||
thread(`alpha-${index + 1}`, "/work/acme/repo"),
|
||||
),
|
||||
thread("beta-1", "/work/other/repo"),
|
||||
];
|
||||
|
||||
const groups = groupThreadsByProject(threads);
|
||||
|
||||
expect(groups.map((group) => group.label)).toEqual([
|
||||
"acme/repo",
|
||||
"other/repo",
|
||||
]);
|
||||
expect(groups[0]?.threads).toHaveLength(12);
|
||||
expect(groups[1]?.threads.map((item) => item.id)).toEqual(["beta-1"]);
|
||||
});
|
||||
|
||||
it("uses the repository directory instead of the full workspace path", () => {
|
||||
expect(workspaceDisplayName("/Users/saoud/code/cline/")).toBe("cline");
|
||||
expect(workspaceDisplayName("C:\\Users\\saoud\\code\\cline\\")).toBe(
|
||||
"cline",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { SessionThread } from "@/hooks/use-session-history";
|
||||
import { normalizeWorkspacePath } from "@/lib/workspace-paths";
|
||||
|
||||
export const INITIAL_VISIBLE_THREAD_COUNT = 10;
|
||||
|
||||
export type SidebarProjectGroup = {
|
||||
id: string;
|
||||
label: string;
|
||||
workspacePath: string;
|
||||
threads: SessionThread[];
|
||||
};
|
||||
|
||||
export function workspaceDisplayName(path: string): string {
|
||||
const trimmed = path.trim().replace(/[\\/]+$/, "");
|
||||
if (!trimmed) return "";
|
||||
const segments = trimmed.split(/[\\/]/).filter(Boolean);
|
||||
return segments.at(-1) || trimmed;
|
||||
}
|
||||
|
||||
function uniqueWorkspaceLabel(path: string, workspacePaths: string[]): string {
|
||||
if (!path) return "Other";
|
||||
const segments = path
|
||||
.replace(/[\\/]+$/, "")
|
||||
.split(/[\\/]/)
|
||||
.filter(Boolean);
|
||||
const allSegments = workspacePaths.map((workspacePath) =>
|
||||
workspacePath
|
||||
.replace(/[\\/]+$/, "")
|
||||
.split(/[\\/]/)
|
||||
.filter(Boolean),
|
||||
);
|
||||
for (let depth = 1; depth <= segments.length; depth += 1) {
|
||||
const candidate = segments.slice(-depth).join("/");
|
||||
const matches = allSegments.filter(
|
||||
(other) => other.slice(-depth).join("/") === candidate,
|
||||
).length;
|
||||
if (matches === 1) return candidate;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
export function groupThreadsByProject(
|
||||
threads: SessionThread[],
|
||||
): SidebarProjectGroup[] {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{ workspacePath: string; threads: SessionThread[] }
|
||||
>();
|
||||
for (const thread of threads) {
|
||||
const workspacePath = thread.workspacePath.trim();
|
||||
const projectId = normalizeWorkspacePath(workspacePath) || "__other__";
|
||||
const current = groups.get(projectId);
|
||||
if (current) current.threads.push(thread);
|
||||
else groups.set(projectId, { workspacePath, threads: [thread] });
|
||||
}
|
||||
const workspacePaths = [...groups.values()].map(
|
||||
(group) => group.workspacePath,
|
||||
);
|
||||
return [...groups.entries()].map(([id, group]) => ({
|
||||
id,
|
||||
label: uniqueWorkspaceLabel(group.workspacePath, workspacePaths),
|
||||
workspacePath: group.workspacePath,
|
||||
threads: group.threads,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
mergeWorkspacePaths,
|
||||
normalizeWorkspacePath,
|
||||
parseWorkspaceSelectionStorage,
|
||||
workspacePathsFromSessions,
|
||||
} from "./workspace-paths";
|
||||
|
||||
describe("workspace paths", () => {
|
||||
it("normalizes trailing separators and Windows path casing", () => {
|
||||
expect(normalizeWorkspacePath(" /workspace/cline/ ")).toBe(
|
||||
"/workspace/cline",
|
||||
);
|
||||
expect(normalizeWorkspacePath("C:\\Users\\Saoud\\Cline\\")).toBe(
|
||||
"c:\\users\\saoud\\cline",
|
||||
);
|
||||
expect(normalizeWorkspacePath("/")).toBe("/");
|
||||
});
|
||||
|
||||
it("retains known projects when discovery returns an incomplete subset", () => {
|
||||
const known = ["/projects/a", "/projects/b", "/projects/c", "/projects/d"];
|
||||
const afterFirstPick = mergeWorkspacePaths(known, [
|
||||
"/projects/e",
|
||||
"/projects/a/",
|
||||
]);
|
||||
const afterSecondPick = mergeWorkspacePaths(afterFirstPick, [
|
||||
"/projects/f",
|
||||
"/projects/b",
|
||||
]);
|
||||
|
||||
expect(afterFirstPick).toEqual([
|
||||
"/projects/a",
|
||||
"/projects/b",
|
||||
"/projects/c",
|
||||
"/projects/d",
|
||||
"/projects/e",
|
||||
]);
|
||||
expect(afterSecondPick).toEqual([
|
||||
"/projects/a",
|
||||
"/projects/b",
|
||||
"/projects/c",
|
||||
"/projects/d",
|
||||
"/projects/e",
|
||||
"/projects/f",
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds the project catalog from every loaded history workspace", () => {
|
||||
const sessions = Array.from({ length: 25 }, (_, index) => ({
|
||||
workspaceRoot: `/projects/project-${String(index + 1).padStart(2, "0")}`,
|
||||
}));
|
||||
sessions.push({ workspaceRoot: "/projects/project-01/" });
|
||||
|
||||
const paths = workspacePathsFromSessions(sessions);
|
||||
|
||||
expect(paths).toHaveLength(25);
|
||||
expect(paths).toContain("/projects/project-25");
|
||||
});
|
||||
|
||||
it("restores the selected project and catalog across thread remounts", () => {
|
||||
expect(
|
||||
parseWorkspaceSelectionStorage(
|
||||
JSON.stringify({
|
||||
lastWorkspace: "/projects/selected/",
|
||||
workspaces: ["/projects/one", "/projects/selected"],
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
lastWorkspace: "/projects/selected/",
|
||||
workspaces: ["/projects/one", "/projects/selected"],
|
||||
});
|
||||
expect(parseWorkspaceSelectionStorage("not json")).toEqual({
|
||||
lastWorkspace: "",
|
||||
workspaces: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
export const WORKSPACE_SELECTION_STORAGE_KEY =
|
||||
"cline.code.workspace-selection.v1";
|
||||
|
||||
export type WorkspaceSelectionStorage = {
|
||||
lastWorkspace: string;
|
||||
workspaces: string[];
|
||||
};
|
||||
|
||||
export type WorkspacePathSource = {
|
||||
cwd?: string;
|
||||
workspaceRoot?: string;
|
||||
};
|
||||
|
||||
export function normalizeWorkspacePath(path: string): string {
|
||||
const trimmed = path.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
const withoutTrailingSeparators = trimmed.replace(/[\\/]+$/, "");
|
||||
const normalized = withoutTrailingSeparators || trimmed[0] || "";
|
||||
return /^[A-Za-z]:/.test(normalized) ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
export function mergeWorkspacePaths(
|
||||
...pathGroups: ReadonlyArray<readonly string[]>
|
||||
): string[] {
|
||||
const byNormalizedPath = new Map<string, string>();
|
||||
for (const paths of pathGroups) {
|
||||
for (const path of paths) {
|
||||
const trimmed = path.trim();
|
||||
const normalized = normalizeWorkspacePath(trimmed);
|
||||
if (normalized && !byNormalizedPath.has(normalized)) {
|
||||
byNormalizedPath.set(normalized, trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...byNormalizedPath.values()].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
export function workspacePathsFromSessions(
|
||||
sessions: readonly WorkspacePathSource[],
|
||||
): string[] {
|
||||
return mergeWorkspacePaths(
|
||||
sessions.map((session) => session.workspaceRoot || session.cwd || ""),
|
||||
);
|
||||
}
|
||||
|
||||
export function parseWorkspaceSelectionStorage(
|
||||
raw: string | null,
|
||||
): WorkspaceSelectionStorage {
|
||||
if (!raw) {
|
||||
return { lastWorkspace: "", workspaces: [] };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as {
|
||||
lastWorkspace?: unknown;
|
||||
workspaces?: unknown;
|
||||
};
|
||||
const lastWorkspace =
|
||||
typeof parsed?.lastWorkspace === "string"
|
||||
? parsed.lastWorkspace.trim()
|
||||
: "";
|
||||
const workspaces = Array.isArray(parsed?.workspaces)
|
||||
? parsed.workspaces.filter(
|
||||
(workspace): workspace is string => typeof workspace === "string",
|
||||
)
|
||||
: [];
|
||||
return {
|
||||
lastWorkspace,
|
||||
workspaces: mergeWorkspacePaths(workspaces, [lastWorkspace]),
|
||||
};
|
||||
} catch {
|
||||
return { lastWorkspace: "", workspaces: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export function readWorkspaceSelectionFromWindow(): WorkspaceSelectionStorage {
|
||||
if (typeof window === "undefined") {
|
||||
return { lastWorkspace: "", workspaces: [] };
|
||||
}
|
||||
try {
|
||||
return parseWorkspaceSelectionStorage(
|
||||
window.localStorage.getItem(WORKSPACE_SELECTION_STORAGE_KEY),
|
||||
);
|
||||
} catch {
|
||||
return { lastWorkspace: "", workspaces: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export function writeWorkspaceSelectionToWindow(
|
||||
value: WorkspaceSelectionStorage,
|
||||
): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
WORKSPACE_SELECTION_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
lastWorkspace: value.lastWorkspace.trim(),
|
||||
workspaces: mergeWorkspacePaths(value.workspaces, [
|
||||
value.lastWorkspace,
|
||||
]),
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// Keep workspace switching functional when storage is unavailable.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 466.73 487.04">
|
||||
<path d="M463.6,275.08l-29.26-58.75v-33.83c0-56.08-45.01-101.5-100.53-101.5h-50.01c3.62-7.43,5.61-15.79,5.61-24.61,0-31.17-25.08-56.39-56.07-56.39s-56.07,25.22-56.07,56.39c0,8.82,1.99,17.17,5.61,24.61h-50.01c-55.51,0-100.52,45.42-100.52,101.5v33.83l-29.87,58.59c-3.01,5.9-3.01,12.92,0,18.81l29.87,57.93v33.83c0,56.08,45.01,101.5,100.52,101.5h200.95c55.51,0,100.53-45.42,100.53-101.5v-33.83l29.21-58.13c2.9-5.79,2.9-12.61.05-18.46ZM202.75,322.96c0,25.48-20.54,46.14-45.88,46.14s-45.88-20.66-45.88-46.14v-82.02c0-25.48,20.54-46.14,45.88-46.14s45.88,20.66,45.88,46.14v82.02ZM350.58,322.96c0,25.48-20.54,46.14-45.88,46.14s-45.88-20.66-45.88-46.14v-82.02c0-25.48,20.54-46.14,45.88-46.14s45.88,20.66,45.88,46.14v82.02Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 847 B |
@@ -0,0 +1,10 @@
|
||||
# Cline web visual foundation
|
||||
|
||||
The shared visual contract now lives in the internal
|
||||
[`@cline/ui`](../../../../../sdk/packages/ui/README.md) workspace package
|
||||
instead of beside the desktop app.
|
||||
|
||||
The desktop imports the complete `@cline/ui/theme/index.css` entry point. Other
|
||||
Cline surfaces can import `@cline/ui/theme/tokens.css` without React or
|
||||
Tailwind, or compose the Tailwind adapter and optional base styles in order.
|
||||
Consuming apps still own their font files and shell-specific layout.
|
||||
@@ -1,189 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "@fontsource-variable/schibsted-grotesk";
|
||||
@import "@fontsource/azeret-mono/latin.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--font-desktop-sans: "Schibsted Grotesk Variable";
|
||||
--font-desktop-mono: "Azeret Mono";
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.145 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.145 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.985 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.396 0.141 25.723);
|
||||
--destructive-foreground: oklch(0.637 0.237 25.331);
|
||||
--border: oklch(0.269 0 0);
|
||||
--input: oklch(0.269 0 0);
|
||||
--ring: oklch(0.439 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(0.269 0 0);
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: var(--font-desktop-sans), sans-serif;
|
||||
--font-mono:
|
||||
var(--font-desktop-mono), ui-monospace, "SFMono-Regular", Menlo, Consolas,
|
||||
"Liberation Mono", monospace;
|
||||
--font-weight-normal: 480;
|
||||
--font-weight-medium: 560;
|
||||
--font-weight-semibold: 640;
|
||||
--font-weight-bold: 640;
|
||||
--text-step-1: 12px;
|
||||
--text-step-1--line-height: 16px;
|
||||
--text-step-1--letter-spacing: 0.0025em;
|
||||
--text-step-2: 14px;
|
||||
--text-step-2--line-height: 20px;
|
||||
--text-step-2--letter-spacing: 0em;
|
||||
--text-step-3: 16px;
|
||||
--text-step-3--line-height: 24px;
|
||||
--text-step-3--letter-spacing: 0em;
|
||||
--text-step-4: 18px;
|
||||
--text-step-4--line-height: 26px;
|
||||
--text-step-4--letter-spacing: -0.0025em;
|
||||
--text-step-5: 20px;
|
||||
--text-step-5--line-height: 28px;
|
||||
--text-step-5--letter-spacing: -0.005em;
|
||||
--text-step-6: 24px;
|
||||
--text-step-6--line-height: 30px;
|
||||
--text-step-6--letter-spacing: -0.00625em;
|
||||
--text-step-7: 28px;
|
||||
--text-step-7--line-height: 36px;
|
||||
--text-step-7--letter-spacing: -0.0075em;
|
||||
--text-step-8: 35px;
|
||||
--text-step-8--line-height: 40px;
|
||||
--text-step-8--letter-spacing: -0.01em;
|
||||
--text-step-9: 60px;
|
||||
--text-step-9--line-height: 60px;
|
||||
--text-step-9--letter-spacing: -0.025em;
|
||||
--text-xs: var(--text-step-1);
|
||||
--text-xs--line-height: var(--text-step-1--line-height);
|
||||
--text-xs--letter-spacing: var(--text-step-1--letter-spacing);
|
||||
--text-sm: var(--text-step-2);
|
||||
--text-sm--line-height: var(--text-step-2--line-height);
|
||||
--text-sm--letter-spacing: var(--text-step-2--letter-spacing);
|
||||
--text-base: var(--text-step-3);
|
||||
--text-base--line-height: var(--text-step-3--line-height);
|
||||
--text-base--letter-spacing: var(--text-step-3--letter-spacing);
|
||||
--text-lg: var(--text-step-4);
|
||||
--text-lg--line-height: var(--text-step-4--line-height);
|
||||
--text-lg--letter-spacing: var(--text-step-4--letter-spacing);
|
||||
--text-xl: var(--text-step-5);
|
||||
--text-xl--line-height: var(--text-step-5--line-height);
|
||||
--text-xl--letter-spacing: var(--text-step-5--letter-spacing);
|
||||
--text-2xl: var(--text-step-6);
|
||||
--text-2xl--line-height: var(--text-step-6--line-height);
|
||||
--text-2xl--letter-spacing: var(--text-step-6--letter-spacing);
|
||||
--text-3xl: var(--text-step-7);
|
||||
--text-3xl--line-height: var(--text-step-7--line-height);
|
||||
--text-3xl--letter-spacing: var(--text-step-7--letter-spacing);
|
||||
--text-4xl: var(--text-step-8);
|
||||
--text-4xl--line-height: var(--text-step-8--line-height);
|
||||
--text-4xl--letter-spacing: var(--text-step-8--letter-spacing);
|
||||
--text-6xl: var(--text-step-9);
|
||||
--text-6xl--line-height: var(--text-step-9--line-height);
|
||||
--text-6xl--letter-spacing: var(--text-step-9--letter-spacing);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-base font-normal text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import tailwindcss from "@tailwindcss/postcss";
|
||||
import postcss from "postcss";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("@cline/ui theme integration", () => {
|
||||
it("compiles the shared theme and standard Tailwind utilities", async () => {
|
||||
const from = fileURLToPath(new URL("./theme-fixture.css", import.meta.url));
|
||||
const result = await postcss([tailwindcss()]).process(
|
||||
[
|
||||
'@import "tailwindcss";',
|
||||
'@import "@cline/ui/theme/index.css";',
|
||||
'@source inline("bg-background bg-primary-emphasis font-sans text-xs");',
|
||||
].join("\n"),
|
||||
{ from },
|
||||
);
|
||||
|
||||
expect(result.css).toContain("--text-xs: 12px");
|
||||
expect(result.css).toContain("--font-weight-normal: 480");
|
||||
expect(result.css).toContain('--font-sans: "Schibsted Grotesk Variable"');
|
||||
expect(result.css).toContain("--primary-emphasis:");
|
||||
expect(result.css).toContain(".bg-background");
|
||||
expect(result.css).toContain(".bg-primary-emphasis");
|
||||
expect(result.css).toContain(".font-sans");
|
||||
expect(result.css).toContain(".text-xs");
|
||||
expect(result.css).toContain(
|
||||
"letter-spacing: var(--tw-tracking, var(--text-xs--letter-spacing))",
|
||||
);
|
||||
expect(result.css).not.toContain("--cline-");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
# vscode-rollout — A/B loader for the SDK extension rollout
|
||||
|
||||
The VS Code Marketplace has no staged rollouts: publishing a version updates
|
||||
every user. This package lets us ship the **SDK-based extension** (main's
|
||||
`apps/vscode`, "next") to a percentage of users while everyone else keeps
|
||||
running the **legacy extension** (the `legacy-extension` branch), inside a
|
||||
single published VSIX.
|
||||
|
||||
## How it works
|
||||
|
||||
The published VSIX contains a ~40 KB loader as its entrypoint and two complete,
|
||||
independently built extension bundles:
|
||||
|
||||
```
|
||||
extension.js ← loader (this package)
|
||||
package.json ← UNION of both bundles' manifests (generated, see below)
|
||||
assets/, walkthrough/ ← manifest-referenced resources (VSIX-root-relative)
|
||||
next/ ← SDK extension (dist/, webview-ui/build/, assets/)
|
||||
legacy/ ← legacy extension (dist/, webview-ui/build/, assets/, codicons)
|
||||
```
|
||||
|
||||
Per window, the loader:
|
||||
|
||||
1. Reads the cached cohort assignment from its own `globalState` keys —
|
||||
synchronously, never from the network.
|
||||
2. Sets the `cline.sdkBundle` context key (gates cohort-specific menu items /
|
||||
palette entries in the union manifest).
|
||||
3. `require()`s exactly one bundle and calls its `activate()` with a
|
||||
Proxy-wrapped `ExtensionContext` whose `extensionUri` / `extensionPath` /
|
||||
`asAbsolutePath` point into that bundle's subdirectory — so each bundle
|
||||
resolves its own webview build and assets without knowing it was relocated.
|
||||
Storage properties pass through untouched: both bundles share the same
|
||||
`~/.cline/data` + VS Code storage they used as standalone extensions.
|
||||
4. After the selected bundle activates, evaluates the PostHog flags in the
|
||||
background and caches the assignment **for the next window**. Flag changes
|
||||
never flip a live window. A crash fallback skips this refresh so it cannot
|
||||
overwrite the legacy pin.
|
||||
|
||||
If the next bundle throws during activation, the loader disposes whatever it
|
||||
half-registered, pins this VSIX version back to legacy
|
||||
(`cline.rollout.nextActivationFailedVersion`), reports a `fallback` telemetry
|
||||
event, and activates legacy — a crashed rollout self-heals without a
|
||||
marketplace re-publish. A new version gets to try next again.
|
||||
|
||||
## Cohort rules
|
||||
|
||||
- **Two-way, one knob.** `ext-sdk-bundle-rollout` (percentage flag) is the
|
||||
entire remote control surface: each background refresh caches exactly what
|
||||
the flag says for the machine's next window. Dialing the percentage up
|
||||
promotes; dialing it down demotes on the next reload — the emergency lever
|
||||
is simply "set the rollout to 0%". Known demotion costs (accepted): tasks
|
||||
created on the SDK bundle are stored as SDK sessions the legacy bundle
|
||||
doesn't list (they reappear on re-promotion — nothing is deleted), and
|
||||
credentials rotated on next may require a re-login on legacy.
|
||||
- **The flag must stay a boolean flag.** The loader only promotes on a
|
||||
literal `true` from `/decide` — a multivariate variant, number, or anything
|
||||
else fails safe to legacy (see `parseRolloutAssignment` + tests). Don't
|
||||
convert it to multivariate.
|
||||
- The flag is evaluated against the same PostHog distinct id the extension's
|
||||
telemetry uses (machine id, mirroring `src/services/logging/distinctId.ts`),
|
||||
so cohort membership is correlatable with telemetry. Flag evaluation is
|
||||
always on (matching `FeatureFlagsService`); the loader's own
|
||||
`extension.rollout.loader_decision` event respects the user's telemetry
|
||||
opt-out and VS Code's global telemetry switch.
|
||||
- **Manual overrides, in either direction.** The `cline.rollout.bundleOverride`
|
||||
user setting (`"auto" | "next" | "legacy"`, editable straight from
|
||||
settings.json) forces a bundle for anyone — users in a pinch, or us
|
||||
debugging — beating the remote assignment both ways. Applies on window
|
||||
reload. `CLINE_BUNDLE_OVERRIDE=next|legacy` (env var) does the same for
|
||||
local dev and e2e and beats even the setting. Both are reported as
|
||||
`override` on the loader event so overridden machines don't pollute
|
||||
cohort comparisons.
|
||||
- **Crash pinning is local, not remote.** If the next bundle throws during
|
||||
activation, the loader falls back to legacy in the same window and pins
|
||||
that VSIX version on this machine (`cline.rollout.nextActivationFailedVersion`);
|
||||
a new release gets to try next again. This safety net is independent of the
|
||||
flag.
|
||||
|
||||
## The union manifest
|
||||
|
||||
`package.json` contributions are static — VS Code reads them before any code
|
||||
runs — so the shipped manifest must serve both cohorts. `scripts/gen-manifest.mjs`
|
||||
regenerates it at stitch time from both branches' real manifests:
|
||||
|
||||
- Contributions declared by both bundles pass through untouched.
|
||||
- Menu entries / keybindings declared by only one get `when` AND-ed with
|
||||
`cline.sdkBundle` / `!cline.sdkBundle`, so a cohort never sees a button its
|
||||
bundle didn't register (and shared buttons that moved position don't render
|
||||
twice).
|
||||
- Commands exclusive to one bundle are hidden from the other cohort's command
|
||||
palette.
|
||||
- `views` / `viewsContainers` / `configuration` / `walkthroughs`
|
||||
**must be identical** in both manifests — they can't be safely gated at
|
||||
runtime, so divergence fails the build. Keep these static contributions in
|
||||
sync between the branches. `engines` may diverge: the union takes the newer
|
||||
requirement (which necessarily satisfies the older one).
|
||||
|
||||
Because the manifest is regenerated from both branches on every build,
|
||||
contribution drift between the branches can't ship silently — it either merges
|
||||
cleanly or the stitch fails.
|
||||
|
||||
## Building locally
|
||||
|
||||
```bash
|
||||
# 1. build both bundles (their own toolchains)
|
||||
cd apps/vscode && bun run package # next
|
||||
cd <legacy worktree>/apps/vscode && npm run package # legacy (npm ci first)
|
||||
|
||||
# 2. build the loader + stitch + package
|
||||
cd apps/vscode-rollout
|
||||
bun run build # dev build; CI uses build:production with the PostHog key
|
||||
node scripts/stitch.mjs \
|
||||
--next ../vscode --legacy <legacy worktree>/apps/vscode \
|
||||
--loader dist/extension.js --version 4.1.0 --out /tmp/cline-ab-staging
|
||||
node scripts/smoke-loader.mjs /tmp/cline-ab-staging # loader behavior smoke
|
||||
cd /tmp/cline-ab-staging && vsce package --no-dependencies --allow-package-secrets sendgrid
|
||||
```
|
||||
|
||||
The narrowly scoped `sendgrid` scanner exemption mirrors the existing next and
|
||||
legacy packaging workflows. This workflow supplies only the existing PostHog
|
||||
project-key inputs; it does not declare a SendGrid credential. Identify the
|
||||
exact matching string in production staging output before changing or
|
||||
broadening the exemption.
|
||||
|
||||
Local builds have no `TELEMETRY_SERVICE_API_KEY`, so the loader skips PostHog
|
||||
entirely and everyone stays on legacy unless `CLINE_BUNDLE_OVERRIDE` is set.
|
||||
|
||||
CI: the `ext-vscode-ab-package` workflow (manual dispatch) builds both refs,
|
||||
stitches, smoke-tests, uploads the `.vsix` artifact, and optionally publishes.
|
||||
|
||||
## Nightly channel
|
||||
|
||||
The daily `ext-vscode-publish-nightly` workflow (cron + manual dispatch)
|
||||
publishes this same combined package as **`saoudrizwan.cline-nightly`**. Before
|
||||
each bundle builds, `scripts/nightlify.mjs` rewrites its manifest to the
|
||||
nightly identity — the same mutation the standalone nightly always applied
|
||||
(`apps/vscode/scripts/publish-nightly.mjs` on both branches is the source of
|
||||
truth), so nightly can be installed alongside stable:
|
||||
|
||||
| | stable | nightly |
|
||||
|---|---|---|
|
||||
| manifest `name` | `claude-dev` | `cline-nightly` |
|
||||
| contribution IDs / context key / settings | `cline.*` | `cline-nightly.*` |
|
||||
| version | operator-supplied (4.1.0+) | `<major>.<minor>.<unix-seconds>` |
|
||||
|
||||
The loader derives the namespace from its own `packageJSON.name` at runtime
|
||||
(`idPrefix` in `src/cohort.ts`), and gen-manifest derives it from the next
|
||||
manifest's name — no build flags involved. Nightly builds also show a
|
||||
status-bar indicator (`Cline: Next` / `Cline: Legacy`); stable builds never do.
|
||||
|
||||
Dispatching the nightly workflow from `main` with `dry-run` builds and uploads
|
||||
the installable `.vsix` without publishing or tagging. The publish job is
|
||||
intentionally restricted to `main` by both the workflow and the
|
||||
`PublishNightly` environment's deployment-branch policy.
|
||||
|
||||
### Telemetry events
|
||||
|
||||
- **`extension.rollout.bundle_activated`** (authoritative, captured by the
|
||||
activated bundle's own telemetry via its `reportRolloutActivation` export;
|
||||
requires the bundle to be built with `CLINE_ROLLOUT_VARIANT`): attempted vs
|
||||
actual bundle, fallback flag, error details on fallback. Every other event
|
||||
from a rollout build carries `extension_variant` as a common property.
|
||||
- **`extension.rollout.loader_decision`** (loader-owned, direct capture): the
|
||||
loader-side metadata the bundle event can't know — override source, launch
|
||||
cadence, loader version, `extension_name` (nightly vs stable) — and the only
|
||||
signal when BOTH bundles fail (`double_failure: true`).
|
||||
|
||||
## Rollout runbook
|
||||
|
||||
Until the stable combined VSIX ships, the flag governs **nightly installs
|
||||
only** — dialing it is safe for production users and is the lever for moving
|
||||
nightly dogfooders onto next.
|
||||
|
||||
1. Create `ext-sdk-bundle-rollout` in PostHog **before** the first publish: a
|
||||
plain boolean release flag with a percentage rollout, starting at **0%**.
|
||||
(There is deliberately no kill-switch flag — the assignment is two-way, so
|
||||
0% *is* the kill switch.)
|
||||
2. Publish the combined VSIX (version above every previously published one).
|
||||
With the rollout at 0% this release is behaviorally identical to legacy for
|
||||
everyone — it only validates the loader plumbing in the wild. Watch
|
||||
`extension.rollout.bundle_activated` and `extension.rollout.loader_decision`.
|
||||
3. Dial `ext-sdk-bundle-rollout` up: 1% → 5% → 25% → 100%. Assignments apply on
|
||||
each machine's next window reload after its flag refresh, so propagation
|
||||
speed is bounded by how often people reload windows — watch the
|
||||
`ms_since_last_activation` distribution on loader events to see real
|
||||
uptake lag before deciding the next step, and compare cohorts by the
|
||||
`bundle` property.
|
||||
4. Emergencies: dial the percentage **down** (0% pulls everyone back to legacy
|
||||
on their next reload). Demoted machines keep settings and creds; tasks
|
||||
created on the SDK bundle reappear when re-promoted. Ship the fix as a
|
||||
higher version, then dial back up. Machines whose next bundle *crashed*
|
||||
are additionally version-pinned to legacy locally, independent of the flag.
|
||||
5. When next reaches 100% and soaks, retire the loader: publish a plain SDK
|
||||
extension build and delete this package.
|
||||
|
||||
## Version numbering
|
||||
|
||||
The combined VSIX owns the marketplace version line and must always exceed the
|
||||
last version published from either branch (legacy stable was 4.0.x → start at
|
||||
4.1.0). The bundles' own `package.json` versions ride along inside their
|
||||
subdirectories for provenance; the loader reports the combined version as
|
||||
`loader_version`.
|
||||
@@ -0,0 +1,27 @@
|
||||
import esbuild from "esbuild";
|
||||
|
||||
const production = process.argv.includes("--production");
|
||||
|
||||
// Same build-time secret injection scheme as apps/vscode/esbuild.mjs: CI
|
||||
// provides TELEMETRY_SERVICE_API_KEY; local builds leave it undefined and the
|
||||
// loader skips all PostHog calls (everyone stays on legacy).
|
||||
const define = {};
|
||||
if (process.env.TELEMETRY_SERVICE_API_KEY) {
|
||||
define["process.env.TELEMETRY_SERVICE_API_KEY"] = JSON.stringify(
|
||||
process.env.TELEMETRY_SERVICE_API_KEY,
|
||||
);
|
||||
}
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: ["src/extension.ts"],
|
||||
bundle: true,
|
||||
outfile: "dist/extension.js",
|
||||
platform: "node",
|
||||
format: "cjs",
|
||||
target: "node18",
|
||||
external: ["vscode"],
|
||||
minify: production,
|
||||
sourcemap: !production,
|
||||
define,
|
||||
logLevel: "info",
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@cline/vscode-rollout",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"description": "Loader + packaging tooling for the staged (A/B) rollout of the SDK-based VS Code extension alongside the legacy extension",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node esbuild.mjs",
|
||||
"build:production": "node esbuild.mjs --production",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "bun test src scripts",
|
||||
"stitch": "node scripts/stitch.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "20.x",
|
||||
"@types/vscode": "1.84.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"node-machine-id": "^1.1.12",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* Generate the combined VSIX's package.json as the UNION of the two bundles'
|
||||
* manifests, regenerated from both branches' actual package.json files at
|
||||
* stitch time so contribution drift between branches can't ship silently.
|
||||
*
|
||||
* Rules:
|
||||
* - Identity/top-level fields come from the next (main) manifest.
|
||||
* - `main` points at the loader; `version` comes from the release input.
|
||||
* - commands / menus / keybindings / activationEvents / icons are unioned.
|
||||
* Menu entries and keybindings present in only ONE manifest get their
|
||||
* `when` clause AND-ed with the `<prefix>.sdkBundle` context key (set by the
|
||||
* loader before activation; prefix follows the manifest identity — see
|
||||
* src/cohort.ts idPrefix), so a cohort never sees a button whose handler
|
||||
* its bundle doesn't register — and shared buttons that moved position
|
||||
* don't show up twice. Commands exclusive to one bundle are likewise hidden
|
||||
* from the other cohort's command palette.
|
||||
* - views / viewsContainers / configuration / engines MUST be
|
||||
* identical in both manifests — they can't be safely gated at runtime, so
|
||||
* divergence is a hard error.
|
||||
*
|
||||
* Usage: node gen-manifest.mjs --next <pkg.json> --legacy <pkg.json> --version <x.y.z> [--out <file>]
|
||||
*/
|
||||
|
||||
import { deepStrictEqual } from "node:assert";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
export function generateManifest(nextPkg, legacyPkg, version) {
|
||||
for (const field of ["name", "publisher", "main"]) {
|
||||
if (nextPkg[field] !== legacyPkg[field]) {
|
||||
throw new Error(
|
||||
`manifest field "${field}" differs: ${nextPkg[field]} vs ${legacyPkg[field]}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const field of ["views", "viewsContainers", "configuration"]) {
|
||||
try {
|
||||
deepStrictEqual(
|
||||
nextPkg.contributes?.[field],
|
||||
legacyPkg.contributes?.[field],
|
||||
);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`contributes.${field} diverged between bundles — it cannot be gated at runtime; reconcile the branches`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const engines = unionEngines(nextPkg.engines, legacyPkg.engines);
|
||||
|
||||
assertWalkthroughsCompatible(
|
||||
nextPkg.contributes?.walkthroughs,
|
||||
legacyPkg.contributes?.walkthroughs,
|
||||
);
|
||||
|
||||
// The nightly packaging rewrites the whole `cline.*` ID namespace to
|
||||
// `cline-nightly.*` (scripts/nightlify.mjs), so the context key and the
|
||||
// injected setting must follow the manifest's identity. Keep in sync with
|
||||
// idPrefix/bundleContextKey/settingSection in src/cohort.ts.
|
||||
const prefix = nextPkg.name === "cline-nightly" ? "cline-nightly" : "cline";
|
||||
const nextGate = `${prefix}.sdkBundle`;
|
||||
const legacyGate = `!${nextGate}`;
|
||||
|
||||
const nc = nextPkg.contributes ?? {};
|
||||
const lc = legacyPkg.contributes ?? {};
|
||||
const menus = unionMenus(nc.menus, lc.menus, nextGate, legacyGate);
|
||||
hideExclusiveCommandsFromPalette(
|
||||
menus,
|
||||
nc.commands,
|
||||
lc.commands,
|
||||
nextGate,
|
||||
legacyGate,
|
||||
);
|
||||
|
||||
const manifest = {
|
||||
name: nextPkg.name,
|
||||
displayName: nextPkg.displayName,
|
||||
description: nextPkg.description,
|
||||
version,
|
||||
icon: nextPkg.icon,
|
||||
engines,
|
||||
author: nextPkg.author,
|
||||
license: nextPkg.license,
|
||||
publisher: nextPkg.publisher,
|
||||
repository: nextPkg.repository,
|
||||
homepage: nextPkg.homepage,
|
||||
categories: nextPkg.categories,
|
||||
keywords: nextPkg.keywords,
|
||||
activationEvents: unionPrimitive(
|
||||
nextPkg.activationEvents,
|
||||
legacyPkg.activationEvents,
|
||||
),
|
||||
main: "./extension.js",
|
||||
contributes: {
|
||||
viewsContainers: nc.viewsContainers,
|
||||
views: nc.views,
|
||||
commands: unionBy(
|
||||
[...(nc.commands ?? []), ...(lc.commands ?? [])],
|
||||
(c) => c.command,
|
||||
),
|
||||
keybindings: unionGated(
|
||||
nc.keybindings,
|
||||
lc.keybindings,
|
||||
nextGate,
|
||||
legacyGate,
|
||||
),
|
||||
menus,
|
||||
icons: unionIcons(nc.icons, lc.icons),
|
||||
configuration: injectLoaderConfiguration(nc.configuration, prefix),
|
||||
walkthroughs: nc.walkthroughs,
|
||||
},
|
||||
scripts: {},
|
||||
};
|
||||
|
||||
assertSuperset(manifest, nextPkg, "next", nextGate);
|
||||
assertSuperset(manifest, legacyPkg, "legacy", legacyGate);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* The loader's own user-visible escape hatch, keyed by the manifest identity.
|
||||
* Neither bundle knows about it; only the loader reads it (src/cohort.ts
|
||||
* settingSection/SETTING_BUNDLE_OVERRIDE — keep the key and values in sync).
|
||||
* Injected after the configuration-equality invariant so it can't mask real
|
||||
* drift between the bundles.
|
||||
*/
|
||||
function loaderSettings(prefix) {
|
||||
return {
|
||||
[`${prefix}.rollout.bundleOverride`]: {
|
||||
type: "string",
|
||||
enum: ["auto", "next", "legacy"],
|
||||
enumDescriptions: [
|
||||
"Follow the remote rollout assignment.",
|
||||
"Force the new (SDK-based) extension.",
|
||||
"Force the previous (legacy) extension.",
|
||||
],
|
||||
default: "auto",
|
||||
scope: "application",
|
||||
markdownDescription:
|
||||
"Manual override for Cline's staged extension rollout. `next` forces the new (SDK-based) extension, `legacy` forces the previous one, `auto` follows the remote rollout assignment. Takes effect on window reload.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function injectLoaderConfiguration(configuration, prefix) {
|
||||
const properties = { ...(configuration?.properties ?? {}) };
|
||||
for (const [key, schema] of Object.entries(loaderSettings(prefix))) {
|
||||
if (properties[key]) {
|
||||
throw new Error(
|
||||
`bundle manifests must not declare loader-owned setting ${key}`,
|
||||
);
|
||||
}
|
||||
properties[key] = schema;
|
||||
}
|
||||
return { title: "Cline", ...(configuration ?? {}), properties };
|
||||
}
|
||||
|
||||
/**
|
||||
* Walkthroughs can't be gated per cohort, and their markdown at the VSIX root
|
||||
* always comes from the next checkout — so requiring byte-identical manifests
|
||||
* here would brick releases over copy tweaks while protecting nothing. Only
|
||||
* STRUCTURE must match (walkthrough/step ids, media paths, completion events —
|
||||
* the parts code and the manifest reference); when titles/descriptions
|
||||
* diverge, next's copy ships for everyone and the build says so.
|
||||
*/
|
||||
function assertWalkthroughsCompatible(next = [], legacy = []) {
|
||||
const structure = (walkthroughs) =>
|
||||
walkthroughs.map((walkthrough) => ({
|
||||
id: walkthrough.id,
|
||||
steps: (walkthrough.steps ?? []).map((step) => ({
|
||||
id: step.id,
|
||||
media: step.media,
|
||||
completionEvents: step.completionEvents,
|
||||
when: step.when,
|
||||
})),
|
||||
}));
|
||||
try {
|
||||
deepStrictEqual(structure(next), structure(legacy));
|
||||
} catch {
|
||||
throw new Error(
|
||||
"contributes.walkthroughs diverged structurally (ids/media/completionEvents) — reconcile the branches",
|
||||
);
|
||||
}
|
||||
try {
|
||||
deepStrictEqual(next, legacy);
|
||||
} catch {
|
||||
console.warn(
|
||||
"warning: walkthrough titles/descriptions differ between bundles; shipping next's copy for both cohorts",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Engines can safely diverge in ONE direction: the union requires whichever
|
||||
* bundle needs the NEWER host, which necessarily satisfies the other bundle's
|
||||
* older requirement too. (main routinely bumps the VS Code engine ahead of the
|
||||
* legacy branch — an equality assertion here would brick every combined build
|
||||
* over that.) Non-caret/complex ranges we can't compare fail hard rather than
|
||||
* guessing.
|
||||
*/
|
||||
function unionEngines(nextEngines = {}, legacyEngines = {}) {
|
||||
const union = {};
|
||||
for (const key of new Set([
|
||||
...Object.keys(nextEngines),
|
||||
...Object.keys(legacyEngines),
|
||||
])) {
|
||||
const a = nextEngines[key];
|
||||
const b = legacyEngines[key];
|
||||
if (a === undefined || b === undefined || a === b) {
|
||||
union[key] = a ?? b;
|
||||
continue;
|
||||
}
|
||||
const minimum = (range) => {
|
||||
const match = /^\^(\d+(?:\.\d+)*)$/.exec(range);
|
||||
return match?.[1];
|
||||
};
|
||||
const [minA, minB] = [minimum(a), minimum(b)];
|
||||
if (!minA || !minB) {
|
||||
throw new Error(
|
||||
`engines.${key} diverged with uncomparable ranges: ${a} vs ${b}`,
|
||||
);
|
||||
}
|
||||
union[key] = compareDotted(minA, minB) >= 0 ? a : b;
|
||||
console.warn(
|
||||
`warning: engines.${key} differs between bundles (next ${a}, legacy ${b}); union requires ${union[key]}`,
|
||||
);
|
||||
}
|
||||
return union;
|
||||
}
|
||||
|
||||
/** Compare dotted numeric versions. */
|
||||
function compareDotted(a, b) {
|
||||
const pa = a.split(".").map((part) => Number.parseInt(part, 10) || 0);
|
||||
const pb = b.split(".").map((part) => Number.parseInt(part, 10) || 0);
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
||||
if (diff !== 0) {
|
||||
return diff;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function unionPrimitive(a = [], b = []) {
|
||||
return [...new Set([...a, ...b])];
|
||||
}
|
||||
|
||||
/** Union keeping first occurrence per key (next wins on shared ids). */
|
||||
function unionBy(items, keyFn) {
|
||||
const seen = new Map();
|
||||
for (const item of items) {
|
||||
const key = keyFn(item);
|
||||
if (!seen.has(key)) {
|
||||
seen.set(key, item);
|
||||
}
|
||||
}
|
||||
return [...seen.values()];
|
||||
}
|
||||
|
||||
function sortKeysDeep(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(sortKeysDeep);
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.keys(value)
|
||||
.sort()
|
||||
.map((key) => [key, sortKeysDeep(value[key])]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function stableJson(value) {
|
||||
return JSON.stringify(sortKeysDeep(value));
|
||||
}
|
||||
|
||||
function gateWhen(entry, gate) {
|
||||
return { ...entry, when: entry.when ? `(${entry.when}) && ${gate}` : gate };
|
||||
}
|
||||
|
||||
/**
|
||||
* Union two entry lists (menu entries or keybindings): entries declared by
|
||||
* both bundles pass through untouched; entries declared by only one get their
|
||||
* `when` AND-ed with that bundle's cohort gate.
|
||||
*/
|
||||
function unionGated(
|
||||
nextEntries = [],
|
||||
legacyEntries = [],
|
||||
nextGate,
|
||||
legacyGate,
|
||||
) {
|
||||
const nextSet = new Set(nextEntries.map(stableJson));
|
||||
const legacySet = new Set(legacyEntries.map(stableJson));
|
||||
const entries = [];
|
||||
for (const entry of nextEntries) {
|
||||
entries.push(
|
||||
legacySet.has(stableJson(entry)) ? entry : gateWhen(entry, nextGate),
|
||||
);
|
||||
}
|
||||
for (const entry of legacyEntries) {
|
||||
if (!nextSet.has(stableJson(entry))) {
|
||||
entries.push(gateWhen(entry, legacyGate));
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function unionMenus(a = {}, b = {}, nextGate, legacyGate) {
|
||||
const menus = {};
|
||||
for (const location of new Set([...Object.keys(a), ...Object.keys(b)])) {
|
||||
menus[location] = unionGated(
|
||||
a[location],
|
||||
b[location],
|
||||
nextGate,
|
||||
legacyGate,
|
||||
);
|
||||
}
|
||||
return menus;
|
||||
}
|
||||
|
||||
/**
|
||||
* A command declared by only one bundle would surface in the other cohort's
|
||||
* command palette with no registered handler ("command not found" on run).
|
||||
* Hide it there unless that bundle's own manifest already constrains it.
|
||||
*/
|
||||
function hideExclusiveCommandsFromPalette(
|
||||
menus,
|
||||
nextCommands = [],
|
||||
legacyCommands = [],
|
||||
nextGate,
|
||||
legacyGate,
|
||||
) {
|
||||
const nextIds = new Set(nextCommands.map((c) => c.command));
|
||||
const legacyIds = new Set(legacyCommands.map((c) => c.command));
|
||||
const palette = menus.commandPalette ?? (menus.commandPalette = []);
|
||||
const alreadyListed = new Set(palette.map((e) => e.command));
|
||||
for (const id of nextIds) {
|
||||
if (!legacyIds.has(id) && !alreadyListed.has(id)) {
|
||||
palette.push({ command: id, when: nextGate });
|
||||
}
|
||||
}
|
||||
for (const id of legacyIds) {
|
||||
if (!nextIds.has(id) && !alreadyListed.has(id)) {
|
||||
palette.push({ command: id, when: legacyGate });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function unionIcons(a = {}, b = {}) {
|
||||
const icons = { ...b, ...a };
|
||||
for (const id of Object.keys(icons)) {
|
||||
if (a[id] && b[id] && JSON.stringify(a[id]) !== JSON.stringify(b[id])) {
|
||||
throw new Error(
|
||||
`contributes.icons["${id}"] diverged between bundles — icon fonts resolve from the VSIX root`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return icons;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every command/keybinding/menu entry/activation event a bundle declares must
|
||||
* survive the union, either verbatim or with its `when` AND-ed with that
|
||||
* bundle's cohort gate.
|
||||
*/
|
||||
function assertSuperset(manifest, sourcePkg, label, gate) {
|
||||
const missing = [];
|
||||
const commandIds = new Set(
|
||||
manifest.contributes.commands.map((c) => c.command),
|
||||
);
|
||||
for (const cmd of sourcePkg.contributes?.commands ?? []) {
|
||||
if (!commandIds.has(cmd.command)) {
|
||||
missing.push(`command ${cmd.command}`);
|
||||
}
|
||||
}
|
||||
for (const event of sourcePkg.activationEvents ?? []) {
|
||||
if (!manifest.activationEvents.includes(event)) {
|
||||
missing.push(`activationEvent ${event}`);
|
||||
}
|
||||
}
|
||||
const presentOrGated = (unionEntries, entry) => {
|
||||
const set = new Set((unionEntries ?? []).map(stableJson));
|
||||
return (
|
||||
set.has(stableJson(entry)) || set.has(stableJson(gateWhen(entry, gate)))
|
||||
);
|
||||
};
|
||||
for (const kb of sourcePkg.contributes?.keybindings ?? []) {
|
||||
if (!presentOrGated(manifest.contributes.keybindings, kb)) {
|
||||
missing.push(`keybinding ${kb.command}`);
|
||||
}
|
||||
}
|
||||
for (const [location, entries] of Object.entries(
|
||||
sourcePkg.contributes?.menus ?? {},
|
||||
)) {
|
||||
for (const entry of entries) {
|
||||
if (!presentOrGated(manifest.contributes.menus[location], entry)) {
|
||||
missing.push(
|
||||
`menu ${location}: ${entry.command ?? JSON.stringify(entry)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`union manifest is missing ${label} contributions:\n ${missing.join("\n ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {};
|
||||
for (let i = 2; i < argv.length; i += 2) {
|
||||
args[argv[i].replace(/^--/, "")] = argv[i + 1];
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const { next, legacy, version, out } = parseArgs(process.argv);
|
||||
if (!next || !legacy || !version) {
|
||||
console.error(
|
||||
"usage: gen-manifest.mjs --next <pkg.json> --legacy <pkg.json> --version <x.y.z> [--out <file>]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const manifest = generateManifest(
|
||||
JSON.parse(readFileSync(next, "utf8")),
|
||||
JSON.parse(readFileSync(legacy, "utf8")),
|
||||
version,
|
||||
);
|
||||
const json = `${JSON.stringify(manifest, null, "\t")}\n`;
|
||||
if (out) {
|
||||
writeFileSync(out, json);
|
||||
console.log(`wrote ${out}`);
|
||||
} else {
|
||||
process.stdout.write(json);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user