Compare commits

..

67 Commits

Author SHA1 Message Date
Saoud Rizwan c2faf38d72 chore(cli): release v3.0.46 2026-07-18 23:14:41 -07:00
Saoud Rizwan 4dab17769c fix(cli): detect real insufficient_credits error from Cline API (#12394) 2026-07-18 23:11:46 -07:00
Saoud Rizwan 396032cd3b chore(cli): release v3.0.45 2026-07-18 21:03:37 -07:00
Saoud Rizwan f33ab3a872 chore(sdk): release v0.0.65 2026-07-18 20:46:28 -07:00
Saoud Rizwan 2ca8364ffc docs: add Kimi K3 to ClinePass model list and reference pricing (#12393) 2026-07-18 20:35:48 -07:00
Saoud Rizwan 2ef81be703 feat(llms): make Claude Code and Codex provider packages optional peers (#12379)
ai-sdk-provider-claude-code and ai-sdk-provider-codex-cli were hard
dependencies of @cline/llms, so every npm install of the cline CLI
pulled their native binaries (~250MB claude-agent-sdk platform binary,
~105MB @openai/codex) even for users who never select those providers.

Move both to optional peerDependencies (kept as devDependencies so
monorepo builds still bundle the JS) and load them via literal dynamic
imports in community.ts, mirroring the existing opencode-sdk pattern.

The Claude Code provider now resolves the claude executable explicitly:
bundled platform package when present, otherwise a user-installed
claude from PATH, passed via defaultSettings.pathToClaudeCodeExecutable.
The agent SDK's own resolution cannot be used from Bun-compiled
binaries because it anchors on the virtual bunfs where node_modules
lookups never see packages on disk. Codex already degrades gracefully
(npx -y @openai/codex, then codex on PATH).
2026-07-18 20:30:24 -07:00
Saoud Rizwan 359445ae0c fix(sdk): stop exposing the team spawn tool to teammates (#12371)
* fix(sdk): retry runs once after refreshing expired OAuth credentials

Teammate and subagent sessions inherit the OAuth access token as a
snapshot at spawn time and had no refresh path: when the token expired
while the lead was blocked (e.g. in team_await_runs), their next model
call died with the provider's raw 401 body. Only the lead's turn-start
sync and runWithAuthRetry could refresh, and neither runs mid-turn.

Add an onAuthError hook to AgentConfig, wired once per session by
LocalRuntimeHost: it refreshes credentials through the shared
single-flight RuntimeOAuthTokenManager and propagates the new key to
the lead, delegated defaults, and all teammates via the existing
updateConnection channels. SessionRuntime retries a run once when it
failed with an auth-like error and the refresh succeeded, continuing
from the persisted trail so completed iterations aren't replayed.

Also fix isLikelyAuthError to lowercase string inputs; the server's
'Unauthorized: ...' message only matched when wrapped in an Error.

* fix(sdk): report errored teammate runs as failed instead of completed

Model-stream failures return results with finishReason 'error' rather
than throwing, so executeQueuedRun marked such runs 'completed' with
the error buried in resultSummary. Throw into the existing failure
path so the run reports status 'failed' (with run.error set and a
RunFailed event) and the retry machinery engages when maxRetries
allows.

* fix(sdk): stop exposing the team spawn tool to teammates

Spawning is lead-only, enforced at execution time, so teammates that
saw team_spawn_teammate in their toolset burned turns on 'Only the
lead agent can manage teammates.' rejections before falling back to
doing the work themselves.
2026-07-18 20:21:35 -07:00
Saoud Rizwan d9e2e9c76b fix(sdk): report errored teammate runs as failed instead of completed (#12370)
* fix(sdk): retry runs once after refreshing expired OAuth credentials

Teammate and subagent sessions inherit the OAuth access token as a
snapshot at spawn time and had no refresh path: when the token expired
while the lead was blocked (e.g. in team_await_runs), their next model
call died with the provider's raw 401 body. Only the lead's turn-start
sync and runWithAuthRetry could refresh, and neither runs mid-turn.

Add an onAuthError hook to AgentConfig, wired once per session by
LocalRuntimeHost: it refreshes credentials through the shared
single-flight RuntimeOAuthTokenManager and propagates the new key to
the lead, delegated defaults, and all teammates via the existing
updateConnection channels. SessionRuntime retries a run once when it
failed with an auth-like error and the refresh succeeded, continuing
from the persisted trail so completed iterations aren't replayed.

Also fix isLikelyAuthError to lowercase string inputs; the server's
'Unauthorized: ...' message only matched when wrapped in an Error.

* fix(sdk): report errored teammate runs as failed instead of completed

Model-stream failures return results with finishReason 'error' rather
than throwing, so executeQueuedRun marked such runs 'completed' with
the error buried in resultSummary. Throw into the existing failure
path so the run reports status 'failed' (with run.error set and a
RunFailed event) and the retry machinery engages when maxRetries
allows.
2026-07-18 20:19:17 -07:00
Saoud Rizwan d859a86a6f fix(sdk): retry runs once after refreshing expired OAuth credentials (#12369)
* fix(sdk): retry runs once after refreshing expired OAuth credentials

Teammate and subagent sessions inherit the OAuth access token as a
snapshot at spawn time and had no refresh path: when the token expired
while the lead was blocked (e.g. in team_await_runs), their next model
call died with the provider's raw 401 body. Only the lead's turn-start
sync and runWithAuthRetry could refresh, and neither runs mid-turn.

Add an onAuthError hook to AgentConfig, wired once per session by
LocalRuntimeHost: it refreshes credentials through the shared
single-flight RuntimeOAuthTokenManager and propagates the new key to
the lead, delegated defaults, and all teammates via the existing
updateConnection channels. SessionRuntime retries a run once when it
failed with an auth-like error and the refresh succeeded, continuing
from the persisted trail so completed iterations aren't replayed.

Also fix isLikelyAuthError to lowercase string inputs; the server's
'Unauthorized: ...' message only matched when wrapped in an Error.

* feat(telemetry): emit user.auth_run_retry when a run is retried after credential refresh

Addresses Greptile review on the auth-retry PR: the refresh itself was
already instrumented (auth_refresh_soft_failure / auth_logged_out fire
inside getValidClineCredentials), but the new retry transition was not.
The recovered flag counts runs that would previously have died with the
raw provider 401 — the direct production measure of this fix working.
2026-07-18 20:08:18 -07:00
Saoud Rizwan 0b7b9c1b3d fix(llms): add Kimi K3 to bundled ClinePass model fallback (#12392)
* fix(llms): add cline-pass/kimi-k3 to bundled model catalog fallback

* fix(llms): derive cline-pass default model from catalog authored order

Adding kimi-k3 (newest releaseDate) to the bundled cline-pass catalog
would have flipped firstGeneratedModelId — which sorts by release date —
to cline-pass/kimi-k3, silently changing the default model for new
ClinePass setups. Use the catalog's authored order instead, which mirrors
the recommended-models endpoint's curated order (intended default first,
subscription models before free ones).
2026-07-18 20:03:19 -07:00
Dominic Cooney 557d725690 fix(vscode): shell mismatch between prompt, execution, and user configuration on Windows (#12331)
* Rationalize shell identification and prompting, especially on Windows.

* Probe all pwsh install locations for the Windows default shell.

The default-shell fallback only checked the Program Files pwsh path,
so Microsoft Store installs of PowerShell 7 fell back to Windows
PowerShell while VS Code's own terminal launched pwsh. Share one
candidate list between the sync default-shell check and the async
PowerShell prober. Also drop an 'as string' cast that hid the
setting's type from the checker.

* Address shell resolution review feedback

* Resolve array-valued terminal profile paths on macOS and Linux too

VS Code permits terminal profile 'path' to be string | string[] on every
platform, not just Windows. The resolver (env expansion, first-existing
selection, PATH lookup) is now platform-generic: it uses the host path
module's separators and delimiter, probes PATHEXT only on Windows, and
treats env var names case-insensitively only on Windows. The macOS and
Linux getters route through it instead of returning the raw config value,
which crashed getShellKind() for array values.

* Apply terminal profile changes at the model-request boundary

A terminal profile change previously triggered a deferred session rebuild
to refresh the run_commands tool description. While a task was running the
rebuild waited, so the description could name one shell while commands
executed in another for the rest of the turn.

Instead of rebuilding, createShellTool now accepts a shell provider
function and re-derives the description each time the runtime reads it,
which happens exactly when a model request is built. The VS Code tool
snapshots {profileId, shell} in that provider; both execution paths (the
background spawn and the foreground terminal, via a new profile parameter
on getOrCreateTerminal) consume the snapshot. Commands produced by an
in-flight inference therefore run with the shell the model was told about,
and a mid-turn profile change takes effect when the tool results are sent
back: the next request names and uses the new shell.

The profile-change session rebuild path (handleTerminalProfileChanged) is
removed along with its deferred-rebuild window.

* Use the real createShellTool in the vitest @cline/core stub

The stub's hand-rolled createShellTool duplicated the 'shell must be a
string' invariant instead of exercising the code that enforces it
(getShellKind via description building), so the array-valued-profile
regression test proved only that the stub threw, not that the real tool
survives. Re-export the real implementation from SDK source — the same
pattern the stub already uses for the apply-patch and editor executors —
and assert on the actual generated descriptions, including that a profile
change is reflected at the next description read.

* Harden shell profile path resolution edge cases

- Warn and skip profile paths containing variable references beyond
  \ (e.g. \) instead of silently probing a
  literal path that can never exist; later candidates and the platform
  default still apply.
- Document that an overriding bash executor in createBuiltinTools bypasses
  the resolved canonical shell and must honor it to keep the run_commands
  description truthful.
2026-07-18 04:23:00 +02:00
Saoud Rizwan 7274d8badc feat(ui): add agent chat components, Storybook, and npm releases (#12374)
* feat(ui): add shared agent chat components and Storybook

* ci(ui): add standalone npm publishing

* docs(ui): keep release commands environment-neutral

* refactor(ui): simplify package validation

* refactor(ui): tighten package and release contracts

* docs(ui): remove duplicate install guidance

* ci(ui): make publishing workflow manual-only
2026-07-17 18:22:57 -07:00
Bee d1837366c0 chore(llms): update model catalog (#12366)
Update model catalog with bun run build:models
Version updated to 1784318695007
2026-07-17 13:28:59 -07:00
Saoud Rizwan c380daf4a3 docs(ui): add adoption primer (#12367) 2026-07-17 13:21:15 -07:00
Bee c564045d81 chore(cli): includes version numbers in hub status output (#12358)
Includes version numbers in hub status output and doctor command to make debugging with user easier.
2026-07-17 05:37:54 +02:00
Saoud Rizwan 9a5e1751b2 chore(cli): release v3.0.44 2026-07-16 18:38:54 -07:00
Saoud Rizwan 131e25e1a1 chore(sdk): release v0.0.64 2026-07-16 18:14:58 -07:00
Saoud Rizwan a7ff007af9 chore(cli): release v3.0.43 2026-07-16 17:52:46 -07:00
Bee ef27f45080 fix: max output token handling (#12031)
* fix: max output token handling

* shared

* max reasoning budgetTokens

* fix unit test

* fix: address review feedback on max output token handling

- OpenRouter effort branch sends only reasoning.effort (OpenRouter rejects
  effort combined with reasoning.max_tokens)
- OpenAI Responses forwards explicit caller maxTokens for API-key usage;
  ChatGPT OAuth and synthesized gateway defaults are still omitted
- Gateway lifts the synthesized default output cap above explicit Anthropic
  reasoning budgets so max_tokens > thinking.budget_tokens holds

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: address second-round review feedback on max output token handling

- Replace the gateway-only requestedMaxTokens field with a defaultedMaxTokens
  flag set when the gateway synthesizes a cap, so explicit maxTokens from
  direct provider callers is forwarded by default (greptile P1)
- Check the parsed hostname instead of a URL substring when detecting the
  ChatGPT OAuth backend (CodeQL)
- Drop the empty else-if branch in toAiSdkMessages in favor of an explicit
  emptiedByDroppedReasoning condition (greptile P2; biome rejects the
  suggested bare continue)
- Dedupe isPositiveFiniteNumber by exporting it from gateway.ts (greptile P2)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor: extract isPositiveFiniteNumber into providers/utils.ts

Move the shared helper to its own module as suggested in review instead
of exporting it from gateway.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: remove unrelated VS Code changes

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-16 17:50:22 -07:00
Dominic Cooney e3c6d51072 fix: recognize frontmatter with a leading UTF-8 BOM (#12277)
* fix(vscode): recognize SKILL.md frontmatter with a leading UTF-8 BOM

SKILL.md files saved with a UTF-8 BOM (e.g. by Windows Notepad's 'UTF-8 with BOM' encoding) were silently skipped and not recognized as skills, because gray-matter/regex-style frontmatter parsers require '---' at byte offset 0 and never accounted for the leading BOM byte sequence Node's utf-8 decoder does not strip.

Fixes the shared parseYamlFrontmatter() helper (used by skills, rules, workflows, and remote skill entries in the VS Code extension) and every duplicated ad-hoc frontmatter regex across the SDK/CLI/hub/desktop-app/example-plugin code paths to strip a leading BOM before matching.

Adds regression tests exercising the exact reported scenario (BOM-prefixed SKILL.md silently missing name/description) in frontmatter.test.ts, skills.test.ts, skill-frontmatter-toggle.test.ts, user-instruction-config-loader.test.ts, and configured-agent-config.test.ts.

Fixes https://github.com/cline/cline/issues/12151

* refactor(shared): centralize UTF-8 BOM stripping

* refactor(shared): add UTF-8 file readers

* docs: guide UTF-8 configuration reads
2026-07-17 09:37:20 +09:00
Saoud Rizwan 48bac25548 chore(sdk): regenerate lockfile for v0.0.63 2026-07-16 17:13:44 -07:00
Saoud Rizwan 37f5f104f3 chore(sdk): release v0.0.63 2026-07-16 16:47:40 -07:00
Saoud Rizwan 3577b52404 feat(core): emit mistake-limit telemetry from the session runtime (#12355)
Moves the task.mistake_limit_reached capture (#12354) from the VS Code
SdkController wrapper into @cline/core so every host (CLI, VS Code,
hub daemon) emits it via its session telemetry service.

The MistakeTracker gains an onLimitTelemetry hook fired exactly once
per limit hit, before the limit decision is resolved — including when
no onConsecutiveMistakeLimitReached callback is configured (the
default-stop path, which the extension-side capture missed). The
orchestrator wires the hook to captureMistakeLimitReached using its
reserved telemetry field, reading sessionId/modelId/providerId at fire
time so mid-session connection updates are reflected.

The now-redundant extension wrapper and TelemetryService method are
removed to avoid double-counting in VS Code.
2026-07-16 16:39:50 -07:00
Max 1843bc8ed0 fix(vscode): persist selected account organization (#12345)
* fix(vscode): persist selected account organization

* fix(vscode): ignore stale organization responses

---------

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-16 16:08:07 -07:00
Max fead00ec57 fix(vscode): avoid duplicate OpenAI provider settings (#12346)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-16 16:07:48 -07:00
Max 238107d21c fix(vscode): preview auto-approved apply patches (#12349)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-16 16:05:24 -07:00
Saoud Rizwan 2063a661bd feat(vscode): capture telemetry when consecutive mistake limit is reached (#12354) 2026-07-16 15:58:21 -07:00
Dominic Cooney ec02d5862e Fix debug harness: run under node, drop ws dependency. (#12319)
The harness rotted after the npm-to-bun migration: the 'ws' package it imported is no longer in the dependency tree, and Playwright's _electron.launch() times out under bun (the debugee Electron starts but Playwright never finishes attaching; the same launch attaches in under a second under node). Use the runtime's built-in WebSocket for the CDP client and document that the harness must be run with node.
2026-07-17 07:43:32 +09:00
Mikołaj Kondratek 8452084842 fix: auto-discover OS trust anchors in the CLI wrapper (#11498)
* fix: auto-discover OS trust anchors in the CLI wrapper

The 3.x CLI ships as a Bun-compiled binary. Bun does not read the OS
trust store unless NODE_USE_SYSTEM_CA is set, and even with the flag its
Windows enumeration covers only the `Root` store, not `CA`/Intermediate
(verified empirically across the CLINE-2353 Windows repro rounds). So a
corporate MITM root is not trusted out of the box and inference fails
with "unable to get local issuer certificate". The pre-3.0 (Node) CLI
had no app-level CA handling either; users only succeeded by setting
NODE_EXTRA_CA_CERTS manually. The reporter's ask: have it just work
without the env var.

This follows the CLINE-2353 SDK fetch-threading change. That made the
inference client honor a host-provided proxy/CA-aware fetch, but on the
CLI Bun's global fetch is already proxy-aware and a fetch function
cannot cross the hub-daemon process boundary, so the CLI's missing piece
is trust material, not the fetch. Env vars do inherit across spawns.

The npm `bin/cline` wrapper runs on Node (not Bun), so it can read the
full OS store via tls.getCACertificates("system") (Node >= 22, no flag
required) — including the Windows `CA` store Bun skips — and hand the
certs to the Bun child via NODE_EXTRA_CA_CERTS, which both runtimes
honor. This mirrors the JetBrains plugin's configureCertificates(),
replacing "harvest from the IDE trust store" with "harvest from the OS".

The merge logic lives in a dependency-free, injectable-module CommonJS
helper (bin/ca-certs.cjs) so it is unit-testable and ships verbatim in
the generated wrapper package (publish copies bin/ wholesale). A
user-set NODE_EXTRA_CA_CERTS is merged ahead of the system certs; a
self-reference to the managed bundle is detected to avoid re-appending
every launch; when no system certs are available the user's setting is
left untouched. Writes are atomic (temp + rename) and owner-only.

Adds ca-certs.test.ts (13 cases) covering harvest filtering, user-bundle
PEM/DER/missing handling, newline-separated merge, managed-path
self-reference, and the no-system-certs no-op.

* fix: harden CLI auto-CA harvesting (review follow-ups)

Follow-ups from the CLINE-2353 review of the CLI auto-CA wrapper.

- H1: a legacy NODE_EXTRA_CA_CERTS set to an OS-path-delimited list
  ("a.pem;b.pem", the CLINE-2324 footgun Node never split) was stat'd as
  one file, failed, and silently dropped the user's certs. readUserCerts
  now tries the whole value as one file first, then splits on the OS path
  delimiter and reads each existing PEM, merging them all.
- M1: skip the rewrite when the managed bundle is already current, instead
  of re-harvesting and rewriting on every launch (mirrors the JetBrains
  hash-and-skip). configureNodeExtraCaCerts now returns a typed outcome
  (unchanged | written | write-failed-reused | write-failed |
  no-system-certs) with cert counts.
- M2: tolerate rename-over-existing failures (Windows EPERM/EBUSY when a
  concurrent child holds the file open) by removing the target and
  retrying, then falling back to a previously-written bundle. Combined
  with M1 the steady state no longer rewrites at all.
- M3: the wrapper prints a one-line diagnostic under CLINE_DEBUG=1
  (cert counts + managed path, or a warning when no OS certs were found
  or the write failed). Runs once per startup.
- M4: corrected the now-stale CLI guidance in shared/net.ts (the CLI no
  longer requires users to set NODE_EXTRA_CA_CERTS manually).
- L1: documented the auto-trust behavior, the managed ~/.cline bundle,
  the merge-not-replace override semantics, and CLINE_DEBUG in the CLI
  README.
- L4: trimmed the helper's file header; DI is still injectable for tests.

ca-certs.test.ts grows to 20 cases: adds readUserCerts (single path,
delimited split, missing-segment skip, managed-bundle exclusion, empty),
the unchanged/second-run skip, and a write-failure outcome via an
fs that throws.

* fix: address CLI auto-CA review issues (temp cleanup, cert count, test)

- writeBundle now hoists the temp path so the outer catch removes a
  partially-written temp file (e.g. ENOSPC / ACL failure mid-write).
  Previously only the inner double-rename failure cleaned up, so repeated
  disk-full/permission failures left a stale .tmp per launch in ~/.cline.
  The inner Windows-rename fallback now lets its failure fall through to
  the single cleanup path instead of duplicating rmSync.
- userCertCount now counts individual certificates (via countCerts, which
  tallies BEGIN CERTIFICATE markers) rather than the number of PEM files,
  so a user bundle with N intermediates reports N and is comparable to
  systemCertCount. countCerts is exported for testing.
- Adds tests for the write-failed-reused branch (stale bundle reused when
  the rewrite fails but the old file is still readable) and for countCerts
  (one file holding two certs reports 2).

* fix: warn when the CLI wrapper's Node cannot read the OS trust store

tls.getCACertificates("system") needs Node >= 22.15; on older hosts the
auto-CA harvest silently did nothing, which is indistinguishable from a
broken corporate proxy. Distinguish the missing-API case as its own
outcome (api-unavailable) and print a non-debug warning when the user
has no NODE_EXTRA_CA_CERTS of their own. Found in round-5 Windows
validation (wrapper under Node 22.1.0).

* fix: copy only certificate blocks into the managed CA bundle

Combined cert+key PEMs (nginx/haproxy-style server.pem) passed the
old contains-a-certificate check, so a user NODE_EXTRA_CA_CERTS
pointing at one duplicated the private key into the managed bundle,
where it outlives rotation of the original and gets no permission
tightening on Windows. Extract complete BEGIN/END CERTIFICATE blocks
instead; files with none are treated as not PEM, and certificates-only
files pass through byte-identical so the unchanged-skip stays stable.
Raised in PR review.

* fix: show the old-Node trust warning once per Node version

The api-unavailable warning printed on every CLI invocation, turning
an actionable nudge into stderr noise for users pinned to an old Node.
Stamp the warning per Node version under the cline dir: it shows once,
re-arms when the Node version changes, and a bookkeeping failure never
suppresses the diagnostic. Raised in PR review.
2026-07-16 10:14:15 -07:00
Dominic Cooney a41129a5db fix(vscode): restore 'Proceed While Running' for foreground terminal commands (#12320)
* First cut of 'proceed while running' for foreground tasks.

* Address review: flush partial line on detach; cap log before write; freeze partial output at detach.

* fix(vscode): cap detached command log replay
2026-07-15 22:46:37 -07:00
Saoud Rizwan 1ea34be611 chore(cli): release v3.0.42 2026-07-15 20:03:31 -07:00
Saoud Rizwan e72bc3cd14 chore(sdk): release v0.0.62 2026-07-15 19:46:58 -07:00
Tomás Barreiro 9c907af826 Send the Feature Flag Event when rolling out (#12325)
* Send the Feature Flag Event when rolling out

* Update apps/vscode-rollout/scripts/smoke-loader.mjs

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-15 19:39:37 -07:00
Saoud Rizwan e8d3d82522 fix(core): omit telemetry from hub tool contexts (#12326) 2026-07-15 19:33:12 -07:00
Max 7f9d2e96d9 fix(ollama): restore native API routing so context window and timeout settings work (#12286)
The 4.0.0 SDK migration routed Ollama through the generic OpenAI-compatible
vendor (/v1/chat/completions), which cannot express Ollama's options.num_ctx.
Every model loaded at Ollama's 4096-token server default, truncating Cline's
prompt and breaking most features (CLINE-2603, CLINE-2566, CLINE-2572).

- Add a native Ollama vendor backed by ai-sdk-ollama (wraps the official
  ollama client); num_ctx derives from the resolved gateway model's
  contextWindow at the adapter boundary, defaulting to 32768
- Persist the Model Context Window setting in providers.json via the
  pre-existing provider-neutral contextWindow field (legacy
  ollamaApiOptionsCtxNum state key kept as read fallback / write mirror),
  and surface it as the selected model's contextWindow so the chat
  indicator, compaction budgets, and num_ctx all agree
- Project ProviderConfig.maxInputTokens (where ProviderSettings.contextWindow
  lands) onto the selected gateway model in both gateway builders so
  CLI/Core hosts honor the configured value too
- Stop falling back to the bundled Ollama-Cloud catalog when /api/tags is
  empty; local-model-source providers keep the user's committed model
  instead of silently selecting a cloud model (nemotron)
- Wire Request Timeout (ms) with the legacy semantics (response must start
  within requestTimeoutMs || 30000; streaming never cut off mid-generation)
- Settings UI: gate the context-window field until provider config loads,
  skip unchanged writes, drop the custom prompt checkbox

Fixes CLINE-2603, CLINE-2566, CLINE-2572

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-15 17:55:29 -07:00
Saoud Rizwan d618f8073a fix(vscode-rollout): align bundle versions and harden combined publish workflows (#12321)
* fix(vscode-rollout): align bundle versions in the stable AB workflow

Found by Max in local testing: the union manifest's version (what the
Marketplace and auto-update see) is the stitch input, but each bundle's
About tab and telemetry extension_version read that bundle's OWN
package.json — so the stable combined VSIX reported three different
versions (dispatch input / main's 4.0.0 / legacy's 4.0.8) depending on
where you looked. The nightly channel doesn't have this problem
(nightlify.mjs stamps one version into everything); this gives the stable
channel the identity-preserving equivalent: scripts/set-version.mjs stamps
the dispatch version into each checkout after install, before its build.

Also fixes a latent ab-package bug while restructuring the steps: the
next-bundle build never ran build:sdk, so the @cline/* workspace deps had
no dist and esbuild would fail on a fresh CI checkout (the workflow has
never run end-to-end — the publish environment gate blocked pre-merge
dispatches). Split install/build:sdk/align/build into separate steps,
mirroring the nightly workflow.

* fix(vscode-rollout): assert bundle sub-manifest versions in identity guardrails

Greptile round on #12321: the stable guardrail didn't assert version at
all. Went one further than the suggestion — both workflows' guardrails now
also assert each bundle sub-manifest's version (and name, for nightly)
matches the expected version, which is the check that actually regression-
guards the set-version.mjs/nightlify.mjs stamping (About tab + telemetry
extension_version read the sub-manifests, not the union). Expected version
routed through env rather than interpolated into the script body. Adds the
conventional paired test for set-version.mjs.

* fix(vscode-rollout): don't fail the nightly run when the tag push is rejected

First real combined publish (run 29454994164) published to both registries
successfully but the run went red at the last step: the default
GITHUB_TOKEN cannot create a ref whose commit modifies workflow files, and
HEAD was the #12253 squash merge which rewrote this very workflow. There
is no workflows permission grantable to the token, so this recurs any
night HEAD touched .github/workflows. The tag is bookkeeping — mark the
step continue-on-error so a successful publish isn't reported as a
failure. (Today's missing tag was pushed manually.)
2026-07-15 17:39:44 -07:00
Saoud Rizwan eb21ba583c fix(ci): restrict nightly publishing to main (#12322) 2026-07-15 15:35:09 -07:00
Saoud Rizwan f29c25395c feat(vscode-rollout): A/B loader and packaging for staged SDK extension rollout (#12253)
* feat(vscode-rollout): A/B loader and packaging for staged SDK extension rollout

Ship one marketplace VSIX containing a tiny loader plus two complete
extension bundles: next/ (SDK-based apps/vscode from main) and legacy/
(the legacy-extension branch). The loader picks one bundle per window
from a PostHog-flag-driven, sticky, one-way cohort assignment, activates
it with a Proxy-scoped ExtensionContext so each bundle resolves its
resources from its own subdirectory, and falls back to legacy (with
partial-registration cleanup and version pinning) if the next bundle
crashes during activation.

Includes the union-manifest generator with per-cohort when-clause
gating, the VSIX stitcher, a node-level loader smoke test, and the
ext-vscode-ab-package workflow that builds both refs and packages
(optionally publishes) the combined VSIX.

* fix(vscode-rollout): address rollout review feedback

* feat(vscode-rollout): versioned kill-switch, user-setting override, launch-cadence telemetry

Review follow-ups from #12253:

- Kill-switch is now scoped by version instead of boolean: the PostHog flag's
  payload carries {"maxKilledVersion": "x.y.z"} and the loader demotes only
  combined VSIXes <= that version, so killing a broken release never blocks
  the release that fixes it. Arming with no payload still demotes everything,
  and the old boolean memento format is normalized on read.

- cline.rollout.bundleOverride user setting (auto | next | legacy) as a
  manual escape hatch editable straight from settings.json: beats flags and
  the kill-switch in both directions, applies on window reload, reported as
  'override' on the activation event. Injected into the union manifest by
  gen-manifest so neither bundle has to know about it.

- parseRolloutFlags hardens flag typing: only a literal boolean true promotes
  (multivariate variants, numbers, junk fail safe), kill payloads are parsed
  defensively from /decide's JSON-string encoding.

- Activation events now carry ms_since_last_activation so the real window-
  reload cadence bounds how fast the rollout percentage gets dialed up.

- Walkthrough manifest invariant relaxed from byte-equality to structural
  equality (ids/media/completionEvents): the branches already diverge on one
  MCP step description, and since walkthrough markdown at the VSIX root comes
  from next regardless, hard-failing on copy tweaks bricked the release
  pipeline while protecting nothing. Copy divergence now warns and ships
  next's text.

* feat(vscode-rollout): identity-aware namespace, authoritative activation telemetry, nightly indicator

- Derive the setting section and sdkBundle context key from the packaged
  manifest name (cline.* for stable claude-dev, cline-nightly.* for the
  nightly identity, whose packaging rewrites the whole ID namespace);
  gen-manifest derives the same prefix for gates and the injected
  bundleOverride setting.
- Call the activated bundle's reportRolloutActivation export (merged on
  both branches) with attempted/actual/fallback — the authoritative
  extension.rollout.bundle_activated event, attributed via the bundle's
  variant-built telemetry. On crash fallback the LEGACY bundle reports it.
- Rename the loader's direct PostHog event to
  extension.rollout.loader_decision: it collided byte-for-byte with the
  bundles' event name under a different schema. It keeps the loader-side
  metadata (override, launch cadence, loader_version, extension_name) and
  gains double_failure for the both-bundles-dead case.
- Fix duplicate activation events on crash fallback: the recursive legacy
  activation no longer emits a second, contradictory fallback:false event.
- Nightly-only status bar indicator (Cline: Next / Cline: Legacy) so
  dogfooders can see which bundle a window is running.
- Union diverged engines to the newer requirement instead of hard-failing:
  main's VS Code engine (^1.101.0) has legitimately moved ahead of
  legacy-extension's (^1.84.0), which bricked every combined build.
- Smoke scenarios for all of the above.

* feat(vscode-rollout): publish the nightly as the combined A/B VSIX

Convert ext-vscode-publish-nightly.yml (cron + dispatch) from the
standalone SDK build to the combined loader + next + legacy package,
published as saoudrizwan.cline-nightly at <major>.<minor>.<unix-seconds>:

- scripts/nightlify.mjs reproduces publish-nightly.mjs's identity mutation
  (claude-dev -> cline-nightly, "cline. -> "cline-nightly., displayName,
  activity bar title) with the version as an explicit argument so ONE
  version reaches both bundle manifests and the union manifest. Runs after
  dependency install and before each bundle build.
- Both bundle builds get CLINE_ROLLOUT_VARIANT (next/legacy) in the nightly
  AND stable workflows — without it the merged rollout telemetry
  (extension_variant common prop + the authoritative bundle_activated
  capture) silently no-ops.
- dry-run dispatch input builds and uploads the installable .vsix without
  publishing or tagging; publish/tag steps are additionally gated to main,
  so the PR branch can be dispatched for pre-merge verification.
- Identity guardrails before packaging: nightly workflow asserts
  cline-nightly, the stable ab-package workflow asserts claude-dev.
- The nightly tag now records the legacy bundle sha in its message.
- README: nightly channel section (identity mapping, the two telemetry
  events and their owners, dry-run verification), and a note that the
  PostHog flags govern nightly only until the stable combined VSIX ships.

The single-bundle publish-nightly.mjs path remains for manual
feature-branch pre-release publishes; CI no longer invokes it.

* chore(vscode-rollout): harden nightly workflow gating

- Restore a job-level branch allowlist on the publish job (main + the
  rehearsal branch). Advisory defense-in-depth: the enforced gate is the
  PublishNightly environment's deployment-branch policy in repo settings,
  which must list the same branches; a dispatched branch runs its own copy
  of this file.
- Route the legacy-ref dispatch input through env instead of interpolating
  it into the run script body (script-injection hygiene; dispatch already
  requires write access).

* add otel vars to rollout build (#12316)

- Extension will not emit otel metrics to otel without these vars, so
adding those into the slow-rollout build workflow

Co-authored-by: Max Paulus 🥪 <max@cline.bot>

* fix(vscode-rollout): pass OTel env to the nightly legacy bundle build

Legacy's esbuild inlines OTEL_* at build time and its standalone publish
workflow passes them, so the combined nightly's legacy bundle was being
built with the OTel logs/metrics pipeline dead. Companion to #12316,
which fixes the same gap in ext-vscode-ab-package.yml (both bundles
there).

* feat(vscode-rollout): make the rollout two-way, remove the kill-switch

The one-way cohort + versioned kill-switch existed to avoid demoting users
whose SDK-bundle tasks aren't listed by legacy and whose rotated creds may
need a re-login. Decision: those are acceptable, temporary UX costs on an
emergency-only path — not worth a second flag and permanent mechanism
complexity (payload parsing, version scoping, killed-up-to cache format).

Now there is ONE knob: each background refresh caches exactly what
ext-sdk-bundle-rollout says for the next window. Dialing the percentage
down demotes; 0% pulls everyone back to legacy on their next reload.
Fail-safe direction preserved: only a literal boolean true promotes —
variant strings / numbers / a deleted flag all resolve to legacy; malformed
/decide responses leave the cache untouched. Local crash pinning (next
threw -> pin this version to legacy on this machine) is unchanged and
independent of the flag.

Removes KILLSWITCH_FLAG/KILLSWITCH_STATE_KEY/isVersionKilled/
normalizeKilledUpTo/compareVersions/nextCachedBundle; parseRolloutFlags
becomes parseRolloutAssignment returning the bundle to cache. Smoke
scenarios replaced with two-way promote/demote coverage.

---------

Co-authored-by: Max <maxpaulus43@gmail.com>
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-07-15 15:07:45 -07:00
Max 84c9b587a6 refactor(vscode): resolve model metadata host-side (#12130)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-07-15 13:37:02 -07:00
Saoud Rizwan 6dca234d8e chore(cli): release v3.0.41 2026-07-15 11:02:17 -07:00
Renee Huang 9217eacbbd fix: update broken ACP editor integrations redirect to CLI reference (#12312)
* fix: update broken ACP editor integrations redirect to point to CLI reference

* feat: add ACP Editor Integrations page under CLI section

- Create cli/acp-editor-integrations.mdx with ACP overview, supported editors, quick start, and usage guide
- Add page to CLI navigation group in docs.json
- Restore redirect from /cline-cli/acp-editor-integrations to /cli/acp-editor-integrations (page now exists)

* Revert "feat: add ACP Editor Integrations page under CLI section"

This reverts commit 2728b9c2ad.
2026-07-15 10:57:59 -07:00
Saoud Rizwan adbb42a99c chore(sdk): release v0.0.61 2026-07-15 10:29:47 -07:00
Saoud Rizwan 50d1578a7e feat(ui): add shared Cline theme package (#12285)
* feat(desktop): refresh navigation and visual foundation

* feat(desktop): align settings with Cline Hub

* feat(desktop): improve chat markdown rendering

* fix(desktop): resolve review feedback blockers

* fix(desktop): refine inline code sizing

* fix(desktop): preserve workspace choices during startup

* feat(ui): add shared Cline theme package

* fix(desktop): restore sidebar session sorting

* fix(desktop): harden session startup state

* fix(desktop): tighten settings navigation state

* fix(desktop): tighten markdown link handling

* fix(ui): harden theme contract validation

* test(desktop): cover late workspace restoration
2026-07-15 00:04:08 -07:00
Saoud Rizwan 5ef3b81369 feat(desktop): improve chat markdown rendering (#12276)
* feat(desktop): refresh navigation and visual foundation

* feat(desktop): align settings with Cline Hub

* feat(desktop): improve chat markdown rendering

* fix(desktop): resolve review feedback blockers

* fix(desktop): refine inline code sizing

* fix(desktop): preserve workspace choices during startup

* fix(desktop): restore sidebar session sorting

* fix(desktop): harden session startup state

* fix(desktop): tighten settings navigation state

* fix(desktop): tighten markdown link handling

* test(desktop): cover late workspace restoration
2026-07-14 23:49:22 -07:00
Saoud Rizwan ec3a57771d fix(cli): block compaction during active turns (#12296) 2026-07-14 23:23:09 -07:00
Saoud Rizwan a695dab23a feat(desktop): align settings with Cline Hub (#12275)
* feat(desktop): refresh navigation and visual foundation

* feat(desktop): align settings with Cline Hub

* test(desktop): support webview component tests

* fix(desktop): resolve review feedback blockers

* fix(desktop): preserve workspace choices during startup

* fix(desktop): restore sidebar session sorting

* fix(desktop): harden session startup state

* fix(desktop): tighten settings navigation state

* test(desktop): cover late workspace restoration

* chore: preserve upstream merge contents
2026-07-14 23:18:48 -07:00
Saoud Rizwan 77af52661c fix(telemetry): attach organization context to cached-credential identity (#12288)
* fix(telemetry): attach organization context to cached-credential identity

CLI cached credentials only stored the account id, so telemetry identity
resolved from them (headless runs via #11581, the hub daemon via #12177)
carried user_id but no organization_id - making CLI/hub usage invisible
to organization-scoped dashboards even where per-user attribution works.

- AuthSettingsSchema gains optional organizationId/organizationName/
  memberId
- loadClineAccountSnapshot persists the active organization into the
  cached cline provider settings after fetching /me (cleared when the
  user is on their personal account), so the context survives across
  processes without a network call
- the CLI runtime identify and the hub daemon identity refresh read the
  persisted fields and pass them to identifyAccount; the daemon re-keys
  its refresh on account+organization so an org switch re-identifies a
  long-lived daemon

* fix(telemetry): strip stray NUL byte, drop needless reshaping of daemon identity resolve
2026-07-14 23:10:27 -07:00
Saoud Rizwan 0f4acccd08 feat(desktop): refresh navigation and visual foundation (#12268)
* feat(desktop): refresh navigation and visual foundation

* test(desktop): support webview component tests

* fix(desktop): resolve review feedback blockers

* fix(desktop): preserve workspace choices during startup

* fix(desktop): restore sidebar session sorting

* fix(desktop): harden session startup state

* test(desktop): cover late workspace restoration
2026-07-14 23:02:06 -07:00
Bee f8c73cd8cc feat(core): persist and refresh workspace git info (#12295) 2026-07-15 07:30:16 +02:00
Max 55a31a0d8a feat(vscode): add rollout telemetry to SDK extension (#12292)
* feat(vscode): add shared rollout telemetry contract

* feat(vscode-sdk): propagate rollout metadata

---------

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-14 22:29:52 -07:00
Bee 04438c0d54 feat: shows compaction progress status in UI (#12137)
* feat: shows compaction progress status in UI

* fixes p2

* fix: complete compaction lifecycle delivery

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-15 13:09:10 +08:00
Bee f053ec48e4 refactor(llms): owns provider-specific header policy (#12187)
* refactor(llms): owns provider-specific header policy

* header
2026-07-15 03:23:23 +02:00
Bee 0df406723c refactor(core): normalize read file request path aliases (#12287)
* fix(core): normalize read file request path aliases

Accept `file_path` and `filePath` in read file requests and normalize them to the canonical `path` field. Apply alias handling to direct, array, and nested inputs to prevent model-generated variants from failing validation.

Clarify path descriptions by removing redundant wording.

* update test
2026-07-15 08:46:00 +08:00
Dominic Cooney 12703bf407 Improve VS Code terminal reliability: OSC 633 parser, exit codes, timeout handling (#11972) 2026-07-14 16:34:36 -07:00
Bee 4a97b46f5f refactor(core): simplifies context compaction trigger (#12217)
* refactor(core): simplifies context compaction trigger

Simplifies automatic context compaction so it always triggers when input usage reaches 80% of the model’s effective maximum input-token limit.

* add bound

* feedback apply

* complete

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-15 02:11:38 +08:00
Saoud Rizwan 36fc3327ac fix(cli): highlight API key fallback hint (#12283) 2026-07-14 10:51:05 -07:00
Max 2b48dc411f make old tasks incompatible with new cline extension (#12127)
Preserve pretty legacy task display after resume

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-14 10:00:23 -07:00
Tomás Barreiro da6fe718d0 Rename sessionStartedAt to sessionStartedAtMs (#12279) 2026-07-14 16:08:17 +02:00
Tomás Barreiro 3515333e23 Review against camelCase telemetry (#12280) 2026-07-14 15:59:19 +02:00
Saoud Rizwan bd9ac5872b ci(sdk): create GitHub release and post to Slack on latest SDK publish (#12223)
* ci(sdk): create GitHub release and post to Slack on latest SDK publish

* ci(sdk): use random heredoc delimiter for changelog output
2026-07-14 01:31:45 -07:00
Saoud Rizwan fb15324ad2 fix(cli): prevent use-after-free when setting terminal title during TUI teardown (#12229)
* fix(cli): prevent use-after-free when setting terminal title during TUI teardown

* fix(cli): re-check renderer destruction before title reset in teardown microtask

* test(cli): cover terminal title teardown lifecycle
2026-07-14 01:31:01 -07:00
Saoud Rizwan 7a27c04ffa fix(core): stop reporting benign git states as workspace init errors (#12189)
* fix(core): stop reporting benign git states as workspace init errors [ENG-2244]

A freshly initialized repo with no commits makes 'git rev-parse HEAD'
fail, which generateWorkspaceInfoWithDiagnostics recorded as a workspace
init error and surfaced as workspace.init_error telemetry on every
session bootstrap. Filter out git failures that reflect normal
repository states; genuine failures (missing directory, real git
breakage) are still reported.

* fix(core): drop 'bad revision' from benign git error filter

Review feedback: 'fatal: bad revision HEAD' can also indicate a corrupt
.git/HEAD (checkIsRepo still succeeds), which is a genuinely broken
workspace that should keep reporting. The remaining patterns cover the
empty-repo message variants.
2026-07-14 01:30:33 -07:00
Saoud Rizwan c37b252f65 fix(vscode): restore multi-root mention resolution and validate stored task cwd (#12190)
* fix(vscode): restore multi-root mention resolution and validate stored task cwd [ENG-2245][ENG-2244]

The SDK adapter's ensureWorkspaceManager() was a stub returning
undefined, which silently disabled multi-root file mention resolution:
parseMentions only searched the primary cwd, so @-mentions of files in
secondary workspace roots failed with not_found. Build a real
WorkspaceRootManager from the host's workspace folders (cached until
the folder set changes) via a new WorkspaceRootManager.fromPaths().

Also validate that a resumed task's stored cwdOnTaskInitialization
still exists before using it — stale paths (deleted/moved dirs) fed
git-based workspace init and produced init-error telemetry.

* fix(vscode): use JSON.stringify for workspace manager cache key

Review feedback: a delimiter-joined key is ambiguous for paths
containing the delimiter (and the previous separator was an embedded
NUL byte). JSON.stringify is unambiguous and order-preserving.

* test(vscode): cover stored task cwd validation
2026-07-14 01:30:21 -07:00
Saoud Rizwan 2872138900 feat: suggest model IDs from OpenAI-compatible endpoints in extension and CLI (#12231)
* fix(vscode): use the requested provider's stored credentials when listing OpenAI-compatible models

The OpenAI-compatible settings pane already fetches GET <baseUrl>/models to
suggest model IDs, but the host handler always read the built-in "openai"
provider's stored settings. Custom OpenAI-compatible providers only expose a
masked API key to the webview, so their model-list requests went out
unauthenticated and the suggestion dropdown stayed empty.

Add provider_id to OpenAiModelsRequest and read that provider's stored API
key and custom headers in refreshOpenAiModels. Old clients omit the field,
which defaults to "openai" and preserves the previous behavior.

* feat(cli): suggest model ids from OpenAI-compatible endpoints in the model picker

The CLI showed a bare free-text input for openai-compatible providers and
never asked the endpoint what it serves. Fetch GET <baseUrl>/models with the
provider's stored API key/headers when opening the picker; when the endpoint
answers, show the standard fuzzy list (which keeps the "Create custom model
ID" row for manual entry). Any failure or empty answer falls back to the
existing free-text input.

* fix: resolve OpenAI-compatible model discovery config
2026-07-14 01:26:48 -07:00
Saoud Rizwan dc4620c529 fix(vscode): add to system prompt about plan/act modes and nudge about mode switches (#12227)
* feat(shared): move plan/act mode prompt instructions into the shared prompt builder

The CLI's #12057 fixes (mode-tag explanation, plan-mode contract,
mode-switch notice tracker) were CLI-only wiring, so the VSCode extension
never told the model what the <user_input mode> attribute means and plan
mode kept mutating files (CLINE-2576, CLINE-2607, CLINE-2579). Promote
the pieces every host needs into @cline/shared:

- buildClineSystemPrompt now appends MODE_TAG_INSTRUCTIONS for every mode
  and PLAN_MODE_INSTRUCTIONS for plan sessions, composed into the rules
  slot in the exact order the CLI historically built by hand, so CLI
  output is byte-identical after the refactor.
- The plan-mode contract gains an explicit run_commands paragraph:
  the tool intentionally stays available in plan mode (essential for
  read-only investigation) but is inspection-only there -- no file
  mutations, no state-changing commands. The mitigation for plan-mode
  mutations is prompting plus mode-switch notices, not tool removal.
- createModeSwitchNoticeTracker moves from apps/cli/runtime/interactive
  to @cline/shared next to formatModeSwitchNotice; the CLI re-exports it
  so its import surface and tests stay unchanged.
- deriveTitleFromPrompt gets a regression test pinning that titles never
  pick up mode-notice text.

* fix(vscode): teach the model about plan/act modes and surface mode switches

Port the CLI's #12057/#12058 plan-mode fixes to the extension:

- The session factory drops its local PLAN_MODE_INSTRUCTIONS copy; the
  shared prompt builder now emits both the mode-tag explanation and the
  plan-mode contract (including the read-only run_commands rule), so the
  extension's system prompt finally explains the <user_input mode>
  wrapper its own messages have carried all along.
- Manual Plan/Act toggles record a mode-switch notice in
  SdkModeCoordinator (shared round-trip-cancelling tracker, scoped to
  the rebuilt session so it never leaks across tasks), recorded only
  after the session replacement actually commits. The model-initiated
  switch_to_act_mode path passes source: "tool" and records nothing,
  matching the CLI: its tool result and continuation prompt already
  announce the switch.
- SdkSessionLifecycle.fireAndForgetSend -- the single funnel for
  outbound turn sends -- consumes the notice and prepends
  formatModeSwitchNotice() to the next message, exactly like the CLI's
  run-interactive stamping.
- Display boundaries never render the raw tag: the queued-prompt echo
  in the message translator now goes through formatDisplayUserInput,
  and isSyntheticUserPrompt strips notices before matching so a stamped
  continuation prompt cannot shift edit/regenerate ordinals.
2026-07-14 01:26:20 -07:00
Saoud Rizwan 2ac5c85e69 fix(vscode): restore editor diff view for SDK edit tools (#12219)
* feat(sdk): expose edit-executor internals for host diff previews

Extract computePatchChanges() from createApplyPatchExecutor so hosts can
compute a patch's per-file proposed content without writing to disk
(behavior-identical refactor; the executor now calls the helper), and
widen the @cline/core root exports with createEditorExecutor,
createApplyPatchExecutor, computePatchChanges, PatchActionType and the
related types. Needed by the VS Code adapter to restore the editor diff
view for SDK edit tools.

* fix(vscode): restore editor diff view for SDK edit tools

Adds SdkDiffEditCoordinator, which owns per-toolCallId diff sessions over
the legacy DiffViewProvider abstraction (HostProvider factory, so the
external/JetBrains gRPC DiffService path keeps working):

- the diff editor opens populated before the approval ask renders (the
  SDK surfaces tool input only after the model stream completes, so the
  approval callback is the only pre-execution point with full input)
- an overridden editor executor saves through the diff document:
  user edits in the editable right pane and post-save auto-formatting
  flow back to the model via formatResponse.fileEditWithUserChanges,
  plus 'new problems' diagnostics
- Reject/abort reverts (new files: file + created dirs removed)
- auto-approved edits open the diff during execution with the legacy
  3.5s diagnostics settle; Background Edit keeps the headless disk path
- apply_patch gets a preview-only diff of its first changed file; on
  approve the preview is reverted and the untouched SDK executor applies
  the whole patch
- any diff-pipeline failure reverts and falls back to the SDK disk
  executor, preserving canonical error strings

Fixes #11934 (CLINE-2580).

* refactor(vscode): make edit diff preview a read-only virtual-document diff

Reworks the diff view restoration after EDH testing showed the editable
real-document design breaking on same-file multi-edits (tab reuse opened
the actual file instead of a diff; sibling saves closed other sessions'
tabs; right-pane edits misbehaved).

New design per review:
- EditPreview abstraction (mirrors CommentReviewController pattern):
  VscodeEditPreview renders vscode.diff with BOTH sides as virtual
  cline-diff documents (unique fragment per preview, so same-file edits
  get distinct tabs and close is an exact tab match, never the real
  file); ExternalEditPreview uses the existing openMultiFileDiff/
  closeAllDiffs host-bridge RPCs. New createEditPreview factory on
  HostProvider.
- The preview never touches disk: executors close the preview and
  delegate to the SDK's default disk executors, whose results and error
  strings reach the model unchanged. Reject/abort just closes a tab.
- Dropped by design decision: editing in the diff view, user-edit
  feedback to the model, and diagnostics passback (the SDK already
  prompts the model to check).
- Auto-approved edits show a brief preview that lingers ~1.5s after the
  write; an abort cuts the linger short without failing the applied edit.
- A newer same-file preview supersedes an older pending one (approvals
  resolve sequentially), eliminating cross-session interference.
- Legacy DiffViewProvider stack returns to untouched dead code.

* fix(vscode): state that denied edits did not modify the file

Repro: ask Cline to edit a file, then answer the approval with feedback
instead of Approve/Reject. The denial reached the model as just
{"error":"make them bigger"} — nothing said the edit was NOT applied —
so the model treated the feedback as iteration on an applied change and
built its next old_text against content that never landed on disk. From
then on old_text no longer matched the real file and the diff preview
silently stopped appearing (and the eventual executor run would fail the
same way).

Denial reasons now come from buildToolApprovalDenialReason(): edit tools
get 'The user denied this edit. The file was NOT modified and still
contains its original content.' (legacy parity), and all tools get user
feedback wrapped in <feedback> tags instead of the bare prompt as the
whole reason. isKnownToolApprovalDenial also matches the new edit-denial
marker so translator suppression keeps working.

* feat(vscode): simulated streaming animation for edit previews

Brings back the legacy 'yellow sweep' feel on the virtual diff preview.
The SDK only surfaces complete tool input, so this is a deliberate
simulation of the legacy streaming look (which legacy also showed when
it already had the full content in memory).

The sweep covers the whole file like legacy did, with diff-aware pacing:

- Park at the top: whole document under the faded-yellow overlay, cursor
  highlight on line 0, viewport pinned to the top, ~400ms hold so the
  animation unambiguously starts from the top.
- Zip through unchanged spans in small fast steps (~8 lines per 16ms
  frame, capped per span) so they read as continuous motion.
- Slow down through each change: one line per 45ms frame with a ~350ms
  minimum dwell per hunk so even a one-line change visibly pauses.
- Changed runs come from a real line diff (diffLines), so multi-hunk
  edits slow at EACH hunk and the gaps between hunks zip; pure deletions
  pause at the deletion point.
- Zip frames chase the cursor (InCenter) for continuous scroll; typing
  frames scroll only when leaving the viewport (no per-frame judder).
- After the sweep reaches the bottom: short beat, then settle centered
  on the first changed line for review.

Mechanics: edit previews move from base64-query cline-diff URIs to a new
mutable cline-edit-preview content provider (content set programmatically,
re-rendered via onDidChange) so the virtual right side can update in
place. DecorationController is reused as-is. The approval ask renders
while the animation plays (legacy simultaneity); close() cancels
mid-animation; files >3000 lines render the final diff immediately.
External hosts keep the static openMultiFileDiff preview.

* chore(vscode): remove test artifact comment from memory-monitor

* fix(vscode): address review nits — skip diff computation for large files, close partially-opened previews

- buildEditPreviewAnimation (which runs a full line diff) now runs after
  the MAX_ANIMATED_LINES guard; oversized files use a cheap prefix scan
  just to aim the viewport.
- If preview.open() throws after partially opening, the tab is closed
  directly — the session was never registered, so discardPreview could
  not have reached it.

* fix(vscode): keep tsconfig valid JSON for test setup

* fix(vscode): bound diff preview animation
2026-07-14 01:25:36 -07:00
Tomás Barreiro ab68fd7f34 Store startedAt in auth metadata when starting a Cline session (#12270)
* Store startedAt in auth metadata when starting a Cline session

* Inject the sessionStartedAt when creating the auth credentials

* Remove injecting sessionStartedAt when it's not stored already

* Address review

* fix merge inconsistencies
2026-07-14 03:50:00 +02:00
438 changed files with 30261 additions and 8645 deletions
+158
View File
@@ -0,0 +1,158 @@
---
name: publish-ui
description: Prepare, validate, and publish standalone @cline/ui npm releases. Use when bumping the UI package version, publishing latest or next through ui-publish.yml, checking UI release readiness, or completing the one-time npm trusted-publishing bootstrap.
---
# Publish UI
Release `@cline/ui` independently from the Cline SDK runtime packages.
## Release contract
- Version source: `sdk/packages/ui/package.json`.
- Workflow: `.github/workflows/ui-publish.yml`.
- The package keeps `internal: true` only to stay out of the SDK's shared
version/publish scripts. It is still a public npm package because
`private: false` and `publishConfig.access: public` control npm publication.
- `latest` is the production channel. `next` is an opt-in preview channel.
- Use prerelease versions such as `0.2.0-next.0` for `next`; do not publish a
version intended for `latest` under the preview tag because npm versions
cannot be republished.
- There is no UI Git tag, GitHub release, schedule, or Slack announcement.
- The workflow runs only by manual dispatch. Every release attempt runs the UI
quality checks before publishing and requires `confirm_publish=publish` from
`main`.
- The publish job and npm trust relationship use the protected `Publish`
environment.
- Every npm publication needs a new semver version; npm versions are immutable.
- Always ask before pushing commits, triggering the publish workflow, changing
npm trust settings, or running a local publish command.
## Normal release
1. Inspect the branch, current version, npm state, and UI changes.
```sh
git status --short --branch
node -p "require('./sdk/packages/ui/package.json').version"
npm view @cline/ui dist-tags versions --json
git log --oneline --no-merges -- \
sdk/packages/ui apps/examples/desktop-app/webview/components/views/chat \
.github/workflows/ui-publish.yml
```
2. Ask for the npm channel and version together. For `latest`, ask for patch,
minor, major, or an explicit version. For `next`, require an explicit
prerelease version such as `0.2.0-next.0`. Do not guess. Update only
`sdk/packages/ui/package.json` and its workspace version in `bun.lock`. Do
not run the SDK version command.
3. Validate the release candidate.
```sh
bun install --filter @cline/ui --filter @cline/code --frozen-lockfile
bun -F @cline/ui typecheck
bun -F @cline/ui test
bun -F @cline/ui test:package
bun -F @cline/ui build-storybook
bun -F @cline/code test:chat-ui
```
The packed-package test installs the tarball with Bun/React 19 and with
npm/Node/React 18.
Inspect `bun pm pack --dry-run` when the exported file set changed.
4. Commit the version bump separately from feature work. Ask before pushing.
```sh
git add sdk/packages/ui/package.json bun.lock
git commit -m "chore(ui): release vX.Y.Z"
git push origin HEAD
```
5. After the release commit reaches `main`, restate the selected npm tag and ask
for explicit publish approval. Then trigger and watch the standalone
workflow:
```sh
run_url=$(gh workflow run ui-publish.yml --ref main \
-f npm_tag=latest \
-f confirm_publish=publish)
test -n "$run_url"
run_id=${run_url##*/}
gh run watch "$run_id" --exit-status
```
Use `npm_tag=next` only for a deliberate preview. Do not report success until
the workflow succeeds and npm shows the exact version under the selected tag.
```sh
npm view @cline/ui dist-tags versions --json
```
## One-time npm bootstrap
Use this only while `npm view @cline/ui` returns `E404`. npm requires the
package to exist before its GitHub trusted publisher can be configured.
1. Merge the package and `ui-publish.yml` to `main`. Start from a clean,
reviewed `main` checkout. Verify authentication, account 2FA, and write
access to the `@cline` npm organization. The `npm trust` command in step 4
requires npm CLI 11.15 or newer; the automated trusted-publishing workflow
itself enforces npm 11.5.1 or newer.
```sh
npm --version
npm whoami
npm view @cline/ui version
```
If npm is older than 11.15, ask before upgrading with
`npm install -g npm@^11.15.0`.
2. Run the normal release validation in step 3 above. Then build, pack, test,
and inspect the exact initial tarball. Record the absolute archive path
printed by the final command.
```sh
bun -F @cline/ui build
pack_dir=$(mktemp -d)
(cd sdk/packages/ui && bun pm pack --ignore-scripts --destination "$pack_dir" --quiet)
tarball=$(find "$pack_dir" -maxdepth 1 -name '*.tgz' -print -quit)
test -n "$tarball"
bun sdk/packages/ui/scripts/smoke-package.ts "$tarball"
tar -tzf "$tarball"
printf 'Bootstrap archive: %s\n' "$tarball"
```
3. Ask for explicit approval, then publish the initial version publicly under
`latest`:
```sh
npm publish /absolute/path/from-step-2.tgz --access public --tag latest
```
4. Ask separately before configuring the standalone workflow as the trusted
publisher:
```sh
npm trust github @cline/ui \
--repo cline/cline \
--file ui-publish.yml \
--env Publish \
--allow-publish
```
5. Verify both package state and trust. Every later release uses the workflow;
do not add a long-lived npm token.
```sh
npm view @cline/ui dist-tags versions --json
npm trust list @cline/ui
```
## Final report
Report the version and npm tag, release commit, whether anything was pushed,
workflow URL or bootstrap result, npm verification, and tests/builds run. If
the package still returns `E404`, state that bootstrap remains required.
@@ -0,0 +1,4 @@
interface:
display_name: "Publish UI"
short_description: "Prepare and publish the Cline UI package"
default_prompt: "Use $publish-ui to prepare and publish a new @cline/ui npm release."
+3 -2
View File
@@ -8,8 +8,9 @@ HTTP-controlled debugger for the VSCode extension at `src/dev/debug-harness/serv
# Build extension first if needed (protos + esbuild):
bun run protos && IS_DEV=true bun esbuild.mjs
# Launch (skip-build if already built):
bun src/dev/debug-harness/server.ts --skip-build --auto-launch
# Launch (skip-build if already built). Run with node, NOT bun — Playwright's
# Electron launch times out under bun:
node src/dev/debug-harness/server.ts --skip-build --auto-launch
# In another terminal:
curl localhost:19229/api -d '{"method":"status"}'
+1
View File
@@ -16,6 +16,7 @@ This file is the secret sauce for working effectively in this codebase. It captu
- The whole repo (including `apps/vscode`) uses **bun** for package management and task running. Emit `bun run X` / `bun install` / `bunx <bin>` / `bun file.ts`, never npm/npx. Node remains the *runtime* (VS Code's extension host and the standalone cline-core are Node), so Node-runtime tokens are legitimate and must not be "fixed" to bun — see @.clinerules/bun-and-node.md for the keep-list vs rewrite-list.
- Avoid provider-specific string matching / hardcoded provider branches when fixing provider/config plumbing. Prefer provider metadata, shared catalog/defaults, explicit protocol/client capabilities, or centralized normalization utilities that apply by data shape rather than `providerId === "..."`. If a provider exception seems necessary, stop and explain why instead of adding ad-hoc string matching.
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `bun run compile`, not `bun run build`).
- When reading a configuration files that users may edit, use `readFileStrippingUtf8Bom`, `readFileSyncStrippingUtf8Bom`, or `stripUtf8Bom` from `@cline/shared/node`. DON'T strip byte order marks of user files handled by tools/passed to models.
- When creating PRs, contributors should not create changelog-entry files. Maintainers handle release versioning and changelog curation during the release process.
- When adding new feature flags, see this PR as a reference https://github.com/cline/cline/pull/7566
- Additional instructions about making requests: @.clinerules/network.md
+205
View File
@@ -0,0 +1,205 @@
name: ext-vscode-ab-package
# Build (and optionally publish) the combined A/B VSIX: a tiny loader plus two
# complete extension bundles — `next/` from the SDK-based apps/vscode on main,
# `legacy/` from the legacy-extension branch. Cohort selection happens at
# runtime via PostHog flags; see apps/vscode-rollout/README.md for the design
# and the rollout runbook.
on:
workflow_dispatch:
inputs:
version:
description: "Combined VSIX version — must exceed every previously published version (e.g. 4.1.0)"
required: true
type: string
next-ref:
description: "Ref to build the next (SDK) bundle from"
required: true
default: "main"
type: string
legacy-ref:
description: "Ref to build the legacy bundle from"
required: true
default: "legacy-extension"
type: string
publish:
description: "Publish to the VS Code Marketplace (unchecked: just build the .vsix artifact)"
required: true
default: false
type: boolean
permissions:
contents: read
concurrency:
group: ext-vscode-ab-package-${{ github.event.inputs.version }}
cancel-in-progress: false
jobs:
package:
name: Build combined (legacy + next) VSIX
runs-on: ubuntu-latest
environment: publish
steps:
- name: Checkout next (SDK) source
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.next-ref }}
path: next-src
lfs: true
- name: Checkout legacy source
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.legacy-ref }}
path: legacy-src
lfs: true
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install next workspace dependencies
working-directory: next-src
run: bun install
# @cline/* are local workspace symlinks to source packages; apps/vscode's
# `package` script does NOT build them, so without this the esbuild step
# fails on a fresh checkout. (The nightly workflow already does this.)
- name: Build SDK packages
working-directory: next-src
run: bun run build:sdk
# Stamp the combined version into each bundle's package.json AFTER
# install and BEFORE its build: the About tab and telemetry
# extension_version read the bundle's own manifest, so without this
# the VSIX reports three different versions depending on where you
# look. (The nightly workflow gets the same alignment via nightlify.mjs.)
- name: Align next bundle version
working-directory: next-src/apps/vscode-rollout
run: node scripts/set-version.mjs --dir "$GITHUB_WORKSPACE/next-src/apps/vscode" --version "${{ github.event.inputs.version }}"
- name: Build next bundle
working-directory: next-src/apps/vscode
env:
CLINE_ENVIRONMENT: production
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
# Inlined by esbuild: attributes every telemetry event with
# extension_variant and unlocks the bundle's authoritative
# extension.rollout.bundle_activated capture. Rollout builds only.
CLINE_ROLLOUT_VARIANT: next
# Match the stable publish workflow's OpenTelemetry production defaults.
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
run: bun run package
- name: Install legacy dependencies
working-directory: legacy-src
run: |
npm --prefix apps/vscode install --include=optional
npm --prefix apps/vscode/webview-ui install --include=optional
- name: Align legacy bundle version
working-directory: next-src/apps/vscode-rollout
run: node scripts/set-version.mjs --dir "$GITHUB_WORKSPACE/legacy-src/apps/vscode" --version "${{ github.event.inputs.version }}"
- name: Build legacy bundle
working-directory: legacy-src/apps/vscode
env:
CLINE_ENVIRONMENT: production
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
CLINE_ROLLOUT_VARIANT: legacy
# Match the stable publish workflow's OpenTelemetry production defaults.
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
run: npm run package
- name: Build loader and run rollout tests
working-directory: next-src/apps/vscode-rollout
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
run: |
bun run typecheck
bun run test
bun run build:production
- name: Stitch combined VSIX staging
working-directory: next-src/apps/vscode-rollout
run: |
node scripts/stitch.mjs \
--next "$GITHUB_WORKSPACE/next-src/apps/vscode" \
--legacy "$GITHUB_WORKSPACE/legacy-src/apps/vscode" \
--loader dist/extension.js \
--version "${{ github.event.inputs.version }}" \
--out "$GITHUB_WORKSPACE/staging"
- name: Smoke-test loader against staging
working-directory: next-src/apps/vscode-rollout
run: node scripts/smoke-loader.mjs "$GITHUB_WORKSPACE/staging"
# This workflow publishes the STABLE identity. If nightlify ever leaks
# into this path the union manifest would ship under the wrong name.
# The bundle sub-manifest checks guard the set-version.mjs stamping:
# the About tab and telemetry extension_version read those files.
- name: Assert stable manifest identity
working-directory: staging
env:
EXPECTED_VERSION: ${{ github.event.inputs.version }}
run: |
node -e '
const assert = require("node:assert");
const expected = process.env.EXPECTED_VERSION;
const pkg = require("./package.json");
assert.equal(pkg.name, "claude-dev", `unexpected name ${pkg.name}`);
assert.equal(pkg.publisher, "saoudrizwan", `unexpected publisher ${pkg.publisher}`);
assert.equal(pkg.version, expected, `unexpected union version ${pkg.version}`);
for (const bundle of ["next", "legacy"]) {
const sub = require(`./${bundle}/package.json`);
assert.equal(sub.version, expected, `unexpected ${bundle} bundle version ${sub.version}`);
}
console.log(`stable identity ok: ${pkg.publisher}.${pkg.name}@${pkg.version} (bundle versions aligned)`);
'
- name: Package VSIX
working-directory: staging
run: |
npm install -g @vscode/vsce
# Preserve the narrowly scoped VSCE `sendgrid` scanner exemption used by
# both standalone bundle workflows. No SendGrid credential is intentionally
# supplied here; inspect the reported artifact before widening the exemption.
vsce package --no-dependencies --allow-package-secrets sendgrid --out "claude-dev-${{ github.event.inputs.version }}.vsix"
- name: Upload VSIX artifact
uses: actions/upload-artifact@v4
with:
name: claude-dev-${{ github.event.inputs.version }}
path: staging/claude-dev-${{ github.event.inputs.version }}.vsix
if-no-files-found: error
- name: Publish to Marketplace
if: ${{ github.event.inputs.publish == 'true' }}
working-directory: staging
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
run: |
if [[ -z "$VSCE_PAT" ]]; then
echo "Error: VSCE_PAT is required to publish."
exit 1
fi
vsce publish --no-dependencies --packagePath "claude-dev-${{ github.event.inputs.version }}.vsix"
+211 -47
View File
@@ -1,17 +1,40 @@
name: ext-vscode-publish-nightly
# Publishes saoudrizwan.cline-nightly as the COMBINED A/B VSIX: the rollout
# loader plus two complete extension bundles — `next/` from this ref's
# apps/vscode (SDK-based) and `legacy/` from the legacy-extension branch.
# Cohort selection happens at runtime via PostHog flags; see
# apps/vscode-rollout/README.md for the design and rollout runbook.
#
# The stable-identity equivalent of this pipeline is ext-vscode-ab-package.yml
# (manual dispatch, publishes claude-dev). Shared logic lives in
# apps/vscode-rollout/scripts (nightlify/gen-manifest/stitch/smoke) so both
# workflows stay thin. The single-bundle nightly path this replaced
# (apps/vscode/scripts/publish-nightly.mjs) remains for manual feature-branch
# pre-release publishes.
on:
schedule:
# Every day at 4:00 AM PST (12:00 UTC)
- cron: "0 12 * * *"
workflow_dispatch:
inputs:
legacy-ref:
description: "Ref to build the legacy bundle from"
required: false
default: "legacy-extension"
type: string
dry-run:
description: "Build and upload the .vsix artifact without publishing or tagging"
required: false
default: false
type: boolean
run-name: "Publish Nightly from ${{ github.ref_name }} @ ${{ github.sha }}"
run-name: "Publish Combined Nightly from ${{ github.ref_name }} @ ${{ github.sha }}"
# Prevent concurrent publish runs on the same branch. The nightly publish script
# generates the extension version from a seconds-resolution timestamp, so parallel
# runs on the same ref can collide on the same version and cause publish failures
# or inconsistent tagging. Runs on different branches proceed independently.
# Prevent concurrent publish runs on the same branch: the version is generated
# from a seconds-resolution timestamp, so parallel runs on the same ref can
# collide on the same version and cause publish failures or inconsistent tagging.
concurrency:
group: ext-vscode-publish-nightly-${{ github.ref }}
cancel-in-progress: false
@@ -20,7 +43,7 @@ permissions: {}
jobs:
test:
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
if: github.repository == 'cline/cline'
permissions:
contents: read
pull-requests: read
@@ -30,60 +53,79 @@ jobs:
needs: test
permissions:
contents: write
name: Publish Cline (Nightly) Extension
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
name: Publish Cline (Nightly) Combined Extension
# Defense in depth: only protected main may enter the publishing environment.
# This `if` is advisory because a dispatched branch runs its own copy of this
# file; the enforced gate is the PublishNightly environment's deployment-branch
# policy, which must also allow only main.
if: github.repository == 'cline/cline' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: PublishNightly
# The VS Code extension's package.json and lockfiles live under apps/vscode/
# (the repo root has no package.json). Mirror ext-vscode-test.yml so install
# and publish steps run in the correct workspace.
defaults:
run:
working-directory: apps/vscode
steps:
- name: Checkout selected branch
- name: Checkout next (SDK) source
uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
path: next-src
lfs: true
persist-credentials: false
- name: Show build source
working-directory: ${{ github.workspace }}
- name: Checkout legacy source
uses: actions/checkout@v4
with:
# NOTE: inputs are empty strings on `schedule` events, so the ||
# fallback (not the input's declared default) is what the cron uses.
ref: ${{ inputs.legacy-ref || 'legacy-extension' }}
path: legacy-src
lfs: true
persist-credentials: false
- name: Show build sources
env:
# Routed through env rather than interpolated into the script body so
# a crafted dispatch input can't inject shell (hygiene: dispatchers
# need write access anyway, but keep the pattern clean).
LEGACY_REF: ${{ inputs.legacy-ref || 'legacy-extension' }}
run: |
echo "Building ref: $GITHUB_REF"
echo "Building sha: $GITHUB_SHA"
git --no-pager log -1 --oneline
echo "next: $(git -C next-src rev-parse HEAD)"
echo "legacy: $(git -C legacy-src rev-parse HEAD) ($LEGACY_REF)"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
# Node is still REQUIRED in the publish job (not just for install): the publish
# scripts run as `node ./scripts/publish-*.mjs` and shell out to `npx ovsx`.
# setup-bun does not provide a Node runtime, so keep setup-node here.
# Pinned to Node 22 because newer LTS (Node 24 / npm 11) can make vsce's
# `npm list` dependency detection fail with ELSPROBLEMS during packaging.
# Node is required beyond install: the rollout scripts run under node and
# publishing shells out to vsce/ovsx. Pinned to Node 22 because newer LTS
# (Node 24 / npm 11) can make vsce's dependency detection fail.
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
# Single root install resolves the whole bun workspace at once (replaces the
# per-package `npm ci` steps for apps/vscode + webview-ui).
- name: Install workspace dependencies
working-directory: ${{ github.workspace }}
# ONE version for the next bundle, the legacy bundle, and the union
# manifest: gen-manifest hard-fails if the bundle identities diverge.
# Same scheme as the standalone nightly: <major>.<minor>.<unix-seconds>
# from next's base version, so it keeps outranking earlier nightlies.
- name: Compute nightly version
id: version
run: |
BASE=$(node -p "require('./next-src/apps/vscode/package.json').version")
VERSION="$(echo "$BASE" | cut -d. -f1,2).$(date +%s)"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Combined nightly version: $VERSION (base $BASE)"
- name: Install next workspace dependencies
working-directory: next-src
run: bun install --frozen-lockfile
# @cline/* are local workspace symlinks to source packages; build dist/ before
# packaging/publishing the extension.
- name: Build SDK packages
working-directory: ${{ github.workspace }}
working-directory: next-src
run: bun run build:sdk
- name: Assert better-sqlite3 native binary present
working-directory: next-src/apps/vscode
run: |
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
if [ ! -f "$NODE_FILE" ]; then
@@ -93,20 +135,24 @@ jobs:
fi
echo "Found better-sqlite3 native binary: $NODE_FILE"
# vsce is now a workspace devDependency (on node_modules/.bin), but ovsx is not
# vendored and the publish script invokes it via `npx ovsx`, so install ovsx
# globally here (npm is available via setup-node). vsce is installed globally
# too to preserve the script's existing PATH expectations.
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
# Rewrite each bundle's package.json to the cline-nightly identity BEFORE
# its build (runtime command/config IDs derive from the manifest) and
# AFTER dependency install (workspace self-links key off the original
# package name).
- name: Nightlify next bundle manifest
working-directory: next-src/apps/vscode-rollout
run: node scripts/nightlify.mjs --dir "$GITHUB_WORKSPACE/next-src/apps/vscode" --version "${{ steps.version.outputs.version }}"
- name: Publish Nightly Extension
- name: Build next bundle
working-directory: next-src/apps/vscode
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
CLINE_ENVIRONMENT: production
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
CLINE_ENVIRONMENT: production
# Inlined by esbuild: attributes every telemetry event with
# extension_variant and unlocks the bundle's authoritative
# extension.rollout.bundle_activated capture. Rollout builds only.
CLINE_ROLLOUT_VARIANT: next
# OpenTelemetry production defaults (can be overridden at runtime)
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
@@ -114,12 +160,129 @@ jobs:
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
# The script itself runs under `node ./scripts/publish-nightly.mjs`; bun run
# just launches it. Node + npm (for `npx ovsx`) are provided by setup-node above.
run: bun run publish:marketplace:nightly
run: bun run package
- name: Install legacy dependencies
working-directory: legacy-src
run: |
npm --prefix apps/vscode install --include=optional
npm --prefix apps/vscode/webview-ui install --include=optional
- name: Nightlify legacy bundle manifest
working-directory: next-src/apps/vscode-rollout
run: node scripts/nightlify.mjs --dir "$GITHUB_WORKSPACE/legacy-src/apps/vscode" --version "${{ steps.version.outputs.version }}"
- name: Build legacy bundle
working-directory: legacy-src/apps/vscode
env:
CLINE_ENVIRONMENT: production
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
CLINE_ROLLOUT_VARIANT: legacy
# Legacy's esbuild inlines these too (its own publish workflow passes
# them) — omitting them here would ship the legacy bundle with the
# OTel pipeline dead, unlike what legacy users get today.
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
run: npm run package
- name: Build loader and run rollout tests
working-directory: next-src/apps/vscode-rollout
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
run: |
bun run typecheck
bun run test
bun run build:production
- name: Stitch combined VSIX staging
working-directory: next-src/apps/vscode-rollout
run: |
node scripts/stitch.mjs \
--next "$GITHUB_WORKSPACE/next-src/apps/vscode" \
--legacy "$GITHUB_WORKSPACE/legacy-src/apps/vscode" \
--loader dist/extension.js \
--version "${{ steps.version.outputs.version }}" \
--out "$GITHUB_WORKSPACE/staging"
- name: Smoke-test loader against staging
working-directory: next-src/apps/vscode-rollout
run: node scripts/smoke-loader.mjs "$GITHUB_WORKSPACE/staging"
# The nightly identity must have fully propagated (nightlify -> both
# bundle manifests -> union manifest) or we'd publish over the stable
# extension ID. The bundle sub-manifest checks guard the version
# stamping: the About tab and telemetry extension_version read those.
- name: Assert nightly manifest identity
working-directory: staging
env:
EXPECTED_VERSION: ${{ steps.version.outputs.version }}
run: |
node -e '
const assert = require("node:assert");
const expected = process.env.EXPECTED_VERSION;
const pkg = require("./package.json");
assert.equal(pkg.name, "cline-nightly", `unexpected name ${pkg.name}`);
assert.equal(pkg.publisher, "saoudrizwan", `unexpected publisher ${pkg.publisher}`);
assert.equal(pkg.version, expected, `unexpected union version ${pkg.version}`);
for (const bundle of ["next", "legacy"]) {
const sub = require(`./${bundle}/package.json`);
assert.equal(sub.name, "cline-nightly", `unexpected ${bundle} bundle name ${sub.name}`);
assert.equal(sub.version, expected, `unexpected ${bundle} bundle version ${sub.version}`);
}
console.log(`nightly identity ok: ${pkg.publisher}.${pkg.name}@${pkg.version} (bundle identities aligned)`);
'
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
- name: Package VSIX
working-directory: staging
# Preserve the narrowly scoped VSCE `sendgrid` scanner exemption used by
# both standalone bundle workflows. No SendGrid credential is intentionally
# supplied here; inspect the reported artifact before widening the exemption.
run: vsce package --no-dependencies --allow-package-secrets sendgrid --out "cline-nightly-${{ steps.version.outputs.version }}.vsix"
- name: Upload VSIX artifact
uses: actions/upload-artifact@v4
with:
name: cline-nightly-${{ steps.version.outputs.version }}
path: staging/cline-nightly-${{ steps.version.outputs.version }}.vsix
if-no-files-found: error
# The job is main-only; step-level dry-run gating still permits a build-only
# rehearsal without publishing or tagging.
- name: Publish to VS Code Marketplace and Open VSX
if: github.ref == 'refs/heads/main' && inputs.dry-run != true
working-directory: staging
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
run: |
if [[ -z "$VSCE_PAT" ]]; then
echo "Error: VSCE_PAT is required to publish."
exit 1
fi
vsce publish --no-dependencies --packagePath "cline-nightly-${{ steps.version.outputs.version }}.vsix"
if [[ -n "$OVSX_PAT" ]]; then
npx ovsx publish --packagePath "cline-nightly-${{ steps.version.outputs.version }}.vsix" --pat "$OVSX_PAT"
else
echo "WARNING: OVSX_PAT not set; skipping Open VSX publish."
fi
- name: Tag published commit
working-directory: ${{ github.workspace }}
if: github.ref == 'refs/heads/main' && inputs.dry-run != true
# Best-effort bookkeeping: the default GITHUB_TOKEN cannot create a ref
# whose commit modifies workflow files (no workflows permission exists
# for it), so this step fails whenever HEAD touched .github/workflows.
# The publish already succeeded by this point — don't mark the run red;
# push the tag manually with user credentials when it matters.
continue-on-error: true
working-directory: next-src
env:
GH_TOKEN: ${{ github.token }}
run: |
@@ -127,10 +290,11 @@ jobs:
SHORT_SHA=$(git rev-parse --short=12 HEAD)
TIMESTAMP=$(date -u +"%Y%m%d%H%M%S")
TAG="nightly-${SAFE_REF}-${TIMESTAMP}-${SHORT_SHA}"
LEGACY_SHA=$(git -C "$GITHUB_WORKSPACE/legacy-src" rev-parse HEAD)
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "$TAG" -m "Cline Nightly published from ${GITHUB_REF_NAME} at ${GITHUB_SHA}"
git tag -a "$TAG" -m "Cline Nightly (combined A/B) published from ${GITHUB_REF_NAME} at ${GITHUB_SHA} (legacy bundle: ${LEGACY_SHA})"
# Use an explicit HTTPS remote with GH_TOKEN because checkout was run with
# persist-credentials: false, so actions/checkout did not persist a git credential helper.
git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "refs/tags/${TAG}"
+1 -1
View File
@@ -126,7 +126,7 @@ jobs:
needs: [detect-changes, quality-checks]
if: needs.detect-changes.outputs.vscode == 'true'
env:
VSCODE_TEST_VERSION: 1.103.0
VSCODE_TEST_VERSION: 1.101.0
strategy:
fail-fast: false
matrix:
+58
View File
@@ -260,6 +260,41 @@ jobs:
git push origin "refs/tags/${TAG}"
done
- name: Get Previous SDK Tag
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
id: prev_tag
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
# The checkout is shallow and tagless, so fetch the release tags explicitly.
git fetch origin "+refs/tags/sdk/sdk/v*:refs/tags/sdk/sdk/v*"
PREV_TAG=$(git tag -l 'sdk/sdk/v*' | grep -vx "sdk/sdk/v${VERSION}" | sort -V | tail -1 || echo "")
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
- name: Get Changelog Entry
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
id: changelog
run: |
# Grab content between the first "## " header and the next one in sdk/CHANGELOG.md
CONTENT=$(awk '/^## [0-9]/{if(found) exit; found=1; next} found{print}' sdk/CHANGELOG.md)
DELIMITER=$(openssl rand -hex 8)
echo "content<<${DELIMITER}" >> $GITHUB_OUTPUT
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "${DELIMITER}" >> $GITHUB_OUTPUT
- name: Create GitHub Release
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
uses: softprops/action-gh-release@v1
with:
tag_name: sdk/sdk/v${{ steps.version.outputs.version }}
name: "SDK v${{ steps.version.outputs.version }}"
body: |
${{ steps.changelog.outputs.content }}
${{ steps.prev_tag.outputs.prev_tag != '' && format('**Full Changelog**: https://github.com/{0}/compare/{1}...sdk/sdk/v{2}', github.repository, steps.prev_tag.outputs.prev_tag, steps.version.outputs.version) || '' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Summary
if: steps.check_commits.outputs.skip != 'true'
env:
@@ -280,3 +315,26 @@ jobs:
echo " - sdk/core/v${VERSION}"
echo " - sdk/sdk/v${VERSION}"
fi
- name: Post release to Slack
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
uses: slackapi/slack-github-action@v3.0.1
with:
method: chat.postMessage
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
payload: |
channel: "C0APVKGGZFC"
text: "Cline SDK v${{ steps.version.outputs.version }}"
blocks:
- type: "section"
text:
type: "mrkdwn"
text: "Cline SDK v${{ steps.version.outputs.version }}"
- type: "section"
text:
type: "mrkdwn"
text: ${{ toJSON(steps.changelog.outputs.content) }}
- type: "context"
elements:
- type: "mrkdwn"
text: "<https://www.npmjs.com/package/@cline/sdk/v/${{ steps.version.outputs.version }}|View on npm>${{ steps.prev_tag.outputs.prev_tag != '' && format(' | Full Changelog: https://github.com/{0}/compare/{1}...sdk/sdk/v{2}', github.repository, steps.prev_tag.outputs.prev_tag, steps.version.outputs.version) || '' }}"
+145
View File
@@ -0,0 +1,145 @@
name: ui-publish
on:
workflow_dispatch:
inputs:
npm_tag:
description: "npm distribution tag"
required: true
type: choice
options:
- next
- latest
default: next
confirm_publish:
description: 'Type "publish" to publish @cline/ui to npm'
required: true
type: string
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
quality:
name: UI quality and package checks
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24.x"
- name: Install dependencies
run: bun install --filter @cline/ui --filter @cline/code --frozen-lockfile
- name: Typecheck UI
run: bun -F @cline/ui typecheck
- name: Test UI
run: bun -F @cline/ui test
- name: Build Storybook
run: bun -F @cline/ui build-storybook
- name: Build UI package
run: bun -F @cline/ui build
- name: Test desktop chat integration
run: bun -F @cline/code test:chat-ui
- name: Pack publish artifact
id: pack
shell: bash
run: |
set -euo pipefail
pack_dir="$RUNNER_TEMP/ui-npm-pack"
mkdir -p "$pack_dir"
cd sdk/packages/ui
bun pm pack --ignore-scripts --destination "$pack_dir" --quiet
archive=$(find "$pack_dir" -maxdepth 1 -name '*.tgz' -print -quit)
test -n "$archive"
echo "archive=$archive" >> "$GITHUB_OUTPUT"
- name: Test packed package
env:
UI_PACKAGE_ARCHIVE: ${{ steps.pack.outputs.archive }}
run: bun sdk/packages/ui/scripts/smoke-package.ts "$UI_PACKAGE_ARCHIVE"
- name: Upload publish artifact
uses: actions/upload-artifact@v4
with:
name: ui-npm-package
path: ${{ runner.temp }}/ui-npm-pack/*.tgz
if-no-files-found: error
retention-days: 7
publish:
name: Publish @cline/ui
if: >-
github.event_name == 'workflow_dispatch' &&
github.repository == 'cline/cline' &&
github.ref == 'refs/heads/main' &&
inputs.confirm_publish == 'publish' &&
!endsWith(github.actor, '[bot]')
needs: quality
runs-on: ubuntu-latest
environment: Publish
permissions:
contents: read
id-token: write
steps:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24.x"
registry-url: "https://registry.npmjs.org"
- name: Download publish artifact
uses: actions/download-artifact@v4
with:
name: ui-npm-package
path: ${{ runner.temp }}/ui-npm-pack
- name: Verify publish tooling
shell: bash
run: |
set -euo pipefail
npm_version=$(npm --version)
echo "npm ${npm_version}"
node -e 'const [major, minor, patch] = process.argv[1].split(".").map(Number); if (major < 11 || (major === 11 && (minor < 5 || (minor === 5 && patch < 1)))) { console.error("npm 11.5.1 or newer is required for trusted publishing"); process.exit(1); }' "$npm_version"
- name: Publish package
shell: bash
env:
NPM_CONFIG_PROVENANCE: "true"
NPM_TAG: ${{ inputs.npm_tag }}
run: |
set -euo pipefail
archive=$(find "$RUNNER_TEMP/ui-npm-pack" -maxdepth 1 -name '*.tgz' -print -quit)
if [ -z "$archive" ]; then
echo "UI package archive was not downloaded"
exit 1
fi
version=$(tar -xOf "$archive" package/package.json | node -e 'let input=""; process.stdin.on("data", chunk => input += chunk); process.stdin.on("end", () => process.stdout.write(JSON.parse(input).version))')
if npm view "@cline/ui@${version}" version >/dev/null 2>&1; then
echo "@cline/ui@${version} already exists; bump sdk/packages/ui/package.json before publishing"
exit 1
fi
npm publish "$archive" --tag "$NPM_TAG" --access public
echo "Published @cline/ui@${version} with npm tag '${NPM_TAG}'"
+2
View File
@@ -42,6 +42,8 @@ event names. It exports:
"emits X as a normal opt-out-respecting event"). Events that intentionally bypass
opt-out must use `captureRequired` and assert that explicitly.
**All events should be named using snake_case and so should their properties**
## The Activation Funnel
The canonical funnel that downstream analytics depends on:
+36
View File
@@ -1,5 +1,41 @@
# Cline CLI Changelog
## 3.0.46
- Fixed out-of-credits detection so the CLI reliably recognizes the Cline API's real `insufficient_credits` (402) error and shows the "add credits" card instead of a generic error
## 3.0.45
- Smaller install: the Claude Code and Codex providers are now optional and loaded on demand, cutting `npm i -g cline` from ~640MB to ~285MB (from SDK v0.0.65)
- Kimi K3 is now available as a ClinePass model (from SDK v0.0.65)
- Runs now retry once after refreshing expired OAuth credentials (from SDK v0.0.65)
- Team runs: the spawn tool is no longer exposed to teammates, and errored teammate runs now report as failed instead of completed (from SDK v0.0.65)
- Hub status output now includes version numbers
- Updated the bundled model catalog (from SDK v0.0.65)
## 3.0.44
- Improved max output token handling across providers (gateway routing, OpenAI vendor, and reasoning models) (from SDK v0.0.64)
- Frontmatter and configuration files that start with a UTF-8 byte order mark (e.g. saved by Windows editors) now parse correctly (from SDK v0.0.64)
## 3.0.43
- The CLI now automatically trusts your operating system's certificate store, so it works behind corporate proxies and TLS-inspecting firewalls without manually setting `NODE_EXTRA_CA_CERTS` (fixes "unable to get local issuer certificate" errors, including Windows intermediate CA stores)
## 3.0.42
- Fixed Ollama native API routing so context window and timeout settings work again
## 3.0.41
- Compaction now shows progress status in the TUI
- Model IDs are now suggested from OpenAI-compatible endpoints when configuring a provider
- Workspace git info (branch/remote) is now persisted and refreshed across sessions
- Compaction no longer runs during an active turn
- Fixed a crash when the terminal title was updated during TUI teardown
- The API key fallback hint is now highlighted for better visibility
- Benign git states are no longer reported as workspace initialization errors
## 3.0.40
- Added a manual API key escape hatch for Cline OAuth providers, so you can enter a key by hand from settings
+15
View File
@@ -346,9 +346,24 @@ Desktop-integrated approval mode is also supported via env wiring (`CLINE_TOOL_A
- `CLINE_LOG_LEVEL` - Runtime log level (`trace|debug|info|warn|error|fatal|silent`, default `info`)
- `CLINE_LOG_PATH` - Runtime log file path (default `<CLINE_DATA_DIR>/logs/cline.log`)
- `CLINE_LOG_NAME` - Logger name embedded in runtime log records
- `CLINE_DEBUG` - Set to `1`/`true` to print wrapper diagnostics (e.g. the CA bundle summary)
`--key` takes precedence over environment variables.
## Certificate trust
The CLI automatically trusts your operating system's certificate store, so it
works behind corporate TLS-inspecting proxies and with self-signed/internal
endpoints without any setup. On launch the `cline` wrapper harvests the OS trust
anchors and writes them to `~/.cline/cli-node-extra-ca-certs.pem`, then points
the runtime's `NODE_EXTRA_CA_CERTS` at that bundle. The file is regenerated when
it changes and is safe to delete (it is rebuilt on the next run).
If you set `NODE_EXTRA_CA_CERTS` yourself, your certificates are **merged** into
that bundle alongside the system store rather than replacing it. Run with
`CLINE_DEBUG=1` to see how many OS and user CAs were loaded and where the bundle
was written.
## Contributing
See [DEVELOPMENT.md](./DEVELOPMENT.md) for local development setup, monorepo structure, and TUI architecture. See [DISTRIBUTION.md](./DISTRIBUTION.md) for how the CLI is packaged and distributed.
+281
View File
@@ -0,0 +1,281 @@
// Auto-discovery of OS trust anchors for the Cline CLI.
//
// Bun does not read the OS trust store, so the 3.x CLI cannot see corporate
// MITM / self-signed CAs out of the box. This runs in the Node `bin/cline`
// wrapper (not Bun), reads the full OS store via tls.getCACertificates("system")
// (Node >= 22, no --use-system-ca flag), and hands the certs to the Bun child
// via NODE_EXTRA_CA_CERTS, which both runtimes honor. Mirrors the JetBrains
// plugin's configureCertificates(), sourcing from the OS instead of the IDE.
//
// Dependency-free CommonJS with injectable modules so it is unit-testable and
// ships verbatim in the published wrapper package.
const PEM_MARKER = "-----BEGIN CERTIFICATE-----";
const CERT_BLOCK =
/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g;
/**
* Returns only the complete certificate blocks from PEM text, or null when
* there are none. User files may also hold private keys (combined cert+key
* PEMs) or other sections, which must never be copied into the managed
* bundle. Files that contain nothing but certificates pass through verbatim
* so unchanged bundles keep hash-skipping the rewrite.
*/
function sanitizePem(text) {
const blocks = text.match(CERT_BLOCK) ?? [];
if (blocks.length === 0) {
return null;
}
const rest = text.replace(CERT_BLOCK, "");
if (/^\s*$/.test(rest)) {
return text;
}
return `${blocks.join("\n")}\n`;
}
/**
* Returns OS-trusted certificates as PEM strings, or [] when unavailable.
* tls.getCACertificates("system") requires Node >= 22.
*/
function harvestSystemCerts(tlsModule) {
try {
const tls = tlsModule || require("node:tls");
if (typeof tls.getCACertificates !== "function") {
return [];
}
const certs = tls.getCACertificates("system");
if (!Array.isArray(certs)) {
return [];
}
return certs.filter(
(cert) => typeof cert === "string" && cert.includes(PEM_MARKER),
);
} catch {
return [];
}
}
/**
* Returns the file's certificate blocks as PEM text, or null when missing,
* unreadable, or holding no complete certificate block.
*/
function readUserBundle(fsModule, userPath) {
if (!userPath) {
return null;
}
try {
const fs = fsModule || require("node:fs");
const stat = fs.statSync(userPath, { throwIfNoEntry: false });
if (!stat || !stat.isFile()) {
return null;
}
// Binary DER would not have loaded in the runtime either; require PEM.
return sanitizePem(fs.readFileSync(userPath, "utf8"));
} catch {
return null;
}
}
/**
* Reads the user's NODE_EXTRA_CA_CERTS value into PEM strings. Node treats the
* value as a single file, but some users set an OS-path-delimited list; the
* whole value is tried as one file first, then split.
* The managed bundle is excluded so reading it back never re-appends its certs.
*/
function readUserCerts(fsModule, pathModule, value, managedPath) {
if (!value) {
return [];
}
const fs = fsModule || require("node:fs");
const path = pathModule || require("node:path");
const candidates = [];
const whole = readUserBundle(fs, value);
if (whole) {
candidates.push({ filePath: value, pem: whole });
} else if (value.includes(path.delimiter)) {
for (const segment of value.split(path.delimiter)) {
const trimmed = segment.trim();
if (!trimmed) {
continue;
}
const pem = readUserBundle(fs, trimmed);
if (pem) {
candidates.push({ filePath: trimmed, pem });
}
}
}
const pems = [];
for (const candidate of candidates) {
const isManaged =
managedPath &&
path.resolve(candidate.filePath) === path.resolve(managedPath);
if (!isManaged) {
pems.push(candidate.pem);
}
}
return pems;
}
/**
* Concatenates the user PEMs (if any) and the system certificates into one
* bundle. A separating newline is inserted between parts so adjacent END/BEGIN
* markers cannot fuse into one invalid line.
*/
function buildBundle({ systemCerts, userPems }) {
const parts = [...(userPems ?? []), ...systemCerts];
return parts
.map((part) => (part.endsWith("\n") ? part : `${part}\n`))
.join("");
}
/** Counts individual PEM certificates across the given bundle strings. */
function countCerts(pems) {
let count = 0;
for (const pem of pems) {
count += pem.split(PEM_MARKER).length - 1;
}
return count;
}
function readFileIfExists(fs, filePath) {
try {
return fs.readFileSync(filePath, "utf8");
} catch {
return null;
}
}
function resolveClineDir(env, os, path) {
return env.CLINE_DIR?.trim() || path.join(os.homedir(), ".cline");
}
/**
* True when the api-unavailable warning should print. Stamped per Node version
* in the cline dir so the nudge shows once rather than on every command; a
* version change (upgrade that still falls short, or downgrade) re-arms it.
* When the stamp cannot be read or written, warn — bookkeeping failures must
* never suppress a real diagnostic.
*/
function shouldWarnApiUnavailable(env, deps = {}) {
const fs = deps.fs || require("node:fs");
const os = deps.os || require("node:os");
const path = deps.path || require("node:path");
const version = deps.nodeVersion || process.versions.node;
const dir = resolveClineDir(env, os, path);
const stamp = path.join(dir, `.ca-api-warned-${version}`);
try {
if (fs.existsSync(stamp)) {
return false;
}
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(stamp, "", { mode: 0o600 });
return true;
} catch {
return true;
}
}
/** Atomically writes [content] to [target]; returns true on success. */
function writeBundle(fs, dir, target, content) {
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
try {
fs.mkdirSync(dir, { recursive: true });
// Owner read/write: the bundle holds public CA material, not secrets,
// but there is no reason to make it world-writable.
fs.writeFileSync(tmp, content, { mode: 0o600 });
try {
fs.renameSync(tmp, target);
} catch {
// Windows can reject rename over a file a concurrent child holds open.
fs.rmSync(target, { force: true });
fs.renameSync(tmp, target);
}
return true;
} catch {
// Never leave a partial temp file behind (e.g. ENOSPC mid-write).
try {
fs.rmSync(tmp, { force: true });
} catch {
// Ignore: best-effort cleanup.
}
return false;
}
}
/**
* Harvests OS trust anchors, merges them with any user NODE_EXTRA_CA_CERTS, and
* points env.NODE_EXTRA_CA_CERTS at a single managed PEM bundle. Mutates `env`
* in place. Returns an outcome the caller can log; `action` is one of
* "unchanged" | "written" | "write-failed-reused" | "write-failed" |
* "no-system-certs" | "api-unavailable".
*/
function configureNodeExtraCaCerts(env, deps = {}) {
const fs = deps.fs || require("node:fs");
const os = deps.os || require("node:os");
const path = deps.path || require("node:path");
const tls = deps.tls || require("node:tls");
// tls.getCACertificates("system") needs Node >= 22.15; on older Nodes the
// harvest cannot run at all, which the caller should surface to the user.
if (typeof tls.getCACertificates !== "function") {
return {
action: "api-unavailable",
path: null,
systemCertCount: 0,
userCertCount: 0,
};
}
const systemCerts = harvestSystemCerts(tls);
if (systemCerts.length === 0) {
// Nothing to add: leave any user-provided NODE_EXTRA_CA_CERTS untouched
// and let the runtime fall back to its bundled CAs.
return {
action: "no-system-certs",
path: null,
systemCertCount: 0,
userCertCount: 0,
};
}
const managedDir = resolveClineDir(env, os, path);
const managedPath = path.join(managedDir, "cli-node-extra-ca-certs.pem");
const userValue = (env.NODE_EXTRA_CA_CERTS || "").trim() || null;
const userPems = readUserCerts(fs, path, userValue, managedPath);
const bundle = buildBundle({ systemCerts, userPems });
const base = {
path: managedPath,
systemCertCount: systemCerts.length,
userCertCount: countCerts(userPems),
};
// Skip the rewrite when the bundle is already current. Avoids per-launch I/O
// and the concurrent-rename race in the steady state.
if (readFileIfExists(fs, managedPath) === bundle) {
env.NODE_EXTRA_CA_CERTS = managedPath;
return { ...base, action: "unchanged" };
}
if (writeBundle(fs, managedDir, managedPath, bundle)) {
env.NODE_EXTRA_CA_CERTS = managedPath;
return { ...base, action: "written" };
}
// Write failed: fall back to a previously-written bundle if one exists.
if (readFileIfExists(fs, managedPath)) {
env.NODE_EXTRA_CA_CERTS = managedPath;
return { ...base, action: "write-failed-reused" };
}
return { ...base, path: null, action: "write-failed" };
}
module.exports = {
harvestSystemCerts,
sanitizePem,
readUserBundle,
readUserCerts,
buildBundle,
countCerts,
configureNodeExtraCaCerts,
shouldWarnApiUnavailable,
};
+42
View File
@@ -23,6 +23,48 @@ const childEnv = {
CLINE_WRAPPER_PATH: scriptPath,
};
// Auto-discover OS trust anchors and pass them to the Bun child via
// NODE_EXTRA_CA_CERTS. The Bun runtime does not read the OS store on its own,
// so corporate/self-signed CAs would otherwise fail. This wrapper runs on
// Node, which can read the full store here.
try {
const caCerts = require("./ca-certs.cjs");
const outcome = caCerts.configureNodeExtraCaCerts(childEnv);
const debug =
process.env.CLINE_DEBUG === "1" || process.env.CLINE_DEBUG === "true";
// Not debug-gated: on old Nodes the harvest silently doing nothing is
// indistinguishable from a broken corporate proxy. Stamped per Node
// version so the nudge shows once, not on every command.
if (
outcome &&
outcome.action === "api-unavailable" &&
!childEnv.NODE_EXTRA_CA_CERTS &&
caCerts.shouldWarnApiUnavailable(childEnv)
) {
console.warn(
`[cline] Node ${process.versions.node} cannot read the OS trust store (needs >= 22.15); ` +
"corporate or self-signed CAs may fail TLS. Upgrade Node or set NODE_EXTRA_CA_CERTS.",
);
}
if (debug && outcome) {
if (outcome.action === "no-system-certs") {
console.warn(
"[cline] No OS trust anchors found; relying on the runtime's bundled CAs.",
);
} else if (outcome.action === "write-failed") {
console.warn(
"[cline] Could not write the managed CA bundle; relying on the runtime's bundled CAs.",
);
} else {
console.warn(
`[cline] Trust: ${outcome.systemCertCount} OS + ${outcome.userCertCount} user CAs (${outcome.action}) -> ${outcome.path}`,
);
}
}
} catch {
// Best effort: fall back to the runtime's default trust on any failure.
}
function run(target) {
const result = childProcess.spawnSync(target, process.argv.slice(2), {
stdio: "inherit",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.40",
"version": "3.0.46",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
+367
View File
@@ -0,0 +1,367 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
// The helper ships as CommonJS in the published wrapper package, so it is
// loaded via require rather than an ESM import.
const caCerts = require("../../bin/ca-certs.cjs") as {
harvestSystemCerts: (tls?: unknown) => string[];
readUserBundle: (fs: unknown, p: string | null) => string | null;
readUserCerts: (
fs: unknown,
path: unknown,
value: string | null,
managedPath: string | null,
) => string[];
buildBundle: (input: {
systemCerts: string[];
userPems?: string[];
}) => string;
countCerts: (pems: string[]) => number;
configureNodeExtraCaCerts: (
env: Record<string, string>,
deps?: { tls?: unknown; fs?: unknown },
) => {
action: string;
path: string | null;
systemCertCount: number;
userCertCount: number;
};
shouldWarnApiUnavailable: (
env: Record<string, string>,
deps?: { fs?: unknown; nodeVersion?: string },
) => boolean;
};
const fs = require("node:fs");
const path = require("node:path");
const certSystem =
"-----BEGIN CERTIFICATE-----\nSYSTEM\n-----END CERTIFICATE-----\n";
const certUser = "-----BEGIN CERTIFICATE-----\nUSER\n-----END CERTIFICATE-----";
function fakeTls(certs: unknown) {
return { getCACertificates: () => certs };
}
describe("ca-certs", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "cline-ca-"));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
describe("harvestSystemCerts", () => {
it("returns only PEM strings from the system store", () => {
expect(
caCerts.harvestSystemCerts(fakeTls([certSystem, "not-a-cert", 42])),
).toEqual([certSystem]);
});
it("returns [] when getCACertificates is unavailable", () => {
expect(caCerts.harvestSystemCerts({})).toEqual([]);
});
it("returns [] when getCACertificates throws", () => {
expect(
caCerts.harvestSystemCerts({
getCACertificates: () => {
throw new Error("nope");
},
}),
).toEqual([]);
});
});
describe("readUserBundle", () => {
it("returns PEM contents for a PEM file", () => {
const p = join(dir, "user.pem");
writeFileSync(p, certUser);
expect(caCerts.readUserBundle(fs, p)).toBe(certUser);
});
it("returns null for a non-PEM (DER) file", () => {
const p = join(dir, "user.der");
writeFileSync(p, Buffer.from([0x30, 0x82, 0x01, 0x02]));
expect(caCerts.readUserBundle(fs, p)).toBeNull();
});
it("returns null for a missing file and for null path", () => {
expect(caCerts.readUserBundle(fs, join(dir, "nope.pem"))).toBeNull();
expect(caCerts.readUserBundle(fs, null)).toBeNull();
});
it("strips non-certificate sections such as private keys", () => {
// Combined cert+key files (nginx/haproxy style) are common; the key
// must never reach the managed bundle.
const p = join(dir, "combined.pem");
writeFileSync(
p,
`${certUser}\n-----BEGIN PRIVATE KEY-----\nSECRET\n-----END PRIVATE KEY-----\n`,
);
const out = caCerts.readUserBundle(fs, p);
expect(out).toContain("USER");
expect(out).not.toContain("PRIVATE KEY");
expect(out).not.toContain("SECRET");
});
it("keeps certificates-only files verbatim", () => {
// Byte-identical passthrough keeps the unchanged-skip hash stable.
const p = join(dir, "clean.pem");
writeFileSync(p, `${certUser}\n${certSystem}`);
expect(caCerts.readUserBundle(fs, p)).toBe(`${certUser}\n${certSystem}`);
});
it("returns null for a BEGIN marker without a complete block", () => {
const p = join(dir, "truncated.pem");
writeFileSync(p, "-----BEGIN CERTIFICATE-----\ntruncated");
expect(caCerts.readUserBundle(fs, p)).toBeNull();
});
});
describe("readUserCerts", () => {
it("reads a single PEM file path", () => {
const p = join(dir, "corp.pem");
writeFileSync(p, certUser);
expect(caCerts.readUserCerts(fs, path, p, null)).toEqual([certUser]);
});
it("splits a legacy OS-path-delimited value and reads each PEM", () => {
// Legacy footgun: NODE_EXTRA_CA_CERTS="a.pem;b.pem".
const a = join(dir, "a.pem");
const b = join(dir, "b.pem");
writeFileSync(a, certUser);
writeFileSync(b, certSystem);
expect(
caCerts.readUserCerts(fs, path, [a, b].join(delimiter), null),
).toEqual([certUser, certSystem]);
});
it("skips missing segments in a delimited value", () => {
const a = join(dir, "a.pem");
writeFileSync(a, certUser);
const value = [a, join(dir, "missing.pem")].join(delimiter);
expect(caCerts.readUserCerts(fs, path, value, null)).toEqual([certUser]);
});
it("excludes the managed bundle from user certs", () => {
const managed = join(dir, "cli-node-extra-ca-certs.pem");
writeFileSync(managed, certUser);
expect(caCerts.readUserCerts(fs, path, managed, managed)).toEqual([]);
});
it("returns [] for empty value", () => {
expect(caCerts.readUserCerts(fs, path, null, null)).toEqual([]);
});
});
describe("buildBundle", () => {
it("merges user PEMs before system certs", () => {
expect(
caCerts.buildBundle({
systemCerts: [certSystem],
userPems: [certUser],
}),
).toBe(`${certUser}\n${certSystem}`);
});
it("inserts a separating newline so END/BEGIN markers do not fuse", () => {
// certUser has no trailing newline, so this proves the boundary fix.
const merged = caCerts.buildBundle({
systemCerts: [certSystem],
userPems: [certUser],
});
expect(merged).not.toContain(
"-----END CERTIFICATE----------BEGIN CERTIFICATE-----",
);
});
it("handles no user PEMs", () => {
expect(caCerts.buildBundle({ systemCerts: [certSystem] })).toBe(
certSystem,
);
});
});
describe("configureNodeExtraCaCerts", () => {
it("writes a managed bundle and points the env var at it", () => {
const env: Record<string, string> = { CLINE_DIR: dir };
const out = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
});
expect(out.action).toBe("written");
expect(out.path).toBe(join(dir, "cli-node-extra-ca-certs.pem"));
expect(env.NODE_EXTRA_CA_CERTS).toBe(out.path);
expect(readFileSync(out.path as string, "utf8")).toContain("SYSTEM");
});
it("merges a user-supplied NODE_EXTRA_CA_CERTS with system certs", () => {
const userPath = join(dir, "corp.pem");
writeFileSync(userPath, certUser);
const env: Record<string, string> = {
CLINE_DIR: dir,
NODE_EXTRA_CA_CERTS: userPath,
};
const out = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
});
expect(out.userCertCount).toBe(1);
const written = readFileSync(env.NODE_EXTRA_CA_CERTS, "utf8");
expect(written).toContain("USER");
expect(written).toContain("SYSTEM");
});
it("reports unchanged and skips rewrite on the second run", () => {
const env: Record<string, string> = { CLINE_DIR: dir };
expect(
caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([certSystem]) })
.action,
).toBe("written");
expect(
caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([certSystem]) })
.action,
).toBe("unchanged");
});
it("does not re-append when the user already points at the managed bundle", () => {
const env: Record<string, string> = { CLINE_DIR: dir };
const first = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
}).path as string;
const env2: Record<string, string> = {
CLINE_DIR: dir,
NODE_EXTRA_CA_CERTS: first,
};
caCerts.configureNodeExtraCaCerts(env2, { tls: fakeTls([certSystem]) });
const written = readFileSync(env2.NODE_EXTRA_CA_CERTS, "utf8");
expect(written.match(/SYSTEM/g)?.length).toBe(1);
});
it("no-ops when no system certs are available", () => {
const env: Record<string, string> = {
CLINE_DIR: dir,
NODE_EXTRA_CA_CERTS: "/user/corp.pem",
};
const out = caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([]) });
expect(out.action).toBe("no-system-certs");
expect(out.path).toBeNull();
expect(env.NODE_EXTRA_CA_CERTS).toBe("/user/corp.pem");
});
it("reports api-unavailable on Nodes without getCACertificates", () => {
const env: Record<string, string> = {
CLINE_DIR: dir,
NODE_EXTRA_CA_CERTS: "/user/corp.pem",
};
const out = caCerts.configureNodeExtraCaCerts(env, { tls: {} });
expect(out.action).toBe("api-unavailable");
expect(out.path).toBeNull();
expect(env.NODE_EXTRA_CA_CERTS).toBe("/user/corp.pem");
});
it("reports write-failed when the bundle cannot be written", () => {
const realFs = require("node:fs");
const failingFs = {
...realFs,
mkdirSync: () => {
throw new Error("EACCES");
},
writeFileSync: () => {
throw new Error("EACCES");
},
};
const env: Record<string, string> = { CLINE_DIR: dir };
const out = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
fs: failingFs,
});
expect(out.action).toBe("write-failed");
expect(out.path).toBeNull();
expect(env.NODE_EXTRA_CA_CERTS).toBeUndefined();
});
it("reuses a stale bundle when the rewrite fails", () => {
// First run writes the bundle normally.
const env: Record<string, string> = { CLINE_DIR: dir };
const managedPath = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
}).path as string;
// Second run: writes fail, but the stale bundle is still readable.
const realFs = require("node:fs");
const failingFs = {
...realFs,
mkdirSync: () => {
throw new Error("EACCES");
},
writeFileSync: () => {
throw new Error("EACCES");
},
};
const env2: Record<string, string> = { CLINE_DIR: dir };
const out = caCerts.configureNodeExtraCaCerts(env2, {
// A different system cert forces a rewrite attempt (not "unchanged").
tls: fakeTls([certUser]),
fs: failingFs,
});
expect(out.action).toBe("write-failed-reused");
expect(env2.NODE_EXTRA_CA_CERTS).toBe(managedPath);
});
});
describe("countCerts", () => {
it("counts individual certificates, not files", () => {
// One file holding two certs must report 2, not 1.
const twoInOne = `${certUser}\n${certSystem}`;
expect(caCerts.countCerts([twoInOne])).toBe(2);
expect(caCerts.countCerts([certUser, certSystem])).toBe(2);
expect(caCerts.countCerts([])).toBe(0);
});
});
describe("shouldWarnApiUnavailable", () => {
it("warns once per Node version, then stays quiet", () => {
const env = { CLINE_DIR: dir };
const deps = { nodeVersion: "22.1.0" };
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(false);
});
it("re-arms when the Node version changes", () => {
const env = { CLINE_DIR: dir };
expect(
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.1.0" }),
).toBe(true);
expect(
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.14.0" }),
).toBe(true);
expect(
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.1.0" }),
).toBe(false);
});
it("still warns when the stamp cannot be written", () => {
const realFs = require("node:fs");
const failingFs = {
...realFs,
mkdirSync: () => {
throw new Error("EACCES");
},
writeFileSync: () => {
throw new Error("EACCES");
},
};
const env = { CLINE_DIR: dir };
const deps = { fs: failingFs, nodeVersion: "22.1.0" };
// Bookkeeping failure must never suppress the diagnostic.
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
});
});
});
+3 -2
View File
@@ -1,4 +1,4 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { existsSync, readdirSync } from "node:fs";
import { homedir } from "node:os";
import { basename, extname, join } from "node:path";
import {
@@ -15,6 +15,7 @@ import {
type SkillConfig,
type WorkflowConfig,
} from "@cline/core";
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
import { Command } from "commander";
import { getToolCatalog } from "../runtime/tools";
import { loadInteractiveConfigData } from "../tui/interactive-config";
@@ -209,7 +210,7 @@ async function runAgentsConfigCommand(
continue;
}
const filePath = join(directory, entry.name);
const raw = readFileSync(filePath, "utf8");
const raw = readFileSyncStrippingUtf8Bom(filePath);
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const frontmatter = frontmatterMatch?.[1] ?? "";
const nameMatch = frontmatter.match(/^\s*name:\s*(.+?)\s*$/m);
+35
View File
@@ -9,6 +9,7 @@ import {
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { version as cliVersion } from "../../package.json";
import { getCliBuildInfo } from "../utils/common";
const {
@@ -174,6 +175,40 @@ describe("runDoctorCommand", () => {
);
});
it("reports CLI and running hub Core versions", async () => {
const cwd = "/workspace";
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
port: 25463,
pid: 50174,
coreVersion: "0.0.63",
});
mockProbeHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
port: 25463,
pid: 50174,
coreVersion: "0.0.64",
});
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
const output: string[] = [];
const code = await runDoctorCommand(
{ cwd, json: true },
{
writeln: (text) => {
output.push(text ?? "");
},
writeErr: () => {},
},
);
expect(code).toBe(0);
expect(JSON.parse(output[0] || "")).toMatchObject({
cliVersion,
coreVersion: "0.0.64",
});
});
it("doctor --fix clears wedged hub startup artifacts when no server is actually running", async () => {
const cwd = mkdtempSync(path.join(os.tmpdir(), "doctor-hub-fix-"));
tempDirs.push(cwd);
+7
View File
@@ -14,6 +14,7 @@ import {
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
import { Command } from "commander";
import open from "open";
import { version as cliVersion } from "../../package.json";
import { isProcessRunning } from "../connectors/common";
import {
type ActiveConnectorRecord,
@@ -49,6 +50,8 @@ type SpawnedProcessRecord = {
type DoctorStatus = {
cwd: string;
cliVersion: string;
coreVersion?: string;
hubUrl?: string;
hubHealthy: boolean;
hubPid?: number;
@@ -337,6 +340,8 @@ async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
];
return {
cwd,
cliVersion,
coreVersion: health?.coreVersion ?? discovery?.coreVersion,
hubUrl: current?.url,
hubHealthy: !!health?.url,
hubPid: current?.pid,
@@ -419,6 +424,8 @@ export async function runDoctorCommand(
io.writeln(JSON.stringify(before));
return 0;
}
writeln(`cli version ${c.dim}${before.cliVersion}${c.reset}`);
writeln(`core version ${c.dim}${before.coreVersion ?? "n/a"}${c.reset}`);
writeln(`hub url ${c.dim}${before.hubUrl ?? "none"}${c.reset}`);
writeln(
`hub healthy ${c.dim}${before.hubHealthy ? "yes" : "no"}${before.hubPid ? ` (pid=${before.hubPid})` : ""}${c.reset}`,
+4
View File
@@ -34,6 +34,7 @@ vi.mock("@cline/core", () => ({
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
}));
import { version as cliVersion } from "../../package.json";
import { createHubCommand } from "./hub";
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
@@ -63,6 +64,7 @@ describe("createHubCommand", () => {
port: 25463,
pid: 50174,
startedAt: "2026-01-01T00:00:00.000Z",
coreVersion: "0.0.62",
});
const output: string[] = [];
@@ -88,6 +90,8 @@ describe("createHubCommand", () => {
pid: 50174,
startedAt: "2026-01-01T00:00:00.000Z",
uptime: "1m 5s",
cliVersion,
coreVersion: "0.0.62",
});
});
+3
View File
@@ -9,6 +9,7 @@ import {
} from "@cline/core";
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
import { Command } from "commander";
import { version as cliVersion } from "../../package.json";
interface HubCommandIo {
writeln: (text?: string) => void;
@@ -134,6 +135,8 @@ export function createHubCommand(
pid: health?.pid,
startedAt: health?.startedAt,
uptime,
cliVersion,
coreVersion: health?.coreVersion ?? discovery?.coreVersion,
}),
);
}),
+9 -3
View File
@@ -982,9 +982,15 @@ export async function runCli(): Promise<void> {
// and cannot be retroactively updated; this is by design for
// lightweight subcommand and pre-auth CLI flows. See CLINE-2406.
if (provider === "cline") {
const savedAccountId = selectedProviderSettings?.auth?.accountId;
if (savedAccountId) {
identifyTelemetryAccount({ id: savedAccountId, provider: "cline" });
const savedAuth = selectedProviderSettings?.auth;
if (savedAuth?.accountId) {
identifyTelemetryAccount({
id: savedAuth.accountId,
provider: "cline",
organizationId: savedAuth.organizationId,
organizationName: savedAuth.organizationName,
memberId: savedAuth.memberId,
});
}
}
@@ -106,7 +106,7 @@ describe("compactInteractiveMessages", () => {
}));
const config = createConfig();
const compact = vi.fn((context: CoreCompactionContext) => {
expect(context.maxInputTokens).toBe(400_000);
expect(context.budget.request.maxInputTokens).toBe(400_000);
return { messages: [messages[0]] };
});
config.knownModels = {
@@ -130,7 +130,7 @@ describe("compactInteractiveMessages", () => {
expect(result.compactionState?.messages).toEqual([messages[0]]);
});
it("falls back to legacy contextWindow for manual compaction", async () => {
it("uses 90 percent of legacy contextWindow for manual compaction", async () => {
const longText = "x".repeat(16_000);
const messages = Array.from({ length: 10 }, (_, index) => ({
role: index % 2 === 0 ? ("user" as const) : ("assistant" as const),
@@ -138,7 +138,7 @@ describe("compactInteractiveMessages", () => {
}));
const config = createConfig();
const compact = vi.fn((context: CoreCompactionContext) => {
expect(context.maxInputTokens).toBe(400_000);
expect(context.budget.request.maxInputTokens).toBe(360_000);
return { messages: [messages[0]] };
});
config.knownModels = {
+10 -10
View File
@@ -61,11 +61,15 @@ export async function compactInteractiveMessages(input: {
compactionState?: SessionCompactionState;
}> {
const modelInfo = input.config.knownModels?.[input.config.modelId];
const maxInputTokens =
input.config.compaction?.maxInputTokens ??
modelInfo?.maxInputTokens ??
modelInfo?.contextWindow ??
FALLBACK_MANUAL_COMPACTION_MAX_INPUT_TOKENS;
const compactionModelInfo = modelInfo
? {
...modelInfo,
id: modelInfo.id ?? input.config.modelId,
}
: {
id: input.config.modelId,
maxInputTokens: FALLBACK_MANUAL_COMPACTION_MAX_INPUT_TOKENS,
};
const compact = createContextCompactionPrepareTurn(
{
providerConfig: resolveCompactionProviderConfig(
@@ -106,11 +110,7 @@ export async function compactInteractiveMessages(input: {
model: {
id: input.config.modelId,
provider: input.config.providerId,
info: {
...(modelInfo ?? {}),
id: modelInfo?.id ?? input.config.modelId,
maxInputTokens: maxInputTokens,
},
info: compactionModelInfo,
},
});
if (!result?.messages) {
+7 -32
View File
@@ -107,38 +107,13 @@ export async function sendTurnWithActModeContinuation<
};
}
export type ModeSwitchNotice = {
from: InteractiveUiMode;
to: InteractiveUiMode;
};
/**
* Tracks a user-initiated mode switch so the next user message can carry a
* <mode_notice> marking it. Only UI toggles are recorded: the model-initiated
* switch_to_act_mode path already announces itself via the continuation
* prompt. A round trip (plan -> act -> plan before sending anything) cancels
* out, since the mode the model last saw never effectively changed.
*/
export function createModeSwitchNoticeTracker() {
let pending: ModeSwitchNotice | null = null;
return {
record(from: InteractiveUiMode, to: InteractiveUiMode): void {
if (from === to) {
return;
}
if (pending) {
pending = pending.from === to ? null : { from: pending.from, to };
return;
}
pending = { from, to };
},
consume(): ModeSwitchNotice | null {
const notice = pending;
pending = null;
return notice;
},
};
}
// The tracker moved to @cline/shared so the VSCode extension can share the
// exact round-trip-cancelling semantics; re-exported here to keep the CLI's
// import surface stable.
export {
createModeSwitchNoticeTracker,
type ModeSwitchNotice,
} from "@cline/shared";
export async function applyInteractiveModeConfig(input: {
config: Config;
@@ -641,7 +641,22 @@ export function createInteractiveSessionRuntime(input: {
})
: undefined,
);
return { forkedFromSessionId, newSessionId: activeSessionId };
// Report carried context from what the new session actually accepted:
// the host can reject the inherited state (e.g. stale anchor), and the
// UI must not claim a carry-over that did not happen.
const acceptedState = projectedMessages
? await readCompactionState(activeSessionId)
: undefined;
return {
forkedFromSessionId,
newSessionId: activeSessionId,
carriedWorkingContext: acceptedState
? {
workingContextMessages: acceptedState.messages.length,
canonicalMessages: messages.length,
}
: undefined,
};
};
const resumeSession = async (sessionId: string): Promise<Message[]> => {
+4 -26
View File
@@ -9,23 +9,6 @@ import {
import { type AgentMode, buildClineSystemPrompt } from "@cline/shared";
import { isImagePath, loadImageAsDataUrl } from "../utils/image-attachments";
const MODE_TAG_INSTRUCTIONS = `# Plan / Act Modes
User messages arrive wrapped in a <user_input mode="..."> tag. The mode attribute is the interaction mode the user was in when they sent that message: "plan" means plan-mode constraints applied (explore, analyze, and align on a plan -- no edits or state-changing commands), while "act" (or "yolo") means implementation was allowed. If the mode attribute changes between messages, the user switched modes -- the newest message's mode is what governs right now, regardless of what earlier messages allowed. A <mode_notice> block inside a message marks exactly when such a switch happened.`;
const PLAN_MODE_INSTRUCTIONS = `# Plan Mode
You are in Plan mode. Your role is to explore, analyze, and plan -- not to execute.
- Read files, search the codebase, and gather context to understand the problem
- Ask clarifying questions when requirements are ambiguous
- Present your plan as a structured outline with clear steps
- Explain tradeoffs between different approaches when they exist
- Do NOT edit files, write code, run destructive commands, or make any changes
- Do NOT implement anything -- focus on understanding and alignment first
Once the user has reviewed your plan and explicitly approved it in a follow-up message, use the switch_to_act_mode tool to switch to act mode and begin implementation. Calling switch_to_act_mode immediately starts execution, so never call it in the same turn you present a plan and never treat the original task request as approval -- end your turn after presenting the plan and wait for the user's response.`;
export async function resolveSystemPrompt(input: {
cwd: string;
explicitSystemPrompt?: string;
@@ -34,15 +17,10 @@ export async function resolveSystemPrompt(input: {
mode?: AgentMode;
}): Promise<string> {
const metadata = await buildWorkspaceMetadata(input.cwd);
let rules = mergeRulesForSystemPrompt(undefined, input.rules);
// Both modes get the mode-tag explanation: after a switch, the transcript
// still contains messages tagged with the other mode.
rules = rules
? `${rules}\n\n${MODE_TAG_INSTRUCTIONS}`
: MODE_TAG_INSTRUCTIONS;
if (input.mode === "plan") {
rules = `${rules}\n\n${PLAN_MODE_INSTRUCTIONS}`;
}
// Mode-tag and plan-mode instructions are appended by the shared prompt
// builder itself (see MODE_TAG_INSTRUCTIONS / PLAN_MODE_INSTRUCTIONS in
// @cline/shared), so only the caller-specific rules are merged here.
const rules = mergeRulesForSystemPrompt(undefined, input.rules);
return buildClineSystemPrompt({
ide: "Terminal Shell",
workspaceRoot: input.cwd,
+59
View File
@@ -309,3 +309,62 @@ describe("loadIndividualSubscriptionPlans", () => {
expect(result).toEqual(plans);
});
});
describe("isClineAccountCreditsErrorMessage", () => {
it("matches the raw insufficient_credits JSON payload from the Cline API 402", async () => {
const { isClineAccountCreditsErrorMessage } = await import(
"./cline-account"
);
expect(
isClineAccountCreditsErrorMessage(
'{"code":"insufficient_credits","current_balance":-0.14,"message":"Not enough credits available"}',
),
).toBe(true);
});
it("matches the insufficient_credits payload wrapped in an error prefix", async () => {
const { isClineAccountCreditsErrorMessage } = await import(
"./cline-account"
);
expect(
isClineAccountCreditsErrorMessage(
'Error: {"code":"insufficient_credits","current_balance":0,"message":"Not enough credits available"}',
),
).toBe(true);
});
it("matches the plain human-readable Cline API message", async () => {
const { isClineAccountCreditsErrorMessage } = await import(
"./cline-account"
);
expect(
isClineAccountCreditsErrorMessage("Not enough credits available"),
).toBe(true);
});
it("matches the legacy insufficient balance phrasing", async () => {
const { isClineAccountCreditsErrorMessage } = await import(
"./cline-account"
);
expect(
isClineAccountCreditsErrorMessage(
"Insufficient balance. Your Cline credits balance is $0.00.",
),
).toBe(true);
});
it("does not match unrelated errors", async () => {
const { isClineAccountCreditsErrorMessage } = await import(
"./cline-account"
);
expect(isClineAccountCreditsErrorMessage("Payment Required")).toBe(false);
expect(
isClineAccountCreditsErrorMessage(
"Your credit balance is too low to access the Anthropic API.",
),
).toBe(false);
expect(
isClineAccountCreditsErrorMessage("insufficient balance on gateway"),
).toBe(false);
});
});
+42 -2
View File
@@ -51,9 +51,16 @@ export function isClineAccountAuthErrorMessage(message: string): boolean {
export function isClineAccountCreditsErrorMessage(message: string): boolean {
const normalized = message.trim().toLowerCase();
// The Cline API's 402 response carries `code: "insufficient_credits"` and
// the message "Not enough credits available". Depending on how much of the
// payload survives error extraction, the CLI may see the raw JSON blob or
// just the human-readable message, so match both. The
// "insufficient balance" pair is an older backend phrasing kept for safety.
return (
normalized.includes("insufficient balance") &&
normalized.includes("cline credits balance")
normalized.includes("insufficient_credits") ||
normalized.includes("not enough credits") ||
(normalized.includes("insufficient balance") &&
normalized.includes("cline credits balance"))
);
}
@@ -151,6 +158,38 @@ export async function createClineAccountService(input: {
});
}
/**
* Persist the active organization so headless runs and the hub daemon can
* attach it to telemetry identity. Personal account clears stale org fields.
*/
function persistClineOrganizationContext(
activeOrganization: ClineAccountOrganization | null,
userId: string,
): void {
try {
const manager = new ProviderSettingsManager();
const persisted = manager.getProviderSettings("cline");
if (!persisted) {
return;
}
manager.saveProviderSettings(
{
...persisted,
auth: {
...persisted.auth,
accountId: persisted.auth?.accountId ?? userId,
organizationId: activeOrganization?.organizationId,
organizationName: activeOrganization?.name,
memberId: activeOrganization?.memberId,
},
},
{ setLastUsed: false },
);
} catch {
// Best-effort only.
}
}
export async function loadClineAccountSnapshot(input: {
config: ClineAccountConfig;
clineApiBaseUrl?: string;
@@ -183,6 +222,7 @@ export async function loadClineAccountSnapshot(input: {
memberId: activeOrganization?.memberId,
};
identifyTelemetryAccount(accountContext, input.config.logger);
persistClineOrganizationContext(activeOrganization, user.id);
return {
user,
+37 -1
View File
@@ -25,6 +25,7 @@ import {
type TerminalTheme,
} from "../palette";
import type { ChatEntry } from "../types";
import { formatCompactionDividerLabel } from "../utils/compaction-status";
import { getSyntaxStyle, type SyntaxAccentMode } from "../utils/syntax-style";
import { isWarningToolError } from "../utils/tool-errors";
import {
@@ -133,7 +134,7 @@ function formatToolParams(
const el = f.endLine != null ? String(f.endLine) : "undefined";
const sep = i > 0 ? "; " : "";
return (
<span key={f.path}>
<span key={`${i}:${f.path}`}>
{sep}
{shortenPath(f.path)}
<span fg="gray">
@@ -421,6 +422,38 @@ function ClineOrgIndividualInferenceSubscriptionErrorView(props: {
);
}
function CompactionDividerRow(props: {
entry: Extract<ChatEntry, { kind: "compaction" }>;
}) {
const { entry } = props;
const { width: terminalWidth } = useTerminalDimensions();
const inProgress = entry.status === "started";
const labelColor = inProgress
? "cyan"
: entry.status === "failed"
? "red"
: entry.status === "cancelled" || entry.status === "skipped"
? "gray"
: "cyan";
const label = `${formatCompactionDividerLabel(entry)}`;
// Fill the remaining line with a plain rule instead of a flexGrow bordered
// box: a single fixed-content text row keeps the renderer's diffing stable.
const ruleWidth = Math.max(2, Math.min(40, terminalWidth - label.length - 8));
return (
<box flexDirection="row">
{inProgress ? (
<box width={2}>
<spinner name="dots" color={labelColor} />
</box>
) : (
<text fg="gray" content="── " />
)}
<text fg={labelColor} selectable content={label} />
<text fg="gray" content={` ${"─".repeat(ruleWidth)}`} />
</box>
);
}
function ClinePassLimitErrorView(props: {
message: string;
defaultFg?: string;
@@ -616,6 +649,9 @@ export function ChatEntryView(props: {
</box>
);
case "compaction":
return <CompactionDividerRow entry={entry} />;
case "done": {
const parts: string[] = [];
if (entry.elapsed) parts.push(`${entry.elapsed}s`);
@@ -891,6 +891,7 @@ export function OAuthLoginContent(
const escapeHint = allowApiKeyFallback
? "K to enter an API key instead, Esc to cancel"
: "Esc to cancel";
const escapeHintColor = allowApiKeyFallback ? "white" : "gray";
if (mode === "device") {
return (
@@ -918,7 +919,7 @@ export function OAuthLoginContent(
{deviceError && <text fg="red">{deviceError}</text>}
<text fg="gray">
<text fg={escapeHintColor}>
<em>{escapeHint}</em>
</text>
</box>
@@ -941,7 +942,7 @@ export function OAuthLoginContent(
{error && <text fg="red">{error}</text>}
<text fg="gray">
<text fg={escapeHintColor}>
<em>{escapeHint}</em>
</text>
</box>
@@ -3,6 +3,7 @@ import type { OpenConfigOptions } from "./use-config-panel";
export interface LocalSlashCommandActionInput {
name: string;
isRunning: boolean;
openAccount: () => void;
openConfig: (options?: OpenConfigOptions) => void;
openMcpManager: () => Promise<boolean>;
@@ -46,7 +47,12 @@ export function runLocalSlashCommandAction(
return true;
}
if (normalized === "compact") {
input.runCompact();
// Autocomplete can invoke local commands while a turn is running. Keep
// /compact handled, but do not let it take ownership of the active turn's
// shared running state.
if (!input.isRunning) {
input.runCompact();
}
return true;
}
if (normalized === "fork") {
+87 -3
View File
@@ -6,13 +6,14 @@ import type {
PendingPromptSubmittedEvent,
} from "../../runtime/session-events";
import { formatCliErrorMessage } from "../../utils/cline-pass-errors";
import { resolveStatusNoticeLabel } from "../../utils/events";
import { resolveNonCompactionStatusLabel } from "../../utils/events";
import {
formatToolInput,
formatToolOutput,
truncate,
} from "../../utils/helpers";
import type { ChatEntry, InlineStream, TuiProps } from "../types";
import { parseCompactionNoticeMetadata } from "../utils/compaction-status";
interface AgentEventDeps {
appendEntry: (entry: ChatEntry) => void;
@@ -32,6 +33,7 @@ interface AgentEventDeps {
}
export function useAgentEventHandlers(deps: AgentEventDeps) {
const openCompactionEntryRef = useRef(false);
const {
appendEntry,
updateLastEntry,
@@ -45,6 +47,47 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
verbose,
} = deps;
// Compaction dividers that arrived while an assistant message was still
// streaming. Appending them immediately would split the message in two, so
// they are held until the active content block closes (or the turn ends).
const pendingCompactionEntriesRef = useRef<
Extract<ChatEntry, { kind: "compaction" }>[]
>([]);
const flushPendingCompactionEntries = useCallback(() => {
const pending = pendingCompactionEntriesRef.current;
if (pending.length === 0) return;
pendingCompactionEntriesRef.current = [];
for (const entry of pending) {
if (entry.status !== "started" && openCompactionEntryRef.current) {
updateEntry((current) =>
current.kind === "compaction" && current.status === "started"
? { ...current, ...entry }
: current,
);
openCompactionEntryRef.current = false;
} else {
appendEntry(entry);
if (entry.status === "started") {
openCompactionEntryRef.current = true;
}
}
}
}, [appendEntry, updateEntry]);
const finalizeDanglingCompactionEntry = useCallback(
(status: "failed" | "cancelled") => {
if (!openCompactionEntryRef.current) return;
openCompactionEntryRef.current = false;
updateEntry((entry) =>
entry.kind === "compaction" && entry.status === "started"
? { ...entry, status }
: entry,
);
},
[updateEntry],
);
const closeToolEntry = useCallback(
(event: AgentEvent & { type: "content_end" }) => {
const error = event.error ?? undefined;
@@ -84,9 +127,11 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
setIsRunning(true);
setIsStreaming(true);
closeInlineStream();
flushPendingCompactionEntries();
break;
case "iteration_end":
closeInlineStream();
flushPendingCompactionEntries();
break;
case "content_start": {
setIsStreaming(false);
@@ -165,11 +210,15 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
setIsRunning(false);
setIsStreaming(false);
closeInlineStream();
flushPendingCompactionEntries();
finalizeDanglingCompactionEntry("cancelled");
break;
case "error":
setIsRunning(false);
setIsStreaming(false);
closeInlineStream();
flushPendingCompactionEntries();
finalizeDanglingCompactionEntry("failed");
turnErrorReportedRef.current = true;
onTurnErrorReported(true);
if (!event.recoverable || verbose) {
@@ -181,8 +230,40 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
break;
case "notice":
if (event.displayRole === "status") {
closeInlineStream();
const label = resolveStatusNoticeLabel(event);
const compaction = parseCompactionNoticeMetadata(event.metadata);
if (!compaction) {
closeInlineStream();
}
if (compaction) {
if (activeInlineStreamRef.current) {
// An assistant message is still streaming; appending now
// would split it around the divider. Hold the divider (final
// state until the content block closes, then reconcile it
// with the same open divider atomically.
pendingCompactionEntriesRef.current.push({
kind: "compaction",
...compaction,
});
break;
}
if (compaction.status === "started") {
appendEntry({ kind: "compaction", ...compaction });
openCompactionEntryRef.current = true;
} else if (openCompactionEntryRef.current) {
// Finalize the in-progress divider in place, wherever it
// sits in the transcript.
updateEntry((entry) =>
entry.kind === "compaction" && entry.status === "started"
? { ...entry, ...compaction }
: entry,
);
openCompactionEntryRef.current = false;
} else {
appendEntry({ kind: "compaction", ...compaction });
}
break;
}
const label = resolveNonCompactionStatusLabel(event);
if (label) {
appendEntry({ kind: "status", text: label });
}
@@ -200,6 +281,7 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
[
appendEntry,
updateLastEntry,
updateEntry,
closeInlineStream,
activeInlineStreamRef,
setIsRunning,
@@ -208,6 +290,8 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
onTurnErrorReported,
verbose,
closeToolEntry,
finalizeDanglingCompactionEntry,
flushPendingCompactionEntries,
],
);
@@ -9,6 +9,7 @@ function makeActions(
overrides: Partial<Omit<LocalSlashCommandActionInput, "name">> = {},
): Omit<LocalSlashCommandActionInput, "name"> {
return {
isRunning: false,
openAccount: vi.fn(),
openConfig: vi.fn(),
openMcpManager: vi.fn(async () => false),
@@ -58,6 +59,32 @@ describe("runLocalSlashCommandAction", () => {
expect(openConfig).toHaveBeenCalledWith({ initialTab: "plugins" });
});
it("does not start compaction while a turn is running", () => {
const runCompact = vi.fn();
const actions = makeActions({ isRunning: true, runCompact });
const handled = runLocalSlashCommandAction({
name: "compact",
...actions,
});
expect(handled).toBe(true);
expect(runCompact).not.toHaveBeenCalled();
});
it("starts compaction while the session is idle", () => {
const runCompact = vi.fn();
const actions = makeActions({ runCompact });
const handled = runLocalSlashCommandAction({
name: "compact",
...actions,
});
expect(handled).toBe(true);
expect(runCompact).toHaveBeenCalledOnce();
});
it("waits for clear to reset the runtime session", async () => {
let resolveClear: (() => void) | undefined;
const clearConversation = vi.fn(
@@ -9,7 +9,6 @@ import { HelpDialogContent } from "../components/dialogs/help-dialog";
import { withLoadingDialog } from "../components/dialogs/loading-dialog";
import { useSession } from "../contexts/session-context";
import type { AppView, TuiProps } from "../types";
import { formatCompactionStatus } from "../utils/compaction-status";
import { hydrateSessionMessages } from "../utils/hydrate-messages";
import type { LocalSlashCommandInvocation } from "../utils/skill-command-input";
import { HistoryDialogContent } from "../views/history-view";
@@ -116,21 +115,42 @@ export function useLocalCommandActions(input: {
}, [dialog, refocusTextarea, termHeight]);
const runCompact = useCallback(async () => {
session.setIsRunning(true);
session.appendEntry({
kind: "status",
text: "Compacting context...",
kind: "compaction",
compactionMode: "manual",
status: "started",
});
try {
const result = await onCompact();
session.updateLastEntry(() => ({
kind: "status",
text: formatCompactionStatus(result),
}));
session.updateLastEntry((entry) =>
entry.kind === "compaction" && entry.status === "started"
? {
...entry,
status: result.compacted ? "completed" : "skipped",
messagesBefore: result.messagesBefore,
messagesAfter:
result.workingContextMessagesAfter ?? result.messagesAfter,
}
: entry,
);
} catch (error) {
session.appendEntry({
kind: "error",
text: `Compaction failed: ${error instanceof Error ? error.message : String(error)}`,
});
const cancelled =
error instanceof Error &&
(error.name === "AbortError" || /abort/i.test(error.message));
session.updateLastEntry((entry) =>
entry.kind === "compaction" && entry.status === "started"
? { ...entry, status: cancelled ? "cancelled" : "failed" }
: entry,
);
if (!cancelled) {
session.appendEntry({
kind: "error",
text: `Compaction failed: ${error instanceof Error ? error.message : String(error)}`,
});
}
} finally {
session.setIsRunning(false);
}
}, [onCompact, session]);
@@ -159,6 +179,15 @@ export function useLocalCommandActions(input: {
kind: "status",
text: `Forked into new session ${result.newSessionId}. This is now the active session. Use /history to switch sessions.`,
}));
if (result.carriedWorkingContext) {
session.appendEntry({
kind: "compaction",
compactionMode: "inherited",
status: "completed",
messagesBefore: result.carriedWorkingContext.canonicalMessages,
messagesAfter: result.carriedWorkingContext.workingContextMessages,
});
}
} else {
session.updateLastEntry(() => ({
kind: "error",
@@ -181,6 +210,7 @@ export function useLocalCommandActions(input: {
}
return runLocalSlashCommandAction({
name: resolved.name,
isRunning: session.isRunning,
invocation,
openAccount,
openConfig,
@@ -209,6 +239,7 @@ export function useLocalCommandActions(input: {
openSkills,
runCompact,
runFork,
session.isRunning,
slashCommandRegistry,
],
);
+65 -1
View File
@@ -82,6 +82,51 @@ function usesModelIdInput(providerId: string): boolean {
return providerId === "openai-compatible";
}
/**
* Ask an OpenAI-compatible endpoint for its model list (`GET <baseUrl>/models`)
* using the provider's stored API key and headers, mirroring the extension's
* refreshOpenAiModels handler. Returns [] on any failure so callers fall back
* to manual model-id entry.
*/
async function fetchOpenAiCompatibleModelIds(
providerId: string,
): Promise<string[]> {
try {
const manager = new ProviderSettingsManager();
const config = manager.getProviderConfig(providerId, {
includeKnownModels: false,
});
const baseUrl = config?.baseUrl?.trim().replace(/\/+$/, "");
if (!baseUrl || !URL.canParse(baseUrl)) return [];
const headers: Record<string, string> = { ...(config?.headers ?? {}) };
const apiKey = config?.apiKey?.trim();
if (
apiKey &&
!Object.keys(headers).some((h) => h.toLowerCase() === "authorization")
) {
headers.Authorization = `Bearer ${apiKey}`;
}
const response = await fetch(`${baseUrl}/models`, {
headers,
signal: AbortSignal.timeout(5_000),
});
if (!response.ok) return [];
const payload = (await response.json()) as { data?: unknown };
const list = Array.isArray(payload?.data) ? payload.data : [];
const ids = list
.map((model) => {
const id = (model as { id?: unknown } | null)?.id;
return typeof id === "string" ? id.trim() : "";
})
.filter(Boolean);
return [...new Set(ids)];
} catch {
return [];
}
}
function providerToExistingProviderOptions(input: {
providerId: string;
providerName: string;
@@ -300,12 +345,28 @@ export function useModelSelector(opts: {
config.knownModels as Record<string, Llms.ModelInfo>,
);
let providerDisplayName = config.providerId;
let endpointModelOptions: ModelOption[] = [];
const refreshProviderContext = async () => {
modelOptions = buildModelOptions(
config.knownModels as Record<string, Llms.ModelInfo>,
);
providerDisplayName = await getProviderDisplayName(config.providerId);
// Free-text providers (openai-compatible) can still suggest model
// ids when their endpoint answers /models; otherwise they keep the
// manual input.
endpointModelOptions = usesModelIdInput(config.providerId)
? buildModelOptions(
Object.fromEntries(
(await fetchOpenAiCompatibleModelIds(config.providerId)).map(
(id) => [id, { id, name: id }],
),
),
)
: [];
if (endpointModelOptions.length > 0) {
modelOptions = endpointModelOptions;
}
};
if (!options?.startWithProviderChange) {
@@ -341,7 +402,10 @@ export function useModelSelector(opts: {
let pickingModel = true;
while (pickingModel) {
if (usesModelIdInput(config.providerId)) {
if (
usesModelIdInput(config.providerId) &&
endpointModelOptions.length === 0
) {
const modelId = await dialog.choice<string>({
style: { maxHeight: termHeight - 2 },
content: (ctx: ChoiceContext<string>) => (
@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
type TerminalTitleRenderer,
useTerminalTitle,
} from "./use-terminal-title";
const reactMock = vi.hoisted(() => {
const cleanups: Array<() => void> = [];
return {
cleanups,
// Run effect bodies now, but retain their cleanups so each test can move
// the renderer across the native destruction boundary before unmount.
useEffect: vi.fn((effect: () => undefined | (() => void)) => {
const cleanup = effect();
if (cleanup) {
cleanups.push(cleanup);
}
}),
};
});
vi.mock("react", () => ({
useEffect: reactMock.useEffect,
}));
function createTitleRenderer() {
let destroyed = false;
const setTerminalTitle = vi.fn(() => {
if (destroyed) {
throw new Error("setTerminalTitle called after renderer destruction");
}
});
const renderer: TerminalTitleRenderer = {
get isDestroyed() {
return destroyed;
},
setTerminalTitle,
};
return {
destroy: () => {
destroyed = true;
},
renderer,
setTerminalTitle,
};
}
beforeEach(() => {
reactMock.cleanups.length = 0;
reactMock.useEffect.mockClear();
});
describe("useTerminalTitle", () => {
it("sets and resets the title while the renderer is active", () => {
const titleRenderer = createTitleRenderer();
useTerminalTitle(titleRenderer.renderer, "Cline");
expect(titleRenderer.setTerminalTitle).toHaveBeenNthCalledWith(1, "Cline");
for (const cleanup of reactMock.cleanups) {
cleanup();
}
expect(titleRenderer.setTerminalTitle).toHaveBeenCalledTimes(2);
expect(titleRenderer.setTerminalTitle).toHaveBeenNthCalledWith(2, "");
});
it("does not set the title when its effect runs after renderer destruction", () => {
const titleRenderer = createTitleRenderer();
titleRenderer.destroy();
useTerminalTitle(titleRenderer.renderer, "Cline");
expect(titleRenderer.setTerminalTitle).not.toHaveBeenCalled();
});
it("does not reset the title when cleanup runs after renderer destruction", () => {
const titleRenderer = createTitleRenderer();
useTerminalTitle(titleRenderer.renderer, "Cline");
expect(titleRenderer.setTerminalTitle).toHaveBeenCalledOnce();
titleRenderer.destroy();
for (const cleanup of reactMock.cleanups) {
cleanup();
}
expect(titleRenderer.setTerminalTitle).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,29 @@
import { useEffect } from "react";
export interface TerminalTitleRenderer {
readonly isDestroyed: boolean;
setTerminalTitle(title: string): void;
}
export function useTerminalTitle(
renderer: TerminalTitleRenderer,
terminalTitle: string,
): void {
// setTerminalTitle writes into memory owned by the native renderer, so it
// must never run after destroy. React can flush passive effects after the
// renderer's memory has been freed.
useEffect(() => {
if (renderer.isDestroyed) {
return;
}
renderer.setTerminalTitle(terminalTitle);
}, [renderer, terminalTitle]);
useEffect(() => {
return () => {
if (!renderer.isDestroyed) {
renderer.setTerminalTitle("");
}
};
}, [renderer]);
}
+37
View File
@@ -8,7 +8,9 @@ const rendererMock = vi.hoisted(() => ({
defaultBackground: null,
defaultForeground: null,
})),
isDestroyed: false,
on: vi.fn(),
setTerminalTitle: vi.fn(),
}));
const rootMock = vi.hoisted(() => ({
@@ -37,7 +39,9 @@ describe("renderOpenTui", () => {
beforeEach(() => {
destroyHandlers.length = 0;
rendererMock.isDestroyed = false;
rendererMock.destroy.mockReset();
rendererMock.setTerminalTitle.mockReset();
rendererMock.on.mockReset();
rendererMock.on.mockImplementation((event: string, handler: () => void) => {
if (event === "destroy") {
@@ -96,4 +100,37 @@ describe("renderOpenTui", () => {
expect(rendererMock.destroy).toHaveBeenCalledTimes(1);
expect(rootMock.unmount).toHaveBeenCalledTimes(1);
});
it("resets the terminal title before destroying the renderer", async () => {
const { renderOpenTui } = await import("./index");
const tui = await renderOpenTui({} as TuiProps);
tui.destroy();
await Promise.resolve();
expect(rendererMock.setTerminalTitle).toHaveBeenCalledWith("");
expect(rendererMock.destroy).toHaveBeenCalledTimes(1);
const titleCallOrder =
rendererMock.setTerminalTitle.mock.invocationCallOrder[0];
const destroyCallOrder = rendererMock.destroy.mock.invocationCallOrder[0];
expect(titleCallOrder).toBeLessThan(destroyCallOrder);
});
it("skips the title reset when the renderer is destroyed before the teardown microtask runs", async () => {
const { renderOpenTui } = await import("./index");
const tui = await renderOpenTui({} as TuiProps);
tui.destroy();
// Simulate OpenTUI's own signal handler destroying the renderer in the
// same dispatch (e.g. an idle SIGTERM fires both our handler and
// OpenTUI's exitHandler before microtasks drain).
rendererMock.isDestroyed = true;
for (const handler of destroyHandlers) {
handler();
}
await Promise.resolve();
expect(rendererMock.setTerminalTitle).not.toHaveBeenCalled();
});
});
+8
View File
@@ -67,6 +67,14 @@ export async function renderOpenTui(
unmountRoot();
// Let OpenTUI finish parsing the current stdin batch before teardown.
queueMicrotask(() => {
// Reset the title while the native renderer is still alive; the
// unmount cleanup in root.tsx skips it once the renderer is destroyed.
// Re-check here: OpenTUI's own signal handlers can destroy the
// renderer between destroy() queuing this microtask and it running
// (e.g. an idle SIGTERM dispatches to both our handler and OpenTUI's).
if (!renderer.isDestroyed) {
renderer.setTerminalTitle("");
}
renderer.destroy();
});
};
+2 -1
View File
@@ -27,6 +27,7 @@ import {
type UserInstructionConfigService,
type WorkflowConfig,
} from "@cline/core";
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
import { getToolCatalog } from "../runtime/tools";
import {
type InteractiveSlashCommand,
@@ -195,7 +196,7 @@ function loadAgentConfigItems(workspaceRoot: string): InteractiveConfigItem[] {
continue;
}
const filePath = join(directory, entry.name);
const raw = readFileSync(filePath, "utf8");
const raw = readFileSyncStrippingUtf8Bom(filePath);
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const frontmatter = frontmatterMatch?.[1] ?? "";
const nameMatch = frontmatter.match(/^\s*name:\s*(.+?)\s*$/m);
+2 -9
View File
@@ -53,6 +53,7 @@ import { useRootKeyboard } from "./hooks/use-root-keyboard";
import { useRuntimeDialogBridge } from "./hooks/use-runtime-dialog-bridge";
import { useSlashCommands } from "./hooks/use-slash-commands";
import { TerminalColorsContext } from "./hooks/use-terminal-background";
import { useTerminalTitle } from "./hooks/use-terminal-title";
import type { AppView, TuiProps } from "./types";
import { hydrateSessionMessages } from "./utils/hydrate-messages";
import { isProviderConfigured } from "./utils/provider-configured";
@@ -472,15 +473,7 @@ function App(props: TuiProps) {
};
}, [renderer, showToast]);
useEffect(() => {
renderer.setTerminalTitle(terminalTitle);
}, [renderer, terminalTitle]);
useEffect(() => {
return () => {
renderer.setTerminalTitle("");
};
}, [renderer]);
useTerminalTitle(renderer, terminalTitle);
useEffect(() => {
return () => {
+18 -1
View File
@@ -44,6 +44,15 @@ export type ChatEntry = (
}
| { kind: "error"; text: string }
| { kind: "status"; text: string }
| {
kind: "compaction";
compactionMode: "auto" | "manual" | "inherited";
status: "started" | "completed" | "skipped" | "failed" | "cancelled";
tokensBefore?: number;
tokensAfter?: number;
messagesBefore?: number;
messagesAfter?: number;
}
| { kind: "team"; text: string }
| { kind: "user_submitted"; text: string; delivery?: "queue" | "steer" }
| {
@@ -186,7 +195,15 @@ export interface TuiProps {
onResumeSession: (sessionId: string) => Promise<ResumedSessionResult>;
onCompact: () => Promise<InteractiveCompactionResult>;
onFork: () => Promise<
{ forkedFromSessionId: string; newSessionId: string } | undefined
| {
forkedFromSessionId: string;
newSessionId: string;
carriedWorkingContext?: {
workingContextMessages: number;
canonicalMessages: number;
};
}
| undefined
>;
getCheckpointData: () => Promise<
{ messages: Message[]; checkpointHistory: CheckpointEntry[] } | undefined
@@ -0,0 +1,168 @@
import { describe, expect, it } from "vitest";
import {
formatCompactionDividerLabel,
formatTokenCount,
parseCompactionNoticeMetadata,
} from "./compaction-status";
describe("parseCompactionNoticeMetadata", () => {
it("extracts a divider entry from a completed auto-compaction notice", () => {
expect(
parseCompactionNoticeMetadata({
kind: "auto_compaction",
reason: "auto_compaction",
phase: "completed",
tokensBefore: 25_101,
tokensAfter: 6_300,
messagesBefore: 142,
messagesAfter: 9,
}),
).toEqual({
compactionMode: "auto",
status: "completed",
tokensBefore: 25_101,
tokensAfter: 6_300,
messagesBefore: 142,
messagesAfter: 9,
});
});
it("extracts a streaming divider entry from a started notice", () => {
expect(
parseCompactionNoticeMetadata({
kind: "auto_compaction",
phase: "started",
}),
).toEqual({ compactionMode: "auto", status: "started" });
});
it("maps manual compaction notices to manual mode", () => {
expect(
parseCompactionNoticeMetadata({
kind: "manual_compaction",
phase: "completed",
})?.compactionMode,
).toBe("manual");
});
it("maps a benign no-result terminal notice to skipped", () => {
expect(
parseCompactionNoticeMetadata({
kind: "auto_compaction",
phase: "skipped",
}),
).toEqual({ compactionMode: "auto", status: "skipped" });
});
it("ignores non-compaction metadata", () => {
expect(
parseCompactionNoticeMetadata({ kind: "recovery", phase: "completed" }),
).toBeUndefined();
expect(
parseCompactionNoticeMetadata({ kind: "auto_compaction" }),
).toBeUndefined();
expect(parseCompactionNoticeMetadata(undefined)).toBeUndefined();
});
it("drops non-numeric counters instead of rendering garbage", () => {
const parsed = parseCompactionNoticeMetadata({
kind: "auto_compaction",
phase: "completed",
tokensBefore: "25000",
tokensAfter: Number.NaN,
});
expect(parsed?.tokensBefore).toBeUndefined();
expect(parsed?.tokensAfter).toBeUndefined();
});
});
describe("formatTokenCount", () => {
it("formats counts into compact units", () => {
expect(formatTokenCount(999)).toBe("999");
expect(formatTokenCount(6_300)).toBe("6.3k");
expect(formatTokenCount(25_000)).toBe("25k");
expect(formatTokenCount(1_200_000)).toBe("1.2M");
});
});
describe("formatCompactionDividerLabel", () => {
it("includes token and message deltas when present", () => {
expect(
formatCompactionDividerLabel({
kind: "compaction",
compactionMode: "auto",
status: "completed",
tokensBefore: 25_101,
tokensAfter: 6_300,
messagesBefore: 142,
messagesAfter: 9,
}),
).toBe("Context compacted · 25.1k → 6.3k tokens · 142 → 9 messages");
});
it("labels in-progress compaction", () => {
expect(
formatCompactionDividerLabel({
kind: "compaction",
compactionMode: "auto",
status: "started",
}),
).toBe("Auto compacting messages");
expect(
formatCompactionDividerLabel({
kind: "compaction",
compactionMode: "manual",
status: "started",
}),
).toBe("Compacting messages");
});
it("labels failed and cancelled compaction", () => {
expect(
formatCompactionDividerLabel({
kind: "compaction",
compactionMode: "auto",
status: "failed",
}),
).toBe("Compaction failed");
expect(
formatCompactionDividerLabel({
kind: "compaction",
compactionMode: "auto",
status: "cancelled",
}),
).toBe("Compaction cancelled");
});
it("labels skipped compaction without calling it cancelled", () => {
expect(
formatCompactionDividerLabel({
kind: "compaction",
compactionMode: "auto",
status: "skipped",
}),
).toBe("Compaction skipped");
});
it("labels inherited working context from forks and restarts", () => {
expect(
formatCompactionDividerLabel({
kind: "compaction",
compactionMode: "inherited",
status: "completed",
messagesBefore: 60,
messagesAfter: 15,
}),
).toBe("Compacted working context carried over · 60 → 15 messages");
});
it("labels manual compaction and omits missing counters", () => {
expect(
formatCompactionDividerLabel({
kind: "compaction",
compactionMode: "manual",
status: "completed",
}),
).toBe("Context compacted (manual)");
});
});
+98 -1
View File
@@ -1,9 +1,106 @@
import type { InteractiveCompactionResult } from "../types";
import type { ChatEntry, InteractiveCompactionResult } from "../types";
export type CompactionDividerEntry = Extract<ChatEntry, { kind: "compaction" }>;
function formatMessageCount(count: number): string {
return `${count} ${count === 1 ? "message" : "messages"}`;
}
function asFiniteNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value)
? value
: undefined;
}
/**
* Extracts a compaction divider entry from a status notice's metadata.
* "started" notices produce a streaming (in-progress) divider; "completed"
* notices produce the final divider with counters. Returns undefined for
* non-compaction notices.
*/
export function parseCompactionNoticeMetadata(
metadata: Record<string, unknown> | undefined,
): Omit<CompactionDividerEntry, "kind"> | undefined {
if (
!metadata ||
(metadata.phase !== "started" &&
metadata.phase !== "completed" &&
metadata.phase !== "skipped")
) {
return undefined;
}
const kind = metadata.kind ?? metadata.reason;
if (kind !== "auto_compaction" && kind !== "manual_compaction") {
return undefined;
}
const compactionMode = kind === "manual_compaction" ? "manual" : "auto";
if (metadata.phase === "started") {
return { compactionMode, status: "started" };
}
if (metadata.phase === "skipped") {
return { compactionMode, status: "skipped" };
}
return {
compactionMode,
status: "completed",
tokensBefore: asFiniteNumber(metadata.tokensBefore),
tokensAfter: asFiniteNumber(metadata.tokensAfter),
messagesBefore: asFiniteNumber(metadata.messagesBefore),
messagesAfter: asFiniteNumber(metadata.messagesAfter),
};
}
export function formatTokenCount(count: number): string {
if (count < 1_000) {
return `${count}`;
}
if (count < 1_000_000) {
return `${(count / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
}
return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
}
export function formatCompactionDividerLabel(
entry: CompactionDividerEntry,
): string {
if (entry.status === "started") {
return entry.compactionMode === "manual"
? "Compacting messages"
: "Auto compacting messages";
}
if (entry.status === "failed") {
return "Compaction failed";
}
if (entry.status === "cancelled") {
return "Compaction cancelled";
}
if (entry.status === "skipped") {
return "Compaction skipped";
}
const parts: string[] = [
entry.compactionMode === "manual"
? "Context compacted (manual)"
: entry.compactionMode === "inherited"
? "Compacted working context carried over"
: "Context compacted",
];
if (
typeof entry.tokensBefore === "number" &&
typeof entry.tokensAfter === "number"
) {
parts.push(
`${formatTokenCount(entry.tokensBefore)}${formatTokenCount(entry.tokensAfter)} tokens`,
);
}
if (
typeof entry.messagesBefore === "number" &&
typeof entry.messagesAfter === "number"
) {
parts.push(`${entry.messagesBefore}${entry.messagesAfter} messages`);
}
return parts.join(" · ");
}
export function formatCompactionStatus(
result: InteractiveCompactionResult,
): string {
+9
View File
@@ -14,6 +14,15 @@ export type ChatCommandState = {
export type ForkSessionResult = {
forkedFromSessionId: string;
newSessionId: string;
/**
* Present when the source session had valid compaction state that was
* re-anchored onto the forked session, so the UI can surface why the
* next request is smaller than the canonical history.
*/
carriedWorkingContext?: {
workingContextMessages: number;
canonicalMessages: number;
};
};
export type MuteCommandInput = {
+3 -3
View File
@@ -26,20 +26,20 @@ describe("CLI compaction mode helpers", () => {
});
it("maps basic and off modes to core compaction config", () => {
const config = createConfig({ enabled: true, maxInputTokens: 123 });
const config = createConfig({ enabled: true, preserveRecentTokens: 123 });
applyCliCompactionMode(config, "basic");
expect(config.compaction).toEqual({
enabled: true,
strategy: "basic",
maxInputTokens: 123,
preserveRecentTokens: 123,
});
expect(getCliCompactionMode(config)).toBe("basic");
applyCliCompactionMode(config, "off");
expect(config.compaction).toEqual({
enabled: false,
maxInputTokens: 123,
preserveRecentTokens: 123,
});
expect(getCliCompactionMode(config)).toBe("off");
});
+22
View File
@@ -1,4 +1,8 @@
import type { AgentEvent, TeamEvent } from "@cline/core";
import {
formatCompactionDividerLabel,
parseCompactionNoticeMetadata,
} from "../tui/utils/compaction-status";
import { formatCliErrorMessage } from "./cline-pass-errors";
import { formatToolInput, formatToolOutput, truncate } from "./helpers";
import {
@@ -24,6 +28,24 @@ const TEAM_RUN_ACTIVE_SUFFIX = `${c.dim} ...${c.reset}`;
export function resolveStatusNoticeLabel(
event: AgentEvent,
): string | undefined {
if (event.type !== "notice" || event.displayRole !== "status") {
return undefined;
}
const compaction = parseCompactionNoticeMetadata(event.metadata);
if (compaction) {
return formatCompactionDividerLabel({ kind: "compaction", ...compaction });
}
return resolveNonCompactionStatusLabel(event);
}
/**
* Label for a status notice already known not to be a compaction notice.
* Callers that have parsed the compaction metadata themselves use this to
* avoid re-parsing.
*/
export function resolveNonCompactionStatusLabel(
event: AgentEvent,
): string | undefined {
if (event.type !== "notice" || event.displayRole !== "status") {
return undefined;
@@ -18,6 +18,7 @@ import {
resolvePluginConfigSearchPaths,
resolveAgentConfigSearchPaths as resolveSharedAgentConfigSearchPaths,
} from "@cline/core";
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
import { readMcpServersResponse } from "./mcp";
import type { JsonRecord } from "./types";
@@ -114,7 +115,7 @@ export async function listUserInstructionConfigs(
const ext = extname(entry.name).toLowerCase();
if (ext !== ".yml" && ext !== ".yaml") continue;
const filePath = join(directory, entry.name);
const raw = readFileSync(filePath, "utf8");
const raw = readFileSyncStrippingUtf8Bom(filePath);
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const fm = fmMatch?.[1] ?? "";
const nameMatch = fm.match(/^\s*name:\s*(.+?)\s*$/m);
+9
View File
@@ -16,6 +16,15 @@ From `apps/examples/desktop-app/`:
- `bun run package:desktop` - package the current OS desktop app into `dist/desktop/`
- `bun run typecheck` - TypeScript check
## Web Visual System
The framework-neutral color, typography, radius, and navigation contract lives
in the internal [`@cline/ui`](../../../sdk/packages/ui/README.md) workspace
package. Other Cline web surfaces can take only its tokens or opt into the
Tailwind adapter and shared base styles without depending on the desktop
runtime. See [`webview/styles/README.md`](./webview/styles/README.md) for the
desktop integration notes.
## Shareable Desktop Packages
Tauri desktop bundles are OS-specific, so build each package on the target OS:
+13 -3
View File
@@ -3,9 +3,12 @@
"version": "0.0.1",
"private": true,
"scripts": {
"build:ui": "bun -F @cline/ui build",
"predev:web": "bun run build:ui",
"dev:web": "next dev webview -p 3125 --turbo",
"dev:sidecar": "bun run sidecar/index.ts",
"dev": "tauri dev",
"prebuild": "bun run build:ui",
"build": "bun run bun.mts",
"build:sidecar": "mkdir -p dist/sidecar && bun build ./sidecar/index.ts --outfile ./dist/sidecar/index.js --target bun",
"build:sidecar:bin": "bun run scripts/build-sidecar-bin.ts",
@@ -16,7 +19,10 @@
"package:desktop:windows": "bun run scripts/package-desktop.ts --platform windows",
"package:desktop:linux": "bun run scripts/package-desktop.ts --platform linux",
"start": "next start webview",
"pretypecheck": "bun run build:ui",
"typecheck": "tsc -p tsconfig.dev.json --noEmit",
"pretest:chat-ui": "bun run build:ui",
"test:chat-ui": "vitest run webview/components/views/chat/chat-messages.test.tsx --config vitest.config.ts",
"clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)"
},
"dependencies": {
@@ -24,6 +30,7 @@
"@cline/core": "workspace:*",
"@cline/llms": "workspace:*",
"@cline/shared": "workspace:*",
"@cline/ui": "workspace:*",
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
"@fontsource/azeret-mono": "^5.2.9",
"@hookform/resolvers": "^3.9.1",
@@ -54,6 +61,9 @@
"@radix-ui/react-toggle": "1.1.10",
"@radix-ui/react-toggle-group": "1.1.11",
"@radix-ui/react-tooltip": "1.2.8",
"@shikijs/langs": "^4.2.0",
"@shikijs/themes": "^4.2.0",
"@streamdown/cjk": "^1.0.3",
"@tauri-apps/api": "^2.0.0",
"@vercel/analytics": "1.6.1",
"autoprefixer": "^10.4.20",
@@ -64,7 +74,6 @@
"embla-carousel-react": "8.6.0",
"input-otp": "1.4.2",
"lucide-react": "^0.564.0",
"marked": "^17.0.3",
"next": "16.2.6",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
@@ -72,11 +81,11 @@
"react-day-picker": "9.13.2",
"react-dom": "19.2.4",
"react-hook-form": "^7.54.1",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^2.1.7",
"recharts": "2.15.0",
"remark-gfm": "^4.0.1",
"shiki": "^4.0.2",
"sonner": "^1.7.1",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.3.1",
"vaul": "^1.1.2",
"zod": "^3.24.1"
@@ -86,6 +95,7 @@
"@tailwindcss/postcss": "^4.2.0",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"jsdom": "^26.0.0",
"postcss": "^8.5",
"tailwindcss": "^4.2.0",
"tw-animate-css": "1.3.3",
@@ -1,5 +1,13 @@
import { describe, expect, it } from "vitest";
import { buildSessionConnectionUpdate } from "./chat-session";
import { describe, expect, it, vi } from "vitest";
import {
buildSessionConnectionUpdate,
consumeWorkspaceMetadata,
handleChatSessionCommand,
prewarmWorkspaceMetadata,
shouldUpdateSessionConnection,
WORKSPACE_METADATA_PREWARM_TTL_MS,
} from "./chat-session";
import type { SidecarContext } from "./types";
describe("buildSessionConnectionUpdate", () => {
it("does not clear reasoning settings when config omits reasoning fields", () => {
@@ -49,3 +57,188 @@ describe("buildSessionConnectionUpdate", () => {
expect(Object.hasOwn(update, "thinkingBudgetTokens")).toBe(false);
});
});
describe("shouldUpdateSessionConnection", () => {
it("skips the redundant connection update on the first send", () => {
const config = {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
thinking: true,
reasoningEffort: "high",
};
expect(shouldUpdateSessionConnection(config, { ...config })).toBe(false);
});
it("updates the connection when the selected reasoning level changes", () => {
const current = {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
thinking: true,
reasoningEffort: "low",
};
expect(
shouldUpdateSessionConnection(current, {
...current,
reasoningEffort: "high",
}),
).toBe(true);
});
});
describe("first-send connection updates", () => {
const baseConfig = {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
thinking: true,
reasoningEffort: "high",
};
function createContext(options?: {
attachedViaHub?: boolean;
config?: Record<string, unknown>;
}) {
const updateSessionConnection = vi.fn(async () => undefined);
const send = vi.fn(async () => ({
text: "done",
finishReason: "completed",
messages: [],
}));
const sessionId = "session-connection-test";
const ctx = {
liveSessions: new Map([
[
sessionId,
{
config: options?.config ?? baseConfig,
messages: [],
promptsInQueue: [],
busy: false,
startedAt: Date.now(),
status: "idle",
attachedViaHub: options?.attachedViaHub ?? false,
},
],
]),
sessionManager: { send, updateSessionConnection },
} as unknown as SidecarContext;
return { ctx, send, sessionId, updateSessionConnection };
}
it("skips an identical update for a locally-created session", async () => {
const { ctx, send, sessionId, updateSessionConnection } = createContext();
await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "hello",
config: { ...baseConfig },
});
expect(updateSessionConnection).not.toHaveBeenCalled();
expect(send).toHaveBeenCalledTimes(1);
});
it("updates a changed connection before sending", async () => {
const { ctx, send, sessionId, updateSessionConnection } = createContext({
config: { ...baseConfig, reasoningEffort: "low" },
});
await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "hello",
config: { ...baseConfig },
});
expect(updateSessionConnection).toHaveBeenCalledTimes(1);
expect(updateSessionConnection.mock.invocationCallOrder[0]).toBeLessThan(
send.mock.invocationCallOrder[0] ?? 0,
);
});
it("refreshes hub-attached sessions even when the cached config matches", async () => {
const { ctx, sessionId, updateSessionConnection } = createContext({
attachedViaHub: true,
});
await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "hello",
config: { ...baseConfig },
});
expect(updateSessionConnection).toHaveBeenCalledTimes(1);
});
});
describe("workspace metadata prewarming", () => {
it("reuses one in-flight scan and consumes it only once", async () => {
let resolveFirst: ((value: string) => void) | undefined;
const firstResult = new Promise<string>((resolve) => {
resolveFirst = resolve;
});
const load = vi
.fn<(cwd: string) => Promise<string>>()
.mockImplementationOnce(async () => await firstResult)
.mockResolvedValueOnce("fresh metadata");
const cwd = "/tmp/cline-desktop-prewarm-reuse";
prewarmWorkspaceMetadata(cwd, load);
const consumed = consumeWorkspaceMetadata(cwd, load);
expect(load).toHaveBeenCalledTimes(1);
resolveFirst?.("prewarmed metadata");
await expect(consumed).resolves.toBe("prewarmed metadata");
await expect(consumeWorkspaceMetadata(cwd, load)).resolves.toBe(
"fresh metadata",
);
expect(load).toHaveBeenCalledTimes(2);
});
it("evicts failed scans so the next session can retry", async () => {
const load = vi
.fn<(cwd: string) => Promise<string>>()
.mockRejectedValueOnce(new Error("git unavailable"))
.mockResolvedValueOnce("recovered metadata");
const cwd = "/tmp/cline-desktop-prewarm-retry";
prewarmWorkspaceMetadata(cwd, load);
await expect(consumeWorkspaceMetadata(cwd, load)).rejects.toThrow(
"git unavailable",
);
await expect(consumeWorkspaceMetadata(cwd, load)).resolves.toBe(
"recovered metadata",
);
expect(load).toHaveBeenCalledTimes(2);
});
it("keeps different workspaces in separate single-flight entries", () => {
const load = vi.fn(async (cwd: string) => `metadata for ${cwd}`);
prewarmWorkspaceMetadata("/tmp/cline-desktop-prewarm-a", load);
prewarmWorkspaceMetadata("/tmp/cline-desktop-prewarm-b", load);
expect(load).toHaveBeenCalledTimes(2);
});
it("refreshes a prewarm that is older than the startup window", async () => {
const load = vi
.fn<(cwd: string) => Promise<string>>()
.mockResolvedValueOnce("startup metadata")
.mockResolvedValueOnce("current metadata");
const cwd = "/tmp/cline-desktop-prewarm-expired";
prewarmWorkspaceMetadata(cwd, load, () => 0);
await expect(
consumeWorkspaceMetadata(
cwd,
load,
() => WORKSPACE_METADATA_PREWARM_TTL_MS + 1,
),
).resolves.toBe("current metadata");
expect(load).toHaveBeenCalledTimes(2);
});
});
@@ -1,5 +1,6 @@
import { existsSync, readFileSync } from "node:fs";
import { basename, join } from "node:path";
import { basename, join, resolve } from "node:path";
import { isDeepStrictEqual } from "node:util";
import {
buildConnectionUpdate,
buildWorkspaceMetadata,
@@ -25,6 +26,69 @@ type SessionConnectionUpdate = Parameters<
ClineCore["updateSessionConnection"]
>[1];
type WorkspaceMetadataLoader = (cwd: string) => Promise<string>;
type WorkspaceMetadataCacheEntry = {
createdAt: number;
promise: Promise<string>;
};
export const WORKSPACE_METADATA_PREWARM_TTL_MS = 60_000;
const workspaceMetadataPromises = new Map<
string,
WorkspaceMetadataCacheEntry
>();
function getWorkspaceMetadataPromise(
cwd: string,
load: WorkspaceMetadataLoader,
now: () => number,
): { key: string; promise: Promise<string> } {
const key = resolve(cwd);
const existing = workspaceMetadataPromises.get(key);
const createdAt = now();
if (
existing &&
createdAt - existing.createdAt <= WORKSPACE_METADATA_PREWARM_TTL_MS
) {
return { key, promise: existing.promise };
}
const promise = load(key);
workspaceMetadataPromises.set(key, { createdAt, promise });
void promise.catch(() => {
if (workspaceMetadataPromises.get(key)?.promise === promise) {
workspaceMetadataPromises.delete(key);
}
});
return { key, promise };
}
export function prewarmWorkspaceMetadata(
cwd: string,
load: WorkspaceMetadataLoader = buildWorkspaceMetadata,
now: () => number = Date.now,
): void {
void getWorkspaceMetadataPromise(cwd, load, now).promise.catch(() => {});
}
export async function consumeWorkspaceMetadata(
cwd: string,
load: WorkspaceMetadataLoader = buildWorkspaceMetadata,
now: () => number = Date.now,
): Promise<string> {
const { key, promise } = getWorkspaceMetadataPromise(cwd, load, now);
try {
return await promise;
} finally {
if (workspaceMetadataPromises.get(key)?.promise === promise) {
workspaceMetadataPromises.delete(key);
}
}
}
export function refreshWorkspaceMetadata(cwd: string): void {
workspaceMetadataPromises.delete(resolve(cwd));
prewarmWorkspaceMetadata(cwd);
}
// ---------------------------------------------------------------------------
// Session data helpers
// ---------------------------------------------------------------------------
@@ -219,6 +283,16 @@ export function buildSessionConnectionUpdate(
});
}
export function shouldUpdateSessionConnection(
currentConfig: JsonRecord,
nextConfig: JsonRecord,
): boolean {
return !isDeepStrictEqual(
buildSessionConnectionUpdate(currentConfig),
buildSessionConnectionUpdate(nextConfig),
);
}
async function resolveSystemPrompt(config: JsonRecord): Promise<string> {
const cwd = String(
config.cwd ?? config.workspaceRoot ?? config.workspace_root ?? "",
@@ -232,7 +306,7 @@ async function resolveSystemPrompt(config: JsonRecord): Promise<string> {
: config.mode === "plan"
? "plan"
: "act";
const metadata = await buildWorkspaceMetadata(cwd);
const metadata = await consumeWorkspaceMetadata(cwd);
const inlineRules =
typeof config.rules === "string" && config.rules.trim().length > 0
? config.rules
@@ -447,8 +521,14 @@ async function handleSend(
const manager = getSessionManager(ctx);
const session = ctx.liveSessions.get(sessionId);
if (request.config) {
const connectionUpdate = buildSessionConnectionUpdate(request.config);
await manager.updateSessionConnection(sessionId, connectionUpdate);
if (
!session ||
session.attachedViaHub ||
shouldUpdateSessionConnection(session.config, request.config)
) {
const connectionUpdate = buildSessionConnectionUpdate(request.config);
await manager.updateSessionConnection(sessionId, connectionUpdate);
}
if (session) {
session.config = { ...session.config, ...request.config };
}
+29 -7
View File
@@ -1,5 +1,11 @@
import { execFileSync, spawn } from "node:child_process";
import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
import {
existsSync,
readdirSync,
readFileSync,
rmSync,
statSync,
} from "node:fs";
import { basename, dirname, extname, join } from "node:path";
import type {
ClineAccountActionRequest,
@@ -44,6 +50,7 @@ import {
updateMcpSettingsFileSync,
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
import {
connectorChannelsPayload,
startConnectorChannel,
@@ -552,7 +559,7 @@ async function listUserInstructionConfigs(
const ext = extname(entry.name).toLowerCase();
if (ext !== ".yml" && ext !== ".yaml") continue;
const filePath = join(directory, entry.name);
const raw = readFileSync(filePath, "utf8");
const raw = readFileSyncStrippingUtf8Bom(filePath);
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const fm = fmMatch?.[1] ?? "";
const nameMatch = fm.match(/^\s*name:\s*(.+?)\s*$/m);
@@ -1163,10 +1170,13 @@ export async function handleCommand(
// ── Git operations ─────────────────────────────────────────────────
if (command === "get_git_branch") {
const branches = listGitBranches(
ctx,
typeof args?.cwd === "string" ? args.cwd : undefined,
);
const cwd =
typeof args?.cwd === "string" && args.cwd.trim()
? args.cwd.trim()
: ctx.workspaceRoot;
const branches = listGitBranches(ctx, cwd);
const { prewarmWorkspaceMetadata } = await import("./chat-session");
prewarmWorkspaceMetadata(cwd);
return { branch: branches.current };
}
if (command === "list_git_branches") {
@@ -1179,11 +1189,14 @@ export async function handleCommand(
const cwd = typeof args?.cwd === "string" ? args.cwd : undefined;
const branch = String(args?.branch ?? "").trim();
if (!branch) throw new Error("branch is required");
const targetCwd = cwd?.trim() || ctx.workspaceRoot;
execFileSync("git", ["checkout", branch], {
cwd: cwd?.trim() || ctx.workspaceRoot,
cwd: targetCwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
const { refreshWorkspaceMetadata } = await import("./chat-session");
refreshWorkspaceMetadata(targetCwd);
return { branch };
}
@@ -1252,6 +1265,15 @@ export async function handleCommand(
}
// ── Native OS commands ────────────────────────────────────────────
if (command === "validate_workspace_directory") {
const workspacePath = String(args?.path ?? "").trim();
if (!workspacePath) return { valid: false };
try {
return { valid: statSync(workspacePath).isDirectory() };
} catch {
return { valid: false };
}
}
if (command === "pick_workspace_directory") {
return pickWorkspaceDirectory();
}
@@ -1,3 +1,4 @@
import { prewarmWorkspaceMetadata } from "./chat-session";
import {
createSidecarContext,
disposeSidecarContext,
@@ -34,6 +35,7 @@ async function main() {
const workspaceRoot = resolveWorkspaceRoot(process.cwd());
const ctx = createSidecarContext(workspaceRoot);
prewarmWorkspaceMetadata(workspaceRoot);
await initializeSessionManager(ctx);
let shuttingDown = false;
@@ -1,6 +1,12 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
alias: {
"@": fileURLToPath(new URL("./webview", import.meta.url)),
},
},
test: {
environment: "node",
},
+126 -301
View File
@@ -2,181 +2,10 @@
@import "@fontsource/azeret-mono/latin.css";
@import "tailwindcss";
@import "tw-animate-css";
@import "@cline/ui/theme/index.css";
@import "@cline/ui/components/agent-chat.css";
@custom-variant dark (&:is(.dark *));
:root {
--font-desktop-sans: "Schibsted Grotesk Variable";
--font-desktop-mono: "Azeret Mono";
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.75 0.12 165);
--primary-foreground: oklch(0.962 0.018 272.314);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.5rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.75 0.12 165);
--primary-foreground: oklch(0.962 0.018 272.314);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.585 0.233 277.117);
--sidebar-primary-foreground: oklch(0.962 0.018 272.314);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@theme inline {
--font-sans: var(--font-desktop-sans), sans-serif;
--font-mono:
var(--font-desktop-mono), ui-monospace, "SFMono-Regular", Menlo, Consolas,
"Liberation Mono", monospace;
--font-weight-normal: 480;
--font-weight-medium: 560;
--font-weight-semibold: 640;
--font-weight-bold: 640;
--text-step-1: 12px;
--text-step-1--line-height: 16px;
--text-step-1--letter-spacing: 0.0025em;
--text-step-2: 14px;
--text-step-2--line-height: 20px;
--text-step-2--letter-spacing: 0em;
--text-step-3: 16px;
--text-step-3--line-height: 24px;
--text-step-3--letter-spacing: 0em;
--text-step-4: 18px;
--text-step-4--line-height: 26px;
--text-step-4--letter-spacing: -0.0025em;
--text-step-5: 20px;
--text-step-5--line-height: 28px;
--text-step-5--letter-spacing: -0.005em;
--text-step-6: 24px;
--text-step-6--line-height: 30px;
--text-step-6--letter-spacing: -0.00625em;
--text-step-7: 28px;
--text-step-7--line-height: 36px;
--text-step-7--letter-spacing: -0.0075em;
--text-step-8: 35px;
--text-step-8--line-height: 40px;
--text-step-8--letter-spacing: -0.01em;
--text-step-9: 60px;
--text-step-9--line-height: 60px;
--text-step-9--letter-spacing: -0.025em;
--text-xs: var(--text-step-1);
--text-xs--line-height: var(--text-step-1--line-height);
--text-xs--letter-spacing: var(--text-step-1--letter-spacing);
--text-sm: var(--text-step-2);
--text-sm--line-height: var(--text-step-2--line-height);
--text-sm--letter-spacing: var(--text-step-2--letter-spacing);
--text-base: var(--text-step-3);
--text-base--line-height: var(--text-step-3--line-height);
--text-base--letter-spacing: var(--text-step-3--letter-spacing);
--text-lg: var(--text-step-4);
--text-lg--line-height: var(--text-step-4--line-height);
--text-lg--letter-spacing: var(--text-step-4--letter-spacing);
--text-xl: var(--text-step-5);
--text-xl--line-height: var(--text-step-5--line-height);
--text-xl--letter-spacing: var(--text-step-5--letter-spacing);
--text-2xl: var(--text-step-6);
--text-2xl--line-height: var(--text-step-6--line-height);
--text-2xl--letter-spacing: var(--text-step-6--letter-spacing);
--text-3xl: var(--text-step-7);
--text-3xl--line-height: var(--text-step-7--line-height);
--text-3xl--letter-spacing: var(--text-step-7--letter-spacing);
--text-4xl: var(--text-step-8);
--text-4xl--line-height: var(--text-step-8--line-height);
--text-4xl--letter-spacing: var(--text-step-8--letter-spacing);
--text-6xl: var(--text-step-9);
--text-6xl--line-height: var(--text-step-9--line-height);
--text-6xl--letter-spacing: var(--text-step-9--letter-spacing);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@source "../../node_modules/streamdown/dist";
@layer base {
html,
@@ -185,144 +14,70 @@
min-height: 100vh;
overflow: hidden;
}
#__next {
height: 100%;
}
* {
@apply border-border outline-ring/50;
}
body {
@apply m-0 bg-background text-base font-normal text-foreground;
}
}
@layer components {
.markdown {
@apply leading-relaxed;
}
.markdown * {
@apply text-sm leading-relaxed;
}
.markdown + .markdown {
@apply mt-2;
}
.markdown p {
@apply my-2 first:mt-0 last:mb-0;
}
.markdown a {
@apply underline;
}
.markdown blockquote {
@apply border-l-2 border-border pl-3;
}
.markdown code {
@apply space-y-1 rounded bg-muted px-1 py-0.5;
}
.markdown h1 {
@apply text-lg font-bold;
}
.markdown h2,
.markdown h3 {
@apply text-lg font-semibold;
}
.markdown ul {
@apply list-disc space-y-1;
}
.markdown ol {
@apply list-decimal space-y-1;
}
.markdown li {
@apply ml-5;
}
.markdown pre {
@apply space-y-1 overflow-x-auto rounded bg-muted p-3;
}
.markdown div {
@apply space-y-1;
}
}
/* Custom scrollbar for dark theme */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: oklch(0.3 0.005 260);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: oklch(0.4 0.005 260);
}
/* Selection color */
::selection {
background: oklch(0.75 0.12 165 / 0.25);
}
/* Aurora background (components/ui/aurora-bg.tsx) */
@keyframes aurora-drift {
0%,
100% {
transform: translate(0, 0) rotate(0deg) scale(1);
}
25% {
transform: translate(14%, -10%) rotate(18deg) scale(1.25);
}
50% {
transform: translate(-4%, 6%) rotate(-6deg) scale(1.05);
}
75% {
transform: translate(-12%, -4%) rotate(-16deg) scale(0.9);
}
}
/* Curtain ribbons: sway side-to-side while skewing and stretching, like an
aurora curtain rippling. Ribbons are bottom-anchored (transform-origin
bottom), so skew/scale fan out from the horizon. */
@keyframes aurora-wave {
0%,
100% {
transform: translateX(0) skewX(0deg) scaleY(1);
opacity: 0.7;
}
20% {
transform: translateX(4%) skewX(8deg) scaleY(1.15);
opacity: 1;
}
45% {
transform: translateX(-3%) skewX(-10deg) scaleY(0.9);
opacity: 0.55;
}
70% {
transform: translateX(5%) skewX(12deg) scaleY(1.25);
opacity: 0.9;
}
}
/* Traveling wave: a 200%-wide striped sheet slides left by half its width,
looping seamlessly, while bobbing vertically — bands visibly roll across. */
@keyframes aurora-flow {
0% {
transform: translateX(0) translateY(0) skewX(-6deg);
}
25% {
transform: translateX(-12.5%) translateY(-4%) skewX(4deg);
opacity: 0.55;
transform: translate3d(-8%, 5%, 0) rotate(-5deg) scale(0.94);
}
50% {
transform: translateX(-25%) translateY(2%) skewX(-3deg);
}
75% {
transform: translateX(-37.5%) translateY(-5%) skewX(6deg);
opacity: 0.92;
transform: translate3d(13%, -10%, 0) rotate(6deg) scale(1.11);
}
100% {
transform: translateX(-50%) translateY(0) skewX(-6deg);
opacity: 0.62;
transform: translate3d(-5%, -2%, 0) rotate(-3deg) scale(1.02);
}
}
@keyframes aurora-drift-reverse {
0% {
opacity: 0.62;
transform: translate3d(10%, -6%, 0) rotate(5deg) scale(1.08);
}
50% {
opacity: 0.9;
transform: translate3d(-12%, -12%, 0) rotate(-6deg) scale(0.96);
}
100% {
opacity: 0.58;
transform: translate3d(6%, 2%, 0) rotate(3deg) scale(1.04);
}
}
@keyframes aurora-horizon-breathe {
0% {
opacity: 0.48;
transform: translate3d(-3%, 9%, 0) scale(0.96, 0.84);
}
50% {
opacity: 0.88;
transform: translate3d(3%, -5%, 0) scale(1.08, 1.16);
}
100% {
opacity: 0.58;
transform: translate3d(-1%, 2%, 0) scale(1.02, 0.96);
}
}
@keyframes aurora-current-sweep {
0% {
opacity: 0.28;
transform: translate3d(-16%, 8%, 0) rotate(-8deg) scaleX(0.84);
}
50% {
opacity: 0.72;
transform: translate3d(17%, -8%, 0) rotate(4deg) scaleX(1.08);
}
100% {
opacity: 0.38;
transform: translate3d(28%, 4%, 0) rotate(-2deg) scaleX(0.94);
}
}
@@ -330,8 +85,78 @@
0%,
100% {
opacity: 0.15;
transform: translate3d(0, 4px, 0) rotate(0deg) scale(0.78);
}
50% {
opacity: 1;
opacity: 0.78;
transform: translate3d(var(--aurora-star-x, 5px), -8px, 0) rotate(35deg)
scale(1.08);
}
}
.aurora-horizon {
animation: aurora-horizon-breathe 8s ease-in-out -3s infinite alternate;
transform-origin: center bottom;
will-change: opacity, transform;
}
.aurora-current {
animation-name: aurora-current-sweep;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
animation-direction: alternate;
will-change: opacity, transform;
}
.aurora-current-reverse {
animation-direction: alternate-reverse;
}
.aurora-motion {
animation-name: aurora-drift;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
animation-direction: alternate;
}
.aurora-motion-reverse {
animation-name: aurora-drift-reverse;
}
.aurora-star {
--aurora-star-x: 5px;
animation-name: aurora-twinkle;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
.aurora-star:nth-of-type(2n) {
--aurora-star-x: -6px;
}
@media (prefers-reduced-motion: reduce) {
.aurora-current,
.aurora-horizon,
.aurora-motion,
.aurora-star {
/* biome-ignore lint/complexity/noImportantStyles: reduced motion must override inline animation timing */
animation: none !important;
}
.aurora-horizon {
opacity: 0.68;
transform: translate3d(0, -1%, 0) scale(1.05);
}
.aurora-current,
.aurora-motion {
opacity: 0.58;
transform: none;
}
.aurora-current,
.aurora-horizon,
.aurora-motion {
will-change: auto;
}
}
@@ -3,9 +3,8 @@ import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Agent Desktop",
description: "AI coding agent interface",
generator: "v0.app",
title: "Cline",
description: "Build software with Cline.",
icons: {
icon: [
{
+286 -241
View File
@@ -18,12 +18,17 @@ import {
SidebarInset,
SidebarProvider,
SidebarRail,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { ChatInputBar } from "@/components/views/chat/chat-input-bar";
import { ChatMessages } from "@/components/views/chat/chat-messages";
import { DiffView } from "@/components/views/chat/diff-view";
import { WelcomeScreen } from "@/components/views/chat/welcome-chat";
import { SessionsView } from "@/components/views/sessions/sessions-view";
import { SettingsView } from "@/components/views/settings/settings-view";
import {
type SettingsSection,
SettingsView,
} from "@/components/views/settings/settings-view";
import { WorkspaceProvider } from "@/contexts/workspace-context";
import type { PromptInQueue } from "@/hooks/chat-session/types";
import { useChatSession } from "@/hooks/use-chat-session";
@@ -37,6 +42,13 @@ import {
type SessionMetadata,
} from "@/lib/session-history";
import { syncHubTheme, watchSystemHubTheme } from "@/lib/theme";
import {
mergeWorkspacePaths,
normalizeWorkspacePath,
readWorkspaceSelectionFromWindow,
workspacePathsFromSessions,
writeWorkspaceSelectionToWindow,
} from "@/lib/workspace-paths";
function makeThreadId(): string {
return `thread_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
@@ -45,24 +57,9 @@ function makeThreadId(): string {
type Thread = {
id: string;
historySession?: SessionHistoryItem;
hasStarted?: boolean;
};
type WorkspaceSessionItem = {
cwd?: string;
workspaceRoot?: string;
};
function normalizeWorkspacePath(path: string): string {
const normalized = path.trim().replace(/[\\/]+$/, "");
if (!normalized) {
return "";
}
if (/^[A-Za-z]:/.test(normalized)) {
return normalized.toLowerCase();
}
return normalized;
}
function toThreadTitle(options: { title?: string; prompt?: string }): string {
const preferredTitle = options.title?.trim();
if (preferredTitle) {
@@ -75,6 +72,8 @@ function toThreadTitle(options: { title?: string; prompt?: string }): string {
export default function Home() {
const [view, setView] = useState<"chat" | "sessions" | "settings">("chat");
const [settingsSection, setSettingsSection] =
useState<SettingsSection>("General");
const [threads, setThreads] = useState<Thread[]>(() => [
{ id: makeThreadId() },
]);
@@ -102,11 +101,15 @@ export default function Home() {
const next = [...prev];
next[existingIdx] = {
...next[existingIdx],
hasStarted: true,
historySession: session,
};
return next;
}
return [...prev, { id: threadId, historySession: session }];
return [
...prev,
{ id: threadId, hasStarted: true, historySession: session },
];
});
setActiveThreadId(threadId);
setView("chat");
@@ -188,70 +191,107 @@ export default function Home() {
?.sessionId ?? null;
const activeThread =
threads.find((thread) => thread.id === activeThreadId) ?? threads[0];
const handleHome = useCallback(() => {
if (activeThread?.historySession || activeThread?.hasStarted) {
handleNewThread();
return;
}
setView("chat");
}, [activeThread, handleNewThread]);
const handleThreadStarted = useCallback((threadId: string) => {
setThreads((current) =>
current.map((thread) =>
thread.id === threadId && !thread.hasStarted
? { ...thread, hasStarted: true }
: thread,
),
);
}, []);
const sessionHistory = useSessionHistory({
activeSessionId: activeHistorySessionId,
onDeleteSession: handleDeleteSession,
onOpenSession: handleOpenSession,
onUpdateSessionMetadata: handleUpdateSessionMetadata,
});
const historyWorkspacePaths = useMemo(
() => workspacePathsFromSessions(sessionHistory.sessions),
[sessionHistory.sessions],
);
return (
<>
<SidebarProvider>
<div className="flex h-screen w-full overflow-hidden bg-background text-foreground">
<Sidebar
className="border-r border-sidebar-border"
collapsible="icon"
>
<AgentSidebar
<SidebarProvider>
<div className="flex h-screen w-full overflow-hidden bg-background text-foreground">
<Sidebar className="border-r border-sidebar-border" collapsible="icon">
<AgentSidebar
activeSessionId={activeHistorySessionId}
isHomeActive={
view === "chat" &&
!activeThread?.historySession &&
!activeThread?.hasStarted
}
onHome={handleHome}
onNewThread={handleNewThread}
onSettingsSectionChange={setSettingsSection}
sessionHistory={sessionHistory}
setView={setView}
settingsSection={settingsSection}
view={view}
/>
<SidebarRail />
</Sidebar>
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
<SidebarTrigger className="absolute left-3 top-3 z-40 md:hidden" />
{view === "sessions" ? (
<SessionsView
activeSessionId={activeHistorySessionId}
onNewThread={handleNewThread}
sessionHistory={sessionHistory}
setView={setView}
history={sessionHistory}
/>
<SidebarRail />
</Sidebar>
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
{view === "sessions" ? (
<SessionsView
activeSessionId={activeHistorySessionId}
history={sessionHistory}
) : activeThread ? (
<div
aria-hidden={view === "settings" ? true : undefined}
className="flex min-h-0 flex-1 flex-col"
inert={view === "settings" ? true : undefined}
>
<ChatThreadPane
key={activeThread.id}
historySession={activeThread.historySession}
knownWorkspacePaths={historyWorkspacePaths}
onUpdateSessionMetadata={handleUpdateSessionMetadata}
threadId={activeThread.id}
onDeleteSession={handleDeleteSession}
onNewThread={handleNewThread}
onOpenSession={handleOpenSession}
onThreadStarted={handleThreadStarted}
/>
) : activeThread ? (
<div className="flex min-h-0 flex-1 flex-col">
<ChatThreadPane
key={activeThread.id}
historySession={activeThread.historySession}
onUpdateSessionMetadata={handleUpdateSessionMetadata}
threadId={activeThread.id}
onDeleteSession={handleDeleteSession}
onNewThread={handleNewThread}
onOpenSession={handleOpenSession}
/>
</div>
) : null}
</SidebarInset>
</div>
</SidebarProvider>
{view === "settings" ? (
<div className="fixed inset-0 z-50 bg-background text-foreground">
<SettingsView onClose={() => setView("chat")} />
</div>
) : null}
</>
</div>
) : null}
{view === "settings" ? (
<div className="absolute inset-0 z-30 bg-background text-foreground">
<SettingsView
onNavigateSection={setSettingsSection}
section={settingsSection}
/>
</div>
) : null}
</SidebarInset>
</div>
</SidebarProvider>
);
}
function ChatThreadPane({
threadId,
historySession,
knownWorkspacePaths,
onUpdateSessionMetadata,
onDeleteSession,
onNewThread,
onOpenSession,
onThreadStarted,
}: {
threadId: string;
historySession?: SessionHistoryItem;
knownWorkspacePaths: string[];
onUpdateSessionMetadata?: (
sessionId: string,
metadata: SessionMetadata,
@@ -259,6 +299,7 @@ function ChatThreadPane({
onDeleteSession?: (sessionId: string, threadId?: string) => void;
onNewThread?: () => void;
onOpenSession?: (session: SessionHistoryItem) => void;
onThreadStarted?: (threadId: string) => void;
}) {
const {
sessionId,
@@ -304,7 +345,12 @@ function ChatThreadPane({
Record<string, { apiKey: string }>
>({});
const [providersLoaded, setProvidersLoaded] = useState(false);
const [workspaces, setWorkspaces] = useState<string[]>([]);
const [workspaces, setWorkspaces] = useState<string[]>(() =>
mergeWorkspacePaths(
readWorkspaceSelectionFromWindow().workspaces,
knownWorkspacePaths,
),
);
const [workspacesLoaded, setWorkspacesLoaded] = useState(false);
const hydratedSessionRef = useRef<string | null>(null);
const resetThreadRef = useRef<string | null>(null);
@@ -318,6 +364,24 @@ function ChatThreadPane({
workspaceRoot: config.workspaceRoot,
};
useEffect(() => {
setWorkspaces((current) => {
const merged = mergeWorkspacePaths(current, knownWorkspacePaths);
return current.length === merged.length &&
current.every((workspace, index) => workspace === merged[index])
? current
: merged;
});
}, [knownWorkspacePaths]);
useEffect(() => {
const lastWorkspace = (config.workspaceRoot || config.cwd || "").trim();
writeWorkspaceSelectionToWindow({
lastWorkspace,
workspaces: mergeWorkspacePaths(workspaces, [lastWorkspace]),
});
}, [config.cwd, config.workspaceRoot, workspaces]);
useEffect(() => {
let cancelled = false;
@@ -438,52 +502,28 @@ function ChatThreadPane({
const listWorkspaces = useCallback(
async (preferredWorkspace?: string): Promise<string[]> => {
const roots = new Set<string>();
const preferred = (preferredWorkspace || "").trim();
if (preferred) {
roots.add(preferred);
}
const current = (
workspaceRef.current.workspaceRoot ||
workspaceRef.current.cwd ||
""
).trim();
if (current) {
roots.add(current);
}
try {
const discovered = await desktopClient
.invoke<WorkspaceSessionItem[]>("list_discovered_sessions", {
limit: 20,
})
.catch(() => []);
for (const session of discovered) {
const candidate = (session.workspaceRoot || session.cwd || "").trim();
if (candidate) {
roots.add(candidate);
}
}
} catch {
// Keep fallback to current workspace when history is unavailable.
}
return [...roots].sort((a, b) => a.localeCompare(b));
return mergeWorkspacePaths(knownWorkspacePaths, [preferred, current]);
},
[],
[knownWorkspacePaths],
);
const refreshWorkspaces = useCallback(
async (preferredWorkspace?: string) => {
try {
const results = await listWorkspaces(preferredWorkspace);
setWorkspaces((current) =>
current.length === results.length &&
current.every((workspace, index) => workspace === results[index])
setWorkspaces((current) => {
const merged = mergeWorkspacePaths(current, results);
return current.length === merged.length &&
current.every((workspace, index) => workspace === merged[index])
? current
: results,
);
: merged;
});
} finally {
setWorkspacesLoaded(true);
}
@@ -508,17 +548,21 @@ function ChatThreadPane({
if (normalizedNext === normalizedCurrent) {
return true;
}
const validation = await desktopClient
.invoke<{ valid?: boolean }>("validate_workspace_directory", {
path: nextWorkspace,
})
.catch(() => ({ valid: false }));
if (validation.valid !== true) {
return false;
}
setConfig((prev) => ({
...prev,
workspaceRoot: nextWorkspace,
cwd: nextWorkspace,
}));
setWorkspaces((prev) => {
const next = new Set(prev);
next.add(nextWorkspace);
return [...next].sort((a, b) => a.localeCompare(b));
});
setWorkspaces((prev) => mergeWorkspacePaths(prev, [nextWorkspace]));
// Fire git branch + workspace list refresh in the background
desktopClient
@@ -533,7 +577,7 @@ function ChatThreadPane({
setGitBranch("no-git");
});
// Re-fetch workspace list so the new root appears
// Refresh the merged history, stored, and current workspace catalog.
void refreshWorkspaces(nextWorkspace);
return true;
@@ -618,11 +662,12 @@ function ChatThreadPane({
if (!trimmed && pendingAttachments.length === 0) {
return;
}
onThreadStarted?.(threadId);
setPromptInput("");
const toSend = [...pendingAttachments];
setPendingAttachments([]);
await sendPrompt(trimmed, toSend);
}, [pendingAttachments, promptInput, sendPrompt]);
}, [onThreadStarted, pendingAttachments, promptInput, sendPrompt, threadId]);
const handleReasoningChange = useCallback(
(next: Pick<ChatSessionConfig, "thinking" | "reasoningEffort">) => {
@@ -827,7 +872,7 @@ function ChatThreadPane({
? false
: isHydratingSession;
const isWelcomeState =
displayedMessages.length === 0 && !displayedIsSwitching;
displayedMessages.length === 0 && !displayedIsSwitching && !displayedError;
const handleRenameTitle = useCallback(
async (nextTitle: string) => {
@@ -915,153 +960,153 @@ function ChatThreadPane({
);
}
const composer = (
<ChatInputBar
attachments={attachmentList}
onAbort={() => void abort()}
onAttachFiles={(files) => {
setPendingAttachments((prev) => {
const existing = new Set(
prev.map(
(file) => `${file.name}:${file.size}:${file.lastModified}`,
),
);
const next = [...prev];
for (const file of files) {
const key = `${file.name}:${file.size}:${file.lastModified}`;
if (!existing.has(key)) {
existing.add(key);
next.push(file);
}
}
return next;
});
}}
onListGitBranches={listGitBranches}
onRemoveAttachment={(id) => {
setPendingAttachments((prev) =>
prev.filter((file, index) => {
const fileId = `${file.name}:${file.size}:${file.lastModified}:${index}`;
return fileId !== id;
}),
);
}}
onSwitchGitBranch={switchGitBranch}
onModelChange={(nextModel) =>
setConfig((prev) =>
prev.model === nextModel ? prev : { ...prev, model: nextModel },
)
}
onModeToggle={() =>
setConfig((prev) => ({
...prev,
mode: prev.mode === "plan" ? "act" : "plan",
}))
}
onPromptInputChange={setPromptInput}
onReasoningChange={handleReasoningChange}
onSteerPromptInQueue={(promptId) => {
void steerPromptInQueue(promptId);
}}
onEditPromptInQueue={(promptId, prompt) => {
void updatePromptInQueue(promptId, prompt);
}}
onUndoPromptInQueue={(item) => {
void handleUndoQueuedPrompt(item);
}}
onProviderChange={(nextProvider) =>
setConfig((prev) => {
const selected = providerCredentials[nextProvider];
const nextApiKey = selected?.apiKey ?? "";
if (prev.provider === nextProvider && prev.apiKey === nextApiKey) {
return prev;
}
return {
...prev,
provider: nextProvider,
apiKey: nextApiKey,
};
})
}
onSend={() => void handleSend()}
gitBranch={gitBranch}
model={config.model}
mode={config.mode}
promptsInQueue={promptsInQueue}
promptInput={promptInput}
provider={config.provider}
reasoningEffort={config.reasoningEffort}
status={status}
summary={summary}
thinking={config.thinking}
variant={isWelcomeState ? "welcome" : "conversation"}
/>
);
return (
<WorkspaceProvider value={workspaceContextValue}>
<div className="grid h-full min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden">
<div className="z-20">
<AgentHeader
canEditTitle={Boolean(activeSessionForTitle)}
canDeleteSession={Boolean(activeSessionToDelete)}
deletingSession={deletingSession}
diff={{
additions: summary.additions,
deletions: summary.deletions,
}}
onDeleteSession={requestDeleteSession}
onNewThread={onNewThread}
onOpenDiff={() => {
if (hasDiffChanges) {
setShowDiffView(true);
}
}}
onRenameTitle={handleRenameTitle}
renamingTitle={renamingSession}
showSessionActions={!isWelcomeState}
status={status}
title={threadTitle}
/>
</div>
<div className="h-full min-h-0 overflow-hidden">
{showDiffView ? (
<DiffView
fileDiffs={fileDiffs}
onClose={() => setShowDiffView(false)}
/>
) : (
<ChatMessages
onAnswerAskQuestion={handleAnswerAskQuestion}
onApproveToolApproval={handleApproveToolApproval}
onRejectToolApproval={handleRejectToolApproval}
onStartChat={(prompt) => {
setPromptInput(prompt);
<div
className={
isWelcomeState
? "grid h-full min-h-0 flex-1 grid-rows-[minmax(0,1fr)] overflow-hidden"
: "grid h-full min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden"
}
>
{!isWelcomeState ? (
<div className="z-20 border-b border-border/70 bg-background/85 backdrop-blur-sm">
<AgentHeader
canEditTitle={Boolean(activeSessionForTitle)}
canDeleteSession={Boolean(activeSessionToDelete)}
deletingSession={deletingSession}
diff={{
additions: summary.additions,
deletions: summary.deletions,
}}
chatTransportState={chatTransportState}
error={displayedError}
messages={displayedMessages}
model={config.model}
onRestoreCheckpoint={(runCount) =>
void restoreCheckpoint(runCount)
}
onForkSession={handleForkSession}
pendingToolApprovals={pendingToolApprovals}
pendingAskQuestions={pendingAskQuestions}
provider={config.provider}
sessionId={displayedSessionId}
streamingMessageId={activeAssistantMessageId}
isSessionSwitching={displayedIsSwitching}
status={displayedStatus}
onDeleteSession={requestDeleteSession}
onNewThread={onNewThread}
onOpenDiff={() => {
if (hasDiffChanges) setShowDiffView(true);
}}
onRenameTitle={handleRenameTitle}
renamingTitle={renamingSession}
status={status}
title={threadTitle}
/>
)}
</div>
<div className="z-20 shrink-0">
<ChatInputBar
attachments={attachmentList}
onAbort={() => void abort()}
onAttachFiles={(files) => {
setPendingAttachments((prev) => {
const existing = new Set(
prev.map(
(file) => `${file.name}:${file.size}:${file.lastModified}`,
),
);
const next = [...prev];
for (const file of files) {
const key = `${file.name}:${file.size}:${file.lastModified}`;
if (!existing.has(key)) {
existing.add(key);
next.push(file);
}
</div>
) : null}
<WelcomeScreen
active={isWelcomeState}
body={
showDiffView ? (
<DiffView
fileDiffs={fileDiffs}
onClose={() => setShowDiffView(false)}
/>
) : (
<ChatMessages
onAnswerAskQuestion={handleAnswerAskQuestion}
onApproveToolApproval={handleApproveToolApproval}
onRejectToolApproval={handleRejectToolApproval}
chatTransportState={chatTransportState}
error={displayedError}
messages={displayedMessages}
onRestoreCheckpoint={(runCount) =>
void restoreCheckpoint(runCount)
}
return next;
});
}}
onListGitBranches={listGitBranches}
onRemoveAttachment={(id) => {
setPendingAttachments((prev) =>
prev.filter((file, index) => {
const fileId = `${file.name}:${file.size}:${file.lastModified}:${index}`;
return fileId !== id;
}),
);
}}
onSwitchGitBranch={switchGitBranch}
onRefreshGitBranch={() => void refreshGitBranch()}
onModelChange={(nextModel) =>
setConfig((prev) =>
prev.model === nextModel ? prev : { ...prev, model: nextModel },
)
}
onModeToggle={() =>
setConfig((prev) => ({
...prev,
mode: prev.mode === "plan" ? "act" : "plan",
}))
}
onPromptInputChange={setPromptInput}
onReasoningChange={handleReasoningChange}
onSteerPromptInQueue={(promptId) => {
void steerPromptInQueue(promptId);
}}
onEditPromptInQueue={(promptId, prompt) => {
void updatePromptInQueue(promptId, prompt);
}}
onUndoPromptInQueue={(item) => {
void handleUndoQueuedPrompt(item);
}}
onProviderChange={(nextProvider) =>
setConfig((prev) => {
const selected = providerCredentials[nextProvider];
const nextApiKey = selected?.apiKey ?? "";
if (
prev.provider === nextProvider &&
prev.apiKey === nextApiKey
) {
return prev;
}
return {
...prev,
provider: nextProvider,
apiKey: nextApiKey,
};
})
}
onReset={() => {
setPendingAttachments([]);
void reset();
}}
onSend={() => void handleSend()}
gitBranch={gitBranch}
model={config.model}
mode={config.mode}
promptsInQueue={promptsInQueue}
promptInput={promptInput}
provider={config.provider}
reasoningEffort={config.reasoningEffort}
status={status}
summary={summary}
thinking={config.thinking}
/>
</div>
onForkSession={handleForkSession}
pendingToolApprovals={pendingToolApprovals}
pendingAskQuestions={pendingAskQuestions}
sessionId={displayedSessionId}
streamingMessageId={activeAssistantMessageId}
isSessionSwitching={displayedIsSwitching}
status={displayedStatus}
/>
)
}
composer={composer}
onStartChat={setPromptInput}
quickActions={[]}
/>
</div>
<AlertDialog
open={deleteConfirmOpen}
@@ -82,12 +82,13 @@ export function AgentHeader({
const triggerDeleteSession = () => onDeleteSession?.();
return (
<header className="flex h-12 items-center justify-between px-4">
<header className="flex h-12 items-center justify-between gap-2 px-4 max-md:pl-12">
{/* Left: thread title */}
<div className="flex items-center gap-2">
<span
<div className="flex min-w-0 flex-1 items-center gap-2">
<output
aria-label={`Session status: ${status}`}
className={cn(
"rounded w-2 h-2 font-mono",
"size-2 shrink-0 rounded font-mono",
status === "running"
? "bg-green-500"
: status === "failed"
@@ -97,7 +98,7 @@ export function AgentHeader({
/>
{isEditingTitle ? (
<form
className="m-0"
className="m-0 min-w-0 flex-1"
onSubmit={(event) => {
event.preventDefault();
void submitTitle();
@@ -105,7 +106,7 @@ export function AgentHeader({
>
<Input
autoFocus
className="h-7 w-64 text-sm"
className="h-7 w-64 max-w-full text-sm"
disabled={renamingTitle}
onBlur={() => {
void submitTitle();
@@ -124,7 +125,7 @@ export function AgentHeader({
) : (
<button
className={cn(
"text-sm font-medium text-foreground",
"min-w-0 truncate text-sm font-medium text-foreground",
canEditTitle &&
"rounded px-1 py-0.5 transition-colors hover:bg-accent",
)}
@@ -137,6 +138,7 @@ export function AgentHeader({
setIsEditingTitle(true);
}}
type="button"
title={threadTitle}
>
{threadTitle}
</button>
@@ -144,7 +146,8 @@ export function AgentHeader({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="text-muted-foreground hover:text-foreground transition-colors"
aria-label="Session actions"
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
id="show-more-btn"
variant="ghost"
size="icon-sm"
@@ -167,8 +170,9 @@ export function AgentHeader({
</div>
{showSessionActions ? (
<div className="flex items-center gap-2">
<div className="flex shrink-0 items-center gap-2">
<Button
aria-label={`Open diff: ${additions} additions, ${deletions} deletions`}
className={cn(
"flex items-center gap-1 rounded-md bg-secondary px-2 py-1 text-xs font-mono transition-colors",
hasChanges
@@ -186,6 +190,7 @@ export function AgentHeader({
<span className="text-destructive">-{deletions}</span>
</Button>
<Button
aria-label="New session"
className="flex items-center gap-1 rounded-md text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
onClick={() => onNewThread?.()}
size="icon-sm"
@@ -0,0 +1,176 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AgentSidebar } from "@/components/agent-sidebar";
import { SidebarProvider } from "@/components/ui/sidebar";
import type {
SessionThread,
UseSessionHistoryResult,
} from "@/hooks/use-session-history";
let container: HTMLDivElement;
let root: Root;
function makeThread(project: string, index: number): SessionThread {
return {
id: `${project}-${index}`,
title: `${project} session ${index}`,
codebase: project,
workspacePath: `/projects/${project}`,
time: `${index}m`,
provider: "cline",
model: "test-model",
status: "completed",
};
}
function makeSessionHistory(
threads: SessionThread[],
loadMoreSessions: ReturnType<typeof vi.fn>,
options: {
loadOlderSessions?: ReturnType<typeof vi.fn>;
mayHaveMoreSessions?: boolean;
} = {},
): UseSessionHistoryResult {
return {
deleteThread: vi.fn(),
forkThread: vi.fn(),
isLoadingHistory: false,
isLoadingMore: false,
loadOlderSessions: options.loadOlderSessions ?? vi.fn(),
loadMoreSessions,
mayHaveMoreSessions: options.mayHaveMoreSessions ?? false,
openThread: vi.fn(),
pendingAction: null,
renameThread: vi.fn(),
threads,
unreadSessionIds: new Set<string>(),
} as unknown as UseSessionHistoryResult;
}
async function click(element: Element): Promise<void> {
await act(async () => {
element.dispatchEvent(
new MouseEvent("pointerdown", { bubbles: true, cancelable: true }),
);
element.dispatchEvent(
new MouseEvent("click", { bubbles: true, cancelable: true }),
);
await Promise.resolve();
});
}
function buttonWithText(text: string, rootNode: ParentNode = container) {
const button = [
...rootNode.querySelectorAll<HTMLButtonElement>("button"),
].find((candidate) => candidate.textContent?.includes(text));
expect(button).toBeDefined();
return button as HTMLButtonElement;
}
function sessionIsVisible(title: string): boolean {
return [...container.querySelectorAll<HTMLButtonElement>("button")].some(
(button) => button.querySelector("span")?.textContent === title,
);
}
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: vi.fn(() => ({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})),
});
HTMLElement.prototype.hasPointerCapture = vi.fn(() => false);
HTMLElement.prototype.setPointerCapture = vi.fn();
HTMLElement.prototype.releasePointerCapture = vi.fn();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.restoreAllMocks();
});
describe("AgentSidebar session organization", () => {
it("defaults to time and keeps project expansion scoped to one project", async () => {
const threads = [
...Array.from({ length: 12 }, (_, index) =>
makeThread("alpha", index + 1),
),
...Array.from({ length: 12 }, (_, index) =>
makeThread("beta", index + 1),
),
];
const loadMoreSessions = vi.fn(async () => undefined);
const loadOlderSessions = vi.fn(async () => undefined);
const sessionHistory = makeSessionHistory(threads, loadMoreSessions, {
loadOlderSessions,
mayHaveMoreSessions: true,
});
await act(async () => {
root.render(
<SidebarProvider>
<AgentSidebar
activeSessionId={null}
isHomeActive
onHome={vi.fn()}
onNewThread={vi.fn()}
sessionHistory={sessionHistory}
setView={vi.fn()}
view="chat"
/>
</SidebarProvider>,
);
});
expect(
container.querySelector('[aria-label="Sort sessions: Time"]'),
).not.toBeNull();
expect(sessionIsVisible("alpha session 10")).toBe(true);
expect(sessionIsVisible("alpha session 11")).toBe(false);
expect(sessionIsVisible("beta session 1")).toBe(false);
await click(buttonWithText("Show more"));
expect(sessionIsVisible("alpha session 11")).toBe(true);
expect(loadMoreSessions).toHaveBeenCalledWith(20);
await click(
container.querySelector('[aria-label="Sort sessions: Time"]') as Element,
);
const projectOption = await vi.waitFor(() => {
const option = [
...document.querySelectorAll<HTMLElement>('[role="menuitemradio"]'),
].find((candidate) => candidate.textContent?.includes("Sort by project"));
expect(option).toBeDefined();
return option as HTMLElement;
});
await click(projectOption);
await vi.waitFor(() => {
expect(
container.querySelector('[aria-label="Sort sessions: Project"]'),
).not.toBeNull();
});
expect(container.textContent).toContain("alpha");
expect(container.textContent).toContain("beta");
expect(sessionIsVisible("alpha session 11")).toBe(false);
expect(sessionIsVisible("beta session 11")).toBe(false);
await click(buttonWithText("Show more in alpha"));
expect(sessionIsVisible("alpha session 11")).toBe(true);
expect(sessionIsVisible("beta session 11")).toBe(false);
await click(buttonWithText("Load older projects"));
expect(loadOlderSessions).toHaveBeenCalledOnce();
});
});
@@ -1,17 +1,28 @@
"use client";
import {
ArrowDownUp,
Blocks,
Bot,
ChevronDown,
CircleUserRound,
Clock3,
Filter,
FolderTree,
GitFork,
Home,
Loader2,
MessageSquare,
PanelLeftOpen,
Pencil,
Pin,
Plus,
Radio,
Search,
Server,
Settings,
SlidersHorizontal,
Store,
Trash2,
} from "lucide-react";
import {
@@ -22,6 +33,7 @@ import {
useRef,
useState,
} from "react";
import { ClineLogo } from "@/components/cline-logo";
import {
AlertDialog,
AlertDialogAction,
@@ -55,37 +67,117 @@ import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useSidebar } from "@/components/ui/sidebar";
import { normalizeTitle } from "@/components/utils";
import {
SETTINGS_SECTIONS,
type SettingsSection,
} from "@/components/views/settings/settings-view";
import type {
SessionThread,
UseSessionHistoryResult,
} from "@/hooks/use-session-history";
import { formatCostUsd, formatTokenCount } from "@/hooks/use-session-history";
import {
groupThreadsByProject,
INITIAL_VISIBLE_THREAD_COUNT,
workspaceDisplayName,
} from "@/lib/sidebar-session-organization";
import { cn } from "@/lib/utils";
type Thread = SessionThread;
type AppView = "chat" | "sessions" | "settings";
const filterOptions = ["All", "Running", "Recent", "Pinned"] as const;
type FilterOption = (typeof filterOptions)[number];
const INITIAL_VISIBLE_THREAD_COUNT = 10;
type SidebarSortMode = "time" | "project";
const SETTINGS_SECTION_ICONS = {
General: SlidersHorizontal,
Models: Bot,
"MCP Servers": Server,
"MCP Marketplace": Store,
Customizations: Blocks,
Channels: Radio,
Schedules: Clock3,
Account: CircleUserRound,
} satisfies Record<SettingsSection, typeof Settings>;
function SettingsSectionNavigation({
activeSection,
collapsed,
onSelect,
}: {
activeSection: SettingsSection;
collapsed: boolean;
onSelect: (section: SettingsSection) => void;
}) {
return (
<nav
aria-label="Settings sections"
className={cn(
"flex h-full min-h-0 flex-col gap-0.5 overflow-y-auto",
collapsed ? "w-full items-center" : "w-full",
)}
>
{!collapsed ? (
<p className="px-2 pb-2 text-sm font-medium text-muted-foreground">
Settings
</p>
) : null}
{SETTINGS_SECTIONS.map((section) => {
const Icon = SETTINGS_SECTION_ICONS[section];
return (
<Button
aria-current={activeSection === section ? "page" : undefined}
aria-label={section}
className={cn(
"min-w-0 justify-start",
activeSection === section &&
"bg-sidebar-accent text-sidebar-accent-foreground",
collapsed && "mx-auto size-9 justify-center px-0",
)}
key={section}
onClick={() => onSelect(section)}
title={section}
type="button"
variant="sidebarItem"
>
<Icon className="size-4 shrink-0" />
{!collapsed ? <span className="truncate">{section}</span> : null}
</Button>
);
})}
</nav>
);
}
export function AgentSidebar({
isHomeActive,
onHome,
onNewThread,
onSettingsSectionChange,
setView,
settingsSection,
view,
activeSessionId,
sessionHistory,
}: {
isHomeActive: boolean;
onHome: () => void;
onNewThread?: () => void;
setView: (view: "chat" | "sessions" | "settings") => void;
onSettingsSectionChange: (section: SettingsSection) => void;
setView: (view: AppView) => void;
settingsSection: SettingsSection;
view: AppView;
activeSessionId?: string | null;
sessionHistory: UseSessionHistoryResult;
}) {
const { isMobile, setOpen, state } = useSidebar();
const { isMobile, setOpen, setOpenMobile, state } = useSidebar();
const isCollapsed = !isMobile && state === "collapsed";
const {
deleteThread: deleteHistoryThread,
forkThread: forkHistoryThread,
isLoadingHistory,
isLoadingMore,
loadOlderSessions,
loadMoreSessions,
mayHaveMoreSessions,
openThread: openHistoryThread,
@@ -96,6 +188,7 @@ export function AgentSidebar({
} = sessionHistory;
const activeThread = activeSessionId ?? "";
const [filter, setFilter] = useState<FilterOption>("All");
const [sortMode, setSortMode] = useState<SidebarSortMode>("time");
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [showMoreCount, setShowMoreCount] = useState(
@@ -106,6 +199,12 @@ export function AgentSidebar({
const [deleteConfirmThread, setDeleteConfirmThread] = useState<Thread | null>(
null,
);
const [collapsedProjects, setCollapsedProjects] = useState<Set<string>>(
() => new Set(),
);
const [projectVisibleCounts, setProjectVisibleCounts] = useState<
Record<string, number>
>({});
useEffect(() => {
if (isCollapsed && searchOpen) {
@@ -120,7 +219,8 @@ export function AgentSidebar({
filtered = filtered.filter(
(t) =>
t.title.toLowerCase().includes(q) ||
t.codebase.toLowerCase().includes(q),
t.codebase.toLowerCase().includes(q) ||
t.workspacePath.toLowerCase().includes(q),
);
}
switch (filter) {
@@ -134,19 +234,44 @@ export function AgentSidebar({
return filtered;
}
}, [filter, searchQuery, threads]);
const closeMobileSidebar = useCallback(() => {
if (isMobile) setOpenMobile(false);
}, [isMobile, setOpenMobile]);
const openThread = useCallback(
(threadId: string) => {
setView("chat");
openHistoryThread(threadId);
closeMobileSidebar();
},
[openHistoryThread, setView],
[closeMobileSidebar, openHistoryThread, setView],
);
const openNewThread = useCallback(() => {
setView("chat");
onNewThread?.();
}, [onNewThread, setView]);
closeMobileSidebar();
}, [closeMobileSidebar, onNewThread, setView]);
const openHome = useCallback(() => {
onHome();
closeMobileSidebar();
}, [closeMobileSidebar, onHome]);
const openSessions = useCallback(() => {
setView("sessions");
closeMobileSidebar();
}, [closeMobileSidebar, setView]);
const openSettings = useCallback(() => {
setView("settings");
closeMobileSidebar();
}, [closeMobileSidebar, setView]);
const openSettingsSection = useCallback(
(section: SettingsSection) => {
onSettingsSectionChange(section);
setView("settings");
closeMobileSidebar();
},
[closeMobileSidebar, onSettingsSectionChange, setView],
);
const startRenameThread = useCallback((thread: Thread) => {
setEditingSessionId(thread.id);
@@ -202,8 +327,30 @@ export function AgentSidebar({
: [...pinnedThreads, ...sessionThreads].slice(0, showMoreCount),
[filter, pinnedThreads, sessionThreads, showMoreCount],
);
const showShowMore =
sessionThreads.length > showMoreCount || mayHaveMoreSessions;
const showTimeShowMore =
sessionThreads.length > showMoreCount ||
(filter === "All" && !searchQuery && mayHaveMoreSessions);
const projectGroups = useMemo(
() => groupThreadsByProject([...pinnedThreads, ...sessionThreads]),
[pinnedThreads, sessionThreads],
);
const toggleProject = useCallback((project: string) => {
setCollapsedProjects((current) => {
const next = new Set(current);
if (next.has(project)) next.delete(project);
else next.add(project);
return next;
});
}, []);
const showMoreForProject = useCallback((project: string) => {
setProjectVisibleCounts((current) => ({
...current,
[project]:
(current[project] ?? INITIAL_VISIBLE_THREAD_COUNT) +
INITIAL_VISIBLE_THREAD_COUNT,
}));
}, []);
const filterMenu = (
<DropdownMenu>
@@ -222,6 +369,7 @@ export function AgentSidebar({
onValueChange={(value) => {
setFilter(value as FilterOption);
setShowMoreCount(INITIAL_VISIBLE_THREAD_COUNT);
setProjectVisibleCounts({});
}}
value={filter}
>
@@ -234,29 +382,119 @@ export function AgentSidebar({
</DropdownMenuContent>
</DropdownMenu>
);
const sortMenu = (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={`Sort sessions: ${sortMode === "time" ? "Time" : "Project"}`}
className="m-0! inline-flex size-8 items-center justify-center rounded-md p-0! text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
size="icon"
title={sortMode === "time" ? "Sort by time" : "Sort by project"}
variant="ghost"
>
<ArrowDownUp className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuRadioGroup
onValueChange={(value) => {
if (value === "time" || value === "project") {
setSortMode(value);
}
}}
value={sortMode}
>
<DropdownMenuRadioItem value="time">
<Clock3 className="size-4" />
Sort by time
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="project">
<FolderTree className="size-4" />
Sort by project
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
const threadItem = (thread: Thread) => (
<ThreadItem
editTitle={editingTitle}
editing={editingSessionId === thread.id}
isActive={activeThread === thread.id}
key={thread.id}
onCancelRename={cancelRenameThread}
onClick={() => openThread(thread.id)}
onCommitRename={() => void commitRenameThread(thread)}
onDelete={() => requestDeleteThread(thread)}
onEditTitleChange={setEditingTitle}
onFork={() => void forkThread(thread)}
onRename={() => startRenameThread(thread)}
pendingAction={
pendingAction?.sessionId === thread.id ? pendingAction.action : null
}
thread={thread}
unread={unreadSessionIds.has(thread.id)}
/>
);
return (
<>
<div className="flex h-full min-h-0 w-full min-w-0 shrink-0 flex-col overflow-hidden bg-sidebar text-sidebar-foreground">
<div className="mt-2 flex w-full min-w-0 flex-col gap-1">
<div
className={cn(
"flex h-16 shrink-0 items-center px-4",
isCollapsed && "justify-center px-0",
)}
>
<button
aria-label="Cline home"
className="rounded-md p-1 text-sidebar-foreground transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
onClick={openHome}
type="button"
>
<ClineLogo className="h-6 w-6" />
</button>
</div>
<div className={cn("shrink-0 px-3", isCollapsed && "px-1.5")}>
<Button
className={cn(
"justify-start min-w-0",
"min-w-0 justify-start",
view === "chat" &&
isHomeActive &&
"bg-sidebar-accent text-sidebar-accent-foreground",
isCollapsed && "mx-auto size-9 justify-center px-0",
)}
aria-label="New Session"
onClick={openNewThread}
title="New Session"
variant="sidebar"
aria-label="Home"
onClick={openHome}
title="Home"
variant="sidebarItem"
>
{isCollapsed ? (
<MessageSquare className="size-4" />
) : (
<Plus className="size-4" />
)}
{!isCollapsed ? "New Session" : null}
<Home className="size-4" />
{!isCollapsed ? "Home" : null}
</Button>
{isCollapsed ? (
</div>
{isCollapsed ? (
<div className="mt-2 flex min-h-0 flex-1 flex-col items-center gap-1 px-1.5">
{view === "settings" ? (
<SettingsSectionNavigation
activeSection={settingsSection}
collapsed
onSelect={openSettingsSection}
/>
) : (
<Button
aria-label="New session"
className="mx-auto size-9 justify-center px-0"
onClick={openNewThread}
title="New session"
type="button"
variant="sidebarItem"
>
<MessageSquare className="size-4" />
</Button>
)}
<Button
aria-label="Expand sidebar"
className="mx-auto size-9 justify-center px-0"
@@ -267,139 +505,211 @@ export function AgentSidebar({
>
<PanelLeftOpen className="size-4" />
</Button>
) : null}
</div>
{!isCollapsed ? (
<div className="flex w-full min-w-0 flex-col gap-1">
{searchOpen ? (
<div className="flex min-w-0 items-center gap-2 overflow-hidden rounded-md bg-sidebar-accent px-2 py-1.5">
<Search className="size-4 shrink-0" />
<Input
className="min-w-0 flex-1 bg-transparent text-sm text-sidebar-foreground outline-none placeholder:text-muted-foreground"
onBlur={() => {
if (!searchQuery) setSearchOpen(false);
}}
autoFocus={true}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search sessions..."
value={searchQuery}
/>
</div>
) : (
<Button
className="py-1.5 min-w-0"
onClick={() => setSearchOpen(true)}
title="Search sessions"
type="button"
variant="sidebarItem"
>
<Search className="size-4 shrink-0" />
<span>Search</span>
</Button>
)}
</div>
) : null}
{!isCollapsed ? (
<div className="mt-2 min-h-0 w-full flex-1">
<ScrollArea className="h-full min-h-0 w-full min-w-0">
<div className="flex min-w-0 flex-col gap-0.5 pb-3 px-3">
{isLoadingHistory && threads.length === 0 ? (
<div className="p-4 text-xs text-muted-foreground">
Loading session history...
</div>
) : (
<>
{displayedThreads.length > 0 && (
<ThreadSection
action={filterMenu}
label={filter === "All" ? "Sessions" : filter}
onClick={() => setView("sessions")}
>
{displayedThreads.map((thread) => (
<ThreadItem
editTitle={editingTitle}
editing={editingSessionId === thread.id}
isActive={activeThread === thread.id}
key={thread.id}
onCancelRename={cancelRenameThread}
onClick={() => openThread(thread.id)}
onCommitRename={() =>
void commitRenameThread(thread)
}
onDelete={() => requestDeleteThread(thread)}
onEditTitleChange={setEditingTitle}
onFork={() => void forkThread(thread)}
onRename={() => startRenameThread(thread)}
pendingAction={
pendingAction?.sessionId === thread.id
? pendingAction.action
: null
}
thread={thread}
unread={unreadSessionIds.has(thread.id)}
/>
))}
</ThreadSection>
)}
{displayedThreads.length === 0 && (
<div className="p-4 text-xs text-muted-foreground">
{searchQuery
? "No sessions match your search."
: "No sessions found in history."}
</div>
)}
</>
)}
{showShowMore && (
<Button
className="pl-0"
disabled={isLoadingMore}
onClick={() => {
const nextCount =
showMoreCount + INITIAL_VISIBLE_THREAD_COUNT;
setShowMoreCount(nextCount);
void loadMoreSessions(nextCount);
}}
type="button"
variant="sidebarText"
>
{isLoadingMore ? (
<>
<Loader2 className="size-3 animate-spin" />
Loading...
</>
) : (
<>
Show more
<ChevronDown className="size-3" />
</>
)}
</Button>
)}
</div>
</ScrollArea>
) : view === "settings" ? (
<div className="mt-5 min-h-0 flex-1 px-3">
<SettingsSectionNavigation
activeSection={settingsSection}
collapsed={false}
onSelect={openSettingsSection}
/>
</div>
) : (
<div className="min-h-0 w-full flex-1" />
<>
<div className="mt-5 shrink-0 px-3">
<div className="flex h-8 items-center justify-between gap-2">
<button
className={cn(
"min-w-0 truncate text-sm font-medium text-muted-foreground transition-colors hover:text-sidebar-foreground",
view === "sessions" && "text-sidebar-foreground",
)}
onClick={openSessions}
type="button"
>
{sortMode === "time" ? "Sessions" : "Projects"}
</button>
<div className="flex shrink-0 items-center gap-0.5">
<Button
aria-label="Search sessions"
className="m-0! size-8 p-0! text-muted-foreground hover:text-sidebar-foreground"
onClick={() => setSearchOpen((current) => !current)}
size="icon"
title="Search sessions"
type="button"
variant="ghost"
>
<Search className="size-3.5" />
</Button>
{sortMenu}
{filterMenu}
<Button
aria-label="New session"
className="m-0! size-8 p-0! text-muted-foreground hover:text-sidebar-foreground"
onClick={openNewThread}
size="icon"
title="New session"
type="button"
variant="ghost"
>
<Plus className="size-4" />
</Button>
</div>
</div>
{searchOpen ? (
<div className="mt-1 flex min-w-0 items-center gap-2 overflow-hidden rounded-md border border-sidebar-border bg-background/70 px-2 py-1">
<Search className="size-4 shrink-0" />
<Input
className="h-7 min-w-0 flex-1 border-0 bg-transparent px-0 text-sm text-sidebar-foreground shadow-none outline-none placeholder:text-muted-foreground focus-visible:ring-0"
autoFocus={true}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search sessions..."
value={searchQuery}
/>
</div>
) : null}
</div>
<div className="mt-1 min-h-0 w-full flex-1">
<ScrollArea className="h-full min-h-0 w-full min-w-0">
<div className="flex min-w-0 flex-col gap-0.5 pb-3 px-3">
{isLoadingHistory && threads.length === 0 ? (
<div className="p-4 text-xs text-muted-foreground">
Loading session history...
</div>
) : (
<>
{sortMode === "time"
? displayedThreads.map(threadItem)
: projectGroups.map((project) => {
const visibleCount =
projectVisibleCounts[project.id] ??
INITIAL_VISIBLE_THREAD_COUNT;
return (
<ProjectSection
collapsed={collapsedProjects.has(project.id)}
key={project.id}
label={project.label}
onToggle={() => toggleProject(project.id)}
>
{project.threads
.slice(0, visibleCount)
.map(threadItem)}
{project.threads.length > visibleCount ? (
<Button
className="pl-2"
onClick={() =>
showMoreForProject(project.id)
}
type="button"
variant="sidebarText"
>
Show more in {project.label}
<ChevronDown className="size-3" />
</Button>
) : null}
</ProjectSection>
);
})}
{(sortMode === "time"
? displayedThreads.length === 0
: projectGroups.length === 0) && (
<div className="px-2 py-4 text-xs text-muted-foreground">
{searchQuery
? "No sessions match your search."
: "No sessions found in history."}
</div>
)}
</>
)}
{sortMode === "time" && showTimeShowMore && (
<Button
className="pl-0"
disabled={isLoadingMore}
onClick={() => {
const nextCount =
showMoreCount + INITIAL_VISIBLE_THREAD_COUNT;
setShowMoreCount(nextCount);
void loadMoreSessions(nextCount);
}}
type="button"
variant="sidebarText"
>
{isLoadingMore ? (
<>
<Loader2 className="size-3 animate-spin" />
Loading...
</>
) : (
<>
Show more
<ChevronDown className="size-3" />
</>
)}
</Button>
)}
{sortMode === "project" &&
filter === "All" &&
!searchQuery &&
mayHaveMoreSessions && (
<Button
className="pl-0"
disabled={isLoadingMore}
onClick={() => void loadOlderSessions()}
type="button"
variant="sidebarText"
>
{isLoadingMore ? (
<>
<Loader2 className="size-3 animate-spin" />
Loading older projects...
</>
) : (
<>
Load older projects
<ChevronDown className="size-3" />
</>
)}
</Button>
)}
</div>
</ScrollArea>
</div>
</>
)}
<div className="shrink-0 px-2 py-3">
<div className="shrink-0 border-t border-sidebar-border/70 px-2 py-3">
<Button
aria-label="Settings"
type="button"
variant="sidebarItem"
className={cn(
"justify-start min-w-0",
"min-w-0 justify-start",
view === "settings" &&
"bg-sidebar-accent text-sidebar-accent-foreground",
isCollapsed && "mx-auto size-9 justify-center px-0",
)}
onClick={() => setView("settings")}
onClick={openSettings}
title="Settings"
>
<Settings className="size-4" />
{!isCollapsed ? "Settings" : null}
</Button>
{!isCollapsed ? (
<div className="mt-2 flex items-center gap-2 rounded-md px-3 py-2 text-sidebar-foreground">
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-primary text-[11px] font-semibold text-primary-foreground">
C
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-medium">
Cline Desktop
</span>
<span className="block text-[11px] text-muted-foreground">
Local
</span>
</span>
</div>
) : null}
</div>
</div>
<AlertDialog
@@ -451,33 +761,35 @@ export function AgentSidebar({
);
}
function ThreadSection({
function ProjectSection({
label,
action,
onClick,
collapsed,
onToggle,
children,
}: {
label: string;
action?: ReactNode;
onClick?: () => void;
collapsed: boolean;
onToggle: () => void;
children: ReactNode;
}) {
return (
<div className={cn("mb-1 min-w-0")}>
<div className="flex h-9 w-full min-w-0 flex-nowrap items-center gap-1 text-sm font-medium text-muted-foreground">
<button
aria-label={`Open ${label} sessions view`}
className="flex min-w-0 flex-1 items-center self-stretch rounded-md pl-0 pr-2 text-left transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={onClick}
type="button"
>
<span className="block min-w-0 shrink truncate">{label}</span>
</button>
{action ? (
<div className="flex shrink-0 items-center">{action}</div>
) : null}
</div>
{children}
<div className="mb-1 min-w-0">
<button
aria-expanded={!collapsed}
className="flex h-8 w-full min-w-0 items-center gap-1.5 rounded-md px-1 text-left text-sm font-medium text-sidebar-foreground transition-colors hover:bg-sidebar-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
onClick={onToggle}
title={label}
type="button"
>
<ChevronDown
className={cn(
"size-3.5 shrink-0 transition-transform",
collapsed && "-rotate-90",
)}
/>
<span className="block min-w-0 truncate">{label}</span>
</button>
{!collapsed ? <div className="pl-3">{children}</div> : null}
</div>
);
}
@@ -515,6 +827,7 @@ function ThreadItem({
const costLabel = formatCostUsd(thread.totalCostUsd);
const title = normalizeTitle(thread.title);
const pending = pendingAction !== null;
const workspacePath = thread.workspacePath || thread.codebase;
const statusDotClass = pending
? "bg-yellow-400"
: thread.status === "running"
@@ -522,16 +835,20 @@ function ThreadItem({
: unread
? "bg-blue-500"
: "";
const infoItems: Array<[string, string | null | undefined]> = [
const infoItems: Array<[string, string | null | undefined, string?]> = [
["ID", thread.id],
["Workspace", thread.codebase],
[
"Workspace",
workspaceDisplayName(workspacePath),
workspacePath || undefined,
],
["Status", thread.status],
["Updated", thread.time],
["Provider", thread.provider],
["Model", thread.model],
["Tokens", tokenLabel],
["Cost", costLabel],
].filter((item): item is [string, string] => Boolean(item[1]));
].filter((item): item is [string, string, string?] => Boolean(item[1]));
if (editing) {
return (
@@ -573,20 +890,20 @@ function ThreadItem({
onClick={onClick}
type="button"
>
<span className="block max-w-full min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm font-semibold leading-tight">
<span className="block max-w-full min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm font-normal leading-tight">
{title}
</span>
{thread.pinned ? (
<Pin
aria-label="Pinned"
className="size-3 shrink-0 text-muted-foreground"
/>
) : statusDotClass ? (
<span
aria-hidden="true"
className={cn("size-2 rounded-full", statusDotClass)}
/>
) : null}
<span className="flex shrink-0 items-center gap-1.5 text-[11px] text-muted-foreground">
{thread.pinned ? (
<Pin aria-label="Pinned" className="size-3" />
) : statusDotClass ? (
<span
aria-hidden="true"
className={cn("size-1.5 rounded-full", statusDotClass)}
/>
) : null}
<span>{thread.time}</span>
</span>
</button>
</HoverCardTrigger>
</ContextMenuTrigger>
@@ -600,10 +917,15 @@ function ThreadItem({
<div className="min-w-0 space-y-2">
<div className="truncate text-sm font-medium">{title}</div>
<div className="grid grid-cols-[72px_minmax(0,1fr)] gap-x-2 gap-y-1 text-xs">
{infoItems.map(([label, value]) => (
{infoItems.map(([label, value, fullValue]) => (
<div className="contents" key={label}>
<span className="text-muted-foreground">{label}</span>
<span className="min-w-0 truncate font-mono">{value}</span>
<span
className="min-w-0 truncate font-mono"
title={fullValue}
>
{value}
</span>
</div>
))}
</div>
@@ -0,0 +1,20 @@
import { cn } from "@/lib/utils";
export function ClineLogo({ className }: { className?: string }) {
return (
<span
aria-hidden="true"
className={cn("inline-block shrink-0 bg-current", className)}
style={{
maskImage: "url('/cline-logo-filled.svg')",
maskPosition: "center",
maskRepeat: "no-repeat",
maskSize: "contain",
WebkitMaskImage: "url('/cline-logo-filled.svg')",
WebkitMaskPosition: "center",
WebkitMaskRepeat: "no-repeat",
WebkitMaskSize: "contain",
}}
/>
);
}
@@ -9,36 +9,41 @@ interface Star {
delay: string;
duration: string;
opacity: number;
color: string;
}
// Big blurred gradient blobs that slowly drift/rotate to fake an aurora.
// Each entry is [positionClasses, gradient, animationDuration, animationDelay].
const BLOBS: Array<[string, string, string, string]> = [
[
"left-[-20%] bottom-[-40%] w-[70%] h-[80%]",
"radial-gradient(ellipse at center, oklch(0.55 0.2 278 / 0.55), transparent 70%)",
"16s",
"0s",
],
[
"left-[25%] bottom-[-50%] w-[60%] h-[90%]",
"radial-gradient(ellipse at center, oklch(0.65 0.19 200 / 0.4), transparent 70%)",
"22s",
"-6s",
],
[
"right-[-15%] bottom-[-40%] w-[65%] h-[85%]",
"radial-gradient(ellipse at center, oklch(0.6 0.18 310 / 0.5), transparent 70%)",
"19s",
"-12s",
],
[
"left-[10%] bottom-[-30%] w-[80%] h-[60%]",
"radial-gradient(ellipse at center, oklch(0.75 0.13 340 / 0.35), transparent 70%)",
"26s",
"-3s",
],
];
const BLOBS = [
{
id: "periwinkle-left",
position: "left-[-20%] bottom-[-40%] w-[70%] h-[80%]",
gradient:
"radial-gradient(ellipse at center, color-mix(in oklab, var(--brand-periwinkle) 64%, transparent), transparent 70%)",
duration: "11s",
delay: "0s",
reverse: false,
},
{
id: "violet-right",
position: "right-[-15%] bottom-[-40%] w-[65%] h-[85%]",
gradient:
"radial-gradient(ellipse at center, color-mix(in oklab, var(--brand-violet) 58%, transparent), transparent 70%)",
duration: "12.5s",
delay: "-12s",
reverse: true,
},
] as const;
function seededUnit(index: number, salt: number): number {
let value =
Math.imul(index + 1, 0x9e3779b1) ^ Math.imul(salt + 1, 0x85ebca6b);
value ^= value >>> 16;
value = Math.imul(value, 0x7feb352d);
value ^= value >>> 15;
value = Math.imul(value, 0x846ca68b);
value ^= value >>> 16;
return (value >>> 0) / 0x1_0000_0000;
}
/**
* A decorative aurora background built entirely from CSS: blurred gradient
@@ -48,47 +53,81 @@ const BLOBS: Array<[string, string, string, string]> = [
*
* Keyframes (`aurora-drift`, `aurora-twinkle`) live in app/globals.css.
*/
export function AuroraBackground({ starCount = 90 }: { starCount?: number }) {
// Random star field, generated once per mount.
export function AuroraBackground({ starCount = 48 }: { starCount?: number }) {
// The field is deterministic so server and browser markup always agree.
const stars = useMemo<Star[]>(
() =>
Array.from({ length: starCount }, () => {
Array.from({ length: starCount }, (_, index) => {
// Squared skew biases stars toward the bottom, where the glow lives.
const r = Math.random();
const r = seededUnit(index, 1);
const sizeRoll = seededUnit(index, 3);
return {
left: `${Math.random() * 100}%`,
left: `${seededUnit(index, 2) * 100}%`,
top: `${100 - (1 - r * r) * 45}%`,
size: Math.random() < 0.15 ? 3 : Math.random() < 0.5 ? 2 : 1,
delay: `${Math.random() * 4}s`,
duration: `${1.5 + Math.random() * 3.5}s`,
opacity: 0.3 + Math.random() * 0.6,
size: sizeRoll < 0.14 ? 4 : sizeRoll < 0.52 ? 3 : 2,
delay: `${seededUnit(index, 4) * -5}s`,
duration: `${3.5 + seededUnit(index, 5) * 3.5}s`,
opacity: 0.35 + seededUnit(index, 6) * 0.6,
color:
seededUnit(index, 7) > 0.78
? "var(--brand-cyan)"
: "color-mix(in oklab, white 92%, var(--brand-lilac))",
};
}),
[starCount],
);
return (
<div className="pointer-events-none absolute inset-0 overflow-hidden">
{BLOBS.map(([position, gradient, duration, delay], idx) => (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 overflow-hidden"
>
<div
className="aurora-horizon absolute inset-x-[-8%] bottom-[-3%] h-[40%] opacity-60 blur-[64px]"
style={{
background:
"linear-gradient(90deg, color-mix(in oklab, var(--brand-lilac) 58%, transparent), color-mix(in oklab, var(--brand-magenta) 62%, transparent) 42%, color-mix(in oklab, var(--brand-periwinkle) 72%, transparent) 78%, color-mix(in oklab, var(--brand-cyan) 58%, transparent))",
}}
/>
<div
className="aurora-current absolute bottom-[3%] left-[-45%] h-[30%] w-[125%] opacity-50 blur-[46px]"
style={{
animationDelay: "-2s",
animationDuration: "9s",
background:
"linear-gradient(105deg, transparent 12%, color-mix(in oklab, var(--brand-magenta) 66%, transparent) 38%, color-mix(in oklab, var(--brand-periwinkle) 72%, transparent) 58%, transparent 82%)",
}}
/>
<div
className="aurora-current aurora-current-reverse absolute bottom-[-5%] right-[-42%] h-[34%] w-[120%] opacity-45 blur-[52px]"
style={{
animationDelay: "-6s",
animationDuration: "12s",
background:
"linear-gradient(75deg, transparent 10%, color-mix(in oklab, var(--brand-cyan) 62%, transparent) 42%, color-mix(in oklab, var(--brand-violet) 70%, transparent) 64%, transparent 88%)",
}}
/>
{BLOBS.map((blob) => (
<div
key={`blob${idx}`}
className={`absolute blur-3xl animate-[aurora-drift_linear_infinite] ${position}`}
key={blob.id}
className={`aurora-motion absolute blur-[64px] ${blob.reverse ? "aurora-motion-reverse" : ""} ${blob.position}`}
style={{
background: gradient,
animationDuration: duration,
animationDelay: delay,
background: blob.gradient,
animationDuration: blob.duration,
animationDelay: blob.delay,
}}
/>
))}
{stars.map((s, idx) => (
{stars.map((s) => (
<span
key={`star${idx}`}
className="absolute rounded-none bg-[#b8f3ee] animate-[aurora-twinkle_ease-in-out_infinite]"
key={`${s.left}-${s.top}`}
className="aurora-star absolute rounded-[1px]"
style={{
left: s.left,
top: s.top,
width: s.size,
height: s.size,
background: s.color,
opacity: s.opacity,
animationDelay: s.delay,
animationDuration: s.duration,
@@ -0,0 +1,54 @@
import { describe, expect, test } from "vitest";
import {
markdownCodeHighlighter,
SUPPORTED_MARKDOWN_LANGUAGES,
} from "./markdown-highlighter";
function highlight(code: string, language: "typescript") {
return new Promise<
NonNullable<ReturnType<typeof markdownCodeHighlighter.highlight>>
>((resolve) => {
const immediate = markdownCodeHighlighter.highlight(
{ code, language, themes: ["github-light", "github-dark"] },
resolve,
);
if (immediate) resolve(immediate);
});
}
describe("markdownCodeHighlighter", () => {
test("keeps the syntax bundle to the shared supported language set", () => {
expect(SUPPORTED_MARKDOWN_LANGUAGES).toEqual([
"bash",
"css",
"diff",
"html",
"javascript",
"json",
"jsonc",
"jsx",
"markdown",
"python",
"shellscript",
"tsx",
"typescript",
"yaml",
]);
expect(markdownCodeHighlighter.supportsLanguage("ts")).toBe(true);
expect(markdownCodeHighlighter.supportsLanguage("rust")).toBe(false);
});
test("loads a supported grammar and returns themed tokens", async () => {
const result = await highlight("const answer: number = 42;", "typescript");
expect(
result.tokens
.flat()
.map((token) => token.content)
.join(""),
).toBe("const answer: number = 42;");
expect(result.tokens.flat().some((token) => token.htmlStyle?.color)).toBe(
true,
);
});
});
@@ -0,0 +1,237 @@
import type {
HighlighterCore,
LanguageRegistration,
ThemeRegistration,
} from "shiki/core";
import type { CodeHighlighterPlugin } from "streamdown";
type HighlightResult = NonNullable<
ReturnType<CodeHighlighterPlugin["highlight"]>
>;
export const SUPPORTED_MARKDOWN_LANGUAGES = [
"bash",
"css",
"diff",
"html",
"javascript",
"json",
"jsonc",
"jsx",
"markdown",
"python",
"shellscript",
"tsx",
"typescript",
"yaml",
] as const;
type SupportedMarkdownLanguage = (typeof SUPPORTED_MARKDOWN_LANGUAGES)[number];
const SUPPORTED_LANGUAGE_SET = new Set<string>(SUPPORTED_MARKDOWN_LANGUAGES);
const LANGUAGE_ALIASES: Record<string, SupportedMarkdownLanguage> = {
cjs: "javascript",
console: "shellscript",
htm: "html",
js: "javascript",
json5: "jsonc",
md: "markdown",
mjs: "javascript",
py: "python",
sh: "shellscript",
shell: "shellscript",
ts: "typescript",
yml: "yaml",
};
const LANGUAGE_LOADERS: Record<
SupportedMarkdownLanguage,
() => Promise<LanguageRegistration[]>
> = {
bash: () => import("@shikijs/langs/bash").then((module) => module.default),
css: () => import("@shikijs/langs/css").then((module) => module.default),
diff: () => import("@shikijs/langs/diff").then((module) => module.default),
html: () => import("@shikijs/langs/html").then((module) => module.default),
javascript: () =>
import("@shikijs/langs/javascript").then((module) => module.default),
json: () => import("@shikijs/langs/json").then((module) => module.default),
jsonc: () => import("@shikijs/langs/jsonc").then((module) => module.default),
jsx: () => import("@shikijs/langs/jsx").then((module) => module.default),
markdown: () =>
import("@shikijs/langs/markdown").then((module) => module.default),
python: () =>
import("@shikijs/langs/python").then((module) => module.default),
shellscript: () =>
import("@shikijs/langs/shellscript").then((module) => module.default),
tsx: () => import("@shikijs/langs/tsx").then((module) => module.default),
typescript: () =>
import("@shikijs/langs/typescript").then((module) => module.default),
yaml: () => import("@shikijs/langs/yaml").then((module) => module.default),
};
const LIGHT_THEME = "github-light";
const DARK_THEME = "github-dark";
const MAX_CACHED_RESULTS = 256;
let highlighterPromise: Promise<HighlighterCore> | undefined;
let themesPromise: Promise<void> | undefined;
const languagePromises = new Map<SupportedMarkdownLanguage, Promise<void>>();
const resultCache = new Map<string, HighlightResult>();
const pendingHighlights = new Map<string, Promise<HighlightResult>>();
const loggedFailures = new Set<string>();
function normalizeLanguage(language: string): SupportedMarkdownLanguage | null {
const normalized = language.trim().toLowerCase();
if (!normalized) return null;
const aliased = LANGUAGE_ALIASES[normalized] ?? normalized;
return SUPPORTED_LANGUAGE_SET.has(aliased)
? (aliased as SupportedMarkdownLanguage)
: null;
}
function getHighlighter(): Promise<HighlighterCore> {
if (!highlighterPromise) {
highlighterPromise = Promise.all([
import("shiki/core"),
import("shiki/engine/javascript"),
]).then(([core, engine]) =>
core.createHighlighterCore({
engine: engine.createJavaScriptRegexEngine({ forgiving: true }),
}),
);
}
return highlighterPromise;
}
function ensureThemes(highlighter: HighlighterCore): Promise<void> {
if (!themesPromise) {
themesPromise = Promise.all([
import("@shikijs/themes/github-light").then((module) => module.default),
import("@shikijs/themes/github-dark").then((module) => module.default),
]).then((themes: ThemeRegistration[]) => highlighter.loadTheme(...themes));
}
return themesPromise;
}
function ensureLanguage(
highlighter: HighlighterCore,
language: SupportedMarkdownLanguage,
): Promise<void> {
const existing = languagePromises.get(language);
if (existing) return existing;
const loading = LANGUAGE_LOADERS[language]().then((registrations) =>
highlighter.loadLanguage(...registrations),
);
languagePromises.set(language, loading);
return loading;
}
function rawHighlight(code: string): HighlightResult {
return {
bg: "transparent",
fg: "inherit",
tokens: code.split("\n").map((line) =>
line
? [
{
bgColor: "transparent",
color: "inherit",
content: line,
htmlStyle: {},
offset: 0,
},
]
: [],
),
};
}
function cacheResult(key: string, result: HighlightResult): void {
resultCache.delete(key);
resultCache.set(key, result);
if (resultCache.size <= MAX_CACHED_RESULTS) return;
const oldestKey = resultCache.keys().next().value;
if (oldestKey !== undefined) resultCache.delete(oldestKey);
}
function reportHighlightFailure(
language: SupportedMarkdownLanguage,
error: unknown,
): void {
if (loggedFailures.has(language)) return;
loggedFailures.add(language);
console.warn(
`Syntax highlighting unavailable for ${language}; rendering plain code.`,
error,
);
}
function loadHighlight(
code: string,
language: SupportedMarkdownLanguage,
): Promise<HighlightResult> {
const cacheKey = `${language}\0${code}`;
const cached = resultCache.get(cacheKey);
if (cached) return Promise.resolve(cached);
const pending = pendingHighlights.get(cacheKey);
if (pending) return pending;
const loading = getHighlighter()
.then(async (highlighter) => {
await ensureThemes(highlighter);
await ensureLanguage(highlighter, language);
const result = highlighter.codeToTokens(code, {
lang: language,
themes: {
dark: DARK_THEME,
light: LIGHT_THEME,
},
});
return {
bg: result.bg,
fg: result.fg,
rootStyle: result.rootStyle,
tokens: result.tokens,
} satisfies HighlightResult;
})
.catch((error: unknown) => {
reportHighlightFailure(language, error);
return rawHighlight(code);
})
.then((result) => {
cacheResult(cacheKey, result);
return result;
})
.finally(() => {
pendingHighlights.delete(cacheKey);
});
pendingHighlights.set(cacheKey, loading);
return loading;
}
export const markdownCodeHighlighter = {
getSupportedLanguages: () => [...SUPPORTED_MARKDOWN_LANGUAGES],
getThemes: () => [LIGHT_THEME, DARK_THEME],
highlight: ({ code, language }, callback) => {
const normalizedLanguage = normalizeLanguage(language);
if (!normalizedLanguage) return rawHighlight(code);
const cacheKey = `${normalizedLanguage}\0${code}`;
const cached = resultCache.get(cacheKey);
if (cached) return cached;
const loading = loadHighlight(code, normalizedLanguage);
if (callback) {
void loading.then((result) => callback(result));
}
return null;
},
name: "shiki",
supportsLanguage: (language) => normalizeLanguage(language) !== null,
type: "code-highlighter",
} satisfies CodeHighlighterPlugin;
@@ -0,0 +1,208 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { MarkdownLinkSafetyModal, MemoizedMarkdown } from "./markdown";
const originalClipboard = Object.getOwnPropertyDescriptor(
navigator,
"clipboard",
);
let writeText: ReturnType<typeof vi.fn>;
let openWindow: ReturnType<typeof vi.fn<typeof window.open>>;
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
openWindow = vi.fn<typeof window.open>(() => null);
vi.spyOn(window, "open").mockImplementation(openWindow);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.restoreAllMocks();
if (originalClipboard) {
Object.defineProperty(navigator, "clipboard", originalClipboard);
} else {
Reflect.deleteProperty(navigator, "clipboard");
}
});
async function renderMarkdown(
props: Parameters<typeof MemoizedMarkdown>[0],
): Promise<void> {
await act(async () => root.render(<MemoizedMarkdown {...props} />));
}
async function click(element: Element): Promise<void> {
await act(async () => {
element.dispatchEvent(
new MouseEvent("click", { bubbles: true, cancelable: true }),
);
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
async function dispatchMouseEvent(
element: Element,
type: "auxclick" | "contextmenu",
button: number,
): Promise<void> {
await act(async () => {
element.dispatchEvent(
new MouseEvent(type, { bubbles: true, button, cancelable: true }),
);
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
function getButton(label: string): HTMLButtonElement {
const button = [
...document.querySelectorAll<HTMLButtonElement>("button"),
].find((candidate) => candidate.textContent?.trim() === label);
expect(button).toBeDefined();
return button as HTMLButtonElement;
}
describe("MemoizedMarkdown interactions", () => {
test("confirms and closes an external link dialog exactly once", async () => {
const onClose = vi.fn();
const onConfirm = vi.fn();
await act(async () => {
root.render(
<MarkdownLinkSafetyModal
isOpen
onClose={onClose}
onConfirm={onConfirm}
url="https://example.com/review"
/>,
);
});
await click(getButton("Open link"));
await vi.waitFor(() => {
expect(onConfirm).toHaveBeenCalledOnce();
expect(onClose).toHaveBeenCalledOnce();
});
});
test("requires confirmation before opening an external link", async () => {
const url = "https://example.com/review?source=cline";
await renderMarkdown({ content: `[Review docs](${url})` });
const link = await vi.waitFor(() => {
const renderedLink = container.querySelector<HTMLElement>(
'[data-streamdown="link"]',
);
expect(renderedLink).not.toBeNull();
return renderedLink as HTMLElement;
});
expect(link.tagName).toBe("A");
expect(link.getAttribute("href")).toBe("#confirm-external-link");
await dispatchMouseEvent(link, "contextmenu", 2);
expect(openWindow).not.toHaveBeenCalled();
expect(document.querySelector('[role="alertdialog"]')).toBeNull();
await dispatchMouseEvent(link, "auxclick", 1);
await vi.waitFor(() => {
expect(document.querySelector('[role="alertdialog"]')).not.toBeNull();
});
await click(getButton("Cancel"));
await click(link);
await vi.waitFor(() => {
expect(document.querySelector('[role="alertdialog"]')).not.toBeNull();
expect(document.body.textContent).toContain(url);
});
await click(getButton("Cancel"));
await vi.waitFor(() => {
expect(document.querySelector('[role="alertdialog"]')).toBeNull();
});
expect(openWindow).not.toHaveBeenCalled();
await click(link);
await vi.waitFor(() => {
expect(document.querySelector('[role="alertdialog"]')).not.toBeNull();
});
await click(getButton("Open link"));
expect(openWindow).toHaveBeenCalledTimes(1);
expect(openWindow).toHaveBeenCalledWith(url, "_blank", "noreferrer");
await vi.waitFor(() => {
expect(document.querySelector('[role="alertdialog"]')).toBeNull();
});
});
test("keeps same-document links navigable without a confirmation", async () => {
await renderMarkdown({ content: "[Details](#details)" });
const link = container.querySelector<HTMLAnchorElement>(
'[data-streamdown="link"]',
);
expect(link?.getAttribute("href")).toBe("#details");
await click(link as HTMLAnchorElement);
expect(document.querySelector('[role="alertdialog"]')).toBeNull();
expect(openWindow).not.toHaveBeenCalled();
});
test("copies fenced code through the Clipboard API", async () => {
const source = "const answer = 42;";
await renderMarkdown({
content: `\`\`\`text\n${source}\n\`\`\``,
});
const copyButton = await vi.waitFor(() => {
const button = container.querySelector<HTMLButtonElement>(
'[data-streamdown="code-block-copy-button"]',
);
expect(button).not.toBeNull();
return button as HTMLButtonElement;
});
await click(copyButton);
await vi.waitFor(() => {
expect(writeText).toHaveBeenCalledWith(`${source}\n`);
});
});
test("rerenders incomplete streaming Markdown as completed static Markdown", async () => {
await renderMarkdown({
content: "```text\nconst answer =",
streaming: true,
});
await vi.waitFor(() => {
const codeBlock = container.querySelector(
'[data-streamdown="code-block"]',
);
expect(codeBlock?.getAttribute("data-incomplete")).toBe("true");
});
await renderMarkdown({
content: "```text\nconst answer = 42;\n```\n\nCompleted.",
streaming: false,
});
await vi.waitFor(() => {
const codeBlock = container.querySelector(
'[data-streamdown="code-block"]',
);
expect(codeBlock).not.toBeNull();
expect(codeBlock?.getAttribute("data-incomplete")).toBeNull();
expect(container.textContent).toContain("const answer = 42;");
expect(container.textContent).toContain("Completed.");
});
});
});
@@ -0,0 +1,105 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, test } from "vitest";
import { MemoizedMarkdown } from "./markdown";
describe("MemoizedMarkdown", () => {
test("renders structured GFM content and blocks remote images", () => {
const html = renderToStaticMarkup(
<MemoizedMarkdown
content={`# Review
| Surface | Status |
| --- | --- |
| Code | Ready |
\`\`\`typescript
const ready = true;
\`\`\`
![remote image](https://example.com/tracker.png)`}
/>,
);
expect(html).toContain('data-streamdown="heading-1"');
expect(html).toContain('data-streamdown="table-wrapper"');
expect(html).toContain('data-streamdown="code-block"');
expect(html).toContain('data-streamdown="blocked-image"');
expect(html).toContain("External image blocked for privacy");
expect(html).not.toContain("<img");
});
test("renders app-local images", () => {
const html = renderToStaticMarkup(
<MemoizedMarkdown
content={`![local](/images/local.png)
![second local](/images/second-local.png)`}
/>,
);
expect(html.match(/<img/g)).toHaveLength(2);
expect(html).toContain('src="/images/local.png"');
expect(html).toContain('src="/images/second-local.png"');
expect(html).not.toContain('data-streamdown="blocked-image"');
});
test("repairs an unfinished code fence while streaming", () => {
const html = renderToStaticMarkup(
<MemoizedMarkdown
content={"```typescript\nconst stillStreaming = true;"}
streaming
/>,
);
expect(html).toContain('data-streamdown="code-block"');
expect(html).toContain("stillStreaming");
});
test("routes external links through confirmation controls", () => {
const html = renderToStaticMarkup(
<MemoizedMarkdown content="[Review](https://example.com/review)" />,
);
expect(html).toContain('data-streamdown="link"');
expect(html).toContain("Review");
expect(html).toContain('href="#confirm-external-link"');
expect(html).toContain('aria-haspopup="dialog"');
expect(html).not.toContain('href="https://example.com/review"');
});
test("leaves app-local and fragment links navigable", () => {
const html = renderToStaticMarkup(
<MemoizedMarkdown content="[Details](#details) [Home](/)" />,
);
expect(html).toContain('href="#details"');
expect(html).toContain('href="/"');
expect(html).not.toContain('aria-haspopup="dialog"');
});
test("blocks scheme-less hostnames before they reach link rendering", () => {
const html = renderToStaticMarkup(
<MemoizedMarkdown content="[Review](example.com/path)" />,
);
expect(html).toContain("Review");
expect(html).toContain("blocked");
expect(html).not.toContain("<a");
expect(html).not.toContain('data-streamdown="link"');
});
test("does not expose unsafe script URLs or raw scripts", () => {
const html = renderToStaticMarkup(
<MemoizedMarkdown
content={
'[unsafe](javascript:alert("no"))\n\n![embedded](data:image/png;base64,iVBORw0KGgo=)\n\n![file](file:///etc/passwd)\n\n<script>window.pwned = true</script>'
}
/>,
);
expect(html).not.toContain("javascript:");
expect(html).not.toContain("data:image");
expect(html).not.toContain("file:///etc/passwd");
expect(html).not.toContain("<script");
});
});
@@ -1,45 +1,216 @@
import { marked } from "marked";
import { memo, useMemo } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { cjk } from "@streamdown/cjk";
import type { ComponentProps, MouseEvent } from "react";
import { memo, useState } from "react";
import {
type Components,
type ControlsConfig,
type ExtraProps,
type LinkSafetyModalProps,
Streamdown,
} from "streamdown";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "./alert-dialog";
import { markdownCodeHighlighter } from "./markdown-highlighter";
const MemoizedMarkdownBlock = memo(
({ content }: { content: string }) => {
return (
<div className="markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
);
},
(prevProps, nextProps) => {
if (prevProps.content !== nextProps.content) return false;
return true;
},
);
const streamdownPlugins = { cjk, code: markdownCodeHighlighter };
const streamdownControls = {
code: { copy: true, download: false },
mermaid: false,
table: false,
} satisfies ControlsConfig;
MemoizedMarkdownBlock.displayName = "MemoizedMarkdownBlock";
export function parseMarkdownIntoBlocks(markdown: string): string[] {
const tokens = marked.lexer(markdown);
return tokens.map((token) => token.raw);
export function MarkdownLinkSafetyModal({
isOpen,
onClose,
onConfirm,
url,
}: LinkSafetyModalProps) {
return (
<AlertDialog
onOpenChange={(open) => {
if (!open) onClose();
}}
open={isOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Open external link?</AlertDialogTitle>
<AlertDialogDescription>
You are about to leave Cline and visit this address.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="max-h-32 overflow-y-auto wrap-break-word rounded-md bg-muted p-3 font-mono text-sm">
{url}
</div>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>Open link</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
export const MemoizedMarkdown = memo(
({ content, id }: { content: string; id: string }) => {
const blocks = useMemo(() => parseMarkdownIntoBlocks(content), [content]);
const occurrences = new Map<string, number>();
type MarkdownLinkProps = ComponentProps<"a"> & ExtraProps;
return blocks.map((block) => {
const occurrence = (occurrences.get(block) ?? 0) + 1;
occurrences.set(block, occurrence);
return (
<MemoizedMarkdownBlock
content={block}
key={`${id}-block_${occurrence}-${block}`}
/>
);
});
},
function SafeMarkdownLink({
children,
className,
href,
node: _node,
rel: _rel,
target: _target,
title,
...props
}: MarkdownLinkProps) {
const [isOpen, setIsOpen] = useState(false);
const isIncomplete = href === "streamdown:incomplete-link";
const url = isIncomplete ? undefined : href;
if (!url) {
return (
<span
className={className}
data-incomplete={isIncomplete}
data-streamdown="link"
>
{children}
</span>
);
}
const isAppLink =
url.startsWith("#") ||
(url.startsWith("/") && !url.startsWith("//")) ||
url.startsWith("./") ||
url.startsWith("../") ||
(!/^[a-z][a-z\d+.-]*:/i.test(url) && !url.startsWith("//"));
if (isAppLink) {
return (
<a
{...props}
className={`wrap-anywhere font-medium text-primary underline ${className ?? ""}`}
data-streamdown="link"
href={url}
title={title}
>
{children}
</a>
);
}
const openConfirmation = (event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
setIsOpen(true);
};
const confirmMiddleClick = (event: MouseEvent<HTMLAnchorElement>) => {
if (event.button === 1) openConfirmation(event);
};
return (
<>
{/* biome-ignore lint/a11y/useValidAnchor: External Markdown retains native link semantics while confirmation withholds the live destination. */}
<a
{...props}
aria-haspopup="dialog"
className={`wrap-anywhere font-medium text-primary underline ${className ?? ""}`}
data-streamdown="link"
href="#confirm-external-link"
onAuxClick={confirmMiddleClick}
onClick={openConfirmation}
title={title ?? url}
>
{children}
</a>
<MarkdownLinkSafetyModal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
onConfirm={() => window.open(url, "_blank", "noreferrer")}
url={url}
/>
</>
);
}
type MarkdownImageProps =
| (ComponentProps<"img"> & ExtraProps)
| (Record<string, unknown> & ExtraProps);
const remoteImagePattern = /^(?:https?:)?[\\/]{2}/i;
function isSafeMarkdownImageSource(source: string): boolean {
const normalized = source.trim();
if (!normalized || remoteImagePattern.test(normalized)) return false;
// Streamdown's hardened URL policy accepts app-root paths. Keeping the rule
// this narrow prevents model-authored Markdown from making hidden requests.
return normalized.startsWith("/");
}
function MarkdownImage({ alt, height, src, title, width }: MarkdownImageProps) {
const label = typeof alt === "string" ? alt.trim() : "";
const source = typeof src === "string" ? src.trim() : "";
if (source && isSafeMarkdownImageSource(source)) {
return (
// biome-ignore lint/performance/noImgElement: Markdown can reference runtime app assets that Next Image cannot statically optimize.
<img
alt={label}
className="my-4 max-w-full rounded-lg"
data-streamdown="image"
height={typeof height === "number" ? height : undefined}
loading="lazy"
src={source}
title={typeof title === "string" ? title : undefined}
width={typeof width === "number" ? width : undefined}
/>
);
}
return (
<span data-streamdown="blocked-image" role="note">
External image blocked for privacy{label ? `: ${label}` : ""}
</span>
);
}
const markdownComponents = {
a: SafeMarkdownLink,
img: MarkdownImage,
} satisfies Components;
export const MemoizedMarkdown = memo(
({
content,
streaming = false,
}: {
content: string;
streaming?: boolean;
}) => (
<Streamdown
className="cline-markdown"
components={markdownComponents}
controls={streamdownControls}
dir="auto"
isAnimating={streaming}
lineNumbers
mode={streaming ? "streaming" : "static"}
normalizeHtmlIndentation
parseIncompleteMarkdown={streaming}
plugins={streamdownPlugins}
>
{content}
</Streamdown>
),
);
MemoizedMarkdown.displayName = "MemoizedMarkdown";
@@ -26,7 +26,7 @@ import { cn } from "@/lib/utils";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = 256;
const SIDEBAR_WIDTH = 240;
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
@@ -26,6 +26,7 @@ function Slider({
return (
<SliderPrimitive.Root
data-interactive=""
data-slot="slider"
defaultValue={defaultValue}
value={value}
@@ -0,0 +1,209 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WorkspaceProvider } from "@/contexts/workspace-context";
import type { ChatSessionStatus } from "@/lib/chat-schema";
import { ChatInputBar } from "./chat-input-bar";
const { loadProviderModelCatalogMock, loadProviderModelsMock } = vi.hoisted(
() => ({
loadProviderModelCatalogMock: vi.fn(),
loadProviderModelsMock: vi.fn(),
}),
);
vi.mock("@/lib/provider-model-catalog", () => ({
loadProviderModelCatalog: loadProviderModelCatalogMock,
loadProviderModels: loadProviderModelsMock,
}));
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
loadProviderModelCatalogMock.mockReset().mockResolvedValue({
providers: [],
enabledProviderIds: ["cline"],
providerModels: { cline: ["test-model"] },
providerReasoningModels: { cline: [] },
});
loadProviderModelsMock.mockReset().mockResolvedValue([]);
HTMLElement.prototype.scrollIntoView = vi.fn();
HTMLElement.prototype.hasPointerCapture = vi.fn(() => false);
HTMLElement.prototype.setPointerCapture = vi.fn();
HTMLElement.prototype.releasePointerCapture = vi.fn();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.restoreAllMocks();
});
describe("ChatInputBar", () => {
it("preserves an explicit High selection across capability and status updates", async () => {
const onReasoningChange = vi.fn();
const render = async (status: ChatSessionStatus) => {
await act(async () => {
root.render(
<WorkspaceProvider
value={{
workspaceRoot: "/workspace/cline",
workspaces: ["/workspace/cline"],
listWorkspaces: vi.fn(async () => ["/workspace/cline"]),
refreshWorkspaces: vi.fn(async () => undefined),
switchWorkspace: vi.fn(async () => true),
pickWorkspaceDirectory: vi.fn(async () => null),
}}
>
<ChatInputBar
attachments={[]}
gitBranch="main"
mode="act"
model="test-model"
onAbort={vi.fn()}
onAttachFiles={vi.fn()}
onEditPromptInQueue={vi.fn()}
onListGitBranches={vi.fn(async () => ({
current: "main",
branches: ["main"],
}))}
onModeToggle={vi.fn()}
onModelChange={vi.fn()}
onPromptInputChange={vi.fn()}
onProviderChange={vi.fn()}
onReasoningChange={onReasoningChange}
onRemoveAttachment={vi.fn()}
onSend={vi.fn()}
onSteerPromptInQueue={vi.fn()}
onSwitchGitBranch={vi.fn(async () => true)}
onUndoPromptInQueue={vi.fn()}
promptInput=""
promptsInQueue={[]}
provider="cline"
reasoningEffort="high"
status={status}
summary={{ toolCalls: 0, tokensIn: 0, tokensOut: 0 }}
thinking
/>
</WorkspaceProvider>,
);
await Promise.resolve();
});
};
await render("idle");
await vi.waitFor(() => {
const trigger = container.querySelector<HTMLButtonElement>(
'[aria-label="Thinking level"]',
);
expect(trigger?.textContent).toContain("High");
expect(trigger?.disabled).toBe(true);
});
await render("starting");
expect(container.querySelector('[aria-label="Stop agent"]')).toBeNull();
await render("running");
expect(container.querySelector('[aria-label="Stop agent"]')).not.toBeNull();
expect(onReasoningChange).not.toHaveBeenCalled();
const workspaceTrigger = container.querySelector("#git-branch-btn");
expect(workspaceTrigger?.parentElement?.parentElement?.className).toContain(
"overflow-visible",
);
expect(
workspaceTrigger?.parentElement?.parentElement?.className,
).not.toContain("truncate");
});
it("selects High from the supported model thinking menu", async () => {
loadProviderModelCatalogMock.mockResolvedValue({
providers: [],
enabledProviderIds: ["cline"],
providerModels: { cline: ["test-model"] },
providerReasoningModels: { cline: ["test-model"] },
});
const onReasoningChange = vi.fn();
await act(async () => {
root.render(
<WorkspaceProvider
value={{
workspaceRoot: "/workspace/cline",
workspaces: ["/workspace/cline"],
listWorkspaces: vi.fn(async () => ["/workspace/cline"]),
refreshWorkspaces: vi.fn(async () => undefined),
switchWorkspace: vi.fn(async () => true),
pickWorkspaceDirectory: vi.fn(async () => null),
}}
>
<ChatInputBar
attachments={[]}
gitBranch="main"
mode="act"
model="test-model"
onAbort={vi.fn()}
onAttachFiles={vi.fn()}
onEditPromptInQueue={vi.fn()}
onListGitBranches={vi.fn(async () => ({
current: "main",
branches: ["main"],
}))}
onModeToggle={vi.fn()}
onModelChange={vi.fn()}
onPromptInputChange={vi.fn()}
onProviderChange={vi.fn()}
onReasoningChange={onReasoningChange}
onRemoveAttachment={vi.fn()}
onSend={vi.fn()}
onSteerPromptInQueue={vi.fn()}
onSwitchGitBranch={vi.fn(async () => true)}
onUndoPromptInQueue={vi.fn()}
promptInput=""
promptsInQueue={[]}
provider="cline"
reasoningEffort="low"
status="idle"
summary={{ toolCalls: 0, tokensIn: 0, tokensOut: 0 }}
thinking
/>
</WorkspaceProvider>,
);
});
const trigger = await vi.waitFor(() => {
const element = container.querySelector<HTMLButtonElement>(
'[aria-label="Thinking level"]',
);
expect(element?.disabled).toBe(false);
return element as HTMLButtonElement;
});
await act(async () => {
trigger.dispatchEvent(
new MouseEvent("pointerdown", { bubbles: true, cancelable: true }),
);
trigger.click();
});
const highOption = await vi.waitFor(() => {
const element = [
...document.querySelectorAll<HTMLElement>('[role="option"]'),
].find((option) => option.textContent?.includes("High"));
expect(element).toBeDefined();
return element as HTMLElement;
});
await act(async () => {
highOption.dispatchEvent(
new MouseEvent("pointerup", { bubbles: true, cancelable: true }),
);
highOption.click();
});
expect(onReasoningChange).toHaveBeenCalledWith({
thinking: true,
reasoningEffort: "high",
});
});
});
@@ -7,10 +7,8 @@ import {
ChevronDown,
CircleStop,
Coins,
Mic,
Paperclip,
Pencil,
RotateCcw,
Undo2,
X,
} from "lucide-react";
@@ -23,6 +21,13 @@ import {
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useWorkspace } from "@/contexts/workspace-context";
import type { PromptInQueue } from "@/hooks/chat-session/types";
import type { ChatSessionConfig, ChatSessionStatus } from "@/lib/chat-schema";
@@ -188,6 +193,7 @@ function getActiveSlash(input: string, cursor: number): ActiveSlash | null {
}
type ChatInputBarProps = {
variant?: "conversation" | "welcome";
status: ChatSessionStatus;
provider: string;
model: string;
@@ -203,12 +209,10 @@ type ChatInputBarProps = {
onReasoningChange: (
next: Pick<ChatSessionConfig, "thinking" | "reasoningEffort">,
) => void;
onRefreshGitBranch: () => void;
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
onSwitchGitBranch: (branch: string) => Promise<boolean>;
onSend: () => void;
onAbort: () => void;
onReset: () => void;
promptsInQueue: PromptInQueue[];
attachments: Array<{ id: string; name: string; isImage: boolean }>;
onAttachFiles: (files: File[]) => void;
@@ -227,6 +231,7 @@ type ChatInputBarProps = {
};
export function ChatInputBar({
variant = "conversation",
status,
provider,
model,
@@ -240,12 +245,10 @@ export function ChatInputBar({
onModelChange,
onModeToggle,
onReasoningChange,
onRefreshGitBranch,
onListGitBranches,
onSwitchGitBranch,
onSend,
onAbort,
onReset,
promptsInQueue,
attachments,
onAttachFiles,
@@ -264,10 +267,33 @@ export function ChatInputBar({
} = useWorkspace();
const isBusy =
status === "starting" || status === "running" || status === "stopping";
const canAbort = status === "running" || status === "stopping";
const hasDraft = promptInput.trim().length > 0 || attachments.length > 0;
const [modelSupportsReasoning, setModelSupportsReasoning] = useState(() =>
hasReasoningCapability(FALLBACK_PROVIDER_REASONING_MODELS, provider, model),
const [reasoningCapability, setReasoningCapability] = useState<{
provider: string;
model: string;
supported: boolean | null;
} | null>(null);
const modelSupportsReasoning =
reasoningCapability?.provider === provider &&
reasoningCapability.model === model
? reasoningCapability.supported
: null;
const handleModelSupportsReasoningChange = useCallback(
(supported: boolean | null) => {
setReasoningCapability((current) => {
if (
current?.provider === provider &&
current.model === model &&
current.supported === supported
) {
return current;
}
return { provider, model, supported };
});
},
[model, provider],
);
const canSend = hasDraft;
const fileInputRef = useRef<HTMLInputElement | null>(null);
@@ -312,32 +338,50 @@ export function ChatInputBar({
() => resolveEffortIndex(thinking, reasoningEffort),
[reasoningEffort, thinking],
);
const effortLabel = modelSupportsReasoning
? (EFFORT_LEVELS[effortIndex]?.label ?? "Low")
: "None";
const handleEffortCycle = useCallback(() => {
if (!modelSupportsReasoning) {
return;
}
const nextOption = EFFORT_LEVELS[(effortIndex + 1) % EFFORT_LEVELS.length];
if (!nextOption) {
return;
}
onReasoningChange(buildReasoningConfig(nextOption));
}, [effortIndex, modelSupportsReasoning, onReasoningChange]);
const hasExplicitReasoningSelection =
thinking !== undefined || reasoningEffort !== undefined;
const effortLabel =
!hasExplicitReasoningSelection && modelSupportsReasoning === null
? "Reasoning"
: !hasExplicitReasoningSelection && modelSupportsReasoning === false
? "None"
: (EFFORT_LEVELS[effortIndex]?.label ?? "Reasoning");
const handleEffortChange = useCallback(
(value: string) => {
if (modelSupportsReasoning !== true) {
return;
}
const nextOption = EFFORT_LEVELS.find((option) => option.value === value);
if (nextOption) {
onReasoningChange(buildReasoningConfig(nextOption));
}
},
[modelSupportsReasoning, onReasoningChange],
);
useEffect(() => {
if (!modelSupportsReasoning) {
if (thinking !== false || reasoningEffort !== undefined) {
onReasoningChange({ thinking: false, reasoningEffort: undefined });
}
return;
}
if (thinking === undefined && reasoningEffort === undefined) {
if (
modelSupportsReasoning === true &&
thinking === undefined &&
reasoningEffort === undefined
) {
onReasoningChange(buildReasoningConfig(DEFAULT_REASONING_EFFORT));
}
}, [modelSupportsReasoning, onReasoningChange, reasoningEffort, thinking]);
useEffect(() => {
const input = promptInputRef.current;
if (!input) return;
if (
variant === "conversation" ||
(variant === "welcome" &&
promptInput.trim().length > 0 &&
document.activeElement !== input)
) {
input.focus();
}
}, [promptInput, variant]);
const startQueuedPromptEdit = useCallback((item: PromptInQueue) => {
setEditingQueuedPromptId(item.id);
setEditingQueuedPromptValue(item.prompt);
@@ -589,9 +633,16 @@ export function ChatInputBar({
);
return (
<div className="border-t border-border bg-card">
<div
className={cn(
"bg-card",
variant === "welcome"
? "overflow-visible rounded-xl border border-border/90 bg-card/90 shadow-[0_24px_80px_-56px_color-mix(in_oklab,var(--primary)_72%,transparent)] backdrop-blur-md"
: "border-t border-border bg-card/95 backdrop-blur-sm",
)}
>
{/* Input area */}
<div className="px-4 py-3">
<div className={cn("px-4 py-3", variant === "welcome" && "pb-2 pt-4")}>
{promptsInQueue.length > 0 && (
<div className="mb-3 rounded-lg border border-border bg-background/70 p-2">
<div className="mb-2 flex items-center justify-between gap-2">
@@ -734,7 +785,11 @@ export function ChatInputBar({
)}
<div className="relative">
{slashOpen && (
<div className="absolute inset-x-0 bottom-full z-50 mb-1 max-h-56 overflow-y-auto rounded-lg border border-border bg-popover p-1 shadow-xl">
<div
className="absolute inset-x-0 bottom-full z-50 mb-1 max-h-56 overflow-y-auto rounded-lg border border-border bg-popover p-1 shadow-xl"
id="slash-command-suggestions"
role="listbox"
>
{filteredSlashCommands.length === 0 ? (
<div className="px-3 py-2 text-xs text-muted-foreground">
{slashLoading
@@ -745,6 +800,7 @@ export function ChatInputBar({
<>
{filteredSlashCommands.map((cmd, index) => (
<button
aria-selected={index === slashSelectedIndex}
className={cn(
"flex w-full flex-col rounded-md px-3 py-2 text-left text-xs transition-colors",
index === slashSelectedIndex
@@ -752,7 +808,9 @@ export function ChatInputBar({
: "text-muted-foreground hover:bg-accent hover:text-foreground",
)}
key={cmd.name}
id={`slash-command-option-${index}`}
onClick={() => insertSlashCommandItem(cmd.name)}
role="option"
type="button"
>
<span className="font-medium">/{cmd.name}</span>
@@ -773,7 +831,11 @@ export function ChatInputBar({
</div>
)}
{mentionOpen && (
<div className="absolute inset-x-0 bottom-full z-50 mb-1 max-h-56 overflow-y-auto rounded-lg border border-border bg-popover p-1 shadow-xl">
<div
className="absolute inset-x-0 bottom-full z-50 mb-1 max-h-56 overflow-y-auto rounded-lg border border-border bg-popover p-1 shadow-xl"
id="mention-file-suggestions"
role="listbox"
>
{mentionFiles.length === 0 ? (
<div className="px-3 py-2 text-xs text-muted-foreground">
{mentionLoading ? "Searching files..." : "No matching files"}
@@ -782,6 +844,7 @@ export function ChatInputBar({
<>
{mentionFiles.map((filePath, index) => (
<button
aria-selected={index === mentionSelectedIndex}
className={cn(
"block w-full rounded-md px-3 py-2 text-left text-xs transition-colors",
index === mentionSelectedIndex
@@ -789,7 +852,9 @@ export function ChatInputBar({
: "text-muted-foreground hover:bg-accent hover:text-foreground",
)}
key={filePath}
id={`mention-file-option-${index}`}
onClick={() => insertMentionFile(filePath)}
role="option"
type="button"
>
{filePath}
@@ -804,8 +869,31 @@ export function ChatInputBar({
)}
</div>
)}
<div className="flex items-end gap-2 rounded-lg border border-border bg-background px-3 py-2.5 transition-all focus-within:border-primary/50 focus-within:ring-1 focus-within:ring-primary/20">
<div
className={cn(
"flex items-end gap-2 rounded-lg border border-border bg-background px-3 py-2.5 transition-all focus-within:border-primary/50 focus-within:ring-1 focus-within:ring-primary/20",
variant === "welcome" &&
"min-h-16 items-start rounded-none border-0 bg-transparent px-0 py-0 focus-within:ring-0",
)}
>
<textarea
aria-activedescendant={
slashOpen && filteredSlashCommands.length > 0
? `slash-command-option-${slashSelectedIndex}`
: mentionOpen && mentionFiles.length > 0
? `mention-file-option-${mentionSelectedIndex}`
: undefined
}
aria-autocomplete="list"
aria-controls={
slashOpen
? "slash-command-suggestions"
: mentionOpen
? "mention-file-suggestions"
: undefined
}
aria-expanded={slashOpen || mentionOpen}
aria-haspopup="listbox"
className="max-h-60 min-h-5 flex-1 resize-none overflow-y-auto bg-transparent text-sm leading-5 text-foreground placeholder:text-muted-foreground outline-none"
onChange={(e) => {
onPromptInputChange(e.target.value);
@@ -893,15 +981,20 @@ export function ChatInputBar({
)
}
placeholder={
isBusy
? "Agent is working... submit to queue another message"
: "Enter your question or type / for commands or @ for context"
variant === "welcome"
? "Ask to make changes, @mention files, reference #PRs, or run /commands."
: isBusy
? "Agent is working... submit to queue another message"
: "Enter your question or type / for commands or @ for context"
}
ref={promptInputRef}
role="combobox"
rows={
promptInputFocused
? PROMPT_INPUT_FOCUSED_ROWS
: PROMPT_INPUT_COLLAPSED_ROWS
variant === "welcome"
? 2
: promptInputFocused
? PROMPT_INPUT_FOCUSED_ROWS
: PROMPT_INPUT_COLLAPSED_ROWS
}
value={promptInput}
/>
@@ -916,6 +1009,7 @@ export function ChatInputBar({
>
{attachment.isImage ? "image:" : "file:"} {attachment.name}
<button
aria-label={`Remove ${attachment.name}`}
className="rounded-sm p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={() => onRemoveAttachment(attachment.id)}
type="button"
@@ -928,11 +1022,12 @@ export function ChatInputBar({
)}
</div>
{/* Controls row */}
<div className="flex items-center justify-between px-4 pb-2">
<div className="flex items-center gap-1">
{/* Composer settings and submit */}
<div className="flex min-w-0 flex-wrap items-center justify-between gap-x-3 gap-y-2 border-t border-border px-3 py-2 text-[11px] text-muted-foreground max-[560px]:grid max-[560px]:grid-cols-[auto_auto_minmax(0,1fr)_auto] max-[560px]:items-center">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2 max-[560px]:contents">
<button
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
aria-label="Attach files"
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground max-[560px]:col-start-1 max-[560px]:row-start-1"
onClick={() => fileInputRef.current?.click()}
type="button"
>
@@ -944,97 +1039,138 @@ export function ChatInputBar({
multiple
onChange={(event) => {
const files = Array.from(event.target.files ?? []);
if (files.length > 0) {
onAttachFiles(files);
}
if (files.length > 0) onAttachFiles(files);
event.currentTarget.value = "";
}}
ref={fileInputRef}
type="file"
/>
<ModelSelector
isBusy={isBusy}
model={model}
onModelChange={onModelChange}
onModelSupportsReasoningChange={setModelSupportsReasoning}
onProviderChange={onProviderChange}
provider={provider}
/>
</div>
<div className="flex items-center gap-1">
<button
className="hidden rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
type="button"
>
<Mic className="h-4 w-4" />
</button>
{isBusy && (
<div className="flex shrink-0 items-center rounded-md bg-muted p-0.5 max-[560px]:col-start-2 max-[560px]:row-start-1">
<button
className="rounded-full bg-foreground p-1.5 text-background hover:bg-foreground/80 transition-colors"
onClick={onAbort}
aria-pressed={mode === "plan"}
className={cn(
"rounded px-2 py-1 transition-colors",
mode === "plan"
? "bg-background text-foreground shadow-xs"
: "hover:text-foreground",
)}
onClick={() => {
if (mode !== "plan") onModeToggle();
}}
type="button"
>
<CircleStop className="h-4 w-4" />
Plan
</button>
)}
{(!isBusy || canSend) && (
<button
className="rounded-full bg-foreground p-1.5 text-background hover:bg-foreground/80 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
disabled={!canSend}
onClick={onSend}
aria-pressed={mode === "act"}
className={cn(
"rounded px-2 py-1 transition-colors",
mode === "act"
? "bg-background text-foreground shadow-xs"
: "hover:text-foreground",
)}
onClick={() => {
if (mode !== "act") onModeToggle();
}}
type="button"
>
<ArrowUp className="h-4 w-4" />
Act
</button>
)}
</div>
<div className="min-w-0 shrink-0 max-[560px]:col-start-3 max-[560px]:col-end-5 max-[560px]:row-start-1">
<ModelSelector
isBusy={isBusy}
model={model}
onModelChange={onModelChange}
onModelSupportsReasoningChange={
handleModelSupportsReasoningChange
}
onProviderChange={onProviderChange}
provider={provider}
variant={variant}
/>
</div>
<Select
disabled={modelSupportsReasoning !== true}
onValueChange={handleEffortChange}
value={EFFORT_LEVELS[effortIndex]?.value ?? "low"}
>
<SelectTrigger
aria-label="Thinking level"
className="h-7 min-w-[5.75rem] gap-1.5 border-0 bg-muted px-2 text-[11px] shadow-none data-[size=sm]:h-7 max-[560px]:col-span-2 max-[560px]:col-start-1 max-[560px]:row-start-2"
size="sm"
title={
modelSupportsReasoning === false
? "The selected model does not report reasoning support"
: undefined
}
>
<Brain className="size-3" />
<SelectValue>{effortLabel}</SelectValue>
</SelectTrigger>
<SelectContent align="start">
{EFFORT_LEVELS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{tokensSummary ? (
<span className="max-[900px]:hidden">
<StatusItem
icon={Coins}
label={tokensSummary}
hasOption={false}
/>
</span>
) : null}
</div>
</div>
{/* Status bar */}
<div className="flex items-center justify-between border-t border-border px-4 py-1.5 text-[11px] text-muted-foreground">
<div className="flex items-center gap-3">
<StatusItem
label={mode === "act" ? "Act" : "Plan"}
onClick={onModeToggle}
/>
<StatusItem
disabled={!modelSupportsReasoning}
icon={Brain}
label={effortLabel}
onClick={handleEffortCycle}
/>
{tokensSummary && (
<StatusItem icon={Coins} label={tokensSummary} hasOption={false} />
)}
</div>
{/* GIT BRANCH */}
<div className="flex items-center gap-3">
<WorkspaceSelector
currentBranch={gitBranch}
onListGitBranches={onListGitBranches}
onRefreshWorkspaces={onRefreshWorkspaces}
onPickWorkspaceDirectory={onPickWorkspaceDirectory}
onSwitchGitBranch={onSwitchGitBranch}
onSwitchWorkspace={onSwitchWorkspace}
workspaces={workspaces}
workspaceRoot={workspaceRoot}
/>
<button
className="hidden items-center gap-1 hover:text-foreground transition-colors"
onClick={onRefreshGitBranch}
type="button"
>
<RotateCcw className="h-3 w-3" />
</button>
<button
className="hidden items-center gap-1 hover:text-foreground transition-colors"
onClick={onReset}
type="button"
>
<RotateCcw className="h-4 w-4" />
</button>
<div className="ml-auto flex min-w-0 shrink-0 items-center gap-2 max-[560px]:contents">
<div className="max-w-48 overflow-visible max-[720px]:max-w-36 max-[560px]:col-start-3 max-[560px]:row-start-2">
<WorkspaceSelector
currentBranch={gitBranch}
onListGitBranches={onListGitBranches}
onRefreshWorkspaces={onRefreshWorkspaces}
onPickWorkspaceDirectory={onPickWorkspaceDirectory}
onSwitchGitBranch={onSwitchGitBranch}
onSwitchWorkspace={onSwitchWorkspace}
workspaces={workspaces}
workspaceRoot={workspaceRoot}
/>
</div>
<div className="flex shrink-0 items-center gap-2 max-[560px]:col-start-4 max-[560px]:row-start-2">
{canAbort && (
<button
aria-label="Stop agent"
className={cn(
"bg-foreground p-1.5 text-background transition-colors hover:bg-foreground/80",
variant === "welcome" ? "rounded-md" : "rounded-full",
)}
onClick={onAbort}
type="button"
>
<CircleStop className="h-4 w-4" />
</button>
)}
{(!isBusy || canSend) && (
<button
aria-label="Send message"
className={cn(
"p-1.5 transition-colors disabled:cursor-not-allowed disabled:opacity-50",
variant === "welcome"
? "rounded-md bg-[linear-gradient(145deg,var(--primary-emphasis),var(--primary))] text-white shadow-sm hover:brightness-110"
: "rounded-full bg-foreground text-background hover:bg-foreground/80",
)}
disabled={!canSend}
onClick={onSend}
type="button"
>
<ArrowUp className="h-4 w-4" />
</button>
)}
</div>
</div>
</div>
</div>
@@ -1045,6 +1181,7 @@ function ModelSelector({
provider,
model,
isBusy,
variant,
onProviderChange,
onModelChange,
onModelSupportsReasoningChange,
@@ -1052,9 +1189,10 @@ function ModelSelector({
provider: string;
model: string;
isBusy: boolean;
variant: "conversation" | "welcome";
onProviderChange: (provider: string) => void;
onModelChange: (model: string) => void;
onModelSupportsReasoningChange: (supportsReasoning: boolean) => void;
onModelSupportsReasoningChange: (supportsReasoning: boolean | null) => void;
}) {
const normalizedProvider = normalizeProviderId(provider);
const [providerModels, setProviderModels] = useState<
@@ -1063,6 +1201,9 @@ function ModelSelector({
const [providerReasoningModels, setProviderReasoningModels] = useState<
Record<string, string[]>
>(FALLBACK_PROVIDER_REASONING_MODELS);
const [reasoningCapabilitySource, setReasoningCapabilitySource] = useState<
"loading" | "catalog" | "fallback"
>("loading");
const [enabledProviderIds, setEnabledProviderIds] = useState<string[]>([]);
const [lastSelection, setLastSelection] = useState(() =>
readModelSelectionStorageFromWindow(),
@@ -1120,6 +1261,7 @@ function ModelSelector({
useEffect(() => {
let cancelled = false;
setReasoningCapabilitySource("loading");
async function loadCatalog() {
try {
@@ -1129,6 +1271,7 @@ function ModelSelector({
}
setProviderModels(payload.providerModels);
setProviderReasoningModels(payload.providerReasoningModels);
setReasoningCapabilitySource("catalog");
setEnabledProviderIds((current) => {
const nextProviderIds = new Set(payload.enabledProviderIds);
if (normalizedProvider) {
@@ -1142,7 +1285,7 @@ function ModelSelector({
return Array.from(nextProviderIds);
});
} catch {
// Keep local fallback values when provider catalog is unavailable.
if (!cancelled) setReasoningCapabilitySource("fallback");
}
}
@@ -1180,6 +1323,7 @@ function ModelSelector({
...current,
[normalizedProvider]: reasoningModelIds,
}));
setReasoningCapabilitySource("catalog");
setEnabledProviderIds((current) =>
current.includes(normalizedProvider)
? current
@@ -1246,6 +1390,16 @@ function ModelSelector({
]);
useEffect(() => {
if (reasoningCapabilitySource === "loading") {
return;
}
if (
reasoningCapabilitySource === "fallback" &&
!(FALLBACK_PROVIDER_MODELS[normalizedProvider] ?? []).includes(model)
) {
onModelSupportsReasoningChange(null);
return;
}
onModelSupportsReasoningChange(
hasReasoningCapability(
providerReasoningModels,
@@ -1258,10 +1412,11 @@ function ModelSelector({
onModelSupportsReasoningChange,
normalizedProvider,
providerReasoningModels,
reasoningCapabilitySource,
]);
return (
<div className="flex items-center gap-1 text-xxs">
<div className="flex min-w-0 shrink-0 items-center gap-1 text-[11px]">
<Combobox
items={providers}
onValueChange={(value) => {
@@ -1287,7 +1442,11 @@ function ModelSelector({
value={resolvedProvider}
>
<ComboboxInput
className="h-7 text-xxs"
aria-label="Provider"
className={cn(
"h-7 text-[11px] max-[560px]:w-20",
variant === "welcome" && "w-24 border-0 bg-transparent shadow-none",
)}
disabled={isBusy || providers.length === 0}
readOnly
showClear={false}
@@ -1297,7 +1456,7 @@ function ModelSelector({
<ComboboxEmpty>No providers found.</ComboboxEmpty>
<ComboboxList>
{(item) => (
<ComboboxItem className="text-xxs" key={item} value={item}>
<ComboboxItem className="text-[11px]" key={item} value={item}>
{item}
</ComboboxItem>
)}
@@ -1316,7 +1475,11 @@ function ModelSelector({
value={resolvedModel}
>
<ComboboxInput
className="h-7"
aria-label="Model"
className={cn(
"h-7 text-[11px] max-[560px]:w-32",
variant === "welcome" && "w-52 border-0 bg-transparent shadow-none",
)}
disabled={isBusy || modelsForProvider.length === 0}
readOnly
showClear={false}
@@ -1326,7 +1489,7 @@ function ModelSelector({
<ComboboxEmpty>No models found.</ComboboxEmpty>
<ComboboxList>
{(item) => (
<ComboboxItem className="text-xxs" key={item} value={item}>
<ComboboxItem className="text-[11px]" key={item} value={item}>
{item}
</ComboboxItem>
)}
@@ -1350,6 +1513,16 @@ function StatusItem({
disabled?: boolean;
hasOption?: boolean;
}) {
const content = (
<>
{Icon ? <Icon className="h-3 w-3" /> : null}
<span className="max-[560px]:sr-only">{label}</span>
{hasOption ? <ChevronDown className="h-2.5 w-2.5" /> : null}
</>
);
if (!onClick) {
return <span className="flex items-center gap-1">{content}</span>;
}
return (
<button
className={cn(
@@ -1360,9 +1533,7 @@ function StatusItem({
onClick={onClick}
type="button"
>
{Icon ? <Icon className="h-3 w-3" /> : null}
<span>{label}</span>
{hasOption ? <ChevronDown className="h-2.5 w-2.5" /> : null}
{content}
</button>
);
}
@@ -0,0 +1,93 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatMessage } from "@/lib/chat-schema";
import { ChatMessages } from "./chat-messages";
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
HTMLElement.prototype.scrollTo = vi.fn();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.restoreAllMocks();
});
async function renderMessages(messages: ChatMessage[]) {
await act(async () => {
root.render(
<ChatMessages
chatTransportState="connected"
error={null}
messages={messages}
onAnswerAskQuestion={vi.fn()}
onApproveToolApproval={vi.fn()}
onRejectToolApproval={vi.fn()}
pendingAskQuestions={[]}
pendingToolApprovals={[]}
sessionId="session-1"
status="completed"
/>,
);
});
}
describe("ChatMessages tool disclosures", () => {
it("renders a detail-less tool summary as static text", async () => {
await renderMessages([
{
id: "tool-static",
sessionId: "session-1",
role: "tool",
content: "not-json",
createdAt: 1,
meta: { toolName: "search" },
},
]);
const summary = [...container.querySelectorAll("span")].find((element) =>
element.textContent?.includes("Explored"),
);
expect(summary).toBeDefined();
expect(summary?.closest("button")).toBeNull();
});
it("exposes and toggles expandable tool details", async () => {
await renderMessages([
{
id: "tool-expandable",
sessionId: "session-1",
role: "tool",
content: JSON.stringify({
toolName: "search",
input: { queries: ["workspace selector"] },
result: {},
}),
createdAt: 1,
},
]);
const trigger = [...container.querySelectorAll("button")].find((element) =>
element.textContent?.includes("Explored 1 search"),
);
expect(trigger?.getAttribute("aria-expanded")).toBe("false");
const panelId = trigger?.getAttribute("aria-controls");
expect(panelId).toBeTruthy();
await act(async () => trigger?.click());
expect(trigger?.getAttribute("aria-expanded")).toBe("true");
expect(document.getElementById(panelId ?? "")?.textContent).toContain(
"workspace selector",
);
});
});
@@ -1,12 +1,27 @@
"use client";
import {
Message as AgentMessage,
Conversation,
ConversationContent,
ConversationScrollButton,
ConversationViewport,
MessageAction,
MessageActions,
MessageContent,
Reasoning,
ReasoningContent,
ReasoningTrigger,
ToolActivity,
ToolActivityCode,
ToolActivityContent,
ToolActivityDetails,
ToolActivityTrigger,
} from "@cline/ui/components/agent-chat";
import {
AlertCircle,
Bot,
BrainIcon,
Check,
ChevronDown,
ChevronRight,
Clock3,
Copy,
FileEdit,
@@ -20,22 +35,14 @@ import {
SquareTerminalIcon,
UndoIcon,
} from "lucide-react";
import {
memo,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { memo, useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { toast } from "@/hooks/use-toast";
import type { ChatMessage, ChatSessionStatus } from "@/lib/chat-schema";
import { parseApplyPatchInput } from "@/lib/session-diff";
import { cn } from "@/lib/utils";
import { MemoizedMarkdown } from "../../ui/markdown";
import { normalizeTitle } from "../../utils";
import { WelcomeScreen } from "./welcome-chat";
import { formatChatMessageContent } from "./message-content";
type ChatMessagesProps = {
sessionId: string | null;
@@ -46,8 +53,6 @@ type ChatMessagesProps = {
| "connected"
| "unavailable";
isSessionSwitching?: boolean;
provider: string;
model: string;
messages: ChatMessage[];
error: string | null;
streamingMessageId?: string | null;
@@ -61,7 +66,6 @@ type ChatMessagesProps = {
) => void | Promise<void>;
onRestoreCheckpoint?: (runCount: number) => void | Promise<void>;
onForkSession?: () => void | Promise<void>;
onStartChat?: (prompt: string) => void;
};
type ToolApprovalRequestItem = {
@@ -89,16 +93,12 @@ type AskQuestionRequestItem = {
};
const IS_DEBUG = process.env.NODE_ENV === "test";
const STICKY_BOTTOM_THRESHOLD_PX = 24;
const SCROLL_TO_BOTTOM_BUTTON_THRESHOLD_PX = 120;
function ChatMessagesImpl({
sessionId: _sessionId,
sessionId,
status,
chatTransportState = "connecting",
isSessionSwitching = false,
provider,
model,
messages,
error,
streamingMessageId = null,
@@ -109,10 +109,7 @@ function ChatMessagesImpl({
onAnswerAskQuestion,
onRestoreCheckpoint,
onForkSession,
onStartChat,
}: ChatMessagesProps) {
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
const shouldStickToBottomRef = useRef(true);
const hasMessages = messages.length > 0;
const lastErrorMessage = [...messages]
.reverse()
@@ -120,7 +117,6 @@ function ChatMessagesImpl({
const shouldShowErrorBanner =
Boolean(error) && (!lastErrorMessage || lastErrorMessage.content !== error);
const [showSwitchTransition, setShowSwitchTransition] = useState(false);
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const [toolApprovalActions, setToolApprovalActions] = useState<
Record<string, "approving" | "rejecting">
>({});
@@ -145,23 +141,6 @@ function ChatMessagesImpl({
const showIdleDetails =
!hasMessages && !isSessionSwitching && !showSwitchTransition;
const getViewport = useCallback(() => {
return scrollAreaRef.current;
}, []);
const scrollToBottom = useCallback(
(behavior: ScrollBehavior = "smooth") => {
const viewport = getViewport();
if (!viewport) {
return;
}
shouldStickToBottomRef.current = true;
viewport.scrollTo({ top: viewport.scrollHeight, behavior });
setShowScrollToBottom((prev) => (prev ? false : prev));
},
[getViewport],
);
useEffect(() => {
if (!isSessionSwitching) {
setShowSwitchTransition((prev) => (prev ? false : prev));
@@ -175,39 +154,6 @@ function ChatMessagesImpl({
};
}, [isSessionSwitching]);
useEffect(() => {
const viewport = getViewport();
if (!viewport) {
return;
}
const updateScrollToBottomVisibility = () => {
const distanceFromBottom =
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
shouldStickToBottomRef.current =
distanceFromBottom <= STICKY_BOTTOM_THRESHOLD_PX;
const shouldShow =
distanceFromBottom > SCROLL_TO_BOTTOM_BUTTON_THRESHOLD_PX;
setShowScrollToBottom((prev) =>
prev === shouldShow ? prev : shouldShow,
);
};
updateScrollToBottomVisibility();
viewport.addEventListener("scroll", updateScrollToBottomVisibility);
return () => {
viewport.removeEventListener("scroll", updateScrollToBottomVisibility);
};
}, [getViewport]);
useLayoutEffect(() => {
if (!shouldStickToBottomRef.current) {
return;
}
scrollToBottom("auto");
}, [scrollToBottom]);
useEffect(() => {
const activeRequestIds = new Set(
pendingToolApprovals.map((item) => item.requestId),
@@ -371,21 +317,22 @@ function ChatMessagesImpl({
);
return (
<div className="relative h-full min-h-0 min-w-0">
<div
className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto"
ref={scrollAreaRef}
<Conversation
className="h-full min-h-0 min-w-0"
key={sessionId ?? "new-chat"}
>
<ConversationViewport
aria-label="Agent conversation"
className="h-full min-h-0 min-w-0"
>
<div className="relative mx-auto w-full h-full min-w-0 max-w-full overflow-x-hidden px-6 py-6">
{showIdleDetails ? (
<WelcomeScreen
provider={provider}
model={model}
onStartChat={onStartChat ?? (() => {})}
quickActions={[]}
/>
) : (
<div className="flex h-full w-full min-w-0 flex-col gap-2 overflow-x-hidden">
<ConversationContent
className={cn(
"relative mx-auto min-h-full w-full min-w-0 max-w-full overflow-x-hidden",
showIdleDetails ? "p-0" : "px-6 py-6",
)}
>
{showIdleDetails ? null : (
<div className="flex min-h-full w-full min-w-0 flex-col gap-2 overflow-x-hidden">
{pendingToolApprovals.length > 0 ? (
<ToolApprovalPanel
items={pendingToolApprovals}
@@ -492,21 +439,10 @@ function ChatMessagesImpl({
{error}
</div>
) : null}
</div>
</div>
{showScrollToBottom ? (
<Button
className="absolute bottom-4 right-4 z-20 size-9 rounded-full shadow-sm"
onClick={() => scrollToBottom("smooth")}
size="icon"
type="button"
variant="secondary"
>
<ChevronDown className="size-4" />
<span className="sr-only">Scroll to bottom</span>
</Button>
) : null}
</div>
</ConversationContent>
</ConversationViewport>
<ConversationScrollButton />
</Conversation>
);
}
@@ -741,218 +677,138 @@ function MessageBubble({
isUser && Boolean(onCopyRawText || checkpoint);
const keepUserActionsVisible = restorePending || Boolean(restoreError);
const keepAssistantActionsVisible = forkPending || Boolean(forkError);
const hiddenActionButtonsClassName =
"pointer-events-none opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100";
if (message.role === "tool") {
return <ToolMessageBlock message={message} />;
}
const normalizedContent = normalizeTitle(message.content);
const displayContent = formatChatMessageContent(
message.role,
message.content,
);
const reasoningContent = message.reasoning?.trim() || "";
return (
<div
className={cn(
"flex min-w-0",
isUser ? "justify-end" : "w-full justify-start",
)}
>
<div
className={cn(
"group max-w-full min-w-0 wrap-break-word text-sm",
isUser && "flex max-w-[50%] flex-col items-end gap-1",
!isUser && "flex flex-col items-start gap-2 overflow-hidden",
!isUser && !isError && "text-foreground",
isError &&
"bg-destructive/10 border border-destructive/40 text-destructive",
)}
>
<div
className={cn(
"max-w-full min-w-0 space-y-2 overflow-hidden wrap-break-word",
isUser && "rounded-sm bg-card p-2 text-foreground/80",
)}
>
{isStreaming && message.role === "assistant" ? (
<>
{reasoningContent || message.reasoningRedacted ? (
<ReasoningBlock
content={reasoningContent}
redacted={message.reasoningRedacted === true}
/>
) : null}
<div className="whitespace-pre-wrap wrap-break-word leading-relaxed">
{normalizedContent || " "}
</div>
</>
) : (
<>
{reasoningContent || message.reasoningRedacted ? (
<ReasoningBlock
content={reasoningContent}
redacted={message.reasoningRedacted === true}
/>
) : null}
<AgentMessage from={message.role}>
<MessageContent className="space-y-2 wrap-break-word">
{reasoningContent || message.reasoningRedacted ? (
<ReasoningBlock
content={reasoningContent}
redacted={message.reasoningRedacted === true}
streaming={isStreaming}
/>
) : null}
<div className="my-1 ml-3 min-w-0 max-w-full overflow-x-hidden wrap-break-word **:max-w-full [&_code]:whitespace-pre-wrap [&_code]:wrap-break-word [&_pre]:overflow-x-hidden [&_pre]:whitespace-pre-wrap [&_pre]:wrap-break-word">
<MemoizedMarkdown
content={normalizedContent || " "}
id={message.id}
/>
</div>
</>
)}
<div className="my-1 min-w-0 max-w-full wrap-break-word">
<MemoizedMarkdown
content={displayContent || " "}
streaming={isStreaming && message.role === "assistant"}
/>
</div>
{shouldRenderUserActions ? (
<div className="space-y-1">
<div className="flex h-6 items-center justify-end">
<div
className={cn(
"flex items-center justify-end gap-2",
keepUserActionsVisible
? "pointer-events-auto opacity-100"
: hiddenActionButtonsClassName,
)}
</MessageContent>
{shouldRenderUserActions ? (
<>
<MessageActions visible={keepUserActionsVisible}>
{onCopyRawText ? (
<MessageAction
label={wasCopied ? "Copied user message" : "Copy user message"}
onClick={onCopyRawText}
title={wasCopied ? "Copied" : "Copy message"}
>
{onCopyRawText ? (
<Button
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
aria-label={
wasCopied ? "Copied user message" : "Copy user message"
}
onClick={onCopyRawText}
size="sm"
title={wasCopied ? "Copied" : "Copy message"}
type="button"
variant="ghost"
>
{wasCopied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
) : null}
{checkpoint ? (
<Button
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
aria-label="Restore checkpoint"
disabled={restoreDisabled || restorePending}
onClick={() => onRestoreCheckpoint?.(checkpoint.runCount)}
size="sm"
title="Restore checkpoint"
type="button"
variant="ghost"
>
{restorePending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<UndoIcon className="h-3.5 w-3.5" />
)}
</Button>
) : null}
</div>
</div>
{restoreError ? (
<div className="text-right text-xs text-destructive">
{restoreError}
</div>
{wasCopied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</MessageAction>
) : null}
</div>
) : null}
{shouldRenderAssistantActions ? (
<div className="flex h-6 items-center hidden">
<div
className={cn(
"flex items-center gap-0",
keepAssistantActionsVisible
? "pointer-events-auto opacity-100"
: hiddenActionButtonsClassName,
)}
>
{onCopyRawText ? (
<Button
className="h-6 gap-1.5 px-2 text-[11px] text-muted-foreground hover:text-foreground"
aria-label={
wasCopied
? "Copied assistant message"
: "Copy assistant message"
}
onClick={onCopyRawText}
size="sm"
title={wasCopied ? "Copied" : "Copy raw assistant output"}
type="button"
variant="ghost"
>
{wasCopied ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
) : null}
{onForkSession ? (
<Button
className="h-6 gap-1.5 px-2 text-[11px] text-muted-foreground hover:text-foreground"
aria-label="Fork session"
disabled={forkPending}
onClick={onForkSession}
size="sm"
title="Fork session - copy full message history into a new session"
type="button"
variant="ghost"
>
{forkPending ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<SplitIcon className="h-3 w-3" />
)}
</Button>
) : null}
{forkError ? (
<span className="text-[11px] text-destructive">
{forkError}
</span>
) : null}
{checkpoint ? (
<MessageAction
disabled={restoreDisabled || restorePending}
label="Restore checkpoint"
onClick={() => onRestoreCheckpoint?.(checkpoint.runCount)}
title="Restore checkpoint"
>
{restorePending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<UndoIcon className="h-3.5 w-3.5" />
)}
</MessageAction>
) : null}
</MessageActions>
{restoreError ? (
<div className="text-right text-xs text-destructive">
{restoreError}
</div>
</div>
) : null}
</div>
</div>
) : null}
</>
) : null}
{shouldRenderAssistantActions ? (
<MessageActions visible={keepAssistantActionsVisible}>
{onCopyRawText ? (
<MessageAction
label={
wasCopied
? "Copied assistant message"
: "Copy assistant message"
}
onClick={onCopyRawText}
title={wasCopied ? "Copied" : "Copy raw assistant output"}
>
{wasCopied ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
</MessageAction>
) : null}
{onForkSession ? (
<MessageAction
disabled={forkPending}
label="Fork session"
onClick={onForkSession}
title="Fork session - copy full message history into a new session"
>
{forkPending ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<SplitIcon className="h-3 w-3" />
)}
</MessageAction>
) : null}
{forkError ? (
<span className="text-[11px] text-destructive">{forkError}</span>
) : null}
</MessageActions>
) : null}
</AgentMessage>
);
}
function ReasoningBlock({
content,
redacted,
streaming = false,
}: {
content: string;
redacted: boolean;
streaming?: boolean;
}) {
const [expanded, setExpanded] = useState(false);
const displayContent = content || (redacted ? "[redacted]" : "");
if (!displayContent) {
return null;
}
return (
<div className="my-2">
<Button
className="h-auto min-h-0 max-w-full justify-start gap-2 whitespace-normal px-0 py-1 text-left text-sm font-medium text-foreground/70 hover:bg-transparent hover:text-foreground dark:hover:bg-transparent dark:hover:text-foreground"
onClick={() => setExpanded((current) => !current)}
type="button"
variant="ghost"
>
<BrainIcon className="size-4" />
Thinking
</Button>
{expanded ? (
<div className="mt-1.5 whitespace-pre-wrap rounded-lg border border-border/70 bg-muted/30 p-3 text-sm leading-relaxed text-muted-foreground">
{displayContent}
</div>
) : null}
</div>
<Reasoning isStreaming={streaming}>
<ReasoningTrigger />
<ReasoningContent>
<MemoizedMarkdown content={displayContent} streaming={streaming} />
</ReasoningContent>
</Reasoning>
);
}
@@ -1333,7 +1189,6 @@ function buildToolSummaryFromMeta(
}
function ToolMessageBlock({ message }: { message: ChatMessage }) {
const [expanded, setExpanded] = useState(false);
const payload = parseToolPayload(message.content);
const toolName = message.meta?.toolName || payload?.toolName || "tool";
const hookEventName = message.meta?.hookEventName;
@@ -1367,80 +1222,48 @@ function ToolMessageBlock({ message }: { message: ChatMessage }) {
details.length > 0 || Boolean(inputPreview || resultPreview);
return (
<div className="my-2 flex w-full min-w-0 justify-start">
<div
className={cn("min-w-0 max-w-full overflow-hidden rounded-xl text-sm")}
>
<Button
className="h-auto min-h-0 max-w-full justify-start gap-2 whitespace-normal px-0 py-1 text-left text-sm font-medium text-primary hover:bg-transparent hover:text-primary/80 dark:hover:bg-transparent dark:hover:text-primary/80"
onClick={() => setExpanded((current) => !current)}
type="button"
variant="ghost"
>
{payload?.isError ? (
<ToolActivity expandable={hasExpandedSections}>
<ToolActivityTrigger
additions={summary.diff?.additions}
deletions={summary.diff?.deletions}
icon={
payload?.isError ? (
<AlertCircle className="size-4 text-destructive/80" />
) : (
<Icon className="size-4" />
)}
<span className="min-w-0 wrap-break-word">{summary.label}</span>
{summary.diff ? (
<span className="shrink-0 font-mono text-xs">
<span className="text-chart-2">+{summary.diff.additions}</span>{" "}
<span className="text-destructive">
-{summary.diff.deletions}
</span>
</span>
) : null}
{hasExpandedSections ? (
<span className="shrink-0 text-muted-foreground">
{expanded ? (
<ChevronDown className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
</span>
) : null}
</Button>
{expanded ? (
<div className="mt-1.5 min-w-0 max-w-full overflow-x-hidden pl-8 text-sm text-muted-foreground">
{hasExpandedSections ? (
<div className="space-y-1">
{details.map((detail) => (
<div
className="wrap-break-word"
key={`${message.id}_${detail}`}
>
{detail}
</div>
))}
</div>
) : null}
{inputPreview ? (
<div className="space-y-1">
<div className="text-xxs uppercase tracking-wide text-muted-foreground/80">
Input
</div>
<pre className="max-h-52 max-w-full overflow-x-hidden overflow-y-auto whitespace-pre-wrap wrap-break-word rounded-md border border-border/70 bg-background/60 p-2 text-sm leading-relaxed text-foreground">
{inputPreview}
</pre>
</div>
) : null}
{resultPreview ? (
payload?.isError ? (
<div className="mt-1">
<span className="text-destructive">{resultPreview}</span>
</div>
) : (
<div className="space-y-1">
<pre className="max-h-64 max-w-full overflow-x-hidden overflow-y-auto whitespace-pre-wrap wrap-break-word rounded-md border border-border/70 bg-background/60 p-2 text-sm leading-relaxed text-foreground">
{resultPreview}
</pre>
</div>
)
) : null}
)
}
label={summary.label}
status={payload?.isError ? "error" : inProgress ? "running" : "success"}
/>
<ToolActivityContent>
{details.length > 0 ? (
<ToolActivityDetails>
{details.map((detail) => (
<div key={`${message.id}_${detail}`}>{detail}</div>
))}
</ToolActivityDetails>
) : null}
{inputPreview ? (
<div className="space-y-1">
<div className="text-[11px] uppercase tracking-wide text-muted-foreground/80">
Input
</div>
<ToolActivityCode className="text-sm">
{inputPreview}
</ToolActivityCode>
</div>
) : null}
</div>
</div>
{resultPreview ? (
payload?.isError ? (
<div className="mt-1 text-destructive">{resultPreview}</div>
) : (
<ToolActivityCode className="max-h-64 text-sm">
{resultPreview}
</ToolActivityCode>
)
) : null}
</ToolActivityContent>
</ToolActivity>
);
}
@@ -0,0 +1,30 @@
import { describe, expect, test } from "vitest";
import { formatChatMessageContent } from "./message-content";
describe("formatChatMessageContent", () => {
test("unwraps transport envelopes only for user messages", () => {
expect(
formatChatMessageContent(
"user",
" <user_input>\nPlease fix the tests\n</user_input> ",
),
).toBe("Please fix the tests");
});
test("preserves assistant examples that contain transport tags", () => {
const content =
"<user_input>\nThis tag is part of the explanation.\n</user_input>";
expect(formatChatMessageContent("assistant", content)).toBe(content);
});
test("preserves assistant mode notices instead of stripping them", () => {
const content = "<mode_notice>\nPlan mode details\n</mode_notice>";
expect(formatChatMessageContent("assistant", content)).toBe(content);
});
test("trims outer whitespace for non-user roles", () => {
expect(formatChatMessageContent("error", " Request failed \n")).toBe(
"Request failed",
);
});
});
@@ -0,0 +1,10 @@
import { formatDisplayUserInput } from "@cline/shared/browser";
import type { ChatMessage } from "@/lib/chat-schema";
export function formatChatMessageContent(
role: ChatMessage["role"],
content: string,
): string {
const trimmed = content.trim();
return role === "user" ? formatDisplayUserInput(trimmed) : trimmed;
}
@@ -0,0 +1,37 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { WorkspaceProvider } from "@/contexts/workspace-context";
import { WelcomeScreen } from "./welcome-chat";
describe("WelcomeScreen", () => {
it("renders every known project instead of capping the project strip", () => {
const workspaces = Array.from(
{ length: 6 },
(_, index) => `/projects/project-${index + 1}`,
);
const html = renderToStaticMarkup(
<WorkspaceProvider
value={{
workspaceRoot: workspaces[0] ?? "",
workspaces,
listWorkspaces: vi.fn(async () => workspaces),
refreshWorkspaces: vi.fn(async () => undefined),
switchWorkspace: vi.fn(async () => true),
pickWorkspaceDirectory: vi.fn(async () => null),
}}
>
<WelcomeScreen
active
body={null}
composer={null}
onStartChat={vi.fn()}
quickActions={[]}
/>
</WorkspaceProvider>,
);
for (let index = 1; index <= workspaces.length; index += 1) {
expect(html).toContain(`project-${index}`);
}
});
});
@@ -1,18 +1,12 @@
"use client";
import { Check, FolderOpen } from "lucide-react";
import { ArrowRight, FolderPlus, Plus } from "lucide-react";
import type { ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { AuroraBackground } from "@/components/ui/aurora-bg";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Label } from "@/components/ui/label";
import { useWorkspace } from "@/contexts/workspace-context";
import { cn } from "@/lib/utils";
import { normalizeWorkspacePath } from "@/lib/workspace-paths";
interface QuickAction {
id: string;
@@ -21,68 +15,95 @@ interface QuickAction {
prompt: string;
}
function normalizeWorkspacePath(path: string): string {
const normalized = path.trim().replace(/[\\/]+$/, "");
if (!normalized) {
return "";
}
if (/^[A-Za-z]:/.test(normalized)) {
return normalized.toLowerCase();
}
return normalized;
}
const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
{
id: "review-changes",
label: "Review changes",
description: "Review the current changes and call out anything risky.",
prompt: "Review the current changes and call out anything risky.",
},
{
id: "check-build",
label: "Check for build errors",
description: "Run the relevant checks and help me fix any failures.",
prompt: "Check this project for build errors and help me fix any failures.",
},
];
function toWorkspaceName(path: string): string {
const trimmed = path.trim().replace(/[\\/]+$/, "");
if (!trimmed) return "workspace";
if (!trimmed) return "Workspace";
const parts = trimmed.split(/[\\/]/);
return parts[parts.length - 1] || "workspace";
return parts[parts.length - 1] || "Workspace";
}
function formatWorkspaceLabel(workspacePath: string): string {
const trimmed = workspacePath.trim();
if (!trimmed) return workspacePath;
const unixHome = trimmed.match(/^\/Users\/[^/]+\/(.*)$/);
if (unixHome) return unixHome[1] ? `~/${unixHome[1]}` : "~";
const linuxHome = trimmed.match(/^\/home\/[^/]+\/(.*)$/);
if (linuxHome) return linuxHome[1] ? `~/${linuxHome[1]}` : "~";
const windowsHome = trimmed.match(/^[A-Za-z]:\\Users\\[^\\]+\\(.*)$/);
if (windowsHome) {
const tail = windowsHome[1]?.replaceAll("\\", "/") || "";
return tail ? `~/${tail}` : "~";
}
return workspacePath;
function workspaceLabels(paths: string[]): Map<string, string> {
const segments = paths.map((path) =>
path
.trim()
.replace(/[\\/]+$/, "")
.split(/[\\/]/)
.filter(Boolean),
);
return new Map(
paths.map((path, index) => {
const parts = segments[index] ?? [];
for (let depth = 1; depth <= parts.length; depth += 1) {
const candidate = parts.slice(-depth).join("/");
const matches = segments.filter(
(other) => other.slice(-depth).join("/") === candidate,
).length;
if (matches === 1) return [path, candidate];
}
return [path, toWorkspaceName(path)];
}),
);
}
export function WelcomeScreen({
active,
body,
composer,
onStartChat,
quickActions,
}: {
provider: string;
model: string;
active: boolean;
body: ReactNode;
composer: ReactNode;
onStartChat: (prompt: string) => void;
quickActions: QuickAction[];
}) {
const { workspaceRoot, workspaces, refreshWorkspaces, switchWorkspace } =
useWorkspace();
const [switchingWorkspace, setSwitchingWorkspace] = useState(false);
const {
workspaceRoot,
workspaces,
refreshWorkspaces,
switchWorkspace,
pickWorkspaceDirectory,
} = useWorkspace();
const [switchingWorkspace, setSwitchingWorkspace] = useState<string | null>(
null,
);
const [addingWorkspace, setAddingWorkspace] = useState(false);
const availableWorkspaces = useMemo(() => {
const next = new Map<string, string>();
const register = (path: string) => {
const trimmed = path.trim();
if (!trimmed) {
return;
}
next.set(normalizeWorkspacePath(trimmed), trimmed);
if (trimmed) next.set(normalizeWorkspacePath(trimmed), trimmed);
};
register(workspaceRoot);
for (const workspacePath of workspaces) {
register(workspacePath);
}
for (const workspacePath of workspaces) register(workspacePath);
return [...next.values()];
}, [workspaceRoot, workspaces]);
const actions =
quickActions.length > 0 ? quickActions : DEFAULT_QUICK_ACTIONS;
const labelsByWorkspace = useMemo(
() => workspaceLabels(availableWorkspaces),
[availableWorkspaces],
);
useEffect(() => {
void refreshWorkspaces();
}, [refreshWorkspaces]);
if (active) void refreshWorkspaces();
}, [active, refreshWorkspaces]);
const handleSelectWorkspace = useCallback(
async (path: string) => {
@@ -90,109 +111,145 @@ export function WelcomeScreen({
normalizeWorkspacePath(path) ===
normalizeWorkspacePath(workspaceRoot) ||
switchingWorkspace
)
) {
return;
setSwitchingWorkspace(true);
await switchWorkspace(path);
setSwitchingWorkspace(false);
}
setSwitchingWorkspace(path);
try {
await switchWorkspace(path);
} finally {
setSwitchingWorkspace(null);
}
},
[workspaceRoot, switchWorkspace, switchingWorkspace],
[switchWorkspace, switchingWorkspace, workspaceRoot],
);
const handleQuickAction = (action: QuickAction) => {
// TODO: wire up quick action prompt to chat input
void action;
};
const handleAddWorkspace = useCallback(async () => {
if (addingWorkspace) return;
setAddingWorkspace(true);
try {
const selected = await pickWorkspaceDirectory(workspaceRoot || undefined);
if (selected) await switchWorkspace(selected);
} finally {
setAddingWorkspace(false);
}
}, [addingWorkspace, pickWorkspaceDirectory, switchWorkspace, workspaceRoot]);
return (
<div className="flex flex-1 flex-col items-center overflow-hidden bg-background">
<AuroraBackground />
<div className="relative z-10 flex w-full max-w-3xl flex-1 flex-col items-center px-6 py-12">
<div className="mb-8 flex flex-col items-center">
<h1 className="text-balance text-center text-3xl font-bold tracking-tight text-foreground">
What can I do for you?
</h1>
<p className="mt-2 text-balance text-center text-muted-foreground">
Let's explore, edit, and ship code together!
</p>
</div>
<div
className={cn(
active
? "relative h-full min-h-0 overflow-hidden bg-background"
: "contents",
)}
>
{active ? <AuroraBackground /> : null}
<div
className={cn(
active
? "relative z-10 h-full w-full overflow-x-hidden overflow-y-auto"
: "contents",
)}
>
<div
className={cn(
active
? "mx-auto flex w-full max-w-[960px] flex-col px-6 pb-32 pt-[clamp(8rem,26vh,17rem)] max-[720px]:px-4 max-[720px]:pb-20 max-[720px]:pt-16"
: "contents",
)}
>
{active ? (
<>
<h1 className="text-balance text-center text-[clamp(2rem,3vw,2.6rem)] font-semibold leading-[1.12] tracking-[-0.025em] text-foreground">
What would you like to build?
</h1>
{/* Workspace selector */}
<div className="mb-8 w-full max-w-md">
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Workspace
</Label>
<Command className="rounded-xl border border-border bg-card">
<CommandInput placeholder="Search workspaces..." />
<CommandList>
<CommandEmpty>No workspaces found.</CommandEmpty>
<CommandGroup>
{availableWorkspaces.map((wsPath) => {
const isActive =
normalizeWorkspacePath(wsPath) ===
normalizeWorkspacePath(workspaceRoot);
return (
<CommandItem
key={wsPath}
value={wsPath}
onSelect={() => {
void handleSelectWorkspace(wsPath);
}}
disabled={switchingWorkspace}
className="gap-3 py-2.5"
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-secondary">
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-medium text-foreground">
{toWorkspaceName(wsPath)}
</p>
{isActive && (
<span className="rounded-full bg-primary/20 px-1.5 py-0.5 text-[10px] font-medium text-primary">
Active
</span>
)}
</div>
<p className="truncate text-xs text-muted-foreground">
{formatWorkspaceLabel(wsPath)}
</p>
</div>
{isActive && (
<Check className="ml-auto h-4 w-4 text-primary" />
)}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</div>
{/* Quick actions */}
<div className="mb-8 w-full">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{quickActions?.map((action) => {
return (
<div className="mt-11 flex min-w-0 items-center gap-1.5 text-sm">
<fieldset className="flex min-h-8 min-w-0 flex-1 items-center gap-1.5 overflow-x-auto pb-1">
<legend className="sr-only">Workspaces</legend>
{availableWorkspaces.map((path) => {
const isActive =
normalizeWorkspacePath(path) ===
normalizeWorkspacePath(workspaceRoot);
const isSwitching = switchingWorkspace === path;
return (
<button
aria-pressed={isActive}
className={cn(
"shrink-0 rounded-md px-3 py-1.5 font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
isActive
? "bg-foreground text-background"
: "text-muted-foreground hover:bg-accent hover:text-foreground",
)}
disabled={Boolean(switchingWorkspace)}
key={path}
onClick={() => void handleSelectWorkspace(path)}
title={path}
type="button"
>
{isSwitching
? "Switching..."
: (labelsByWorkspace.get(path) ??
toWorkspaceName(path))}
</button>
);
})}
</fieldset>
<button
className="inline-flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-1.5 font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring max-[480px]:px-2"
disabled={addingWorkspace}
onClick={() => void handleAddWorkspace()}
type="button"
key={action.id}
onClick={() => handleQuickAction(action)}
className="group flex flex-col items-start gap-2 rounded-xl border border-border bg-card/50 p-4 text-left transition-all hover:border-primary/30 hover:bg-card"
>
<div>
<p className="text-sm font-medium text-foreground">
{action.label}
</p>
<p className="mt-0.5 text-xs text-muted-foreground line-clamp-2">
{action.description}
</p>
</div>
{addingWorkspace ? (
<FolderPlus className="size-4 animate-pulse" />
) : (
<Plus className="size-4" />
)}
New project
</button>
);
})}
</div>
</>
) : null}
<div
className={active ? "hidden" : "h-full min-h-0 overflow-hidden"}
key="conversation-body"
>
{body}
</div>
<div
className={active ? "mt-4 w-full" : "z-20 shrink-0"}
key="persistent-composer"
>
{composer}
</div>
{active ? (
<div className="mt-11 w-full divide-y divide-border/80 overflow-hidden rounded-xl border border-border/60 bg-background/95 px-2 shadow-sm">
{actions.map((action) => (
<button
className="group flex w-full items-center justify-between gap-5 px-3 py-3 text-left transition-colors hover:bg-background/55 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
key={action.id}
onClick={() => onStartChat(action.prompt)}
type="button"
>
<span className="min-w-0">
<span className="block text-[15px] font-medium text-foreground">
{action.label}
</span>
<span className="mt-0.5 block truncate text-sm text-muted-foreground">
{action.description}
</span>
</span>
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
<ArrowRight className="size-3" />
</span>
</button>
))}
</div>
) : null}
</div>
</div>
</div>
@@ -0,0 +1,82 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WorkspaceSelector } from "./workspace-selector";
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.restoreAllMocks();
});
async function click(element: Element): Promise<void> {
await act(async () => {
element.dispatchEvent(
new MouseEvent("click", { bubbles: true, cancelable: true }),
);
await Promise.resolve();
});
}
function buttonWithText(text: string): HTMLButtonElement {
const button = [
...container.querySelectorAll<HTMLButtonElement>("button"),
].find((candidate) => candidate.textContent?.includes(text));
expect(button).toBeDefined();
return button as HTMLButtonElement;
}
describe("WorkspaceSelector", () => {
it("switches both workspace and branch choices from the opened menu", async () => {
const onSwitchWorkspace = vi.fn(async () => true);
const onSwitchGitBranch = vi.fn(async () => true);
await act(async () => {
root.render(
<WorkspaceSelector
currentBranch="main"
onListGitBranches={vi.fn(async () => ({
current: "main",
branches: ["main", "feature/review"],
}))}
onPickWorkspaceDirectory={vi.fn(async () => null)}
onRefreshWorkspaces={vi.fn(async () => undefined)}
onSwitchGitBranch={onSwitchGitBranch}
onSwitchWorkspace={onSwitchWorkspace}
workspaceRoot="/workspace/one"
workspaces={["/workspace/one", "/workspace/two"]}
/>,
);
});
await click(container.querySelector("#git-branch-btn") as Element);
await vi.waitFor(() => {
expect(container.textContent).toContain("/workspace/two");
expect(container.textContent).toContain("feature/review");
});
await click(buttonWithText("/workspace/two"));
await vi.waitFor(() => {
expect(onSwitchWorkspace).toHaveBeenCalledWith("/workspace/two");
});
await click(container.querySelector("#git-branch-btn") as Element);
await vi.waitFor(() => {
expect(container.textContent).toContain("feature/review");
});
await click(buttonWithText("feature/review"));
await vi.waitFor(() => {
expect(onSwitchGitBranch).toHaveBeenCalledWith("feature/review");
});
});
});
@@ -215,6 +215,7 @@ export function WorkspaceSelector({
variant="ghost"
aria-label="Close menu"
className="fixed inset-0 z-40 cursor-default h-auto rounded-none opacity-0"
data-cursor="default"
onClick={() => {
setOpen(false);
setShowWorkspacePathInput(false);
@@ -195,10 +195,12 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background text-foreground">
<header className="flex shrink-0 items-center justify-between gap-4 border-b px-6 py-4">
<header className="flex shrink-0 items-end justify-between gap-6 px-18 pb-7 pt-10 max-[1200px]:px-8 max-md:pl-12 max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:pr-4 max-[720px]:pt-5">
<div className="min-w-0">
<h1 className="text-lg font-semibold leading-tight">Sessions</h1>
<p className="mt-1 text-sm text-muted-foreground">
<h1 className="text-[32px] font-semibold leading-[1.15] tracking-normal">
Sessions
</h1>
<p className="mt-3 text-[15px] leading-6 text-muted-foreground">
Recent sessions across clients and workspaces.
</p>
</div>
@@ -228,10 +230,10 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={6}>
<DropdownMenuItem onClick={() => setSortDirection("newest")}>
{sortDirection === "newest" ? "Newest first" : "Newest first"}
Newest first
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortDirection("oldest")}>
{sortDirection === "oldest" ? "Oldest first" : "Oldest first"}
Oldest first
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -284,7 +286,7 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
</div>
</header>
<section className="min-h-0 flex-1 overflow-auto px-6 py-5">
<section className="min-h-0 flex-1 overflow-auto px-18 pb-10 max-[1200px]:px-8 max-[720px]:px-4">
<div className="min-w-240 overflow-hidden rounded-lg border bg-card">
<div className="grid grid-cols-[minmax(14rem,1.35fr)_minmax(9rem,0.8fr)_minmax(12rem,1fr)_7rem_5rem_6rem_2.5rem] gap-x-4 bg-muted/40 px-4 py-3 text-sm font-medium text-muted-foreground">
<span>Session</span>
@@ -293,7 +295,7 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
<span>Tokens</span>
<span>Cost</span>
<span>Updated</span>
<span />
<span className="sr-only">Actions</span>
</div>
<div>
{history.isLoadingHistory && history.threads.length === 0 ? (
@@ -412,12 +414,15 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
type="button"
>
<span className="flex min-w-0 items-center gap-3 font-semibold">
<span className="sr-only">Open session: </span>
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
statusTone(thread.status),
)}
aria-hidden="true"
/>
<span className="sr-only">{thread.status}: </span>
<span className="truncate">{thread.title}</span>
</span>
<span className="flex min-w-0 items-center gap-2 text-muted-foreground">
@@ -440,42 +445,44 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
</span>
</button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
aria-label={`Session actions for ${thread.title}`}
className="grid size-7 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
disabled={Boolean(pendingKind)}
type="button"
>
{pendingKind ? (
<Loader2 className="size-4 animate-spin" />
) : (
<MoreHorizontal className="size-4" />
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={6}>
<DropdownMenuItem onClick={() => startRename(thread)}>
<Pencil className="size-4" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void history.forkThread(thread.id)}
>
<GitFork className="size-4" />
Fork
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setDeleteCandidate(thread)}
variant="destructive"
>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
aria-label={`Session actions for ${thread.title}`}
className="grid size-7 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
disabled={Boolean(pendingKind)}
type="button"
>
{pendingKind ? (
<Loader2 className="size-4 animate-spin" />
) : (
<MoreHorizontal className="size-4" />
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={6}>
<DropdownMenuItem onClick={() => startRename(thread)}>
<Pencil className="size-4" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void history.forkThread(thread.id)}
>
<GitFork className="size-4" />
Fork
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setDeleteCandidate(thread)}
variant="destructive"
>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
})}
@@ -1,7 +1,4 @@
import { X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Switch } from "@/components/ui/switch";
import { desktopClient } from "@/lib/desktop-client";
import type {
@@ -16,7 +13,6 @@ import {
readSystemHubTheme,
setStoredHubTheme,
} from "@/lib/theme";
import { cn } from "@/lib/utils";
import { PageFrame, PageHeader } from "../page-layout";
import { AccountView } from "./account-view";
import { AddProviderContent, type AddProviderPayload } from "./add-provider";
@@ -34,18 +30,22 @@ import { toSettingsPatch } from "./settings-patch";
// Settings nav categories
// -----------------------------------------------------------
const navCategories = [
export const SETTINGS_SECTIONS = [
"General",
"Providers",
"MCP",
"Marketplace",
"Extensions",
"Models",
"MCP Servers",
"MCP Marketplace",
"Customizations",
"Channels",
"Schedules",
"Account",
] as const;
export type SettingsSection = (typeof navCategories)[number];
export type SettingsSection = (typeof SETTINGS_SECTIONS)[number];
type GlobalSettingsResponse = {
telemetryOptOut: boolean;
autoUpdateEnabled: boolean;
};
const PROVIDER_CATALOG_CACHE_TTL_MS = 60_000;
@@ -59,17 +59,13 @@ let providerCatalogCache: {
// -----------------------------------------------------------
export function SettingsView({
chrome = "full",
initialSection = "General",
onClose,
section,
onNavigateSection,
}: {
chrome?: "full" | "content";
initialSection?: SettingsSection;
onClose: () => void;
onNavigateSection?: (section: SettingsSection) => void;
section: SettingsSection;
onNavigateSection: (section: SettingsSection) => void;
}) {
const [activeNav, setActiveNav] = useState<SettingsSection>(initialSection);
const activeNav = section;
const [providers, setProviders] = useState<Provider[]>(
() => providerCatalogCache?.providers ?? [],
);
@@ -93,6 +89,13 @@ export function SettingsView({
);
const [addingProvider, setAddingProvider] = useState(false);
useEffect(() => {
if (section !== "Models") {
setSelectedProviderId(null);
setAddingProvider(false);
}
}, [section]);
const setProvidersWithCache = useCallback(
(next: Provider[] | ((prev: Provider[]) => Provider[])) => {
setProviders((prev) => {
@@ -139,7 +142,7 @@ export function SettingsView({
}, [setProvidersWithCache]);
useEffect(() => {
if (activeNav !== "Providers") {
if (activeNav !== "Models") {
return;
}
const timeoutId = window.setTimeout(() => {
@@ -288,8 +291,7 @@ export function SettingsView({
};
const openProviderDetail = (id: string) => {
setActiveNav("Providers");
onNavigateSection?.("Providers");
onNavigateSection("Models");
setSelectedProviderId(id);
};
@@ -310,7 +312,7 @@ export function SettingsView({
}, [loadProviderModels, providers, selectedProviderId]);
const backToProviderList = () => {
onNavigateSection?.("Providers");
onNavigateSection("Models");
setSelectedProviderId(null);
setAddingProvider(false);
};
@@ -337,18 +339,11 @@ export function SettingsView({
);
const openAddProvider = () => {
onNavigateSection?.("Providers");
onNavigateSection("Models");
setSelectedProviderId(null);
setAddingProvider(true);
};
const selectSection = (section: SettingsSection) => {
setActiveNav(section);
onNavigateSection?.(section);
setSelectedProviderId(null);
setAddingProvider(false);
};
const providerContent = addingProvider ? (
<AddProviderContent
existingProviderIds={providers.map((provider) => provider.id)}
@@ -403,13 +398,13 @@ export function SettingsView({
);
const content =
activeNav === "Providers" ? (
activeNav === "Models" ? (
providerContent
) : activeNav === "MCP" ? (
) : activeNav === "MCP Servers" ? (
<McpServersContent />
) : activeNav === "Marketplace" ? (
) : activeNav === "MCP Marketplace" ? (
<CustomizationSectionView catalogPrimitive="mcp" section="MCP" />
) : activeNav === "Extensions" ? (
) : activeNav === "Customizations" ? (
<RulesView />
) : activeNav === "Channels" ? (
<ChannelsContent />
@@ -427,57 +422,10 @@ export function SettingsView({
</div>
);
if (chrome === "content") {
return (
<div className="h-full overflow-hidden bg-background">{content}</div>
);
}
return (
<div className="flex h-full flex-col overflow-hidden bg-background">
{/* Header bar */}
<div className="flex shrink-0 items-center justify-between border-b border-border px-6 py-3">
<h1 className="text-lg font-semibold text-foreground">Settings</h1>
<Button
aria-label="Close settings"
className="justify-start"
onClick={onClose}
variant="ghost"
>
<X className="size-3" />
</Button>
</div>
{/* Body */}
<div className="flex flex-1 overflow-hidden">
{/* Settings sidebar nav */}
<nav className="w-56 shrink-0 border-r border-border">
<ScrollArea className="h-full">
<div className="flex flex-col gap-0.5 p-3">
{navCategories.map((cat) => (
<Button
className={cn(
"justify-start",
activeNav === cat
? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
key={cat}
onClick={() => {
selectSection(cat);
}}
variant="ghost"
>
{cat}
</Button>
))}
</div>
</ScrollArea>
</nav>
{/* Content area */}
<div className="flex-1 overflow-hidden">{content}</div>
</div>
<div className="grid h-full grid-rows-[3rem_minmax(0,1fr)] overflow-hidden bg-background md:block">
<div aria-hidden="true" className="md:hidden" />
<div className="min-h-0 overflow-hidden md:h-full">{content}</div>
</div>
);
}
@@ -487,6 +435,82 @@ function GeneralSettingsContent() {
if (typeof window === "undefined") return "light";
return readStoredHubTheme() ?? readSystemHubTheme();
});
const [telemetryOptOut, setTelemetryOptOut] = useState(false);
const [telemetryLoading, setTelemetryLoading] = useState(true);
const [telemetrySaving, setTelemetrySaving] = useState(false);
const [telemetryError, setTelemetryError] = useState<string | null>(null);
const [autoUpdateEnabled, setAutoUpdateEnabled] = useState(true);
const [autoUpdateLoading, setAutoUpdateLoading] = useState(true);
const [autoUpdateSaving, setAutoUpdateSaving] = useState(false);
const [autoUpdateError, setAutoUpdateError] = useState<string | null>(null);
const loadGlobalSettings = useCallback(async () => {
setTelemetryLoading(true);
setTelemetryError(null);
setAutoUpdateLoading(true);
setAutoUpdateError(null);
try {
const settings = await desktopClient.invoke<GlobalSettingsResponse>(
"get_global_settings",
);
setTelemetryOptOut(settings.telemetryOptOut);
setAutoUpdateEnabled(settings.autoUpdateEnabled);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setTelemetryError(message);
setAutoUpdateError(message);
} finally {
setTelemetryLoading(false);
setAutoUpdateLoading(false);
}
}, []);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
void loadGlobalSettings();
}, 0);
return () => window.clearTimeout(timeoutId);
}, [loadGlobalSettings]);
const updateTelemetryOptOut = async (nextValue: boolean) => {
const previousValue = telemetryOptOut;
setTelemetryOptOut(nextValue);
setTelemetrySaving(true);
setTelemetryError(null);
try {
const settings = await desktopClient.invoke<GlobalSettingsResponse>(
"set_telemetry_opt_out",
{ telemetry_opt_out: nextValue },
);
setTelemetryOptOut(settings.telemetryOptOut);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setTelemetryOptOut(previousValue);
setTelemetryError(message);
} finally {
setTelemetrySaving(false);
}
};
const updateAutoUpdateEnabled = async (nextValue: boolean) => {
const previousValue = autoUpdateEnabled;
setAutoUpdateEnabled(nextValue);
setAutoUpdateSaving(true);
setAutoUpdateError(null);
try {
const settings = await desktopClient.invoke<GlobalSettingsResponse>(
"set_auto_update_enabled",
{ auto_update_enabled: nextValue },
);
setAutoUpdateEnabled(settings.autoUpdateEnabled);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setAutoUpdateEnabled(previousValue);
setAutoUpdateError(message);
} finally {
setAutoUpdateSaving(false);
}
};
const updateTheme = (darkModeEnabled: boolean) => {
const nextTheme = darkModeEnabled ? "dark" : "light";
@@ -515,6 +539,48 @@ function GeneralSettingsContent() {
onCheckedChange={updateTheme}
/>
</div>
<div className="flex min-h-20 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
<div>
<p className="text-[17px] font-semibold text-foreground">
Auto update
</p>
<p className="mt-1 text-[15px] text-muted-foreground">
Automatically install Cline CLI updates on startup.
</p>
{autoUpdateError ? (
<p className="mt-2 text-xs text-destructive" role="alert">
Failed to update auto update setting: {autoUpdateError}
</p>
) : null}
</div>
<Switch
aria-label="Auto update"
checked={autoUpdateEnabled}
disabled={autoUpdateLoading || autoUpdateSaving}
onCheckedChange={(checked) => void updateAutoUpdateEnabled(checked)}
/>
</div>
<div className="flex min-h-20 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
<div>
<p className="text-[17px] font-semibold text-foreground">
Telemetry
</p>
<p className="mt-1 text-[15px] text-muted-foreground">
Enable error and usage reports to help improve Cline.
</p>
{telemetryError ? (
<p className="mt-2 text-xs text-destructive" role="alert">
Failed to update telemetry setting: {telemetryError}
</p>
) : null}
</div>
<Switch
aria-label="Telemetry"
checked={!telemetryOptOut}
disabled={telemetryLoading || telemetrySaving}
onCheckedChange={(checked) => void updateTelemetryOptOut(!checked)}
/>
</div>
</section>
</PageFrame>
);
@@ -1,6 +1,7 @@
import type { ChatSessionConfig } from "@/lib/chat-schema";
import { readModelSelectionStorageFromWindow } from "@/lib/model-selection";
import { normalizeProviderId } from "@/lib/provider-id";
import { readWorkspaceSelectionFromWindow } from "@/lib/workspace-paths";
export const CHAT_TRANSPORT_UNAVAILABLE_MESSAGE =
"Chat connection is unavailable. Reopen the app window to restore realtime chat.";
@@ -40,6 +41,7 @@ export const DEFAULT_CHAT_CONFIG: ChatSessionConfig = {
export function getInitialChatConfig(): ChatSessionConfig {
const selection = readModelSelectionStorageFromWindow();
const workspaceSelection = readWorkspaceSelectionFromWindow();
const rememberedProvider = normalizeProviderId(selection.lastProvider);
const rememberedModelForProvider = rememberedProvider
? (selection.lastModelByProvider[rememberedProvider] ??
@@ -59,5 +61,7 @@ export function getInitialChatConfig(): ChatSessionConfig {
...DEFAULT_CHAT_CONFIG,
provider,
model,
workspaceRoot: workspaceSelection.lastWorkspace,
cwd: workspaceSelection.lastWorkspace,
};
}
@@ -1,3 +1,4 @@
import { createSessionId } from "@cline/shared/browser";
import type {
ChatMessage,
ChatSessionConfig,
@@ -12,7 +13,7 @@ type RpcMessageLike = {
};
export function makeId(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
return createSessionId(`${prefix}_`);
}
function stringifyRpcMessageContent(content: unknown): string {
@@ -0,0 +1,520 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useChatSession } from "./use-chat-session";
const { invokeMock } = vi.hoisted(() => ({
invokeMock: vi.fn(),
}));
vi.mock("@/lib/desktop-client", () => ({
desktopClient: {
getTransportError: vi.fn(() => null),
getTransportState: vi.fn(() => "connected"),
invoke: invokeMock,
subscribe: vi.fn(() => () => undefined),
subscribeTransportState: vi.fn(() => () => undefined),
},
}));
type ChatSessionHook = ReturnType<typeof useChatSession>;
let container: HTMLDivElement;
let root: Root;
let current: ChatSessionHook;
function HookHarness() {
current = useChatSession();
return null;
}
beforeEach(async () => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
window.localStorage.clear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
invokeMock.mockReset();
invokeMock.mockImplementation(async (command: string) => {
if (command === "get_process_context") {
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
}
return [];
});
await act(async () => root.render(<HookHarness />));
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.restoreAllMocks();
});
describe("useChatSession", () => {
it("publishes the first user message before cold session startup resolves", async () => {
let resolveStart: ((value: { sessionId: string }) => void) | undefined;
const startResponse = new Promise<{ sessionId: string }>((resolve) => {
resolveStart = resolve;
});
let plannedSessionId = "";
let startConfig:
| { thinking?: boolean; reasoningEffort?: string; sessionId?: string }
| undefined;
invokeMock.mockImplementation(
async (command: string, args?: Record<string, unknown>) => {
if (command === "get_process_context") {
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
}
if (command === "chat_session_command") {
const request = args?.request as
| {
action?: string;
config?: {
sessionId?: string;
thinking?: boolean;
reasoningEffort?: string;
};
}
| undefined;
if (request?.action === "start") {
plannedSessionId = request.config?.sessionId ?? "";
startConfig = request.config;
return await startResponse;
}
if (request?.action === "send") {
return {
ok: true,
result: { text: "Ready", finishReason: "completed" },
};
}
return { promptsInQueue: [] };
}
return [];
},
);
await act(async () => {
current.setConfig((previous) => ({
...previous,
thinking: true,
reasoningEffort: "high",
}));
});
let sendPromise: Promise<void> | undefined;
await act(async () => {
sendPromise = current.sendPrompt("Start the task");
await Promise.resolve();
});
expect(current.status).toBe("starting");
expect(current.messages).toHaveLength(1);
expect(current.messages[0]).toMatchObject({
role: "user",
content: "Start the task",
});
expect(current.messages[0]?.sessionId).toMatch(/^session_/);
expect(plannedSessionId).toBe(current.messages[0]?.sessionId);
expect(startConfig).toMatchObject({
thinking: true,
reasoningEffort: "high",
sessionId: plannedSessionId,
});
await act(async () => {
resolveStart?.({ sessionId: plannedSessionId });
await sendPromise;
});
expect(
current.messages.some((message) => message.content === "Ready"),
).toBe(true);
});
it("overlaps attachment serialization with cold session startup", async () => {
let resolveStart: ((value: { sessionId: string }) => void) | undefined;
let resolveFile: ((value: string) => void) | undefined;
const startResponse = new Promise<{ sessionId: string }>((resolve) => {
resolveStart = resolve;
});
const fileContent = new Promise<string>((resolve) => {
resolveFile = resolve;
});
const text = vi.fn(async () => await fileContent);
const attachment = {
name: "notes.txt",
type: "text/plain",
size: 5,
lastModified: 1,
text,
} as unknown as File;
let plannedSessionId = "";
let sentAttachments: unknown;
invokeMock.mockImplementation(
async (command: string, args?: Record<string, unknown>) => {
if (command === "get_process_context") {
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
}
if (command === "chat_session_command") {
const request = args?.request as
| {
action?: string;
config?: { sessionId?: string };
attachments?: unknown;
}
| undefined;
if (request?.action === "start") {
plannedSessionId = request.config?.sessionId ?? "";
return await startResponse;
}
if (request?.action === "send") {
sentAttachments = request.attachments;
return {
ok: true,
result: { text: "Done", finishReason: "completed" },
};
}
return { promptsInQueue: [] };
}
return [];
},
);
let sendPromise: Promise<void> | undefined;
await act(async () => {
sendPromise = current.sendPrompt("Read this", [attachment]);
await Promise.resolve();
});
expect(text).toHaveBeenCalledTimes(1);
expect(plannedSessionId).toMatch(/^session_/);
await act(async () => {
resolveStart?.({ sessionId: plannedSessionId });
await Promise.resolve();
});
expect(sentAttachments).toBeUndefined();
await act(async () => {
resolveFile?.("hello");
await sendPromise;
});
expect(sentAttachments).toEqual({
userImages: [],
userFiles: [{ name: "notes.txt", content: "hello" }],
});
});
it("shares one cold start and queues a second prompt behind it", async () => {
let resolveStart: ((value: { sessionId: string }) => void) | undefined;
const startResponse = new Promise<{ sessionId: string }>((resolve) => {
resolveStart = resolve;
});
const actions: Array<{
action?: string;
delivery?: string;
sessionId?: string;
}> = [];
let plannedSessionId = "";
invokeMock.mockImplementation(
async (command: string, args?: Record<string, unknown>) => {
if (command === "get_process_context") {
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
}
if (command === "chat_session_command") {
const request = args?.request as
| {
action?: string;
delivery?: string;
sessionId?: string;
config?: { sessionId?: string };
}
| undefined;
actions.push(request ?? {});
if (request?.action === "start") {
plannedSessionId = request.config?.sessionId ?? "";
return await startResponse;
}
if (request?.action === "send" && request.delivery === "queue") {
return { ok: true, queued: true, promptsInQueue: [] };
}
if (request?.action === "send") {
return {
ok: true,
result: { text: "First done", finishReason: "completed" },
};
}
return { promptsInQueue: [] };
}
return [];
},
);
let firstSend: Promise<void> | undefined;
let secondSend: Promise<void> | undefined;
await act(async () => {
firstSend = current.sendPrompt("First prompt");
await Promise.resolve();
});
await act(async () => {
secondSend = current.sendPrompt("Second prompt");
await Promise.resolve();
});
expect(
actions.filter((request) => request.action === "start"),
).toHaveLength(1);
expect(current.promptsInQueue.map((item) => item.prompt)).toContain(
"Second prompt",
);
await act(async () => {
resolveStart?.({ sessionId: plannedSessionId });
await Promise.all([firstSend, secondSend]);
});
const sends = actions.filter((request) => request.action === "send");
expect(sends).toHaveLength(2);
expect(sends.map((request) => request.sessionId)).toEqual([
plannedSessionId,
plannedSessionId,
]);
expect(sends.map((request) => request.delivery)).toEqual([
undefined,
"queue",
]);
});
it("preserves prompt order when the first prompt has a slow attachment", async () => {
let resolveFile: ((value: string) => void) | undefined;
const fileContent = new Promise<string>((resolve) => {
resolveFile = resolve;
});
const attachment = {
name: "slow.txt",
type: "text/plain",
size: 5,
lastModified: 1,
text: vi.fn(async () => await fileContent),
} as unknown as File;
const sends: Array<{
prompt?: string;
delivery?: string;
sessionId?: string;
}> = [];
invokeMock.mockImplementation(
async (command: string, args?: Record<string, unknown>) => {
if (command === "get_process_context") {
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
}
if (command === "chat_session_command") {
const request = args?.request as
| {
action?: string;
prompt?: string;
delivery?: string;
sessionId?: string;
config?: { sessionId?: string };
}
| undefined;
if (request?.action === "start") {
return { sessionId: request.config?.sessionId };
}
if (request?.action === "send") {
sends.push(request);
return request.delivery === "queue"
? { ok: true, queued: true, promptsInQueue: [] }
: {
ok: true,
result: { text: "Done", finishReason: "completed" },
};
}
return { promptsInQueue: [] };
}
return [];
},
);
let firstSend: Promise<void> | undefined;
let secondSend: Promise<void> | undefined;
await act(async () => {
firstSend = current.sendPrompt("First prompt", [attachment]);
await Promise.resolve();
});
await act(async () => {
secondSend = current.sendPrompt("Second prompt");
await Promise.resolve();
});
expect(sends).toHaveLength(0);
await act(async () => {
resolveFile?.("hello");
await Promise.all([firstSend, secondSend]);
});
expect(sends.map(({ prompt, delivery }) => ({ prompt, delivery }))).toEqual(
[
{ prompt: "First prompt", delivery: undefined },
{ prompt: "Second prompt", delivery: "queue" },
],
);
});
it("starts a fresh session when a cold start fails and the user retries", async () => {
let startAttempts = 0;
const actions: string[] = [];
invokeMock.mockImplementation(
async (command: string, args?: Record<string, unknown>) => {
if (command === "get_process_context") {
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
}
if (command === "chat_session_command") {
const request = args?.request as
| { action?: string; config?: { sessionId?: string } }
| undefined;
actions.push(request?.action ?? "unknown");
if (request?.action === "start") {
startAttempts += 1;
if (startAttempts === 1) throw new Error("start failed");
return { sessionId: request.config?.sessionId };
}
if (request?.action === "send") {
return {
ok: true,
result: { text: "Recovered", finishReason: "completed" },
};
}
return { promptsInQueue: [] };
}
return [];
},
);
await act(async () => current.sendPrompt("First attempt"));
expect(current.status).toBe("error");
await act(async () => current.sendPrompt("Retry"));
expect(actions.filter((action) => action !== "pending_prompts")).toEqual([
"start",
"start",
"send",
]);
expect(
current.messages.some((message) => message.content === "Recovered"),
).toBe(true);
});
it("falls back to process context when the remembered workspace is stale", async () => {
await act(async () => root.unmount());
window.localStorage.setItem(
"cline.code.workspace-selection.v1",
JSON.stringify({
lastWorkspace: "/workspace/deleted",
workspaces: ["/workspace/deleted"],
}),
);
invokeMock.mockImplementation(async (command: string) => {
if (command === "get_process_context") {
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
}
if (command === "validate_workspace_directory") {
return { valid: false };
}
return [];
});
root = createRoot(container);
await act(async () => root.render(<HookHarness />));
await vi.waitFor(() => {
expect(current.config.workspaceRoot).toBe("/workspace/cline");
expect(current.config.cwd).toBe("/workspace/cline");
});
expect(invokeMock).toHaveBeenCalledWith("validate_workspace_directory", {
path: "/workspace/deleted",
});
});
it("applies a remembered workspace that becomes available while process context is loading", async () => {
await act(async () => root.unmount());
let resolveContext:
| ((value: { cwd: string; workspaceRoot: string }) => void)
| undefined;
const contextResponse = new Promise<{
cwd: string;
workspaceRoot: string;
}>((resolve) => {
resolveContext = resolve;
});
invokeMock.mockImplementation(async (command: string) => {
if (command === "get_process_context") {
return await contextResponse;
}
if (command === "validate_workspace_directory") {
return { valid: true };
}
return [];
});
root = createRoot(container);
await act(async () => root.render(<HookHarness />));
await vi.waitFor(() => {
expect(invokeMock).toHaveBeenCalledWith("get_process_context");
});
window.localStorage.setItem(
"cline.code.workspace-selection.v1",
JSON.stringify({
lastWorkspace: "/workspace/remembered",
workspaces: ["/workspace/remembered"],
}),
);
await act(async () => {
resolveContext?.({
cwd: "/workspace/default",
workspaceRoot: "/workspace/default",
});
await contextResponse;
});
await vi.waitFor(() => {
expect(current.config.workspaceRoot).toBe("/workspace/remembered");
expect(current.config.cwd).toBe("/workspace/remembered");
});
expect(invokeMock).toHaveBeenCalledWith("validate_workspace_directory", {
path: "/workspace/remembered",
});
});
it("preserves a workspace selected while process context is loading", async () => {
await act(async () => root.unmount());
let resolveContext:
| ((value: { cwd: string; workspaceRoot: string }) => void)
| undefined;
const contextResponse = new Promise<{
cwd: string;
workspaceRoot: string;
}>((resolve) => {
resolveContext = resolve;
});
invokeMock.mockImplementation(async (command: string) => {
if (command === "get_process_context") return await contextResponse;
return [];
});
root = createRoot(container);
await act(async () => root.render(<HookHarness />));
await act(async () => {
current.setConfig((previous) => ({
...previous,
workspaceRoot: "/workspace/selected",
cwd: "/workspace/selected",
}));
});
await act(async () => {
resolveContext?.({
cwd: "/workspace/default",
workspaceRoot: "/workspace/default",
});
await contextResponse;
});
expect(current.config.workspaceRoot).toBe("/workspace/selected");
expect(current.config.cwd).toBe("/workspace/selected");
});
});
@@ -34,15 +34,19 @@ import {
import { desktopClient } from "@/lib/desktop-client";
import {
buildSessionDiffState,
type SessionHookEvent,
EMPTY_DIFF_SUMMARY,
type SessionDiffSummary,
type SessionFileDiff,
type SessionHookEvent,
} from "@/lib/session-diff";
import type {
SessionHistoryItem,
SessionHistoryStatus,
} from "@/lib/session-history";
import {
normalizeWorkspacePath,
readWorkspaceSelectionFromWindow,
} from "@/lib/workspace-paths";
export { DEFAULT_CHAT_CONFIG } from "@/hooks/chat-session/constants";
@@ -240,6 +244,9 @@ export function useChatSession() {
null,
);
const hydrationRequestIdRef = useRef(0);
const sessionStartPromiseRef = useRef<Promise<string> | null>(null);
const promptDispatchTailRef = useRef<Promise<void>>(Promise.resolve());
const activePromptSubmissionsRef = useRef(0);
const [chatTransportState, setChatTransportState] =
useState<ChatTransportState>(desktopClient.getTransportState());
const [chatTransportError, setChatTransportError] = useState<string | null>(
@@ -468,11 +475,33 @@ export function useChatSession() {
const ctx = await desktopClient.invoke<ProcessContext>(
"get_process_context",
);
setConfig((prev) => ({
...prev,
workspaceRoot: ctx.workspaceRoot || ctx.cwd,
cwd: ctx.workspaceRoot || ctx.cwd,
}));
const rememberedWorkspace =
readWorkspaceSelectionFromWindow().lastWorkspace;
const validation = rememberedWorkspace
? await desktopClient
.invoke<{ valid?: boolean }>("validate_workspace_directory", {
path: rememberedWorkspace,
})
.catch(() => ({ valid: false }))
: { valid: false };
setConfig((prev) => {
const currentWorkspace = (prev.workspaceRoot || prev.cwd || "").trim();
const selectionChangedWhileLoading = Boolean(
currentWorkspace &&
normalizeWorkspacePath(currentWorkspace) !==
normalizeWorkspacePath(rememberedWorkspace),
);
const workspace = selectionChangedWhileLoading
? currentWorkspace
: validation.valid === true
? rememberedWorkspace
: ctx.workspaceRoot || ctx.cwd;
return {
...prev,
workspaceRoot: workspace,
cwd: workspace,
};
});
} catch {
// Ignore in non-Tauri mode.
}
@@ -914,7 +943,10 @@ export function useChatSession() {
// ---- Shared: start a new session via RPC ----
const startSession = useCallback(
async (validatedConfig: ChatSessionConfig): Promise<string> => {
async (
validatedConfig: ChatSessionConfig,
options: { preserveStatus?: boolean } = {},
): Promise<string> => {
const payload = await postSession({
action: "start",
config: validatedConfig,
@@ -925,7 +957,9 @@ export function useChatSession() {
// Mark idle — not running — so the first sendPrompt is not queued.
// The status transitions to "starting"/"running" once a prompt is
// actually dispatched.
setStatus("idle");
if (!options.preserveStatus) {
setStatus("idle");
}
setConfig(validatedConfig);
setHydratedHistorySessionId(null);
return id;
@@ -987,7 +1021,8 @@ export function useChatSession() {
setIsHydratingSession(false);
abortedRef.current = false;
clearAbortFallbackTimeout();
let activeSessionId = sessionId;
const pendingSessionStart = sessionStartPromiseRef.current;
let activeSessionId = sessionId ?? activeSessionIdRef.current;
const validation = validateConfig(config);
if (!validation.parsed) {
@@ -995,51 +1030,67 @@ export function useChatSession() {
return;
}
const parsed = validation.parsed;
if (activeSessionId && hydratedHistorySessionId === activeSessionId) {
try {
activeSessionId = await startSession({
...parsed,
sessionId: activeSessionId,
});
} catch (err) {
setErrorState(errorMessage(err), activeSessionId);
return;
const hasEarlierPromptSubmission = activePromptSubmissionsRef.current > 0;
activePromptSubmissionsRef.current += 1;
let promptSubmissionFinished = false;
const finishPromptSubmission = () => {
if (promptSubmissionFinished) return;
promptSubmissionFinished = true;
activePromptSubmissionsRef.current = Math.max(
0,
activePromptSubmissionsRef.current - 1,
);
};
const precedingPromptDispatch = promptDispatchTailRef.current;
let resolvePromptDispatch: (() => void) | undefined;
const ownPromptDispatch = new Promise<void>((resolve) => {
resolvePromptDispatch = resolve;
});
const promptDispatchTail = precedingPromptDispatch.then(
() => ownPromptDispatch,
);
promptDispatchTailRef.current = promptDispatchTail;
let promptDispatchReleased = false;
const releasePromptDispatch = () => {
if (promptDispatchReleased) return;
promptDispatchReleased = true;
resolvePromptDispatch?.();
};
void promptDispatchTail.then(() => {
if (promptDispatchTailRef.current === promptDispatchTail) {
promptDispatchTailRef.current = Promise.resolve();
}
}
if (!activeSessionId) {
try {
activeSessionId = await startSession(parsed);
} catch (err) {
setErrorState(errorMessage(err));
return;
}
}
});
const now = Date.now();
const shouldQueue = Boolean(activeSessionId) && BUSY_STATUSES.has(status);
const serializedAttachments = await serializeAttachments(attachedFiles);
const hasAttachments =
serializedAttachments.userImages.length > 0 ||
serializedAttachments.userFiles.length > 0;
const userLabel = hasAttachments
? `${trimmed}${trimmed.length > 0 ? "\n\n" : ""}[attached ${attachedFiles.length} file${attachedFiles.length === 1 ? "" : "s"}]`
: trimmed;
const serializedAttachmentsTask = serializeAttachments(
attachedFiles,
).then(
(attachments) => ({ ok: true as const, attachments }),
(error: unknown) => ({ ok: false as const, error }),
);
const userLabel =
attachedFiles.length > 0
? `${trimmed}${trimmed.length > 0 ? "\n\n" : ""}[attached ${attachedFiles.length} file${attachedFiles.length === 1 ? "" : "s"}]`
: trimmed;
const shouldQueue =
Boolean(activeSessionId) &&
(hasEarlierPromptSubmission ||
Boolean(pendingSessionStart) ||
BUSY_STATUSES.has(status));
const optimisticQueuedPromptId = shouldQueue
? makeId("queued_prompt")
: null;
const plannedSessionId = activeSessionId ?? makeId("session");
if (!shouldQueue) {
addMessage({
id: makeId("user"),
sessionId: activeSessionId,
sessionId: plannedSessionId,
role: "user",
content: userLabel,
createdAt: now,
});
activeSessionIdRef.current = activeSessionId;
activeSessionIdRef.current = plannedSessionId;
activeAssistantMessageIdRef.current = null;
setActiveAssistantMessageId(null);
clearLiveToolRefs();
@@ -1054,8 +1105,90 @@ export function useChatSession() {
},
]);
}
let sendTask: ReturnType<typeof postSession> | null = null;
try {
const payload = await postSession({
if (pendingSessionStart) {
try {
activeSessionId = await pendingSessionStart;
} catch (err) {
if (optimisticQueuedPromptId) {
setPromptsInQueue((prev) =>
prev.filter((item) => item.id !== optimisticQueuedPromptId),
);
}
setErrorState(errorMessage(err), activeSessionId);
finishPromptSubmission();
return;
}
} else if (
activeSessionId &&
hydratedHistorySessionId === activeSessionId
) {
const startPromise = startSession(
{
...parsed,
sessionId: activeSessionId,
},
{ preserveStatus: true },
);
sessionStartPromiseRef.current = startPromise;
try {
activeSessionId = await startPromise;
} catch (err) {
setErrorState(errorMessage(err), activeSessionId);
finishPromptSubmission();
return;
} finally {
if (sessionStartPromiseRef.current === startPromise) {
sessionStartPromiseRef.current = null;
}
}
}
if (!activeSessionId) {
const startPromise = startSession(
{
...parsed,
sessionId: plannedSessionId,
},
{ preserveStatus: true },
);
sessionStartPromiseRef.current = startPromise;
try {
activeSessionId = await startPromise;
} catch (err) {
if (activeSessionIdRef.current === plannedSessionId) {
activeSessionIdRef.current = null;
}
setErrorState(errorMessage(err));
finishPromptSubmission();
return;
} finally {
if (sessionStartPromiseRef.current === startPromise) {
sessionStartPromiseRef.current = null;
}
}
}
const serializedAttachmentsResult = await serializedAttachmentsTask;
if (!serializedAttachmentsResult.ok) {
setErrorState(
errorMessage(serializedAttachmentsResult.error),
activeSessionId,
);
finishPromptSubmission();
return;
}
const serializedAttachments = serializedAttachmentsResult.attachments;
const hasAttachments =
serializedAttachments.userImages.length > 0 ||
serializedAttachments.userFiles.length > 0;
if (!shouldQueue) {
activeSessionIdRef.current = activeSessionId;
setStatus("starting");
}
await precedingPromptDispatch;
sendTask = postSession({
action: "send",
sessionId: activeSessionId,
prompt: trimmed,
@@ -1063,6 +1196,15 @@ export function useChatSession() {
config: parsed,
attachments: hasAttachments ? serializedAttachments : undefined,
});
} finally {
releasePromptDispatch();
}
if (!sendTask) {
finishPromptSubmission();
return;
}
try {
const payload = await sendTask;
if (payload.ok && payload.queued) {
applyPromptsInQueue(payload.promptsInQueue);
setStatus("running");
@@ -1266,6 +1408,7 @@ export function useChatSession() {
setActiveAssistantMessageId(null);
clearLiveToolRefs();
}
finishPromptSubmission();
}
},
[
@@ -1431,6 +1574,7 @@ export function useChatSession() {
sessionId: undefined,
}));
activeSessionIdRef.current = null;
sessionStartPromiseRef.current = null;
activeAssistantMessageIdRef.current = null;
setActiveAssistantMessageId(null);
setHydratedHistorySessionId(null);
@@ -19,6 +19,7 @@ export interface SessionThread {
id: string;
title: string;
codebase: string;
workspacePath: string;
time: string;
provider: string;
model: string;
@@ -224,10 +225,12 @@ function inferStatusFromMessages(
}
function toThread(session: SessionHistoryItem): SessionThread {
const workspacePath = (session.workspaceRoot || session.cwd).trim();
return {
id: session.sessionId,
title: toTitle(session),
codebase: basenamePath(session.workspaceRoot || session.cwd),
codebase: basenamePath(workspacePath),
workspacePath,
time: formatRelativeTime(session.endedAt || session.startedAt),
provider: session.provider || "",
model: session.model || "",
@@ -355,6 +358,7 @@ function areThreadsEquivalent(
a.id !== b.id ||
a.title !== b.title ||
a.codebase !== b.codebase ||
a.workspacePath !== b.workspacePath ||
a.time !== b.time ||
a.provider !== b.provider ||
a.model !== b.model ||
@@ -1222,6 +1226,10 @@ export function useSessionHistory({
},
[refreshSessions],
);
const loadOlderSessions = useCallback(
() => loadMoreSessions(fetchLimitRef.current + INITIAL_HISTORY_FETCH_LIMIT),
[loadMoreSessions],
);
const mayHaveMoreSessions = sessions.length >= fetchLimitRef.current;
const sessionById = useMemo(
@@ -1233,6 +1241,7 @@ export function useSessionHistory({
getSessionByThreadId,
isLoadingHistory,
isLoadingMore,
loadOlderSessions,
loadMoreSessions,
mayHaveMoreSessions,
openThread,
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import type { SessionThread } from "@/hooks/use-session-history";
import {
groupThreadsByProject,
workspaceDisplayName,
} from "./sidebar-session-organization";
function thread(
id: string,
workspacePath: string,
overrides: Partial<SessionThread> = {},
): SessionThread {
return {
id,
title: id,
codebase: workspaceDisplayName(workspacePath),
workspacePath,
time: "now",
provider: "cline",
model: "test-model",
status: "completed",
...overrides,
};
}
describe("sidebar session organization", () => {
it("groups every loaded thread before applying per-project visibility", () => {
const threads = [
...Array.from({ length: 12 }, (_, index) =>
thread(`alpha-${index + 1}`, "/work/acme/repo"),
),
thread("beta-1", "/work/other/repo"),
];
const groups = groupThreadsByProject(threads);
expect(groups.map((group) => group.label)).toEqual([
"acme/repo",
"other/repo",
]);
expect(groups[0]?.threads).toHaveLength(12);
expect(groups[1]?.threads.map((item) => item.id)).toEqual(["beta-1"]);
});
it("uses the repository directory instead of the full workspace path", () => {
expect(workspaceDisplayName("/Users/saoud/code/cline/")).toBe("cline");
expect(workspaceDisplayName("C:\\Users\\saoud\\code\\cline\\")).toBe(
"cline",
);
});
});
@@ -0,0 +1,65 @@
import type { SessionThread } from "@/hooks/use-session-history";
import { normalizeWorkspacePath } from "@/lib/workspace-paths";
export const INITIAL_VISIBLE_THREAD_COUNT = 10;
export type SidebarProjectGroup = {
id: string;
label: string;
workspacePath: string;
threads: SessionThread[];
};
export function workspaceDisplayName(path: string): string {
const trimmed = path.trim().replace(/[\\/]+$/, "");
if (!trimmed) return "";
const segments = trimmed.split(/[\\/]/).filter(Boolean);
return segments.at(-1) || trimmed;
}
function uniqueWorkspaceLabel(path: string, workspacePaths: string[]): string {
if (!path) return "Other";
const segments = path
.replace(/[\\/]+$/, "")
.split(/[\\/]/)
.filter(Boolean);
const allSegments = workspacePaths.map((workspacePath) =>
workspacePath
.replace(/[\\/]+$/, "")
.split(/[\\/]/)
.filter(Boolean),
);
for (let depth = 1; depth <= segments.length; depth += 1) {
const candidate = segments.slice(-depth).join("/");
const matches = allSegments.filter(
(other) => other.slice(-depth).join("/") === candidate,
).length;
if (matches === 1) return candidate;
}
return path;
}
export function groupThreadsByProject(
threads: SessionThread[],
): SidebarProjectGroup[] {
const groups = new Map<
string,
{ workspacePath: string; threads: SessionThread[] }
>();
for (const thread of threads) {
const workspacePath = thread.workspacePath.trim();
const projectId = normalizeWorkspacePath(workspacePath) || "__other__";
const current = groups.get(projectId);
if (current) current.threads.push(thread);
else groups.set(projectId, { workspacePath, threads: [thread] });
}
const workspacePaths = [...groups.values()].map(
(group) => group.workspacePath,
);
return [...groups.entries()].map(([id, group]) => ({
id,
label: uniqueWorkspaceLabel(group.workspacePath, workspacePaths),
workspacePath: group.workspacePath,
threads: group.threads,
}));
}
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import {
mergeWorkspacePaths,
normalizeWorkspacePath,
parseWorkspaceSelectionStorage,
workspacePathsFromSessions,
} from "./workspace-paths";
describe("workspace paths", () => {
it("normalizes trailing separators and Windows path casing", () => {
expect(normalizeWorkspacePath(" /workspace/cline/ ")).toBe(
"/workspace/cline",
);
expect(normalizeWorkspacePath("C:\\Users\\Saoud\\Cline\\")).toBe(
"c:\\users\\saoud\\cline",
);
expect(normalizeWorkspacePath("/")).toBe("/");
});
it("retains known projects when discovery returns an incomplete subset", () => {
const known = ["/projects/a", "/projects/b", "/projects/c", "/projects/d"];
const afterFirstPick = mergeWorkspacePaths(known, [
"/projects/e",
"/projects/a/",
]);
const afterSecondPick = mergeWorkspacePaths(afterFirstPick, [
"/projects/f",
"/projects/b",
]);
expect(afterFirstPick).toEqual([
"/projects/a",
"/projects/b",
"/projects/c",
"/projects/d",
"/projects/e",
]);
expect(afterSecondPick).toEqual([
"/projects/a",
"/projects/b",
"/projects/c",
"/projects/d",
"/projects/e",
"/projects/f",
]);
});
it("builds the project catalog from every loaded history workspace", () => {
const sessions = Array.from({ length: 25 }, (_, index) => ({
workspaceRoot: `/projects/project-${String(index + 1).padStart(2, "0")}`,
}));
sessions.push({ workspaceRoot: "/projects/project-01/" });
const paths = workspacePathsFromSessions(sessions);
expect(paths).toHaveLength(25);
expect(paths).toContain("/projects/project-25");
});
it("restores the selected project and catalog across thread remounts", () => {
expect(
parseWorkspaceSelectionStorage(
JSON.stringify({
lastWorkspace: "/projects/selected/",
workspaces: ["/projects/one", "/projects/selected"],
}),
),
).toEqual({
lastWorkspace: "/projects/selected/",
workspaces: ["/projects/one", "/projects/selected"],
});
expect(parseWorkspaceSelectionStorage("not json")).toEqual({
lastWorkspace: "",
workspaces: [],
});
});
});
@@ -0,0 +1,109 @@
export const WORKSPACE_SELECTION_STORAGE_KEY =
"cline.code.workspace-selection.v1";
export type WorkspaceSelectionStorage = {
lastWorkspace: string;
workspaces: string[];
};
export type WorkspacePathSource = {
cwd?: string;
workspaceRoot?: string;
};
export function normalizeWorkspacePath(path: string): string {
const trimmed = path.trim();
if (!trimmed) {
return "";
}
const withoutTrailingSeparators = trimmed.replace(/[\\/]+$/, "");
const normalized = withoutTrailingSeparators || trimmed[0] || "";
return /^[A-Za-z]:/.test(normalized) ? normalized.toLowerCase() : normalized;
}
export function mergeWorkspacePaths(
...pathGroups: ReadonlyArray<readonly string[]>
): string[] {
const byNormalizedPath = new Map<string, string>();
for (const paths of pathGroups) {
for (const path of paths) {
const trimmed = path.trim();
const normalized = normalizeWorkspacePath(trimmed);
if (normalized && !byNormalizedPath.has(normalized)) {
byNormalizedPath.set(normalized, trimmed);
}
}
}
return [...byNormalizedPath.values()].sort((a, b) => a.localeCompare(b));
}
export function workspacePathsFromSessions(
sessions: readonly WorkspacePathSource[],
): string[] {
return mergeWorkspacePaths(
sessions.map((session) => session.workspaceRoot || session.cwd || ""),
);
}
export function parseWorkspaceSelectionStorage(
raw: string | null,
): WorkspaceSelectionStorage {
if (!raw) {
return { lastWorkspace: "", workspaces: [] };
}
try {
const parsed = JSON.parse(raw) as {
lastWorkspace?: unknown;
workspaces?: unknown;
};
const lastWorkspace =
typeof parsed?.lastWorkspace === "string"
? parsed.lastWorkspace.trim()
: "";
const workspaces = Array.isArray(parsed?.workspaces)
? parsed.workspaces.filter(
(workspace): workspace is string => typeof workspace === "string",
)
: [];
return {
lastWorkspace,
workspaces: mergeWorkspacePaths(workspaces, [lastWorkspace]),
};
} catch {
return { lastWorkspace: "", workspaces: [] };
}
}
export function readWorkspaceSelectionFromWindow(): WorkspaceSelectionStorage {
if (typeof window === "undefined") {
return { lastWorkspace: "", workspaces: [] };
}
try {
return parseWorkspaceSelectionStorage(
window.localStorage.getItem(WORKSPACE_SELECTION_STORAGE_KEY),
);
} catch {
return { lastWorkspace: "", workspaces: [] };
}
}
export function writeWorkspaceSelectionToWindow(
value: WorkspaceSelectionStorage,
): void {
if (typeof window === "undefined") {
return;
}
try {
window.localStorage.setItem(
WORKSPACE_SELECTION_STORAGE_KEY,
JSON.stringify({
lastWorkspace: value.lastWorkspace.trim(),
workspaces: mergeWorkspacePaths(value.workspaces, [
value.lastWorkspace,
]),
}),
);
} catch {
// Keep workspace switching functional when storage is unavailable.
}
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 466.73 487.04">
<path d="M463.6,275.08l-29.26-58.75v-33.83c0-56.08-45.01-101.5-100.53-101.5h-50.01c3.62-7.43,5.61-15.79,5.61-24.61,0-31.17-25.08-56.39-56.07-56.39s-56.07,25.22-56.07,56.39c0,8.82,1.99,17.17,5.61,24.61h-50.01c-55.51,0-100.52,45.42-100.52,101.5v33.83l-29.87,58.59c-3.01,5.9-3.01,12.92,0,18.81l29.87,57.93v33.83c0,56.08,45.01,101.5,100.52,101.5h200.95c55.51,0,100.53-45.42,100.53-101.5v-33.83l29.21-58.13c2.9-5.79,2.9-12.61.05-18.46ZM202.75,322.96c0,25.48-20.54,46.14-45.88,46.14s-45.88-20.66-45.88-46.14v-82.02c0-25.48,20.54-46.14,45.88-46.14s45.88,20.66,45.88,46.14v82.02ZM350.58,322.96c0,25.48-20.54,46.14-45.88,46.14s-45.88-20.66-45.88-46.14v-82.02c0-25.48,20.54-46.14,45.88-46.14s45.88,20.66,45.88,46.14v82.02Z"/>
</svg>

After

Width:  |  Height:  |  Size: 847 B

@@ -0,0 +1,10 @@
# Cline web visual foundation
The shared visual contract now lives in the internal
[`@cline/ui`](../../../../../sdk/packages/ui/README.md) workspace package
instead of beside the desktop app.
The desktop imports the complete `@cline/ui/theme/index.css` entry point. Other
Cline surfaces can import `@cline/ui/theme/tokens.css` without React or
Tailwind, or compose the Tailwind adapter and optional base styles in order.
Consuming apps still own their font files and shell-specific layout.
@@ -1,189 +0,0 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "@fontsource-variable/schibsted-grotesk";
@import "@fontsource/azeret-mono/latin.css";
@custom-variant dark (&:is(.dark *));
:root {
--font-desktop-sans: "Schibsted Grotesk Variable";
--font-desktop-mono: "Azeret Mono";
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}
@theme inline {
--font-sans: var(--font-desktop-sans), sans-serif;
--font-mono:
var(--font-desktop-mono), ui-monospace, "SFMono-Regular", Menlo, Consolas,
"Liberation Mono", monospace;
--font-weight-normal: 480;
--font-weight-medium: 560;
--font-weight-semibold: 640;
--font-weight-bold: 640;
--text-step-1: 12px;
--text-step-1--line-height: 16px;
--text-step-1--letter-spacing: 0.0025em;
--text-step-2: 14px;
--text-step-2--line-height: 20px;
--text-step-2--letter-spacing: 0em;
--text-step-3: 16px;
--text-step-3--line-height: 24px;
--text-step-3--letter-spacing: 0em;
--text-step-4: 18px;
--text-step-4--line-height: 26px;
--text-step-4--letter-spacing: -0.0025em;
--text-step-5: 20px;
--text-step-5--line-height: 28px;
--text-step-5--letter-spacing: -0.005em;
--text-step-6: 24px;
--text-step-6--line-height: 30px;
--text-step-6--letter-spacing: -0.00625em;
--text-step-7: 28px;
--text-step-7--line-height: 36px;
--text-step-7--letter-spacing: -0.0075em;
--text-step-8: 35px;
--text-step-8--line-height: 40px;
--text-step-8--letter-spacing: -0.01em;
--text-step-9: 60px;
--text-step-9--line-height: 60px;
--text-step-9--letter-spacing: -0.025em;
--text-xs: var(--text-step-1);
--text-xs--line-height: var(--text-step-1--line-height);
--text-xs--letter-spacing: var(--text-step-1--letter-spacing);
--text-sm: var(--text-step-2);
--text-sm--line-height: var(--text-step-2--line-height);
--text-sm--letter-spacing: var(--text-step-2--letter-spacing);
--text-base: var(--text-step-3);
--text-base--line-height: var(--text-step-3--line-height);
--text-base--letter-spacing: var(--text-step-3--letter-spacing);
--text-lg: var(--text-step-4);
--text-lg--line-height: var(--text-step-4--line-height);
--text-lg--letter-spacing: var(--text-step-4--letter-spacing);
--text-xl: var(--text-step-5);
--text-xl--line-height: var(--text-step-5--line-height);
--text-xl--letter-spacing: var(--text-step-5--letter-spacing);
--text-2xl: var(--text-step-6);
--text-2xl--line-height: var(--text-step-6--line-height);
--text-2xl--letter-spacing: var(--text-step-6--letter-spacing);
--text-3xl: var(--text-step-7);
--text-3xl--line-height: var(--text-step-7--line-height);
--text-3xl--letter-spacing: var(--text-step-7--letter-spacing);
--text-4xl: var(--text-step-8);
--text-4xl--line-height: var(--text-step-8--line-height);
--text-4xl--letter-spacing: var(--text-step-8--letter-spacing);
--text-6xl: var(--text-step-9);
--text-6xl--line-height: var(--text-step-9--line-height);
--text-6xl--letter-spacing: var(--text-step-9--letter-spacing);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-base font-normal text-foreground;
}
}
@@ -0,0 +1,31 @@
import { fileURLToPath } from "node:url";
import tailwindcss from "@tailwindcss/postcss";
import postcss from "postcss";
import { describe, expect, it } from "vitest";
describe("@cline/ui theme integration", () => {
it("compiles the shared theme and standard Tailwind utilities", async () => {
const from = fileURLToPath(new URL("./theme-fixture.css", import.meta.url));
const result = await postcss([tailwindcss()]).process(
[
'@import "tailwindcss";',
'@import "@cline/ui/theme/index.css";',
'@source inline("bg-background bg-primary-emphasis font-sans text-xs");',
].join("\n"),
{ from },
);
expect(result.css).toContain("--text-xs: 12px");
expect(result.css).toContain("--font-weight-normal: 480");
expect(result.css).toContain('--font-sans: "Schibsted Grotesk Variable"');
expect(result.css).toContain("--primary-emphasis:");
expect(result.css).toContain(".bg-background");
expect(result.css).toContain(".bg-primary-emphasis");
expect(result.css).toContain(".font-sans");
expect(result.css).toContain(".text-xs");
expect(result.css).toContain(
"letter-spacing: var(--tw-tracking, var(--text-xs--letter-spacing))",
);
expect(result.css).not.toContain("--cline-");
});
});
+202
View File
@@ -0,0 +1,202 @@
# vscode-rollout — A/B loader for the SDK extension rollout
The VS Code Marketplace has no staged rollouts: publishing a version updates
every user. This package lets us ship the **SDK-based extension** (main's
`apps/vscode`, "next") to a percentage of users while everyone else keeps
running the **legacy extension** (the `legacy-extension` branch), inside a
single published VSIX.
## How it works
The published VSIX contains a ~40 KB loader as its entrypoint and two complete,
independently built extension bundles:
```
extension.js ← loader (this package)
package.json ← UNION of both bundles' manifests (generated, see below)
assets/, walkthrough/ ← manifest-referenced resources (VSIX-root-relative)
next/ ← SDK extension (dist/, webview-ui/build/, assets/)
legacy/ ← legacy extension (dist/, webview-ui/build/, assets/, codicons)
```
Per window, the loader:
1. Reads the cached cohort assignment from its own `globalState` keys —
synchronously, never from the network.
2. Sets the `cline.sdkBundle` context key (gates cohort-specific menu items /
palette entries in the union manifest).
3. `require()`s exactly one bundle and calls its `activate()` with a
Proxy-wrapped `ExtensionContext` whose `extensionUri` / `extensionPath` /
`asAbsolutePath` point into that bundle's subdirectory — so each bundle
resolves its own webview build and assets without knowing it was relocated.
Storage properties pass through untouched: both bundles share the same
`~/.cline/data` + VS Code storage they used as standalone extensions.
4. After the selected bundle activates, evaluates the PostHog flags in the
background and caches the assignment **for the next window**. Flag changes
never flip a live window. A crash fallback skips this refresh so it cannot
overwrite the legacy pin.
If the next bundle throws during activation, the loader disposes whatever it
half-registered, pins this VSIX version back to legacy
(`cline.rollout.nextActivationFailedVersion`), reports a `fallback` telemetry
event, and activates legacy — a crashed rollout self-heals without a
marketplace re-publish. A new version gets to try next again.
## Cohort rules
- **Two-way, one knob.** `ext-sdk-bundle-rollout` (percentage flag) is the
entire remote control surface: each background refresh caches exactly what
the flag says for the machine's next window. Dialing the percentage up
promotes; dialing it down demotes on the next reload — the emergency lever
is simply "set the rollout to 0%". Known demotion costs (accepted): tasks
created on the SDK bundle are stored as SDK sessions the legacy bundle
doesn't list (they reappear on re-promotion — nothing is deleted), and
credentials rotated on next may require a re-login on legacy.
- **The flag must stay a boolean flag.** The loader only promotes on a
literal `true` from `/decide` — a multivariate variant, number, or anything
else fails safe to legacy (see `parseRolloutAssignment` + tests). Don't
convert it to multivariate.
- The flag is evaluated against the same PostHog distinct id the extension's
telemetry uses (machine id, mirroring `src/services/logging/distinctId.ts`),
so cohort membership is correlatable with telemetry. Flag evaluation is
always on (matching `FeatureFlagsService`); the loader's own
`extension.rollout.loader_decision` event respects the user's telemetry
opt-out and VS Code's global telemetry switch.
- **Manual overrides, in either direction.** The `cline.rollout.bundleOverride`
user setting (`"auto" | "next" | "legacy"`, editable straight from
settings.json) forces a bundle for anyone — users in a pinch, or us
debugging — beating the remote assignment both ways. Applies on window
reload. `CLINE_BUNDLE_OVERRIDE=next|legacy` (env var) does the same for
local dev and e2e and beats even the setting. Both are reported as
`override` on the loader event so overridden machines don't pollute
cohort comparisons.
- **Crash pinning is local, not remote.** If the next bundle throws during
activation, the loader falls back to legacy in the same window and pins
that VSIX version on this machine (`cline.rollout.nextActivationFailedVersion`);
a new release gets to try next again. This safety net is independent of the
flag.
## The union manifest
`package.json` contributions are static — VS Code reads them before any code
runs — so the shipped manifest must serve both cohorts. `scripts/gen-manifest.mjs`
regenerates it at stitch time from both branches' real manifests:
- Contributions declared by both bundles pass through untouched.
- Menu entries / keybindings declared by only one get `when` AND-ed with
`cline.sdkBundle` / `!cline.sdkBundle`, so a cohort never sees a button its
bundle didn't register (and shared buttons that moved position don't render
twice).
- Commands exclusive to one bundle are hidden from the other cohort's command
palette.
- `views` / `viewsContainers` / `configuration` / `walkthroughs`
**must be identical** in both manifests — they can't be safely gated at
runtime, so divergence fails the build. Keep these static contributions in
sync between the branches. `engines` may diverge: the union takes the newer
requirement (which necessarily satisfies the older one).
Because the manifest is regenerated from both branches on every build,
contribution drift between the branches can't ship silently — it either merges
cleanly or the stitch fails.
## Building locally
```bash
# 1. build both bundles (their own toolchains)
cd apps/vscode && bun run package # next
cd <legacy worktree>/apps/vscode && npm run package # legacy (npm ci first)
# 2. build the loader + stitch + package
cd apps/vscode-rollout
bun run build # dev build; CI uses build:production with the PostHog key
node scripts/stitch.mjs \
--next ../vscode --legacy <legacy worktree>/apps/vscode \
--loader dist/extension.js --version 4.1.0 --out /tmp/cline-ab-staging
node scripts/smoke-loader.mjs /tmp/cline-ab-staging # loader behavior smoke
cd /tmp/cline-ab-staging && vsce package --no-dependencies --allow-package-secrets sendgrid
```
The narrowly scoped `sendgrid` scanner exemption mirrors the existing next and
legacy packaging workflows. This workflow supplies only the existing PostHog
project-key inputs; it does not declare a SendGrid credential. Identify the
exact matching string in production staging output before changing or
broadening the exemption.
Local builds have no `TELEMETRY_SERVICE_API_KEY`, so the loader skips PostHog
entirely and everyone stays on legacy unless `CLINE_BUNDLE_OVERRIDE` is set.
CI: the `ext-vscode-ab-package` workflow (manual dispatch) builds both refs,
stitches, smoke-tests, uploads the `.vsix` artifact, and optionally publishes.
## Nightly channel
The daily `ext-vscode-publish-nightly` workflow (cron + manual dispatch)
publishes this same combined package as **`saoudrizwan.cline-nightly`**. Before
each bundle builds, `scripts/nightlify.mjs` rewrites its manifest to the
nightly identity — the same mutation the standalone nightly always applied
(`apps/vscode/scripts/publish-nightly.mjs` on both branches is the source of
truth), so nightly can be installed alongside stable:
| | stable | nightly |
|---|---|---|
| manifest `name` | `claude-dev` | `cline-nightly` |
| contribution IDs / context key / settings | `cline.*` | `cline-nightly.*` |
| version | operator-supplied (4.1.0+) | `<major>.<minor>.<unix-seconds>` |
The loader derives the namespace from its own `packageJSON.name` at runtime
(`idPrefix` in `src/cohort.ts`), and gen-manifest derives it from the next
manifest's name — no build flags involved. Nightly builds also show a
status-bar indicator (`Cline: Next` / `Cline: Legacy`); stable builds never do.
Dispatching the nightly workflow from `main` with `dry-run` builds and uploads
the installable `.vsix` without publishing or tagging. The publish job is
intentionally restricted to `main` by both the workflow and the
`PublishNightly` environment's deployment-branch policy.
### Telemetry events
- **`extension.rollout.bundle_activated`** (authoritative, captured by the
activated bundle's own telemetry via its `reportRolloutActivation` export;
requires the bundle to be built with `CLINE_ROLLOUT_VARIANT`): attempted vs
actual bundle, fallback flag, error details on fallback. Every other event
from a rollout build carries `extension_variant` as a common property.
- **`extension.rollout.loader_decision`** (loader-owned, direct capture): the
loader-side metadata the bundle event can't know — override source, launch
cadence, loader version, `extension_name` (nightly vs stable) — and the only
signal when BOTH bundles fail (`double_failure: true`).
## Rollout runbook
Until the stable combined VSIX ships, the flag governs **nightly installs
only** — dialing it is safe for production users and is the lever for moving
nightly dogfooders onto next.
1. Create `ext-sdk-bundle-rollout` in PostHog **before** the first publish: a
plain boolean release flag with a percentage rollout, starting at **0%**.
(There is deliberately no kill-switch flag — the assignment is two-way, so
0% *is* the kill switch.)
2. Publish the combined VSIX (version above every previously published one).
With the rollout at 0% this release is behaviorally identical to legacy for
everyone — it only validates the loader plumbing in the wild. Watch
`extension.rollout.bundle_activated` and `extension.rollout.loader_decision`.
3. Dial `ext-sdk-bundle-rollout` up: 1% → 5% → 25% → 100%. Assignments apply on
each machine's next window reload after its flag refresh, so propagation
speed is bounded by how often people reload windows — watch the
`ms_since_last_activation` distribution on loader events to see real
uptake lag before deciding the next step, and compare cohorts by the
`bundle` property.
4. Emergencies: dial the percentage **down** (0% pulls everyone back to legacy
on their next reload). Demoted machines keep settings and creds; tasks
created on the SDK bundle reappear when re-promoted. Ship the fix as a
higher version, then dial back up. Machines whose next bundle *crashed*
are additionally version-pinned to legacy locally, independent of the flag.
5. When next reaches 100% and soaks, retire the loader: publish a plain SDK
extension build and delete this package.
## Version numbering
The combined VSIX owns the marketplace version line and must always exceed the
last version published from either branch (legacy stable was 4.0.x → start at
4.1.0). The bundles' own `package.json` versions ride along inside their
subdirectories for provenance; the loader reports the combined version as
`loader_version`.
+27
View File
@@ -0,0 +1,27 @@
import esbuild from "esbuild";
const production = process.argv.includes("--production");
// Same build-time secret injection scheme as apps/vscode/esbuild.mjs: CI
// provides TELEMETRY_SERVICE_API_KEY; local builds leave it undefined and the
// loader skips all PostHog calls (everyone stays on legacy).
const define = {};
if (process.env.TELEMETRY_SERVICE_API_KEY) {
define["process.env.TELEMETRY_SERVICE_API_KEY"] = JSON.stringify(
process.env.TELEMETRY_SERVICE_API_KEY,
);
}
await esbuild.build({
entryPoints: ["src/extension.ts"],
bundle: true,
outfile: "dist/extension.js",
platform: "node",
format: "cjs",
target: "node18",
external: ["vscode"],
minify: production,
sourcemap: !production,
define,
logLevel: "info",
});
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@cline/vscode-rollout",
"private": true,
"version": "0.1.0",
"description": "Loader + packaging tooling for the staged (A/B) rollout of the SDK-based VS Code extension alongside the legacy extension",
"type": "module",
"scripts": {
"build": "node esbuild.mjs",
"build:production": "node esbuild.mjs --production",
"typecheck": "tsc --noEmit",
"test": "bun test src scripts",
"stitch": "node scripts/stitch.mjs"
},
"devDependencies": {
"@types/node": "20.x",
"@types/vscode": "1.84.0",
"esbuild": "^0.25.0",
"node-machine-id": "^1.1.12",
"typescript": "^5.4.5"
}
}
@@ -0,0 +1,437 @@
/**
* Generate the combined VSIX's package.json as the UNION of the two bundles'
* manifests, regenerated from both branches' actual package.json files at
* stitch time so contribution drift between branches can't ship silently.
*
* Rules:
* - Identity/top-level fields come from the next (main) manifest.
* - `main` points at the loader; `version` comes from the release input.
* - commands / menus / keybindings / activationEvents / icons are unioned.
* Menu entries and keybindings present in only ONE manifest get their
* `when` clause AND-ed with the `<prefix>.sdkBundle` context key (set by the
* loader before activation; prefix follows the manifest identity see
* src/cohort.ts idPrefix), so a cohort never sees a button whose handler
* its bundle doesn't register and shared buttons that moved position
* don't show up twice. Commands exclusive to one bundle are likewise hidden
* from the other cohort's command palette.
* - views / viewsContainers / configuration / engines MUST be
* identical in both manifests they can't be safely gated at runtime, so
* divergence is a hard error.
*
* Usage: node gen-manifest.mjs --next <pkg.json> --legacy <pkg.json> --version <x.y.z> [--out <file>]
*/
import { deepStrictEqual } from "node:assert";
import { readFileSync, writeFileSync } from "node:fs";
export function generateManifest(nextPkg, legacyPkg, version) {
for (const field of ["name", "publisher", "main"]) {
if (nextPkg[field] !== legacyPkg[field]) {
throw new Error(
`manifest field "${field}" differs: ${nextPkg[field]} vs ${legacyPkg[field]}`,
);
}
}
for (const field of ["views", "viewsContainers", "configuration"]) {
try {
deepStrictEqual(
nextPkg.contributes?.[field],
legacyPkg.contributes?.[field],
);
} catch {
throw new Error(
`contributes.${field} diverged between bundles — it cannot be gated at runtime; reconcile the branches`,
);
}
}
const engines = unionEngines(nextPkg.engines, legacyPkg.engines);
assertWalkthroughsCompatible(
nextPkg.contributes?.walkthroughs,
legacyPkg.contributes?.walkthroughs,
);
// The nightly packaging rewrites the whole `cline.*` ID namespace to
// `cline-nightly.*` (scripts/nightlify.mjs), so the context key and the
// injected setting must follow the manifest's identity. Keep in sync with
// idPrefix/bundleContextKey/settingSection in src/cohort.ts.
const prefix = nextPkg.name === "cline-nightly" ? "cline-nightly" : "cline";
const nextGate = `${prefix}.sdkBundle`;
const legacyGate = `!${nextGate}`;
const nc = nextPkg.contributes ?? {};
const lc = legacyPkg.contributes ?? {};
const menus = unionMenus(nc.menus, lc.menus, nextGate, legacyGate);
hideExclusiveCommandsFromPalette(
menus,
nc.commands,
lc.commands,
nextGate,
legacyGate,
);
const manifest = {
name: nextPkg.name,
displayName: nextPkg.displayName,
description: nextPkg.description,
version,
icon: nextPkg.icon,
engines,
author: nextPkg.author,
license: nextPkg.license,
publisher: nextPkg.publisher,
repository: nextPkg.repository,
homepage: nextPkg.homepage,
categories: nextPkg.categories,
keywords: nextPkg.keywords,
activationEvents: unionPrimitive(
nextPkg.activationEvents,
legacyPkg.activationEvents,
),
main: "./extension.js",
contributes: {
viewsContainers: nc.viewsContainers,
views: nc.views,
commands: unionBy(
[...(nc.commands ?? []), ...(lc.commands ?? [])],
(c) => c.command,
),
keybindings: unionGated(
nc.keybindings,
lc.keybindings,
nextGate,
legacyGate,
),
menus,
icons: unionIcons(nc.icons, lc.icons),
configuration: injectLoaderConfiguration(nc.configuration, prefix),
walkthroughs: nc.walkthroughs,
},
scripts: {},
};
assertSuperset(manifest, nextPkg, "next", nextGate);
assertSuperset(manifest, legacyPkg, "legacy", legacyGate);
return manifest;
}
/**
* The loader's own user-visible escape hatch, keyed by the manifest identity.
* Neither bundle knows about it; only the loader reads it (src/cohort.ts
* settingSection/SETTING_BUNDLE_OVERRIDE keep the key and values in sync).
* Injected after the configuration-equality invariant so it can't mask real
* drift between the bundles.
*/
function loaderSettings(prefix) {
return {
[`${prefix}.rollout.bundleOverride`]: {
type: "string",
enum: ["auto", "next", "legacy"],
enumDescriptions: [
"Follow the remote rollout assignment.",
"Force the new (SDK-based) extension.",
"Force the previous (legacy) extension.",
],
default: "auto",
scope: "application",
markdownDescription:
"Manual override for Cline's staged extension rollout. `next` forces the new (SDK-based) extension, `legacy` forces the previous one, `auto` follows the remote rollout assignment. Takes effect on window reload.",
},
};
}
function injectLoaderConfiguration(configuration, prefix) {
const properties = { ...(configuration?.properties ?? {}) };
for (const [key, schema] of Object.entries(loaderSettings(prefix))) {
if (properties[key]) {
throw new Error(
`bundle manifests must not declare loader-owned setting ${key}`,
);
}
properties[key] = schema;
}
return { title: "Cline", ...(configuration ?? {}), properties };
}
/**
* Walkthroughs can't be gated per cohort, and their markdown at the VSIX root
* always comes from the next checkout so requiring byte-identical manifests
* here would brick releases over copy tweaks while protecting nothing. Only
* STRUCTURE must match (walkthrough/step ids, media paths, completion events
* the parts code and the manifest reference); when titles/descriptions
* diverge, next's copy ships for everyone and the build says so.
*/
function assertWalkthroughsCompatible(next = [], legacy = []) {
const structure = (walkthroughs) =>
walkthroughs.map((walkthrough) => ({
id: walkthrough.id,
steps: (walkthrough.steps ?? []).map((step) => ({
id: step.id,
media: step.media,
completionEvents: step.completionEvents,
when: step.when,
})),
}));
try {
deepStrictEqual(structure(next), structure(legacy));
} catch {
throw new Error(
"contributes.walkthroughs diverged structurally (ids/media/completionEvents) — reconcile the branches",
);
}
try {
deepStrictEqual(next, legacy);
} catch {
console.warn(
"warning: walkthrough titles/descriptions differ between bundles; shipping next's copy for both cohorts",
);
}
}
/**
* Engines can safely diverge in ONE direction: the union requires whichever
* bundle needs the NEWER host, which necessarily satisfies the other bundle's
* older requirement too. (main routinely bumps the VS Code engine ahead of the
* legacy branch an equality assertion here would brick every combined build
* over that.) Non-caret/complex ranges we can't compare fail hard rather than
* guessing.
*/
function unionEngines(nextEngines = {}, legacyEngines = {}) {
const union = {};
for (const key of new Set([
...Object.keys(nextEngines),
...Object.keys(legacyEngines),
])) {
const a = nextEngines[key];
const b = legacyEngines[key];
if (a === undefined || b === undefined || a === b) {
union[key] = a ?? b;
continue;
}
const minimum = (range) => {
const match = /^\^(\d+(?:\.\d+)*)$/.exec(range);
return match?.[1];
};
const [minA, minB] = [minimum(a), minimum(b)];
if (!minA || !minB) {
throw new Error(
`engines.${key} diverged with uncomparable ranges: ${a} vs ${b}`,
);
}
union[key] = compareDotted(minA, minB) >= 0 ? a : b;
console.warn(
`warning: engines.${key} differs between bundles (next ${a}, legacy ${b}); union requires ${union[key]}`,
);
}
return union;
}
/** Compare dotted numeric versions. */
function compareDotted(a, b) {
const pa = a.split(".").map((part) => Number.parseInt(part, 10) || 0);
const pb = b.split(".").map((part) => Number.parseInt(part, 10) || 0);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
if (diff !== 0) {
return diff;
}
}
return 0;
}
function unionPrimitive(a = [], b = []) {
return [...new Set([...a, ...b])];
}
/** Union keeping first occurrence per key (next wins on shared ids). */
function unionBy(items, keyFn) {
const seen = new Map();
for (const item of items) {
const key = keyFn(item);
if (!seen.has(key)) {
seen.set(key, item);
}
}
return [...seen.values()];
}
function sortKeysDeep(value) {
if (Array.isArray(value)) {
return value.map(sortKeysDeep);
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.keys(value)
.sort()
.map((key) => [key, sortKeysDeep(value[key])]),
);
}
return value;
}
function stableJson(value) {
return JSON.stringify(sortKeysDeep(value));
}
function gateWhen(entry, gate) {
return { ...entry, when: entry.when ? `(${entry.when}) && ${gate}` : gate };
}
/**
* Union two entry lists (menu entries or keybindings): entries declared by
* both bundles pass through untouched; entries declared by only one get their
* `when` AND-ed with that bundle's cohort gate.
*/
function unionGated(
nextEntries = [],
legacyEntries = [],
nextGate,
legacyGate,
) {
const nextSet = new Set(nextEntries.map(stableJson));
const legacySet = new Set(legacyEntries.map(stableJson));
const entries = [];
for (const entry of nextEntries) {
entries.push(
legacySet.has(stableJson(entry)) ? entry : gateWhen(entry, nextGate),
);
}
for (const entry of legacyEntries) {
if (!nextSet.has(stableJson(entry))) {
entries.push(gateWhen(entry, legacyGate));
}
}
return entries;
}
function unionMenus(a = {}, b = {}, nextGate, legacyGate) {
const menus = {};
for (const location of new Set([...Object.keys(a), ...Object.keys(b)])) {
menus[location] = unionGated(
a[location],
b[location],
nextGate,
legacyGate,
);
}
return menus;
}
/**
* A command declared by only one bundle would surface in the other cohort's
* command palette with no registered handler ("command not found" on run).
* Hide it there unless that bundle's own manifest already constrains it.
*/
function hideExclusiveCommandsFromPalette(
menus,
nextCommands = [],
legacyCommands = [],
nextGate,
legacyGate,
) {
const nextIds = new Set(nextCommands.map((c) => c.command));
const legacyIds = new Set(legacyCommands.map((c) => c.command));
const palette = menus.commandPalette ?? (menus.commandPalette = []);
const alreadyListed = new Set(palette.map((e) => e.command));
for (const id of nextIds) {
if (!legacyIds.has(id) && !alreadyListed.has(id)) {
palette.push({ command: id, when: nextGate });
}
}
for (const id of legacyIds) {
if (!nextIds.has(id) && !alreadyListed.has(id)) {
palette.push({ command: id, when: legacyGate });
}
}
}
function unionIcons(a = {}, b = {}) {
const icons = { ...b, ...a };
for (const id of Object.keys(icons)) {
if (a[id] && b[id] && JSON.stringify(a[id]) !== JSON.stringify(b[id])) {
throw new Error(
`contributes.icons["${id}"] diverged between bundles — icon fonts resolve from the VSIX root`,
);
}
}
return icons;
}
/**
* Every command/keybinding/menu entry/activation event a bundle declares must
* survive the union, either verbatim or with its `when` AND-ed with that
* bundle's cohort gate.
*/
function assertSuperset(manifest, sourcePkg, label, gate) {
const missing = [];
const commandIds = new Set(
manifest.contributes.commands.map((c) => c.command),
);
for (const cmd of sourcePkg.contributes?.commands ?? []) {
if (!commandIds.has(cmd.command)) {
missing.push(`command ${cmd.command}`);
}
}
for (const event of sourcePkg.activationEvents ?? []) {
if (!manifest.activationEvents.includes(event)) {
missing.push(`activationEvent ${event}`);
}
}
const presentOrGated = (unionEntries, entry) => {
const set = new Set((unionEntries ?? []).map(stableJson));
return (
set.has(stableJson(entry)) || set.has(stableJson(gateWhen(entry, gate)))
);
};
for (const kb of sourcePkg.contributes?.keybindings ?? []) {
if (!presentOrGated(manifest.contributes.keybindings, kb)) {
missing.push(`keybinding ${kb.command}`);
}
}
for (const [location, entries] of Object.entries(
sourcePkg.contributes?.menus ?? {},
)) {
for (const entry of entries) {
if (!presentOrGated(manifest.contributes.menus[location], entry)) {
missing.push(
`menu ${location}: ${entry.command ?? JSON.stringify(entry)}`,
);
}
}
}
if (missing.length > 0) {
throw new Error(
`union manifest is missing ${label} contributions:\n ${missing.join("\n ")}`,
);
}
}
function parseArgs(argv) {
const args = {};
for (let i = 2; i < argv.length; i += 2) {
args[argv[i].replace(/^--/, "")] = argv[i + 1];
}
return args;
}
if (import.meta.url === `file://${process.argv[1]}`) {
const { next, legacy, version, out } = parseArgs(process.argv);
if (!next || !legacy || !version) {
console.error(
"usage: gen-manifest.mjs --next <pkg.json> --legacy <pkg.json> --version <x.y.z> [--out <file>]",
);
process.exit(1);
}
const manifest = generateManifest(
JSON.parse(readFileSync(next, "utf8")),
JSON.parse(readFileSync(legacy, "utf8")),
version,
);
const json = `${JSON.stringify(manifest, null, "\t")}\n`;
if (out) {
writeFileSync(out, json);
console.log(`wrote ${out}`);
} else {
process.stdout.write(json);
}
}

Some files were not shown because too many files have changed in this diff Show More