mirror of
https://github.com/cline/cline.git
synced 2026-09-11 16:42:40 +08:00
Compare commits
91
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f36b59c9cb | ||
|
|
311959e757 | ||
|
|
f21e6faf1c | ||
|
|
1cf19304f4 | ||
|
|
09aec528d0 | ||
|
|
4ec2b68c7c | ||
|
|
56fd6bb1ce | ||
|
|
49c7a89882 | ||
|
|
b47851791c | ||
|
|
c0a966c46a | ||
|
|
cf3f3e08eb | ||
|
|
7712e44468 | ||
|
|
f77f584a32 | ||
|
|
2771760305 | ||
|
|
16d0d04573 | ||
|
|
3590b425eb | ||
|
|
12404f0a4b | ||
|
|
6cb43f0309 | ||
|
|
3058563f37 | ||
|
|
39de7479b8 | ||
|
|
86c2600a8a | ||
|
|
f4230e475b | ||
|
|
078abcd055 | ||
|
|
afc1229ab0 | ||
|
|
892837d352 | ||
|
|
078d9f63f0 | ||
|
|
fba27b3c81 | ||
|
|
6549bdbacc | ||
|
|
55ccbb7d51 | ||
|
|
f039b1419f | ||
|
|
97c558ffe8 | ||
|
|
8f6f1652e1 | ||
|
|
26ee3abf82 | ||
|
|
d2b674bb9d | ||
|
|
2ce4facd9d | ||
|
|
ac4724166d | ||
|
|
2131bbba0c | ||
|
|
792738eeed | ||
|
|
0ac7d0bdb5 | ||
|
|
4fa42771a7 | ||
|
|
c91cd4be59 | ||
|
|
851ee033bc | ||
|
|
192e6ffab2 | ||
|
|
6b668fefdc | ||
|
|
8224ad1634 | ||
|
|
96e8f51436 | ||
|
|
5e5c4475bc | ||
|
|
66228fb30b | ||
|
|
7b8798c996 | ||
|
|
dfd22bf79e | ||
|
|
307707fd00 | ||
|
|
2a47c6ca08 | ||
|
|
704e953b69 | ||
|
|
c5661f8835 | ||
|
|
60c5a24eef | ||
|
|
3f9ed573db | ||
|
|
ac432c87f0 | ||
|
|
95b841a2dd | ||
|
|
7d63376d98 | ||
|
|
912c467818 | ||
|
|
c39c6d4479 | ||
|
|
071e5451b1 | ||
|
|
159961b3a3 | ||
|
|
4a3f1ce310 | ||
|
|
10a658a767 | ||
|
|
9d63bfcb31 | ||
|
|
daa32ee138 | ||
|
|
a51fa646bb | ||
|
|
5213070f67 | ||
|
|
7d7cddb9d9 | ||
|
|
55e169f1f8 | ||
|
|
c227e1ae36 | ||
|
|
d0a0c802af | ||
|
|
f9227fead4 | ||
|
|
bd83980359 | ||
|
|
4d3b161b9a | ||
|
|
255be9ad29 | ||
|
|
77b181a472 | ||
|
|
45476650b7 | ||
|
|
f847b06cfd | ||
|
|
598b3af7eb | ||
|
|
fc936b7305 | ||
|
|
9247dc30bb | ||
|
|
1b499161cc | ||
|
|
0e5cb433b0 | ||
|
|
53a46c309b | ||
|
|
36c78267c6 | ||
|
|
76c30b1c60 | ||
|
|
b496c5a1f3 | ||
|
|
2fc06ce446 | ||
|
|
64c50380f3 |
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.cline/skills/publish-extension
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: restore workflow support regressions — expand `/workflow.md` slash commands (the legacy filename spelling the autocomplete inserts) and mid-message commands, honor workflow enable/disable toggles during expansion, refresh the slash menu's workflow list on webview launch, and bring back the Workflows management tab in the rules modal (now last in the tab list, with a deprecation notice pointing to Skills)
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix hidden plan/act mode-switch and task-resumption prompts reappearing as user messages when a task is reopened from history
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Enable Auto Compact by default so long chats automatically compress conversation history instead of failing at the model context limit. It can be disabled in Settings → Features → "Auto Compact".
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix /compact UX: clear the chat input as soon as the command is submitted, wrap the compaction divider row at narrow sidebar widths, and update the context-window header even when compacting a small conversation grows the estimated context
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Disable feature tips by default; they can be enabled in Settings → Features → "Feature Tips"
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Show the edited file in a regular editor tab after the diff preview closes, restoring the legacy post-edit behavior
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Show the user's message in chat immediately when sending to a task opened from history, instead of only a thinking indicator until the session resume finishes
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.cline/skills/publish-extension
|
||||
@@ -0,0 +1,176 @@
|
||||
---
|
||||
name: publish-extension
|
||||
description: Use when releasing the Cline VS Code extension — stable (currently the combined legacy+next A/B VSIX via ext-vscode-ab-package), nightly (ext-vscode-publish-nightly), or a legacy-branch hotfix (ext-vscode-publish-legacy). Guides version selection, changelog, PostHog rollout-flag coordination, workflow dispatch, environment approvals, tagging, and post-publish verification, plus the eventual cutover to publishing the SDK extension standalone.
|
||||
---
|
||||
|
||||
# VS Code Extension Release
|
||||
|
||||
Use this skill when the user asks to release, publish, or ship the VS Code extension — stable, nightly, or a legacy hotfix — or to dial the rollout, or to cut over to the SDK extension permanently.
|
||||
|
||||
> Working directory: repo root. All workflows are dispatched from `main` (GitHub requires the workflow file on the default branch; each workflow checks out the refs it actually builds).
|
||||
|
||||
## The current era: combined A/B rollout
|
||||
|
||||
We are mid-migration from the legacy (npm, pre-SDK) extension to the next (SDK-based, bun) extension. Until the cutover is complete, **the stable and nightly listings ship a combined VSIX**: a small loader + two complete extensions (`next/` built from `main`, `legacy/` built from the `legacy-extension` branch). The loader picks one per window based on the PostHog flag `ext-sdk-bundle-rollout`. Deep-dive docs: `apps/vscode-rollout/README.md` (authoritative) and PR #12253 (design + runbook comments).
|
||||
|
||||
Endgame (see "Cutover" at the bottom): once the next bundle is trusted at 100%, stable goes back to a plain build of `main` via `ext-vscode-publish-stable.yml` and all the legacy/rollout machinery is retired.
|
||||
|
||||
### The listings and the workflows
|
||||
|
||||
| Channel | Marketplace ID | Workflow | Trigger | Version |
|
||||
|---|---|---|---|---|
|
||||
| Stable (combined) | `saoudrizwan.claude-dev` | `ext-vscode-ab-package.yml` | dispatch only; `publish` input defaults false | manual input (semver, e.g. `4.1.0`) |
|
||||
| Nightly (combined) | `saoudrizwan.cline-nightly` | `ext-vscode-publish-nightly.yml` | cron 12:00 UTC + dispatch | auto `<major>.<minor>.<unix-ts>` from main's `apps/vscode/package.json` |
|
||||
| Legacy hotfix (standalone) | `saoudrizwan.claude-dev` | `ext-vscode-publish-legacy.yml` | dispatch | from `apps/vscode/package.json` on `legacy-extension` |
|
||||
| Stable standalone (post-cutover) | `saoudrizwan.claude-dev` | `ext-vscode-publish-stable.yml` | dispatch | from `apps/vscode/package.json` on `main` |
|
||||
|
||||
All three publish paths gate on tests before publishing: nightly and ab-package run the reusable bun suite (`ext-vscode-test.yml`, tests `main`) — ab-package additionally runs the legacy branch's npm suite — and the legacy workflow inlines the npm suite. Environment gates: stable paths use `publish` → `Publish` environment (required reviewers approve in the Actions UI); nightly uses `PublishNightly` (branch policy only, no reviewers — a reviewer requirement would block the cron).
|
||||
|
||||
## Golden rules (read before any release)
|
||||
|
||||
1. **One listing, one version line.** `claude-dev` is published from multiple workflows/branches. Every stable publish must use a version **strictly above the highest version ever published to the listing from any branch** — marketplace versions are monotonic and cannot be unpublished (supersede, never delete). Check what's live first:
|
||||
|
||||
```bash
|
||||
curl -s -X POST "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery" \
|
||||
-H "Content-Type: application/json" -H "Accept: application/json;api-version=3.0-preview.1" \
|
||||
-d '{"filters":[{"criteria":[{"filterType":7,"value":"saoudrizwan.claude-dev"}]}],"flags":16}' \
|
||||
| python3 -c "import json,sys; v=json.load(sys.stdin)['results'][0]['extensions'][0]['versions'][0]; print(v['version'], v['lastUpdated'])"
|
||||
```
|
||||
|
||||
2. **Check the flag BEFORE any stable combined publish.** `ext-sdk-bundle-rollout` is **shared between nightly and stable** — the loader sends only a machine id to `/decide`, no channel property, so there is no per-channel targeting. If the flag is high (nightly dogfooding) and you publish stable, stable users get the next bundle at that same percentage. Verify the effective percentage empirically (no PostHog admin needed — sample `/decide` with random ids using the key inlined in any shipped loader):
|
||||
|
||||
```bash
|
||||
node -e '
|
||||
const KEY = process.argv[1]; // phc_... extracted from a shipped VSIX loader
|
||||
(async () => {
|
||||
let t = 0, n = 200;
|
||||
for (let i = 0; i < n; i += 20) {
|
||||
const rs = await Promise.all(Array.from({length: 20}, (_, j) =>
|
||||
fetch("https://data.cline.bot/decide?v=3", { method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({api_key: KEY, distinct_id: `probe-${i+j}-${Math.random()}`})
|
||||
}).then(r => r.json())));
|
||||
for (const r of rs) if ((r.featureFlags||{})["ext-sdk-bundle-rollout"] === true) t++;
|
||||
}
|
||||
console.log(`~${(100*t/n).toFixed(1)}% (${t}/${n})`);
|
||||
})()' "$KEY"
|
||||
```
|
||||
|
||||
Flag changes are made in the PostHog UI (Cline project). **0% is the kill switch** — the flag is two-way; there is no separate killswitch flag. Dialing down demotes machines back to legacy on their next window reload.
|
||||
|
||||
3. **Ask before pushing** commits or tags. Environment approvals are the maintainer's to give.
|
||||
|
||||
4. **Changelog lives at the repo ROOT** (`CHANGELOG.md`), on the branch being released — not `apps/vscode/CHANGELOG.md` (doesn't exist). The legacy and stable workflows hard-fail unless the first heading is exactly `## [<version>]`.
|
||||
|
||||
5. **Stuck concurrency groups**: `ext-vscode-ab-package` groups on the version with `cancel-in-progress: false`. A run left `waiting` on environment approval blocks every later dispatch of the same version — cancel it (`gh run cancel <id>`) before re-dispatching.
|
||||
|
||||
## Stable release (combined A/B VSIX) — the current stable path
|
||||
|
||||
### Pre-flight
|
||||
|
||||
```bash
|
||||
# 1. What's live, and what version comes next (must exceed it — rule 1)
|
||||
# 2. Flag percentage (rule 2) — decide where it should be for this release
|
||||
# 3. Legacy tip = what the non-promoted cohort will run; confirm it's the shipped hotfix line
|
||||
git fetch origin main legacy-extension
|
||||
git log --oneline -3 origin/legacy-extension
|
||||
|
||||
# 4. Cheap local rehearsal of the most likely build failure: the union manifest
|
||||
# hard-fails if views/viewsContainers/configuration diverged between branches.
|
||||
git show origin/main:apps/vscode/package.json > /tmp/next.json
|
||||
git show origin/legacy-extension:apps/vscode/package.json > /tmp/legacy.json
|
||||
node apps/vscode-rollout/scripts/gen-manifest.mjs --next /tmp/next.json --legacy /tmp/legacy.json --version <VERSION>
|
||||
# Expected warnings only: engines union (takes newer) + walkthrough copy drift.
|
||||
```
|
||||
|
||||
Release prep on `main` (PR, not direct push):
|
||||
- Add `## [<VERSION>]` entry at the top of root `CHANGELOG.md`.
|
||||
- Bump `apps/vscode/package.json` to `<VERSION>` so the repo reflects the published line. Side effect: nightly versions become `<major>.<minor>.<unix-ts>` of the new base — harmless (separate listing, still monotonic).
|
||||
|
||||
### Dispatch
|
||||
|
||||
```bash
|
||||
gh workflow run ext-vscode-ab-package.yml --ref main \
|
||||
-f version=<VERSION> -f next-ref=main -f legacy-ref=legacy-extension -f publish=true
|
||||
# publish=false builds an installable .vsix artifact without publishing, but the
|
||||
# package job still requires the same Publish environment approval — an
|
||||
# unapproved rehearsal sits in `waiting` and blocks that version's concurrency
|
||||
# group (rule 5).
|
||||
gh run list --workflow=ext-vscode-ab-package.yml --limit 1
|
||||
```
|
||||
|
||||
Both test suites run first (no approval needed); the gated `package` job then **waits for `Publish` environment approval** (Actions → run → "Review deployments"). Both bundles build the exact revisions their test gates ran against (branch names are resolved once — commits landing on either branch mid-run or during the approval wait are not picked up); `publish=true` is additionally refused for any `next-ref` other than `main` (the bun gate only tests main — non-main next-refs are for build-only artifact rehearsals). Check what a run is waiting on:
|
||||
|
||||
```bash
|
||||
gh api repos/cline/cline/actions/runs/<run-id>/pending_deployments
|
||||
```
|
||||
|
||||
### Post-publish
|
||||
|
||||
1. Verify the marketplace serves the new version (query from rule 1).
|
||||
2. This workflow does **not** tag or create a GitHub release — do it manually:
|
||||
|
||||
```bash
|
||||
git tag v<VERSION> <main-sha-built> # ask before pushing
|
||||
git push origin v<VERSION>
|
||||
gh release create v<VERSION> --title "v<VERSION>" --notes "<curated notes from CHANGELOG>"
|
||||
```
|
||||
|
||||
3. Thorough artifact check (`gh run download <run-id>`): union `package.json` is `saoudrizwan.claude-dev@<VERSION>`, `next/package.json` and `legacy/package.json` carry the SAME version, `grep -c 'phc_' extension/extension.js` ≥ 1 (loader key inlined), no leftover `process.env.TELEMETRY_SERVICE_API_KEY` / `process.env.CLINE_ROLLOUT_VARIANT` literals in either bundle's dist (leftovers = a build ran without its env and telemetry is silently dead).
|
||||
4. Monitor: `extension.rollout.bundle_activated` in `otel.otel_logs` filtered to `extension_version = '<VERSION>'` (stable cohort is cleanly separable — nightly versions are timestamps). Watch the next/legacy ratio and the crash-fallback rate; Metabase dashboards 17 (rollout + task error rate) and 19 (error deep dive). `extension.rollout.loader_decision` (incl. `double_failure`) is PostHog-only, not in ClickHouse.
|
||||
5. Dial the flag per the rollout plan (e.g. 0% at publish → 1% → up), verifying each change with the probe from rule 2. Announce demotions ahead of time — dialing down also demotes nightly dogfooders unless they set `"cline-nightly.rollout.bundleOverride": "next"`.
|
||||
|
||||
### Known caveats of this path
|
||||
|
||||
- **Marketplace only** — no Open VSX step (both standalone workflows have one). Open VSX users stay on the last standalone version until a standalone publish or the cutover.
|
||||
- **`engines.vscode` unions upward** (main's floor wins, e.g. `^1.101.0` vs legacy's `^1.84.0`): users on older VS Code are never offered the combined VSIX. Fail-safe during rollout; must be resolved before 100%.
|
||||
- A red run can still mean a successful publish on paths that tag (see Gotchas).
|
||||
|
||||
## Nightly release
|
||||
|
||||
Happens automatically (cron 12:00 UTC). Manual cut:
|
||||
|
||||
```bash
|
||||
gh workflow run ext-vscode-publish-nightly.yml --ref main # real publish
|
||||
gh workflow run ext-vscode-publish-nightly.yml --ref main -f dry-run=true # artifact only
|
||||
gh run watch <run-id> --exit-status --interval 60
|
||||
```
|
||||
|
||||
No changelog/version prep — the version is computed. Verify with the marketplace query against `saoudrizwan.cline-nightly`.
|
||||
|
||||
**Red run ≠ failed publish**: the final tag-push step fails whenever main's HEAD touches `.github/workflows/**` (default token cannot create such refs). If "Published" appears in the logs, the release went out; push the `nightly-main-<UTC ts>-<sha12>` tag manually with user credentials.
|
||||
|
||||
## Legacy hotfix release (and emergency full rollback)
|
||||
|
||||
For shipping a fix on the `legacy-extension` branch — or as the **structural rollback** from a bad combined stable VSIX: a standalone legacy publish at a higher version supersedes the combined VSIX entirely (loader and all) for every user. (For "next bundle misbehaving" you don't need this — dial the flag to 0% instead.)
|
||||
|
||||
```bash
|
||||
# On legacy-extension: commit the fix, bump apps/vscode/package.json ABOVE the
|
||||
# highest version ever published to the listing (rule 1 — including combined
|
||||
# versions, e.g. combined 4.1.0 live -> hotfix is 4.1.1, not 4.0.13),
|
||||
# add the matching `## [x.y.z]` entry to root CHANGELOG.md, push.
|
||||
gh workflow run ext-vscode-publish-legacy.yml --ref main \
|
||||
-f release-type=release -f branch=legacy-extension
|
||||
```
|
||||
|
||||
npm test suite runs ungated; the publish job waits on the `Publish` environment. This workflow derives + pushes the `v<version>` tag itself and creates the GitHub release — no manual tagging. Publishes to Marketplace **and** Open VSX. The branch is the npm codebase: use `npm`, never `bun`, and expect the old monolith layout (`apps/vscode/src/core/...`).
|
||||
|
||||
## Cutover: retiring the A/B machinery (the endgame)
|
||||
|
||||
When the next bundle has held at 100% long enough to trust:
|
||||
|
||||
1. **Resolve the engines floor**: decide whether stranding VS Code < main's `engines.vscode` on the last combined version is acceptable, or lower main's floor first.
|
||||
2. Bump `apps/vscode/package.json` on `main` above everything ever published; root `CHANGELOG.md` entry to match (both are enforced by the workflow).
|
||||
3. Ship standalone from main: `gh workflow run ext-vscode-publish-stable.yml --ref main` — tests main, tags `v<version>` itself, creates the GitHub release, publishes Marketplace + Open VSX (this also heals the Open VSX gap).
|
||||
4. Watch the same rollout telemetry through the transition — `extension_variant` disappears from events as users leave combined builds, which is itself the adoption signal.
|
||||
5. Only after the standalone version dominates: retire `legacy-extension` (keep for history), delete `ext-vscode-publish-legacy.yml` and `ext-vscode-ab-package.yml`, convert the nightly workflow back to a plain build of main, remove `apps/vscode-rollout/`, and archive the `ext-sdk-bundle-rollout` flag in PostHog (harmless to machines still on a combined VSIX: absent flag fails safe to... nothing changing until they update, but their loader treats a deleted flag as legacy — leave the flag at 100% until combined-VSIX activations flatline, then archive).
|
||||
6. Update this skill: delete the combined-era sections and keep the standalone flow.
|
||||
|
||||
## Gotchas index
|
||||
|
||||
- `inputs.*` are empty strings on `schedule` events — preserve `|| 'default'` fallbacks when editing the nightly workflow.
|
||||
- `bun run package` in `apps/vscode` does not build `@cline/*` workspace deps — fresh checkouts need `bun run build:sdk` first (workflows handle this).
|
||||
- Job-level `if:` ref checks in workflow YAML are advisory (a dispatched branch runs its own copy of the file); the enforced boundary is each environment's deployment-branch policy in repo settings.
|
||||
- Marketplace PATs (`VSCE_PAT`/`OVSX_PAT`) are only mounted into publish steps; neither publish workflow has an untrusted trigger surface.
|
||||
- Environment-approval runs left waiting don't time out quickly — they sit for days and (for ab-package) block their version's concurrency group.
|
||||
- Local forcing for manual testing: `CLINE_BUNDLE_OVERRIDE=next|legacy` env (launch VS Code fresh from a terminal) or the `<prefix>.rollout.bundleOverride` setting + reload; both report as `override` in telemetry so they don't pollute cohort data.
|
||||
@@ -37,22 +37,123 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Gate the build/publish on BOTH bundles' own test suites, mirroring the two
|
||||
# standalone publish paths (nightly gates on the bun suite via the same
|
||||
# reusable workflow; the legacy publish inlines the npm suite). The gated
|
||||
# `package` job requests its `publish` environment approval only after both
|
||||
# suites pass.
|
||||
#
|
||||
# Caveat (shared with the nightly workflow): the reusable bun suite tests the
|
||||
# DISPATCH revision — main's tip at dispatch, since this workflow is only
|
||||
# dispatched from main — not `next-ref`. The package job therefore pins the
|
||||
# default next-ref checkout to that same revision (tested == built) and
|
||||
# refuses publish=true for any other next-ref; build-only artifact runs may
|
||||
# still build untested refs.
|
||||
test-next:
|
||||
name: Test next (SDK) bundle
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
uses: ./.github/workflows/ext-vscode-test.yml
|
||||
|
||||
# The legacy branch is the npm codebase, so the bun-based reusable workflow
|
||||
# cannot test it. Inlined npm steps, kept in sync with the `test` job in
|
||||
# ext-vscode-publish-legacy.yml (same suite, different ref input name).
|
||||
test-legacy:
|
||||
name: Test legacy bundle
|
||||
runs-on: ubuntu-latest
|
||||
# The tested revision, exported so the package job builds EXACTLY what
|
||||
# this suite ran against. legacy-ref is a mutable branch name and the
|
||||
# package job starts much later (test phase + environment-approval wait,
|
||||
# potentially days) — re-resolving the name there could pick up commits
|
||||
# this gate never saw.
|
||||
outputs:
|
||||
tested-sha: ${{ steps.rev.outputs.sha }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.legacy-ref }}
|
||||
|
||||
- name: Record tested revision
|
||||
id: rev
|
||||
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
cache-dependency-path: |
|
||||
apps/vscode/package-lock.json
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci
|
||||
|
||||
- name: Run Quality Checks (lint + typecheck)
|
||||
run: npm run ci:check-all
|
||||
|
||||
- name: Build Tests and Extension
|
||||
id: build_step
|
||||
run: npm run ci:build
|
||||
|
||||
- name: Unit Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: npm run test:unit
|
||||
|
||||
- name: Extension Integration Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: xvfb-run -a npm run test:coverage
|
||||
|
||||
- name: Webview Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: |
|
||||
cd webview-ui
|
||||
npm run test:coverage
|
||||
|
||||
package:
|
||||
name: Build combined (legacy + next) VSIX
|
||||
needs: [test-next, test-legacy]
|
||||
runs-on: ubuntu-latest
|
||||
environment: publish
|
||||
steps:
|
||||
# Refuse to publish a next bundle the test-next gate did not cover.
|
||||
# The reusable suite tests the dispatch revision (main), so publishing
|
||||
# any other next-ref would ship an untested bundle. Build-only runs
|
||||
# (publish=false) may still use arbitrary next-refs for artifact
|
||||
# rehearsals.
|
||||
- name: Refuse to publish an untested next-ref
|
||||
if: ${{ github.event.inputs.publish == 'true' && github.event.inputs.next-ref != 'main' }}
|
||||
run: |
|
||||
echo "Error: publish=true requires next-ref=main — the test gate only covers main."
|
||||
exit 1
|
||||
|
||||
# For the default next-ref (main), pin the checkout to the exact
|
||||
# revision the test-next gate ran against: a moving branch name could
|
||||
# otherwise drift past the tested commit during the test phase.
|
||||
- name: Checkout next (SDK) source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.next-ref }}
|
||||
ref: ${{ github.event.inputs.next-ref == 'main' && github.sha || github.event.inputs.next-ref }}
|
||||
path: next-src
|
||||
lfs: true
|
||||
|
||||
# Pin to the revision test-legacy actually tested (see that job's
|
||||
# outputs comment) — never re-resolve the mutable branch name here.
|
||||
- name: Checkout legacy source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.legacy-ref }}
|
||||
ref: ${{ needs.test-legacy.outputs.tested-sha }}
|
||||
path: legacy-src
|
||||
lfs: true
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
node-version: "24.x"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --filter @cline/ui --filter @cline/code --frozen-lockfile
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Typecheck UI
|
||||
run: bun -F @cline/ui typecheck
|
||||
@@ -58,6 +58,11 @@ jobs:
|
||||
- name: Build UI package
|
||||
run: bun -F @cline/ui build
|
||||
|
||||
# The desktop chat test imports @cline/shared/browser, which resolves to
|
||||
# dist output that nothing else in this job builds.
|
||||
- name: Build shared package
|
||||
run: bun -F @cline/shared build
|
||||
|
||||
- name: Test desktop chat integration
|
||||
run: bun -F @cline/code test:chat-ui
|
||||
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.47
|
||||
|
||||
- Free Cline models are now supported end to end: free models show as "(free)", and hitting the free limit renders a dedicated card with the reset time (from SDK v0.0.66)
|
||||
- `/settings` general toggles (plan/act mode, tool auto-approve, compaction mode) now persist across restarts
|
||||
- Upgraded the TUI stack from opentui 0.1.102 to 0.4.3
|
||||
- Fixed a grey panel left behind on screen after closing a dialog (model picker, help, command palette) — a leftover from the opentui upgrade
|
||||
- Fixed a React duplicate-key warning when `read_files` listed the same path more than once
|
||||
- Aborting a task no longer risks killing the shared hub daemon
|
||||
- Connector status delivery failures are no longer fatal to the turn
|
||||
- Agentic compaction is now the default context-compaction strategy, with fixes for it silently falling back to basic compaction and for tool-heavy transcripts that could never find a cut point (from SDK v0.0.66)
|
||||
- Editor edits preserve a file's existing line endings, fixing failed exact-match edits on CRLF files (from SDK v0.0.66)
|
||||
- Broader built-in provider coverage, now generated from models.dev (from SDK v0.0.66)
|
||||
- Updated the bundled model catalog (from SDK v0.0.66)
|
||||
|
||||
## 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
|
||||
|
||||
+1
-1
@@ -260,7 +260,7 @@ Schedules can route results back to chat surfaces with `--delivery-adapter`, `--
|
||||
| `--compaction <agentic\|basic\|off>` | Context compaction mode. Defaults to `agentic`; use `basic` for local truncation or `off` to disable. |
|
||||
| `--retries <count>` | Maximum consecutive mistakes (retries) before halting (default: `3`) |
|
||||
| `--json` | Output NDJSON instead of styled text |
|
||||
| `--data-dir <path>` | Use isolated local state at `<path>` instead of `~/.cline` (enables sandbox mode automatically) |
|
||||
| `--data-dir <path>` | Use isolated local state at `<path>` instead of `~/.cline/data` (enables sandbox mode automatically) |
|
||||
| `--auto-approve [true\|false]` | Set tool auto-approval for all tools |
|
||||
| `--kanban` | Run the external `kanban` app |
|
||||
| `-y, --yolo` | Skip tool approval prompts, enable `submit_and_exit`, and disable spawn/team tools by default |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.46",
|
||||
"version": "3.0.47",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -220,7 +220,7 @@ describe("cli interactive e2e", () => {
|
||||
"claude-sonnet-4-6",
|
||||
"-k",
|
||||
"test-key",
|
||||
"hello",
|
||||
"seed history session",
|
||||
],
|
||||
{ cwd: cliRoot, encoding: "utf8", env, timeout: 60_000 },
|
||||
);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Command } from "commander";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const historyMocks = vi.hoisted(() => ({
|
||||
runHistoryDelete: vi.fn(async () => 0),
|
||||
runHistoryExport: vi.fn(async () => 0),
|
||||
runHistoryList: vi.fn(async () => 0),
|
||||
runHistoryUpdate: vi.fn(async () => 0),
|
||||
}));
|
||||
|
||||
vi.mock("./history", () => historyMocks);
|
||||
|
||||
import { registerHistoryCommand } from "./history-command";
|
||||
|
||||
function createHarness(isInteractiveTTY: boolean) {
|
||||
const program = new Command()
|
||||
.exitOverride()
|
||||
.option("--json", "Output as JSON");
|
||||
program.configureOutput({
|
||||
writeOut: vi.fn(),
|
||||
writeErr: vi.fn(),
|
||||
});
|
||||
const io = {
|
||||
writeln: vi.fn(),
|
||||
writeErr: vi.fn(),
|
||||
};
|
||||
const setExitCode = vi.fn();
|
||||
const setStartupTarget = vi.fn();
|
||||
registerHistoryCommand({
|
||||
program,
|
||||
io,
|
||||
setExitCode,
|
||||
setStartupTarget,
|
||||
isInteractiveTTY: () => isInteractiveTTY,
|
||||
});
|
||||
return { program, io, setExitCode, setStartupTarget };
|
||||
}
|
||||
|
||||
describe("registerHistoryCommand", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("opens the in-app history picker for an interactive text terminal", async () => {
|
||||
const { program, setExitCode, setStartupTarget } = createHarness(true);
|
||||
|
||||
await program.parseAsync(["history"], { from: "user" });
|
||||
|
||||
expect(setStartupTarget).toHaveBeenCalledOnce();
|
||||
expect(setStartupTarget).toHaveBeenCalledWith("history");
|
||||
expect(historyMocks.runHistoryList).not.toHaveBeenCalled();
|
||||
expect(setExitCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps explicit JSON output non-interactive even when a TTY is attached", async () => {
|
||||
const { program, io, setExitCode, setStartupTarget } = createHarness(true);
|
||||
|
||||
await program.parseAsync(["history", "--json"], { from: "user" });
|
||||
|
||||
expect(setStartupTarget).not.toHaveBeenCalled();
|
||||
expect(historyMocks.runHistoryList).toHaveBeenCalledWith({
|
||||
limit: 50,
|
||||
outputMode: "json",
|
||||
io,
|
||||
});
|
||||
expect(setExitCode).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it("prints text history when no interactive terminal is attached", async () => {
|
||||
const { program, io, setExitCode, setStartupTarget } = createHarness(false);
|
||||
|
||||
await program.parseAsync(["history", "--limit", "12"], { from: "user" });
|
||||
|
||||
expect(setStartupTarget).not.toHaveBeenCalled();
|
||||
expect(historyMocks.runHistoryList).toHaveBeenCalledWith({
|
||||
limit: 12,
|
||||
outputMode: "text",
|
||||
io,
|
||||
});
|
||||
expect(setExitCode).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it("returns an error when delete is missing --session-id", async () => {
|
||||
const { program, io, setExitCode } = createHarness(false);
|
||||
|
||||
await program.parseAsync(["history", "delete"], { from: "user" });
|
||||
|
||||
expect(io.writeErr).toHaveBeenCalledWith(
|
||||
"history delete requires --session-id <id>",
|
||||
);
|
||||
expect(historyMocks.runHistoryDelete).not.toHaveBeenCalled();
|
||||
expect(setExitCode).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { Command } from "commander";
|
||||
import type { TuiStartupTarget } from "../tui/types";
|
||||
import type { CliOutputMode } from "../utils/types";
|
||||
import {
|
||||
runHistoryDelete,
|
||||
runHistoryExport,
|
||||
runHistoryList,
|
||||
runHistoryUpdate,
|
||||
} from "./history";
|
||||
|
||||
type HistoryCommandIo = {
|
||||
writeln: (text?: string) => void;
|
||||
writeErr: (text: string) => void;
|
||||
};
|
||||
|
||||
type RegisterHistoryCommandOptions = {
|
||||
program: Command;
|
||||
io: HistoryCommandIo;
|
||||
setExitCode: (code: number) => void;
|
||||
setStartupTarget: (target: TuiStartupTarget) => void;
|
||||
isInteractiveTTY?: () => boolean;
|
||||
};
|
||||
|
||||
function resolveHistoryOutputMode(
|
||||
program: Command,
|
||||
historyCmd: Command,
|
||||
): CliOutputMode {
|
||||
return program.opts().json || historyCmd.opts().json ? "json" : "text";
|
||||
}
|
||||
|
||||
export function registerHistoryCommand({
|
||||
program,
|
||||
io,
|
||||
setExitCode,
|
||||
setStartupTarget,
|
||||
isInteractiveTTY = () =>
|
||||
process.stdin.isTTY === true && process.stdout.isTTY === true,
|
||||
}: RegisterHistoryCommandOptions): void {
|
||||
const historyCmd = program
|
||||
.command("history")
|
||||
.alias("h")
|
||||
.description("List session history or manage saved sessions")
|
||||
.option("--json", "Output as JSON")
|
||||
.option("--limit <count>", "Maximum number of sessions to show", "50")
|
||||
.option("--page <number>", "Page number for paginated results")
|
||||
.option("--config <dir>", "configuration directory")
|
||||
.action(async () => {
|
||||
const opts = historyCmd.opts();
|
||||
const limit = Number.parseInt(opts.limit, 10);
|
||||
const outputMode = resolveHistoryOutputMode(program, historyCmd);
|
||||
if (outputMode === "text" && isInteractiveTTY()) {
|
||||
setStartupTarget("history");
|
||||
return;
|
||||
}
|
||||
setExitCode(
|
||||
await runHistoryList({
|
||||
limit,
|
||||
outputMode,
|
||||
io,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const historyDeleteCmd = historyCmd
|
||||
.command("delete")
|
||||
.description("Delete a session from history")
|
||||
.option("--session-id <id>", "Session ID to delete")
|
||||
.action(async () => {
|
||||
const opts = historyDeleteCmd.opts();
|
||||
if (!opts.sessionId) {
|
||||
io.writeErr("history delete requires --session-id <id>");
|
||||
setExitCode(1);
|
||||
return;
|
||||
}
|
||||
const outputMode = resolveHistoryOutputMode(program, historyCmd);
|
||||
setExitCode(await runHistoryDelete(opts.sessionId, outputMode, io));
|
||||
});
|
||||
|
||||
const historyUpdateCmd = historyCmd
|
||||
.command("update")
|
||||
.description("Update a session in history")
|
||||
.option("--metadata <json>", "Metadata as JSON string")
|
||||
.option("--prompt <text>", "New prompt text")
|
||||
.option("--session-id <id>", "Session ID to update")
|
||||
.option("--title <text>", "New title")
|
||||
.action(async () => {
|
||||
const opts = historyUpdateCmd.opts();
|
||||
if (!opts.sessionId) {
|
||||
io.writeErr("history update requires --session-id <id>");
|
||||
setExitCode(1);
|
||||
return;
|
||||
}
|
||||
const outputMode = resolveHistoryOutputMode(program, historyCmd);
|
||||
setExitCode(
|
||||
await runHistoryUpdate(
|
||||
opts.sessionId,
|
||||
opts.prompt,
|
||||
opts.title,
|
||||
opts.metadata,
|
||||
outputMode,
|
||||
io,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const historyExportCmd = historyCmd
|
||||
.command("export <sessionId>")
|
||||
.description("Export a session as a standalone HTML file")
|
||||
.option("-o, --output <path>", "Output HTML file path")
|
||||
.action(async (sessionId: string) => {
|
||||
const opts = historyExportCmd.opts();
|
||||
const outputMode = resolveHistoryOutputMode(program, historyCmd);
|
||||
setExitCode(
|
||||
await runHistoryExport(sessionId, opts.output, outputMode, io),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { SessionHistoryRecord } from "@cline/core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { exportHistorySession } from "../session/history-export";
|
||||
import {
|
||||
formatCheckpointDetail,
|
||||
formatHistoryListLine,
|
||||
@@ -16,18 +17,12 @@ vi.mock("../session/session", () => ({
|
||||
readSessionMessagesArtifact: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../tui/history-standalone", () => ({
|
||||
renderHistoryStandalone: vi.fn(async () => 0),
|
||||
}));
|
||||
|
||||
import { listSessions, readSessionMessagesArtifact } from "../session/session";
|
||||
import { renderHistoryStandalone } from "../tui/history-standalone";
|
||||
|
||||
const mockedReadSessionMessagesArtifact = vi.mocked(
|
||||
readSessionMessagesArtifact,
|
||||
);
|
||||
const mockedListSessions = vi.mocked(listSessions);
|
||||
const mockedRenderHistoryStandalone = vi.mocked(renderHistoryStandalone);
|
||||
|
||||
function createHistoryRow(
|
||||
overrides: Partial<SessionHistoryRecord> = {},
|
||||
@@ -200,8 +195,11 @@ describe("runHistoryList", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("hydrates interactive history rows so titles can be inferred from messages", async () => {
|
||||
const row = createHistoryRow({ prompt: undefined, metadata: undefined });
|
||||
it("requests hydrated text history rows so titles can come from messages", async () => {
|
||||
const row = createHistoryRow({
|
||||
prompt: undefined,
|
||||
metadata: { title: "hydrated title", totalCost: 0.25 },
|
||||
});
|
||||
mockedListSessions.mockResolvedValue([row]);
|
||||
const io = {
|
||||
writeln: vi.fn(),
|
||||
@@ -218,8 +216,8 @@ describe("runHistoryList", () => {
|
||||
expect(mockedListSessions).toHaveBeenCalledWith(25, {
|
||||
hydrate: true,
|
||||
});
|
||||
expect(mockedRenderHistoryStandalone).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ rows: [row] }),
|
||||
expect(io.writeln).toHaveBeenCalledWith(
|
||||
expect.stringContaining("hydrated title"),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -313,6 +311,42 @@ describe("runHistoryExport", () => {
|
||||
await expect(readFile(outputPath, "utf8")).resolves.toContain("world");
|
||||
});
|
||||
|
||||
it("writes structured JSON from a persisted messages artifact", async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "cline-history-export-"));
|
||||
const artifact = {
|
||||
version: 1,
|
||||
updated_at: "2026-04-22T17:42:10.123Z",
|
||||
sessionId: "sess_1",
|
||||
systemPrompt: "Be helpful",
|
||||
messages: [
|
||||
{
|
||||
id: "m1",
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
},
|
||||
{
|
||||
id: "m2",
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "world" }],
|
||||
},
|
||||
],
|
||||
} satisfies NonNullable<
|
||||
Awaited<ReturnType<typeof readSessionMessagesArtifact>>
|
||||
>;
|
||||
mockedReadSessionMessagesArtifact.mockResolvedValue(artifact);
|
||||
|
||||
const targetPath = await exportHistorySession({
|
||||
sessionId: "sess_1",
|
||||
format: "json",
|
||||
outputDirectory: tempDir,
|
||||
});
|
||||
|
||||
expect(targetPath).toBe(join(tempDir, "sess_1.json"));
|
||||
await expect(
|
||||
readFile(targetPath, "utf8").then((contents) => JSON.parse(contents)),
|
||||
).resolves.toEqual(artifact);
|
||||
});
|
||||
|
||||
it("exports run_commands history with structured command objects", async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "cline-history-export-"));
|
||||
const outputPath = join(tempDir, "export.html");
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { generateConversationHTML } from "../session/export";
|
||||
import {
|
||||
deleteSession,
|
||||
listSessions,
|
||||
readSessionMessagesArtifact,
|
||||
updateSession,
|
||||
} from "../session/session";
|
||||
import { disableOpenTuiGraphicsProbe } from "../tui/opentui-env";
|
||||
import { exportHistorySession } from "../session/history-export";
|
||||
import { deleteSession, listSessions, updateSession } from "../session/session";
|
||||
import { formatHistoryListLine } from "../utils/history-format";
|
||||
import { writeln } from "../utils/output";
|
||||
import type { CliOutputMode } from "../utils/types";
|
||||
|
||||
@@ -22,22 +15,6 @@ type HistoryIo = {
|
||||
writeErr: (text: string) => void;
|
||||
};
|
||||
|
||||
async function exportHistorySession(
|
||||
sessionId: string,
|
||||
outputPath?: string,
|
||||
): Promise<string> {
|
||||
const data = await readSessionMessagesArtifact(sessionId);
|
||||
if (!data) {
|
||||
throw new Error(`Session ${sessionId} not found or has no messages.json`);
|
||||
}
|
||||
|
||||
const targetPath = resolve(outputPath?.trim() || `${sessionId}.html`);
|
||||
const html = generateConversationHTML(data, sessionId);
|
||||
await mkdir(dirname(targetPath), { recursive: true });
|
||||
await writeFile(targetPath, html, "utf8");
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
async function runHistoryDelete(
|
||||
sessionId: string | undefined,
|
||||
outputMode: CliOutputMode,
|
||||
@@ -136,7 +113,11 @@ async function runHistoryExport(
|
||||
}
|
||||
|
||||
try {
|
||||
const targetPath = await exportHistorySession(sessionId, outputPath);
|
||||
const targetPath = await exportHistorySession({
|
||||
sessionId,
|
||||
format: "html",
|
||||
outputPath,
|
||||
});
|
||||
|
||||
if (outputMode === "json") {
|
||||
process.stdout.write(
|
||||
@@ -161,7 +142,7 @@ export async function runHistoryList(input: {
|
||||
outputMode: CliOutputMode;
|
||||
workspaceRoot?: string;
|
||||
io?: HistoryIo;
|
||||
}): Promise<number | string> {
|
||||
}): Promise<number> {
|
||||
const io = input.io ?? {
|
||||
writeln,
|
||||
writeErr: (text: string) => process.stderr.write(`${text}\n`),
|
||||
@@ -186,23 +167,10 @@ export async function runHistoryList(input: {
|
||||
return 0;
|
||||
}
|
||||
|
||||
disableOpenTuiGraphicsProbe();
|
||||
const { renderHistoryStandalone } = await import("../tui/history-standalone");
|
||||
return await renderHistoryStandalone({
|
||||
rows,
|
||||
refreshRows: async () =>
|
||||
await listSessions(limit, {
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
hydrate: false,
|
||||
}),
|
||||
onExport: async (sessionId: string) =>
|
||||
await exportHistorySession(sessionId, undefined),
|
||||
});
|
||||
for (const row of rows) {
|
||||
io.writeln(formatHistoryListLine(row));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export {
|
||||
exportHistorySession,
|
||||
runHistoryDelete,
|
||||
runHistoryExport,
|
||||
runHistoryUpdate,
|
||||
};
|
||||
export { runHistoryDelete, runHistoryExport, runHistoryUpdate };
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { relative, sep } from "node:path";
|
||||
import {
|
||||
resolveClineDataDir,
|
||||
resolveClineDir,
|
||||
setHomeDir,
|
||||
} from "@cline/shared/storage";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { createProgram } from "./program";
|
||||
|
||||
/** Render an absolute path under `home` the way help text does: `~/...`. */
|
||||
function tildePath(absolutePath: string, home: string): string {
|
||||
return `~/${relative(home, absolutePath).split(sep).join("/")}`;
|
||||
}
|
||||
|
||||
describe("root option help text", () => {
|
||||
const FAKE_HOME = "/home/cline-help-test";
|
||||
const savedEnv: Record<string, string | undefined> = {};
|
||||
|
||||
beforeAll(() => {
|
||||
// Pin the resolver inputs so the defaults below are the true defaults
|
||||
// (no CLINE_DIR/CLINE_DATA_DIR overrides, known home directory).
|
||||
for (const key of ["CLINE_DIR", "CLINE_DATA_DIR"]) {
|
||||
savedEnv[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
setHomeDir(FAKE_HOME);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
for (const [key, value] of Object.entries(savedEnv)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("reports the actual resolver defaults for --config and --data-dir", () => {
|
||||
// A wide help width keeps each option description on one line so the
|
||||
// full default text can be matched.
|
||||
const help = createProgram()
|
||||
.configureHelp({ helpWidth: 500 })
|
||||
.helpInformation();
|
||||
|
||||
const configDefault = tildePath(resolveClineDir(), FAKE_HOME);
|
||||
const dataDirDefault = tildePath(resolveClineDataDir(), FAKE_HOME);
|
||||
|
||||
// Sanity-check the resolvers themselves so the assertions below can't
|
||||
// silently drift along with a resolver regression.
|
||||
expect(configDefault).toBe("~/.cline");
|
||||
expect(dataDirDefault).toBe("~/.cline/data");
|
||||
|
||||
expect(help).toContain(
|
||||
`Configuration directory (default: ${configDefault})`,
|
||||
);
|
||||
expect(help).toContain(
|
||||
`Use isolated local state at this directory path (default: ${dataDirDefault})`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -64,13 +64,10 @@ export function addRootOptions(cmd: Command): Command {
|
||||
"--acp",
|
||||
"Run in Agent Client Protocol (ACP) mode for editor integration",
|
||||
)
|
||||
.option(
|
||||
"--config <path>",
|
||||
"Configuration directory (default: ~/.cline/data/settings)",
|
||||
)
|
||||
.option("--config <path>", "Configuration directory (default: ~/.cline)")
|
||||
.option(
|
||||
"--data-dir <path>",
|
||||
"Use isolated local state at this directory path (default: ~/.cline)",
|
||||
"Use isolated local state at this directory path (default: ~/.cline/data)",
|
||||
)
|
||||
.option(
|
||||
"--hooks-dir <path>",
|
||||
|
||||
@@ -1120,6 +1120,8 @@ class DiscordConnector extends ConnectorBase<
|
||||
},
|
||||
) => {
|
||||
const queueKey = thread.id;
|
||||
const enqueueTurn = (work: () => Promise<void>) =>
|
||||
enqueueThreadTurn(threadQueues, queueKey, work);
|
||||
const runTurn = async () => {
|
||||
try {
|
||||
await handleConnectorUserTurn({
|
||||
@@ -1152,6 +1154,7 @@ class DiscordConnector extends ConnectorBase<
|
||||
userInstructionService,
|
||||
chatCommandHost,
|
||||
activeTurns,
|
||||
enqueueTurn,
|
||||
turnKey: queueKey,
|
||||
resolveMuteTarget: ({ target }) => resolveDiscordMuteTarget(target),
|
||||
createEmptyRuntimeReplyResolver:
|
||||
@@ -1291,9 +1294,7 @@ class DiscordConnector extends ConnectorBase<
|
||||
await runTurn();
|
||||
return;
|
||||
}
|
||||
await enqueueThreadTurn(threadQueues, queueKey, async () => {
|
||||
await runTurn();
|
||||
});
|
||||
await enqueueTurn(runTurn);
|
||||
};
|
||||
|
||||
bot.onNewMention(async (thread, message) => {
|
||||
|
||||
@@ -615,6 +615,8 @@ class GoogleChatConnector extends ConnectorBase<
|
||||
text: string,
|
||||
) => {
|
||||
const queueKey = thread.id;
|
||||
const enqueueTurn = (work: () => Promise<void>) =>
|
||||
enqueueThreadTurn(threadQueues, queueKey, work);
|
||||
const runTurn = async () => {
|
||||
try {
|
||||
await handleConnectorUserTurn({
|
||||
@@ -637,6 +639,7 @@ class GoogleChatConnector extends ConnectorBase<
|
||||
userInstructionService,
|
||||
chatCommandHost,
|
||||
activeTurns,
|
||||
enqueueTurn,
|
||||
turnKey: queueKey,
|
||||
getSessionMetadata: (currentThread, _clientId, currentState) => ({
|
||||
userName: options.userName,
|
||||
@@ -698,9 +701,7 @@ class GoogleChatConnector extends ConnectorBase<
|
||||
await runTurn();
|
||||
return;
|
||||
}
|
||||
await enqueueThreadTurn(threadQueues, queueKey, async () => {
|
||||
await runTurn();
|
||||
});
|
||||
await enqueueTurn(runTurn);
|
||||
};
|
||||
|
||||
bot.onNewMention(async (thread, message) => {
|
||||
|
||||
@@ -638,6 +638,8 @@ class LinearConnector extends ConnectorBase<
|
||||
text: string,
|
||||
) => {
|
||||
const queueKey = thread.id;
|
||||
const enqueueTurn = (work: () => Promise<void>) =>
|
||||
enqueueThreadTurn(threadQueues, queueKey, work);
|
||||
const runTurn = async () => {
|
||||
try {
|
||||
await handleConnectorUserTurn({
|
||||
@@ -662,6 +664,7 @@ class LinearConnector extends ConnectorBase<
|
||||
userInstructionService,
|
||||
chatCommandHost,
|
||||
activeTurns,
|
||||
enqueueTurn,
|
||||
turnKey: queueKey,
|
||||
getSessionMetadata: (currentThread, _clientId, currentState) => ({
|
||||
userName: options.userName,
|
||||
@@ -723,9 +726,7 @@ class LinearConnector extends ConnectorBase<
|
||||
await runTurn();
|
||||
return;
|
||||
}
|
||||
await enqueueThreadTurn(threadQueues, queueKey, async () => {
|
||||
await runTurn();
|
||||
});
|
||||
await enqueueTurn(runTurn);
|
||||
};
|
||||
|
||||
bot.onNewMention(async (thread, message) => {
|
||||
|
||||
@@ -839,6 +839,8 @@ class SlackConnector extends ConnectorBase<
|
||||
startRequest,
|
||||
);
|
||||
const queueKey = thread.id;
|
||||
const enqueueTurn = (work: () => Promise<void>) =>
|
||||
enqueueThreadTurn(threadQueues, queueKey, work);
|
||||
const runTurn = async () => {
|
||||
try {
|
||||
await withSlackTeamBotToken({
|
||||
@@ -867,6 +869,7 @@ class SlackConnector extends ConnectorBase<
|
||||
userInstructionService,
|
||||
chatCommandHost,
|
||||
activeTurns,
|
||||
enqueueTurn,
|
||||
turnKey: queueKey,
|
||||
getSessionMetadata: (
|
||||
currentThread,
|
||||
@@ -951,9 +954,7 @@ class SlackConnector extends ConnectorBase<
|
||||
await runTurn();
|
||||
return;
|
||||
}
|
||||
await enqueueThreadTurn(threadQueues, queueKey, async () => {
|
||||
await runTurn();
|
||||
});
|
||||
await enqueueTurn(runTurn);
|
||||
};
|
||||
|
||||
bot.onNewMention(async (thread, message) => {
|
||||
|
||||
@@ -817,6 +817,8 @@ class TelegramConnector extends ConnectorBase<
|
||||
text: string,
|
||||
) => {
|
||||
const queueKey = thread.id;
|
||||
const enqueueTurn = (work: () => Promise<void>) =>
|
||||
enqueueThreadTurn(threadQueues, queueKey, work);
|
||||
const runTurn = async () => {
|
||||
try {
|
||||
await handleConnectorUserTurn({
|
||||
@@ -841,6 +843,7 @@ class TelegramConnector extends ConnectorBase<
|
||||
userInstructionService,
|
||||
chatCommandHost,
|
||||
activeTurns,
|
||||
enqueueTurn,
|
||||
turnKey: queueKey,
|
||||
forceDisableTools: !options.enableTools,
|
||||
postFinalReply: async ({
|
||||
@@ -952,9 +955,7 @@ class TelegramConnector extends ConnectorBase<
|
||||
await runTurn();
|
||||
return;
|
||||
}
|
||||
await enqueueThreadTurn(threadQueues, queueKey, async () => {
|
||||
await runTurn();
|
||||
});
|
||||
await enqueueTurn(runTurn);
|
||||
};
|
||||
|
||||
bot.onNewMention(async (thread, message) => {
|
||||
|
||||
@@ -609,6 +609,8 @@ class WhatsAppConnector extends ConnectorBase<
|
||||
text: string,
|
||||
) => {
|
||||
const queueKey = thread.id;
|
||||
const enqueueTurn = (work: () => Promise<void>) =>
|
||||
enqueueThreadTurn(threadQueues, queueKey, work);
|
||||
const runTurn = async () => {
|
||||
try {
|
||||
await handleConnectorUserTurn({
|
||||
@@ -633,6 +635,7 @@ class WhatsAppConnector extends ConnectorBase<
|
||||
userInstructionService,
|
||||
chatCommandHost,
|
||||
activeTurns,
|
||||
enqueueTurn,
|
||||
turnKey: queueKey,
|
||||
getSessionMetadata: (currentThread, _clientId, currentState) => ({
|
||||
userName: options.userName,
|
||||
@@ -709,9 +712,7 @@ class WhatsAppConnector extends ConnectorBase<
|
||||
await runTurn();
|
||||
return;
|
||||
}
|
||||
await enqueueThreadTurn(threadQueues, queueKey, async () => {
|
||||
await runTurn();
|
||||
});
|
||||
await enqueueTurn(runTurn);
|
||||
};
|
||||
|
||||
bot.onNewMention(async (thread, message) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { SentMessage } from "chat";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { enqueueThreadTurn } from "./chat-runtime";
|
||||
import { handleConnectorUserTurn } from "./connector-host";
|
||||
|
||||
vi.mock("./hooks", () => ({
|
||||
@@ -43,7 +44,22 @@ function createThread(initialState: TestState = {}, isDM = true) {
|
||||
state = { ...nextState };
|
||||
},
|
||||
async post(message: unknown) {
|
||||
posts.push(message);
|
||||
// Real thread implementations drain async-iterable replies; the
|
||||
// connector streams runtime output straight into post() for
|
||||
// transports without a custom final-reply hook.
|
||||
if (
|
||||
message &&
|
||||
typeof message === "object" &&
|
||||
Symbol.asyncIterator in message
|
||||
) {
|
||||
let streamed = "";
|
||||
for await (const chunk of message as AsyncIterable<string>) {
|
||||
streamed += chunk;
|
||||
}
|
||||
posts.push(streamed);
|
||||
} else {
|
||||
posts.push(message);
|
||||
}
|
||||
const sentMessage = {
|
||||
edit: async (nextMessage: unknown) => {
|
||||
posts.push(nextMessage);
|
||||
@@ -99,13 +115,21 @@ function createRuntimeClient(
|
||||
);
|
||||
const abortRuntimeSession = vi.fn(async () => undefined);
|
||||
const deleteSession = vi.fn(async () => undefined);
|
||||
const sendRuntimeSession = vi.fn(async () => ({
|
||||
result: {
|
||||
text: responseText,
|
||||
finishReason: "stop",
|
||||
iterations: 1,
|
||||
},
|
||||
}));
|
||||
const sendRuntimeSession = vi.fn(
|
||||
async (
|
||||
_sessionId: string,
|
||||
_request?: unknown,
|
||||
_options?: unknown,
|
||||
): Promise<{
|
||||
result?: { text: string; finishReason: string; iterations: number };
|
||||
}> => ({
|
||||
result: {
|
||||
text: responseText,
|
||||
finishReason: "stop",
|
||||
iterations: 1,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const readMessages = vi.fn(async () => messages);
|
||||
return {
|
||||
client: {
|
||||
@@ -138,6 +162,10 @@ function messageText(message: unknown): string {
|
||||
return String(message);
|
||||
}
|
||||
|
||||
async function runTurnImmediately(work: () => Promise<void>): Promise<void> {
|
||||
await work();
|
||||
}
|
||||
|
||||
describe("handleConnectorUserTurn", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
@@ -542,6 +570,316 @@ describe("handleConnectorUserTurn", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("recovers from a stale thread session mapping by starting a new session", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
|
||||
tempDirs.push(dir);
|
||||
const bindingsPath = join(dir, "threads.json");
|
||||
const { thread, posts, getState } = createThread({
|
||||
enableTools: false,
|
||||
autoApproveTools: false,
|
||||
cwd: "/tmp/work",
|
||||
workspaceRoot: "/tmp/work",
|
||||
sessionId: "dead-session",
|
||||
welcomeSentAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const runtime = createRuntimeClient("recovered reply");
|
||||
// The hub still reports the persisted session row, so the connector reuses
|
||||
// the stale mapping...
|
||||
runtime.getSession.mockImplementation(async (sessionId: string) => ({
|
||||
sessionId,
|
||||
}));
|
||||
// ...but sending input to the dead session fails with session_not_found
|
||||
// until a fresh session id is used.
|
||||
runtime.startRuntimeSession.mockResolvedValue({
|
||||
sessionId: "fresh-session",
|
||||
});
|
||||
runtime.sendRuntimeSession.mockImplementation(async (sessionId: string) => {
|
||||
if (sessionId === "dead-session") {
|
||||
throw Object.assign(new Error("session not found: dead-session"), {
|
||||
code: "session_not_found",
|
||||
});
|
||||
}
|
||||
return {
|
||||
result: {
|
||||
text: "recovered reply",
|
||||
finishReason: "stop",
|
||||
iterations: 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await handleConnectorUserTurn({
|
||||
thread: thread as never,
|
||||
text: "are you there?",
|
||||
client: runtime.client as never,
|
||||
pendingApprovals: new Map(),
|
||||
baseStartRequest: baseStartRequest() as never,
|
||||
explicitSystemPrompt: undefined,
|
||||
clientId: "client-1",
|
||||
logger: {
|
||||
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
|
||||
} as never,
|
||||
transport: "slack",
|
||||
botUserName: "ClineAdapterBot",
|
||||
requestStop: vi.fn(),
|
||||
bindingsPath,
|
||||
systemRules: "rules",
|
||||
errorLabel: "Slack",
|
||||
getSessionMetadata: () => ({}),
|
||||
reusedLogMessage: "reused",
|
||||
startedLogMessage: "started",
|
||||
});
|
||||
|
||||
expect(
|
||||
runtime.sendRuntimeSession.mock.calls.map((call) => call[0]),
|
||||
).toEqual(["dead-session", "fresh-session"]);
|
||||
expect(runtime.startRuntimeSession).toHaveBeenCalledTimes(1);
|
||||
expect(getState().sessionId).toBe("fresh-session");
|
||||
expect(messageText(posts.at(-1))).toBe("recovered reply");
|
||||
expect(
|
||||
posts.some((message) =>
|
||||
messageText(message).includes("session not found"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not retry forever when the replacement session is also missing", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
|
||||
tempDirs.push(dir);
|
||||
const bindingsPath = join(dir, "threads.json");
|
||||
const { thread } = createThread({
|
||||
enableTools: false,
|
||||
autoApproveTools: false,
|
||||
cwd: "/tmp/work",
|
||||
workspaceRoot: "/tmp/work",
|
||||
sessionId: "dead-session",
|
||||
welcomeSentAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const runtime = createRuntimeClient("never delivered");
|
||||
runtime.getSession.mockImplementation(async (sessionId: string) => ({
|
||||
sessionId,
|
||||
}));
|
||||
runtime.startRuntimeSession.mockResolvedValue({
|
||||
sessionId: "also-dead-session",
|
||||
});
|
||||
runtime.sendRuntimeSession.mockImplementation(async () => {
|
||||
throw Object.assign(new Error("session not found"), {
|
||||
code: "session_not_found",
|
||||
});
|
||||
});
|
||||
|
||||
await expect(
|
||||
handleConnectorUserTurn({
|
||||
thread: thread as never,
|
||||
text: "are you there?",
|
||||
client: runtime.client as never,
|
||||
pendingApprovals: new Map(),
|
||||
baseStartRequest: baseStartRequest() as never,
|
||||
explicitSystemPrompt: undefined,
|
||||
clientId: "client-1",
|
||||
logger: {
|
||||
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
|
||||
} as never,
|
||||
transport: "slack",
|
||||
botUserName: "ClineAdapterBot",
|
||||
requestStop: vi.fn(),
|
||||
bindingsPath,
|
||||
systemRules: "rules",
|
||||
errorLabel: "Slack",
|
||||
getSessionMetadata: () => ({}),
|
||||
reusedLogMessage: "reused",
|
||||
startedLogMessage: "started",
|
||||
}),
|
||||
).rejects.toThrow(/session not found/);
|
||||
|
||||
expect(runtime.sendRuntimeSession).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("starts a new session when steering an active turn hits a dead session", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
|
||||
tempDirs.push(dir);
|
||||
const bindingsPath = join(dir, "threads.json");
|
||||
const { thread, posts, getState } = createThread({
|
||||
enableTools: false,
|
||||
autoApproveTools: false,
|
||||
cwd: "/tmp/work",
|
||||
workspaceRoot: "/tmp/work",
|
||||
sessionId: "dead-session",
|
||||
welcomeSentAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const runtime = createRuntimeClient("recovered reply");
|
||||
runtime.getSession.mockImplementation(async (sessionId: string) => ({
|
||||
sessionId,
|
||||
}));
|
||||
runtime.startRuntimeSession.mockResolvedValue({
|
||||
sessionId: "fresh-session",
|
||||
});
|
||||
runtime.sendRuntimeSession.mockImplementation(async (sessionId: string) => {
|
||||
if (sessionId === "dead-session") {
|
||||
throw Object.assign(new Error("session not found: dead-session"), {
|
||||
code: "session_not_found",
|
||||
});
|
||||
}
|
||||
return {
|
||||
result: {
|
||||
text: "recovered reply",
|
||||
finishReason: "stop",
|
||||
iterations: 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
const activeTurns = new Map([
|
||||
["thread-1", { sessionId: "dead-session", threadId: "thread-1" }],
|
||||
]);
|
||||
|
||||
await handleConnectorUserTurn({
|
||||
thread: thread as never,
|
||||
text: "actually do this instead",
|
||||
client: runtime.client as never,
|
||||
pendingApprovals: new Map(),
|
||||
baseStartRequest: baseStartRequest() as never,
|
||||
explicitSystemPrompt: undefined,
|
||||
clientId: "client-1",
|
||||
logger: {
|
||||
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
|
||||
} as never,
|
||||
transport: "slack",
|
||||
botUserName: "ClineAdapterBot",
|
||||
requestStop: vi.fn(),
|
||||
bindingsPath,
|
||||
systemRules: "rules",
|
||||
errorLabel: "Slack",
|
||||
getSessionMetadata: () => ({}),
|
||||
reusedLogMessage: "reused",
|
||||
startedLogMessage: "started",
|
||||
activeTurns: activeTurns as never,
|
||||
enqueueTurn: runTurnImmediately,
|
||||
turnKey: "thread-1",
|
||||
});
|
||||
|
||||
expect(runtime.startRuntimeSession).toHaveBeenCalledTimes(1);
|
||||
expect(getState().sessionId).toBe("fresh-session");
|
||||
expect(messageText(posts.at(-1))).toBe("recovered reply");
|
||||
expect(
|
||||
posts.some((message) =>
|
||||
messageText(message).includes("Steering current task."),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("serializes concurrent recovery from the same stale active turn", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
|
||||
tempDirs.push(dir);
|
||||
const bindingsPath = join(dir, "threads.json");
|
||||
const { thread, posts, getState } = createThread({
|
||||
enableTools: false,
|
||||
autoApproveTools: false,
|
||||
cwd: "/tmp/work",
|
||||
workspaceRoot: "/tmp/work",
|
||||
sessionId: "dead-session",
|
||||
welcomeSentAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const runtime = createRuntimeClient("unused");
|
||||
runtime.getSession.mockImplementation(async (sessionId: string) => ({
|
||||
sessionId,
|
||||
}));
|
||||
runtime.startRuntimeSession.mockResolvedValue({
|
||||
sessionId: "fresh-session",
|
||||
});
|
||||
|
||||
let releaseStaleSteers = () => {};
|
||||
const bothStaleSteersStarted = new Promise<void>((resolve) => {
|
||||
releaseStaleSteers = resolve;
|
||||
});
|
||||
let staleSteerCount = 0;
|
||||
runtime.sendRuntimeSession.mockImplementation(
|
||||
async (sessionId: string, request?: unknown) => {
|
||||
if (sessionId === "dead-session") {
|
||||
staleSteerCount += 1;
|
||||
if (staleSteerCount === 2) {
|
||||
releaseStaleSteers();
|
||||
}
|
||||
await bothStaleSteersStarted;
|
||||
throw Object.assign(new Error("session not found: dead-session"), {
|
||||
code: "session_not_found",
|
||||
});
|
||||
}
|
||||
const prompt =
|
||||
request && typeof request === "object" && "prompt" in request
|
||||
? String((request as { prompt?: unknown }).prompt)
|
||||
: "";
|
||||
return {
|
||||
result: {
|
||||
text: `recovered: ${prompt}`,
|
||||
finishReason: "stop",
|
||||
iterations: 1,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const activeTurns = new Map([
|
||||
["thread-1", { sessionId: "dead-session", threadId: "thread-1" }],
|
||||
]);
|
||||
const threadQueues = new Map<string, Promise<void>>();
|
||||
const enqueueTurn = (work: () => Promise<void>) =>
|
||||
enqueueThreadTurn(threadQueues, "thread-1", work);
|
||||
const commonInput = {
|
||||
thread: thread as never,
|
||||
client: runtime.client as never,
|
||||
pendingApprovals: new Map(),
|
||||
baseStartRequest: baseStartRequest() as never,
|
||||
explicitSystemPrompt: undefined,
|
||||
clientId: "client-1",
|
||||
logger: {
|
||||
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
|
||||
} as never,
|
||||
transport: "slack",
|
||||
botUserName: "ClineAdapterBot",
|
||||
requestStop: vi.fn(),
|
||||
bindingsPath,
|
||||
systemRules: "rules",
|
||||
errorLabel: "Slack",
|
||||
getSessionMetadata: () => ({}),
|
||||
reusedLogMessage: "reused",
|
||||
startedLogMessage: "started",
|
||||
activeTurns,
|
||||
enqueueTurn,
|
||||
turnKey: "thread-1",
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
handleConnectorUserTurn({
|
||||
...commonInput,
|
||||
text: "first recovery message",
|
||||
}),
|
||||
handleConnectorUserTurn({
|
||||
...commonInput,
|
||||
text: "second recovery message",
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(runtime.startRuntimeSession).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
runtime.sendRuntimeSession.mock.calls.map((call) => call[0]),
|
||||
).toEqual([
|
||||
"dead-session",
|
||||
"dead-session",
|
||||
"fresh-session",
|
||||
"fresh-session",
|
||||
]);
|
||||
expect(getState().sessionId).toBe("fresh-session");
|
||||
expect(activeTurns.size).toBe(0);
|
||||
expect(posts.map(messageText)).toEqual([
|
||||
"recovered: first recovery message",
|
||||
"recovered: second recovery message",
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates schedules with forced-disabled runtime options", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
|
||||
tempDirs.push(dir);
|
||||
@@ -977,6 +1315,7 @@ describe("handleConnectorUserTurn", () => {
|
||||
reusedLogMessage: "reused",
|
||||
startedLogMessage: "started",
|
||||
activeTurns,
|
||||
enqueueTurn: runTurnImmediately,
|
||||
turnKey: "current-participant",
|
||||
});
|
||||
|
||||
@@ -1225,6 +1564,7 @@ describe("handleConnectorUserTurn", () => {
|
||||
reusedLogMessage: "reused",
|
||||
startedLogMessage: "started",
|
||||
activeTurns,
|
||||
enqueueTurn: runTurnImmediately,
|
||||
resolveMuteTarget: () => ({
|
||||
participantKey: "discord:user:bob",
|
||||
participantLabel: "<@bob>",
|
||||
@@ -1426,6 +1766,7 @@ describe("handleConnectorUserTurn", () => {
|
||||
getSessionMetadata: () => ({}),
|
||||
reusedLogMessage: "reused",
|
||||
activeTurns,
|
||||
enqueueTurn: runTurnImmediately,
|
||||
});
|
||||
|
||||
expect(runtime.startRuntimeSession).not.toHaveBeenCalled();
|
||||
@@ -1476,6 +1817,7 @@ describe("handleConnectorUserTurn", () => {
|
||||
getSessionMetadata: () => ({}),
|
||||
reusedLogMessage: "reused",
|
||||
activeTurns,
|
||||
enqueueTurn: runTurnImmediately,
|
||||
turnKey: "thread-1",
|
||||
});
|
||||
|
||||
@@ -1526,6 +1868,7 @@ describe("handleConnectorUserTurn", () => {
|
||||
getSessionMetadata: () => ({}),
|
||||
reusedLogMessage: "reused",
|
||||
activeTurns,
|
||||
enqueueTurn: runTurnImmediately,
|
||||
turnKey: "thread-1",
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
HubSessionClient,
|
||||
UserInstructionConfigService,
|
||||
} from "@cline/core";
|
||||
import { isSessionNotFoundError } from "@cline/core";
|
||||
import type { SentMessage, Thread } from "chat";
|
||||
import type { CliLoggerAdapter } from "../logging/adapter";
|
||||
import { buildUserInputMessage, resolveSystemPrompt } from "../runtime/prompt";
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
import {
|
||||
buildThreadStartRequest,
|
||||
clearSession,
|
||||
forgetThreadSession,
|
||||
getOrCreateSessionId,
|
||||
} from "./session-runtime";
|
||||
import {
|
||||
@@ -51,6 +53,20 @@ export type ActiveConnectorTurn = {
|
||||
participantKey?: string;
|
||||
};
|
||||
|
||||
type ConnectorTurnQueue = (work: () => Promise<void>) => Promise<void>;
|
||||
|
||||
type ConnectorTurnCoordination =
|
||||
| {
|
||||
activeTurns: Map<string, ActiveConnectorTurn>;
|
||||
enqueueTurn: ConnectorTurnQueue;
|
||||
turnKey?: string;
|
||||
}
|
||||
| {
|
||||
activeTurns?: undefined;
|
||||
enqueueTurn?: undefined;
|
||||
turnKey?: string;
|
||||
};
|
||||
|
||||
type EmptyRuntimeReplyResolver = () => Promise<string | undefined>;
|
||||
|
||||
type EmptyRuntimeReplyResolverFactory = (input: {
|
||||
@@ -135,6 +151,45 @@ async function postConnectorRuntimeReply<TState extends ConnectorThreadState>(
|
||||
await postConnectorText(thread, transport, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears a thread's stale session mapping after the hub reported the mapped
|
||||
* session no longer exists, so the next turn starts a fresh session instead of
|
||||
* failing forever against a dead session id.
|
||||
*/
|
||||
async function forgetStaleThreadSession<
|
||||
TState extends ConnectorThreadState,
|
||||
>(input: {
|
||||
thread: Thread<TState>;
|
||||
bindingsPath: string;
|
||||
baseStartRequest: ChatStartSessionRequest;
|
||||
errorLabel: string;
|
||||
logger: CliLoggerAdapter;
|
||||
transport: string;
|
||||
sessionId: string;
|
||||
}): Promise<boolean> {
|
||||
const forgotten = await forgetThreadSession({
|
||||
thread: input.thread,
|
||||
bindingsPath: input.bindingsPath,
|
||||
baseStartRequest: input.baseStartRequest,
|
||||
errorLabel: input.errorLabel,
|
||||
expectedSessionId: input.sessionId,
|
||||
});
|
||||
if (!forgotten) {
|
||||
return false;
|
||||
}
|
||||
input.logger.core.log(
|
||||
"Connector thread session no longer exists; starting a new session",
|
||||
{
|
||||
severity: "warn",
|
||||
transport: input.transport,
|
||||
threadId: input.thread.id,
|
||||
channelId: input.thread.channelId,
|
||||
sessionId: input.sessionId,
|
||||
},
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyForcedToolDisable<TState extends ConnectorThreadState>(
|
||||
state: TState,
|
||||
forceDisableTools: boolean | undefined,
|
||||
@@ -200,9 +255,7 @@ function formatMuteTargetList(targets: ConnectorMuteTarget[]): string {
|
||||
return targets.map(formatMuteTargetLabel).join(", ");
|
||||
}
|
||||
|
||||
export async function handleConnectorUserTurn<
|
||||
TState extends ConnectorThreadState,
|
||||
>(input: {
|
||||
type ConnectorUserTurnInput<TState extends ConnectorThreadState> = {
|
||||
thread: Thread<TState>;
|
||||
text: string;
|
||||
runtimeText?: string;
|
||||
@@ -231,8 +284,6 @@ export async function handleConnectorUserTurn<
|
||||
firstContactMessage?: string | ((currentState: TState) => string | undefined);
|
||||
chatCommandHost?: ChatCommandHost;
|
||||
userInstructionService?: UserInstructionConfigService;
|
||||
activeTurns?: Map<string, ActiveConnectorTurn>;
|
||||
turnKey?: string;
|
||||
resolveMuteTarget?: (input: {
|
||||
target: string;
|
||||
thread: Thread<TState>;
|
||||
@@ -276,7 +327,11 @@ export async function handleConnectorUserTurn<
|
||||
threadId: string;
|
||||
error: Error;
|
||||
}) => Promise<void>;
|
||||
}): Promise<void> {
|
||||
} & ConnectorTurnCoordination;
|
||||
|
||||
export async function handleConnectorUserTurn<
|
||||
TState extends ConnectorThreadState,
|
||||
>(input: ConnectorUserTurnInput<TState>): Promise<void> {
|
||||
const resolvedInput = input.text.trim();
|
||||
if (!resolvedInput) {
|
||||
return;
|
||||
@@ -892,30 +947,61 @@ export async function handleConnectorUserTurn<
|
||||
input.baseStartRequest,
|
||||
effectiveCurrentState,
|
||||
);
|
||||
const activeTurn =
|
||||
input.activeTurns?.get(turnKey) ??
|
||||
(input.activeTurns && currentState.sessionId?.trim()
|
||||
? Array.from(input.activeTurns.values()).find(
|
||||
(turn) =>
|
||||
const keyedActiveTurn = input.activeTurns?.get(turnKey);
|
||||
const activeTurnEntry = keyedActiveTurn
|
||||
? ([turnKey, keyedActiveTurn] as const)
|
||||
: input.activeTurns && currentState.sessionId?.trim()
|
||||
? Array.from(input.activeTurns.entries()).find(
|
||||
([, turn]) =>
|
||||
turn.sessionId === currentState.sessionId?.trim() &&
|
||||
turn.threadId === input.thread.id,
|
||||
)
|
||||
: undefined);
|
||||
if (activeTurn?.sessionId?.trim()) {
|
||||
: undefined;
|
||||
if (activeTurnEntry?.[1].sessionId?.trim()) {
|
||||
const [activeTurnKey, activeTurn] = activeTurnEntry;
|
||||
const { prompt, userImages, userFiles } = await buildUserInputMessage(
|
||||
runtimeInput,
|
||||
input.userInstructionService,
|
||||
);
|
||||
await input.client.sendRuntimeSession(
|
||||
activeTurn.sessionId,
|
||||
{
|
||||
config: startRequest,
|
||||
prompt,
|
||||
attachments: buildAttachments({ userImages, userFiles }),
|
||||
delivery: "steer",
|
||||
},
|
||||
{ timeoutMs: null },
|
||||
);
|
||||
try {
|
||||
await input.client.sendRuntimeSession(
|
||||
activeTurn.sessionId,
|
||||
{
|
||||
config: startRequest,
|
||||
prompt,
|
||||
attachments: buildAttachments({ userImages, userFiles }),
|
||||
delivery: "steer",
|
||||
},
|
||||
{ timeoutMs: null },
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isSessionNotFoundError(error)) {
|
||||
throw error;
|
||||
}
|
||||
// The tracked turn points at a session the hub no longer knows about.
|
||||
// Remove only the entry we attempted to steer, then route recovery
|
||||
// through the normal per-thread queue. Concurrent messages that saw
|
||||
// the same stale turn will line up behind this one instead of creating
|
||||
// independent replacement sessions.
|
||||
if (input.activeTurns?.get(activeTurnKey) === activeTurn) {
|
||||
input.activeTurns.delete(activeTurnKey);
|
||||
}
|
||||
const enqueueTurn = input.enqueueTurn;
|
||||
if (!enqueueTurn) {
|
||||
throw new Error(
|
||||
"Active connector turns require a per-thread turn queue",
|
||||
);
|
||||
}
|
||||
await enqueueTurn(() =>
|
||||
runConnectorRuntimeTurnWithRecovery({
|
||||
input,
|
||||
runtimeInput,
|
||||
turnKey,
|
||||
staleSessionId: activeTurn.sessionId,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await postConnectorText(
|
||||
input.thread,
|
||||
input.transport,
|
||||
@@ -923,25 +1009,44 @@ export async function handleConnectorUserTurn<
|
||||
);
|
||||
return;
|
||||
}
|
||||
const sessionId = await getOrCreateSessionId({
|
||||
thread: input.thread,
|
||||
client: input.client,
|
||||
startRequest,
|
||||
logger: input.logger,
|
||||
clientId: input.clientId,
|
||||
transport: input.transport,
|
||||
bindingsPath: input.bindingsPath,
|
||||
errorLabel: input.errorLabel,
|
||||
hookCommand: input.hookCommand,
|
||||
hookBotUserName: input.botUserName,
|
||||
sessionMetadata: input.getSessionMetadata(
|
||||
input.thread,
|
||||
input.clientId,
|
||||
currentState,
|
||||
),
|
||||
reusedLogMessage: input.reusedLogMessage,
|
||||
startedLogMessage: input.startedLogMessage,
|
||||
|
||||
await runConnectorRuntimeTurnWithRecovery({
|
||||
input,
|
||||
runtimeInput,
|
||||
turnKey,
|
||||
currentState,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a queued connector turn, replacing a stale session mapping at most once
|
||||
* before replaying the user's input.
|
||||
*/
|
||||
async function runConnectorRuntimeTurnWithRecovery<
|
||||
TState extends ConnectorThreadState,
|
||||
>(params: {
|
||||
input: ConnectorUserTurnInput<TState>;
|
||||
runtimeInput: string;
|
||||
turnKey: string;
|
||||
currentState?: TState;
|
||||
staleSessionId?: string;
|
||||
}): Promise<void> {
|
||||
const { input, runtimeInput, turnKey } = params;
|
||||
const currentState =
|
||||
params.currentState ??
|
||||
(await loadThreadState(
|
||||
input.thread,
|
||||
input.bindingsPath,
|
||||
input.baseStartRequest,
|
||||
));
|
||||
const effectiveCurrentState = applyForcedToolDisable(
|
||||
currentState,
|
||||
input.forceDisableTools,
|
||||
);
|
||||
const startRequest = buildThreadStartRequest(
|
||||
input.baseStartRequest,
|
||||
effectiveCurrentState,
|
||||
);
|
||||
const { prompt, userImages, userFiles } = await buildUserInputMessage(
|
||||
runtimeInput,
|
||||
input.userInstructionService,
|
||||
@@ -951,16 +1056,107 @@ export async function handleConnectorUserTurn<
|
||||
prompt,
|
||||
attachments: buildAttachments({ userImages, userFiles }),
|
||||
};
|
||||
if (params.staleSessionId) {
|
||||
await forgetStaleThreadSession({
|
||||
thread: input.thread,
|
||||
bindingsPath: input.bindingsPath,
|
||||
baseStartRequest: input.baseStartRequest,
|
||||
errorLabel: input.errorLabel,
|
||||
logger: input.logger,
|
||||
transport: input.transport,
|
||||
sessionId: params.staleSessionId,
|
||||
});
|
||||
}
|
||||
// A thread binding can outlive its runtime session (hub restart, session
|
||||
// deletion, retention cleanup). When that happens the turn fails with
|
||||
// `session_not_found`; drop the stale mapping and replay the turn once
|
||||
// against a brand new session instead of wedging the thread forever.
|
||||
const resolveSessionId = () =>
|
||||
getOrCreateSessionId({
|
||||
thread: input.thread,
|
||||
client: input.client,
|
||||
startRequest,
|
||||
logger: input.logger,
|
||||
clientId: input.clientId,
|
||||
transport: input.transport,
|
||||
bindingsPath: input.bindingsPath,
|
||||
errorLabel: input.errorLabel,
|
||||
hookCommand: input.hookCommand,
|
||||
hookBotUserName: input.botUserName,
|
||||
sessionMetadata: input.getSessionMetadata(
|
||||
input.thread,
|
||||
input.clientId,
|
||||
currentState,
|
||||
),
|
||||
reusedLogMessage: input.reusedLogMessage,
|
||||
startedLogMessage: input.startedLogMessage,
|
||||
});
|
||||
let sessionId = await resolveSessionId();
|
||||
let allowStaleSessionRetry = params.staleSessionId === undefined;
|
||||
for (;;) {
|
||||
try {
|
||||
await runConnectorRuntimeTurn({
|
||||
input,
|
||||
sessionId,
|
||||
request,
|
||||
currentState,
|
||||
turnKey,
|
||||
});
|
||||
break;
|
||||
} catch (error) {
|
||||
if (!allowStaleSessionRetry || !isSessionNotFoundError(error)) {
|
||||
throw error;
|
||||
}
|
||||
allowStaleSessionRetry = false;
|
||||
await forgetStaleThreadSession({
|
||||
thread: input.thread,
|
||||
bindingsPath: input.bindingsPath,
|
||||
baseStartRequest: input.baseStartRequest,
|
||||
errorLabel: input.errorLabel,
|
||||
logger: input.logger,
|
||||
transport: input.transport,
|
||||
sessionId,
|
||||
});
|
||||
sessionId = await resolveSessionId();
|
||||
}
|
||||
}
|
||||
|
||||
await persistMergedThreadState(
|
||||
input.thread,
|
||||
input.bindingsPath,
|
||||
{
|
||||
...currentState,
|
||||
sessionId,
|
||||
},
|
||||
input.errorLabel,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a single connector turn against an already-resolved session and streams
|
||||
* the reply back into the thread.
|
||||
*/
|
||||
async function runConnectorRuntimeTurn<
|
||||
TState extends ConnectorThreadState,
|
||||
>(params: {
|
||||
input: ConnectorUserTurnInput<TState>;
|
||||
sessionId: string;
|
||||
request: ChatRunTurnRequest;
|
||||
currentState: TState;
|
||||
turnKey: string;
|
||||
}): Promise<void> {
|
||||
const { input, sessionId, request, currentState, turnKey } = params;
|
||||
const resolveFallbackText = await input.createEmptyRuntimeReplyResolver?.({
|
||||
client: input.client,
|
||||
sessionId,
|
||||
});
|
||||
|
||||
input.activeTurns?.set(turnKey, {
|
||||
const activeTurn: ActiveConnectorTurn = {
|
||||
sessionId,
|
||||
threadId: input.thread.id,
|
||||
participantKey: currentState.participantKey,
|
||||
});
|
||||
};
|
||||
input.activeTurns?.set(turnKey, activeTurn);
|
||||
await input.thread.startTyping();
|
||||
let toolStatusMessage: SentMessage | undefined;
|
||||
const postFinalReply = input.postFinalReply
|
||||
@@ -1025,21 +1221,13 @@ export async function handleConnectorUserTurn<
|
||||
);
|
||||
} finally {
|
||||
input.pendingApprovals.delete(input.thread.id);
|
||||
input.activeTurns?.delete(turnKey);
|
||||
if (input.activeTurns?.get(turnKey) === activeTurn) {
|
||||
input.activeTurns.delete(turnKey);
|
||||
}
|
||||
if (toolStatusMessage) {
|
||||
await toolStatusMessage.delete().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
await persistMergedThreadState(
|
||||
input.thread,
|
||||
input.bindingsPath,
|
||||
{
|
||||
...currentState,
|
||||
sessionId,
|
||||
},
|
||||
input.errorLabel,
|
||||
);
|
||||
}
|
||||
|
||||
export async function maybeHandleConnectorApprovalReply<
|
||||
|
||||
@@ -283,6 +283,43 @@ export async function getOrCreateSessionId<
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops a thread's session mapping without touching the runtime session, but
|
||||
* only when it still points at the session the caller observed as stale.
|
||||
*
|
||||
* Used when the hub reports the mapped session no longer exists: the thread
|
||||
* binding may have been recovered concurrently, so a newer session id must
|
||||
* never be cleared by an older failure.
|
||||
*/
|
||||
export async function forgetThreadSession<
|
||||
TState extends ConnectorThreadState,
|
||||
>(input: {
|
||||
thread: Thread<TState>;
|
||||
bindingsPath: string;
|
||||
baseStartRequest: ChatStartSessionRequest;
|
||||
errorLabel: string;
|
||||
expectedSessionId: string;
|
||||
}): Promise<boolean> {
|
||||
const threadState = await loadThreadState(
|
||||
input.thread,
|
||||
input.bindingsPath,
|
||||
input.baseStartRequest,
|
||||
);
|
||||
if (threadState.sessionId?.trim() !== input.expectedSessionId.trim()) {
|
||||
return false;
|
||||
}
|
||||
await persistMergedThreadState(
|
||||
input.thread,
|
||||
input.bindingsPath,
|
||||
{
|
||||
...threadState,
|
||||
sessionId: undefined,
|
||||
},
|
||||
input.errorLabel,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function clearSession<TState extends ConnectorThreadState>(input: {
|
||||
thread: Thread<TState>;
|
||||
client: HubSessionClient;
|
||||
|
||||
+20
-68
@@ -96,16 +96,11 @@ const worktreeMocks = vi.hoisted(() => ({
|
||||
createTaskWorktree: vi.fn(),
|
||||
}));
|
||||
const historyMocks = vi.hoisted(() => ({
|
||||
runHistoryList: vi.fn<() => Promise<number | string>>(async () => 0),
|
||||
runHistoryList: vi.fn<() => Promise<number>>(async () => 0),
|
||||
runHistoryDelete: vi.fn(async () => 0),
|
||||
runHistoryExport: vi.fn(async () => 0),
|
||||
runHistoryUpdate: vi.fn(async () => 0),
|
||||
}));
|
||||
const historyResumeMocks = vi.hoisted(() => ({
|
||||
spawnHistoryResume: vi.fn<() => Promise<number | undefined>>(
|
||||
async () => undefined,
|
||||
),
|
||||
}));
|
||||
const loggingMocks = vi.hoisted(() => ({
|
||||
createCliLoggerAdapter: vi.fn(() => ({
|
||||
core: {
|
||||
@@ -212,7 +207,6 @@ vi.mock("./commands/connect", () => connectMocks);
|
||||
vi.mock("./kanban-migration/notice", () => migrationNoticeMocks);
|
||||
vi.mock("./commands/update", () => updateMocks);
|
||||
vi.mock("./commands/history", () => historyMocks);
|
||||
vi.mock("./utils/history-resume", () => historyResumeMocks);
|
||||
vi.mock("./logging/adapter", () => loggingMocks);
|
||||
vi.mock("./utils/hub-runtime", () => hubRuntimeMocks);
|
||||
vi.mock("./utils/telemetry", () => telemetryMocks);
|
||||
@@ -241,8 +235,6 @@ describe("runCli lightweight command dispatch", () => {
|
||||
historyMocks.runHistoryExport.mockResolvedValue(0);
|
||||
historyMocks.runHistoryUpdate.mockReset();
|
||||
historyMocks.runHistoryUpdate.mockResolvedValue(0);
|
||||
historyResumeMocks.spawnHistoryResume.mockReset();
|
||||
historyResumeMocks.spawnHistoryResume.mockResolvedValue(undefined);
|
||||
sessionMocks.getSessionRow.mockReset();
|
||||
sessionMocks.getSessionRow.mockResolvedValue({
|
||||
sessionId: "sess_123",
|
||||
@@ -324,6 +316,10 @@ describe("runCli lightweight command dispatch", () => {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(process.stdout, "isTTY", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -480,7 +476,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(process.exitCode).toBe(0);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(mockState.runAgentImports).toBe(0);
|
||||
expect(mockState.runInteractiveImports).toBe(0);
|
||||
});
|
||||
@@ -617,10 +613,6 @@ describe("runCli lightweight command dispatch", () => {
|
||||
});
|
||||
|
||||
it("creates a worktree for default interactive mode", async () => {
|
||||
Object.defineProperty(process.stdout, "isTTY", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
process.argv = ["bun", "src/index.ts", "--worktree"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
@@ -723,7 +715,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
initialView: undefined,
|
||||
startupTarget: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -734,10 +726,6 @@ describe("runCli lightweight command dispatch", () => {
|
||||
title: "Try ClinePass",
|
||||
};
|
||||
migrationNoticeMocks.getClineCliMigrationNotice.mockReturnValue(notice);
|
||||
Object.defineProperty(process.stdout, "isTTY", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
process.argv = ["bun", "src/index.ts"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
@@ -767,10 +755,6 @@ describe("runCli lightweight command dispatch", () => {
|
||||
provider: "cline-pass",
|
||||
model: "cline-pass/test-model",
|
||||
});
|
||||
Object.defineProperty(process.stdout, "isTTY", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
process.argv = ["bun", "src/index.ts"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
@@ -875,7 +859,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
initialPrompt: "sup",
|
||||
initialView: undefined,
|
||||
startupTarget: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -1115,65 +1099,33 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect.anything(),
|
||||
"sess_123",
|
||||
expect.objectContaining({
|
||||
initialView: "chat",
|
||||
startupTarget: "chat",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("resumes a history-picked session in a child process", async () => {
|
||||
historyMocks.runHistoryList.mockImplementationOnce(
|
||||
async () => "sess_from_history",
|
||||
);
|
||||
historyResumeMocks.spawnHistoryResume.mockResolvedValueOnce(0);
|
||||
process.argv = ["bun", "src/index.ts", "history"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(historyResumeMocks.spawnHistoryResume).toHaveBeenCalledTimes(1);
|
||||
expect(historyResumeMocks.spawnHistoryResume).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: "sess_from_history",
|
||||
normalizedArgs: ["history"],
|
||||
remainingArgs: ["history"],
|
||||
}),
|
||||
);
|
||||
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it("propagates the child exit code when resuming from history picker", async () => {
|
||||
historyMocks.runHistoryList.mockImplementationOnce(
|
||||
async () => "sess_from_history",
|
||||
);
|
||||
historyResumeMocks.spawnHistoryResume.mockResolvedValueOnce(3);
|
||||
process.argv = ["bun", "src/index.ts", "history"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(process.exitCode).toBe(3);
|
||||
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forces chat view when the history-picker child cannot launch", async () => {
|
||||
historyMocks.runHistoryList.mockImplementationOnce(
|
||||
async () => "sess_from_history",
|
||||
);
|
||||
historyResumeMocks.spawnHistoryResume.mockResolvedValueOnce(undefined);
|
||||
it("opens history inside the interactive TUI for the history picker", async () => {
|
||||
migrationNoticeMocks.getClineCliMigrationNotice.mockReturnValue({
|
||||
id: "cline-cli-cline-pass-intro",
|
||||
title: "Try ClinePass",
|
||||
});
|
||||
process.argv = ["bun", "src/index.ts", "history"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(historyMocks.runHistoryList).not.toHaveBeenCalled();
|
||||
expect(
|
||||
migrationNoticeMocks.getClineCliMigrationNotice,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(runtimeMocks.runInteractive).toHaveBeenCalledTimes(1);
|
||||
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
"sess_from_history",
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
initialPrompt: undefined,
|
||||
initialView: "chat",
|
||||
startupTarget: "history",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
+39
-150
@@ -5,6 +5,7 @@ import type { ToolPolicy } from "@cline/core";
|
||||
|
||||
import { registerDisposable } from "@cline/shared";
|
||||
import type { Command } from "commander";
|
||||
import { registerHistoryCommand } from "./commands/history-command";
|
||||
import {
|
||||
CommanderError,
|
||||
commanderToParsedArgs,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
getPreferredKanbanInstaller,
|
||||
} from "./commands/update";
|
||||
import { CLI_DEFAULT_CHECKPOINT_CONFIG } from "./runtime/defaults";
|
||||
import type { TuiStartupTarget } from "./tui/types";
|
||||
import { getCliBuildInfo } from "./utils/common";
|
||||
import {
|
||||
buildCliCompactionConfig,
|
||||
@@ -136,11 +138,19 @@ function writePromptArgError(args: string[]): void {
|
||||
);
|
||||
}
|
||||
|
||||
function startupTargetTakesPrecedenceOverMigrationNotice(
|
||||
target: TuiStartupTarget | undefined,
|
||||
): boolean {
|
||||
return target === "config" || target === "history";
|
||||
}
|
||||
|
||||
export async function runCli(): Promise<void> {
|
||||
installStreamErrorGuards();
|
||||
autoUpdateOnStartup();
|
||||
|
||||
const cliArgs = process.argv.slice(2);
|
||||
const isFullTTY =
|
||||
process.stdin.isTTY === true && process.stdout.isTTY === true;
|
||||
const configDir = resolveConfigDirArg(cliArgs);
|
||||
const { setClineDir, setHomeDir } = await import("@cline/shared/storage");
|
||||
if (configDir) {
|
||||
@@ -154,11 +164,13 @@ export async function runCli(): Promise<void> {
|
||||
// `--config <dir>` rather than the default home/config location.
|
||||
captureCliExtensionActivated();
|
||||
|
||||
let launchConfigView = false;
|
||||
const normalizedArgs = normalizeAutoApproveArgs(cliArgs);
|
||||
|
||||
// Subcommand routing via Commander
|
||||
const ctx: { exitCode?: number; resumeSessionId?: string } = {};
|
||||
const ctx: {
|
||||
exitCode?: number;
|
||||
startupTarget?: TuiStartupTarget;
|
||||
} = {};
|
||||
const io = { writeln, writeErr };
|
||||
const program = createProgram();
|
||||
// Re-enable built-in help/version output for the routing program
|
||||
@@ -249,7 +261,7 @@ export async function runCli(): Promise<void> {
|
||||
ctx.exitCode = code;
|
||||
},
|
||||
() => {
|
||||
launchConfigView = true;
|
||||
ctx.startupTarget = "config";
|
||||
},
|
||||
);
|
||||
return configCmd;
|
||||
@@ -416,7 +428,7 @@ export async function runCli(): Promise<void> {
|
||||
connectCmd.args.slice(1),
|
||||
io,
|
||||
);
|
||||
} else if (process.stdin.isTTY && process.stdout.isTTY) {
|
||||
} else if (isFullTTY) {
|
||||
ctx.exitCode = await runConnectWizard();
|
||||
} else {
|
||||
writeln(`\nAdapters:\n${formatAdapterList()}`);
|
||||
@@ -428,7 +440,7 @@ export async function runCli(): Promise<void> {
|
||||
.command("mcp")
|
||||
.description("Manage MCP servers")
|
||||
.action(async () => {
|
||||
if (process.stdin.isTTY && process.stdout.isTTY) {
|
||||
if (isFullTTY) {
|
||||
ctx.exitCode = await runMcpWizard();
|
||||
} else {
|
||||
writeln(
|
||||
@@ -493,107 +505,17 @@ export async function runCli(): Promise<void> {
|
||||
await doctorCmd.parseAsync(cmd.args, { from: "user" });
|
||||
});
|
||||
|
||||
const historyCmd = program
|
||||
.command("history")
|
||||
.alias("h")
|
||||
.description("List session history or manage saved sessions")
|
||||
.option("--json", "Output as JSON")
|
||||
.option("--limit <count>", "Maximum number of sessions to show", "50")
|
||||
.option("--page <number>", "Page number for paginated results")
|
||||
.option("--config <dir>", "configuration directory")
|
||||
.action(async () => {
|
||||
const opts = historyCmd.opts();
|
||||
const limit = Number.parseInt(opts.limit, 10);
|
||||
const outputMode =
|
||||
program.opts().json || opts.json
|
||||
? ("json" as const)
|
||||
: ("text" as const);
|
||||
const { runHistoryList } = await import("./commands/history");
|
||||
const result = await runHistoryList({
|
||||
limit,
|
||||
outputMode,
|
||||
io,
|
||||
});
|
||||
if (typeof result === "string") {
|
||||
ctx.resumeSessionId = result;
|
||||
// JSON listing should never return a session id; if it does, still exit here so
|
||||
// we never fall through to agent bootstrap (which can block on stdin in CI).
|
||||
if (outputMode === "json") {
|
||||
ctx.exitCode = 0;
|
||||
}
|
||||
} else {
|
||||
// Always set exit code for numeric results so `ctx.exitCode` is never left
|
||||
// undefined (that would fall through and load the full CLI runtime).
|
||||
ctx.exitCode = result ?? 0;
|
||||
}
|
||||
});
|
||||
|
||||
const historyDeleteCmd = historyCmd
|
||||
.command("delete")
|
||||
.description("Delete a session from history")
|
||||
.option("--session-id <id>", "Session ID to delete")
|
||||
.action(async () => {
|
||||
const opts = historyDeleteCmd.opts();
|
||||
if (!opts.sessionId) {
|
||||
writeErr("history delete requires --session-id <id>");
|
||||
ctx.exitCode = 0;
|
||||
return;
|
||||
}
|
||||
const outputMode =
|
||||
program.opts().json || historyCmd.opts().json
|
||||
? ("json" as const)
|
||||
: ("text" as const);
|
||||
const { runHistoryDelete } = await import("./commands/history");
|
||||
ctx.exitCode = await runHistoryDelete(opts.sessionId, outputMode, io);
|
||||
});
|
||||
|
||||
const historyUpdateCmd = historyCmd
|
||||
.command("update")
|
||||
.description("Update a session in history")
|
||||
.option("--metadata <json>", "Metadata as JSON string")
|
||||
.option("--prompt <text>", "New prompt text")
|
||||
.option("--session-id <id>", "Session ID to update")
|
||||
.option("--title <text>", "New title")
|
||||
.action(async () => {
|
||||
const opts = historyUpdateCmd.opts();
|
||||
if (!opts.sessionId) {
|
||||
writeErr("history update requires --session-id <id>");
|
||||
ctx.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const outputMode =
|
||||
program.opts().json || historyCmd.opts().json
|
||||
? ("json" as const)
|
||||
: ("text" as const);
|
||||
const { runHistoryUpdate } = await import("./commands/history");
|
||||
ctx.exitCode = await runHistoryUpdate(
|
||||
opts.sessionId,
|
||||
opts.prompt,
|
||||
opts.title,
|
||||
opts.metadata,
|
||||
outputMode,
|
||||
io,
|
||||
);
|
||||
});
|
||||
|
||||
const historyExportCmd = historyCmd
|
||||
.command("export <sessionId>")
|
||||
.description("Export a session as a standalone HTML file")
|
||||
.option("-o, --output <path>", "Output HTML file path")
|
||||
.action(async (sessionId: string) => {
|
||||
const opts = historyExportCmd.opts();
|
||||
const outputMode =
|
||||
program.opts().json || historyCmd.opts().json
|
||||
? ("json" as const)
|
||||
: ("text" as const);
|
||||
const { runHistoryExport } = await import("./commands/history");
|
||||
ctx.exitCode = await runHistoryExport(
|
||||
sessionId,
|
||||
opts.output,
|
||||
outputMode,
|
||||
io,
|
||||
);
|
||||
});
|
||||
registerHistoryCommand({
|
||||
program,
|
||||
io,
|
||||
setExitCode: (code) => {
|
||||
ctx.exitCode = code;
|
||||
},
|
||||
setStartupTarget: (target) => {
|
||||
ctx.startupTarget = target;
|
||||
},
|
||||
isInteractiveTTY: () => isFullTTY,
|
||||
});
|
||||
|
||||
program
|
||||
.command("hook")
|
||||
@@ -625,11 +547,7 @@ export async function runCli(): Promise<void> {
|
||||
.allowExcessArguments()
|
||||
.passThroughOptions()
|
||||
.action(async (_opts: unknown, cmd: Command) => {
|
||||
if (
|
||||
cmd.args.length === 0 &&
|
||||
process.stdin.isTTY &&
|
||||
process.stdout.isTTY
|
||||
) {
|
||||
if (cmd.args.length === 0 && isFullTTY) {
|
||||
ctx.exitCode = await runScheduleWizard();
|
||||
return;
|
||||
}
|
||||
@@ -777,30 +695,8 @@ export async function runCli(): Promise<void> {
|
||||
// Default flow: no subcommand matched, or fall-through from config/history.
|
||||
let args = commanderToParsedArgs(program);
|
||||
|
||||
let resumeSessionId: string | undefined = ctx.resumeSessionId;
|
||||
if (resumeSessionId) {
|
||||
// The history picker already created (and tore down) an OpenTUI renderer
|
||||
// in this process; starting the interactive TUI here would create a
|
||||
// second one, which can crash natively during teardown. Resume in a
|
||||
// fresh `cline --id <session-id>` child process instead.
|
||||
const { spawnHistoryResume } = await import("./utils/history-resume");
|
||||
const childExitCode = await spawnHistoryResume({
|
||||
sessionId: resumeSessionId,
|
||||
normalizedArgs,
|
||||
remainingArgs: program.args,
|
||||
configDir,
|
||||
});
|
||||
if (childExitCode !== undefined) {
|
||||
process.exitCode = childExitCode;
|
||||
return;
|
||||
}
|
||||
args = {
|
||||
...args,
|
||||
interactive: true,
|
||||
prompt: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
let startupTarget = ctx.startupTarget;
|
||||
let resumeSessionId: string | undefined;
|
||||
if (args.id !== undefined) {
|
||||
const sessionId = args.id.trim();
|
||||
if (!sessionId) {
|
||||
@@ -809,16 +705,12 @@ export async function runCli(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
resumeSessionId = sessionId;
|
||||
startupTarget = "chat";
|
||||
process.env.CLINE_HOOK_AGENT_RESUME = "1";
|
||||
args = {
|
||||
...args,
|
||||
interactive: true,
|
||||
prompt: undefined,
|
||||
};
|
||||
} else {
|
||||
delete process.env.CLINE_HOOK_AGENT_RESUME;
|
||||
}
|
||||
if (launchConfigView) {
|
||||
if (startupTarget) {
|
||||
args = {
|
||||
...args,
|
||||
interactive: true,
|
||||
@@ -892,7 +784,7 @@ export async function runCli(): Promise<void> {
|
||||
!args.prompt &&
|
||||
!resumeSessionId &&
|
||||
!stdinHasPipedInput() &&
|
||||
(!process.stdin.isTTY || !process.stdout.isTTY)
|
||||
!isFullTTY
|
||||
) {
|
||||
writeErr("--worktree without a prompt requires an interactive terminal.");
|
||||
process.exitCode = 1;
|
||||
@@ -1239,12 +1131,6 @@ export async function runCli(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
const runInteractive = await loadInteractiveRuntimeModule();
|
||||
let initialView: "chat" | "config" | undefined;
|
||||
if (launchConfigView) {
|
||||
initialView = "config";
|
||||
} else if (resumeSessionId) {
|
||||
initialView = "chat";
|
||||
}
|
||||
const initialClineProviderSettings =
|
||||
provider === "cline" ? selectedProviderSettings : undefined;
|
||||
let initialNotice:
|
||||
@@ -1255,7 +1141,10 @@ export async function runCli(): Promise<void> {
|
||||
notice: import("./kanban-migration/notice").CliMigrationNotice,
|
||||
) => void)
|
||||
| undefined;
|
||||
if (!launchConfigView && process.stdin.isTTY && process.stdout.isTTY) {
|
||||
if (
|
||||
!startupTargetTakesPrecedenceOverMigrationNotice(startupTarget) &&
|
||||
isFullTTY
|
||||
) {
|
||||
const { getClineCliMigrationNotice, markClineCliMigrationNoticeShown } =
|
||||
await import("./kanban-migration/notice");
|
||||
initialNotice = getClineCliMigrationNotice(undefined, process.env, {
|
||||
@@ -1271,7 +1160,7 @@ export async function runCli(): Promise<void> {
|
||||
initialPrompt: args.prompt,
|
||||
clineApiBaseUrl: initialClineProviderSettings?.baseUrl,
|
||||
clineProviderSettings: initialClineProviderSettings,
|
||||
initialView,
|
||||
startupTarget,
|
||||
initialNotice,
|
||||
onInitialNoticeShown: markInitialNoticeShown,
|
||||
});
|
||||
|
||||
@@ -1012,9 +1012,9 @@ Review with the bundled skill.`,
|
||||
const linear = data.mcp.find((item) => item.name === "linear");
|
||||
const docs = data.mcp.find((item) => item.name === "docs");
|
||||
|
||||
expect(linear?.description).toBe("streamableHttp, oauth error");
|
||||
expect(linear?.description).toBe("streamableHttp, oauth error, timeout 60s");
|
||||
expect(linear?.loadError).toBe("OAuth authorization failed");
|
||||
expect(docs?.description).toBe("sse, oauth authorized");
|
||||
expect(docs?.description).toBe("sse, oauth authorized, timeout 60s");
|
||||
expect(docs?.loadError).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Config } from "../utils/types";
|
||||
import {
|
||||
applyInteractiveModelChange,
|
||||
assertHistorySessionIsDeletable,
|
||||
resolveReasoningForModelChange,
|
||||
resumeInteractiveSession,
|
||||
} from "./run-interactive";
|
||||
|
||||
describe("assertHistorySessionIsDeletable", () => {
|
||||
it("rejects deleting the active interactive session", () => {
|
||||
expect(() => assertHistorySessionIsDeletable("sess_1", "sess_1")).toThrow(
|
||||
"Cannot delete the active session",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows deleting another or pre-startup session", () => {
|
||||
expect(() =>
|
||||
assertHistorySessionIsDeletable("sess_1", "sess_2"),
|
||||
).not.toThrow();
|
||||
expect(() => assertHistorySessionIsDeletable("sess_1", "")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveReasoningForModelChange", () => {
|
||||
it("persists disabled reasoning only when thinking is explicitly false", () => {
|
||||
expect(
|
||||
@@ -108,3 +125,74 @@ describe("applyInteractiveModelChange", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resumeInteractiveSession", () => {
|
||||
const originalAgentResume = process.env.CLINE_HOOK_AGENT_RESUME;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalAgentResume === undefined) {
|
||||
delete process.env.CLINE_HOOK_AGENT_RESUME;
|
||||
} else {
|
||||
process.env.CLINE_HOOK_AGENT_RESUME = originalAgentResume;
|
||||
}
|
||||
});
|
||||
|
||||
it("starts the selected session directly without ensuring an empty session first", async () => {
|
||||
const messages = [
|
||||
{ id: "message-1", role: "user" as const, content: "hello" },
|
||||
];
|
||||
const ensureReady = vi.fn(async () => {});
|
||||
const resumeSession = vi.fn(async () => {
|
||||
expect(process.env.CLINE_HOOK_AGENT_RESUME).toBe("1");
|
||||
return messages;
|
||||
});
|
||||
const getAccumulatedUsage = vi.fn(async () => ({
|
||||
inputTokens: 12,
|
||||
outputTokens: 3,
|
||||
totalCost: 0.42,
|
||||
}));
|
||||
const sessionRuntime = {
|
||||
ensureReady,
|
||||
resumeSession,
|
||||
getAccumulatedUsage,
|
||||
};
|
||||
|
||||
const result = await resumeInteractiveSession(
|
||||
sessionRuntime,
|
||||
"session-selected",
|
||||
);
|
||||
|
||||
expect(ensureReady).not.toHaveBeenCalled();
|
||||
expect(resumeSession).toHaveBeenCalledOnce();
|
||||
expect(resumeSession).toHaveBeenCalledWith("session-selected");
|
||||
expect(getAccumulatedUsage).toHaveBeenCalledWith({
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
messages,
|
||||
totalCost: 0.42,
|
||||
});
|
||||
expect(process.env.CLINE_HOOK_AGENT_RESUME).toBe("1");
|
||||
});
|
||||
|
||||
it("restores the hook state when the selected session cannot resume", async () => {
|
||||
delete process.env.CLINE_HOOK_AGENT_RESUME;
|
||||
const resumeSession = vi.fn(async () => {
|
||||
expect(process.env.CLINE_HOOK_AGENT_RESUME).toBe("1");
|
||||
throw new Error("resume failed");
|
||||
});
|
||||
|
||||
await expect(
|
||||
resumeInteractiveSession(
|
||||
{
|
||||
resumeSession,
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
},
|
||||
"session-missing",
|
||||
),
|
||||
).rejects.toThrow("resume failed");
|
||||
|
||||
expect(process.env.CLINE_HOOK_AGENT_RESUME).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
import { formatModeSwitchNotice } from "@cline/shared";
|
||||
import type { CliMigrationNotice } from "../kanban-migration/notice";
|
||||
import { logCliError } from "../logging/errors";
|
||||
import { exportHistorySession } from "../session/history-export";
|
||||
import { deleteSession } from "../session/session";
|
||||
import {
|
||||
loadClineAccountSnapshot,
|
||||
loadIndividualSubscriptionPlans,
|
||||
@@ -26,7 +28,7 @@ import {
|
||||
resolveClineWelcomeLine,
|
||||
} from "../tui/interactive-welcome";
|
||||
import { disableOpenTuiGraphicsProbe } from "../tui/opentui-env";
|
||||
import type { QueuedPromptItem } from "../tui/types";
|
||||
import type { QueuedPromptItem, TuiStartupTarget } from "../tui/types";
|
||||
import { type ChatCommandState, chatCommandHost } from "../utils/chat-commands";
|
||||
import { applyCliCompactionMode } from "../utils/compaction-mode";
|
||||
import {
|
||||
@@ -73,6 +75,17 @@ type ModelChangeReasoningConfig = {
|
||||
reasoningEffort?: Config["reasoningEffort"];
|
||||
};
|
||||
|
||||
export function assertHistorySessionIsDeletable(
|
||||
sessionId: string,
|
||||
activeSessionId: string,
|
||||
): void {
|
||||
if (activeSessionId && sessionId === activeSessionId) {
|
||||
throw new Error(
|
||||
"Cannot delete the active session. Start or resume another session first.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveReasoningForModelChange(
|
||||
config: ModelChangeReasoningConfig,
|
||||
existing: Pick<ProviderSettings, "reasoning">,
|
||||
@@ -130,6 +143,37 @@ export async function applyInteractiveModelChange(input: {
|
||||
});
|
||||
}
|
||||
|
||||
export async function resumeInteractiveSession(
|
||||
sessionRuntime: Pick<
|
||||
ReturnType<typeof createInteractiveSessionRuntime>,
|
||||
"resumeSession" | "getAccumulatedUsage"
|
||||
>,
|
||||
sessionId: string,
|
||||
) {
|
||||
const previousAgentResume = process.env.CLINE_HOOK_AGENT_RESUME;
|
||||
process.env.CLINE_HOOK_AGENT_RESUME = "1";
|
||||
let messages: Awaited<ReturnType<typeof sessionRuntime.resumeSession>>;
|
||||
try {
|
||||
messages = await sessionRuntime.resumeSession(sessionId);
|
||||
} catch (error) {
|
||||
if (previousAgentResume === undefined) {
|
||||
delete process.env.CLINE_HOOK_AGENT_RESUME;
|
||||
} else {
|
||||
process.env.CLINE_HOOK_AGENT_RESUME = previousAgentResume;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const usage = await sessionRuntime.getAccumulatedUsage({
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
});
|
||||
return {
|
||||
messages,
|
||||
totalCost: usage.totalCost,
|
||||
currentContextSize: getCurrentContextSize(messages),
|
||||
};
|
||||
}
|
||||
|
||||
export async function runInteractive(
|
||||
config: Config,
|
||||
userInstructionService?: UserInstructionConfigService,
|
||||
@@ -137,7 +181,7 @@ export async function runInteractive(
|
||||
options?: {
|
||||
clineApiBaseUrl?: string;
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
initialView?: "chat" | "config";
|
||||
startupTarget?: TuiStartupTarget;
|
||||
initialPrompt?: string;
|
||||
initialNotice?: CliMigrationNotice;
|
||||
onInitialNoticeShown?: (notice: CliMigrationNotice) => void | Promise<void>;
|
||||
@@ -470,7 +514,7 @@ export async function runInteractive(
|
||||
|
||||
tuiApp = await renderOpenTui({
|
||||
config,
|
||||
initialView: options?.initialView,
|
||||
startupTarget: options?.startupTarget,
|
||||
initialPrompt: options?.initialPrompt,
|
||||
initialNotice: options?.initialNotice,
|
||||
onInitialNoticeShown: options?.onInitialNoticeShown,
|
||||
@@ -764,18 +808,23 @@ export async function runInteractive(
|
||||
});
|
||||
await sessionRuntime.restartWithCurrentMessages();
|
||||
},
|
||||
onResumeSession: async (sessionId: string) => {
|
||||
await sessionRuntime.ensureReady();
|
||||
const messages = await sessionRuntime.resumeSession(sessionId);
|
||||
const usage = await sessionRuntime.getAccumulatedUsage({
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
});
|
||||
return {
|
||||
messages,
|
||||
totalCost: usage.totalCost,
|
||||
currentContextSize: getCurrentContextSize(messages),
|
||||
};
|
||||
// resumeSession initializes the manager and starts the selected session
|
||||
// directly. Ensuring a session first would mint an empty history entry
|
||||
// when the TUI was launched through `cline history`.
|
||||
onResumeSession: async (sessionId: string) =>
|
||||
await resumeInteractiveSession(sessionRuntime, sessionId),
|
||||
onExportHistorySession: async (sessionId, format) =>
|
||||
await exportHistorySession({
|
||||
sessionId,
|
||||
format,
|
||||
outputDirectory: config.cwd,
|
||||
}),
|
||||
onDeleteHistorySession: async (sessionId) => {
|
||||
assertHistorySessionIsDeletable(
|
||||
sessionId,
|
||||
sessionRuntime.getActiveSessionId(),
|
||||
);
|
||||
return (await deleteSession(sessionId)).deleted;
|
||||
},
|
||||
onCompact: async () => {
|
||||
await sessionRuntime.ensureReady();
|
||||
@@ -804,7 +853,7 @@ export async function runInteractive(
|
||||
},
|
||||
});
|
||||
|
||||
if (!loadDeferredInitialMessages) {
|
||||
if (!loadDeferredInitialMessages && options?.startupTarget !== "history") {
|
||||
setTimeout(() => {
|
||||
void sessionRuntime.ensureReady().catch((error) => {
|
||||
if (sessionRuntime.isShutdownRequested() || startupErrorReported) {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { generateConversationHTML } from "./export";
|
||||
import { readSessionMessagesArtifact } from "./session";
|
||||
|
||||
export type HistoryExportFormat = "html" | "json";
|
||||
|
||||
export async function exportHistorySession(input: {
|
||||
sessionId: string;
|
||||
format: HistoryExportFormat;
|
||||
outputPath?: string;
|
||||
outputDirectory?: string;
|
||||
}): Promise<string> {
|
||||
const { sessionId, format, outputPath, outputDirectory } = input;
|
||||
const data = await readSessionMessagesArtifact(sessionId);
|
||||
if (!data) {
|
||||
throw new Error(`Session ${sessionId} not found or has no messages.json`);
|
||||
}
|
||||
|
||||
const targetPath = outputPath?.trim()
|
||||
? resolve(outputPath)
|
||||
: resolve(
|
||||
outputDirectory?.trim() || process.cwd(),
|
||||
`${sessionId}.${format}`,
|
||||
);
|
||||
const contents =
|
||||
format === "html"
|
||||
? generateConversationHTML(data, sessionId)
|
||||
: `${JSON.stringify(data, null, 2)}\n`;
|
||||
|
||||
await mkdir(dirname(targetPath), { recursive: true });
|
||||
await writeFile(targetPath, contents, "utf8");
|
||||
return targetPath;
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { SessionHistoryRecord } from "@cline/core";
|
||||
import { createCliRenderer } from "@opentui/core";
|
||||
import { createRoot } from "@opentui/react";
|
||||
import React from "react";
|
||||
import { deleteSession } from "../session/session";
|
||||
import { HistoryStandaloneContent } from "./views/history-view";
|
||||
|
||||
export async function renderHistoryStandalone(input: {
|
||||
rows: SessionHistoryRecord[];
|
||||
onExport: (sessionId: string) => Promise<string | undefined>;
|
||||
refreshRows?: () => Promise<SessionHistoryRecord[]>;
|
||||
}): Promise<number | string> {
|
||||
const renderer = await createCliRenderer({
|
||||
exitOnCtrlC: true,
|
||||
autoFocus: false,
|
||||
enableMouseMovement: true,
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let result: number | string = 0;
|
||||
let resolved = false;
|
||||
let destroyStarted = false;
|
||||
let unmounted = false;
|
||||
const root = createRoot(renderer);
|
||||
|
||||
const unmountRoot = () => {
|
||||
if (unmounted) {
|
||||
return;
|
||||
}
|
||||
unmounted = true;
|
||||
root.unmount();
|
||||
};
|
||||
|
||||
// Resolve only once teardown has finished, so callers never run while
|
||||
// the renderer is still restoring the terminal.
|
||||
renderer.on("destroy", () => {
|
||||
unmountRoot();
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
|
||||
const settle = (value: number | string) => {
|
||||
if (destroyStarted) {
|
||||
return;
|
||||
}
|
||||
destroyStarted = true;
|
||||
result = value;
|
||||
unmountRoot();
|
||||
// Let OpenTUI finish parsing the current stdin batch before teardown.
|
||||
queueMicrotask(() => {
|
||||
renderer.destroy();
|
||||
});
|
||||
};
|
||||
|
||||
root.render(
|
||||
React.createElement(HistoryStandaloneContent, {
|
||||
rows: input.rows,
|
||||
onResolve: (sessionId: string) => settle(sessionId),
|
||||
onExport: input.onExport,
|
||||
refreshRows: input.refreshRows,
|
||||
onDelete: async (sessionId: string) => {
|
||||
const result = await deleteSession(sessionId);
|
||||
return result.deleted;
|
||||
},
|
||||
onDismiss: () => settle(0),
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -27,6 +27,8 @@ export function useLocalCommandActions(input: {
|
||||
setAppView: (view: AppView) => void;
|
||||
onClearConversation: () => Promise<void>;
|
||||
onResumeSession: TuiProps["onResumeSession"];
|
||||
onExportHistorySession: TuiProps["onExportHistorySession"];
|
||||
onDeleteHistorySession: TuiProps["onDeleteHistorySession"];
|
||||
onCompact: TuiProps["onCompact"];
|
||||
onFork: TuiProps["onFork"];
|
||||
onUndo: () => Promise<void>;
|
||||
@@ -47,6 +49,8 @@ export function useLocalCommandActions(input: {
|
||||
setAppView,
|
||||
onClearConversation,
|
||||
onResumeSession,
|
||||
onExportHistorySession,
|
||||
onDeleteHistorySession,
|
||||
onCompact,
|
||||
onFork,
|
||||
onUndo,
|
||||
@@ -58,7 +62,11 @@ export function useLocalCommandActions(input: {
|
||||
size: "large",
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
content: (ctx: ChoiceContext<string>) => (
|
||||
<HistoryDialogContent {...ctx} />
|
||||
<HistoryDialogContent
|
||||
{...ctx}
|
||||
onExport={onExportHistorySession}
|
||||
onDelete={onDeleteHistorySession}
|
||||
/>
|
||||
),
|
||||
});
|
||||
if (sessionId) {
|
||||
@@ -98,6 +106,8 @@ export function useLocalCommandActions(input: {
|
||||
refocusTextarea();
|
||||
}, [
|
||||
dialog,
|
||||
onDeleteHistorySession,
|
||||
onExportHistorySession,
|
||||
onResumeSession,
|
||||
refocusTextarea,
|
||||
session,
|
||||
@@ -244,5 +254,5 @@ export function useLocalCommandActions(input: {
|
||||
],
|
||||
);
|
||||
|
||||
return { handleSlashCommand };
|
||||
return { handleSlashCommand, openHistory };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getMcpDescription } from "./interactive-config";
|
||||
|
||||
describe("getMcpDescription", () => {
|
||||
it("discloses the fast initialize probe for unconfigured stdio servers", () => {
|
||||
expect(
|
||||
getMcpDescription({
|
||||
name: "local",
|
||||
transport: { type: "stdio", command: "node" },
|
||||
}),
|
||||
).toBe("stdio, local, request timeout 60s, initialize probe 1.5s");
|
||||
});
|
||||
|
||||
it("shows one configured timeout when it also applies to initialize", () => {
|
||||
expect(
|
||||
getMcpDescription({
|
||||
name: "local",
|
||||
transport: { type: "stdio", command: "node" },
|
||||
timeoutSeconds: 120,
|
||||
}),
|
||||
).toBe("stdio, local, timeout 120s");
|
||||
});
|
||||
|
||||
it("shows the request default for URL transports", () => {
|
||||
expect(
|
||||
getMcpDescription({
|
||||
name: "remote",
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://mcp.example.test",
|
||||
},
|
||||
}),
|
||||
).toBe("streamableHttp, no auth, timeout 60s");
|
||||
});
|
||||
|
||||
it("reports malformed programmatic timeouts as unconfigured", () => {
|
||||
expect(
|
||||
getMcpDescription({
|
||||
name: "local",
|
||||
transport: { type: "stdio", command: "node" },
|
||||
timeoutSeconds: Number.NaN,
|
||||
}),
|
||||
).toBe("stdio, local, request timeout 60s, initialize probe 1.5s");
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,10 @@ import {
|
||||
type UserInstructionConfigService,
|
||||
type WorkflowConfig,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
isMcpTimeoutConfigured,
|
||||
resolveMcpTimeoutSeconds,
|
||||
} from "@cline/shared";
|
||||
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
|
||||
import { getToolCatalog } from "../runtime/tools";
|
||||
import {
|
||||
@@ -174,8 +178,14 @@ function getMcpAuthLabel(registration: McpServerRegistration): string {
|
||||
return "no auth";
|
||||
}
|
||||
|
||||
function getMcpDescription(registration: McpServerRegistration): string {
|
||||
return `${registration.transport.type}, ${getMcpAuthLabel(registration)}`;
|
||||
export function getMcpDescription(registration: McpServerRegistration): string {
|
||||
const timeoutSeconds = resolveMcpTimeoutSeconds(registration.timeoutSeconds);
|
||||
const timeoutDescription =
|
||||
registration.transport.type === "stdio" &&
|
||||
!isMcpTimeoutConfigured(registration.timeoutSeconds)
|
||||
? `request timeout ${timeoutSeconds}s, initialize probe 1.5s`
|
||||
: `timeout ${timeoutSeconds}s`;
|
||||
return `${registration.transport.type}, ${getMcpAuthLabel(registration)}, ${timeoutDescription}`;
|
||||
}
|
||||
|
||||
function loadAgentConfigItems(workspaceRoot: string): InteractiveConfigItem[] {
|
||||
|
||||
+23
-10
@@ -54,7 +54,7 @@ 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 type { AppView, TuiProps, TuiStartupTarget } from "./types";
|
||||
import { hydrateSessionMessages } from "./utils/hydrate-messages";
|
||||
import { isProviderConfigured } from "./utils/provider-configured";
|
||||
import { createSelectionCopyHandler } from "./utils/selection-copy";
|
||||
@@ -64,6 +64,13 @@ import { ChatView } from "./views/chat-view";
|
||||
import { HomeView } from "./views/home-view";
|
||||
import { type OnboardingResult, OnboardingView } from "./views/onboarding";
|
||||
|
||||
function isChatBackedStartupTarget(
|
||||
target: TuiStartupTarget | undefined,
|
||||
): boolean {
|
||||
// History is a dialog layered over chat, so dismissing it should reveal chat.
|
||||
return target === "chat" || target === "history";
|
||||
}
|
||||
|
||||
function App(props: TuiProps) {
|
||||
const session = useSession();
|
||||
const renderer = useRenderer();
|
||||
@@ -84,7 +91,8 @@ function App(props: TuiProps) {
|
||||
const [appView, setAppView] = useState<AppView>(() => {
|
||||
if (process.env.CLINE_FORCE_ONBOARDING === "1") return "onboarding";
|
||||
if (!isProviderConfigured(props.config)) return "onboarding";
|
||||
return props.initialView === "chat" || session.entries.length > 0
|
||||
return isChatBackedStartupTarget(props.startupTarget) ||
|
||||
session.entries.length > 0
|
||||
? "chat"
|
||||
: "home";
|
||||
});
|
||||
@@ -627,14 +635,7 @@ function App(props: TuiProps) {
|
||||
setSessionLastTotalTokens,
|
||||
]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount
|
||||
useEffect(() => {
|
||||
if (props.initialView === "config") {
|
||||
openConfig();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { handleSlashCommand } = useLocalCommandActions({
|
||||
const { handleSlashCommand, openHistory } = useLocalCommandActions({
|
||||
slashCommandRegistry,
|
||||
canForkSession,
|
||||
openAccount,
|
||||
@@ -646,12 +647,24 @@ function App(props: TuiProps) {
|
||||
setAppView,
|
||||
onClearConversation: clearConversation,
|
||||
onResumeSession: props.onResumeSession,
|
||||
onExportHistorySession: props.onExportHistorySession,
|
||||
onDeleteHistorySession: props.onDeleteHistorySession,
|
||||
onCompact: props.onCompact,
|
||||
onFork: props.onFork,
|
||||
onUndo: openCheckpointRestore,
|
||||
onExit: exitCline,
|
||||
});
|
||||
|
||||
const startupActionsRef = useRef({ openConfig, openHistory });
|
||||
startupActionsRef.current = { openConfig, openHistory };
|
||||
useEffect(() => {
|
||||
if (props.startupTarget === "config") {
|
||||
void startupActionsRef.current.openConfig();
|
||||
} else if (props.startupTarget === "history") {
|
||||
void startupActionsRef.current.openHistory();
|
||||
}
|
||||
}, [props.startupTarget]);
|
||||
|
||||
const runCommandPaletteResult = useCallback(
|
||||
async (result: CommandPaletteResult) => {
|
||||
if (result.action === "change-provider") {
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
PendingPromptSnapshot,
|
||||
PendingPromptSubmittedEvent,
|
||||
} from "../runtime/session-events";
|
||||
import type { HistoryExportFormat } from "../session/history-export";
|
||||
import type { RepoStatus } from "../utils/repo-status";
|
||||
import type { CliCompactionMode, Config } from "../utils/types";
|
||||
import type { ClineAccountSnapshot } from "./cline-account";
|
||||
@@ -123,6 +124,7 @@ export interface PendingPromptMutationResult {
|
||||
}
|
||||
|
||||
export type AppView = "onboarding" | "home" | "chat";
|
||||
export type TuiStartupTarget = "chat" | "config" | "history";
|
||||
|
||||
export type RuntimeToolInteraction =
|
||||
| {
|
||||
@@ -139,7 +141,7 @@ export type RuntimeToolInteraction =
|
||||
|
||||
export interface TuiProps {
|
||||
config: Config;
|
||||
initialView?: "chat" | "config";
|
||||
startupTarget?: TuiStartupTarget;
|
||||
initialPrompt?: string;
|
||||
initialNotice?: CliMigrationNotice;
|
||||
onInitialNoticeShown?: (notice: CliMigrationNotice) => void | Promise<void>;
|
||||
@@ -193,6 +195,11 @@ export interface TuiProps {
|
||||
onSessionRestart: () => Promise<void>;
|
||||
onAccountChange: () => Promise<void>;
|
||||
onResumeSession: (sessionId: string) => Promise<ResumedSessionResult>;
|
||||
onExportHistorySession: (
|
||||
sessionId: string,
|
||||
format: HistoryExportFormat,
|
||||
) => Promise<string>;
|
||||
onDeleteHistorySession: (sessionId: string) => Promise<boolean>;
|
||||
onCompact: () => Promise<InteractiveCompactionResult>;
|
||||
onFork: () => Promise<
|
||||
| {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildHistoryFooterText,
|
||||
HISTORY_EXPORT_OPTIONS,
|
||||
resolveHistoryExportPickerAction,
|
||||
} from "./history-export-picker";
|
||||
|
||||
describe("history export picker", () => {
|
||||
it("offers HTML and JSON exports", () => {
|
||||
expect(HISTORY_EXPORT_OPTIONS.map((option) => option.format)).toEqual([
|
||||
"html",
|
||||
"json",
|
||||
]);
|
||||
});
|
||||
|
||||
it("selects JSON with the down arrow and Enter", () => {
|
||||
const initialState = { sessionId: "sess_1", selectedIndex: 0 };
|
||||
const navigation = resolveHistoryExportPickerAction(initialState, {
|
||||
name: "down",
|
||||
});
|
||||
expect(navigation).toEqual({
|
||||
kind: "update",
|
||||
state: { sessionId: "sess_1", selectedIndex: 1 },
|
||||
});
|
||||
if (navigation.kind !== "update") {
|
||||
throw new Error("Expected export picker navigation");
|
||||
}
|
||||
|
||||
expect(
|
||||
resolveHistoryExportPickerAction(navigation.state, { name: "enter" }),
|
||||
).toEqual({
|
||||
kind: "export",
|
||||
sessionId: "sess_1",
|
||||
format: "json",
|
||||
});
|
||||
});
|
||||
|
||||
it("wraps selection and supports cancelling", () => {
|
||||
const initialState = { sessionId: "sess_1", selectedIndex: 0 };
|
||||
|
||||
expect(
|
||||
resolveHistoryExportPickerAction(initialState, { name: "up" }),
|
||||
).toEqual({
|
||||
kind: "update",
|
||||
state: { sessionId: "sess_1", selectedIndex: 1 },
|
||||
});
|
||||
expect(
|
||||
resolveHistoryExportPickerAction(initialState, { name: "escape" }),
|
||||
).toEqual({ kind: "cancel" });
|
||||
});
|
||||
|
||||
it("includes available history actions in the footer", () => {
|
||||
expect(
|
||||
buildHistoryFooterText({ canDelete: true, canExport: true }),
|
||||
).toContain("\u2190 delete, \u2192 export");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { HistoryExportFormat } from "../../session/history-export";
|
||||
|
||||
export const HISTORY_EXPORT_OPTIONS = [
|
||||
{
|
||||
format: "html",
|
||||
label: "HTML",
|
||||
description: "Standalone, readable conversation",
|
||||
},
|
||||
{
|
||||
format: "json",
|
||||
label: "JSON",
|
||||
description: "Structured session messages and metadata",
|
||||
},
|
||||
] as const satisfies ReadonlyArray<{
|
||||
format: HistoryExportFormat;
|
||||
label: string;
|
||||
description: string;
|
||||
}>;
|
||||
|
||||
export type HistoryExportPickerState = {
|
||||
sessionId: string;
|
||||
selectedIndex: number;
|
||||
};
|
||||
|
||||
export type HistoryPickerKey = {
|
||||
name?: string;
|
||||
ctrl?: boolean;
|
||||
};
|
||||
|
||||
export type HistoryExportPickerAction =
|
||||
| { kind: "cancel" }
|
||||
| {
|
||||
kind: "export";
|
||||
sessionId: string;
|
||||
format: HistoryExportFormat;
|
||||
}
|
||||
| { kind: "update"; state: HistoryExportPickerState }
|
||||
| { kind: "ignore" };
|
||||
|
||||
export function resolveHistoryExportPickerAction(
|
||||
state: HistoryExportPickerState,
|
||||
key: HistoryPickerKey,
|
||||
): HistoryExportPickerAction {
|
||||
if (key.name === "escape") {
|
||||
return { kind: "cancel" };
|
||||
}
|
||||
if (key.name === "return" || key.name === "enter") {
|
||||
const option = HISTORY_EXPORT_OPTIONS[state.selectedIndex];
|
||||
return option
|
||||
? {
|
||||
kind: "export",
|
||||
sessionId: state.sessionId,
|
||||
format: option.format,
|
||||
}
|
||||
: { kind: "ignore" };
|
||||
}
|
||||
if (
|
||||
key.name === "up" ||
|
||||
key.name === "left" ||
|
||||
(key.ctrl && key.name === "p")
|
||||
) {
|
||||
return {
|
||||
kind: "update",
|
||||
state: {
|
||||
...state,
|
||||
selectedIndex:
|
||||
state.selectedIndex <= 0
|
||||
? HISTORY_EXPORT_OPTIONS.length - 1
|
||||
: state.selectedIndex - 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (
|
||||
key.name === "down" ||
|
||||
key.name === "right" ||
|
||||
(key.ctrl && key.name === "n")
|
||||
) {
|
||||
return {
|
||||
kind: "update",
|
||||
state: {
|
||||
...state,
|
||||
selectedIndex:
|
||||
state.selectedIndex >= HISTORY_EXPORT_OPTIONS.length - 1
|
||||
? 0
|
||||
: state.selectedIndex + 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { kind: "ignore" };
|
||||
}
|
||||
|
||||
export function buildHistoryFooterText(input: {
|
||||
canDelete: boolean;
|
||||
canExport: boolean;
|
||||
}): string {
|
||||
return [
|
||||
"\u2191/\u2193 navigate",
|
||||
"Enter to resume",
|
||||
input.canDelete ? "\u2190 delete" : undefined,
|
||||
input.canExport ? "\u2192 export" : undefined,
|
||||
"Esc to close",
|
||||
]
|
||||
.filter((part): part is string => part !== undefined)
|
||||
.join(", ");
|
||||
}
|
||||
@@ -6,15 +6,22 @@ import {
|
||||
formatHumanReadableDate,
|
||||
truncateStr,
|
||||
} from "@cline/shared";
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/react";
|
||||
import { useTerminalDimensions } from "@opentui/react";
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { HistoryExportFormat } from "../../session/history-export";
|
||||
import { listSessions } from "../../session/session";
|
||||
import { mergeHistoryStatusRows } from "../../utils/history-format";
|
||||
import { formatUsd } from "../../utils/output";
|
||||
import { shouldShowCliUsageCost } from "../../utils/usage-cost-display";
|
||||
import { palette } from "../palette";
|
||||
import {
|
||||
buildHistoryFooterText,
|
||||
HISTORY_EXPORT_OPTIONS,
|
||||
type HistoryExportPickerState,
|
||||
resolveHistoryExportPickerAction,
|
||||
} from "./history-export-picker";
|
||||
|
||||
function hasForkMetadata(row: SessionHistoryRecord): boolean {
|
||||
const fork = row.metadata?.fork;
|
||||
@@ -51,7 +58,10 @@ const DEFAULT_REFRESH_INTERVAL_MS = 2000;
|
||||
type HistoryListActions = {
|
||||
onResolve: (sessionId: string) => void;
|
||||
onDismiss: () => void;
|
||||
onExport?: (sessionId: string) => Promise<string | undefined>;
|
||||
onExport?: (
|
||||
sessionId: string,
|
||||
format: HistoryExportFormat,
|
||||
) => Promise<string>;
|
||||
onDelete?: (sessionId: string) => Promise<boolean>;
|
||||
};
|
||||
|
||||
@@ -81,7 +91,7 @@ function HistoryListContent({
|
||||
onExport,
|
||||
onDelete,
|
||||
emptyMessage = "No sessions found",
|
||||
footerText = "\u2191/\u2193 navigate, Enter to resume, Esc to close",
|
||||
footerText,
|
||||
title = "Session History",
|
||||
loadRows = false,
|
||||
refreshRows,
|
||||
@@ -96,9 +106,16 @@ function HistoryListContent({
|
||||
const [loading, setLoading] = useState(loadRows);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
const [exportPicker, setExportPickerState] =
|
||||
useState<HistoryExportPickerState | null>(null);
|
||||
const exportPickerRef = useRef(exportPicker);
|
||||
const handlerRef = useRef<(key: HistoryKeyEvent | undefined) => void>(
|
||||
() => {},
|
||||
);
|
||||
const setExportPicker = (next: HistoryExportPickerState | null) => {
|
||||
exportPickerRef.current = next;
|
||||
setExportPickerState(next);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadInitialRows = () => listSessions(50, { hydrate: true });
|
||||
@@ -188,6 +205,26 @@ function HistoryListContent({
|
||||
return { items: rows.slice(start, end), startIndex: start };
|
||||
}, [rows, safeSelected]);
|
||||
|
||||
const exportSelectedSession = (
|
||||
sessionId: string,
|
||||
format: HistoryExportFormat,
|
||||
) => {
|
||||
if (!onExport) {
|
||||
return;
|
||||
}
|
||||
setExportPicker(null);
|
||||
setStatusMessage(`Exporting ${sessionId} as ${format.toUpperCase()}...`);
|
||||
void onExport(sessionId, format)
|
||||
.then((path) => {
|
||||
setStatusMessage(`Exported ${sessionId} to ${path}`);
|
||||
})
|
||||
.catch((error) => {
|
||||
setStatusMessage(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
handlerRef.current = (key: HistoryKeyEvent | undefined) => {
|
||||
if (!key) {
|
||||
return;
|
||||
@@ -196,6 +233,18 @@ function HistoryListContent({
|
||||
onDismiss();
|
||||
return;
|
||||
}
|
||||
const currentExportPicker = exportPickerRef.current;
|
||||
if (currentExportPicker) {
|
||||
const action = resolveHistoryExportPickerAction(currentExportPicker, key);
|
||||
if (action.kind === "cancel") {
|
||||
setExportPicker(null);
|
||||
} else if (action.kind === "update") {
|
||||
setExportPicker(action.state);
|
||||
} else if (action.kind === "export") {
|
||||
exportSelectedSession(action.sessionId, action.format);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (confirmDelete) {
|
||||
if (key.name === "y" || (key.shift && key.name === "y")) {
|
||||
const sessionId = confirmDelete;
|
||||
@@ -265,20 +314,11 @@ function HistoryListContent({
|
||||
if (key.name === "right") {
|
||||
const row = rowsRef.current[selectedRef.current];
|
||||
if (row?.sessionId && onExport) {
|
||||
setStatusMessage(`Exporting ${row.sessionId}...`);
|
||||
void onExport(row.sessionId)
|
||||
.then((path) => {
|
||||
setStatusMessage(
|
||||
path
|
||||
? `Exported ${row.sessionId} to ${path}`
|
||||
: `Exported ${row.sessionId}`,
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
setStatusMessage(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
});
|
||||
setStatusMessage(null);
|
||||
setExportPicker({
|
||||
sessionId: row.sessionId,
|
||||
selectedIndex: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -295,6 +335,53 @@ function HistoryListContent({
|
||||
);
|
||||
}
|
||||
|
||||
if (exportPicker) {
|
||||
const selectedRow = rows.find(
|
||||
(row) => row.sessionId === exportPicker.sessionId,
|
||||
);
|
||||
const exportTitle = selectedRow
|
||||
? formatTitle(selectedRow, Math.max(20, width - 12))
|
||||
: exportPicker.sessionId;
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1}>
|
||||
<text>Export Session</text>
|
||||
<text fg="gray" marginTop={1}>
|
||||
{exportTitle}
|
||||
</text>
|
||||
|
||||
<box flexDirection="column" marginTop={1}>
|
||||
{HISTORY_EXPORT_OPTIONS.map((option, index) => {
|
||||
const isSelected = index === exportPicker.selectedIndex;
|
||||
return (
|
||||
<box
|
||||
key={option.format}
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
backgroundColor={isSelected ? palette.selection : undefined}
|
||||
>
|
||||
<text fg={isSelected ? palette.textOnSelection : undefined}>
|
||||
{isSelected ? "\u276f " : " "}
|
||||
{option.label}
|
||||
</text>
|
||||
<text
|
||||
fg={isSelected ? palette.textOnSelection : "gray"}
|
||||
paddingLeft={2}
|
||||
>
|
||||
{option.description}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
})}
|
||||
</box>
|
||||
|
||||
<text fg="gray" marginTop={1}>
|
||||
<em>Arrow keys choose, Enter to export, Esc to go back</em>
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
@@ -309,6 +396,12 @@ function HistoryListContent({
|
||||
|
||||
const aboveCount = window.startIndex;
|
||||
const belowCount = rows.length - window.startIndex - window.items.length;
|
||||
const resolvedFooterText =
|
||||
footerText ??
|
||||
buildHistoryFooterText({
|
||||
canDelete: onDelete !== undefined,
|
||||
canExport: onExport !== undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1}>
|
||||
@@ -399,14 +492,17 @@ function HistoryListContent({
|
||||
)}
|
||||
|
||||
<text fg="gray" marginTop={1}>
|
||||
<em>{footerText}</em>
|
||||
<em>{resolvedFooterText}</em>
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
export function HistoryDialogContent(props: ChoiceContext<string>) {
|
||||
const { resolve, dismiss, dialogId } = props;
|
||||
export function HistoryDialogContent(
|
||||
props: ChoiceContext<string> &
|
||||
Pick<HistoryListActions, "onExport" | "onDelete">,
|
||||
) {
|
||||
const { resolve, dismiss, dialogId, onExport, onDelete } = props;
|
||||
const [keyHandler, setKeyHandler] = useState<
|
||||
((key: HistoryKeyEvent | undefined) => void) | undefined
|
||||
>();
|
||||
@@ -424,46 +520,8 @@ export function HistoryDialogContent(props: ChoiceContext<string>) {
|
||||
loadRows
|
||||
onResolve={resolve}
|
||||
onDismiss={dismiss}
|
||||
registerKeyHandler={registerKeyHandler}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function HistoryStandaloneContent(
|
||||
props: HistoryListActions & {
|
||||
rows: SessionHistoryRecord[];
|
||||
title?: string;
|
||||
footerText?: string;
|
||||
refreshRows?: () => Promise<SessionHistoryRecord[]>;
|
||||
refreshIntervalMs?: number;
|
||||
},
|
||||
) {
|
||||
const [keyHandler, setKeyHandler] = useState<
|
||||
((key: HistoryKeyEvent | undefined) => void) | undefined
|
||||
>();
|
||||
const registerKeyHandler = useCallback(
|
||||
(handler: (key: HistoryKeyEvent | undefined) => void) => {
|
||||
setKeyHandler(() => handler);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useKeyboard((key) => keyHandler?.(key));
|
||||
|
||||
return (
|
||||
<HistoryListContent
|
||||
initialRows={props.rows}
|
||||
onResolve={props.onResolve}
|
||||
onDismiss={props.onDismiss}
|
||||
onExport={props.onExport}
|
||||
onDelete={props.onDelete}
|
||||
refreshRows={props.refreshRows}
|
||||
refreshIntervalMs={props.refreshIntervalMs}
|
||||
title={props.title ?? "History"}
|
||||
footerText={
|
||||
props.footerText ??
|
||||
"\u2191/\u2193 navigate, Enter to resume, \u2190 delete, \u2192 export, Esc to close"
|
||||
}
|
||||
onExport={onExport}
|
||||
onDelete={onDelete}
|
||||
registerKeyHandler={registerKeyHandler}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildHistoryResumeArgs } from "./history-resume";
|
||||
|
||||
describe("buildHistoryResumeArgs", () => {
|
||||
it("replaces the history subcommand with --id", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["history"],
|
||||
remainingArgs: ["history"],
|
||||
}),
|
||||
).toEqual(["--id", "sess_1"]);
|
||||
});
|
||||
|
||||
it("preserves global flags that precede the subcommand", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: [
|
||||
"--data-dir",
|
||||
"/tmp/data",
|
||||
"-m",
|
||||
"claude-sonnet-4-6",
|
||||
"history",
|
||||
"--limit",
|
||||
"5",
|
||||
],
|
||||
remainingArgs: ["history", "--limit", "5"],
|
||||
}),
|
||||
).toEqual([
|
||||
"--data-dir",
|
||||
"/tmp/data",
|
||||
"-m",
|
||||
"claude-sonnet-4-6",
|
||||
"--id",
|
||||
"sess_1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a global flag value that matches the subcommand alias", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["-m", "h", "h"],
|
||||
remainingArgs: ["h"],
|
||||
}),
|
||||
).toEqual(["-m", "h", "--id", "sess_1"]);
|
||||
});
|
||||
|
||||
it("forwards a config dir passed as a subcommand option", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["history", "--config", "/tmp/conf"],
|
||||
remainingArgs: ["history", "--config", "/tmp/conf"],
|
||||
configDir: "/tmp/conf",
|
||||
}),
|
||||
).toEqual(["--config", "/tmp/conf", "--id", "sess_1"]);
|
||||
});
|
||||
|
||||
it("does not duplicate a config dir already in the global flags", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["--config", "/tmp/conf", "history"],
|
||||
remainingArgs: ["history"],
|
||||
configDir: "/tmp/conf",
|
||||
}),
|
||||
).toEqual(["--config", "/tmp/conf", "--id", "sess_1"]);
|
||||
});
|
||||
|
||||
it("recognizes the --config=<dir> spelling in global flags", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["--config=/tmp/conf", "history"],
|
||||
remainingArgs: ["history"],
|
||||
configDir: "/tmp/conf",
|
||||
}),
|
||||
).toEqual(["--config=/tmp/conf", "--id", "sess_1"]);
|
||||
});
|
||||
|
||||
it("returns undefined when remaining args are not a suffix of argv", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["history", "--limit", "5"],
|
||||
remainingArgs: ["history", "--limit", "9"],
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["history"],
|
||||
remainingArgs: ["extra", "history"],
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,115 +0,0 @@
|
||||
import { resolveCliLaunchSpec } from "./internal-launch";
|
||||
|
||||
export interface HistoryResumeCommand {
|
||||
launcher: string;
|
||||
childArgs: string[];
|
||||
}
|
||||
|
||||
export interface BuildHistoryResumeArgsInput {
|
||||
sessionId: string;
|
||||
/** Full normalized CLI args (process.argv.slice(2) after normalization). */
|
||||
normalizedArgs: string[];
|
||||
/**
|
||||
* Commander's `program.args` after parsing: the `history` subcommand token
|
||||
* and everything following it. Must be a suffix of `normalizedArgs`.
|
||||
*/
|
||||
remainingArgs: string[];
|
||||
/**
|
||||
* Config dir resolved from the full argv. Forwarded explicitly because
|
||||
* `--config` may have been passed as a `history` subcommand option, which
|
||||
* would otherwise be dropped with the rest of the subcommand args.
|
||||
*/
|
||||
configDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds argv for relaunching the CLI as `cline <globalFlags> --id <sessionId>`
|
||||
* after a session is picked in `cline history`. Returns undefined when the
|
||||
* global-flag prefix cannot be derived safely (caller falls back to resuming
|
||||
* in-process).
|
||||
*/
|
||||
export function buildHistoryResumeArgs(
|
||||
input: BuildHistoryResumeArgsInput,
|
||||
): string[] | undefined {
|
||||
const { sessionId, normalizedArgs, remainingArgs, configDir } = input;
|
||||
const splitIndex = normalizedArgs.length - remainingArgs.length;
|
||||
if (splitIndex < 0) {
|
||||
return undefined;
|
||||
}
|
||||
for (let i = 0; i < remainingArgs.length; i++) {
|
||||
if (normalizedArgs[splitIndex + i] !== remainingArgs[i]) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
const globalArgs = normalizedArgs.slice(0, splitIndex);
|
||||
const args = [...globalArgs];
|
||||
const hasConfigFlag = globalArgs.some(
|
||||
(arg) => arg === "--config" || arg.startsWith("--config="),
|
||||
);
|
||||
if (configDir && !hasConfigFlag) {
|
||||
args.push("--config", configDir);
|
||||
}
|
||||
args.push("--id", sessionId);
|
||||
return args;
|
||||
}
|
||||
|
||||
export function buildHistoryResumeCommand(
|
||||
input: BuildHistoryResumeArgsInput,
|
||||
): HistoryResumeCommand | undefined {
|
||||
const childArgs = buildHistoryResumeArgs(input);
|
||||
if (!childArgs) {
|
||||
return undefined;
|
||||
}
|
||||
const spec = resolveCliLaunchSpec();
|
||||
if (!spec) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
launcher: spec.launcher,
|
||||
childArgs: [...spec.childArgsPrefix, ...childArgs],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumes a history-picked session in a fresh `cline --id <sessionId>` child
|
||||
* process with inherited stdio, and returns its exit code. Creating a second
|
||||
* OpenTUI renderer in the picker's process can crash natively during teardown
|
||||
* (Bun "panic(main thread): Segmentation fault" on Ctrl+C), so the resumed
|
||||
* interactive TUI must get a process of its own.
|
||||
*
|
||||
* Returns undefined when the child cannot be launched; the caller should fall
|
||||
* back to resuming in-process.
|
||||
*/
|
||||
export async function spawnHistoryResume(
|
||||
input: BuildHistoryResumeArgsInput,
|
||||
): Promise<number | undefined> {
|
||||
const command = buildHistoryResumeCommand(input);
|
||||
if (!command) {
|
||||
return undefined;
|
||||
}
|
||||
const { spawn } = await import("node:child_process");
|
||||
return await new Promise<number | undefined>((resolve) => {
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = spawn(command.launcher, command.childArgs, {
|
||||
stdio: "inherit",
|
||||
});
|
||||
} catch {
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
// The child shares this foreground process group, so terminal-generated
|
||||
// Ctrl+C already reaches it. Keep the parent alive to reap the child
|
||||
// without re-forwarding a second signal into the TUI teardown path.
|
||||
const suppressParentSignal = () => {};
|
||||
process.on("SIGINT", suppressParentSignal);
|
||||
process.on("SIGTERM", suppressParentSignal);
|
||||
const finish = (value: number | undefined) => {
|
||||
process.off("SIGINT", suppressParentSignal);
|
||||
process.off("SIGTERM", suppressParentSignal);
|
||||
resolve(value);
|
||||
};
|
||||
child.once("error", () => finish(undefined));
|
||||
child.once("exit", (code, signal) => finish(signal ? 1 : (code ?? 0)));
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,17 @@
|
||||
# Cline Code Desktop Changelog
|
||||
|
||||
## 0.0.7
|
||||
|
||||
- New system tray icon showing app status and how many agent sessions are currently running.
|
||||
- Session history is now paginated in ten-session pages, fetching older history only when you reach the end.
|
||||
- You can favorite sessions, and sessions are now ordered by most recent activity with consistent status dot colors across views.
|
||||
- Subagent and teammate runs from a session now show up in the app with their status and results.
|
||||
- Chat polish: tool-specific icons on tool disclosures, elapsed thinking time and restyled reasoning sections, aligned timestamps, and message actions that no longer shift the layout while scrolling stays anchored to the conversation viewport.
|
||||
- Free Cline models are now supported and labeled "(free)" in model pickers, with a clear message — including reset time — when you hit the free-tier limit.
|
||||
- Fixed the China/international endpoint toggles for Qwen, Moonshot, Z AI, and MiniMax being ignored, which silently routed regional users to the wrong host.
|
||||
- Fixed tool calls failing when a model emitted a line number as a string (e.g. `insert_line: "3"`), forcing the agent to waste a round trip retrying.
|
||||
- Refreshed the bundled provider and model catalog.
|
||||
|
||||
## 0.0.6
|
||||
|
||||
- Queued messages now appear in a collapsible list above the composer with a count — expand it to edit, send-now, or delete individual queued turns.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/code",
|
||||
"version": "0.0.6",
|
||||
"version": "0.0.7",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:ui": "bun -F @cline/ui build",
|
||||
|
||||
@@ -146,6 +146,7 @@ describe("pathless session starts", () => {
|
||||
});
|
||||
const ctx = {
|
||||
liveSessions: new Map(),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
sessionManager: { start },
|
||||
} as unknown as SidecarContext;
|
||||
|
||||
@@ -174,6 +175,439 @@ describe("pathless session starts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("session forks", () => {
|
||||
it("restores the selected workspace checkpoint before forking for message editing", async () => {
|
||||
const sourceSessionId = `source-fork-${Date.now()}`;
|
||||
const sourceMessages = [
|
||||
{ role: "user" as const, content: "first prompt" },
|
||||
{ role: "assistant" as const, content: "first response" },
|
||||
{ role: "user" as const, content: "prompt to edit" },
|
||||
{ role: "assistant" as const, content: "response to replace" },
|
||||
];
|
||||
const expectedMessages = sourceMessages.slice(0, 2);
|
||||
const start = vi.fn(async () => ({ sessionId: "edited-fork" }));
|
||||
const restore = vi.fn(async () => ({
|
||||
sessionId: "edited-fork",
|
||||
messages: sourceMessages.slice(0, 3),
|
||||
checkpoint: {
|
||||
ref: "second",
|
||||
createdAt: 2,
|
||||
runCount: 2,
|
||||
},
|
||||
}));
|
||||
const readMessages = vi.fn(async () => expectedMessages);
|
||||
const ctx = {
|
||||
liveSessions: new Map([
|
||||
[
|
||||
sourceSessionId,
|
||||
{
|
||||
config: {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
},
|
||||
messages: sourceMessages,
|
||||
promptsInQueue: [],
|
||||
busy: false,
|
||||
startedAt: Date.now(),
|
||||
status: "completed",
|
||||
},
|
||||
],
|
||||
]),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
sessionManager: {
|
||||
get: vi.fn(async () => ({
|
||||
sessionId: sourceSessionId,
|
||||
source: "desktop",
|
||||
status: "completed",
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
cwd: "/workspace/project",
|
||||
workspaceRoot: "/workspace/project",
|
||||
metadata: {
|
||||
checkpoint: {
|
||||
latest: { ref: "second", createdAt: 2, runCount: 2 },
|
||||
history: [
|
||||
{ ref: "first", createdAt: 1, runCount: 1 },
|
||||
{ ref: "second", createdAt: 2, runCount: 2 },
|
||||
],
|
||||
},
|
||||
},
|
||||
})),
|
||||
readMessages,
|
||||
restore,
|
||||
start,
|
||||
},
|
||||
streamIndices: new Map(),
|
||||
wsClients: new Set(),
|
||||
} as unknown as SidecarContext;
|
||||
|
||||
const result = (await handleChatSessionCommand(ctx, {
|
||||
action: "fork",
|
||||
sessionId: sourceSessionId,
|
||||
forkBeforeRunCount: 2,
|
||||
config: {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
},
|
||||
})) as { sessionId: string; messages: unknown[] };
|
||||
|
||||
expect(restore).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: sourceSessionId,
|
||||
checkpointRunCount: 2,
|
||||
cwd: "/workspace/project",
|
||||
restore: {
|
||||
messages: true,
|
||||
workspace: true,
|
||||
omitCheckpointMessageFromSession: true,
|
||||
},
|
||||
start: expect.objectContaining({
|
||||
sessionMetadata: expect.objectContaining({
|
||||
fork: expect.objectContaining({
|
||||
forkedFromSessionId: sourceSessionId,
|
||||
beforeRunCount: 2,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
expect(readMessages).toHaveBeenCalledWith("edited-fork");
|
||||
expect(result).toEqual({
|
||||
sessionId: "edited-fork",
|
||||
forkedFromSessionId: sourceSessionId,
|
||||
messages: expectedMessages,
|
||||
});
|
||||
expect(ctx.liveSessions.get("edited-fork")?.messages).toEqual(
|
||||
expectedMessages,
|
||||
);
|
||||
expect(ctx.restoringWorkspacePaths.size).toBe(0);
|
||||
});
|
||||
|
||||
it("holds the workspace lock for the full edit restore", async () => {
|
||||
const sourceSessionId = `locking-source-${Date.now()}`;
|
||||
const siblingSessionId = `locking-sibling-${Date.now()}`;
|
||||
const sourceMessages = [
|
||||
{ role: "user" as const, content: "first prompt" },
|
||||
{ role: "assistant" as const, content: "first response" },
|
||||
];
|
||||
let releaseRestore = () => {};
|
||||
const restoreGate = new Promise<void>((resolve) => {
|
||||
releaseRestore = resolve;
|
||||
});
|
||||
let markRestoreStarted = () => {};
|
||||
const restoreStarted = new Promise<void>((resolve) => {
|
||||
markRestoreStarted = resolve;
|
||||
});
|
||||
const send = vi.fn();
|
||||
const restore = vi.fn(async () => {
|
||||
markRestoreStarted();
|
||||
await restoreGate;
|
||||
return {
|
||||
sessionId: "locked-edited-fork",
|
||||
messages: sourceMessages,
|
||||
checkpoint: { ref: "first", createdAt: 1, runCount: 1 },
|
||||
};
|
||||
});
|
||||
const ctx = {
|
||||
liveSessions: new Map([
|
||||
[
|
||||
sourceSessionId,
|
||||
{
|
||||
config: {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
cwd: "/workspace/project",
|
||||
},
|
||||
messages: sourceMessages,
|
||||
promptsInQueue: [],
|
||||
busy: false,
|
||||
startedAt: Date.now(),
|
||||
status: "idle",
|
||||
},
|
||||
],
|
||||
[
|
||||
siblingSessionId,
|
||||
{
|
||||
config: { workspaceRoot: "/workspace/project/." },
|
||||
messages: [],
|
||||
promptsInQueue: [],
|
||||
busy: false,
|
||||
startedAt: Date.now(),
|
||||
status: "idle",
|
||||
},
|
||||
],
|
||||
]),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
sessionManager: {
|
||||
get: vi.fn(async () => ({
|
||||
sessionId: sourceSessionId,
|
||||
source: "desktop",
|
||||
status: "completed",
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
cwd: "/workspace/project",
|
||||
workspaceRoot: "/workspace/project",
|
||||
metadata: {
|
||||
checkpoint: {
|
||||
latest: { ref: "first", createdAt: 1, runCount: 1 },
|
||||
history: [{ ref: "first", createdAt: 1, runCount: 1 }],
|
||||
},
|
||||
},
|
||||
})),
|
||||
readMessages: vi.fn(async () => sourceMessages),
|
||||
restore,
|
||||
send,
|
||||
},
|
||||
streamIndices: new Map(),
|
||||
wsClients: new Set(),
|
||||
} as unknown as SidecarContext;
|
||||
|
||||
const fork = handleChatSessionCommand(ctx, {
|
||||
action: "fork",
|
||||
sessionId: sourceSessionId,
|
||||
forkBeforeRunCount: 1,
|
||||
});
|
||||
await restoreStarted;
|
||||
try {
|
||||
expect(ctx.restoringWorkspacePaths).toEqual(
|
||||
new Set(["/workspace/project"]),
|
||||
);
|
||||
await expect(
|
||||
handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId: siblingSessionId,
|
||||
prompt: "race",
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Cannot send a prompt while the session workspace is being restored",
|
||||
);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
releaseRestore();
|
||||
}
|
||||
|
||||
await expect(fork).resolves.toMatchObject({
|
||||
sessionId: "locked-edited-fork",
|
||||
});
|
||||
expect(ctx.restoringWorkspacePaths.size).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps a full-history fork on the current workspace without restoring", async () => {
|
||||
const sourceSessionId = `source-full-fork-${Date.now()}`;
|
||||
const sourceMessages = [
|
||||
{ role: "user" as const, content: "first prompt" },
|
||||
{ role: "assistant" as const, content: "first response" },
|
||||
];
|
||||
const start = vi.fn(async () => ({ sessionId: "full-fork" }));
|
||||
const restore = vi.fn();
|
||||
const readMessages = vi.fn(async () => sourceMessages);
|
||||
const ctx = {
|
||||
liveSessions: new Map([
|
||||
[
|
||||
sourceSessionId,
|
||||
{
|
||||
config: {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
},
|
||||
messages: sourceMessages,
|
||||
promptsInQueue: [],
|
||||
busy: false,
|
||||
startedAt: Date.now(),
|
||||
status: "completed",
|
||||
},
|
||||
],
|
||||
]),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
sessionManager: {
|
||||
get: vi.fn(async () => ({
|
||||
sessionId: sourceSessionId,
|
||||
source: "desktop",
|
||||
status: "completed",
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
cwd: "/workspace/project",
|
||||
workspaceRoot: "/workspace/project",
|
||||
})),
|
||||
readMessages,
|
||||
restore,
|
||||
start,
|
||||
},
|
||||
streamIndices: new Map(),
|
||||
wsClients: new Set(),
|
||||
} as unknown as SidecarContext;
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "fork",
|
||||
sessionId: sourceSessionId,
|
||||
config: {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
},
|
||||
});
|
||||
|
||||
expect(restore).not.toHaveBeenCalled();
|
||||
expect(start).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ initialMessages: sourceMessages }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an edit fork while the source session is running", async () => {
|
||||
const restore = vi.fn();
|
||||
const sourceSessionId = "busy-source-session";
|
||||
const ctx = {
|
||||
liveSessions: new Map([
|
||||
[
|
||||
sourceSessionId,
|
||||
{
|
||||
config: {},
|
||||
messages: [{ role: "user", content: "prompt" }],
|
||||
promptsInQueue: [],
|
||||
busy: true,
|
||||
startedAt: Date.now(),
|
||||
status: "running",
|
||||
},
|
||||
],
|
||||
]),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
sessionManager: { restore },
|
||||
} as unknown as SidecarContext;
|
||||
|
||||
await expect(
|
||||
handleChatSessionCommand(ctx, {
|
||||
action: "fork",
|
||||
sessionId: sourceSessionId,
|
||||
forkBeforeRunCount: 1,
|
||||
}),
|
||||
).rejects.toThrow("Wait for all turns in this workspace to finish");
|
||||
expect(restore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an edit fork when the persisted session is still active", async () => {
|
||||
const restore = vi.fn();
|
||||
const sourceSessionId = "persisted-running-session";
|
||||
const ctx = {
|
||||
liveSessions: new Map([
|
||||
[
|
||||
sourceSessionId,
|
||||
{
|
||||
config: {},
|
||||
messages: [{ role: "user", content: "prompt" }],
|
||||
promptsInQueue: [],
|
||||
busy: false,
|
||||
startedAt: Date.now(),
|
||||
status: "idle",
|
||||
},
|
||||
],
|
||||
]),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
sessionManager: {
|
||||
get: vi.fn(async () => ({
|
||||
sessionId: sourceSessionId,
|
||||
status: "running",
|
||||
})),
|
||||
restore,
|
||||
},
|
||||
} as unknown as SidecarContext;
|
||||
|
||||
await expect(
|
||||
handleChatSessionCommand(ctx, {
|
||||
action: "fork",
|
||||
sessionId: sourceSessionId,
|
||||
forkBeforeRunCount: 1,
|
||||
}),
|
||||
).rejects.toThrow("Wait for all turns in this workspace to finish");
|
||||
expect(restore).not.toHaveBeenCalled();
|
||||
expect(ctx.restoringWorkspacePaths.size).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects an edit fork while a sibling session in the workspace is running", async () => {
|
||||
const sourceSessionId = "idle-source-session";
|
||||
const siblingSessionId = "busy-sibling-session";
|
||||
const restore = vi.fn();
|
||||
const ctx = {
|
||||
liveSessions: new Map([
|
||||
[
|
||||
sourceSessionId,
|
||||
{
|
||||
config: { cwd: "/workspace/project" },
|
||||
messages: [{ role: "user", content: "prompt" }],
|
||||
promptsInQueue: [],
|
||||
busy: false,
|
||||
startedAt: Date.now(),
|
||||
status: "idle",
|
||||
},
|
||||
],
|
||||
[
|
||||
siblingSessionId,
|
||||
{
|
||||
config: { workspaceRoot: "/workspace/project/." },
|
||||
messages: [],
|
||||
promptsInQueue: [],
|
||||
busy: true,
|
||||
startedAt: Date.now(),
|
||||
status: "running",
|
||||
},
|
||||
],
|
||||
]),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
sessionManager: {
|
||||
get: vi.fn(async () => ({
|
||||
sessionId: sourceSessionId,
|
||||
status: "completed",
|
||||
cwd: "/workspace/project",
|
||||
workspaceRoot: "/workspace/project",
|
||||
})),
|
||||
restore,
|
||||
},
|
||||
} as unknown as SidecarContext;
|
||||
|
||||
await expect(
|
||||
handleChatSessionCommand(ctx, {
|
||||
action: "fork",
|
||||
sessionId: sourceSessionId,
|
||||
forkBeforeRunCount: 1,
|
||||
}),
|
||||
).rejects.toThrow("Wait for all turns in this workspace to finish");
|
||||
expect(restore).not.toHaveBeenCalled();
|
||||
expect(ctx.restoringWorkspacePaths.size).toBe(0);
|
||||
});
|
||||
|
||||
it("blocks sends from sibling sessions while their workspace is restored", async () => {
|
||||
const send = vi.fn();
|
||||
const sessionId = "workspace-sibling-session";
|
||||
const ctx = {
|
||||
liveSessions: new Map([
|
||||
[
|
||||
sessionId,
|
||||
{
|
||||
config: { workspaceRoot: "/workspace/project/." },
|
||||
messages: [{ role: "user", content: "prompt" }],
|
||||
promptsInQueue: [],
|
||||
busy: false,
|
||||
startedAt: Date.now(),
|
||||
status: "idle",
|
||||
},
|
||||
],
|
||||
]),
|
||||
restoringWorkspacePaths: new Set(["/workspace/project"]),
|
||||
sessionManager: { send },
|
||||
} as unknown as SidecarContext;
|
||||
|
||||
await expect(
|
||||
handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "race",
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Cannot send a prompt while the session workspace is being restored",
|
||||
);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("first-send connection updates", () => {
|
||||
const baseConfig = {
|
||||
provider: "cline",
|
||||
@@ -215,6 +649,7 @@ describe("first-send connection updates", () => {
|
||||
},
|
||||
],
|
||||
]),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
streamIndices: new Map(),
|
||||
wsClients: new Set(),
|
||||
sessionManager: {
|
||||
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
projectSessionCompactionState,
|
||||
type SessionCompactionState,
|
||||
type SessionPendingPrompt,
|
||||
type SessionRecord,
|
||||
SessionSource,
|
||||
splitCoreSessionConfig,
|
||||
trimMessagesBeforeUserRun,
|
||||
} from "@cline/core";
|
||||
import type { Message } from "@cline/llms";
|
||||
import { buildClineSystemPrompt } from "@cline/shared";
|
||||
@@ -45,6 +47,80 @@ const workspaceMetadataPromises = new Map<
|
||||
string,
|
||||
WorkspaceMetadataCacheEntry
|
||||
>();
|
||||
const ACTIVE_WORKSPACE_SESSION_STATUSES = new Set([
|
||||
"starting",
|
||||
"pending",
|
||||
"running",
|
||||
"stopping",
|
||||
]);
|
||||
const WORKSPACE_RESTORE_SEND_ERROR =
|
||||
"Cannot send a prompt while the session workspace is being restored";
|
||||
const WORKSPACE_RESTORE_BUSY_ERROR =
|
||||
"Wait for all turns in this workspace to finish before restoring it";
|
||||
|
||||
type WorkspacePathSource = {
|
||||
cwd?: unknown;
|
||||
workspaceRoot?: unknown;
|
||||
workspace_root?: unknown;
|
||||
};
|
||||
|
||||
function readWorkspacePath(
|
||||
source: WorkspacePathSource | undefined,
|
||||
): string | undefined {
|
||||
const cwd = typeof source?.cwd === "string" ? source.cwd.trim() : "";
|
||||
if (cwd) return cwd;
|
||||
const workspaceRoot =
|
||||
typeof source?.workspaceRoot === "string"
|
||||
? source.workspaceRoot.trim()
|
||||
: "";
|
||||
if (workspaceRoot) return workspaceRoot;
|
||||
const snakeCaseWorkspaceRoot =
|
||||
typeof source?.workspace_root === "string"
|
||||
? source.workspace_root.trim()
|
||||
: "";
|
||||
return snakeCaseWorkspaceRoot || undefined;
|
||||
}
|
||||
|
||||
function workspacePathKey(
|
||||
source: WorkspacePathSource | undefined,
|
||||
): string | undefined {
|
||||
const workspacePath = readWorkspacePath(source);
|
||||
return workspacePath ? resolve(workspacePath) : undefined;
|
||||
}
|
||||
|
||||
function hasActiveWorkspaceTurn(session: LiveSession): boolean {
|
||||
return (
|
||||
session.busy ||
|
||||
session.transitioningProvider === true ||
|
||||
ACTIVE_WORKSPACE_SESSION_STATUSES.has(session.status)
|
||||
);
|
||||
}
|
||||
|
||||
async function withWorkspaceRestoreLock<T>(
|
||||
ctx: SidecarContext,
|
||||
workspacePath: string,
|
||||
work: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const key = resolve(workspacePath);
|
||||
if (ctx.restoringWorkspacePaths.has(key)) {
|
||||
throw new Error(WORKSPACE_RESTORE_BUSY_ERROR);
|
||||
}
|
||||
for (const session of ctx.liveSessions.values()) {
|
||||
if (
|
||||
workspacePathKey(session.config) === key &&
|
||||
hasActiveWorkspaceTurn(session)
|
||||
) {
|
||||
throw new Error(WORKSPACE_RESTORE_BUSY_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restoringWorkspacePaths.add(key);
|
||||
try {
|
||||
return await work();
|
||||
} finally {
|
||||
ctx.restoringWorkspacePaths.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function getWorkspaceMetadataPromise(
|
||||
cwd: string,
|
||||
@@ -723,6 +799,15 @@ async function handleSend(
|
||||
}
|
||||
const manager = getSessionManager(ctx);
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
const lockedWorkspaceKey = workspacePathKey(
|
||||
session?.config ?? request.config,
|
||||
);
|
||||
if (
|
||||
lockedWorkspaceKey &&
|
||||
ctx.restoringWorkspacePaths.has(lockedWorkspaceKey)
|
||||
) {
|
||||
throw new Error(WORKSPACE_RESTORE_SEND_ERROR);
|
||||
}
|
||||
if (session?.transitioningProvider) {
|
||||
throw new Error("A provider switch is already in progress");
|
||||
}
|
||||
@@ -932,6 +1017,65 @@ async function handleFork(
|
||||
): Promise<unknown> {
|
||||
const sourceSessionId = request.sessionId?.trim();
|
||||
if (!sourceSessionId) throw new Error("sessionId is required");
|
||||
const forkBeforeRunCount = request.forkBeforeRunCount;
|
||||
if (
|
||||
forkBeforeRunCount !== undefined &&
|
||||
(!Number.isInteger(forkBeforeRunCount) || forkBeforeRunCount < 1)
|
||||
) {
|
||||
throw new Error("forkBeforeRunCount must be a positive integer");
|
||||
}
|
||||
const manager = getSessionManager(ctx);
|
||||
const liveSourceSession = ctx.liveSessions.get(sourceSessionId);
|
||||
if (
|
||||
forkBeforeRunCount !== undefined &&
|
||||
liveSourceSession &&
|
||||
hasActiveWorkspaceTurn(liveSourceSession)
|
||||
) {
|
||||
throw new Error(WORKSPACE_RESTORE_BUSY_ERROR);
|
||||
}
|
||||
const sourceSession = await manager.get(sourceSessionId);
|
||||
if (
|
||||
forkBeforeRunCount !== undefined &&
|
||||
(sourceSession?.status === "running" || sourceSession?.status === "pending")
|
||||
) {
|
||||
throw new Error(WORKSPACE_RESTORE_BUSY_ERROR);
|
||||
}
|
||||
if (forkBeforeRunCount === undefined) {
|
||||
return handleForkUnlocked(
|
||||
ctx,
|
||||
request,
|
||||
sourceSessionId,
|
||||
forkBeforeRunCount,
|
||||
sourceSession,
|
||||
);
|
||||
}
|
||||
const restoreWorkspacePath =
|
||||
readWorkspacePath(sourceSession) ??
|
||||
readWorkspacePath(liveSourceSession?.config) ??
|
||||
readWorkspacePath(request.config);
|
||||
if (!restoreWorkspacePath) {
|
||||
throw new Error("cwd or workspaceRoot is required to edit a message");
|
||||
}
|
||||
return withWorkspaceRestoreLock(ctx, restoreWorkspacePath, () =>
|
||||
handleForkUnlocked(
|
||||
ctx,
|
||||
request,
|
||||
sourceSessionId,
|
||||
forkBeforeRunCount,
|
||||
sourceSession,
|
||||
restoreWorkspacePath,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function handleForkUnlocked(
|
||||
ctx: SidecarContext,
|
||||
request: ChatSessionCommandRequest,
|
||||
sourceSessionId: string,
|
||||
forkBeforeRunCount: number | undefined,
|
||||
sourceSession: SessionRecord | undefined,
|
||||
restoreWorkspacePath?: string,
|
||||
): Promise<unknown> {
|
||||
const manager = getSessionManager(ctx);
|
||||
const sourceMessages =
|
||||
readPersistedChatMessages(sourceSessionId) ??
|
||||
@@ -940,7 +1084,6 @@ async function handleFork(
|
||||
throw new Error(`No messages found for session ${sourceSessionId}`);
|
||||
}
|
||||
|
||||
const sourceSession = await manager.get(sourceSessionId);
|
||||
const sourceMetadata =
|
||||
(sourceSession?.metadata && typeof sourceSession.metadata === "object"
|
||||
? (sourceSession.metadata as JsonRecord)
|
||||
@@ -983,31 +1126,77 @@ async function handleFork(
|
||||
sourceMetadata?.checkpoint !== undefined
|
||||
? { checkpoints: sourceMetadata.checkpoint }
|
||||
: {};
|
||||
let forkMessages =
|
||||
forkBeforeRunCount === undefined
|
||||
? sourceMessages
|
||||
: trimMessagesBeforeUserRun(
|
||||
sourceMessages as Message[],
|
||||
forkBeforeRunCount,
|
||||
);
|
||||
const forkMetadata: JsonRecord = {
|
||||
...(sourceMetadata ?? {}),
|
||||
fork: {
|
||||
forkedFromSessionId: sourceSessionId,
|
||||
forkedAt: new Date().toISOString(),
|
||||
source: sourceSession?.source ?? "desktop",
|
||||
...(forkBeforeRunCount !== undefined
|
||||
? { beforeRunCount: forkBeforeRunCount }
|
||||
: {}),
|
||||
...checkpointMetadata,
|
||||
},
|
||||
};
|
||||
const systemPrompt = await resolveSystemPrompt(forkConfig);
|
||||
const startResult = await manager.start({
|
||||
const startInput = {
|
||||
...splitCoreSessionConfig(
|
||||
buildCoreSessionConfig({
|
||||
...forkConfig,
|
||||
systemPrompt,
|
||||
initialMessages: sourceMessages,
|
||||
}) as unknown as ClineCoreStartConfig,
|
||||
),
|
||||
source: SessionSource.DESKTOP,
|
||||
interactive: true,
|
||||
initialMessages: sourceMessages as Message[],
|
||||
sessionMetadata: forkMetadata,
|
||||
toolPolicies: resolveToolPolicies(forkConfig),
|
||||
});
|
||||
const newSessionId = startResult.sessionId;
|
||||
};
|
||||
let newSessionId: string;
|
||||
if (forkBeforeRunCount !== undefined) {
|
||||
const cwd =
|
||||
restoreWorkspacePath ||
|
||||
(typeof forkConfig.cwd === "string" && forkConfig.cwd.trim()) ||
|
||||
(typeof forkConfig.workspaceRoot === "string" &&
|
||||
forkConfig.workspaceRoot.trim()) ||
|
||||
"";
|
||||
if (!cwd) {
|
||||
throw new Error("cwd or workspaceRoot is required to edit a message");
|
||||
}
|
||||
const restored = await manager.restore({
|
||||
sessionId: sourceSessionId,
|
||||
checkpointRunCount: forkBeforeRunCount,
|
||||
cwd,
|
||||
restore: {
|
||||
messages: true,
|
||||
workspace: true,
|
||||
omitCheckpointMessageFromSession: true,
|
||||
},
|
||||
start: startInput,
|
||||
});
|
||||
if (!restored.sessionId) {
|
||||
throw new Error("Message edit restore did not return a new session");
|
||||
}
|
||||
newSessionId = restored.sessionId;
|
||||
} else {
|
||||
const started = await manager.start({
|
||||
...startInput,
|
||||
initialMessages: forkMessages as Message[],
|
||||
});
|
||||
newSessionId = started.sessionId;
|
||||
}
|
||||
try {
|
||||
const read = await manager.readMessages(newSessionId);
|
||||
if (forkBeforeRunCount !== undefined || read.length > 0) {
|
||||
forkMessages = read;
|
||||
}
|
||||
} catch {}
|
||||
discardAllTrackedAttachments(
|
||||
sourceSessionId,
|
||||
ctx.liveSessions.get(sourceSessionId),
|
||||
@@ -1016,23 +1205,18 @@ async function handleFork(
|
||||
ctx.liveSessions.set(
|
||||
newSessionId,
|
||||
createLiveSession(forkConfig, {
|
||||
messages: sourceMessages,
|
||||
prompt: derivePromptFromMessages(sourceMessages),
|
||||
messages: forkMessages,
|
||||
prompt: derivePromptFromMessages(forkMessages),
|
||||
title: readSessionMetadataTitle(sourceSessionId),
|
||||
status: "idle",
|
||||
}),
|
||||
);
|
||||
sendPromptsInQueueSnapshot(ctx, sourceSessionId);
|
||||
sendPromptsInQueueSnapshot(ctx, newSessionId);
|
||||
let messages: unknown[] = sourceMessages;
|
||||
try {
|
||||
const read = await manager.readMessages(newSessionId);
|
||||
if (read?.length > 0) messages = read;
|
||||
} catch {}
|
||||
return {
|
||||
sessionId: newSessionId,
|
||||
forkedFromSessionId: sourceSessionId,
|
||||
messages,
|
||||
messages: forkMessages,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1071,63 +1255,64 @@ async function handleRestoreCheckpoint(
|
||||
runCount < 1
|
||||
)
|
||||
throw new Error("checkpointRunCount must be a positive integer");
|
||||
if (!request.config)
|
||||
throw new Error("config is required to restore a checkpoint");
|
||||
const config = request.config;
|
||||
if (!config) throw new Error("config is required to restore a checkpoint");
|
||||
const cwd =
|
||||
(typeof request.config.cwd === "string" && request.config.cwd.trim()) ||
|
||||
(typeof request.config.workspaceRoot === "string" &&
|
||||
request.config.workspaceRoot.trim()) ||
|
||||
(typeof config.cwd === "string" && config.cwd.trim()) ||
|
||||
(typeof config.workspaceRoot === "string" && config.workspaceRoot.trim()) ||
|
||||
"";
|
||||
if (!cwd) throw new Error("config.cwd or config.workspaceRoot is required");
|
||||
const manager = getSessionManager(ctx);
|
||||
const restored = await manager.restore({
|
||||
sessionId: sourceSessionId,
|
||||
checkpointRunCount: runCount,
|
||||
cwd,
|
||||
restore: { messages: true, workspace: true },
|
||||
start: {
|
||||
...splitCoreSessionConfig(
|
||||
buildCoreSessionConfig({
|
||||
...request.config,
|
||||
systemPrompt: await resolveSystemPrompt(request.config),
|
||||
}) as unknown as ClineCoreStartConfig,
|
||||
),
|
||||
source: SessionSource.DESKTOP,
|
||||
interactive: true,
|
||||
toolPolicies: resolveToolPolicies(request.config),
|
||||
},
|
||||
return withWorkspaceRestoreLock(ctx, cwd, async () => {
|
||||
const restored = await manager.restore({
|
||||
sessionId: sourceSessionId,
|
||||
checkpointRunCount: runCount,
|
||||
cwd,
|
||||
restore: { messages: true, workspace: true },
|
||||
start: {
|
||||
...splitCoreSessionConfig(
|
||||
buildCoreSessionConfig({
|
||||
...config,
|
||||
systemPrompt: await resolveSystemPrompt(config),
|
||||
}) as unknown as ClineCoreStartConfig,
|
||||
),
|
||||
source: SessionSource.DESKTOP,
|
||||
interactive: true,
|
||||
toolPolicies: resolveToolPolicies(config),
|
||||
},
|
||||
});
|
||||
const sessionId = restored.sessionId;
|
||||
const restoredMessages = restored.messages;
|
||||
if (!sessionId || !restoredMessages) {
|
||||
throw new Error("Checkpoint restore did not return a new session");
|
||||
}
|
||||
discardAllTrackedAttachments(
|
||||
sourceSessionId,
|
||||
ctx.liveSessions.get(sourceSessionId),
|
||||
);
|
||||
ctx.liveSessions.delete(sourceSessionId);
|
||||
ctx.liveSessions.set(
|
||||
sessionId,
|
||||
createLiveSession(config, {
|
||||
messages: restoredMessages,
|
||||
prompt: derivePromptFromMessages(restoredMessages),
|
||||
title: readSessionMetadataTitle(sourceSessionId),
|
||||
status: "idle",
|
||||
}),
|
||||
);
|
||||
sendPromptsInQueueSnapshot(ctx, sourceSessionId);
|
||||
sendPromptsInQueueSnapshot(ctx, sessionId);
|
||||
let messages: unknown[] = restoredMessages;
|
||||
try {
|
||||
const read = await manager.readMessages(sessionId);
|
||||
if (read?.length > 0) messages = read;
|
||||
} catch {}
|
||||
return {
|
||||
sessionId,
|
||||
messages,
|
||||
restoredCheckpoint: restored.checkpoint,
|
||||
};
|
||||
});
|
||||
const sessionId = restored.sessionId;
|
||||
const restoredMessages = restored.messages;
|
||||
if (!sessionId || !restoredMessages) {
|
||||
throw new Error("Checkpoint restore did not return a new session");
|
||||
}
|
||||
discardAllTrackedAttachments(
|
||||
sourceSessionId,
|
||||
ctx.liveSessions.get(sourceSessionId),
|
||||
);
|
||||
ctx.liveSessions.delete(sourceSessionId);
|
||||
ctx.liveSessions.set(
|
||||
sessionId,
|
||||
createLiveSession(request.config, {
|
||||
messages: restoredMessages,
|
||||
prompt: derivePromptFromMessages(restoredMessages),
|
||||
title: readSessionMetadataTitle(sourceSessionId),
|
||||
status: "idle",
|
||||
}),
|
||||
);
|
||||
sendPromptsInQueueSnapshot(ctx, sourceSessionId);
|
||||
sendPromptsInQueueSnapshot(ctx, sessionId);
|
||||
let messages: unknown[] = restoredMessages;
|
||||
try {
|
||||
const read = await manager.readMessages(sessionId);
|
||||
if (read?.length > 0) messages = read;
|
||||
} catch {}
|
||||
return {
|
||||
sessionId,
|
||||
messages,
|
||||
restoredCheckpoint: restored.checkpoint,
|
||||
};
|
||||
}
|
||||
|
||||
async function handlePendingPrompts(
|
||||
|
||||
@@ -1282,6 +1282,44 @@ export async function handleCommand(
|
||||
if (liveSession) liveSession.title = title;
|
||||
return true;
|
||||
}
|
||||
if (command === "update_chat_session_metadata") {
|
||||
const sessionId = String(args?.sessionId ?? args?.session_id ?? "").trim();
|
||||
if (!sessionId) throw new Error("session id is required");
|
||||
const patch = args?.metadata;
|
||||
if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
|
||||
throw new Error("metadata patch is required");
|
||||
}
|
||||
// updateSession replaces metadata wholesale in both the session row and
|
||||
// the manifest, so merge over what each already holds. A null value
|
||||
// removes the key, which is how callers clear a flag.
|
||||
const store = new SqliteSessionStore();
|
||||
const asRecord = (value: unknown): JsonRecord =>
|
||||
value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as JsonRecord)
|
||||
: {};
|
||||
const existing = store.get(sessionId);
|
||||
const merged: JsonRecord = {
|
||||
...asRecord(readSessionManifest(sessionId)?.metadata),
|
||||
...asRecord(existing?.metadata),
|
||||
};
|
||||
for (const [key, value] of Object.entries(patch as JsonRecord)) {
|
||||
if (value === null) delete merged[key];
|
||||
else merged[key] = value;
|
||||
}
|
||||
const backend = await resolveSessionBackend({ backendMode: "local" });
|
||||
const result = await backend.updateSession({ sessionId, metadata: merged });
|
||||
if (!result.updated) throw new Error(`Session ${sessionId} not found`);
|
||||
// Annotating a session is not session activity. updateSession stamps
|
||||
// updated_at, which clients sort and label rows by, so a favorite would
|
||||
// otherwise make an old session look like it just ran.
|
||||
if (existing?.updatedAt) {
|
||||
store.run("UPDATE sessions SET updated_at = ? WHERE session_id = ?", [
|
||||
existing.updatedAt,
|
||||
sessionId,
|
||||
]);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
if (command === "delete_chat_session" || command === "delete_cli_session") {
|
||||
const sessionId = String(args?.sessionId ?? args?.session_id ?? "").trim();
|
||||
if (!sessionId) throw new Error("session id is required");
|
||||
|
||||
@@ -456,6 +456,7 @@ export function createSidecarContext(
|
||||
): SidecarContext {
|
||||
return {
|
||||
liveSessions: new Map(),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
streamIndices: new Map(),
|
||||
wsClients: new Set(),
|
||||
pendingApprovals: new Map(),
|
||||
|
||||
@@ -2,6 +2,64 @@ import { describe, expect, it } from "vitest";
|
||||
import { readSessionMessages } from "./messages";
|
||||
|
||||
describe("readSessionMessages", () => {
|
||||
it("preserves each stored message timestamp across projected blocks", async () => {
|
||||
const sessionId = `timestamp-projection-${Date.now()}`;
|
||||
const userTimestamp = 1_781_041_621_282;
|
||||
const assistantTimestamp = 1_781_041_621_946;
|
||||
const liveSessions = new Map([
|
||||
[
|
||||
sessionId,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
id: "user-message",
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Question" }],
|
||||
ts: userTimestamp,
|
||||
},
|
||||
{
|
||||
id: "assistant-message",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "Consider it" },
|
||||
{ type: "text", text: "Answer" },
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool-use",
|
||||
name: "read_files",
|
||||
input: { paths: ["a.ts"] },
|
||||
},
|
||||
],
|
||||
ts: assistantTimestamp,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
await expect(
|
||||
readSessionMessages(
|
||||
{ liveSessions } as Parameters<typeof readSessionMessages>[0],
|
||||
sessionId,
|
||||
),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: "user-message_text_0",
|
||||
createdAt: userTimestamp,
|
||||
meta: { runCount: 1 },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "assistant-message_text_0",
|
||||
createdAt: assistantTimestamp,
|
||||
reasoning: "Consider it",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "assistant-message_tool_use_2",
|
||||
createdAt: assistantTimestamp,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("projects image content blocks without replacing them with placeholder text", async () => {
|
||||
const sessionId = `image-projection-${Date.now()}`;
|
||||
const liveSessions = new Map([
|
||||
@@ -45,4 +103,154 @@ describe("readSessionMessages", () => {
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves absolute user run counts when older messages are omitted", async () => {
|
||||
const sessionId = `run-count-projection-${Date.now()}`;
|
||||
const liveSessions = new Map([
|
||||
[
|
||||
sessionId,
|
||||
{
|
||||
messages: [
|
||||
{ role: "user", content: "First prompt" },
|
||||
{ role: "assistant", content: "First response" },
|
||||
{ role: "user", content: "Second prompt" },
|
||||
{ role: "assistant", content: "Second response" },
|
||||
],
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
await expect(
|
||||
readSessionMessages(
|
||||
{ liveSessions } as Parameters<typeof readSessionMessages>[0],
|
||||
sessionId,
|
||||
2,
|
||||
),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
role: "user",
|
||||
content: "Second prompt",
|
||||
meta: { runCount: 2 },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
role: "assistant",
|
||||
content: "Second response",
|
||||
}),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
readSessionMessages(
|
||||
{ liveSessions } as Parameters<typeof readSessionMessages>[0],
|
||||
sessionId,
|
||||
1,
|
||||
),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
role: "assistant",
|
||||
content: "Second response",
|
||||
meta: { runCount: 2 },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks later display segments from one user message as the same run", async () => {
|
||||
const sessionId = `segmented-user-run-${Date.now()}`;
|
||||
const liveSessions = new Map([
|
||||
[
|
||||
sessionId,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "First segment" },
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "orphan-tool",
|
||||
content: "Tool output",
|
||||
},
|
||||
{ type: "text", text: "Second segment" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const projected = (await readSessionMessages(
|
||||
{ liveSessions } as Parameters<typeof readSessionMessages>[0],
|
||||
sessionId,
|
||||
)) as Array<Record<string, unknown>>;
|
||||
|
||||
expect(projected).toEqual([
|
||||
expect.objectContaining({
|
||||
role: "user",
|
||||
content: "First segment",
|
||||
meta: { runCount: 1 },
|
||||
}),
|
||||
expect.objectContaining({ role: "tool" }),
|
||||
expect.objectContaining({
|
||||
role: "user",
|
||||
content: "Second segment",
|
||||
meta: { userRunSpan: 0 },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves absolute run counts across system-displayed compaction messages", async () => {
|
||||
const sessionId = `compaction-run-count-${Date.now()}`;
|
||||
const liveSessions = new Map([
|
||||
[
|
||||
sessionId,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Compacted context",
|
||||
metadata: {
|
||||
kind: "compaction",
|
||||
displayRole: "system",
|
||||
userRunSpan: 3,
|
||||
},
|
||||
},
|
||||
{ role: "user", content: "First visible prompt" },
|
||||
{ role: "assistant", content: "First response" },
|
||||
{ role: "user", content: "Second visible prompt" },
|
||||
],
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const projected = (await readSessionMessages(
|
||||
{ liveSessions } as Parameters<typeof readSessionMessages>[0],
|
||||
sessionId,
|
||||
)) as Array<Record<string, unknown>>;
|
||||
|
||||
expect(projected[0]).toMatchObject({
|
||||
role: "system",
|
||||
content: "Compacted context",
|
||||
meta: { runCount: 3, userRunSpan: 3 },
|
||||
});
|
||||
expect(projected[1]).toMatchObject({
|
||||
role: "user",
|
||||
content: "First visible prompt",
|
||||
meta: { runCount: 4 },
|
||||
});
|
||||
expect(projected[3]).toMatchObject({
|
||||
role: "user",
|
||||
content: "Second visible prompt",
|
||||
meta: { runCount: 5 },
|
||||
});
|
||||
|
||||
const truncated = (await readSessionMessages(
|
||||
{ liveSessions } as Parameters<typeof readSessionMessages>[0],
|
||||
sessionId,
|
||||
2,
|
||||
)) as Array<Record<string, unknown>>;
|
||||
expect(truncated[1]).toMatchObject({
|
||||
role: "user",
|
||||
content: "Second visible prompt",
|
||||
meta: { runCount: 5 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { getUserRunSpan, resolveMessageDisplayRole } from "@cline/core";
|
||||
import { validateImageMedia } from "@cline/shared";
|
||||
import {
|
||||
readSessionManifest,
|
||||
@@ -29,30 +30,23 @@ type ChatTurnResult = {
|
||||
|
||||
const nowMs = () => Date.now();
|
||||
|
||||
function resolveMessageCreatedAt(
|
||||
message: JsonRecord,
|
||||
fallbackCreatedAt: number,
|
||||
): number {
|
||||
return (
|
||||
parseU64Value(message.ts) ??
|
||||
parseU64Value(message.createdAt) ??
|
||||
fallbackCreatedAt
|
||||
);
|
||||
}
|
||||
|
||||
function readMessageMetadata(message: JsonRecord): JsonRecord | undefined {
|
||||
return message.metadata && typeof message.metadata === "object"
|
||||
? (message.metadata as JsonRecord)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function resolveDisplayRole(
|
||||
role: string,
|
||||
metadata: JsonRecord | undefined,
|
||||
): string {
|
||||
const displayRole =
|
||||
typeof metadata?.displayRole === "string"
|
||||
? metadata.displayRole.trim().toLowerCase()
|
||||
: "";
|
||||
if (
|
||||
displayRole === "system" ||
|
||||
displayRole === "status" ||
|
||||
displayRole === "error"
|
||||
) {
|
||||
return displayRole;
|
||||
}
|
||||
return role;
|
||||
}
|
||||
|
||||
function extractStoredMessageMeta(message: JsonRecord): JsonRecord | undefined {
|
||||
const metadata = readMessageMetadata(message);
|
||||
if (!metadata) {
|
||||
@@ -66,7 +60,19 @@ function extractStoredMessageMeta(message: JsonRecord): JsonRecord | undefined {
|
||||
typeof metadata.displayRole === "string" ? metadata.displayRole : undefined;
|
||||
const reason =
|
||||
typeof metadata.reason === "string" ? metadata.reason : undefined;
|
||||
if (!hookEventName && !messageKind && !displayRole && !reason) {
|
||||
const userRunSpan =
|
||||
typeof metadata.userRunSpan === "number" &&
|
||||
Number.isInteger(metadata.userRunSpan) &&
|
||||
metadata.userRunSpan >= 0
|
||||
? metadata.userRunSpan
|
||||
: undefined;
|
||||
if (
|
||||
!hookEventName &&
|
||||
!messageKind &&
|
||||
!displayRole &&
|
||||
!reason &&
|
||||
userRunSpan === undefined
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
@@ -74,6 +80,7 @@ function extractStoredMessageMeta(message: JsonRecord): JsonRecord | undefined {
|
||||
messageKind,
|
||||
displayRole,
|
||||
reason,
|
||||
userRunSpan,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -333,8 +340,20 @@ export async function readSessionMessages(
|
||||
const out: JsonRecord[] = [];
|
||||
const checkpointsByRunCount = readCheckpointEntriesByRunCount(sessionId);
|
||||
const pendingToolMessages = new Map<string, [number, string, unknown]>();
|
||||
let nextCreatedAt = baseTs;
|
||||
let userRunCount = 0;
|
||||
for (let idx = 0; idx < start; idx += 1) {
|
||||
const rawMessage = messages[idx];
|
||||
if (!rawMessage || typeof rawMessage !== "object") {
|
||||
continue;
|
||||
}
|
||||
const message = rawMessage as JsonRecord;
|
||||
const metadata = readMessageMetadata(message);
|
||||
userRunCount += getUserRunSpan({
|
||||
role: normalizeRole(message.role),
|
||||
content: message.content,
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
|
||||
for (let idx = start; idx < messages.length; idx += 1) {
|
||||
const rawMessage = messages[idx];
|
||||
@@ -342,20 +361,34 @@ export async function readSessionMessages(
|
||||
continue;
|
||||
}
|
||||
const message = rawMessage as JsonRecord;
|
||||
const createdAt = resolveMessageCreatedAt(message, baseTs + idx);
|
||||
let textMeta = extractMessageUsageMeta(message);
|
||||
const storedMeta = extractStoredMessageMeta(message);
|
||||
if (storedMeta) {
|
||||
textMeta = { ...(textMeta ?? {}), ...storedMeta };
|
||||
}
|
||||
const role = resolveDisplayRole(
|
||||
normalizeRole(message.role),
|
||||
readMessageMetadata(message),
|
||||
);
|
||||
const metadata = readMessageMetadata(message);
|
||||
const isRecoveryNotice =
|
||||
typeof metadata?.kind === "string" && metadata.kind === "recovery_notice";
|
||||
if (role === "user" && !isRecoveryNotice) {
|
||||
userRunCount += 1;
|
||||
const userRunSpan = getUserRunSpan({
|
||||
role: normalizeRole(message.role),
|
||||
content: message.content,
|
||||
metadata,
|
||||
});
|
||||
userRunCount += userRunSpan;
|
||||
const role = resolveMessageDisplayRole({
|
||||
role: normalizeRole(message.role),
|
||||
metadata,
|
||||
});
|
||||
if (role === "user" && userRunSpan !== 1) {
|
||||
textMeta = {
|
||||
...(textMeta ?? {}),
|
||||
userRunSpan,
|
||||
};
|
||||
}
|
||||
if (userRunSpan === 1 && role === "user") {
|
||||
textMeta = {
|
||||
...(textMeta ?? {}),
|
||||
runCount: userRunCount,
|
||||
};
|
||||
const checkpoint = checkpointsByRunCount.get(userRunCount);
|
||||
if (checkpoint) {
|
||||
textMeta = {
|
||||
@@ -363,6 +396,14 @@ export async function readSessionMessages(
|
||||
checkpoint,
|
||||
};
|
||||
}
|
||||
} else if (userRunCount > 0) {
|
||||
// This also anchors truncated histories whose visible window starts
|
||||
// with an assistant/tool/system message. The webview can then number
|
||||
// a newly appended optimistic user message from the absolute count.
|
||||
textMeta = {
|
||||
...(textMeta ?? {}),
|
||||
runCount: userRunCount,
|
||||
};
|
||||
}
|
||||
const messageIdBase =
|
||||
(typeof message.id === "string" && message.id.trim()) ||
|
||||
@@ -381,7 +422,7 @@ export async function readSessionMessages(
|
||||
sessionId,
|
||||
role,
|
||||
content,
|
||||
createdAt: nextCreatedAt++,
|
||||
createdAt,
|
||||
meta: textMeta,
|
||||
});
|
||||
continue;
|
||||
@@ -407,8 +448,12 @@ export async function readSessionMessages(
|
||||
sessionId,
|
||||
role,
|
||||
content: joined,
|
||||
createdAt: nextCreatedAt++,
|
||||
meta: textMeta,
|
||||
createdAt,
|
||||
// A persisted user message can project into more than one text
|
||||
// segment around tool blocks. Only its first segment represents
|
||||
// the run; later segments must not acquire a fallback ordinal in
|
||||
// the webview.
|
||||
meta: textMeta ?? (role === "user" ? { userRunSpan: 0 } : undefined),
|
||||
});
|
||||
textSegmentIndex += 1;
|
||||
textMeta = undefined;
|
||||
@@ -437,7 +482,7 @@ export async function readSessionMessages(
|
||||
sessionId,
|
||||
role: "tool",
|
||||
content: buildToolPayloadJson(toolName, input, null, false),
|
||||
createdAt: nextCreatedAt++,
|
||||
createdAt,
|
||||
meta: {
|
||||
toolName,
|
||||
hookEventName: "history_tool_use",
|
||||
@@ -477,7 +522,7 @@ export async function readSessionMessages(
|
||||
sessionId,
|
||||
role: "tool",
|
||||
content: buildToolPayloadJson("tool_result", null, result, isError),
|
||||
createdAt: nextCreatedAt++,
|
||||
createdAt,
|
||||
meta: {
|
||||
toolName: "tool_result",
|
||||
hookEventName: "history_tool_result",
|
||||
@@ -528,7 +573,7 @@ export async function readSessionMessages(
|
||||
role,
|
||||
content: "",
|
||||
images,
|
||||
createdAt: nextCreatedAt++,
|
||||
createdAt,
|
||||
meta: textMeta,
|
||||
});
|
||||
textMeta = undefined;
|
||||
@@ -554,7 +599,7 @@ export async function readSessionMessages(
|
||||
content: "",
|
||||
reasoning: reasoning || undefined,
|
||||
reasoningRedacted: reasoningRedacted || undefined,
|
||||
createdAt: nextCreatedAt++,
|
||||
createdAt,
|
||||
meta: textMeta,
|
||||
});
|
||||
textMeta = undefined;
|
||||
|
||||
@@ -32,6 +32,7 @@ export type ChatSessionCommandRequest = {
|
||||
prompt?: string;
|
||||
promptId?: string;
|
||||
checkpointRunCount?: number;
|
||||
forkBeforeRunCount?: number;
|
||||
delivery?: "queue" | "steer";
|
||||
config?: JsonRecord;
|
||||
attachments?: ChatTurnAttachments;
|
||||
@@ -105,6 +106,7 @@ export type SidecarWebSocketClient = {
|
||||
|
||||
export type SidecarContext = {
|
||||
liveSessions: Map<string, LiveSession>;
|
||||
restoringWorkspacePaths: Set<string>;
|
||||
streamIndices: Map<string, number>;
|
||||
wsClients: Set<SidecarWebSocketClient>;
|
||||
pendingApprovals: Map<string, PendingToolApproval>;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Cline Code",
|
||||
"version": "0.0.6",
|
||||
"version": "0.0.7",
|
||||
"identifier": "bot.cline.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web",
|
||||
|
||||
@@ -21,59 +21,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Hero heading cycling verb (components/views/chat/welcome-chat.tsx).
|
||||
* Opacity + transform only: animating `filter: blur` on text forces
|
||||
* main-thread repaints for every character each cycle.
|
||||
*/
|
||||
@keyframes hero-word-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(0.42em);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.hero-word-char {
|
||||
display: inline-block;
|
||||
white-space: pre;
|
||||
/* Solid fallback so the word is never invisible if text clipping is unsupported. */
|
||||
color: var(--brand-violet);
|
||||
animation: hero-word-in 0.5s cubic-bezier(0.2, 0.65, 0.3, 1) both;
|
||||
}
|
||||
|
||||
/*
|
||||
* Gradient fill per character. The clip lives on each animated span (not a
|
||||
* shared parent) because WebKit — used by the Tauri webview on macOS — drops
|
||||
* the parent's background when a child paints on its own transform/filter
|
||||
* layer, which would leave the animating letters blank. The -webkit- prefixes
|
||||
* are required by WebKit; @supports keeps the solid fallback above otherwise.
|
||||
*/
|
||||
@supports ((-webkit-background-clip: text) or (background-clip: text)) {
|
||||
.hero-word-char {
|
||||
background-image: linear-gradient(
|
||||
135deg,
|
||||
var(--brand-periwinkle),
|
||||
var(--brand-violet) 55%,
|
||||
var(--brand-magenta)
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hero-word-char {
|
||||
/* biome-ignore lint/complexity/noImportantStyles: reduced motion must override inline animation delay */
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Chat Markdown (components/ui/markdown.tsx). Streamdown ships its defaults
|
||||
* as Tailwind utilities, which land in the layered @source output; these
|
||||
@@ -132,6 +79,60 @@
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
/* Running reasoning/tool titles use their inherited text color as the bright
|
||||
* band, so the same shimmer fits muted thinking text and primary tool text. */
|
||||
.cline-chat-streaming-title {
|
||||
display: inline-block;
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
color-mix(in oklab, currentcolor 55%, transparent) 0%,
|
||||
currentcolor 50%,
|
||||
color-mix(in oklab, currentcolor 55%, transparent) 100%
|
||||
);
|
||||
background-position: 200% 0;
|
||||
background-size: 200% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: cline-chat-title-shimmer 2.25s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes cline-chat-title-shimmer {
|
||||
to {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cline-chat-streaming-title {
|
||||
background: none;
|
||||
-webkit-text-fill-color: currentcolor;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tool panels scroll on both axes, but a horizontal thumb inside a short
|
||||
* disclosure eats a whole detail row. Keep the axis scrollable (wheel, trackpad,
|
||||
* keyboard, drag-select) and hide only its bar; the vertical one stays visible.
|
||||
* `:horizontal` is the one selector that separates the two axes — the standard
|
||||
* `scrollbar-width: none` would take out both. */
|
||||
.cline-chat-scroll-x-bare::-webkit-scrollbar:horizontal {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The shared reveal rule is `.cline-chat-message:hover`, so hovering anywhere in
|
||||
* the message pops the action row — including the reasoning disclosure nested in
|
||||
* the message content. The actions act on the message body, so suppress the
|
||||
* reveal while the pointer is inside the thinking panel. `[data-visible]` still
|
||||
* wins, keeping the pending/error states pinned open. */
|
||||
.cline-chat-message:has(
|
||||
> .cline-chat-message-content .cline-chat-reasoning:hover
|
||||
)
|
||||
> .cline-chat-message-actions:not([data-visible="true"]) {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* Code blocks: Streamdown renders a double box — an outer sidebar-tinted card
|
||||
* holding a language header row plus an inner bordered body. Collapse it to a
|
||||
@@ -222,170 +223,6 @@
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
/*
|
||||
* Aurora background (components/ui/aurora-bg.tsx).
|
||||
*
|
||||
* Performance contract: no `filter: blur` and no animated properties beyond
|
||||
* `opacity` + `transform`, so the layers rasterize once and every animation
|
||||
* frame is compositor-only. Blurred full-viewport layers used to be
|
||||
* re-rendered on every frame of their drift animations, which alone dropped
|
||||
* the whole app below 10fps on modest hardware.
|
||||
*/
|
||||
@keyframes aurora-drift {
|
||||
0% {
|
||||
opacity: 0.55;
|
||||
transform: translate3d(-8%, 5%, 0) scale(0.94);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.92;
|
||||
transform: translate3d(13%, -10%, 0) scale(1.11);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.62;
|
||||
transform: translate3d(-5%, -2%, 0) scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes aurora-drift-reverse {
|
||||
0% {
|
||||
opacity: 0.62;
|
||||
transform: translate3d(10%, -6%, 0) scale(1.08);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.9;
|
||||
transform: translate3d(-12%, -12%, 0) scale(0.96);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.58;
|
||||
transform: translate3d(6%, 2%, 0) 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) scaleX(0.84);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.72;
|
||||
transform: translate3d(17%, -8%, 0) scaleX(1.08);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.38;
|
||||
transform: translate3d(28%, 4%, 0) scaleX(0.94);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes aurora-twinkle {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.15;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.78;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Static vertical fade replacing the old `filter: blur(46-64px)` on the big
|
||||
* gradient bands: the mask rasterizes once with the layer, so soft edges no
|
||||
* longer cost anything per animation frame.
|
||||
*/
|
||||
.aurora-soft-band {
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
black 32%,
|
||||
black 68%,
|
||||
transparent
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
black 32%,
|
||||
black 68%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.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;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.aurora-motion-reverse {
|
||||
animation-name: aurora-drift-reverse;
|
||||
}
|
||||
|
||||
.aurora-star {
|
||||
animation-name: aurora-twinkle;
|
||||
animation-timing-function: ease-in-out;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Accent palettes (Settings -> General -> Accent color). The default
|
||||
* "violet" accent is the @cline/ui brand tokens untouched; each block below
|
||||
|
||||
@@ -172,9 +172,12 @@ export default function Home() {
|
||||
handleNewThread();
|
||||
}, [handleNewThread]);
|
||||
|
||||
const handleOpenSession = useCallback((session: SessionHistoryItem) => {
|
||||
dispatchApp({ type: "open-session", session });
|
||||
}, []);
|
||||
const handleOpenSession = useCallback(
|
||||
(session: SessionHistoryItem, initialPromptDraft?: string) => {
|
||||
dispatchApp({ type: "open-session", session, initialPromptDraft });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDeleteSession = useCallback(
|
||||
(deletedSessionId: string, deletedThreadId?: string) => {
|
||||
@@ -252,6 +255,9 @@ export default function Home() {
|
||||
const handleThreadStarted = useCallback((threadId: string) => {
|
||||
dispatchApp({ type: "thread-started", threadId });
|
||||
}, []);
|
||||
const handleInitialPromptDraftConsumed = useCallback((threadId: string) => {
|
||||
dispatchApp({ type: "consume-initial-prompt-draft", threadId });
|
||||
}, []);
|
||||
const sessionHistory = useSessionHistory({
|
||||
activeSessionId: activeHistorySessionId,
|
||||
onDeleteSession: handleDeleteSession,
|
||||
@@ -355,7 +361,11 @@ export default function Home() {
|
||||
<ChatThreadPane
|
||||
key={activeThread.id}
|
||||
historySession={activeThread.historySession}
|
||||
initialPromptDraft={activeThread.initialPromptDraft}
|
||||
knownWorkspacePaths={historyWorkspacePaths}
|
||||
onInitialPromptDraftConsumed={
|
||||
handleInitialPromptDraftConsumed
|
||||
}
|
||||
onUpdateSessionMetadata={handleUpdateSessionMetadata}
|
||||
threadId={activeThread.id}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
@@ -391,7 +401,9 @@ export default function Home() {
|
||||
function ChatThreadPane({
|
||||
threadId,
|
||||
historySession,
|
||||
initialPromptDraft,
|
||||
knownWorkspacePaths,
|
||||
onInitialPromptDraftConsumed,
|
||||
onUpdateSessionMetadata,
|
||||
onDeleteSession,
|
||||
onNewThread,
|
||||
@@ -402,14 +414,19 @@ function ChatThreadPane({
|
||||
}: {
|
||||
threadId: string;
|
||||
historySession?: SessionHistoryItem;
|
||||
initialPromptDraft?: string;
|
||||
knownWorkspacePaths: string[];
|
||||
onInitialPromptDraftConsumed?: (threadId: string) => void;
|
||||
onUpdateSessionMetadata?: (
|
||||
sessionId: string,
|
||||
metadata: SessionMetadata,
|
||||
) => void;
|
||||
onDeleteSession?: (sessionId: string, threadId?: string) => void;
|
||||
onNewThread?: () => void;
|
||||
onOpenSession?: (session: SessionHistoryItem) => void;
|
||||
onOpenSession?: (
|
||||
session: SessionHistoryItem,
|
||||
initialPromptDraft?: string,
|
||||
) => void;
|
||||
onOpenSessionById?: (sessionId: string) => void | Promise<void>;
|
||||
parentSession?: { sessionId: string; title?: string };
|
||||
onThreadStarted?: (threadId: string) => void;
|
||||
@@ -820,11 +837,21 @@ function ChatThreadPane({
|
||||
return;
|
||||
}
|
||||
hydratedSessionRef.current = historySession.sessionId;
|
||||
setPromptInput("");
|
||||
setPromptInput(initialPromptDraft ?? "");
|
||||
if (initialPromptDraft !== undefined) {
|
||||
onInitialPromptDraftConsumed?.(threadId);
|
||||
}
|
||||
setPendingAttachments([]);
|
||||
setManualTitle(getSessionMetadataTitle(historySession.metadata));
|
||||
void hydrateSession(historySession);
|
||||
}, [historySession, hydrateSession, setPromptInput]);
|
||||
}, [
|
||||
historySession,
|
||||
hydrateSession,
|
||||
initialPromptDraft,
|
||||
onInitialPromptDraftConsumed,
|
||||
setPromptInput,
|
||||
threadId,
|
||||
]);
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (prompt: string) => {
|
||||
@@ -889,16 +916,18 @@ function ChatThreadPane({
|
||||
[answerAskQuestion],
|
||||
);
|
||||
const handleRestoreCheckpoint = useCallback(
|
||||
(runCount: number) => {
|
||||
void restoreCheckpoint(runCount);
|
||||
},
|
||||
(runCount: number) => restoreCheckpoint(runCount),
|
||||
[restoreCheckpoint],
|
||||
);
|
||||
|
||||
const handleForkSession = useCallback(async () => {
|
||||
const result = await forkSession();
|
||||
// Open the forked session as a new thread in the sidebar.
|
||||
if (onOpenSession) {
|
||||
const openForkedSession = useCallback(
|
||||
(
|
||||
result: Awaited<ReturnType<typeof forkSession>>,
|
||||
editedPrompt?: string,
|
||||
) => {
|
||||
if (!onOpenSession) {
|
||||
return;
|
||||
}
|
||||
const workspaceRoot = config.workspaceRoot;
|
||||
const cwd = config.cwd ?? workspaceRoot;
|
||||
const forkedHistorySession: SessionHistoryItem = {
|
||||
@@ -916,9 +945,23 @@ function ChatThreadPane({
|
||||
},
|
||||
},
|
||||
};
|
||||
onOpenSession(forkedHistorySession);
|
||||
}
|
||||
}, [config, forkSession, onOpenSession]);
|
||||
onOpenSession(forkedHistorySession, editedPrompt);
|
||||
},
|
||||
[config, onOpenSession],
|
||||
);
|
||||
|
||||
const handleForkSession = useCallback(async () => {
|
||||
const result = await forkSession();
|
||||
openForkedSession(result);
|
||||
}, [forkSession, openForkedSession]);
|
||||
|
||||
const handleEditMessage = useCallback(
|
||||
async (_messageId: string, content: string, runCount: number) => {
|
||||
const result = await forkSession({ beforeRunCount: runCount });
|
||||
openForkedSession(result, content);
|
||||
},
|
||||
[forkSession, openForkedSession],
|
||||
);
|
||||
|
||||
const visibleHistorySession =
|
||||
historySession?.sessionId &&
|
||||
@@ -1368,6 +1411,7 @@ function ChatThreadPane({
|
||||
chatTransportState={chatTransportState}
|
||||
error={displayedError}
|
||||
messages={displayedMessages}
|
||||
onEditMessage={handleEditMessage}
|
||||
onRestoreCheckpoint={handleRestoreCheckpoint}
|
||||
onForkSession={handleForkSession}
|
||||
pendingToolApprovals={pendingToolApprovals}
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
Loader2,
|
||||
PanelLeftOpen,
|
||||
Pencil,
|
||||
Pin,
|
||||
Plug,
|
||||
Plus,
|
||||
Radio,
|
||||
@@ -25,6 +24,7 @@ import {
|
||||
Server,
|
||||
Settings,
|
||||
SlidersHorizontal,
|
||||
Star,
|
||||
Trash2,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
@@ -94,7 +94,7 @@ import { cn } from "@/lib/utils";
|
||||
type Thread = SessionThread;
|
||||
type AppView = "chat" | "sessions" | "settings";
|
||||
|
||||
const filterOptions = ["All", "Running", "Schedules", "Pinned"] as const;
|
||||
const filterOptions = ["All", "Running", "Schedules", "Favorites"] as const;
|
||||
type FilterOption = (typeof filterOptions)[number];
|
||||
type SidebarSortMode = "time" | "project";
|
||||
type DesktopProcessContext = {
|
||||
@@ -244,6 +244,7 @@ export function AgentSidebar({
|
||||
openThread: openHistoryThread,
|
||||
pendingAction,
|
||||
renameThread,
|
||||
setThreadPinned,
|
||||
threads,
|
||||
unreadSessionIds,
|
||||
} = sessionHistory;
|
||||
@@ -329,7 +330,7 @@ export function AgentSidebar({
|
||||
return filtered.filter((t) => t.status === "running");
|
||||
case "Schedules":
|
||||
return filtered.filter((t) => t.source === SCHEDULED_SESSION_SOURCE);
|
||||
case "Pinned":
|
||||
case "Favorites":
|
||||
return filtered.filter((t) => t.pinned);
|
||||
default:
|
||||
return filtered;
|
||||
@@ -404,6 +405,13 @@ export function AgentSidebar({
|
||||
[forkHistoryThread],
|
||||
);
|
||||
|
||||
const toggleFavorite = useCallback(
|
||||
async (thread: Thread) => {
|
||||
await setThreadPinned(thread.id, !thread.pinned);
|
||||
},
|
||||
[setThreadPinned],
|
||||
);
|
||||
|
||||
const requestDeleteThread = useCallback((thread: Thread) => {
|
||||
setDeleteConfirmThread(thread);
|
||||
}, []);
|
||||
@@ -533,6 +541,7 @@ export function AgentSidebar({
|
||||
onEditTitleChange={setEditingTitle}
|
||||
onFork={() => void forkThread(thread)}
|
||||
onRename={() => startRenameThread(thread)}
|
||||
onToggleFavorite={() => void toggleFavorite(thread)}
|
||||
pendingAction={
|
||||
pendingAction?.sessionId === thread.id ? pendingAction.action : null
|
||||
}
|
||||
@@ -759,7 +768,7 @@ export function AgentSidebar({
|
||||
.map(threadItem)}
|
||||
{project.threads.length > visibleCount ? (
|
||||
<Button
|
||||
className="pl-2"
|
||||
className="pl-2!"
|
||||
onClick={() =>
|
||||
showMoreForProject(project.id)
|
||||
}
|
||||
@@ -787,7 +796,11 @@ export function AgentSidebar({
|
||||
)}
|
||||
{sortMode === "time" && showTimeShowMore && (
|
||||
<Button
|
||||
className="pl-0"
|
||||
// `pl-0!`: the default button size adds
|
||||
// `has-[>svg]:px-3`, and that modifier beats a plain
|
||||
// `pl-0` on specificity, so the icon child was
|
||||
// re-indenting the row.
|
||||
className="pl-0!"
|
||||
disabled={isLoadingMore}
|
||||
onClick={() => {
|
||||
const nextCount =
|
||||
@@ -804,10 +817,10 @@ export function AgentSidebar({
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="ml-2 flex items-center gap-1">
|
||||
Show more
|
||||
<ChevronDown className="size-3" />
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
@@ -816,7 +829,7 @@ export function AgentSidebar({
|
||||
!searchQuery &&
|
||||
mayHaveMoreSessions && (
|
||||
<Button
|
||||
className="pl-0"
|
||||
className="pl-0!"
|
||||
disabled={isLoadingMore}
|
||||
onClick={() => void loadOlderSessions()}
|
||||
type="button"
|
||||
@@ -1003,6 +1016,7 @@ function ThreadItem({
|
||||
onCommitRename,
|
||||
onEditTitleChange,
|
||||
onRename,
|
||||
onToggleFavorite,
|
||||
onFork,
|
||||
onDelete,
|
||||
pendingAction,
|
||||
@@ -1017,6 +1031,7 @@ function ThreadItem({
|
||||
onCommitRename: () => void;
|
||||
onEditTitleChange: (title: string) => void;
|
||||
onRename: () => void;
|
||||
onToggleFavorite: () => void;
|
||||
onFork: () => void;
|
||||
onDelete: () => void;
|
||||
pendingAction: "rename" | "fork" | "delete" | null;
|
||||
@@ -1079,7 +1094,10 @@ function ThreadItem({
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
{thread.pinned ? (
|
||||
<Pin aria-label="Pinned" className="size-3" />
|
||||
<Star
|
||||
aria-label="Favorited"
|
||||
className="size-3 fill-current"
|
||||
/>
|
||||
) : statusDotClass ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
@@ -1119,9 +1137,11 @@ function ThreadItem({
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
<SessionContextMenuContent
|
||||
favorited={Boolean(thread.pinned)}
|
||||
onDelete={onDelete}
|
||||
onFork={onFork}
|
||||
onRename={onRename}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
pendingAction={pendingAction}
|
||||
/>
|
||||
</ContextMenu>
|
||||
@@ -1210,12 +1230,16 @@ function EditableSessionTitle({
|
||||
}
|
||||
|
||||
function SessionContextMenuContent({
|
||||
favorited,
|
||||
onRename,
|
||||
onToggleFavorite,
|
||||
onFork,
|
||||
onDelete,
|
||||
pendingAction,
|
||||
}: {
|
||||
favorited: boolean;
|
||||
onRename: () => void;
|
||||
onToggleFavorite: () => void;
|
||||
onFork: () => void;
|
||||
onDelete: () => void;
|
||||
pendingAction: "rename" | "fork" | "delete" | null;
|
||||
@@ -1223,6 +1247,10 @@ function SessionContextMenuContent({
|
||||
const pending = pendingAction !== null;
|
||||
return (
|
||||
<ContextMenuContent className="w-40">
|
||||
<ContextMenuItem disabled={pending} onSelect={onToggleFavorite}>
|
||||
<Star className={cn("size-4", favorited && "fill-current")} />
|
||||
{favorited ? "Unfavorite" : "Favorite"}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={pending} onSelect={onRename}>
|
||||
{pendingAction === "rename" ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface Star {
|
||||
left: string;
|
||||
top: string;
|
||||
size: number;
|
||||
delay: string;
|
||||
duration: string;
|
||||
opacity: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
// Big soft gradient blobs that slowly drift to fake an aurora. Softness is
|
||||
// baked into the gradient stops (no `filter: blur`) so the layers rasterize
|
||||
// once and every animation frame is compositor-only work.
|
||||
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), color-mix(in oklab, var(--brand-periwinkle) 30%, transparent) 42%, 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), color-mix(in oklab, var(--brand-violet) 27%, transparent) 42%, 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: soft gradient
|
||||
* blobs drifting on keyframe animations, plus twinkling star dots. No canvas,
|
||||
* no WebGL, no per-frame JS. Absolutely positioned to fill its nearest
|
||||
* positioned parent; pointer events pass through.
|
||||
*
|
||||
* Performance contract: no `filter: blur` anywhere (soft edges come from
|
||||
* gradient falloff + static masks) and animations only touch `opacity` and
|
||||
* `transform`, so the whole effect stays on the compositor. Blurring these
|
||||
* full-viewport layers used to force a main-thread re-raster every frame and
|
||||
* dragged the entire app below 10fps on modest hardware.
|
||||
*
|
||||
* Keyframes (`aurora-drift`, `aurora-twinkle`) live in app/globals.css.
|
||||
*/
|
||||
export function AuroraBackground({ starCount = 32 }: { starCount?: number }) {
|
||||
// The field is deterministic so server and browser markup always agree.
|
||||
const stars = useMemo<Star[]>(
|
||||
() =>
|
||||
Array.from({ length: starCount }, (_, index) => {
|
||||
// Squared skew biases stars toward the bottom, where the glow lives.
|
||||
const r = seededUnit(index, 1);
|
||||
const sizeRoll = seededUnit(index, 3);
|
||||
return {
|
||||
left: `${seededUnit(index, 2) * 100}%`,
|
||||
top: `${100 - (1 - r * r) * 45}%`,
|
||||
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
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="aurora-horizon aurora-soft-band absolute inset-x-[-8%] bottom-[-3%] h-[40%] opacity-60"
|
||||
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 aurora-soft-band absolute bottom-[3%] left-[-45%] h-[30%] w-[125%] opacity-50"
|
||||
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 aurora-soft-band absolute bottom-[-5%] right-[-42%] h-[34%] w-[120%] opacity-45"
|
||||
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.id}
|
||||
className={`aurora-motion absolute ${blob.reverse ? "aurora-motion-reverse" : ""} ${blob.position}`}
|
||||
style={{
|
||||
background: blob.gradient,
|
||||
animationDuration: blob.duration,
|
||||
animationDelay: blob.delay,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{stars.map((s) => (
|
||||
<span
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Streamdown,
|
||||
} from "streamdown";
|
||||
import { openExternalUrl } from "@/lib/desktop-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -298,13 +299,15 @@ const markdownComponents = {
|
||||
export const MemoizedMarkdown = memo(
|
||||
({
|
||||
content,
|
||||
classNames,
|
||||
streaming = false,
|
||||
}: {
|
||||
content: string;
|
||||
streaming?: boolean;
|
||||
classNames?: string;
|
||||
}) => (
|
||||
<Streamdown
|
||||
className="cline-markdown"
|
||||
className={cn("cline-markdown", classNames)}
|
||||
components={markdownComponents}
|
||||
controls={streamdownControls}
|
||||
dir="auto"
|
||||
|
||||
@@ -118,10 +118,12 @@ describe("ChatInputBar", () => {
|
||||
await act(async () => compactModelTrigger?.click());
|
||||
expect(compactModelTrigger?.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(
|
||||
container.querySelectorAll<HTMLButtonElement>('[aria-label="Provider"]'),
|
||||
container.querySelectorAll<HTMLButtonElement>(
|
||||
'[aria-label^="Provider:"]',
|
||||
),
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
container.querySelectorAll<HTMLButtonElement>('[aria-label="Model"]'),
|
||||
container.querySelectorAll<HTMLButtonElement>('[aria-label^="Model:"]'),
|
||||
).toHaveLength(2);
|
||||
await act(async () =>
|
||||
container
|
||||
@@ -154,7 +156,7 @@ describe("ChatInputBar", () => {
|
||||
|
||||
expect(onReasoningChange).not.toHaveBeenCalled();
|
||||
const providerTrigger = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Provider"]',
|
||||
'[aria-label^="Provider:"]',
|
||||
);
|
||||
expect(providerTrigger?.parentElement?.parentElement?.className).toContain(
|
||||
"max-[560px]:hidden",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared/browser";
|
||||
import { SearchCombobox } from "@cline/ui";
|
||||
import {
|
||||
ArrowUp,
|
||||
Brain,
|
||||
@@ -46,7 +47,6 @@ import {
|
||||
loadProviderModels,
|
||||
} from "@/lib/provider-model-catalog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SearchableSelect } from "./searchable-select";
|
||||
import { WorkspaceSelector as WorkspaceSelectorImpl } from "./workspace-selector";
|
||||
|
||||
// Memoized: the workspace/branch selector fans out into popovers and lists
|
||||
@@ -1524,15 +1524,16 @@ const ModelSelector = memo(function ModelSelector({
|
||||
],
|
||||
);
|
||||
const renderProviderSelect = (triggerClassName: string) => (
|
||||
<SearchableSelect
|
||||
<SearchCombobox
|
||||
ariaLabel="Provider"
|
||||
className={triggerClassName}
|
||||
disabled={isBusy || providers.length === 0}
|
||||
emptyLabel="No providers found."
|
||||
items={providers}
|
||||
onSelect={handleProviderSelect}
|
||||
emptyText="No providers found."
|
||||
onValueChange={handleProviderSelect}
|
||||
options={providers.map((value) => ({ label: value, value }))}
|
||||
placeholder="Provider"
|
||||
placement="top"
|
||||
searchPlaceholder="Search providers"
|
||||
triggerClassName={triggerClassName}
|
||||
value={resolvedProvider}
|
||||
/>
|
||||
);
|
||||
@@ -1540,18 +1541,19 @@ const ModelSelector = memo(function ModelSelector({
|
||||
triggerClassName: string,
|
||||
closeMobileMenu = false,
|
||||
) => (
|
||||
<SearchableSelect
|
||||
<SearchCombobox
|
||||
ariaLabel="Model"
|
||||
className={triggerClassName}
|
||||
disabled={isBusy || modelsForProvider.length === 0}
|
||||
emptyLabel="No models found."
|
||||
items={modelsForProvider}
|
||||
onSelect={(value) => {
|
||||
emptyText="No models found."
|
||||
onValueChange={(value) => {
|
||||
onModelChange(value);
|
||||
if (closeMobileMenu) setMobileOpen(false);
|
||||
}}
|
||||
options={modelsForProvider.map((value) => ({ label: value, value }))}
|
||||
placeholder="Model"
|
||||
placement="top"
|
||||
searchPlaceholder="Search models"
|
||||
triggerClassName={triggerClassName}
|
||||
value={resolvedModel}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -47,6 +47,53 @@ async function renderMessages(
|
||||
}
|
||||
|
||||
describe("ChatMessages tool disclosures", () => {
|
||||
it.each([
|
||||
["run_commands", "lucide-terminal"],
|
||||
["read_files", "lucide-files"],
|
||||
["search_codebase", "lucide-search-code"],
|
||||
["editor", "lucide-pencil"],
|
||||
["apply_patch", "lucide-pencil"],
|
||||
["ask_question", "lucide-message-circle-question-mark"],
|
||||
["fetch_web_content", "lucide-panels-top-left"],
|
||||
["skills", "lucide-library"],
|
||||
["mcp", "lucide-box"],
|
||||
["plugins", "lucide-blocks"],
|
||||
["submit_and_exit", "lucide-square-arrow-right"],
|
||||
["spawn_agent", "lucide-user"],
|
||||
["spawn-agent", "lucide-user"],
|
||||
["spawn_agent_tool", "lucide-user"],
|
||||
["subagent_subagent", "lucide-user"],
|
||||
["subagent_code_reviewer", "lucide-user"],
|
||||
["team_status", "lucide-users"],
|
||||
["bash", "lucide-terminal"],
|
||||
["file_read", "lucide-files"],
|
||||
["file-read", "lucide-files"],
|
||||
["edit", "lucide-pencil"],
|
||||
["edit_file", "lucide-pencil"],
|
||||
["apply-patch", "lucide-pencil"],
|
||||
["search", "lucide-search-code"],
|
||||
["web-fetch", "lucide-panels-top-left"],
|
||||
["web_fetch", "lucide-panels-top-left"],
|
||||
["unknown_tool", "lucide-wrench"],
|
||||
])("uses the expected icon for %s", async (toolName, iconClass) => {
|
||||
await renderMessages([
|
||||
{
|
||||
id: `tool-icon-${toolName}`,
|
||||
sessionId: "session-1",
|
||||
role: "tool",
|
||||
content: JSON.stringify({
|
||||
toolName,
|
||||
input: {},
|
||||
result: {},
|
||||
}),
|
||||
createdAt: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
const icon = container.querySelector(".cline-chat-tool-icon svg");
|
||||
expect(icon?.classList.contains(iconClass)).toBe(true);
|
||||
});
|
||||
|
||||
it("renders a detail-less tool summary as static text", async () => {
|
||||
await renderMessages([
|
||||
{
|
||||
@@ -64,6 +111,49 @@ describe("ChatMessages tool disclosures", () => {
|
||||
);
|
||||
expect(summary).toBeDefined();
|
||||
expect(summary?.closest("button")).toBeNull();
|
||||
expect(
|
||||
container.querySelector(".cline-chat-tool")?.classList.contains("my-0"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("shimmers a tool title only while its result is pending", async () => {
|
||||
const pendingTool: ChatMessage = {
|
||||
id: "tool-pending",
|
||||
sessionId: "session-1",
|
||||
role: "tool",
|
||||
content: JSON.stringify({
|
||||
toolName: "read_files",
|
||||
input: { paths: ["pending.ts"] },
|
||||
result: null,
|
||||
}),
|
||||
createdAt: 1,
|
||||
};
|
||||
await renderMessages([pendingTool]);
|
||||
|
||||
const pendingTitle = container.querySelector(
|
||||
".cline-chat-tool-label > span",
|
||||
);
|
||||
expect(pendingTitle?.classList.contains("cline-chat-streaming-title")).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
await renderMessages([
|
||||
{
|
||||
...pendingTool,
|
||||
content: JSON.stringify({
|
||||
toolName: "read_files",
|
||||
input: { paths: ["pending.ts"] },
|
||||
result: { content: "done" },
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
const completedTitle = container.querySelector(
|
||||
".cline-chat-tool-label > span",
|
||||
);
|
||||
expect(
|
||||
completedTitle?.classList.contains("cline-chat-streaming-title"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("exposes and toggles expandable tool details", async () => {
|
||||
@@ -249,6 +339,330 @@ describe("ChatMessages tool disclosures", () => {
|
||||
container.querySelector('button[aria-label="Copy assistant message"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("spaces reasoning, content, and tool blocks with a single gap-2", async () => {
|
||||
await renderMessages([
|
||||
{
|
||||
id: "assistant-reasoning",
|
||||
sessionId: "session-1",
|
||||
role: "assistant",
|
||||
content: "Assistant message",
|
||||
reasoning: "Thinking about it",
|
||||
createdAt: 2,
|
||||
},
|
||||
{
|
||||
id: "tool-after",
|
||||
sessionId: "session-1",
|
||||
role: "tool",
|
||||
content: JSON.stringify({
|
||||
toolName: "run_commands",
|
||||
input: { commands: ["ls"] },
|
||||
result: {},
|
||||
}),
|
||||
createdAt: 3,
|
||||
},
|
||||
]);
|
||||
|
||||
const message = container.querySelector(
|
||||
'.cline-chat-message[data-role="assistant"]',
|
||||
);
|
||||
const messageList = message?.parentElement;
|
||||
const content = message?.querySelector(".cline-chat-message-content");
|
||||
|
||||
// The list and the content column each own their spacing via gap-2...
|
||||
expect(messageList?.classList.contains("gap-2")).toBe(true);
|
||||
expect(content?.classList.contains("flex")).toBe(true);
|
||||
expect(content?.classList.contains("flex-col")).toBe(true);
|
||||
expect(content?.classList.contains("gap-2")).toBe(true);
|
||||
|
||||
// ...so no block may contribute vertical margins of its own.
|
||||
for (const element of [
|
||||
message,
|
||||
content?.querySelector(".cline-chat-reasoning"),
|
||||
content?.lastElementChild,
|
||||
container.querySelector(".cline-chat-tool"),
|
||||
]) {
|
||||
const classes = [...(element?.classList ?? [])];
|
||||
expect(classes.some((name) => /^-?m[ytb]-[1-9]/.test(name))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("positions hidden message actions outside the message layout", async () => {
|
||||
await renderMessages(
|
||||
[
|
||||
{
|
||||
id: "user-actions",
|
||||
sessionId: "session-1",
|
||||
role: "user",
|
||||
content: "User message",
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: "assistant-actions",
|
||||
sessionId: "session-1",
|
||||
role: "assistant",
|
||||
content: "Assistant message",
|
||||
createdAt: 2,
|
||||
},
|
||||
],
|
||||
{ onForkSession: vi.fn() },
|
||||
);
|
||||
|
||||
const userMessage = container.querySelector(
|
||||
'.cline-chat-message[data-role="user"]',
|
||||
);
|
||||
const userActions = userMessage?.querySelector(
|
||||
":scope > .cline-chat-message-actions",
|
||||
);
|
||||
const assistantMessage = container.querySelector(
|
||||
'.cline-chat-message[data-role="assistant"]',
|
||||
);
|
||||
const assistantActions = assistantMessage?.querySelector(
|
||||
":scope > .cline-chat-message-actions",
|
||||
);
|
||||
|
||||
expect(userMessage?.classList.contains("relative")).toBe(true);
|
||||
expect(userActions?.classList.contains("absolute")).toBe(true);
|
||||
expect(userActions?.classList.contains("right-0")).toBe(true);
|
||||
expect(userActions?.classList.contains("top-full")).toBe(true);
|
||||
expect(userActions?.classList.contains("-translate-y-2")).toBe(true);
|
||||
expect(assistantMessage?.classList.contains("relative")).toBe(true);
|
||||
expect(assistantActions?.classList.contains("absolute")).toBe(true);
|
||||
expect(assistantActions?.classList.contains("left-0")).toBe(true);
|
||||
expect(assistantActions?.classList.contains("top-full")).toBe(true);
|
||||
expect(assistantActions?.classList.contains("-translate-y-2")).toBe(true);
|
||||
expect(assistantActions?.getAttribute("data-visible")).toBe("true");
|
||||
const userAction = userActions?.querySelector(".cline-chat-message-action");
|
||||
expect(userAction?.classList.contains("min-w-0")).toBe(true);
|
||||
expect(userAction?.classList.contains("p-0")).toBe(true);
|
||||
const assistantActionButtons = [
|
||||
...(assistantActions?.querySelectorAll(".cline-chat-message-action") ??
|
||||
[]),
|
||||
];
|
||||
expect(assistantActionButtons).toHaveLength(2);
|
||||
expect(
|
||||
assistantActionButtons.every(
|
||||
(action) =>
|
||||
action.classList.contains("min-w-0") &&
|
||||
action.classList.contains("p-0"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(userActions?.querySelector("time")?.getAttribute("datetime")).toBe(
|
||||
new Date(1).toISOString(),
|
||||
);
|
||||
expect(
|
||||
assistantActions?.querySelector("time")?.getAttribute("datetime"),
|
||||
).toBe(new Date(2).toISOString());
|
||||
});
|
||||
|
||||
it("confirms before restoring a checkpoint", async () => {
|
||||
const onRestoreCheckpoint = vi.fn(async () => undefined);
|
||||
await renderMessages(
|
||||
[
|
||||
{
|
||||
id: "checkpoint-user",
|
||||
sessionId: "session-1",
|
||||
role: "user",
|
||||
content: "Change the implementation",
|
||||
createdAt: 1,
|
||||
meta: {
|
||||
runCount: 2,
|
||||
checkpoint: {
|
||||
ref: "checkpoint-ref",
|
||||
createdAt: 1,
|
||||
runCount: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
{ onRestoreCheckpoint },
|
||||
);
|
||||
|
||||
const restoreButton = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Restore checkpoint"]',
|
||||
);
|
||||
await act(async () => restoreButton?.click());
|
||||
|
||||
expect(onRestoreCheckpoint).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toContain("Revert to this checkpoint?");
|
||||
|
||||
const confirmButton = [...document.body.querySelectorAll("button")].find(
|
||||
(button) => button.textContent === "Revert",
|
||||
);
|
||||
await act(async () => confirmButton?.click());
|
||||
|
||||
expect(onRestoreCheckpoint).toHaveBeenCalledOnce();
|
||||
expect(onRestoreCheckpoint).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it("edits a user message by restarting before its user run", async () => {
|
||||
const onEditMessage = vi.fn(async () => undefined);
|
||||
await renderMessages(
|
||||
[
|
||||
{
|
||||
id: "earlier-user",
|
||||
sessionId: "session-1",
|
||||
role: "user",
|
||||
content: "Earlier prompt",
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: "earlier-assistant",
|
||||
sessionId: "session-1",
|
||||
role: "assistant",
|
||||
content: "Earlier response",
|
||||
createdAt: 2,
|
||||
},
|
||||
{
|
||||
id: "editable-user",
|
||||
sessionId: "session-1",
|
||||
role: "user",
|
||||
content: '<user_input mode="act">Original prompt</user_input>',
|
||||
createdAt: 3,
|
||||
},
|
||||
],
|
||||
{ onEditMessage },
|
||||
);
|
||||
|
||||
const editButton = container.querySelectorAll<HTMLButtonElement>(
|
||||
'button[aria-label="Edit user message"]',
|
||||
)[1];
|
||||
await act(async () => editButton?.click());
|
||||
|
||||
expect(onEditMessage).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toContain("Edit and restart from here?");
|
||||
|
||||
const continueButton = [...document.body.querySelectorAll("button")].find(
|
||||
(button) => button.textContent === "Continue",
|
||||
);
|
||||
await act(async () => continueButton?.click());
|
||||
|
||||
expect(onEditMessage).toHaveBeenCalledOnce();
|
||||
expect(onEditMessage).toHaveBeenCalledWith(
|
||||
"editable-user",
|
||||
"Original prompt",
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
it("counts folded system-displayed runs before an editable user message", async () => {
|
||||
const onEditMessage = vi.fn(async () => undefined);
|
||||
await renderMessages(
|
||||
[
|
||||
{
|
||||
id: "compacted-history",
|
||||
sessionId: "session-1",
|
||||
role: "system",
|
||||
content: "Compacted context",
|
||||
createdAt: 1,
|
||||
meta: {
|
||||
messageKind: "compaction",
|
||||
userRunSpan: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "post-compaction-user",
|
||||
sessionId: "session-1",
|
||||
role: "user",
|
||||
content: "Fourth prompt",
|
||||
createdAt: 2,
|
||||
},
|
||||
],
|
||||
{ onEditMessage },
|
||||
);
|
||||
|
||||
const editButton = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Edit user message"]',
|
||||
);
|
||||
await act(async () => editButton?.click());
|
||||
const continueButton = [...document.body.querySelectorAll("button")].find(
|
||||
(button) => button.textContent === "Continue",
|
||||
);
|
||||
await act(async () => continueButton?.click());
|
||||
|
||||
expect(onEditMessage).toHaveBeenCalledWith(
|
||||
"post-compaction-user",
|
||||
"Fourth prompt",
|
||||
4,
|
||||
);
|
||||
});
|
||||
|
||||
it("continues from a non-user run anchor in a truncated history", async () => {
|
||||
const onEditMessage = vi.fn(async () => undefined);
|
||||
await renderMessages(
|
||||
[
|
||||
{
|
||||
id: "truncated-assistant",
|
||||
sessionId: "session-1",
|
||||
role: "assistant",
|
||||
content: "Most recent response",
|
||||
createdAt: 1,
|
||||
meta: { runCount: 3 },
|
||||
},
|
||||
{
|
||||
id: "optimistic-user",
|
||||
sessionId: "session-1",
|
||||
role: "user",
|
||||
content: "Next prompt",
|
||||
createdAt: 2,
|
||||
},
|
||||
],
|
||||
{ onEditMessage },
|
||||
);
|
||||
|
||||
const editButton = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Edit user message"]',
|
||||
);
|
||||
await act(async () => editButton?.click());
|
||||
const continueButton = [...document.body.querySelectorAll("button")].find(
|
||||
(button) => button.textContent === "Continue",
|
||||
);
|
||||
await act(async () => continueButton?.click());
|
||||
|
||||
expect(onEditMessage).toHaveBeenCalledWith(
|
||||
"optimistic-user",
|
||||
"Next prompt",
|
||||
4,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not offer editing for a message that represents multiple runs", async () => {
|
||||
await renderMessages(
|
||||
[
|
||||
{
|
||||
id: "folded-user-runs",
|
||||
sessionId: "session-1",
|
||||
role: "user",
|
||||
content: "Merged prompts",
|
||||
createdAt: 1,
|
||||
meta: { userRunSpan: 2 },
|
||||
},
|
||||
],
|
||||
{ onEditMessage: vi.fn(async () => undefined) },
|
||||
);
|
||||
|
||||
expect(
|
||||
container.querySelector('button[aria-label="Edit user message"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves vertical scrolling to the conversation viewport", async () => {
|
||||
await renderMessages([
|
||||
{
|
||||
id: "assistant-scroll",
|
||||
sessionId: "session-1",
|
||||
role: "assistant",
|
||||
content: "Assistant message",
|
||||
createdAt: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
const content = container.querySelector(".cline-chat-conversation-content");
|
||||
const messageList = content?.querySelector(":scope > div");
|
||||
|
||||
expect(content?.classList.contains("overflow-x-hidden")).toBe(false);
|
||||
expect(messageList?.classList.contains("overflow-x-hidden")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatMessages image attachments", () => {
|
||||
@@ -270,8 +684,8 @@ describe("ChatMessages image attachments", () => {
|
||||
'img[alt="Attachment 1"]',
|
||||
);
|
||||
expect(image?.src).toBe("data:image/png;base64,aGVsbG8=");
|
||||
expect(image?.className).toContain("max-h-[225px]");
|
||||
expect(image?.className).toContain("max-w-[225px]");
|
||||
expect(image?.className).toContain("max-h-56.25");
|
||||
expect(image?.className).toContain("max-w-56.25");
|
||||
expect(container.textContent).toContain("Describe this");
|
||||
});
|
||||
|
||||
@@ -312,6 +726,318 @@ describe("ChatMessages image attachments", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatMessages reasoning disclosure", () => {
|
||||
it("shimmers the thinking title only while reasoning is streaming", async () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
id: "user-before-streaming-reasoning",
|
||||
sessionId: "session-1",
|
||||
role: "user",
|
||||
content: "Think this through",
|
||||
createdAt: 1_000,
|
||||
},
|
||||
{
|
||||
id: "streaming-reasoning",
|
||||
sessionId: "session-1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "Still considering the answer.",
|
||||
createdAt: 2_000,
|
||||
},
|
||||
];
|
||||
await renderMessages(messages, {
|
||||
status: "running",
|
||||
streamingMessageId: "streaming-reasoning",
|
||||
});
|
||||
|
||||
const streamingTitle = container.querySelector(
|
||||
".cline-chat-reasoning-trigger > span",
|
||||
);
|
||||
expect(
|
||||
streamingTitle?.classList.contains("cline-chat-streaming-title"),
|
||||
).toBe(true);
|
||||
|
||||
await renderMessages(messages, {
|
||||
status: "completed",
|
||||
streamingMessageId: null,
|
||||
});
|
||||
|
||||
const completedTitle = container.querySelector(
|
||||
".cline-chat-reasoning-trigger > span",
|
||||
);
|
||||
expect(
|
||||
completedTitle?.classList.contains("cline-chat-streaming-title"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("shows elapsed thinking time with the border-left disclosure style", async () => {
|
||||
await renderMessages([
|
||||
{
|
||||
id: "user-before-reasoning",
|
||||
sessionId: "session-1",
|
||||
role: "user",
|
||||
content: "Solve this",
|
||||
createdAt: 1_000,
|
||||
},
|
||||
{
|
||||
id: "assistant-reasoning",
|
||||
sessionId: "session-1",
|
||||
role: "assistant",
|
||||
content: "Done",
|
||||
reasoning: "Carefully considered the request.",
|
||||
createdAt: 7_500,
|
||||
},
|
||||
]);
|
||||
|
||||
const trigger = [...container.querySelectorAll("button")].find((element) =>
|
||||
element.textContent?.includes("Thought for 7s"),
|
||||
);
|
||||
expect(trigger?.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(trigger?.querySelector(".lucide-brain")).not.toBeNull();
|
||||
expect(trigger?.querySelector(".cline-chat-disclosure-icon")).toBeNull();
|
||||
expect(trigger?.classList.contains("text-sm")).toBe(true);
|
||||
expect(trigger?.classList.contains("text-xs")).toBe(false);
|
||||
|
||||
await act(async () => trigger?.click());
|
||||
|
||||
const content = container.querySelector(".cline-chat-reasoning-content");
|
||||
expect(trigger?.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(content?.textContent).toContain("Carefully considered the request.");
|
||||
expect(content?.classList.contains("border-l")).toBe(true);
|
||||
expect(content?.classList.contains("rounded-none")).toBe(true);
|
||||
expect(content?.classList.contains("bg-transparent")).toBe(true);
|
||||
// Inset off the rail, without pinning the exact step — the shared-rail
|
||||
// test owns the specific values.
|
||||
expect(
|
||||
[...(content?.classList ?? [])].some((name) => /^p[lx]-/.test(name)),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("hangs expanded reasoning and tool panels off the same left rail", async () => {
|
||||
await renderMessages([
|
||||
{
|
||||
id: "assistant-rail-reasoning",
|
||||
sessionId: "session-1",
|
||||
role: "assistant",
|
||||
content: "Done",
|
||||
reasoning: "Considered the request.",
|
||||
createdAt: 2_000,
|
||||
},
|
||||
{
|
||||
id: "tool-rail",
|
||||
sessionId: "session-1",
|
||||
role: "tool",
|
||||
content: JSON.stringify({
|
||||
toolName: "run_commands",
|
||||
input: { commands: ["git status"] },
|
||||
result: {},
|
||||
}),
|
||||
createdAt: 3_000,
|
||||
},
|
||||
]);
|
||||
|
||||
const reasoningTrigger = container.querySelector(
|
||||
".cline-chat-reasoning-trigger",
|
||||
) as HTMLButtonElement | null;
|
||||
const toolTrigger = container.querySelector(
|
||||
".cline-chat-tool-trigger",
|
||||
) as HTMLButtonElement | null;
|
||||
await act(async () => {
|
||||
reasoningTrigger?.click();
|
||||
toolTrigger?.click();
|
||||
});
|
||||
|
||||
const reasoningContent = container.querySelector(
|
||||
".cline-chat-reasoning-content",
|
||||
);
|
||||
const toolContent = container.querySelector(".cline-chat-tool-content");
|
||||
expect(reasoningContent).not.toBeNull();
|
||||
expect(toolContent).not.toBeNull();
|
||||
|
||||
// Compared as sets rather than pinned to literals, so retuning the rail
|
||||
// stays a one-line change but can never drift between the two panels.
|
||||
const railClasses = (element: Element | null) =>
|
||||
[...(element?.classList ?? [])]
|
||||
.filter((name) =>
|
||||
/^(-?m[a-z]?|p[a-z]?|border|rounded|bg|max)-/.test(name),
|
||||
)
|
||||
.sort();
|
||||
expect(railClasses(reasoningContent).length).toBeGreaterThan(0);
|
||||
expect(railClasses(toolContent)).toEqual(railClasses(reasoningContent));
|
||||
|
||||
// Both panels are capped on both axes so neither can stretch the column.
|
||||
for (const panel of [reasoningContent, toolContent]) {
|
||||
const classes = [...(panel?.classList ?? [])];
|
||||
expect(classes.some((name) => name.startsWith("max-h-"))).toBe(true);
|
||||
expect(classes.some((name) => name.startsWith("max-w-"))).toBe(true);
|
||||
}
|
||||
|
||||
// Reasoning wraps, so it scrolls Y only; tool output scrolls both axes.
|
||||
expect(reasoningContent?.classList.contains("overflow-y-auto")).toBe(true);
|
||||
expect(reasoningContent?.classList.contains("overflow-x-hidden")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(reasoningContent?.classList.contains("overflow-auto")).toBe(false);
|
||||
expect(toolContent?.classList.contains("overflow-auto")).toBe(true);
|
||||
expect(toolContent?.classList.contains("overflow-x-hidden")).toBe(false);
|
||||
|
||||
// The X axis is only reachable if the detail rows keep their lines intact.
|
||||
const details = toolContent?.querySelector(".cline-chat-tool-details");
|
||||
expect(details?.classList.contains("whitespace-pre")).toBe(true);
|
||||
|
||||
// The X axis stays live but loses its bar; reasoning has no X bar to hide.
|
||||
expect(toolContent?.classList.contains("cline-chat-scroll-x-bare")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
reasoningContent?.classList.contains("cline-chat-scroll-x-bare"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the reasoning panel inside the shape the hover-suppress rule targets", async () => {
|
||||
await renderMessages([
|
||||
{
|
||||
id: "assistant-hover-scope",
|
||||
sessionId: "session-1",
|
||||
role: "assistant",
|
||||
content: "Answer body",
|
||||
reasoning: "Weighed the options.",
|
||||
createdAt: 2_000,
|
||||
},
|
||||
// A trailing message keeps the assistant row off the always-visible
|
||||
// last-message path, so its actions are genuinely hover-driven.
|
||||
{
|
||||
id: "user-after-hover-scope",
|
||||
sessionId: "session-1",
|
||||
role: "user",
|
||||
content: "Follow-up",
|
||||
createdAt: 3_000,
|
||||
},
|
||||
]);
|
||||
|
||||
const message = container.querySelector(
|
||||
'.cline-chat-message[data-role="assistant"]',
|
||||
);
|
||||
const actions = message?.querySelector(
|
||||
":scope > .cline-chat-message-actions",
|
||||
);
|
||||
expect(actions).not.toBeNull();
|
||||
|
||||
// globals.css suppresses the hover reveal via this selector plus `:hover`.
|
||||
// jsdom has no pointer state, so assert the structural half: if the DOM is
|
||||
// ever reshaped, the rule stops matching and the reveal silently returns.
|
||||
expect(
|
||||
actions?.matches(
|
||||
".cline-chat-message:has(> .cline-chat-message-content .cline-chat-reasoning) > .cline-chat-message-actions:not([data-visible='true'])",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
// The reveal itself must stay opt-out-able, i.e. driven by hover, not by a
|
||||
// pinned data-visible that would defeat the suppression.
|
||||
expect(actions?.getAttribute("data-visible")).toBeNull();
|
||||
});
|
||||
|
||||
it("combines consecutive assistant reasoning into one disclosure", async () => {
|
||||
await renderMessages([
|
||||
{
|
||||
id: "user-before-combined-reasoning",
|
||||
sessionId: "session-1",
|
||||
role: "user",
|
||||
content: "Investigate this",
|
||||
createdAt: 1_000,
|
||||
},
|
||||
{
|
||||
id: "assistant-reasoning-first",
|
||||
sessionId: "session-1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "First reasoning segment.",
|
||||
createdAt: 2_000,
|
||||
},
|
||||
{
|
||||
id: "assistant-reasoning-second",
|
||||
sessionId: "session-1",
|
||||
role: "assistant",
|
||||
content: "Investigation complete.",
|
||||
reasoning: "Second reasoning segment.",
|
||||
createdAt: 3_000,
|
||||
},
|
||||
]);
|
||||
|
||||
const disclosures = container.querySelectorAll(".cline-chat-reasoning");
|
||||
expect(disclosures).toHaveLength(1);
|
||||
const trigger = disclosures[0]?.querySelector("button");
|
||||
expect(trigger?.textContent).toContain("Thought for 2s");
|
||||
|
||||
await act(async () => trigger?.click());
|
||||
|
||||
const content = disclosures[0]?.querySelector(
|
||||
".cline-chat-reasoning-content",
|
||||
);
|
||||
const contentText = content?.textContent ?? "";
|
||||
expect(contentText).toContain("First reasoning segment.");
|
||||
expect(contentText).toContain("Second reasoning segment.");
|
||||
expect(contentText.indexOf("First reasoning segment.")).toBeLessThan(
|
||||
contentText.indexOf("Second reasoning segment."),
|
||||
);
|
||||
expect(container.textContent).toContain("Investigation complete.");
|
||||
});
|
||||
|
||||
it("keeps reasoning disclosures separate across tool activity", async () => {
|
||||
await renderMessages([
|
||||
{
|
||||
id: "user-before-separated-reasoning",
|
||||
sessionId: "session-1",
|
||||
role: "user",
|
||||
content: "Investigate this",
|
||||
createdAt: 1_000,
|
||||
},
|
||||
{
|
||||
id: "assistant-reasoning-before-tool",
|
||||
sessionId: "session-1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "Reasoning before the tool.",
|
||||
createdAt: 2_000,
|
||||
},
|
||||
{
|
||||
id: "tool-between-reasoning",
|
||||
sessionId: "session-1",
|
||||
role: "tool",
|
||||
content: "not-json",
|
||||
createdAt: 2_500,
|
||||
meta: { toolName: "search" },
|
||||
},
|
||||
{
|
||||
id: "assistant-reasoning-after-tool",
|
||||
sessionId: "session-1",
|
||||
role: "assistant",
|
||||
content: "Investigation complete.",
|
||||
reasoning: "Reasoning after the tool.",
|
||||
createdAt: 3_000,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(container.querySelectorAll(".cline-chat-reasoning")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("falls back to Thinking when there is no previous timestamp", async () => {
|
||||
await renderMessages([
|
||||
{
|
||||
id: "first-reasoning",
|
||||
sessionId: "session-1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "Starting from scratch.",
|
||||
createdAt: 1_000,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(container.textContent).toContain("Thinking");
|
||||
expect(container.textContent).not.toContain("Thought for");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatMessages thinking indicator", () => {
|
||||
const userMessage: ChatMessage = {
|
||||
id: "user-1",
|
||||
@@ -324,6 +1050,11 @@ describe("ChatMessages thinking indicator", () => {
|
||||
it("shows while starting", async () => {
|
||||
await renderMessages([userMessage], { status: "starting" });
|
||||
expect(container.textContent).toContain("Thinking...");
|
||||
expect(
|
||||
[...container.querySelectorAll("span")]
|
||||
.find((element) => element.textContent === "Thinking...")
|
||||
?.classList.contains("cline-chat-streaming-title"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps showing while running until the first assistant output arrives", async () => {
|
||||
@@ -403,3 +1134,46 @@ describe("ChatMessages thinking indicator", () => {
|
||||
expect(container.textContent).not.toContain("Thinking...");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatMessages tool approvals", () => {
|
||||
it("renders the shared card and forwards its decisions", async () => {
|
||||
const onApprove = vi.fn();
|
||||
const onReject = vi.fn();
|
||||
await renderMessages(
|
||||
[
|
||||
{
|
||||
id: "user-1",
|
||||
sessionId: "session-1",
|
||||
role: "user",
|
||||
content: "Run pwd",
|
||||
createdAt: 1,
|
||||
},
|
||||
],
|
||||
{
|
||||
onApproveToolApproval: onApprove,
|
||||
onRejectToolApproval: onReject,
|
||||
pendingToolApprovals: [
|
||||
{
|
||||
requestId: "req-1",
|
||||
sessionId: "session-1",
|
||||
createdAt: new Date(1).toISOString(),
|
||||
toolCallId: "call-1",
|
||||
toolName: "execute_command",
|
||||
input: { command: "pwd" },
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const card = container.querySelector(".cline-ui-agent-approval-card");
|
||||
expect(card?.textContent).toContain("execute_command");
|
||||
expect(card?.textContent).toContain('"command": "pwd"');
|
||||
|
||||
const [approve, reject] = card?.querySelectorAll("button") ?? [];
|
||||
await act(async () => approve?.click());
|
||||
expect(onApprove).toHaveBeenCalledWith("req-1");
|
||||
|
||||
await act(async () => reject?.click());
|
||||
expect(onReject).toHaveBeenCalledWith("req-1");
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,147 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Search } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* A button-styled select whose menu is a searchable, filterable list — the same
|
||||
* interaction the workspace and branch pickers use. The trigger shows the
|
||||
* current value with no chevron; clicking it opens the popover.
|
||||
*/
|
||||
export function SearchableSelect({
|
||||
value,
|
||||
items,
|
||||
onSelect,
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
searchPlaceholder = "Search...",
|
||||
emptyLabel = "No results",
|
||||
placeholder = "Select",
|
||||
icon,
|
||||
triggerClassName,
|
||||
align = "start",
|
||||
placement = "top",
|
||||
}: {
|
||||
value: string;
|
||||
items: string[];
|
||||
onSelect: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
ariaLabel: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyLabel?: string;
|
||||
placeholder?: string;
|
||||
icon?: ReactNode;
|
||||
triggerClassName?: string;
|
||||
align?: "start" | "end";
|
||||
placement?: "top" | "bottom";
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close on outside click; reset the filter each time the menu opens.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSearch("");
|
||||
return;
|
||||
}
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
// pointerdown in the capture phase so we still fire before a portaled menu
|
||||
// (e.g. the Radix effort Select) handles its own trigger's pointerdown and
|
||||
// calls preventDefault, which would otherwise suppress a mousedown listener.
|
||||
document.addEventListener("pointerdown", handlePointerDown, true);
|
||||
return () =>
|
||||
document.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
}, [open]);
|
||||
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
items.filter((item) => item.toLowerCase().includes(search.toLowerCase())),
|
||||
[items, search],
|
||||
);
|
||||
|
||||
const handleSelect = (item: string) => {
|
||||
if (item !== value) onSelect(item);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={containerRef}>
|
||||
<button
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-md px-2 py-1 font-medium text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
triggerClassName,
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
title={value}
|
||||
type="button"
|
||||
>
|
||||
{icon}
|
||||
<span className="truncate">{value || placeholder}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute z-50 w-64 rounded-lg border border-border bg-popover shadow-xl",
|
||||
align === "end" ? "right-0" : "left-0",
|
||||
placement === "top" ? "bottom-full mb-2" : "top-full mt-2",
|
||||
)}
|
||||
>
|
||||
<div className="border-b border-border p-2">
|
||||
<div className="flex items-center gap-2 rounded-md bg-background px-2.5 py-1.5">
|
||||
<Search className="size-3 shrink-0 text-muted-foreground" />
|
||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-auto flex-1 border-0 bg-transparent px-0 py-0 text-xs shadow-none focus-visible:ring-0"
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
value={search}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex max-h-56 flex-col gap-0.5 overflow-y-auto p-1.5">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="px-2 py-2 text-xs text-muted-foreground">
|
||||
{emptyLabel}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((item) => (
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left transition-colors",
|
||||
item === value ? "bg-accent" : "hover:bg-accent/50",
|
||||
)}
|
||||
key={item}
|
||||
onClick={() => handleSelect(item)}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate text-xs text-foreground">
|
||||
{item}
|
||||
</span>
|
||||
{item === value && (
|
||||
<Check className="ml-2 size-3 shrink-0 text-foreground" />
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ afterEach(async () => {
|
||||
async function renderWelcomeScreen({
|
||||
workspaceRoot,
|
||||
workspaces,
|
||||
onStartChat = vi.fn(),
|
||||
selectChat = vi.fn(async () => true),
|
||||
onListGitBranches = vi.fn(async () => ({
|
||||
current: "main",
|
||||
@@ -38,6 +39,7 @@ async function renderWelcomeScreen({
|
||||
}: {
|
||||
workspaceRoot: string;
|
||||
workspaces: string[];
|
||||
onStartChat?: (prompt: string) => void;
|
||||
selectChat?: () => Promise<boolean>;
|
||||
onListGitBranches?: () => Promise<{
|
||||
current: string;
|
||||
@@ -63,7 +65,7 @@ async function renderWelcomeScreen({
|
||||
composer={null}
|
||||
gitBranch="main"
|
||||
onListGitBranches={onListGitBranches}
|
||||
onStartChat={vi.fn()}
|
||||
onStartChat={onStartChat}
|
||||
onSwitchGitBranch={vi.fn(async () => true)}
|
||||
quickActions={[]}
|
||||
/>
|
||||
@@ -86,6 +88,21 @@ async function clickButton(text: string, last = false): Promise<void> {
|
||||
}
|
||||
|
||||
describe("WelcomeScreen", () => {
|
||||
it("starts chat with the selected quick-action prompt", async () => {
|
||||
const onStartChat = vi.fn();
|
||||
await renderWelcomeScreen({
|
||||
onStartChat,
|
||||
workspaceRoot: "/projects/project-1",
|
||||
workspaces: ["/projects/project-1"],
|
||||
});
|
||||
|
||||
await clickButton("Check for build errors");
|
||||
|
||||
expect(onStartChat).toHaveBeenCalledWith(
|
||||
"Check this project for build errors and help me fix any failures.",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders every known project in the opened workspace menu", async () => {
|
||||
const workspaces = Array.from(
|
||||
{ length: 6 },
|
||||
@@ -96,6 +113,12 @@ describe("WelcomeScreen", () => {
|
||||
workspaces,
|
||||
});
|
||||
|
||||
expect(
|
||||
container.querySelectorAll(".cline-ui-agent-aurora__star"),
|
||||
).toHaveLength(32);
|
||||
expect(
|
||||
container.querySelector(".cline-ui-agent-hero-heading"),
|
||||
).not.toBeNull();
|
||||
await clickButton("project-1");
|
||||
|
||||
for (let index = 1; index <= workspaces.length; index += 1) {
|
||||
|
||||
@@ -1,79 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import {
|
||||
AgentAurora,
|
||||
AgentHeroHeading,
|
||||
type AgentQuickAction,
|
||||
AgentQuickActions,
|
||||
} from "@cline/ui";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AuroraBackground } from "@/components/ui/aurora-bg";
|
||||
import { useEffect } from "react";
|
||||
import { useWorkspace } from "@/contexts/workspace-context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { WelcomeWorkspaceControls } from "./welcome-workspace-controls";
|
||||
|
||||
interface QuickAction {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
const HERO_VERBS = ["build", "create", "fix", "know"] as const;
|
||||
const HERO_CYCLE_MS = 2600;
|
||||
|
||||
const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
|
||||
const DEFAULT_QUICK_ACTIONS: AgentQuickAction[] = [
|
||||
{
|
||||
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.",
|
||||
value: "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.",
|
||||
value: "Check this project for build errors and help me fix any failures.",
|
||||
},
|
||||
];
|
||||
|
||||
function HeroHeading() {
|
||||
const [verbIndex, setVerbIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
if (media.matches) return;
|
||||
const interval = setInterval(() => {
|
||||
setVerbIndex((prev) => (prev + 1) % HERO_VERBS.length);
|
||||
}, HERO_CYCLE_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const verb = HERO_VERBS[verbIndex];
|
||||
|
||||
return (
|
||||
<h1
|
||||
id="hero-header"
|
||||
className="text-balance text-left text-[clamp(2rem,3vw,2.6rem)] font-semibold leading-[1.12] tracking-tight text-foreground"
|
||||
>
|
||||
<span className="sr-only">What would you like to build?</span>
|
||||
<span aria-hidden="true">
|
||||
What would you like to{" "}
|
||||
{/* key remounts the word each cycle so the chars re-trigger their entrance */}
|
||||
<span key={verb}>
|
||||
{verb.split("").map((char, index) => (
|
||||
<span
|
||||
className="hero-word-char"
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: the word remounts via the parent key each cycle, so char position is a stable, non-reordering identity
|
||||
key={`${verb}-${index}`}
|
||||
style={{ animationDelay: `${index * 45}ms` }}
|
||||
>
|
||||
{char}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
?
|
||||
</span>
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
|
||||
export function WelcomeScreen({
|
||||
active,
|
||||
body,
|
||||
@@ -88,7 +41,7 @@ export function WelcomeScreen({
|
||||
body: ReactNode;
|
||||
composer: ReactNode;
|
||||
onStartChat: (prompt: string) => void;
|
||||
quickActions: QuickAction[];
|
||||
quickActions: AgentQuickAction[];
|
||||
gitBranch: string;
|
||||
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
|
||||
onSwitchGitBranch: (branch: string) => Promise<boolean>;
|
||||
@@ -116,7 +69,7 @@ export function WelcomeScreen({
|
||||
: "contents",
|
||||
)}
|
||||
>
|
||||
{active ? <AuroraBackground /> : null}
|
||||
{active ? <AgentAurora /> : null}
|
||||
<div
|
||||
className={cn(
|
||||
active
|
||||
@@ -133,7 +86,7 @@ export function WelcomeScreen({
|
||||
>
|
||||
{active ? (
|
||||
<>
|
||||
<HeroHeading />
|
||||
<AgentHeroHeading />
|
||||
|
||||
<div className="mt-11 flex min-w-0 items-center">
|
||||
<WelcomeWorkspaceControls
|
||||
@@ -166,28 +119,11 @@ export function WelcomeScreen({
|
||||
</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>
|
||||
<AgentQuickActions
|
||||
actions={actions}
|
||||
className="mt-11"
|
||||
onSelect={(action) => onStartChat(action.value)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { AgentAurora } from "@cline/ui";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
@@ -9,7 +10,6 @@ import {
|
||||
LogIn,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { AuroraBackground } from "@/components/ui/aurora-bg";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -696,7 +696,7 @@ export function OnboardingView({
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full items-center justify-center overflow-hidden bg-background p-6">
|
||||
<AuroraBackground />
|
||||
<AgentAurora />
|
||||
{step === "welcome" ? (
|
||||
<WelcomeStep onContinue={() => setStep("connect")} />
|
||||
) : step === "connect" ? (
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
// @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 {
|
||||
formatCompactTokens,
|
||||
paginationItems,
|
||||
SessionsView,
|
||||
} from "@/components/views/sessions/sessions-view";
|
||||
import type { SessionThread } from "@/hooks/use-session-history";
|
||||
import type { SessionHistoryItem } from "@/lib/session-history";
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
const thread: SessionThread = {
|
||||
id: "session-1",
|
||||
title: "help me rewrite this sql",
|
||||
codebase: "ai-data-suite",
|
||||
workspacePath: "/Users/dev/ai-data-suite",
|
||||
time: "1d",
|
||||
provider: "cline-pass",
|
||||
model: "cline-pass/glm-5.2-with-a-very-long-identifier",
|
||||
inputTokens: 13_837_938,
|
||||
outputTokens: 132_579,
|
||||
status: "completed",
|
||||
};
|
||||
|
||||
const session: SessionHistoryItem = {
|
||||
sessionId: thread.id,
|
||||
status: "completed",
|
||||
provider: thread.provider,
|
||||
model: thread.model,
|
||||
cwd: thread.workspacePath,
|
||||
workspaceRoot: thread.workspacePath,
|
||||
startedAt: new Date("2026-07-24T10:00:00Z").toISOString(),
|
||||
endedAt: new Date("2026-07-26T10:00:00Z").toISOString(),
|
||||
};
|
||||
|
||||
function renderView({
|
||||
openThread = vi.fn(),
|
||||
loadAllSessions = vi.fn(async () => true),
|
||||
loadOlderSessions = vi.fn(),
|
||||
mayHaveMoreSessions = false,
|
||||
threads = [thread],
|
||||
}: {
|
||||
openThread?: ReturnType<typeof vi.fn>;
|
||||
loadAllSessions?: ReturnType<typeof vi.fn>;
|
||||
loadOlderSessions?: ReturnType<typeof vi.fn>;
|
||||
mayHaveMoreSessions?: boolean;
|
||||
threads?: SessionThread[];
|
||||
} = {}) {
|
||||
const history = {
|
||||
deleteThread: vi.fn(),
|
||||
forkThread: vi.fn(),
|
||||
isLoadingHistory: false,
|
||||
isLoadingMore: false,
|
||||
loadAllSessions,
|
||||
loadOlderSessions,
|
||||
mayHaveMoreSessions,
|
||||
openThread,
|
||||
pendingAction: null,
|
||||
renameThread: vi.fn(),
|
||||
setThreadPinned: vi.fn(),
|
||||
sessionById: new Map(
|
||||
threads.map((item) => [item.id, { ...session, sessionId: item.id }]),
|
||||
),
|
||||
threads,
|
||||
};
|
||||
return {
|
||||
history,
|
||||
loadAllSessions,
|
||||
loadOlderSessions,
|
||||
openThread,
|
||||
render: () =>
|
||||
act(async () => {
|
||||
root.render(
|
||||
<SessionsView
|
||||
history={
|
||||
history as unknown as React.ComponentProps<
|
||||
typeof SessionsView
|
||||
>["history"]
|
||||
}
|
||||
/>,
|
||||
);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("formatCompactTokens", () => {
|
||||
it("abbreviates millions and thousands and leaves small counts alone", () => {
|
||||
expect(formatCompactTokens(13_837_938)).toBe("13.8m");
|
||||
expect(formatCompactTokens(132_579)).toBe("132.6k");
|
||||
expect(formatCompactTokens(17_218)).toBe("17.2k");
|
||||
expect(formatCompactTokens(11)).toBe("11");
|
||||
expect(formatCompactTokens(0)).toBe("0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SessionsView table", () => {
|
||||
it("labels the title and time columns", async () => {
|
||||
const view = renderView();
|
||||
await view.render();
|
||||
|
||||
const headers = Array.from(
|
||||
container.querySelectorAll("div > span:not(.sr-only)"),
|
||||
)
|
||||
.slice(0, 6)
|
||||
.map((node) => node.textContent);
|
||||
expect(headers).toEqual([
|
||||
"Title",
|
||||
"Workspace",
|
||||
"Model",
|
||||
"Tokens",
|
||||
"Cost",
|
||||
"Time",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows compact token counts and truncates the model to a fixed row height", async () => {
|
||||
const view = renderView();
|
||||
await view.render();
|
||||
|
||||
const row = container.querySelector<HTMLDivElement>('[role="button"]');
|
||||
expect(row?.textContent).toContain("13.8m/132.6k");
|
||||
const modelCell = Array.from(row?.children ?? []).find((node) =>
|
||||
node.textContent?.includes(thread.model),
|
||||
);
|
||||
expect(modelCell?.className).toContain("truncate");
|
||||
// Full value stays reachable on hover.
|
||||
expect(modelCell?.getAttribute("title")).toBe(
|
||||
`${thread.provider}:${thread.model}`,
|
||||
);
|
||||
expect(row?.parentElement?.className).toContain("h-14");
|
||||
expect(row?.parentElement?.className).not.toContain("min-h-14");
|
||||
});
|
||||
|
||||
it("marks favorited sessions with a star", async () => {
|
||||
const plain = renderView();
|
||||
await plain.render();
|
||||
expect(container.querySelector('[aria-label="Favorited"]')).toBeNull();
|
||||
|
||||
await act(async () => root.unmount());
|
||||
root = createRoot(container);
|
||||
|
||||
const favorited = renderView({
|
||||
threads: [{ ...thread, pinned: true }],
|
||||
});
|
||||
await favorited.render();
|
||||
expect(container.querySelector('[aria-label="Favorited"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("opens a session on click but not while text is selected", async () => {
|
||||
const view = renderView({});
|
||||
await view.render();
|
||||
|
||||
const row = container.querySelector<HTMLDivElement>('[role="button"]');
|
||||
expect(row).not.toBeNull();
|
||||
|
||||
vi.spyOn(window, "getSelection").mockReturnValue({
|
||||
toString: () => "rewrite this sql",
|
||||
} as unknown as Selection);
|
||||
await act(async () => {
|
||||
row?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(view.openThread).not.toHaveBeenCalled();
|
||||
|
||||
vi.spyOn(window, "getSelection").mockReturnValue({
|
||||
toString: () => "",
|
||||
} as unknown as Selection);
|
||||
await act(async () => {
|
||||
row?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(view.openThread).toHaveBeenCalledWith(thread.id);
|
||||
});
|
||||
|
||||
it("loads complete history before treating search results as exhaustive", async () => {
|
||||
const view = renderView({ mayHaveMoreSessions: true });
|
||||
await view.render();
|
||||
|
||||
const search = container.querySelector<HTMLInputElement>(
|
||||
'input[aria-label="Search sessions"]',
|
||||
);
|
||||
expect(search).not.toBeNull();
|
||||
await act(async () => {
|
||||
if (search) {
|
||||
const setValue = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
setValue?.call(search, "older match");
|
||||
search.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
|
||||
expect(view.loadAllSessions).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("loads complete history for filters and oldest-first sorting", async () => {
|
||||
const view = renderView({ mayHaveMoreSessions: true });
|
||||
await view.render();
|
||||
|
||||
const filterButton = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Filter sessions"]',
|
||||
);
|
||||
await act(async () => {
|
||||
filterButton?.dispatchEvent(
|
||||
new MouseEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(view.loadAllSessions).toHaveBeenCalledOnce();
|
||||
|
||||
view.loadAllSessions.mockClear();
|
||||
const sortButton = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Sort sessions"]',
|
||||
);
|
||||
await act(async () => {
|
||||
sortButton?.dispatchEvent(
|
||||
new MouseEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
const oldestItem = Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
||||
).find((item) => item.textContent === "Oldest first");
|
||||
expect(oldestItem).not.toBeUndefined();
|
||||
await act(async () => {
|
||||
oldestItem?.click();
|
||||
});
|
||||
|
||||
expect(view.loadAllSessions).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SessionsView pagination", () => {
|
||||
const manyThreads = Array.from({ length: 25 }, (_, index) => ({
|
||||
...thread,
|
||||
id: `session-${index}`,
|
||||
title: `Session ${index}`,
|
||||
}));
|
||||
|
||||
const rowTitles = () =>
|
||||
Array.from(container.querySelectorAll('[role="button"]')).map(
|
||||
(row) => row.querySelector("span > span:last-child")?.textContent,
|
||||
);
|
||||
|
||||
const clickButton = async (label: string) => {
|
||||
const button = container.querySelector<HTMLButtonElement>(
|
||||
`button[aria-label="${label}"]`,
|
||||
);
|
||||
expect(button).not.toBeNull();
|
||||
await act(async () => {
|
||||
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
};
|
||||
|
||||
const clickNext = () => clickButton("Next page");
|
||||
|
||||
it("shows ten sessions per page", async () => {
|
||||
const view = renderView({ threads: manyThreads });
|
||||
await view.render();
|
||||
|
||||
expect(rowTitles()).toHaveLength(10);
|
||||
expect(rowTitles()[0]).toBe("Session 0");
|
||||
expect(container.textContent).toContain("1-10 of 25");
|
||||
|
||||
await clickNext();
|
||||
expect(rowTitles()[0]).toBe("Session 10");
|
||||
expect(container.textContent).toContain("11-20 of 25");
|
||||
});
|
||||
|
||||
it("only asks the backend for older sessions at the last page", async () => {
|
||||
const view = renderView({
|
||||
threads: manyThreads,
|
||||
mayHaveMoreSessions: true,
|
||||
});
|
||||
await view.render();
|
||||
|
||||
await clickNext();
|
||||
await clickNext();
|
||||
expect(view.loadOlderSessions).not.toHaveBeenCalled();
|
||||
|
||||
await clickNext();
|
||||
expect(view.loadOlderSessions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stays on the last page when no older sessions come back", async () => {
|
||||
const view = renderView({
|
||||
threads: manyThreads,
|
||||
mayHaveMoreSessions: true,
|
||||
});
|
||||
await view.render();
|
||||
|
||||
await clickNext();
|
||||
await clickNext();
|
||||
await clickNext();
|
||||
|
||||
expect(container.textContent).toContain("21-25 of 25");
|
||||
expect(rowTitles()).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("jumps to a numbered page and back to the first page in one click", async () => {
|
||||
const view = renderView({ threads: manyThreads });
|
||||
await view.render();
|
||||
|
||||
const pageButtons = Array.from(
|
||||
container.querySelectorAll('button[aria-label^="Page "]'),
|
||||
).map((button) => button.textContent);
|
||||
expect(pageButtons).toEqual(["1", "2", "3"]);
|
||||
expect(container.textContent).not.toContain("Page 1 of");
|
||||
|
||||
await clickButton("Page 3");
|
||||
expect(rowTitles()[0]).toBe("Session 20");
|
||||
expect(
|
||||
container
|
||||
.querySelector('button[aria-label="Page 3"]')
|
||||
?.getAttribute("aria-current"),
|
||||
).toBe("page");
|
||||
|
||||
await clickButton("First page");
|
||||
expect(rowTitles()[0]).toBe("Session 0");
|
||||
expect(
|
||||
container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="First page"]',
|
||||
)?.disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("paginationItems", () => {
|
||||
it("lists every page while the pager is short", () => {
|
||||
expect(paginationItems(1, 3)).toEqual([1, 2, 3]);
|
||||
expect(paginationItems(4, 7)).toEqual([1, 2, 3, 4, 5, 6, 7]);
|
||||
});
|
||||
|
||||
it("keeps first, last and a window around the current page", () => {
|
||||
expect(paginationItems(1, 12)).toEqual([1, 2, 3, 4, 5, "gap-end", 12]);
|
||||
expect(paginationItems(6, 12)).toEqual([
|
||||
1,
|
||||
"gap-start",
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
"gap-end",
|
||||
12,
|
||||
]);
|
||||
expect(paginationItems(12, 12)).toEqual([1, "gap-start", 8, 9, 10, 11, 12]);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,9 @@ import { SessionStatus } from "@cline/ui";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
Filter,
|
||||
Folder,
|
||||
GitFork,
|
||||
@@ -11,10 +14,11 @@ import {
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Search,
|
||||
Star,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { type CSSProperties, useMemo, useState } from "react";
|
||||
import { type CSSProperties, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -54,6 +58,8 @@ type SessionsViewProps = {
|
||||
history: UseSessionHistoryResult;
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
function modelLabel(thread: SessionThread): string {
|
||||
if (thread.provider && thread.model) {
|
||||
return `${thread.provider}:${thread.model}`;
|
||||
@@ -61,11 +67,61 @@ function modelLabel(thread: SessionThread): string {
|
||||
return thread.model || thread.provider || "No model";
|
||||
}
|
||||
|
||||
export function formatCompactTokens(value: number): string {
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
return "0";
|
||||
}
|
||||
if (value >= 1_000_000) {
|
||||
return `${(value / 1_000_000).toFixed(1)}m`;
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
return `${(value / 1_000).toFixed(1)}k`;
|
||||
}
|
||||
return `${value}`;
|
||||
}
|
||||
|
||||
const MAX_PAGE_BUTTONS = 7;
|
||||
|
||||
/**
|
||||
* Page buttons for a 1-indexed pager: every page while the list is short, and
|
||||
* first/last plus a window around the current page once it grows.
|
||||
*/
|
||||
export function paginationItems(
|
||||
currentPage: number,
|
||||
pageCount: number,
|
||||
): Array<number | "gap-start" | "gap-end"> {
|
||||
if (pageCount <= MAX_PAGE_BUTTONS) {
|
||||
return Array.from({ length: pageCount }, (_, index) => index + 1);
|
||||
}
|
||||
const windowStart = Math.max(
|
||||
2,
|
||||
Math.min(currentPage - 1, pageCount - MAX_PAGE_BUTTONS + 3),
|
||||
);
|
||||
const windowEnd = Math.min(
|
||||
pageCount - 1,
|
||||
Math.max(currentPage + 1, MAX_PAGE_BUTTONS - 2),
|
||||
);
|
||||
const items: Array<number | "gap-start" | "gap-end"> = [1];
|
||||
if (windowStart > 2) {
|
||||
items.push("gap-start");
|
||||
}
|
||||
for (let page = windowStart; page <= windowEnd; page += 1) {
|
||||
items.push(page);
|
||||
}
|
||||
if (windowEnd < pageCount - 1) {
|
||||
items.push("gap-end");
|
||||
}
|
||||
items.push(pageCount);
|
||||
return items;
|
||||
}
|
||||
|
||||
function tokensLabel(thread: SessionThread): string {
|
||||
if (thread.inputTokens == null && thread.outputTokens == null) {
|
||||
return "-";
|
||||
}
|
||||
return `${thread.inputTokens ?? 0}/${thread.outputTokens ?? 0}`;
|
||||
const input = formatCompactTokens(thread.inputTokens ?? 0);
|
||||
const output = formatCompactTokens(thread.outputTokens ?? 0);
|
||||
return `${input}/${output}`;
|
||||
}
|
||||
|
||||
function sessionFilterDetails(
|
||||
@@ -75,6 +131,7 @@ function sessionFilterDetails(
|
||||
const workspacePath = session?.workspaceRoot || session?.cwd || "";
|
||||
const workspace = workspacePath ? basenamePath(workspacePath) : "";
|
||||
return [
|
||||
thread.pinned ? "favorite:yes" : undefined,
|
||||
workspace ? `workspace:${workspace}` : undefined,
|
||||
thread.status ? `status:${thread.status}` : undefined,
|
||||
thread.provider ? `provider:${thread.provider}` : undefined,
|
||||
@@ -101,6 +158,22 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
const [deleteCandidate, setDeleteCandidate] = useState<SessionThread | null>(
|
||||
null,
|
||||
);
|
||||
const [page, setPage] = useState(0);
|
||||
const requiresCompleteHistory =
|
||||
query.trim().length > 0 ||
|
||||
sessionFilters.length > 0 ||
|
||||
sortDirection === "oldest";
|
||||
|
||||
useEffect(() => {
|
||||
if (!requiresCompleteHistory || !history.mayHaveMoreSessions) {
|
||||
return;
|
||||
}
|
||||
void history.loadAllSessions();
|
||||
}, [
|
||||
history.loadAllSessions,
|
||||
history.mayHaveMoreSessions,
|
||||
requiresCompleteHistory,
|
||||
]);
|
||||
|
||||
const filterOptions = useMemo(
|
||||
() =>
|
||||
@@ -154,6 +227,45 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
sortDirection,
|
||||
]);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(filteredThreads.length / PAGE_SIZE));
|
||||
const currentPage = Math.min(page, pageCount - 1);
|
||||
const pageStart = currentPage * PAGE_SIZE;
|
||||
const visibleThreads = useMemo(
|
||||
() => filteredThreads.slice(pageStart, pageStart + PAGE_SIZE),
|
||||
[filteredThreads, pageStart],
|
||||
);
|
||||
const canGoNext =
|
||||
currentPage + 1 < pageCount ||
|
||||
(history.mayHaveMoreSessions && !requiresCompleteHistory);
|
||||
|
||||
// Snap back when a page disappears (filters changed, or "next" asked the
|
||||
// backend for older sessions and there were none left).
|
||||
useEffect(() => {
|
||||
setPage((current) => Math.min(current, pageCount - 1));
|
||||
}, [pageCount]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: restart paging whenever the result set changes
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
}, [query, sessionFilters, sortDirection]);
|
||||
|
||||
const goToNextPage = async () => {
|
||||
const nextPage = currentPage + 1;
|
||||
if (
|
||||
nextPage >= pageCount &&
|
||||
history.mayHaveMoreSessions &&
|
||||
!requiresCompleteHistory
|
||||
) {
|
||||
// Only page boundaries hit the backend; the mount fetch stays small.
|
||||
// Stay put when the fetch fails so the user keeps the page they can
|
||||
// see and the same click retries.
|
||||
if (!(await history.loadOlderSessions())) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
setPage(nextPage);
|
||||
};
|
||||
|
||||
const toggleFilter = (detail: string, checked: boolean) => {
|
||||
setSessionFilters((current) => {
|
||||
if (checked) {
|
||||
@@ -163,6 +275,17 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
});
|
||||
};
|
||||
|
||||
const openRow = (thread: SessionThread) => {
|
||||
if (history.pendingAction?.sessionId === thread.id) {
|
||||
return;
|
||||
}
|
||||
// Don't open the session when the click only finished a text selection.
|
||||
if (window.getSelection()?.toString()) {
|
||||
return;
|
||||
}
|
||||
history.openThread(thread.id);
|
||||
};
|
||||
|
||||
const startRename = (thread: SessionThread) => {
|
||||
setEditingSessionId(thread.id);
|
||||
setEditingTitle(thread.title);
|
||||
@@ -234,7 +357,17 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu
|
||||
onOpenChange={(open) => {
|
||||
// Filter choices are derived from the loaded rows, so
|
||||
// complete the history as soon as the user opens this
|
||||
// menu. This keeps both the options and their results
|
||||
// global rather than limited to the newest batch.
|
||||
if (open && history.mayHaveMoreSessions) {
|
||||
void history.loadAllSessions();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label="Filter sessions"
|
||||
@@ -285,13 +418,13 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
|
||||
<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>
|
||||
<div className="grid grid-cols-[minmax(14rem,1.35fr)_minmax(9rem,0.8fr)_minmax(12rem,1fr)_7rem_5rem_6rem_1.75rem] gap-x-4 bg-muted/40 px-4 py-3 text-sm font-medium text-muted-foreground">
|
||||
<span>Title</span>
|
||||
<span>Workspace</span>
|
||||
<span>Model</span>
|
||||
<span>Tokens</span>
|
||||
<span>Cost</span>
|
||||
<span>Updated</span>
|
||||
<span>Time</span>
|
||||
<span className="sr-only">Actions</span>
|
||||
</div>
|
||||
<div>
|
||||
@@ -308,7 +441,7 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
: "No sessions match the current filters."}
|
||||
</div>
|
||||
) : null}
|
||||
{filteredThreads.map((thread) => {
|
||||
{visibleThreads.map((thread) => {
|
||||
const session = history.sessionById.get(thread.id);
|
||||
const isEditing = editingSessionId === thread.id;
|
||||
const isPending = history.pendingAction?.sessionId === thread.id;
|
||||
@@ -322,7 +455,9 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid min-h-14 grid-cols-[minmax(14rem,1.35fr)_minmax(9rem,0.8fr)_minmax(12rem,1fr)_7rem_5rem_6rem_2.5rem] items-center gap-x-4 border-t px-4 py-3 text-sm transition-colors",
|
||||
// Fixed height: every row is the same size so the table
|
||||
// never reflows as long values wrap or hydrate in.
|
||||
"grid h-14 grid-cols-[minmax(14rem,1.35fr)_minmax(9rem,0.8fr)_minmax(12rem,1fr)_7rem_5rem_6rem_1.75rem] items-center gap-x-4 border-t px-4 text-sm transition-colors",
|
||||
activeSessionId === thread.id
|
||||
? "bg-accent/50"
|
||||
: "hover:bg-accent/30",
|
||||
@@ -381,7 +516,10 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<span className="truncate text-muted-foreground">
|
||||
<span
|
||||
className="truncate text-muted-foreground"
|
||||
title={modelLabel(thread)}
|
||||
>
|
||||
{modelLabel(thread)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
@@ -395,20 +533,27 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
</span>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
className="col-span-6 grid cursor-pointer select-text grid-cols-[minmax(14rem,1.35fr)_minmax(9rem,0.8fr)_minmax(12rem,1fr)_7rem_5rem_6rem] items-center gap-x-4 border-0 bg-transparent p-0 text-left font-inherit text-inherit focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-default"
|
||||
disabled={Boolean(pendingKind)}
|
||||
onClick={() => {
|
||||
if (pendingKind) {
|
||||
// A native <button> suppresses drag-to-select, so the row is a
|
||||
// plain container with button semantics: the cells stay
|
||||
// selectable and a click that ends a selection does not open
|
||||
// the session.
|
||||
// biome-ignore lint/a11y/useSemanticElements: buttons are not text-selectable
|
||||
<div
|
||||
aria-disabled={Boolean(pendingKind)}
|
||||
className={cn(
|
||||
"col-span-6 grid select-text grid-cols-[minmax(14rem,1.35fr)_minmax(9rem,0.8fr)_minmax(12rem,1fr)_7rem_5rem_6rem] items-center gap-x-4 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
pendingKind ? "cursor-default" : "cursor-pointer",
|
||||
)}
|
||||
onClick={() => openRow(thread)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") {
|
||||
return;
|
||||
}
|
||||
// Don't open the session when the user is selecting text.
|
||||
if (window.getSelection()?.toString()) {
|
||||
return;
|
||||
}
|
||||
history.openThread(thread.id);
|
||||
event.preventDefault();
|
||||
openRow(thread);
|
||||
}}
|
||||
type="button"
|
||||
role="button"
|
||||
tabIndex={pendingKind ? -1 : 0}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-3 font-semibold">
|
||||
<span className="sr-only">Open session: </span>
|
||||
@@ -425,6 +570,12 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
tone={sessionStatusTone(thread.status)}
|
||||
/>
|
||||
<span className="truncate">{thread.title}</span>
|
||||
{thread.pinned ? (
|
||||
<Star
|
||||
aria-label="Favorited"
|
||||
className="size-3.5 shrink-0 fill-current text-muted-foreground"
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="flex min-w-0 items-center gap-2 text-muted-foreground">
|
||||
<Folder className="size-3.5 shrink-0" />
|
||||
@@ -432,19 +583,22 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
{workspace ? basenamePath(workspace) : "No workspace"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="truncate text-muted-foreground">
|
||||
<span
|
||||
className="truncate text-muted-foreground"
|
||||
title={modelLabel(thread)}
|
||||
>
|
||||
{modelLabel(thread)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
<span className="truncate text-muted-foreground">
|
||||
{tokensLabel(thread)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
<span className="truncate text-muted-foreground">
|
||||
{formatCostUsd(thread.totalCostUsd) ?? "-"}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
<span className="truncate text-muted-foreground">
|
||||
{updated || thread.time}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<DropdownMenu>
|
||||
@@ -463,6 +617,22 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={6}>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
void history.setThreadPinned(
|
||||
thread.id,
|
||||
!thread.pinned,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Star
|
||||
className={cn(
|
||||
"size-4",
|
||||
thread.pinned && "fill-current",
|
||||
)}
|
||||
/>
|
||||
{thread.pinned ? "Unfavorite" : "Favorite"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => startRename(thread)}>
|
||||
<Pencil className="size-4" />
|
||||
Rename
|
||||
@@ -487,22 +657,81 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{history.mayHaveMoreSessions ? (
|
||||
<div className="border-t px-4 py-3">
|
||||
<Button
|
||||
className="h-8 rounded-md px-3 text-xs"
|
||||
disabled={history.isLoadingMore}
|
||||
onClick={() =>
|
||||
void history.loadMoreSessions(history.threads.length + 100)
|
||||
}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{history.isLoadingMore ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : null}
|
||||
Load more
|
||||
</Button>
|
||||
{filteredThreads.length > 0 ? (
|
||||
<div className="flex items-center justify-between gap-4 border-t px-4 py-3 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{`${pageStart + 1}-${pageStart + visibleThreads.length} of ${filteredThreads.length}`}
|
||||
{history.mayHaveMoreSessions ? "+" : ""}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
aria-label="First page"
|
||||
className="h-8 rounded-md px-2.5"
|
||||
disabled={currentPage === 0 || history.isLoadingMore}
|
||||
onClick={() => setPage(0)}
|
||||
size="sm"
|
||||
title="First page"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<ChevronsLeft className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Previous page"
|
||||
className="h-8 rounded-md px-2.5"
|
||||
disabled={currentPage === 0 || history.isLoadingMore}
|
||||
onClick={() => setPage(currentPage - 1)}
|
||||
size="sm"
|
||||
title="Previous page"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
{paginationItems(currentPage + 1, pageCount).map((item) =>
|
||||
typeof item === "number" ? (
|
||||
<Button
|
||||
aria-current={
|
||||
item === currentPage + 1 ? "page" : undefined
|
||||
}
|
||||
aria-label={`Page ${item}`}
|
||||
className="h-8 min-w-8 rounded-md px-2 tabular-nums"
|
||||
disabled={history.isLoadingMore}
|
||||
key={item}
|
||||
onClick={() => setPage(item - 1)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={item === currentPage + 1 ? "default" : "ghost"}
|
||||
>
|
||||
{item}
|
||||
</Button>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="px-1 text-muted-foreground"
|
||||
key={item}
|
||||
>
|
||||
...
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
<Button
|
||||
aria-label="Next page"
|
||||
className="h-8 rounded-md px-2.5"
|
||||
disabled={!canGoNext || history.isLoadingMore}
|
||||
onClick={() => void goToNextPage()}
|
||||
size="sm"
|
||||
title="Next page"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{history.isLoadingMore ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<ChevronRight className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -87,6 +87,19 @@ export type ChatApiResult = {
|
||||
messages?: unknown[];
|
||||
};
|
||||
|
||||
export type ChatSessionCommandResponse = {
|
||||
sessionId?: string;
|
||||
cwd?: string;
|
||||
workspaceRoot?: string;
|
||||
result?: ChatApiResult;
|
||||
ok?: boolean;
|
||||
queued?: boolean;
|
||||
promptsInQueue?: PromptInQueue[];
|
||||
prompt?: PromptInQueue;
|
||||
updated?: boolean;
|
||||
removed?: boolean;
|
||||
};
|
||||
|
||||
export type ChatWsResponseEvent = {
|
||||
type: "chat_response";
|
||||
requestId: string;
|
||||
|
||||
@@ -104,6 +104,16 @@ describe("useChatSession", () => {
|
||||
config: expect.objectContaining({ cwd: "", workspaceRoot: "" }),
|
||||
}),
|
||||
});
|
||||
expect(invokeMock).toHaveBeenCalledWith(
|
||||
"chat_session_command",
|
||||
{
|
||||
request: expect.objectContaining({
|
||||
action: "send",
|
||||
prompt: "Start the task",
|
||||
}),
|
||||
},
|
||||
{ timeoutMs: null },
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves server validation errors", async () => {
|
||||
@@ -465,6 +475,56 @@ describe("useChatSession", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps live stream timestamps in milliseconds", async () => {
|
||||
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;
|
||||
if (request?.action === "start") {
|
||||
return { sessionId: request.config?.sessionId };
|
||||
}
|
||||
if (request?.action === "send") {
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await current.sendPrompt("Think about this");
|
||||
});
|
||||
const chatEventHandler = subscribeMock.mock.calls.find(
|
||||
([eventName]) => eventName === "chat_event",
|
||||
)?.[1] as ((payload: unknown) => void) | undefined;
|
||||
const userMessage = current.messages.find(
|
||||
(message) => message.role === "user",
|
||||
);
|
||||
expect(chatEventHandler).toBeDefined();
|
||||
expect(userMessage).toBeDefined();
|
||||
const thinkingTimestamp = (userMessage?.createdAt ?? Date.now()) + 5_000;
|
||||
|
||||
await act(async () => {
|
||||
chatEventHandler?.({
|
||||
sessionId: current.sessionId,
|
||||
stream: "chat_reasoning",
|
||||
chunk: JSON.stringify({ text: "Considering the request." }),
|
||||
ts: thinkingTimestamp,
|
||||
index: 42,
|
||||
});
|
||||
});
|
||||
|
||||
expect(
|
||||
current.messages.find((message) => message.role === "assistant")
|
||||
?.createdAt,
|
||||
).toBe(thinkingTimestamp);
|
||||
});
|
||||
|
||||
it("returns to a completed status when a queued turn finishes via chat_done", async () => {
|
||||
invokeMock.mockImplementation(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
AgentChunkEvent,
|
||||
AskQuestionRequestItem,
|
||||
ChatApiResult,
|
||||
ChatSessionCommandResponse,
|
||||
ChatSessionHookEvent,
|
||||
ChatTransportState,
|
||||
CoreLogChunk,
|
||||
@@ -132,9 +133,7 @@ function sortMessagesChronologically(messages: ChatMessage[]): ChatMessage[] {
|
||||
}
|
||||
|
||||
function chunkCreatedAt(payload: AgentChunkEvent): number {
|
||||
const ts = payload.ts || Date.now();
|
||||
const index = payload.index ?? 0;
|
||||
return ts * 1000 + index;
|
||||
return payload.ts || Date.now();
|
||||
}
|
||||
|
||||
function mergeHydratedMessagesWithLive(options: {
|
||||
@@ -331,18 +330,18 @@ export function useChatSession() {
|
||||
// ---- Data fetching ----
|
||||
|
||||
const postSession = useCallback(async (body: Record<string, unknown>) => {
|
||||
return await desktopClient.invoke<{
|
||||
sessionId?: string;
|
||||
cwd?: string;
|
||||
workspaceRoot?: string;
|
||||
result?: ChatApiResult;
|
||||
ok?: boolean;
|
||||
queued?: boolean;
|
||||
promptsInQueue?: PromptInQueue[];
|
||||
prompt?: PromptInQueue;
|
||||
updated?: boolean;
|
||||
removed?: boolean;
|
||||
}>("chat_session_command", { request: body });
|
||||
const request = { request: body };
|
||||
if (body.action === "send") {
|
||||
return await desktopClient.invoke<ChatSessionCommandResponse>(
|
||||
"chat_session_command",
|
||||
request,
|
||||
{ timeoutMs: null },
|
||||
);
|
||||
}
|
||||
return await desktopClient.invoke<ChatSessionCommandResponse>(
|
||||
"chat_session_command",
|
||||
request,
|
||||
);
|
||||
}, []);
|
||||
|
||||
const refreshPromptsInQueue = useCallback(
|
||||
@@ -1938,44 +1937,50 @@ export function useChatSession() {
|
||||
],
|
||||
);
|
||||
|
||||
const forkSession = useCallback(async (): Promise<{
|
||||
newSessionId: string;
|
||||
forkedFromSessionId: string;
|
||||
messages: ChatMessage[];
|
||||
}> => {
|
||||
const activeSessionId = activeSessionIdRef.current;
|
||||
if (!activeSessionId) {
|
||||
throw new Error("No active session to fork.");
|
||||
}
|
||||
if (BUSY_STATUSES.has(status)) {
|
||||
throw new Error("Wait for the current turn to finish before forking.");
|
||||
}
|
||||
const payload = (await postSession({
|
||||
action: "fork",
|
||||
sessionId: activeSessionId,
|
||||
config,
|
||||
})) as {
|
||||
sessionId?: string;
|
||||
forkedFromSessionId?: string;
|
||||
messages?: ChatMessage[];
|
||||
};
|
||||
const newSessionId =
|
||||
typeof payload.sessionId === "string" ? payload.sessionId.trim() : "";
|
||||
if (!newSessionId) {
|
||||
throw new Error("Fork did not return a new session id.");
|
||||
}
|
||||
const forkedFromSessionId =
|
||||
typeof payload.forkedFromSessionId === "string"
|
||||
? payload.forkedFromSessionId
|
||||
: activeSessionId;
|
||||
const nextMessages = Array.isArray(payload.messages)
|
||||
? (payload.messages as ChatMessage[])
|
||||
: await desktopClient.invoke<ChatMessage[]>("read_session_messages", {
|
||||
sessionId: newSessionId,
|
||||
maxMessages: MAX_MESSAGES,
|
||||
});
|
||||
return { newSessionId, forkedFromSessionId, messages: nextMessages };
|
||||
}, [config, postSession, status]);
|
||||
const forkSession = useCallback(
|
||||
async (options?: {
|
||||
beforeRunCount?: number;
|
||||
}): Promise<{
|
||||
newSessionId: string;
|
||||
forkedFromSessionId: string;
|
||||
messages: ChatMessage[];
|
||||
}> => {
|
||||
const activeSessionId = activeSessionIdRef.current;
|
||||
if (!activeSessionId) {
|
||||
throw new Error("No active session to fork.");
|
||||
}
|
||||
if (BUSY_STATUSES.has(status)) {
|
||||
throw new Error("Wait for the current turn to finish before forking.");
|
||||
}
|
||||
const payload = (await postSession({
|
||||
action: "fork",
|
||||
sessionId: activeSessionId,
|
||||
config,
|
||||
forkBeforeRunCount: options?.beforeRunCount,
|
||||
})) as {
|
||||
sessionId?: string;
|
||||
forkedFromSessionId?: string;
|
||||
messages?: ChatMessage[];
|
||||
};
|
||||
const newSessionId =
|
||||
typeof payload.sessionId === "string" ? payload.sessionId.trim() : "";
|
||||
if (!newSessionId) {
|
||||
throw new Error("Fork did not return a new session id.");
|
||||
}
|
||||
const forkedFromSessionId =
|
||||
typeof payload.forkedFromSessionId === "string"
|
||||
? payload.forkedFromSessionId
|
||||
: activeSessionId;
|
||||
const nextMessages = Array.isArray(payload.messages)
|
||||
? (payload.messages as ChatMessage[])
|
||||
: await desktopClient.invoke<ChatMessage[]>("read_session_messages", {
|
||||
sessionId: newSessionId,
|
||||
maxMessages: MAX_MESSAGES,
|
||||
});
|
||||
return { newSessionId, forkedFromSessionId, messages: nextMessages };
|
||||
},
|
||||
[config, postSession, status],
|
||||
);
|
||||
|
||||
const steerPromptInQueue = useCallback(
|
||||
async (promptId: string) => {
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
// @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 { useSessionHistory } from "./use-session-history";
|
||||
|
||||
const { invokeMock, subscribeMock } = vi.hoisted(() => ({
|
||||
invokeMock: vi.fn(),
|
||||
subscribeMock: vi.fn(() => () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/desktop-client", () => ({
|
||||
desktopClient: {
|
||||
invoke: invokeMock,
|
||||
subscribe: subscribeMock,
|
||||
},
|
||||
}));
|
||||
|
||||
type SessionHistoryHook = ReturnType<typeof useSessionHistory>;
|
||||
type PendingList = {
|
||||
limit: number;
|
||||
resolve: (rows: unknown[]) => void;
|
||||
reject: (error: Error) => void;
|
||||
};
|
||||
|
||||
function sessionRow(sessionId: string) {
|
||||
return {
|
||||
sessionId,
|
||||
status: "completed",
|
||||
provider: "cline",
|
||||
model: "glm-5.2",
|
||||
cwd: "/workspace",
|
||||
workspaceRoot: "/workspace",
|
||||
startedAt: "2026-07-20T10:00:00.000Z",
|
||||
endedAt: "2026-07-20T11:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let current: SessionHistoryHook;
|
||||
let pendingLists: PendingList[];
|
||||
|
||||
function HookHarness() {
|
||||
current = useSessionHistory({});
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Runs queued timers and lets the resulting promise chains settle. */
|
||||
async function flush(ms = 1) {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(ms);
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
vi.useFakeTimers();
|
||||
pendingLists = [];
|
||||
invokeMock.mockReset();
|
||||
subscribeMock.mockClear();
|
||||
invokeMock.mockImplementation(
|
||||
async (command: string, args?: { limit?: number }) => {
|
||||
if (command === "list_discovered_sessions") {
|
||||
return await new Promise<unknown[]>((resolve, reject) => {
|
||||
pendingLists.push({ limit: args?.limit ?? 0, resolve, reject });
|
||||
});
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("useSessionHistory refresh coalescing", () => {
|
||||
it("reuses an in-flight refresh that already covers the requested limit", async () => {
|
||||
await act(async () => {
|
||||
root.render(<HookHarness />);
|
||||
});
|
||||
await flush();
|
||||
expect(pendingLists).toHaveLength(1);
|
||||
expect(pendingLists[0].limit).toBe(50);
|
||||
|
||||
let second: Promise<boolean> | undefined;
|
||||
await act(async () => {
|
||||
second = current.refreshSessions();
|
||||
});
|
||||
expect(pendingLists).toHaveLength(1);
|
||||
|
||||
await act(async () => {
|
||||
pendingLists[0].resolve([]);
|
||||
await second;
|
||||
});
|
||||
});
|
||||
|
||||
it("does not let an in-flight smaller refresh satisfy a load-more", async () => {
|
||||
await act(async () => {
|
||||
root.render(<HookHarness />);
|
||||
});
|
||||
await flush();
|
||||
expect(pendingLists).toHaveLength(1);
|
||||
expect(pendingLists[0].limit).toBe(50);
|
||||
|
||||
// Click "next" while the periodic refresh is still running.
|
||||
let loadMore: Promise<boolean> | undefined;
|
||||
await act(async () => {
|
||||
loadMore = current.loadMoreSessions(100);
|
||||
});
|
||||
|
||||
// The in-flight request only asked for 50 rows, so it must not be
|
||||
// reused: the larger batch has to be requested before load-more resolves.
|
||||
await act(async () => {
|
||||
pendingLists[0].resolve([]);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(pendingLists).toHaveLength(2);
|
||||
expect(pendingLists[1].limit).toBe(100);
|
||||
|
||||
let settled = false;
|
||||
void loadMore?.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(settled).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
pendingLists[1].resolve([]);
|
||||
await loadMore;
|
||||
});
|
||||
expect(pendingLists).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSessionHistory failed refresh", () => {
|
||||
async function renderWithSessions() {
|
||||
await act(async () => {
|
||||
root.render(<HookHarness />);
|
||||
});
|
||||
await flush();
|
||||
await act(async () => {
|
||||
pendingLists[0].resolve(
|
||||
Array.from({ length: 50 }, (_, index) =>
|
||||
sessionRow(`session-${index}`),
|
||||
),
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
it("keeps the loaded history when the list request fails", async () => {
|
||||
await renderWithSessions();
|
||||
expect(current.sessions).toHaveLength(50);
|
||||
expect(current.mayHaveMoreSessions).toBe(true);
|
||||
|
||||
let loadMore: Promise<boolean> | undefined;
|
||||
await act(async () => {
|
||||
loadMore = current.loadMoreSessions(100);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(pendingLists[1].limit).toBe(100);
|
||||
|
||||
let loaded: boolean | undefined;
|
||||
await act(async () => {
|
||||
pendingLists[1].reject(new Error("transport closed"));
|
||||
loaded = await loadMore;
|
||||
});
|
||||
|
||||
// A rejected request must not read as "no sessions": the list stays put
|
||||
// and the backend is still considered to have more.
|
||||
expect(loaded).toBe(false);
|
||||
expect(current.sessions).toHaveLength(50);
|
||||
expect(current.threads).toHaveLength(50);
|
||||
expect(current.mayHaveMoreSessions).toBe(true);
|
||||
});
|
||||
|
||||
it("does not lower a limit an overlapping call already raised", async () => {
|
||||
await renderWithSessions();
|
||||
|
||||
// Two "next page" clicks overlap: the first expands to 100 and is still
|
||||
// in flight when the second expands to 150.
|
||||
let first: Promise<boolean> | undefined;
|
||||
let second: Promise<boolean> | undefined;
|
||||
await act(async () => {
|
||||
first = current.loadMoreSessions(100);
|
||||
await Promise.resolve();
|
||||
second = current.loadMoreSessions(150);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(pendingLists).toHaveLength(2);
|
||||
expect(pendingLists[1].limit).toBe(100);
|
||||
|
||||
// The 100-row request fails. Rolling the shared limit back to 50 here
|
||||
// would make the waiting call fetch 50 rows and still report success.
|
||||
await act(async () => {
|
||||
pendingLists[1].reject(new Error("transport closed"));
|
||||
expect(await first).toBe(false);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(pendingLists).toHaveLength(3);
|
||||
expect(pendingLists[2].limit).toBe(150);
|
||||
await act(async () => {
|
||||
pendingLists[2].resolve(
|
||||
Array.from({ length: 150 }, (_, index) =>
|
||||
sessionRow(`session-${index}`),
|
||||
),
|
||||
);
|
||||
expect(await second).toBe(true);
|
||||
});
|
||||
expect(current.sessions).toHaveLength(150);
|
||||
});
|
||||
|
||||
it("retries the oldest unfetched batch when overlapping calls both fail", async () => {
|
||||
await renderWithSessions();
|
||||
|
||||
let first: Promise<boolean> | undefined;
|
||||
let second: Promise<boolean> | undefined;
|
||||
await act(async () => {
|
||||
first = current.loadMoreSessions(100);
|
||||
await Promise.resolve();
|
||||
second = current.loadMoreSessions(150);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
pendingLists[1].reject(new Error("transport closed"));
|
||||
expect(await first).toBe(false);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
pendingLists[2].reject(new Error("transport closed"));
|
||||
expect(await second).toBe(false);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Neither batch landed, so the next attempt must go back to the first
|
||||
// unfetched one (100) rather than resuming from a limit that was only
|
||||
// ever requested.
|
||||
await act(async () => {
|
||||
const retry = current.loadOlderSessions();
|
||||
await Promise.resolve();
|
||||
pendingLists[3].resolve([]);
|
||||
await retry;
|
||||
});
|
||||
expect(pendingLists[3].limit).toBe(100);
|
||||
});
|
||||
|
||||
it("retries the same batch after a failure instead of skipping it", async () => {
|
||||
await renderWithSessions();
|
||||
|
||||
await act(async () => {
|
||||
const attempt = current.loadMoreSessions(100);
|
||||
await Promise.resolve();
|
||||
pendingLists[1].reject(new Error("transport closed"));
|
||||
await attempt;
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const retry = current.loadOlderSessions();
|
||||
await Promise.resolve();
|
||||
pendingLists[2].resolve([]);
|
||||
await retry;
|
||||
});
|
||||
expect(pendingLists[2].limit).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSessionHistory complete history loading", () => {
|
||||
it("expands requests until the backend returns fewer rows than requested", async () => {
|
||||
await act(async () => {
|
||||
root.render(<HookHarness />);
|
||||
});
|
||||
await flush();
|
||||
await act(async () => {
|
||||
pendingLists[0].resolve(
|
||||
Array.from({ length: 50 }, (_, index) =>
|
||||
sessionRow(`session-${index}`),
|
||||
),
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
let complete: Promise<boolean> | undefined;
|
||||
await act(async () => {
|
||||
complete = current.loadAllSessions();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(pendingLists[1].limit).toBe(100);
|
||||
|
||||
await act(async () => {
|
||||
pendingLists[1].resolve(
|
||||
Array.from({ length: 100 }, (_, index) =>
|
||||
sessionRow(`session-${index}`),
|
||||
),
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(pendingLists[2].limit).toBe(200);
|
||||
|
||||
await act(async () => {
|
||||
pendingLists[2].resolve(
|
||||
Array.from({ length: 120 }, (_, index) =>
|
||||
sessionRow(`session-${index}`),
|
||||
),
|
||||
);
|
||||
expect(await complete).toBe(true);
|
||||
});
|
||||
|
||||
expect(current.sessions).toHaveLength(120);
|
||||
expect(current.mayHaveMoreSessions).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -12,8 +12,10 @@ import type {
|
||||
} from "@/lib/session-history";
|
||||
import {
|
||||
getSessionMetadataGitBranch,
|
||||
getSessionMetadataPinned,
|
||||
getSessionMetadataTitle,
|
||||
getSessionSource,
|
||||
PINNED_METADATA_KEY,
|
||||
} from "@/lib/session-history";
|
||||
|
||||
type CliDiscoveredSession = Omit<SessionHistoryItem, "status"> & {
|
||||
@@ -92,7 +94,10 @@ export type UseSessionHistoryOptions = {
|
||||
) => void;
|
||||
};
|
||||
|
||||
const INITIAL_HISTORY_FETCH_LIMIT = 300;
|
||||
// Kept small on purpose: the sidebar shows 10 threads and the sessions view
|
||||
// pages 10 at a time, so the mount fetch (and every 12s poll after it) only
|
||||
// needs enough rows for the first few pages. Older pages are fetched on demand.
|
||||
const INITIAL_HISTORY_FETCH_LIMIT = 50;
|
||||
const HISTORY_REFRESH_INTERVAL_MS = 12_000;
|
||||
const MIN_EVENT_HISTORY_REFRESH_INTERVAL_MS = 2_000;
|
||||
const HISTORY_EVENT_REFRESH_DELAY_MS = 1_000;
|
||||
@@ -255,6 +260,7 @@ function toThread(session: SessionHistoryItem): SessionThread {
|
||||
model: session.model || "",
|
||||
gitBranch: getSessionMetadataGitBranch(session.metadata) || undefined,
|
||||
status: normalizeDiscoveredStatus(session.status, session.prompt),
|
||||
pinned: getSessionMetadataPinned(session.metadata),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -356,6 +362,8 @@ function areSessionsEquivalent(
|
||||
getSessionMetadataGitBranch(b.metadata) ||
|
||||
getSessionMetadataTitle(a.metadata) !==
|
||||
getSessionMetadataTitle(b.metadata) ||
|
||||
getSessionMetadataPinned(a.metadata) !==
|
||||
getSessionMetadataPinned(b.metadata) ||
|
||||
a.workspaceRoot !== b.workspaceRoot ||
|
||||
a.cwd !== b.cwd ||
|
||||
a.provider !== b.provider ||
|
||||
@@ -480,12 +488,18 @@ export function useSessionHistory({
|
||||
const [threads, setThreads] = useState<SessionThread[]>([]);
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [mayHaveMoreSessions, setMayHaveMoreSessions] = useState(false);
|
||||
const [pendingAction, setPendingAction] =
|
||||
useState<SessionPendingAction>(null);
|
||||
const [unreadSessionIds, setUnreadSessionIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const fetchLimitRef = useRef(INITIAL_HISTORY_FETCH_LIMIT);
|
||||
// Limit of the most recent refresh that actually returned sessions. Failed
|
||||
// attempts roll back to this rather than to a caller-local snapshot, which
|
||||
// may itself name a batch that was never fetched.
|
||||
const loadedLimitRef = useRef(0);
|
||||
const mayHaveMoreSessionsRef = useRef(false);
|
||||
const usageLoadingRef = useRef<Set<string>>(new Set());
|
||||
const usageHydratedStatusRef = useRef<Map<string, SessionHistoryStatus>>(
|
||||
new Map(),
|
||||
@@ -498,7 +512,9 @@ export function useSessionHistory({
|
||||
const threadsRef = useRef<SessionThread[]>([]);
|
||||
const refreshTimeoutRef = useRef<number | null>(null);
|
||||
const scheduledRefreshAtRef = useRef<number | null>(null);
|
||||
const refreshPromiseRef = useRef<Promise<void> | null>(null);
|
||||
const refreshPromiseRef = useRef<Promise<boolean> | null>(null);
|
||||
const refreshLimitRef = useRef(0);
|
||||
const loadAllPromiseRef = useRef<Promise<boolean> | null>(null);
|
||||
const lastRefreshStartedAtRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -524,13 +540,22 @@ export function useSessionHistory({
|
||||
}, [activeSessionId]);
|
||||
|
||||
const refreshSessions = useCallback(async () => {
|
||||
if (refreshPromiseRef.current) {
|
||||
return refreshPromiseRef.current;
|
||||
// Reuse an in-flight refresh only when it already asked for at least as
|
||||
// many sessions as we need now. "Load more" raises the limit and then
|
||||
// awaits a refresh; sharing a request that captured the smaller limit
|
||||
// would resolve without the larger batch ever being fetched.
|
||||
while (refreshPromiseRef.current) {
|
||||
const pending = refreshPromiseRef.current;
|
||||
if (refreshLimitRef.current >= fetchLimitRef.current) {
|
||||
return pending;
|
||||
}
|
||||
await pending;
|
||||
}
|
||||
|
||||
const refreshPromise = (async () => {
|
||||
const refreshPromise = (async (): Promise<boolean> => {
|
||||
lastRefreshStartedAtRef.current = Date.now();
|
||||
const limit = fetchLimitRef.current;
|
||||
refreshLimitRef.current = limit;
|
||||
// Only surface the loading state before anything has been fetched:
|
||||
// consumers only render it for an empty list, and toggling it on
|
||||
// every background poll re-rendered the whole app twice per refresh.
|
||||
@@ -540,7 +565,20 @@ export function useSessionHistory({
|
||||
try {
|
||||
const discovered = await desktopClient
|
||||
.invoke<CliDiscoveredSession[]>("list_discovered_sessions", { limit })
|
||||
.catch(() => []);
|
||||
.catch(() => null);
|
||||
// A rejected request is not an empty history. Treating it as one
|
||||
// would blank the list (the merge below is keyed off the response)
|
||||
// and mark the backend exhausted, hiding sessions that still exist
|
||||
// and disabling "load more" until some later poll happened to work.
|
||||
if (!Array.isArray(discovered)) {
|
||||
return false;
|
||||
}
|
||||
// Ask the raw response, not the filtered list: subagents and
|
||||
// sessions without a known model are dropped below, so a filtered
|
||||
// count under the limit does not mean the backend is exhausted.
|
||||
const hasMoreSessions = discovered.length >= limit;
|
||||
mayHaveMoreSessionsRef.current = hasMoreSessions;
|
||||
setMayHaveMoreSessions(hasMoreSessions);
|
||||
const topLevelSessions = discovered
|
||||
.map((session) => {
|
||||
const normalized: SessionHistoryItem = {
|
||||
@@ -610,21 +648,26 @@ export function useSessionHistory({
|
||||
});
|
||||
return areThreadsEquivalent(current, next) ? current : next;
|
||||
});
|
||||
loadedLimitRef.current = Math.max(loadedLimitRef.current, limit);
|
||||
return true;
|
||||
} catch {
|
||||
// Ignore in browser mode or when tauri command is unavailable.
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoadingHistory(false);
|
||||
}
|
||||
})();
|
||||
|
||||
refreshPromiseRef.current = refreshPromise;
|
||||
try {
|
||||
await refreshPromise;
|
||||
} finally {
|
||||
// Release the slot from the promise itself rather than from this caller,
|
||||
// so a waiter in the loop above always observes a cleared ref when it
|
||||
// resumes instead of spinning on a settled promise.
|
||||
refreshPromise.finally(() => {
|
||||
if (refreshPromiseRef.current === refreshPromise) {
|
||||
refreshPromiseRef.current = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
return await refreshPromise;
|
||||
}, []);
|
||||
|
||||
const scheduleRefresh = useCallback(
|
||||
@@ -1126,6 +1169,56 @@ export function useSessionHistory({
|
||||
[getSessionByThreadId, onUpdateSessionMetadata, pendingAction],
|
||||
);
|
||||
|
||||
const setThreadPinned = useCallback(
|
||||
async (threadId: string, pinned: boolean) => {
|
||||
const applyPinned = (next: boolean) => {
|
||||
setThreads((current) =>
|
||||
updateThreadById(current, threadId, (thread) =>
|
||||
thread.pinned === next ? thread : { ...thread, pinned: next },
|
||||
),
|
||||
);
|
||||
setSessions((current) =>
|
||||
updateSessionById(current, threadId, (session) => ({
|
||||
...session,
|
||||
metadata: {
|
||||
...(session.metadata ?? {}),
|
||||
[PINNED_METADATA_KEY]: next || undefined,
|
||||
},
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
// Favoriting is a single click, so apply it locally first and roll back
|
||||
// if the write fails rather than blocking the row on a round trip.
|
||||
applyPinned(pinned);
|
||||
try {
|
||||
await desktopClient.invoke("update_chat_session_metadata", {
|
||||
sessionId: threadId,
|
||||
metadata: { [PINNED_METADATA_KEY]: pinned ? true : null },
|
||||
});
|
||||
const sourceSession = getSessionByThreadId(threadId);
|
||||
onUpdateSessionMetadata?.(threadId, {
|
||||
...(sourceSession?.metadata ?? {}),
|
||||
[PINNED_METADATA_KEY]: pinned || undefined,
|
||||
});
|
||||
scheduleRefresh(HISTORY_FAST_REFRESH_DELAY_MS);
|
||||
return true;
|
||||
} catch (error) {
|
||||
applyPinned(!pinned);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: pinned ? "Favorite failed" : "Unfavorite failed",
|
||||
description:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "The session could not be updated.",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[getSessionByThreadId, onUpdateSessionMetadata, scheduleRefresh],
|
||||
);
|
||||
|
||||
const forkThread = useCallback(
|
||||
async (threadId: string) => {
|
||||
const thread = threadsRef.current.find((item) => item.id === threadId);
|
||||
@@ -1243,13 +1336,30 @@ export function useSessionHistory({
|
||||
|
||||
const loadMoreSessions = useCallback(
|
||||
async (nextLimit: number) => {
|
||||
if (fetchLimitRef.current >= nextLimit) {
|
||||
return;
|
||||
if (loadedLimitRef.current >= nextLimit) {
|
||||
return true;
|
||||
}
|
||||
fetchLimitRef.current = nextLimit;
|
||||
const requestedLimit = Math.max(fetchLimitRef.current, nextLimit);
|
||||
fetchLimitRef.current = requestedLimit;
|
||||
setIsLoadingMore(true);
|
||||
try {
|
||||
await refreshSessions();
|
||||
const loaded = await refreshSessions();
|
||||
// Roll back to what was last fetched so a retry asks for this batch
|
||||
// again instead of skipping past it — but only when no overlapping
|
||||
// call has raised the limit further in the meantime, since lowering
|
||||
// it would make that call fetch a smaller batch than it asked for
|
||||
// and still report success.
|
||||
if (!loaded && fetchLimitRef.current === requestedLimit) {
|
||||
fetchLimitRef.current = loadedLimitRef.current;
|
||||
}
|
||||
if (!loaded) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Could not load more sessions",
|
||||
description: "Session history is unavailable right now.",
|
||||
});
|
||||
}
|
||||
return loaded;
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
@@ -1260,8 +1370,45 @@ export function useSessionHistory({
|
||||
() => loadMoreSessions(fetchLimitRef.current + INITIAL_HISTORY_FETCH_LIMIT),
|
||||
[loadMoreSessions],
|
||||
);
|
||||
const loadAllSessions = useCallback(() => {
|
||||
if (loadAllPromiseRef.current) {
|
||||
return loadAllPromiseRef.current;
|
||||
}
|
||||
const loadAllPromise = (async () => {
|
||||
// A global search, filter, or oldest-first sort can be selected while
|
||||
// the mount request is still in flight. Wait for that request before
|
||||
// deciding whether there is any older history to fetch.
|
||||
if (loadedLimitRef.current === 0 && !(await refreshSessions())) {
|
||||
return false;
|
||||
}
|
||||
// Grow exponentially so complete-history operations need only
|
||||
// logarithmically many requests while ordinary paging stays in
|
||||
// predictable 50-session increments.
|
||||
while (mayHaveMoreSessionsRef.current) {
|
||||
const currentLimit = Math.max(
|
||||
fetchLimitRef.current,
|
||||
loadedLimitRef.current,
|
||||
INITIAL_HISTORY_FETCH_LIMIT,
|
||||
);
|
||||
const nextLimit = Math.max(
|
||||
currentLimit + INITIAL_HISTORY_FETCH_LIMIT,
|
||||
currentLimit * 2,
|
||||
);
|
||||
if (!(await loadMoreSessions(nextLimit))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
})();
|
||||
loadAllPromiseRef.current = loadAllPromise;
|
||||
loadAllPromise.finally(() => {
|
||||
if (loadAllPromiseRef.current === loadAllPromise) {
|
||||
loadAllPromiseRef.current = null;
|
||||
}
|
||||
});
|
||||
return loadAllPromise;
|
||||
}, [loadMoreSessions, refreshSessions]);
|
||||
|
||||
const mayHaveMoreSessions = sessions.length >= fetchLimitRef.current;
|
||||
const sessionById = useMemo(
|
||||
() => new Map(sessions.map((session) => [session.sessionId, session])),
|
||||
[sessions],
|
||||
@@ -1271,6 +1418,7 @@ export function useSessionHistory({
|
||||
getSessionByThreadId,
|
||||
isLoadingHistory,
|
||||
isLoadingMore,
|
||||
loadAllSessions,
|
||||
loadOlderSessions,
|
||||
loadMoreSessions,
|
||||
mayHaveMoreSessions,
|
||||
@@ -1278,6 +1426,7 @@ export function useSessionHistory({
|
||||
pendingAction,
|
||||
refreshSessions,
|
||||
renameThread,
|
||||
setThreadPinned,
|
||||
deleteThread,
|
||||
forkThread,
|
||||
sessionById,
|
||||
|
||||
@@ -72,6 +72,8 @@ export const ChatMessageSchema = z.object({
|
||||
totalCost: z.number().nonnegative().optional(),
|
||||
providerId: z.string().optional(),
|
||||
modelId: z.string().optional(),
|
||||
userRunSpan: z.number().int().nonnegative().optional(),
|
||||
runCount: z.number().int().positive().optional(),
|
||||
checkpoint: z
|
||||
.object({
|
||||
ref: z.string(),
|
||||
|
||||
@@ -17,6 +17,30 @@ function createSession(sessionId: string): SessionHistoryItem {
|
||||
}
|
||||
|
||||
describe("desktopAppReducer", () => {
|
||||
it("hands an edited prompt to a fork exactly once", () => {
|
||||
let state = createDesktopAppState("welcome", settingsSection);
|
||||
state = desktopAppReducer(state, {
|
||||
type: "open-session",
|
||||
session: createSession("forked-session"),
|
||||
initialPromptDraft: "Revise this prompt",
|
||||
});
|
||||
|
||||
expect(
|
||||
state.threads.find((thread) => thread.id === "session_forked-session")
|
||||
?.initialPromptDraft,
|
||||
).toBe("Revise this prompt");
|
||||
|
||||
state = desktopAppReducer(state, {
|
||||
type: "consume-initial-prompt-draft",
|
||||
threadId: "session_forked-session",
|
||||
});
|
||||
|
||||
expect(
|
||||
state.threads.find((thread) => thread.id === "session_forked-session")
|
||||
?.initialPromptDraft,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps both sessions deleted when deletion actions are queued together", () => {
|
||||
let state = createDesktopAppState("welcome", settingsSection);
|
||||
state = desktopAppReducer(state, {
|
||||
|
||||
@@ -11,6 +11,7 @@ export type DesktopThread = {
|
||||
id: string;
|
||||
historySession?: SessionHistoryItem;
|
||||
hasStarted?: boolean;
|
||||
initialPromptDraft?: string;
|
||||
};
|
||||
|
||||
export type DesktopAppLocation<SettingsSection extends string> = {
|
||||
@@ -29,7 +30,12 @@ export type DesktopAppAction<SettingsSection extends string> =
|
||||
| { type: "back" }
|
||||
| { type: "forward" }
|
||||
| { type: "new-thread"; threadId: string }
|
||||
| { type: "open-session"; session: SessionHistoryItem }
|
||||
| {
|
||||
type: "open-session";
|
||||
session: SessionHistoryItem;
|
||||
initialPromptDraft?: string;
|
||||
}
|
||||
| { type: "consume-initial-prompt-draft"; threadId: string }
|
||||
| {
|
||||
type: "delete-session";
|
||||
deletedSessionId: string;
|
||||
@@ -115,6 +121,7 @@ export function desktopAppReducer<SettingsSection extends string>(
|
||||
...thread,
|
||||
hasStarted: true,
|
||||
historySession: action.session,
|
||||
initialPromptDraft: action.initialPromptDraft,
|
||||
}
|
||||
: thread,
|
||||
)
|
||||
@@ -124,6 +131,7 @@ export function desktopAppReducer<SettingsSection extends string>(
|
||||
id: threadId,
|
||||
hasStarted: true,
|
||||
historySession: action.session,
|
||||
initialPromptDraft: action.initialPromptDraft,
|
||||
},
|
||||
];
|
||||
return {
|
||||
@@ -138,6 +146,16 @@ export function desktopAppReducer<SettingsSection extends string>(
|
||||
}),
|
||||
};
|
||||
}
|
||||
case "consume-initial-prompt-draft":
|
||||
return {
|
||||
...state,
|
||||
threads: state.threads.map((thread) =>
|
||||
thread.id === action.threadId &&
|
||||
thread.initialPromptDraft !== undefined
|
||||
? { ...thread, initialPromptDraft: undefined }
|
||||
: thread,
|
||||
),
|
||||
};
|
||||
case "delete-session": {
|
||||
const historyThreadId = `session_${action.deletedSessionId}`;
|
||||
const deletedThreadIds = new Set(
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type SentDesktopRequest = {
|
||||
id: string;
|
||||
command: string;
|
||||
};
|
||||
|
||||
class FakeWebSocket {
|
||||
static readonly CONNECTING = 0;
|
||||
static readonly OPEN = 1;
|
||||
static readonly CLOSING = 2;
|
||||
static readonly CLOSED = 3;
|
||||
|
||||
readonly sent: string[] = [];
|
||||
readyState = FakeWebSocket.CONNECTING;
|
||||
sendError: Error | null = null;
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((event: { data: string }) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
|
||||
constructor(readonly url: string) {
|
||||
sockets.push(this);
|
||||
}
|
||||
|
||||
open(): void {
|
||||
this.readyState = FakeWebSocket.OPEN;
|
||||
this.onopen?.();
|
||||
}
|
||||
|
||||
send(data: string): void {
|
||||
if (this.sendError) {
|
||||
throw this.sendError;
|
||||
}
|
||||
this.sent.push(data);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.readyState = FakeWebSocket.CLOSED;
|
||||
this.onclose?.();
|
||||
}
|
||||
|
||||
respond(result: unknown): void {
|
||||
const request = this.lastRequest();
|
||||
this.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
type: "response",
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
lastRequest(): SentDesktopRequest {
|
||||
const raw = this.sent.at(-1);
|
||||
if (!raw) {
|
||||
throw new Error("No desktop request was sent");
|
||||
}
|
||||
return JSON.parse(raw) as SentDesktopRequest;
|
||||
}
|
||||
}
|
||||
|
||||
const sockets: FakeWebSocket[] = [];
|
||||
const originalWebSocket = globalThis.WebSocket;
|
||||
|
||||
async function connectLatestSocket(options?: {
|
||||
sendError?: Error;
|
||||
}): Promise<FakeWebSocket> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
const socket = sockets.at(-1);
|
||||
if (!socket) {
|
||||
throw new Error("Desktop client did not create a WebSocket");
|
||||
}
|
||||
socket.sendError = options?.sendError ?? null;
|
||||
socket.open();
|
||||
for (let attempt = 0; attempt < 10 && socket.sent.length === 0; attempt++) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
return socket;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.resetModules();
|
||||
sockets.length = 0;
|
||||
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
|
||||
(window as unknown as Record<string, unknown>).__SIDECAR_WS_ENDPOINT__ =
|
||||
"ws://127.0.0.1:3126/transport";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
globalThis.WebSocket = originalWebSocket;
|
||||
delete (window as unknown as Record<string, unknown>).__SIDECAR_WS_ENDPOINT__;
|
||||
});
|
||||
|
||||
describe("DesktopClient command deadlines", () => {
|
||||
it("keeps an explicitly unbounded command pending past the default deadline", async () => {
|
||||
const { desktopClient } = await import("./desktop-client");
|
||||
let settled = false;
|
||||
const invocation = desktopClient
|
||||
.invoke<{ ok: boolean }>(
|
||||
"chat_session_command",
|
||||
{ request: { action: "send" } },
|
||||
{ timeoutMs: null },
|
||||
)
|
||||
.finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
const socket = await connectLatestSocket();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10 * 60_000);
|
||||
expect(settled).toBe(false);
|
||||
|
||||
socket.respond({ ok: true });
|
||||
await expect(invocation).resolves.toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("retains the default deadline for ordinary commands", async () => {
|
||||
const { desktopClient } = await import("./desktop-client");
|
||||
const invocation = desktopClient.invoke("get_process_context");
|
||||
await connectLatestSocket();
|
||||
const rejection = expect(invocation).rejects.toThrow(
|
||||
"Desktop command timed out waiting for get_process_context",
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
await rejection;
|
||||
});
|
||||
|
||||
it("rejects an unbounded command when the transport closes", async () => {
|
||||
const { desktopClient } = await import("./desktop-client");
|
||||
const invocation = desktopClient.invoke(
|
||||
"chat_session_command",
|
||||
{ request: { action: "send" } },
|
||||
{ timeoutMs: null },
|
||||
);
|
||||
const socket = await connectLatestSocket();
|
||||
const rejection = expect(invocation).rejects.toThrow(
|
||||
"Desktop backend transport closed",
|
||||
);
|
||||
|
||||
socket.close();
|
||||
await rejection;
|
||||
});
|
||||
|
||||
it("removes an unbounded request when WebSocket.send throws", async () => {
|
||||
const { desktopClient } = await import("./desktop-client");
|
||||
const invocation = desktopClient.invoke(
|
||||
"chat_session_command",
|
||||
{ request: { action: "send" } },
|
||||
{ timeoutMs: null },
|
||||
);
|
||||
await connectLatestSocket({
|
||||
sendError: new Error("WebSocket send failed"),
|
||||
});
|
||||
|
||||
await expect(invocation).rejects.toThrow("WebSocket send failed");
|
||||
expect(
|
||||
(
|
||||
desktopClient as unknown as {
|
||||
pending: Map<string, unknown>;
|
||||
}
|
||||
).pending.size,
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -87,12 +87,20 @@ export async function resolveDesktopBackendHttpEndpoint(): Promise<string> {
|
||||
type PendingRequest = {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeoutId: ReturnType<typeof setTimeout>;
|
||||
timeoutId?: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
type EventHandler = (payload: unknown) => void;
|
||||
type TransportStateHandler = (state: DesktopTransportState) => void;
|
||||
|
||||
export type DesktopInvokeOptions = {
|
||||
/**
|
||||
* Override the default command deadline. Use `null` for commands whose
|
||||
* response represents completion of a legitimately long-running operation.
|
||||
*/
|
||||
timeoutMs?: number | null;
|
||||
};
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 120_000;
|
||||
const RECONNECT_BASE_DELAY_MS = 400;
|
||||
const RECONNECT_MAX_DELAY_MS = 4_000;
|
||||
@@ -142,11 +150,21 @@ class DesktopClient {
|
||||
return this.endpoint;
|
||||
}
|
||||
|
||||
private rejectPending(errorMessage: string) {
|
||||
for (const [requestId, pending] of this.pending.entries()) {
|
||||
private takePending(requestId: string): PendingRequest | undefined {
|
||||
const pending = this.pending.get(requestId);
|
||||
if (!pending) {
|
||||
return undefined;
|
||||
}
|
||||
if (pending.timeoutId !== undefined) {
|
||||
clearTimeout(pending.timeoutId);
|
||||
this.pending.delete(requestId);
|
||||
pending.reject(new Error(errorMessage));
|
||||
}
|
||||
this.pending.delete(requestId);
|
||||
return pending;
|
||||
}
|
||||
|
||||
private rejectPending(errorMessage: string) {
|
||||
for (const requestId of this.pending.keys()) {
|
||||
this.takePending(requestId)?.reject(new Error(errorMessage));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,12 +192,10 @@ class DesktopClient {
|
||||
}
|
||||
|
||||
const response = parsed as DesktopTransportResponse;
|
||||
const pending = this.pending.get(response.id);
|
||||
const pending = this.takePending(response.id);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(pending.timeoutId);
|
||||
this.pending.delete(response.id);
|
||||
if (!response.ok) {
|
||||
pending.reject(new Error(response.error || "Desktop command failed"));
|
||||
return;
|
||||
@@ -265,7 +281,11 @@ class DesktopClient {
|
||||
return this.connectPromise;
|
||||
}
|
||||
|
||||
async invoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||
async invoke<T>(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
options?: DesktopInvokeOptions,
|
||||
): Promise<T> {
|
||||
// Route native OS commands (directory picker, file opener) through Tauri
|
||||
// only when running inside the full Tauri app shell. In sidecar/web mode
|
||||
// these are handled by the sidecar over WebSocket.
|
||||
@@ -288,22 +308,33 @@ class DesktopClient {
|
||||
};
|
||||
|
||||
return await new Promise<T>((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
const pending = this.pending.get(id);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
this.pending.delete(id);
|
||||
pending.reject(
|
||||
new Error(`Desktop command timed out waiting for ${command}`),
|
||||
);
|
||||
}, REQUEST_TIMEOUT_MS);
|
||||
const timeoutMs =
|
||||
options?.timeoutMs === undefined
|
||||
? REQUEST_TIMEOUT_MS
|
||||
: options.timeoutMs;
|
||||
const timeoutId =
|
||||
timeoutMs === null
|
||||
? undefined
|
||||
: setTimeout(() => {
|
||||
const pending = this.takePending(id);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
pending.reject(
|
||||
new Error(`Desktop command timed out waiting for ${command}`),
|
||||
);
|
||||
}, timeoutMs);
|
||||
this.pending.set(id, {
|
||||
resolve: (value) => resolve(value as T),
|
||||
reject,
|
||||
timeoutId,
|
||||
});
|
||||
socket.send(JSON.stringify(request));
|
||||
try {
|
||||
socket.send(JSON.stringify(request));
|
||||
} catch (error) {
|
||||
this.takePending(id);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,11 @@ export type SessionHistoryStatus =
|
||||
|
||||
export type SessionMetadata = {
|
||||
title?: string;
|
||||
/**
|
||||
* Favorited sessions. Stored in session metadata rather than desktop-local
|
||||
* state so every client reading the session sees the same flag.
|
||||
*/
|
||||
pinned?: boolean;
|
||||
git?: {
|
||||
url?: string;
|
||||
branch?: string;
|
||||
@@ -14,6 +19,8 @@ export type SessionMetadata = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export const PINNED_METADATA_KEY = "pinned";
|
||||
|
||||
export interface SessionHistoryItem {
|
||||
sessionId: string;
|
||||
source?: string;
|
||||
@@ -51,6 +58,10 @@ export function getSessionMetadataTitle(metadata?: SessionMetadata): string {
|
||||
return typeof metadata.title === "string" ? metadata.title.trim() : "";
|
||||
}
|
||||
|
||||
export function getSessionMetadataPinned(metadata?: SessionMetadata): boolean {
|
||||
return metadata?.[PINNED_METADATA_KEY] === true;
|
||||
}
|
||||
|
||||
export function getSessionMetadataGitBranch(
|
||||
metadata?: SessionMetadata,
|
||||
): string {
|
||||
|
||||
@@ -34,7 +34,7 @@ Thanks to [Claude Sonnet's agentic coding capabilities](https://www.anthropic.c
|
||||
4. When a task is completed, Cline will present the result to you with a terminal command like `open -a "Google Chrome" index.html`, which you run with a click of a button.
|
||||
|
||||
> [!TIP]
|
||||
> Follow [this guide](https://docs.cline.bot/features/customization/opening-cline-in-sidebar) to open Cline on the right side of your editor. This lets you use Cline side-by-side with your file explorer, and see how he changes your workspace more clearly.
|
||||
> Follow [this guide](https://docs.cline.bot/usage/ide#move-cline-to-the-right-sidebar) to open Cline on the right side of your editor. This lets you use Cline side-by-side with your file explorer, and see how he changes your workspace more clearly.
|
||||
|
||||
---
|
||||
|
||||
@@ -56,7 +56,7 @@ The extension also keeps track of total tokens and API usage cost for the entire
|
||||
|
||||
Cline can execute commands and receive their output to install packages, run build scripts, deploy applications, manage databases, and run tests, all while adapting to your dev environment & toolchain to get the job done right.
|
||||
|
||||
By default, commands run in a background process, which works everywhere without extra setup. You can switch to running commands in a visible VS Code terminal instead (Settings → Terminal → Terminal Execution Mode), which uses the [shell integration API introduced in VSCode v1.93](https://code.visualstudio.com/updates/v1_93#_terminal-shell-integration-api) to stream a command's output as it runs and lets you watch or interact with it directly.
|
||||
By default, commands run in a visible VS Code terminal, using the [shell integration API introduced in VSCode v1.93](https://code.visualstudio.com/updates/v1_93#_terminal-shell-integration-api) to stream output as commands run and let you watch or interact with them directly. You can switch to running commands in a background process instead (Settings → Terminal → Terminal Execution Mode).
|
||||
|
||||
<!-- Transparent pixel to create line break after floating image -->
|
||||
|
||||
|
||||
@@ -228,11 +228,6 @@
|
||||
"command": "cline.openWalkthrough",
|
||||
"title": "Open Walkthrough",
|
||||
"category": "Cline"
|
||||
},
|
||||
{
|
||||
"command": "cline.reconstructTaskHistory",
|
||||
"title": "Reconstruct Task History",
|
||||
"category": "Cline"
|
||||
}
|
||||
],
|
||||
"keybindings": [
|
||||
|
||||
@@ -130,11 +130,13 @@ message OpenRouterModelInfo {
|
||||
// strings are silently dropped by the host. The array is additive over
|
||||
// the base metadata; the explicit supports_* booleans win when both are
|
||||
// present.
|
||||
// - `is_r1_format_required` is a legacy alias that forces the R1 chat
|
||||
// format only when true; `api_format` is canonical.
|
||||
// - Invalid numbers (non-positive token limits, negative prices or
|
||||
// temperature, non-finite values) are silently discarded, not rejected.
|
||||
message ModelOverrides {
|
||||
// Field 15 was the removed legacy `is_r1_format_required` alias.
|
||||
reserved 15;
|
||||
reserved "is_r1_format_required";
|
||||
|
||||
optional string name = 1;
|
||||
optional int64 max_tokens = 2;
|
||||
optional int64 context_window = 3;
|
||||
@@ -149,7 +151,6 @@ message ModelOverrides {
|
||||
optional double cache_writes_price = 12;
|
||||
optional double temperature = 13;
|
||||
optional ApiFormat api_format = 14;
|
||||
optional bool is_r1_format_required = 15;
|
||||
}
|
||||
|
||||
// Shared response message for model information
|
||||
@@ -626,6 +627,10 @@ enum ApiFormat {
|
||||
|
||||
// Model info for OpenAI-compatible models
|
||||
message OpenAiCompatibleModelInfo {
|
||||
// Field 14 was the removed legacy `is_r1_format_required` flag.
|
||||
reserved 14;
|
||||
reserved "is_r1_format_required";
|
||||
|
||||
optional int64 max_tokens = 1;
|
||||
optional int64 context_window = 2;
|
||||
optional bool supports_images = 3;
|
||||
@@ -639,7 +644,6 @@ message OpenAiCompatibleModelInfo {
|
||||
optional string description = 11;
|
||||
repeated ModelTier tiers = 12;
|
||||
optional double temperature = 13;
|
||||
optional bool is_r1_format_required = 14;
|
||||
optional ApiFormat api_format = 15;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ service TaskService {
|
||||
rpc newTask(NewTaskRequest) returns (String);
|
||||
// Shows a task with the specified ID
|
||||
rpc showTaskWithId(StringRequest) returns (TaskResponse);
|
||||
// Exports a task with the given ID to markdown
|
||||
// Opens the on-disk session directory (messages json, logs) for the task with the given ID
|
||||
rpc exportTaskWithId(StringRequest) returns (Empty);
|
||||
// Toggles the favorite status of a task
|
||||
rpc toggleTaskFavorite(TaskFavoriteRequest) returns (Empty);
|
||||
|
||||
@@ -75,6 +75,7 @@ enum ClineSay {
|
||||
USE_SUBAGENTS_SAY = 35;
|
||||
SUBAGENT_USAGE = 36;
|
||||
COMPACTION = 37;
|
||||
PLAN_COMPLETION_RESULT = 38;
|
||||
}
|
||||
|
||||
// Enum for ClineSayTool tool types
|
||||
|
||||
@@ -94,8 +94,15 @@ function parseCounts(output: string): { pass: number; fail: number } {
|
||||
|
||||
const PER_FILE_TIMEOUT_MS = 120_000
|
||||
|
||||
// bun test's default per-test timeout is 5000ms. The Windows CI runners are
|
||||
// slow enough under load (several suites spawn powershell + node per test via
|
||||
// the hook bridge) that real, passing tests intermittently crossed 5s. Give
|
||||
// them more headroom; genuinely hung files are still bounded by
|
||||
// PER_FILE_TIMEOUT_MS.
|
||||
const PER_TEST_TIMEOUT_ARGS = process.platform === "win32" ? ["--timeout", "15000"] : []
|
||||
|
||||
async function runOne(file: string): Promise<FileResult> {
|
||||
const proc = Bun.spawn(["bun", "test", file], {
|
||||
const proc = Bun.spawn(["bun", "test", ...PER_TEST_TIMEOUT_ARGS, file], {
|
||||
cwd: projectRoot,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
@@ -178,13 +185,43 @@ async function main(): Promise<void> {
|
||||
|
||||
const results = await runPool(files, concurrency)
|
||||
|
||||
const totalPass = results.reduce((sum, r) => sum + r.pass, 0)
|
||||
const totalFail = results.reduce((sum, r) => sum + r.fail, 0)
|
||||
const failedFiles = results.filter((r) => r.fail > 0 || r.code !== 0).sort((a, b) => a.file.localeCompare(b.file))
|
||||
// Flake guard: rerun failed files once, serially, so a retry is never
|
||||
// competing with other suites for CPU/process slots. This mirrors the
|
||||
// retry the CI workflow already applies to the non-Linux integration
|
||||
// tests; deterministic failures still fail both attempts and stay red.
|
||||
const resultByFile = new Map(results.map((r) => [r.file, r]))
|
||||
const firstPassFailures = results.filter((r) => r.fail > 0 || r.code !== 0).sort((a, b) => a.file.localeCompare(b.file))
|
||||
const recoveredFiles: string[] = []
|
||||
if (firstPassFailures.length > 0) {
|
||||
console.log(`\nRetrying ${firstPassFailures.length} failed file(s) serially (flake guard)…`)
|
||||
for (const failure of firstPassFailures) {
|
||||
const retry = await runOne(failure.file)
|
||||
const failedAgain = retry.fail > 0 || retry.code !== 0
|
||||
const status = failedAgain ? "FAIL" : "ok (flaky)"
|
||||
console.log(`[retry] ${status.padEnd(10)} ${retry.pass} pass / ${retry.fail} fail ${failure.file}`)
|
||||
if (failedAgain) {
|
||||
process.stdout.write(retry.output.trimEnd() + "\n")
|
||||
} else {
|
||||
recoveredFiles.push(failure.file)
|
||||
}
|
||||
resultByFile.set(failure.file, retry)
|
||||
}
|
||||
}
|
||||
|
||||
const finalResults = [...resultByFile.values()]
|
||||
const totalPass = finalResults.reduce((sum, r) => sum + r.pass, 0)
|
||||
const totalFail = finalResults.reduce((sum, r) => sum + r.fail, 0)
|
||||
const failedFiles = finalResults.filter((r) => r.fail > 0 || r.code !== 0).sort((a, b) => a.file.localeCompare(b.file))
|
||||
const elapsed = ((Date.now() - started) / 1000).toFixed(1)
|
||||
|
||||
console.log("\n──────────────────────────────────────────────")
|
||||
console.log(`Files: ${results.length} Pass: ${totalPass} Fail: ${totalFail} Time: ${elapsed}s`)
|
||||
console.log(`Files: ${finalResults.length} Pass: ${totalPass} Fail: ${totalFail} Time: ${elapsed}s`)
|
||||
if (recoveredFiles.length > 0) {
|
||||
console.log(`\nFlaky files that passed on serial retry (${recoveredFiles.length}):`)
|
||||
for (const file of recoveredFiles) {
|
||||
console.log(` ${file}`)
|
||||
}
|
||||
}
|
||||
if (failedFiles.length > 0) {
|
||||
console.log(`\nFailing files (${failedFiles.length}):`)
|
||||
for (const r of failedFiles) {
|
||||
|
||||
@@ -4,10 +4,68 @@ import { ClineRulesToggles } from "@shared/cline-rules"
|
||||
import path from "path"
|
||||
import { Controller } from "@/core/controller"
|
||||
|
||||
/**
|
||||
* Merge a directory-scan result with the toggle state as it stands *after* the
|
||||
* scan. The scan in `synchronizeRuleToggles` is async, so state can change
|
||||
* while it runs:
|
||||
* - a toggle the user flips mid-scan must win over the stale snapshot value;
|
||||
* - an entry added mid-scan (e.g. a workflow file created via the modal) must
|
||||
* be kept even though the older scan didn't see the file;
|
||||
* - an entry removed from state mid-scan (e.g. the workflow was deleted via
|
||||
* the modal, which deletes the file and its entry) must stay removed even
|
||||
* though the older scan still saw the file;
|
||||
* - entries that existed before the scan but whose files the scan no longer
|
||||
* found are pruned (the file was deleted).
|
||||
*/
|
||||
function mergeToggleStateAfterScan(
|
||||
scanned: ClineRulesToggles,
|
||||
preScan: ClineRulesToggles,
|
||||
current: ClineRulesToggles,
|
||||
): ClineRulesToggles {
|
||||
const merged: ClineRulesToggles = {}
|
||||
for (const [key, value] of Object.entries(scanned)) {
|
||||
if (key in current) {
|
||||
merged[key] = current[key]
|
||||
} else if (!(key in preScan)) {
|
||||
merged[key] = value
|
||||
}
|
||||
// else: the entry was removed from state while the scan ran — keep it removed.
|
||||
}
|
||||
for (const [key, value] of Object.entries(current)) {
|
||||
if (!(key in scanned) && !(key in preScan)) {
|
||||
merged[key] = value
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes refresh runs. Overlapping refreshes (webview launch, the rules
|
||||
* modal opening, workflow file creation) would otherwise interleave their
|
||||
* scans and writes and could publish stale state; queueing them makes each
|
||||
* scan atomic relative to other refreshes, and any file create/delete that
|
||||
* happens mid-scan triggers its own refresh that queues behind the running
|
||||
* one and corrects the outcome. The merge in `mergeToggleStateAfterScan`
|
||||
* covers the remaining non-refresh writer: direct toggle flips.
|
||||
*/
|
||||
let refreshQueue: Promise<unknown> = Promise.resolve()
|
||||
|
||||
/**
|
||||
* Refresh the workflow toggles
|
||||
*/
|
||||
export async function refreshWorkflowToggles(
|
||||
export function refreshWorkflowToggles(
|
||||
controller: Controller,
|
||||
workingDirectory: string,
|
||||
): Promise<{
|
||||
globalWorkflowToggles: ClineRulesToggles
|
||||
localWorkflowToggles: ClineRulesToggles
|
||||
}> {
|
||||
const run = refreshQueue.then(() => doRefreshWorkflowToggles(controller, workingDirectory))
|
||||
refreshQueue = run.catch(() => undefined)
|
||||
return run
|
||||
}
|
||||
|
||||
async function doRefreshWorkflowToggles(
|
||||
controller: Controller,
|
||||
workingDirectory: string,
|
||||
): Promise<{
|
||||
@@ -17,12 +75,24 @@ export async function refreshWorkflowToggles(
|
||||
// Global workflows
|
||||
const globalWorkflowToggles = controller.stateManager.getGlobalSettingsKey("globalWorkflowToggles")
|
||||
const globalClineWorkflowsFilePath = await ensureWorkflowsDirectoryExists()
|
||||
const updatedGlobalWorkflowToggles = await synchronizeRuleToggles(globalClineWorkflowsFilePath, globalWorkflowToggles)
|
||||
const scannedGlobalToggles = await synchronizeRuleToggles(globalClineWorkflowsFilePath, globalWorkflowToggles)
|
||||
// Re-read state after the async scans: no `await` between here and the
|
||||
// writes below, so concurrent toggle updates cannot be lost.
|
||||
const updatedGlobalWorkflowToggles = mergeToggleStateAfterScan(
|
||||
scannedGlobalToggles,
|
||||
globalWorkflowToggles,
|
||||
controller.stateManager.getGlobalSettingsKey("globalWorkflowToggles"),
|
||||
)
|
||||
controller.stateManager.setGlobalState("globalWorkflowToggles", updatedGlobalWorkflowToggles)
|
||||
|
||||
const workflowRulesToggles = controller.stateManager.getWorkspaceStateKey("workflowToggles")
|
||||
const workflowsDirPath = path.resolve(workingDirectory, GlobalFileNames.workflows)
|
||||
const updatedWorkflowToggles = await synchronizeRuleToggles(workflowsDirPath, workflowRulesToggles)
|
||||
const scannedWorkspaceToggles = await synchronizeRuleToggles(workflowsDirPath, workflowRulesToggles)
|
||||
const updatedWorkflowToggles = mergeToggleStateAfterScan(
|
||||
scannedWorkspaceToggles,
|
||||
workflowRulesToggles,
|
||||
controller.stateManager.getWorkspaceStateKey("workflowToggles"),
|
||||
)
|
||||
controller.stateManager.setWorkspaceState("workflowToggles", updatedWorkflowToggles)
|
||||
|
||||
return {
|
||||
|
||||
+18
-13
@@ -1,10 +1,14 @@
|
||||
import * as assert from "assert"
|
||||
import { afterEach, beforeEach, describe, it } from "mocha"
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
||||
import sinon from "sinon"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { Controller } from "../../index"
|
||||
import { clearOrganizationForClinePassProviderSelection } from "../handleClinePassProviderSelection"
|
||||
|
||||
/** Let the fire-and-forget switchAccount promise chain settle. */
|
||||
function flushMicrotasks(): Promise<void> {
|
||||
return new Promise((resolve) => setImmediate(resolve))
|
||||
}
|
||||
|
||||
describe("clearOrganizationForClinePassProviderSelection", () => {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
let switchAccount: sinon.SinonStub
|
||||
@@ -25,36 +29,37 @@ describe("clearOrganizationForClinePassProviderSelection", () => {
|
||||
} as unknown as Controller
|
||||
}
|
||||
|
||||
it("does nothing when ClinePass is not selected", async () => {
|
||||
await clearOrganizationForClinePassProviderSelection(createController(), {
|
||||
it("does nothing when ClinePass is not selected", () => {
|
||||
clearOrganizationForClinePassProviderSelection(createController(), {
|
||||
planModeApiProvider: "cline",
|
||||
actModeApiProvider: "openrouter",
|
||||
})
|
||||
|
||||
assert.strictEqual(switchAccount.callCount, 0)
|
||||
expect(switchAccount.callCount).toBe(0)
|
||||
})
|
||||
|
||||
it("switches to the personal account when ClinePass is selected", async () => {
|
||||
await clearOrganizationForClinePassProviderSelection(createController(), {
|
||||
it("switches to the personal account when ClinePass is selected without blocking the caller", () => {
|
||||
clearOrganizationForClinePassProviderSelection(createController(), {
|
||||
planModeApiProvider: "cline-pass",
|
||||
actModeApiProvider: "openrouter",
|
||||
})
|
||||
|
||||
assert.strictEqual(switchAccount.callCount, 1)
|
||||
assert.strictEqual(switchAccount.firstCall.args[0], null)
|
||||
expect(switchAccount.callCount).toBe(1)
|
||||
expect(switchAccount.firstCall.args[0]).toBeUndefined()
|
||||
})
|
||||
|
||||
it("logs and swallows account switch failures", async () => {
|
||||
const error = new Error("not signed in")
|
||||
switchAccount.rejects(error)
|
||||
|
||||
await clearOrganizationForClinePassProviderSelection(createController(), {
|
||||
clearOrganizationForClinePassProviderSelection(createController(), {
|
||||
planModeApiProvider: "cline",
|
||||
actModeApiProvider: "cline-pass",
|
||||
})
|
||||
await flushMicrotasks()
|
||||
|
||||
assert.strictEqual(switchAccount.callCount, 1)
|
||||
assert.strictEqual(switchAccount.firstCall.args[0], null)
|
||||
assert.ok((Logger.debug as sinon.SinonStub).calledOnce)
|
||||
expect(switchAccount.callCount).toBe(1)
|
||||
expect(switchAccount.firstCall.args[0]).toBeUndefined()
|
||||
expect((Logger.debug as sinon.SinonStub).calledOnce).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { refreshGroqModels } from "../refreshGroqModels"
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
axiosGet: vi.fn(),
|
||||
captureProviderApiError: vi.fn(),
|
||||
getModelsCache: vi.fn(),
|
||||
getProviderCollectionSync: vi.fn(),
|
||||
getSecretKey: vi.fn(),
|
||||
setModelsCache: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@cline/llms", () => ({
|
||||
getProviderCollectionSync: mocks.getProviderCollectionSync,
|
||||
}))
|
||||
|
||||
vi.mock("@core/storage/disk", () => ({
|
||||
GlobalFileNames: {
|
||||
groqModels: "groq_models.json",
|
||||
},
|
||||
ensureCacheDirectoryExists: vi.fn(async () => "/tmp/cline-cache"),
|
||||
}))
|
||||
|
||||
vi.mock("@/core/storage/StateManager", () => ({
|
||||
StateManager: {
|
||||
get: () => ({
|
||||
getModelsCache: mocks.getModelsCache,
|
||||
setModelsCache: mocks.setModelsCache,
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/services/telemetry", () => ({
|
||||
telemetryService: {
|
||||
captureProviderApiError: mocks.captureProviderApiError,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/shared/net", () => ({
|
||||
getAxiosSettings: () => ({}),
|
||||
}))
|
||||
|
||||
vi.mock("@/shared/services/Logger", () => ({
|
||||
Logger: {
|
||||
error: vi.fn(),
|
||||
log: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@utils/fs", () => ({
|
||||
fileExistsAtPath: vi.fn(async () => false),
|
||||
}))
|
||||
|
||||
vi.mock("axios", () => ({
|
||||
default: {
|
||||
get: mocks.axiosGet,
|
||||
isAxiosError: vi.fn(() => false),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("fs/promises", () => ({
|
||||
default: {
|
||||
readFile: vi.fn(),
|
||||
writeFile: mocks.writeFile,
|
||||
},
|
||||
}))
|
||||
|
||||
describe("refreshGroqModels", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.getModelsCache.mockReturnValue(null)
|
||||
mocks.getProviderCollectionSync.mockReturnValue({ models: {} })
|
||||
mocks.getSecretKey.mockReturnValue("gsk_test_key")
|
||||
mocks.axiosGet.mockResolvedValue({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
id: "groq-new-chat-model",
|
||||
object: "model",
|
||||
active: true,
|
||||
max_completion_tokens: 4096,
|
||||
context_window: 8192,
|
||||
owned_by: "Groq",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("defaults cache pricing for live models missing SDK catalog metadata", async () => {
|
||||
const controller = {
|
||||
stateManager: {
|
||||
getSecretKey: mocks.getSecretKey,
|
||||
},
|
||||
task: {
|
||||
ulid: "task-1",
|
||||
},
|
||||
} as unknown as Parameters<typeof refreshGroqModels>[0]
|
||||
|
||||
const models = await refreshGroqModels(controller)
|
||||
|
||||
expect(models["groq-new-chat-model"]).toMatchObject({
|
||||
maxTokens: 4096,
|
||||
contextWindow: 8192,
|
||||
cacheWritesPrice: 0,
|
||||
cacheReadsPrice: 0,
|
||||
description: "Groq model with 8,192 token context window",
|
||||
})
|
||||
expect(mocks.captureProviderApiError).not.toHaveBeenCalled()
|
||||
expect(mocks.setModelsCache).toHaveBeenCalledWith("groq", expect.objectContaining(models))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { ModelInfo } from "@shared/api"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import type { ProviderCatalog } from "@/sdk/model-catalog/contracts"
|
||||
import type { ProviderCatalogController } from "../providerCatalogShared"
|
||||
import { refreshBasetenModels } from "../refreshBasetenModels"
|
||||
import { refreshGroqModels } from "../refreshGroqModels"
|
||||
import { refreshOpenRouterModels } from "../refreshOpenRouterModels"
|
||||
import { refreshVercelAiGatewayModels } from "../refreshVercelAiGatewayModels"
|
||||
|
||||
vi.mock("@/shared/services/Logger", () => ({
|
||||
Logger: {
|
||||
error: vi.fn(),
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const MODEL: ModelInfo = {
|
||||
name: "Rich Model",
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 8192,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 3,
|
||||
outputPrice: 15,
|
||||
}
|
||||
|
||||
function makeController(result: unknown): { controller: ProviderCatalogController; resolveModels: ReturnType<typeof vi.fn> } {
|
||||
const resolveModels = vi.fn(async () => result)
|
||||
const catalog = { resolveModels } as unknown as ProviderCatalog
|
||||
const controller = {
|
||||
getProviderCatalog: () => catalog,
|
||||
getProviderConfigStore: () => {
|
||||
throw new Error("not used")
|
||||
},
|
||||
} as unknown as ProviderCatalogController
|
||||
return { controller, resolveModels }
|
||||
}
|
||||
|
||||
function okResult(providerId: string) {
|
||||
return {
|
||||
ok: true as const,
|
||||
providerId,
|
||||
configFingerprint: "fp",
|
||||
models: new Map([["vendor/rich-model", MODEL]]),
|
||||
defaultModelId: "vendor/rich-model",
|
||||
source: "sdk-dynamic",
|
||||
fetchedAt: 0,
|
||||
}
|
||||
}
|
||||
|
||||
describe("provider model refresh handlers (SDK catalog delegation)", () => {
|
||||
it.each([
|
||||
["openrouter", refreshOpenRouterModels],
|
||||
["groq", refreshGroqModels],
|
||||
["baseten", refreshBasetenModels],
|
||||
["vercel-ai-gateway", refreshVercelAiGatewayModels],
|
||||
] as const)("resolves %s models through the SDK provider catalog", async (providerId, refresh) => {
|
||||
const { controller, resolveModels } = makeController(okResult(providerId))
|
||||
|
||||
const models = await refresh(controller)
|
||||
|
||||
expect(resolveModels).toHaveBeenCalledWith(providerId, undefined)
|
||||
expect(models).toEqual({ "vendor/rich-model": MODEL })
|
||||
})
|
||||
|
||||
it("throws the catalog error when model resolution fails", async () => {
|
||||
const { controller } = makeController({
|
||||
ok: false as const,
|
||||
providerId: "openrouter",
|
||||
configFingerprint: "fp",
|
||||
error: { kind: "unknown", message: "catalog exploded" },
|
||||
fetchedAt: 0,
|
||||
})
|
||||
|
||||
await expect(refreshOpenRouterModels(controller)).rejects.toThrow("catalog exploded")
|
||||
})
|
||||
})
|
||||
@@ -139,6 +139,48 @@ describe("resolveModelInfo", () => {
|
||||
expect(catalog.resolveModels).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("returns the committed selection when the request omits a model id (post-migration cold start)", async () => {
|
||||
const { resolveModelInfo } = await import("../resolveModelInfo")
|
||||
const providerId = parseProviderId("openrouter")
|
||||
const store = makeStore({ providerId })
|
||||
// The mode-specific state field is empty (migration wrote providers.json
|
||||
// only), so readSelection falls through to the committed providers.json
|
||||
// selection. The handler must surface that instead of a catalog default.
|
||||
vi.mocked(store.readSelection).mockImplementation((_, mode) =>
|
||||
mode === "act"
|
||||
? {
|
||||
providerId,
|
||||
modelId: "anthropic/claude-sonnet-5",
|
||||
modelInfo: { name: "Claude Sonnet 5", supportsPromptCache: true, contextWindow: 200_000 },
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
const catalog = makeCatalog()
|
||||
vi.mocked(catalog.peekModels).mockReturnValue(
|
||||
peekResult(
|
||||
"openrouter",
|
||||
[
|
||||
[
|
||||
"anthropic/claude-sonnet-4.5",
|
||||
{ name: "Claude Sonnet 4.5", supportsPromptCache: true, contextWindow: 1_000_000 },
|
||||
],
|
||||
],
|
||||
"anthropic/claude-sonnet-4.5",
|
||||
),
|
||||
)
|
||||
|
||||
const response = await resolveModelInfo(makeController(store, catalog), {
|
||||
providerId: "openrouter",
|
||||
})
|
||||
|
||||
expect(response.source).toBe("committed-selection")
|
||||
expect(response.modelId).toBe("anthropic/claude-sonnet-5")
|
||||
expect(response.modelInfo?.contextWindow).toBe(200_000)
|
||||
// The committed selection short-circuits before any catalog lookup.
|
||||
expect(catalog.peekModels).not.toHaveBeenCalled()
|
||||
expect(catalog.resolveModels).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("returns sdk-default when the request omits a model id and the catalog has a default", async () => {
|
||||
const { resolveModelInfo } = await import("../resolveModelInfo")
|
||||
const store = makeStore({ providerId: parseProviderId("deepseek") })
|
||||
|
||||
@@ -7,13 +7,18 @@ export const CLINE_PASS_PROVIDER_ID = "cline-pass"
|
||||
/**
|
||||
* ClinePass always uses the user's personal Cline account balance.
|
||||
*
|
||||
* The account switch is a network round-trip (plus a possible token refresh),
|
||||
* so it runs fire-and-forget: callers must not block the config update — or
|
||||
* the state post that re-renders the settings UI — on it. Auth state changes
|
||||
* propagate to the webview separately once the switch completes.
|
||||
*
|
||||
* This is intentionally best-effort: selecting the provider should still be
|
||||
* saved even if the account switch fails.
|
||||
*/
|
||||
export async function clearOrganizationForClinePassProviderSelection(
|
||||
export function clearOrganizationForClinePassProviderSelection(
|
||||
controller: Controller,
|
||||
apiConfiguration: Pick<ApiConfiguration, "planModeApiProvider" | "actModeApiProvider">,
|
||||
): Promise<void> {
|
||||
): void {
|
||||
if (
|
||||
apiConfiguration.planModeApiProvider !== CLINE_PASS_PROVIDER_ID &&
|
||||
apiConfiguration.actModeApiProvider !== CLINE_PASS_PROVIDER_ID
|
||||
@@ -21,9 +26,7 @@ export async function clearOrganizationForClinePassProviderSelection(
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await controller.accountService.switchAccount(undefined)
|
||||
} catch (error) {
|
||||
controller.accountService.switchAccount(undefined).catch((error) => {
|
||||
Logger.debug("Failed to switch ClinePass to personal account", { error })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -52,6 +52,23 @@ export function hasProviderCatalogStateController(
|
||||
return typeof candidate.stateManager?.setGlobalStateBatch === "function"
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a provider's models through the SDK provider catalog and return
|
||||
* them as a plain record, throwing on catalog errors. Shared by the
|
||||
* per-provider refresh handlers, which are thin RPC adapters over this call.
|
||||
*/
|
||||
export async function resolveProviderModelsRecord(
|
||||
controller: ProviderCatalogController,
|
||||
providerId: string,
|
||||
options?: { readonly forceRefresh?: boolean },
|
||||
): Promise<Record<string, ModelInfo>> {
|
||||
const result = await controller.getProviderCatalog().resolveModels(parseProviderId(providerId), options)
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error.message)
|
||||
}
|
||||
return Object.fromEntries(result.models)
|
||||
}
|
||||
|
||||
export function parseProviderIdRequest(rawProviderId: string | undefined, fieldName = "provider_id"): ProviderId {
|
||||
const providerId = rawProviderId?.trim()
|
||||
if (!providerId) {
|
||||
|
||||
@@ -1,304 +1,14 @@
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { getProviderCollectionSync } from "@cline/llms"
|
||||
import { ensureCacheDirectoryExists, GlobalFileNames } from "@core/storage/disk"
|
||||
import { ANTHROPIC_MAX_THINKING_BUDGET, ModelInfo } from "@shared/api"
|
||||
import { fileExistsAtPath } from "@utils/fs"
|
||||
import { parsePrice } from "@utils/model-utils"
|
||||
import axios from "axios"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { adaptSdkModelInfo } from "@/sdk/model-catalog/shape-adapter"
|
||||
import { getAxiosSettings } from "@/shared/net"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { Controller } from ".."
|
||||
import type { ModelInfo } from "@shared/api"
|
||||
import { type ProviderCatalogController, resolveProviderModelsRecord } from "./providerCatalogShared"
|
||||
|
||||
/**
|
||||
* Baseten's curated model catalog from the SDK. Used as a fallback
|
||||
* when the live API fetch fails. Adapted to the extension `ModelInfo`
|
||||
* shape so the shape matches what `models` is collecting.
|
||||
* Refreshes the Baseten models and returns application types.
|
||||
*
|
||||
* Model catalogs are consolidated in the SDK: `resolveProviderConfig` serves
|
||||
* the models.dev-backed catalog and, when a Baseten API key is configured,
|
||||
* merges in the SDK's authenticated Baseten model fetch — the same source
|
||||
* the CLI uses.
|
||||
*/
|
||||
function getBasetenSdkModels(): Record<string, ModelInfo> {
|
||||
const collection = getProviderCollectionSync("baseten")
|
||||
if (!collection) {
|
||||
return {}
|
||||
}
|
||||
const result: Record<string, ModelInfo> = {}
|
||||
for (const [modelId, sdkInfo] of Object.entries(collection.models)) {
|
||||
result[modelId] = adaptSdkModelInfo(sdkInfo)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Track pending refresh promise to prevent duplicate concurrent fetches
|
||||
let pendingRefresh: Promise<Record<string, ModelInfo>> | null = null
|
||||
|
||||
/**
|
||||
* Core function: Refreshes the Baseten models and returns application types
|
||||
* @param controller The controller instance
|
||||
* @returns Record of model ID to ModelInfo (application types)
|
||||
*/
|
||||
// TODO(sdk-consolidation): Live-fetches Baseten's /models endpoint and parses
|
||||
// live pricing + reasoning support into ModelInfo. The SDK has a generic
|
||||
// models-URL fetcher but it returns ids-only and (for providers with a
|
||||
// registered modelsSourceUrl) REPLACES the curated catalog rather than merging,
|
||||
// so a naive migration would regress metadata. See the detailed note in
|
||||
// refreshGroqModels.ts; share via the SDK + delete this handler + RPC once the
|
||||
// SDK supports rich/merged per-provider live models for all clients.
|
||||
export async function refreshBasetenModels(controller: Controller): Promise<Record<string, ModelInfo>> {
|
||||
// Check in-memory cache first
|
||||
const cache = StateManager.get().getModelsCache("baseten")
|
||||
if (cache) {
|
||||
return cache
|
||||
}
|
||||
|
||||
// If a fetch is already in progress, return the same promise
|
||||
if (pendingRefresh) {
|
||||
return pendingRefresh
|
||||
}
|
||||
|
||||
// Start new fetch and track the promise
|
||||
pendingRefresh = (async () => {
|
||||
try {
|
||||
return await fetchAndCacheModels(controller)
|
||||
} finally {
|
||||
// Clear pending promise when done (success or error)
|
||||
pendingRefresh = null
|
||||
}
|
||||
})()
|
||||
|
||||
return pendingRefresh
|
||||
}
|
||||
|
||||
async function fetchAndCacheModels(controller: Controller): Promise<Record<string, ModelInfo>> {
|
||||
const basetenModelsFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.basetenModels)
|
||||
|
||||
// Get the Baseten API key from the controller's state
|
||||
const basetenApiKey = controller.stateManager.getSecretKey("basetenApiKey")
|
||||
|
||||
const models: Record<string, Partial<ModelInfo> & { supportedFeatures?: string[] }> = {}
|
||||
// The SDK catalog is Baseten's curated model list. Used here for
|
||||
// pricing/capability defaults when the live API doesn't surface
|
||||
// them, and as the offline fallback below when the API fetch fails.
|
||||
const sdkModels = getBasetenSdkModels()
|
||||
try {
|
||||
if (basetenApiKey) {
|
||||
// Ensure the API key is properly formatted
|
||||
const cleanApiKey = basetenApiKey.trim()
|
||||
if (!cleanApiKey) {
|
||||
throw new Error("Invalid Baseten API key format")
|
||||
}
|
||||
|
||||
const response = await axios.get("https://inference.baseten.co/v1/models", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${cleanApiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "Cline-VSCode-Extension",
|
||||
},
|
||||
timeout: 10000, // 10 second timeout
|
||||
...getAxiosSettings(),
|
||||
})
|
||||
|
||||
const rawModels = response?.data?.data
|
||||
|
||||
if (rawModels && Array.isArray(rawModels)) {
|
||||
for (const rawModel of rawModels) {
|
||||
// Filter out non-chat models and validate model capabilities
|
||||
if (!isValidChatModel(rawModel)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// SDK pricing/capability defaults for this model id, if any.
|
||||
const staticModelInfo = sdkModels[rawModel.id]
|
||||
const supportThinking = rawModel?.supported_features?.some(
|
||||
(p: string) => p === "reasoning_effort" || p === "reasoning",
|
||||
)
|
||||
|
||||
const modelInfo: Partial<ModelInfo> & { supportedFeatures?: string[] } = {
|
||||
maxTokens: rawModel.max_completion_tokens || staticModelInfo?.maxTokens,
|
||||
contextWindow: rawModel.context_length || staticModelInfo?.contextWindow,
|
||||
supportsImages: false, // Baseten model APIs does not support image input
|
||||
supportsPromptCache: staticModelInfo?.supportsPromptCache || false,
|
||||
inputPrice: parsePrice(rawModel.pricing?.prompt) || staticModelInfo?.inputPrice || 0,
|
||||
outputPrice: parsePrice(rawModel.pricing?.completion) || staticModelInfo?.outputPrice || 0,
|
||||
cacheWritesPrice: staticModelInfo?.cacheWritesPrice || 0,
|
||||
cacheReadsPrice: staticModelInfo?.cacheReadsPrice || 0,
|
||||
description: generateModelDescription(rawModel, staticModelInfo),
|
||||
supportedFeatures: rawModel.supported_features || [],
|
||||
supportsReasoning: supportThinking || false,
|
||||
// If thinking is supported, set maxBudget with a default value as a placeholder
|
||||
// to ensure it has a valid thinkingConfig that lets the application know thinking is supported.
|
||||
thinkingConfig: supportThinking ? { maxBudget: ANTHROPIC_MAX_THINKING_BUDGET } : undefined,
|
||||
}
|
||||
|
||||
models[rawModel.id] = modelInfo
|
||||
}
|
||||
}
|
||||
// Cache the fetched models to disk
|
||||
await fs.writeFile(basetenModelsFilePath, JSON.stringify(models))
|
||||
}
|
||||
|
||||
// If no API key is set or models is empty, throw an error to trigger fallback
|
||||
if (Object.keys(models).length === 0) {
|
||||
throw new Error("No Baseten API key set or no models fetched")
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error("Error fetching Baseten models:", error)
|
||||
|
||||
// Provide more specific error messages
|
||||
let errorMessage = "Unknown error occurred"
|
||||
if (axios.isAxiosError(error)) {
|
||||
if (error.response?.status === 401) {
|
||||
errorMessage = "Invalid Baseten API key. Please check your API key in settings."
|
||||
} else if (error.response?.status === 403) {
|
||||
errorMessage = "Access forbidden. Please verify your Baseten API key has the correct permissions."
|
||||
} else if (error.response?.status === 429) {
|
||||
errorMessage = "Rate limit exceeded. Please try again later."
|
||||
} else if (error.code === "ECONNABORTED") {
|
||||
errorMessage = "Request timeout. Please check your internet connection."
|
||||
} else {
|
||||
errorMessage = `API request failed: ${error.response?.status || error.code || "Unknown error"}`
|
||||
}
|
||||
} else if (error instanceof Error) {
|
||||
errorMessage = error.message
|
||||
}
|
||||
|
||||
Logger.error("Baseten API Error:", errorMessage)
|
||||
|
||||
// If we failed to fetch models, try to read cached models first
|
||||
const cachedModels = await readBasetenModels()
|
||||
if (cachedModels && Object.keys(cachedModels).length > 0) {
|
||||
// Use all cached models (no filtering)
|
||||
for (const [modelId, modelInfo] of Object.entries(cachedModels)) {
|
||||
models[modelId] = modelInfo
|
||||
}
|
||||
} else {
|
||||
// Fall back to the SDK's curated Baseten catalog. Same shape
|
||||
// the live-fetch path produces, just with no API response to
|
||||
// merge.
|
||||
for (const [modelId, modelInfo] of Object.entries(sdkModels)) {
|
||||
models[modelId] = {
|
||||
maxTokens: modelInfo.maxTokens,
|
||||
contextWindow: modelInfo.contextWindow,
|
||||
supportsImages: modelInfo.supportsImages,
|
||||
supportsPromptCache: modelInfo.supportsPromptCache,
|
||||
inputPrice: modelInfo.inputPrice,
|
||||
outputPrice: modelInfo.outputPrice,
|
||||
cacheWritesPrice: modelInfo.cacheWritesPrice ?? 0,
|
||||
cacheReadsPrice: modelInfo.cacheReadsPrice ?? 0,
|
||||
description: modelInfo.description ?? `${modelId} model`,
|
||||
supportsReasoning: modelInfo.supportsReasoning ?? false,
|
||||
thinkingConfig: modelInfo.supportsReasoning ? { maxBudget: ANTHROPIC_MAX_THINKING_BUDGET } : undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the Record<string, Partial<ModelInfo>> to Record<string, ModelInfo>
|
||||
// by filling in any missing required fields with defaults
|
||||
const typedModels: Record<string, ModelInfo> = {}
|
||||
for (const [key, model] of Object.entries(models)) {
|
||||
typedModels[key] = {
|
||||
maxTokens: model.maxTokens ?? 8192,
|
||||
contextWindow: model.contextWindow ?? 8192,
|
||||
supportsImages: model.supportsImages ?? false,
|
||||
supportsPromptCache: model.supportsPromptCache ?? false,
|
||||
inputPrice: model.inputPrice ?? 0,
|
||||
outputPrice: model.outputPrice ?? 0,
|
||||
cacheWritesPrice: model.cacheWritesPrice ?? 0,
|
||||
cacheReadsPrice: model.cacheReadsPrice ?? 0,
|
||||
description: model.description ?? "",
|
||||
tiers: model.tiers,
|
||||
supportsReasoning: model.supportsReasoning || false,
|
||||
thinkingConfig: model.supportsReasoning ? { maxBudget: ANTHROPIC_MAX_THINKING_BUDGET } : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// Store in StateManager's in-memory cache
|
||||
StateManager.get().setModelsCache("baseten", typedModels)
|
||||
|
||||
return typedModels
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads cached Baseten models from disk (application types)
|
||||
*/
|
||||
async function readBasetenModels(): Promise<Record<string, Partial<ModelInfo>> | undefined> {
|
||||
const basetenModelsFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.basetenModels)
|
||||
const fileExists = await fileExistsAtPath(basetenModelsFilePath)
|
||||
if (fileExists) {
|
||||
try {
|
||||
const fileContents = await fs.readFile(basetenModelsFilePath, "utf8")
|
||||
return JSON.parse(fileContents)
|
||||
} catch (error) {
|
||||
Logger.error("Error reading cached Baseten models:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a model is suitable for chat completions
|
||||
*/
|
||||
function isValidChatModel(rawModel: any): boolean {
|
||||
// Filter out non-chat models (whisper, TTS, guard models, etc.)
|
||||
if (rawModel.id.includes("whisper") || rawModel.id.includes("tts") || rawModel.id.includes("embedding")) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if model supports chat completions
|
||||
if (rawModel.object === "model" && rawModel.id) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a descriptive name for the model
|
||||
*/
|
||||
function generateModelDescription(rawModel: any, staticModelInfo?: any): string {
|
||||
// Use static description if available and preferred
|
||||
if (staticModelInfo?.description) {
|
||||
return staticModelInfo.description
|
||||
}
|
||||
|
||||
// Use API description if available
|
||||
if (rawModel.description) {
|
||||
const contextWindow = rawModel.context_length
|
||||
const quantization = rawModel.quantization
|
||||
const features = rawModel.supported_features || []
|
||||
|
||||
let description = rawModel.description
|
||||
|
||||
// Add technical details if available
|
||||
const technicalDetails = []
|
||||
if (contextWindow) {
|
||||
technicalDetails.push(`${contextWindow.toLocaleString()} token context`)
|
||||
}
|
||||
if (quantization) {
|
||||
technicalDetails.push(`${quantization} precision`)
|
||||
}
|
||||
if (features.length > 0) {
|
||||
const featureList = features.join(", ")
|
||||
technicalDetails.push(`supports ${featureList}`)
|
||||
}
|
||||
|
||||
if (technicalDetails.length > 0) {
|
||||
description += ` (${technicalDetails.join(", ")})`
|
||||
}
|
||||
|
||||
return description
|
||||
}
|
||||
|
||||
// Fallback: use name or model ID
|
||||
const modelName = rawModel.name || rawModel.id
|
||||
const contextWindow = rawModel.context_length
|
||||
const ownedBy = rawModel.owned_by || "Baseten"
|
||||
|
||||
if (contextWindow) {
|
||||
return `${ownedBy} ${modelName} with ${contextWindow.toLocaleString()} token context window`
|
||||
}
|
||||
|
||||
return `${ownedBy} model: ${modelName}`
|
||||
export async function refreshBasetenModels(controller: ProviderCatalogController): Promise<Record<string, ModelInfo>> {
|
||||
return resolveProviderModelsRecord(controller, "baseten")
|
||||
}
|
||||
|
||||
@@ -1,316 +1,13 @@
|
||||
import { getProviderCollectionSync } from "@cline/llms"
|
||||
import { ensureCacheDirectoryExists, GlobalFileNames } from "@core/storage/disk"
|
||||
import { ModelInfo } from "@shared/api"
|
||||
import { fileExistsAtPath } from "@utils/fs"
|
||||
import axios from "axios"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { adaptSdkModelInfo } from "@/sdk/model-catalog/shape-adapter"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { getAxiosSettings } from "@/shared/net"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { Controller } from ".."
|
||||
import type { ModelInfo } from "@shared/api"
|
||||
import { type ProviderCatalogController, resolveProviderModelsRecord } from "./providerCatalogShared"
|
||||
|
||||
/**
|
||||
* Groq's curated catalog from the SDK, used as the static-pricing
|
||||
* source for live API responses and as the offline fallback when the
|
||||
* live fetch fails.
|
||||
* Refreshes the Groq models and returns application types.
|
||||
*
|
||||
* Model catalogs are consolidated in the SDK: this resolves through the
|
||||
* models.dev-backed catalog (bundled + live refresh) via
|
||||
* `resolveProviderConfig`, the same source the CLI uses.
|
||||
*/
|
||||
function getGroqSdkModels(): Record<string, ModelInfo> {
|
||||
const collection = getProviderCollectionSync("groq")
|
||||
if (!collection) {
|
||||
return {}
|
||||
}
|
||||
const result: Record<string, ModelInfo> = {}
|
||||
for (const [modelId, sdkInfo] of Object.entries(collection.models)) {
|
||||
result[modelId] = adaptSdkModelInfo(sdkInfo)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Track pending refresh promise to prevent duplicate concurrent fetches
|
||||
let pendingRefresh: Promise<Record<string, ModelInfo>> | null = null
|
||||
|
||||
/**
|
||||
* Core function: Refreshes the Groq models and returns application types
|
||||
* @param controller The controller instance
|
||||
* @returns Record of model ID to ModelInfo (application types)
|
||||
*/
|
||||
// TODO(sdk-consolidation): This handler live-fetches Groq's /models endpoint and
|
||||
// enriches each model with curated pricing/capabilities. The SDK HAS a generic
|
||||
// models-URL fetcher (sdk/packages/core/src/services/providers/model-source.ts
|
||||
// `fetchModelIdsFromSource` + `resolveModelsSourceUrl`), but it is NOT a drop-in
|
||||
// replacement:
|
||||
// 1. It returns model *ids only*; ids unknown to the curated catalog get
|
||||
// placeholder ModelInfo (no real pricing/context/capabilities).
|
||||
// 2. Worse, `mergeKnownModels` treats a registered `modelsSourceUrl` as the
|
||||
// "authoritative installed list" (Ollama/LM Studio semantics) and DISCARDS
|
||||
// the bundled curated catalog when the live fetch returns results.
|
||||
// So simply registering `modelsSourceUrl` for Groq would regress rich model
|
||||
// metadata. Proper consolidation needs an SDK enhancement first: either a
|
||||
// merge-mode that layers live ids on top of the curated catalog, or a richer
|
||||
// per-provider fetch that parses full ModelInfo. Once the SDK supports that for
|
||||
// all clients (incl. CLI), delete this extension-only handler + its RPC.
|
||||
export async function refreshGroqModels(controller: Controller): Promise<Record<string, ModelInfo>> {
|
||||
// Check in-memory cache first
|
||||
const cache = StateManager.get().getModelsCache("groq")
|
||||
if (cache) {
|
||||
return cache
|
||||
}
|
||||
|
||||
// If a fetch is already in progress, return the same promise
|
||||
if (pendingRefresh) {
|
||||
return pendingRefresh
|
||||
}
|
||||
|
||||
// Start new fetch and track the promise
|
||||
pendingRefresh = (async () => {
|
||||
try {
|
||||
return await fetchAndCacheModels(controller)
|
||||
} finally {
|
||||
// Clear pending promise when done (success or error)
|
||||
pendingRefresh = null
|
||||
}
|
||||
})()
|
||||
|
||||
return pendingRefresh
|
||||
}
|
||||
|
||||
async function fetchAndCacheModels(controller: Controller): Promise<Record<string, ModelInfo>> {
|
||||
const groqModelsFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.groqModels)
|
||||
|
||||
const groqApiKey = controller.stateManager.getSecretKey("groqApiKey")
|
||||
|
||||
let models: Record<string, Partial<ModelInfo>> = {}
|
||||
const sdkModels = getGroqSdkModels()
|
||||
try {
|
||||
if (!groqApiKey) {
|
||||
Logger.log("No Groq API key found, using SDK catalog as fallback")
|
||||
// Don't throw an error, just use SDK catalog.
|
||||
for (const [modelId, modelInfo] of Object.entries(sdkModels)) {
|
||||
models[modelId] = {
|
||||
maxTokens: modelInfo.maxTokens,
|
||||
contextWindow: modelInfo.contextWindow,
|
||||
supportsImages: modelInfo.supportsImages,
|
||||
supportsPromptCache: modelInfo.supportsPromptCache,
|
||||
inputPrice: modelInfo.inputPrice,
|
||||
outputPrice: modelInfo.outputPrice,
|
||||
cacheWritesPrice: modelInfo.cacheWritesPrice ?? 0,
|
||||
cacheReadsPrice: modelInfo.cacheReadsPrice ?? 0,
|
||||
description: modelInfo.description ?? `${modelId} model`,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Ensure the API key is properly formatted
|
||||
const cleanApiKey = groqApiKey.trim()
|
||||
if (!cleanApiKey.startsWith("gsk_")) {
|
||||
throw new Error("Invalid Groq API key format. Groq API keys should start with 'gsk_'")
|
||||
}
|
||||
|
||||
Logger.log("Fetching Groq models with API key:", cleanApiKey.substring(0, 10) + "...")
|
||||
|
||||
const response = await axios.get("https://api.groq.com/openai/v1/models", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${cleanApiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "Cline-VSCode-Extension",
|
||||
},
|
||||
timeout: 10000, // 10 second timeout
|
||||
...getAxiosSettings(),
|
||||
})
|
||||
|
||||
if (response.data?.data) {
|
||||
const rawModels = response.data.data
|
||||
|
||||
for (const rawModel of rawModels) {
|
||||
// Filter out non-chat models and validate model capabilities
|
||||
if (!isValidChatModel(rawModel)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if we have static pricing information for this model
|
||||
const staticModelInfo = sdkModels[rawModel.id as keyof typeof sdkModels]
|
||||
|
||||
const modelInfo: Partial<ModelInfo> = {
|
||||
maxTokens: rawModel.max_completion_tokens || staticModelInfo?.maxTokens || 8192,
|
||||
contextWindow: rawModel.context_window || staticModelInfo?.contextWindow || 8192,
|
||||
supportsImages: detectImageSupport(rawModel, staticModelInfo),
|
||||
supportsPromptCache: staticModelInfo?.supportsPromptCache || false,
|
||||
inputPrice: staticModelInfo?.inputPrice || 0,
|
||||
outputPrice: staticModelInfo?.outputPrice || 0,
|
||||
cacheWritesPrice: staticModelInfo?.cacheWritesPrice || 0,
|
||||
cacheReadsPrice: staticModelInfo?.cacheReadsPrice || 0,
|
||||
description: generateModelDescription(rawModel, staticModelInfo),
|
||||
}
|
||||
|
||||
models[rawModel.id] = modelInfo
|
||||
}
|
||||
|
||||
await fs.writeFile(groqModelsFilePath, JSON.stringify(models))
|
||||
Logger.log("Groq models fetched and saved", models)
|
||||
} else {
|
||||
Logger.error("Invalid response from Groq API")
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error("Error fetching Groq models:", error)
|
||||
|
||||
// Provide more specific error messages
|
||||
let errorMessage = "Unknown error occurred"
|
||||
if (axios.isAxiosError(error)) {
|
||||
if (error.response?.status === 401) {
|
||||
errorMessage = "Invalid Groq API key. Please check your API key in settings."
|
||||
} else if (error.response?.status === 403) {
|
||||
errorMessage = "Access forbidden. Please verify your Groq API key has the correct permissions."
|
||||
} else if (error.response?.status === 429) {
|
||||
errorMessage = "Rate limit exceeded. Please try again later."
|
||||
} else if (error.code === "ECONNABORTED") {
|
||||
errorMessage = "Request timeout. Please check your internet connection."
|
||||
} else {
|
||||
errorMessage = `API request failed: ${error.response?.status || error.code || "Unknown error"}`
|
||||
}
|
||||
} else if (error instanceof Error) {
|
||||
errorMessage = error.message
|
||||
}
|
||||
|
||||
telemetryService.captureProviderApiError({
|
||||
ulid: controller.task?.ulid || "",
|
||||
errorMessage,
|
||||
errorStatus: error.status,
|
||||
model: "groq",
|
||||
})
|
||||
|
||||
// If we failed to fetch models, try to read cached models first
|
||||
const cachedModels = await readGroqModels()
|
||||
if (cachedModels && Object.keys(cachedModels).length > 0) {
|
||||
Logger.log("Using cached Groq models")
|
||||
models = cachedModels
|
||||
} else {
|
||||
// Fall back to the SDK's curated Groq catalog.
|
||||
Logger.log("Using SDK Groq catalog as fallback")
|
||||
for (const [modelId, modelInfo] of Object.entries(sdkModels)) {
|
||||
models[modelId] = {
|
||||
maxTokens: modelInfo.maxTokens,
|
||||
contextWindow: modelInfo.contextWindow,
|
||||
supportsImages: modelInfo.supportsImages,
|
||||
supportsPromptCache: modelInfo.supportsPromptCache,
|
||||
inputPrice: modelInfo.inputPrice,
|
||||
outputPrice: modelInfo.outputPrice,
|
||||
cacheWritesPrice: modelInfo.cacheWritesPrice ?? 0,
|
||||
cacheReadsPrice: modelInfo.cacheReadsPrice ?? 0,
|
||||
description: modelInfo.description ?? `${modelId} model`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the Record<string, Partial<ModelInfo>> to Record<string, ModelInfo>
|
||||
// by filling in any missing required fields with defaults
|
||||
const typedModels: Record<string, ModelInfo> = {}
|
||||
for (const [key, model] of Object.entries(models)) {
|
||||
typedModels[key] = {
|
||||
maxTokens: model.maxTokens ?? 8192,
|
||||
contextWindow: model.contextWindow ?? 8192,
|
||||
supportsImages: model.supportsImages ?? false,
|
||||
supportsPromptCache: model.supportsPromptCache ?? false,
|
||||
inputPrice: model.inputPrice ?? 0,
|
||||
outputPrice: model.outputPrice ?? 0,
|
||||
cacheWritesPrice: model.cacheWritesPrice ?? 0,
|
||||
cacheReadsPrice: model.cacheReadsPrice ?? 0,
|
||||
description: model.description ?? "",
|
||||
tiers: model.tiers,
|
||||
}
|
||||
}
|
||||
|
||||
// Store in StateManager's in-memory cache
|
||||
StateManager.get().setModelsCache("groq", typedModels)
|
||||
|
||||
return typedModels
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads cached Groq models from disk (application types)
|
||||
*/
|
||||
async function readGroqModels(): Promise<Record<string, Partial<ModelInfo>> | undefined> {
|
||||
const groqModelsFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.groqModels)
|
||||
const fileExists = await fileExistsAtPath(groqModelsFilePath)
|
||||
if (fileExists) {
|
||||
try {
|
||||
const fileContents = await fs.readFile(groqModelsFilePath, "utf8")
|
||||
return JSON.parse(fileContents)
|
||||
} catch (error) {
|
||||
Logger.error("Error reading cached Groq models:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a model is suitable for chat completions
|
||||
*/
|
||||
function isValidChatModel(rawModel: any): boolean {
|
||||
// Check if model is active (if the property exists)
|
||||
if (Object.hasOwn(rawModel, "active") && !rawModel.active) {
|
||||
return false
|
||||
}
|
||||
// Filter out non-chat models (whisper, TTS, guard models, etc.)
|
||||
if (
|
||||
rawModel.id.includes("whisper") ||
|
||||
rawModel.id.includes("tts") ||
|
||||
rawModel.id.includes("guard") ||
|
||||
rawModel.id.includes("embedding") ||
|
||||
rawModel.id.includes("moderation") ||
|
||||
rawModel.id.includes("allam")
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if model supports chat completions
|
||||
if (rawModel.object === "model" && rawModel.id) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if a model supports image input
|
||||
*/
|
||||
function detectImageSupport(rawModel: any, staticModelInfo?: any): boolean {
|
||||
// Use static info if available
|
||||
if (staticModelInfo?.supportsImages !== undefined) {
|
||||
return staticModelInfo.supportsImages
|
||||
}
|
||||
|
||||
// Detect based on model name patterns
|
||||
const modelId = rawModel.id.toLowerCase()
|
||||
if (modelId.includes("vision") || modelId.includes("maverick") || modelId.includes("scout")) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a descriptive name for the model
|
||||
*/
|
||||
function generateModelDescription(rawModel: any, staticModelInfo?: any): string {
|
||||
// Use static description if available
|
||||
if (staticModelInfo?.description) {
|
||||
return staticModelInfo.description
|
||||
}
|
||||
|
||||
// Generate description based on model characteristics
|
||||
const modelId = rawModel.id
|
||||
const contextWindow = rawModel.context_window || 8192
|
||||
const ownedBy = rawModel.owned_by || "Unknown"
|
||||
|
||||
// Special handling for new models
|
||||
if (modelId.includes("compound")) {
|
||||
return `${ownedBy}'s ${modelId} model with ${contextWindow.toLocaleString()} token context window - Advanced compound architecture`
|
||||
}
|
||||
|
||||
return `${ownedBy} model with ${contextWindow.toLocaleString()} token context window`
|
||||
export async function refreshGroqModels(controller: ProviderCatalogController): Promise<Record<string, ModelInfo>> {
|
||||
return resolveProviderModelsRecord(controller, "groq")
|
||||
}
|
||||
|
||||
@@ -1,78 +1,20 @@
|
||||
import { GlobalFileNames } from "@core/storage/disk"
|
||||
import { EmptyRequest } from "@shared/proto/cline/common"
|
||||
import { OpenRouterCompatibleModelInfo, OpenRouterModelInfo } from "@shared/proto/cline/models"
|
||||
import axios from "axios"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { getAxiosSettings } from "@/shared/net"
|
||||
import { Controller } from ".."
|
||||
import { OpenRouterCompatibleModelInfo } from "@shared/proto/cline/models"
|
||||
import { toProtobufModels } from "@/shared/proto-conversions/models/typeConversion"
|
||||
import { type ProviderCatalogController, resolveProviderModelsRecord } from "./providerCatalogShared"
|
||||
|
||||
/**
|
||||
* The raw model information returned by the Hicap API to list models
|
||||
* Refreshes the Hicap models and returns the updated model list.
|
||||
*
|
||||
* Model catalogs are consolidated in the SDK: `resolveProviderConfig` serves
|
||||
* the models.dev-backed catalog and, when a Hicap API key is configured,
|
||||
* merges in the SDK's authenticated Hicap model fetch — the same source the
|
||||
* CLI uses.
|
||||
*/
|
||||
interface HicapRawModelInfo {
|
||||
id: string
|
||||
object: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the Hicap models and returns the updated model list
|
||||
* @param controller The controller instance
|
||||
* @param request Empty request object
|
||||
* @returns Response containing the OpenRouter models
|
||||
*/
|
||||
// TODO(sdk-consolidation): Live-fetches Hicap's /models endpoint. The SDK's
|
||||
// generic models-URL fetcher returns ids-only and (for providers with a
|
||||
// registered modelsSourceUrl) REPLACES rather than merges the curated catalog,
|
||||
// so a naive migration would regress metadata. See the detailed note in
|
||||
// refreshGroqModels.ts; share via the SDK + delete this handler + RPC once the
|
||||
// SDK supports rich/merged per-provider live models for all clients (incl. CLI).
|
||||
export async function refreshHicapModels(controller: Controller, _request: EmptyRequest): Promise<OpenRouterCompatibleModelInfo> {
|
||||
const hicapModelsFilePath = path.join(await ensureCacheDirectoryExists(controller), GlobalFileNames.hicapModels)
|
||||
|
||||
const models: Record<string, OpenRouterModelInfo> = {}
|
||||
try {
|
||||
// Get the Hicap API key from the controller's state
|
||||
const hicapApiKey = controller.stateManager.getSecretKey("hicapApiKey")
|
||||
|
||||
const response = await axios.get("https://api.hicap.ai/v2/openai/models", {
|
||||
headers: {
|
||||
"api-key": hicapApiKey,
|
||||
},
|
||||
...getAxiosSettings(),
|
||||
})
|
||||
|
||||
if (response.data?.data) {
|
||||
const rawModels = response.data.data
|
||||
|
||||
for (const rawModel of rawModels as HicapRawModelInfo[]) {
|
||||
models[rawModel.id] = {
|
||||
maxTokens: -1,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
cacheWritesPrice: 0,
|
||||
cacheReadsPrice: 0,
|
||||
tiers: [],
|
||||
description: "",
|
||||
}
|
||||
}
|
||||
}
|
||||
await fs.writeFile(hicapModelsFilePath, JSON.stringify(models))
|
||||
} catch (_error) {
|
||||
// If we failed to fetch models, keep whatever we have.
|
||||
}
|
||||
|
||||
return OpenRouterCompatibleModelInfo.create({ models })
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the cache directory exists and returns its path
|
||||
*/
|
||||
async function ensureCacheDirectoryExists(controller: Controller): Promise<string> {
|
||||
const cacheDir = path.join(controller.context.globalStorageUri.fsPath, "cache")
|
||||
await fs.mkdir(cacheDir, { recursive: true })
|
||||
return cacheDir
|
||||
export async function refreshHicapModels(
|
||||
controller: ProviderCatalogController,
|
||||
_request: EmptyRequest,
|
||||
): Promise<OpenRouterCompatibleModelInfo> {
|
||||
const models = await resolveProviderModelsRecord(controller, "hicap")
|
||||
return OpenRouterCompatibleModelInfo.create({ models: toProtobufModels(models) })
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user