Compare commits

...
Author SHA1 Message Date
Saoud Rizwan f36b59c9cb chore(sdk): release v0.0.67 2026-07-30 18:03:17 -07:00
Saoud Rizwan 311959e757 ci(vscode): add test gate to combined A/B publish + publish-extension skill (#12764)
* ci(vscode): gate the combined A/B package workflow on both bundles' test suites

* docs(skills): add publish-extension skill for VS Code extension releases

* ci(vscode): pin tested revision for next bundle and refuse publishing untested next-refs

* ci(vscode): pin legacy bundle to the revision its test gate ran against
2026-07-30 17:54:29 -07:00
Saoud Rizwan f21e6faf1c fix(vscode): show migrated model in settings instead of hardcoded default (#12768)
* fix(vscode): show migrated model in settings instead of hardcoded default

After the SDK provider migration a user who never explicitly picked a model
(i.e. took the legacy default) ends up with the model recorded in
providers.json but not in the mode-specific globalState fields the settings
picker reads. The OpenRouter picker and its info card then fell back to the
hardcoded openRouterDefaultModelId (claude-sonnet-4.5) and its pricing, while
the extension actually ran the migrated model (claude-sonnet-5).

- resolveModelInfo: when no model id is requested, honor the provider store's
  committed selection (which reads providers.json when the state field is
  empty) before substituting a catalog default.
- OpenRouterModelPicker: source the displayed model id/info from the
  authoritative resolver as the fallback when the mode fields are empty,
  instead of the hardcoded constant. Committed-field users are unaffected.

* fix(vscode): guard picker model info against resolver default substitution

Review hardening: the resolver substitutes its provider default for ids it
cannot resolve, so only trust its info when it answered for the id actually
displayed. Prefer the live catalog entry for the displayed id (synchronous
once fetched, which also removes the transient placeholder while the resolver
is in flight), and never render another model's metadata under the displayed
model's name. Also document why the act-then-plan readSelection order in the
empty-id branch cannot misattribute a mode-specific selection.
2026-07-30 17:51:21 -07:00
Saoud Rizwan 1cf19304f4 docs: restore 'open Cline in right sidebar' guide as section of IDE usage page (#12771) 2026-07-30 17:43:58 -07:00
Saoud Rizwan 09aec528d0 Fix task export button: resolve the SDK session folder reliably (#12772)
* Restore task export to markdown and show download button in all builds

* Render untyped tool outputs and object tool inputs as JSON in task export

* Open the task's SDK session folder from the export button and show it in all builds

* Keep the task header session-folder button dev-only
2026-07-30 17:34:29 -07:00
Saoud Rizwan 4ec2b68c7c Fix queued prompt row alignment and auto-scroll when queueing a message (#12767)
* Fix queued prompt row alignment and auto-scroll on queue

Center the dot, badges, and cancel button on the first text line of each queued prompt row (the X previously sat ~3px below the text), and re-pin the chat view to the bottom when a prompt is queued so the queue banner doesn't cover the end of the conversation.

* Don't treat task switches as queue growth for auto-scroll

Guard the queued-prompt auto-scroll effect on the displayed task's ts: switching to a task that already has queued prompts grows the count without a send from this webview, and should not hijack the newly opened conversation's scroll position.
2026-07-30 17:33:19 -07:00
Saoud Rizwan 56fd6bb1ce Fix hidden plan/act mode-switch prompts reappearing when resuming a task from history (#12769)
* fix(vscode): hide synthetic mode-switch and resumption prompts when rehydrating chat from history

* chore: add changeset
2026-07-30 17:04:32 -07:00
Tomás Barreiro 49c7a89882 Inject device_id into tracking events (#12708)
* Inject  into tracking events

* fix sandbox resolution
2026-07-31 01:13:17 +02:00
Bee b47851791c feat(desktop): support message editing & checkpoints (#12691)
* feat(desktop): support message editing & checkpoints

Fork sessions before a selected user run, trim checkpoint history, and restore prior messages so prompts can be edited safely. Update the chat UI and tool activity panels to support the editing flow and preserve horizontal scrolling for long content.

* fix(desktop): restore checkpoints when editing messages

* fix(core): infer kindless checkpoint types

* fix(core): preserve checkpoint run numbering

* fix(desktop): make message edit restores transactional

* fix(desktop): make checkpoint restores workspace-atomic
2026-07-30 15:50:33 -07:00
Saoud Rizwan c0a966c46a Restyle compact-task confirmation as a bordered card with even spacing (#12759)
The confirmation that appears when clicking the compact button in the
task header was a bare unstyled row with a stray bottom margin (my-2)
that stacked on the header card's own bottom padding, leaving a dead
gap under the buttons. It is now a distinct bordered card (editor
background against the header's toolbar surface) with a title, a short
description of what compacting does, and right-aligned Cancel/Compact
buttons, with symmetric spacing above and below.

Also drops the ContextWindow wrapper's bottom margin (my-1.5 -> mt-1.5)
so the row's bottom spacing matches the header padding, and adds a
ContextWindow Storybook story that mirrors the expanded TaskHeader
surface so the confirmation can be previewed in isolation.
2026-07-30 15:42:30 -07:00
Saoud Rizwan cf3f3e08eb fix: don't block provider switch UI on ClinePass account switch (#12758)
Selecting ClinePass in settings awaited a network round-trip (PUT
/active-account + possible token refresh) before postStateToWebview,
so the settings panel stayed on the previous provider until the
request finished. Make the personal-account switch fire-and-forget:
it was already best-effort, and auth state changes propagate to the
webview separately once it completes.

Also convert the helper's test to bun:test so it actually runs (the
mocha version was excluded by both the bun unit runner and the
vscode-test glob) and fix its stale null-vs-undefined assertion from
the SDK migration.
2026-07-30 15:42:06 -07:00
7712e44468 fix(vscode): show catalog-driven reasoning effort selector for xAI, Z AI, and Moonshot (#12754)
The xAI, Z AI, and Moonshot settings components were never wired to the
catalog's reasoning capability: xAI only offered a legacy low/high
checkbox hardcoded to grok-3-mini model ids, and Z AI / Moonshot had no
reasoning control at all, even though models.dev marks grok-4.5, glm-5,
kimi-k2-thinking, etc. as reasoning models. Every catalog-driven
provider (GenericProviderSettings, OpenRouter/Vercel/Requesty pickers)
already gates ReasoningEffortSelector on supportsReasoning.

Render the shared ReasoningEffortSelector in these three components when
the selected model's catalog info advertises reasoning, persisting the
choice to the provider config the same way GenericProviderSettings does.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-30 14:39:48 -07:00
Saoud Rizwan f77f584a32 fix(vscode): stop queued-prompt turns from getting stuck on Thinking (#12751)
* fix(vscode): stop queued-prompt turns from getting stuck on Thinking

When the SDK drains a queued prompt at the end of a turn, the new turn's
pending_prompt_submitted bookkeeping (isRunning=true, phase=streaming) always
runs before the previous turn's send promise unwinds in fireAndForgetSend.
That .then then unconditionally called setRunning(false), so the queued turn
ran with isRunning=false and its own turn-complete was mistaken for a
cancelled-turn straggler - the phase never left "streaming" and the chat
showed an endless Thinking indicator.

Track a monotonic turn epoch on SdkSessionLifecycle: immediate sends and
drained queue prompts bump it, and both the send-settled callbacks and the
event coordinator's turn-end handling skip their bookkeeping when a newer
turn has started since (covers the symmetric interleaving where the done
handler resumes after the drain and would clobber the queued turn's
streaming phase).

* Simplify: preserve only an actual cancel phase in the turn-complete straggler guard

Replaces the turn-epoch machinery with the minimal fix: the straggler
guard's intent is to preserve the cancel-set "resumable" phase, so key it
on the phase itself instead of the isRunning proxy. When the SDK drains a
queued prompt at turn end, the previous turn's send promise settles after
the queued turn already started and flips isRunning back to false
mid-turn; with the old guard the queued turn's real completion was then
mistaken for a cancel straggler and the phase stayed stuck on
"streaming" (endless Thinking). Checking for "resumable" lets that
completion resolve the terminal phase normally while cancel behavior is
unchanged.
2026-07-30 14:35:33 -07:00
Saoud Rizwan 2771760305 fix(vscode): stop thinking loader flickering around mid-turn tool calls (#12750)
The anti-flash grace period (added to stop the loader flashing at turn
end) also fired mid-turn, causing a visible hide/show/hide flicker right
before a tool row appeared:

- When a reasoning tail finalized while the turn kept streaming, the
  reasoning shimmer collapsed, the loader stayed hidden for the 500ms
  grace, popped in, then hid again when the tool row landed. Reasoning
  never ends a turn, so skip the grace for reasoning tails and hand the
  shimmer straight to the loader.
- When the loader was already visible below a streaming tool group, the
  group tail finalizing blinked it off for the grace period. The grace
  now only delays hidden -> visible transitions, never hides an
  already-visible loader.
2026-07-30 14:32:15 -07:00
Saoud Rizwan 16d0d04573 Show user message immediately when sending to a task opened from history (#12753)
* Show user message immediately when sending to a history-resumed task

Sending a message to a task opened from history routed through the
resume_task/resume_completed_task askResponse branch, which forced the
Thinking loader but never set the optimistic user_feedback bubble. The
extension only echoes the user's message after the full SDK session
resume completes, so the chat showed a Thinking indicator with no user
message until the (slow) resume finished.

Pass showPendingMessage on the resume branch like the other
non-streaming follow-up paths, so the user's message appears in the
chat immediately. The optimistic bubble reconciles with the extension's
say:user_feedback echo once the resume completes (identical raw text).

* Add changeset
2026-07-30 14:28:18 -07:00
Saoud Rizwan 3590b425eb fix(ci): de-flake Windows bun unit tests (hook PowerShell bridge + runner retry) (#12752) 2026-07-30 14:21:05 -07:00
BeeandClaude Fable 5 12404f0a4b fix(core): add a plugin telemetry bridge (#12741)
* fix(core): add a plugin telemetry bridge

* fix(core): address plugin telemetry bridge review feedback

- Sanitization fallback now covers the whole executeTool IPC payload:
  `input` can be rewritten by beforeTool hooks or programmatic callers,
  so a non-serializable input degrades gracefully like the context does.
- The sandbox only offers ctx.telemetry when the host actually has a
  telemetry service (new PluginSandboxOptions.telemetryAvailable, derived
  from options.telemetry in the config loader), so feature-detecting
  ctx.telemetry means "someone is listening" in both execution modes.

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

* fix(core): plugin telemetry review round 2 — timer leak and setup-time fallback

- SubprocessSandbox.call: a synchronous child.send() throw (cyclic payload)
  left the pending timeout timer armed; it later fired and shut the sandbox
  down, killing unrelated in-flight calls. Cancel the pending entry and
  reject with the original error so serialization failures stay classifiable.
- plugin_telemetry events emitted during plugin setup() arrive before the
  session is registered, so the session-config lookup missed and setup-time
  telemetry was silently dropped. Route through a fallback telemetry service
  (extensionContext/local config/host default), mirroring handlePluginLog's
  fallback logger.

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

* fix(core): classify BigInt IPC serialization errors for the sandbox fallback

Bun ("cannot serialize BigInt") and Node ("Do not know how to serialize a
BigInt") raise messages that did not match the cyclic/circular predicate, so
a bigint smuggled into tool input or context by a hook or programmatic caller
rethrew instead of retrying with the JSON-safe clone — which already drops
bigint leaves.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 13:44:25 -07:00
Saoud Rizwan 6cb43f0309 fix(core): make compaction sidecar persistence reliable (#12747)
Auto-compaction state was silently rejected on every save ("Skipped
stale session compaction state"), forcing a full re-compaction — an
extra summarizer LLM call — on every turn past the trigger, and a
resume-time identity churn could leave a dead sidecar permanently
blocking replacements.

Three changes:

1. Stop hashing volatile transport identity. The source-prefix hash no
   longer includes message id/ts, which the codec regenerates on every
   wire/storage round-trip (a store's just-appended user turn has none
   yet; consolidated parallel tool results are re-split with minted ids
   on resume). The fingerprint now covers role, content, and durable
   metadata. Hash seed bumped to v2; v1 sidecars fail projection once
   and are replaced by the next compaction.

2. Validate persists against the exact source messages the state was
   computed over. createCompactionStateAwarePrepareTurn passes
   context.messages to saveState, and the local runtime host threads
   them into persistActiveSessionCompactionState instead of falling
   back to the conversation store's mid-turn shape.

3. Scope the count-based stale-write guard to states that still
   project. An unprojectable current state no longer blocks a
   newer-timestamped replacement, so invalidated sidecars self-heal
   instead of deadlocking the session.

All three regression tests fail on main and pass with this change.
2026-07-30 13:05:47 -07:00
Saoud Rizwan 3058563f37 fix(vscode): mark onboarding complete only after OAuth succeeds (#12744)
* fix(vscode): mark onboarding complete only after OAuth succeeds

The onboarding webview marked welcomeViewCompleted immediately after the
sign-in URL opened (accountLoginClicked resolves at URL-open time), so
Free/Frontier/ClinePass signups landed in chat signed out when the user
abandoned or failed browser auth, and the flag persisted across reloads.

Restore the classic extension behavior: the host (SdkAuthService) now
sets welcomeViewCompleted after the OAuth token exchange succeeds, in
createAuthRequest, handleAuthCallback, and the E2E mock login. The
webview persists the model selection up front, stays on the 'Almost
there!' step until auth completes, and fires the 'completed' funnel
event via a pending-intent module once clineUser arrives (mirroring the
pendingClinePassSubscribe pattern). This also fixes the legacy
WelcomeView fallback, whose 'Get Started for Free' never completed
onboarding after login.

* refactor(vscode): slim the onboarding-completion fix to its essentials

Drop the pending-telemetry module and App hook (the 'completed' funnel
event keeps its existing main-branch semantics, firing when the flow is
initiated, so no telemetry change in this PR), restore finishOnboarding
to its original shape with just a markCompleted parameter, and reduce
the host helper to a single setGlobalState call.
2026-07-30 12:40:44 -07:00
Saoud RizwanandSaoud Rizwan 39de7479b8 Fix delayed and stale Plan/Act mode switches (#12732)
* fix(vscode): make plan act switches responsive

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* fix(vscode): recognize completed plans with trailing usage

* fix(vscode): continue reopened completed plans

* fix(vscode): always publish state after mode rebuild

* fix(vscode): roll mode back when session replacement is refused

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-30 12:34:29 -07:00
Saoud Rizwan 86c2600a8a Show the thinking indicator immediately when starting a turn (#12746)
* Post streaming turn state to webview before session startup

The webview only learns the turn phase through full state posts, and the
first post after initTask happened only after startNewSession settled —
so the chat mounted with a stale idle TurnState and the thinking
indicator popped in noticeably late. Ship a state post right after the
initial task message is emitted, in parallel with session startup.

* Show thinking indicator optimistically on new-task submit

Capture the TurnState seq at the moment the newTask RPC is sent and
force the in-list Thinking loader row until a fresher TurnState arrives
(any phase), so the indicator renders together with the task message
instead of waiting for the streaming TurnState to round-trip. Rolled
back if the RPC fails; legacy (no turnState) hands off to the existing
tail heuristic once the task message lands.

* Paint the initial Thinking loader without waiting for Virtuoso

Frame-by-frame measurement showed the loader decision was true on the
chat view's first paint, but the synthetic in-list row still appeared
~150-200ms later: a cold-mounting virtualized list needs several frames
to measure and paint its first item. When the list has no visible rows
yet (new task just submitted), render the waiting row as a plain
element over the (empty) list instead; once any real row exists the
warm list takes over with the in-list row as before.

* Show thinking indicator immediately for follow-up messages too

Follow-ups had the same delay as new tasks: SdkController.askResponse
moves the phase to streaming but never posted state, so the webview
kept the stale terminal phase (hiding the loader) until the new turn's
first session event posted state. Post right after the phase change,
and generalize the webview's optimistic marker from new-task-only to
any turn-starting send (askResponse outside a streaming phase), with a
guard that never shows the loader while a content row is actively
streaming. Renames pendingNewTaskSeq to pendingTurnStartSeq.

* fix(vscode): render thinking loader synchronously
2026-07-30 12:28:40 -07:00
Saoud Rizwan f4230e475b fix(vscode): /compact UX — clear input, wrap divider, always update context header (#12735)
* fix(vscode): clear chat input immediately when /compact is submitted

* fix(vscode): let the compaction divider label wrap at narrow widths

* fix(vscode): update context-window header even when compaction grows the context

* chore: add changeset for /compact UX fixes

* docs(vscode): align getLastApiReqTotalTokens return doc with unclamped rescale
2026-07-30 12:13:33 -07:00
Saoud Rizwan 078abcd055 fix(ci): build shared package before the ui-publish desktop chat test
The desktop chat integration test renders components from @cline/ui, but
it also pulls @cline/shared/browser through the desktop app's own
message-content module. That subpath resolves to dist output no step in
this job produced, so the suite failed to collect.

Build @cline/shared before the test, and install the full workspace: the
two-package filter did not provide enough of the tree for that build.
2026-07-30 12:02:42 -07:00
Saoud Rizwan afc1229ab0 fix(ui): declare bun and node type dependencies
The ui-publish workflow installs only the @cline/ui and @cline/code
workspaces, so the root devDependencies that previously supplied the
'bun' and 'node' type roots were absent and tsc failed with TS2688.
Declare them on the package that requires them in its tsconfig types.

Also refreshes the stale @cline/code version recorded in bun.lock.
2026-07-30 12:02:42 -07:00
Saoud Rizwan 892837d352 Enable Auto Compact by default in VS Code (#12739)
* feat(vscode): enable Auto Compact by default

The SDK-based extension has no fallback context management: with auto
compact off, hitting the model's context window fails the request with a
provider error and retrying keeps failing (the legacy extension truncated
the oldest half of the conversation in this situation). The CLI already
defaults compaction on (agentic); align the extension with it.

* chore: add changeset for Auto Compact default-on
2026-07-30 12:02:02 -07:00
078d9f63f0 fix(models): tolerate null contextWindow/maxTokens in SDK catalog shapes (#12743)
* fix(models): tolerate null contextWindow/maxTokens in SDK catalog shapes

Live LiteLLM proxies report unknown model limits as explicit nulls in
/model/info (e.g. max_tokens: null). adaptSdkModelInfo only tolerated
undefined, so a single such model failed the entire catalog refresh and
left the model picker empty. Treat null like a missing value (matching
the existing pricing handling) and fall back to the safe defaults.

* Update apps/vscode/src/sdk/model-catalog/shape-adapter.ts

Co-authored-by: Tomás Barreiro <52393857+BarreiroT@users.noreply.github.com>

* Update apps/vscode/src/sdk/model-catalog/shape-adapter.ts

Co-authored-by: Tomás Barreiro <52393857+BarreiroT@users.noreply.github.com>

* fix(models): restore missing limit fallbacks

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Tomás Barreiro <52393857+BarreiroT@users.noreply.github.com>
2026-07-30 20:19:56 +02:00
Sufiyan Khan fba27b3c81 fix(vscode): preserve draft when toggling from act to plan (#12241)
* fix(vscode): preserve draft when toggling from act to plan

* fix(vscode): simplify mode toggle draft handling
2026-07-30 11:17:57 -07:00
Saoud Rizwan 6549bdbacc Show edited file after the diff preview closes (legacy parity) (#12731)
* Show edited file after diff preview closes, matching legacy behavior

* Add changeset

* Reveal destination after apply patch moves

* Skip reveal for superseded previews and aborted edits
2026-07-30 11:17:43 -07:00
Bee 55ccbb7d51 fix(cli): remove rendering multiple views in one Bun process (#10936)
* fix(cli): open history in the existing TUI

* refactor(cli): clarify history TUI startup target

* feat(cli): add history actions to TUI

* fix(cli): avoid empty session when resuming history

* fix(cli): fail history delete without session id

* fix(cli): dispatch resume hook from history picker
2026-07-30 11:16:51 -07:00
Tran Binh MinhandMinhkunn f039b1419f fix(vscode): show per-file diff for multi-file apply_patch (#12086)
* fix(vscode): show per-file diff for multi-file apply_patch

apply_patch edits to multiple files rendered the entire multi-file patch in every per-file diff row. Split the patch into one tool message per file at content_end (mirroring the read_files split) so each row shows only that file's changes.

Closes #9904

* fix(vscode): address review on multi-file apply_patch split

Import the canonical PATCH_MARKERS from @cline/core instead of the local AP_MARKERS duplicate and export it through the core barrel. The cross-world import barrier the old comment claimed does not exist - apps/vscode already imports runtime values from @cline/core.

Route the apply_patch branch in sdkToolToClineSayTool through getApplyPatchString so the streaming and finalized rows derive their content from one source.

Handle the bare-string apply_patch input. ApplyPatchInputUnionSchema accepts { input: string } | string; a bare two-file patch made getApplyPatchString return undefined, so both content_start and content_end produced one empty-path row instead of the per-file split. Return the raw string when the field lookup finds nothing, with a start/end reconciliation test.

Refs #9904

---------

Co-authored-by: Minhkunn <minh.12072k6@gmail.com>
2026-07-30 11:13:45 -07:00
Saoud Rizwan 97c558ffe8 chore(ui): release v0.2.0-next.1 2026-07-30 11:02:02 -07:00
Saoud Rizwan 8f6f1652e1 Improve slash command description contrast on selection (#12742)
* fix slash command hover text contrast

* remove slash menu regression test
2026-07-30 10:40:54 -07:00
26ee3abf82 fix(core): stabilize Windows SDK tests (#12722)
* fix(core): stabilize Windows SDK tests

* fix(core): handle late subprocess stdin errors

* test(core): verify shell process cleanup

* test(core): budget Windows PowerShell hook

---------

Co-authored-by: Cline Bot <bot@cline.bot>
Co-authored-by: Dominic Cooney <dominic.cooney@cline.bot>
2026-07-30 19:01:15 +02:00
d2b674bb9d fix(vscode): restore macOS E2E launch (#12726)
Co-authored-by: Dominic Cooney <dominic.cooney@cline.bot>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-30 03:04:21 -07:00
Saoud RizwanandSaoud Rizwan 2ce4facd9d fix(vscode): show thinking immediately after submit (#12733)
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-30 02:59:38 -07:00
Saoud Rizwan ac4724166d Disable feature tips by default in the VS Code extension (#12730)
* Disable feature tips by default in VS Code extension

* Add changeset for feature tips default change
2026-07-30 00:58:42 -07:00
2131bbba0c fix(connectors): recover Slack thread mapping when session is gone (#12727)
* fix(connectors): recover Slack thread mapping when session is gone

A connector thread binding can outlive its runtime session (hub restart,
session abort, retention cleanup). When that happened the thread stayed
pinned to a dead session id and every subsequent turn failed with
`session_not_found`, so the bot replied "Slack bridge error: session not
found" forever with no way to recover short of editing threads.json.

Drop the stale binding and replay the turn once against a brand new
session. Both the normal turn path and the steering path are covered.

Adds forgetThreadSession() to session-runtime and 3 regression tests.

* fix(connectors): serialize stale session recovery

---------

Co-authored-by: cline-test-bot <cline-test-bot@users.noreply.github.com>
Co-authored-by: abeatrix <beatrix@cline.bot>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-29 23:57:53 -07:00
Saoud Rizwan 792738eeed fix(vscode): auto-proceed long-running terminal commands (#12712)
* fix(vscode): auto-proceed long-running terminal commands

* chore(vscode): raise terminal auto-proceed timeout to 60s

* chore(vscode): raise terminal auto-proceed timeout to 300s
2026-07-29 23:30:52 -07:00
Saoud Rizwan 0ac7d0bdb5 remove Enable R1 messages format option from OpenAI Compatible provider (#12729) 2026-07-29 23:28:02 -07:00
Saoud Rizwan 4fa42771a7 Consolidate per-provider model refresh handlers onto the SDK catalog (#12716)
* Consolidate per-provider model refresh handlers into the SDK

The VS Code extension resolved model catalogs from two sources: the SDK
catalog (models.dev-backed) used by resolveModelInfo/task header, and
host-side refresh handlers (refreshOpenRouterModels & co.) used by the
settings pickers. This dual-source split produced inconsistencies like
ENG-2345.

SDK (@cline/core):
- New rich live model sources (live-model-sources.ts) ported from the
  extension handlers: OpenRouter (pricing incl. cache read/write,
  descriptions, image support, thinking config, tiers/global-endpoint
  metadata, curated overrides, stealth models), Vercel AI Gateway, and
  Hugging Face. Keyed by generated catalog key so cline shares
  OpenRouter's live data.
- mergeKnownModels layers rich live entries field-wise on top of the
  curated catalog (live fields win, curated fields fill gaps) instead of
  the modelsSourceUrl replace semantics.
- New Groq and Requesty private fetchers (API-key gated); Baseten
  private fetcher now parses live pricing and reasoning support and is
  enriched from the curated catalog.

Extension (apps/vscode):
- refreshOpenRouterModels/Groq/Baseten/VercelAiGateway/HuggingFace/
  Hicap/Requesty are now thin delegates over the SDK provider catalog;
  all bespoke fetch/parse/disk-cache code is deleted.
- shape-adapter maps the SDK's thinkingConfig, temperature,
  global-endpoint capability, and metadata tiers onto the extension
  ModelInfo.
- Removed the now-unused StateManager models cache, per-provider disk
  cache files, and the dead readOpenRouterModels stub.

Fixes ENG-2381.

* Simplify: rely on the SDK's models.dev catalog, no rich live sources

Drop the ported per-provider live fetchers and curated overrides
(live-model-sources.ts) and all SDK merge changes. The extension now does
exactly what the CLI does: refresh handlers resolve through
resolveProviderConfig, which serves the models.dev-backed catalog
(bundled + runtime live refresh) plus the SDK's pre-existing
authenticated fetchers (Baseten/Hicap/LiteLLM/Poolside). No hardcoded
model info or per-model pricing workarounds remain anywhere.

Also reverts the shape-adapter additions since no SDK catalog source
populates thinkingConfig/temperature/metadata tiers today.

* Replace thinking-budget sliders with catalog-driven reasoning effort selection

Match the CLI's UX: every reasoning-capable model (SDK catalog
'reasoning' capability -> supportsReasoning) gets the Reasoning Effort
selector (none/low/medium/high/xhigh); the legacy 'Enable thinking' +
budget-tokens slider is removed everywhere, along with the hardcoded
per-provider thinking-model id lists (Anthropic, Claude Code, Bedrock,
Qwen) and claude/grok model-id heuristics in the OpenRouter, Vercel,
and Requesty pickers.

Effort changes now dual-write the provider-config reasoning settings
({enabled, effort}) that the session factory actually consumes - the
budget slider wrote legacy plan/act thinkingBudgetTokens state that
sessions already ignored. The utility request path
(buildSdkProviderConfig) drops its budget preference and forwards
effort only; the SDK translates effort into each provider's wire
format (including budget-token mapping where required).

* Gate picker reasoning-effort UI on live catalog entries

The OpenRouter/Vercel/Requesty pickers read the committed legacy
model-info snapshot, which provider-config writes can clear when a
resolution lands on a fallback source - selecting an effort made the
selector disappear. Gate on the live catalog map (with snapshot
fallback) instead; Requesty gates on the catalog only, since its
safe-default fallback over-reports reasoning support.

* Address review: honor legacy thinking budgets, dedupe refresh handlers

- Persisted thinking budgets are honored again (greptile P1 / review
  request): normalizeProviderReasoningSettings maps a stored
  reasoning.budgetTokens (written by older versions or the SDK's
  legacy-state migration) onto the effort scale and treats it as
  thinking-on, and buildSdkProviderConfig derives an effort from the
  legacy plan/act budget fields when no explicit effort exists. An
  explicit 'none' still wins. Shared mapping lives in
  reasoningEffortFromThinkingBudget with low/medium/high buckets.
- Extract resolveProviderModelsRecord into providerCatalogShared and
  collapse the seven refresh handlers onto it.
- Document the explicit OCA decision: its reasoning control is the
  API-driven effort dropdown; the removed budget slider wrote state no
  OCA request path consumed.

* Harden OpenRouter picker reasoning gate against placeholder metadata

Gate on the raw committed model-info snapshot instead of the hook's
default-info fallback, so a selected id that is absent from the catalog
can never inherit reasoning support from placeholder metadata (the
fallback carries no supportsReasoning today, but reading the raw field
removes the latent dependency).
2026-07-29 23:27:05 -07:00
Saoud RizwanandCursor Agent c91cd4be59 fix(vscode): clear pending approvals on task switch (#12705)
* fix(vscode): clear pending approvals on task switch

* feat(vscode): show compact slash command

* docs(vscode): explain task approval cleanup

* fix(vscode): settle pending questions on cleanup

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-07-29 22:15:12 -07:00
Saoud RizwanandSaoud Rizwan 851ee033bc Fix checkpoint restores across session resumes (#12713)
* fix checkpoints across session resumes

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* style checkpoints mapping helper

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-29 22:08:37 -07:00
Saoud Rizwan 192e6ffab2 fix(vscode): interrupt running terminal command when a task is cancelled (#12696)
Cancelling a task previously only detached Cline's listeners from an
in-flight foreground command (process.continue()); the spawned process
kept running in the user's terminal after cancellation.

Send Ctrl+C (ETX) to the terminal before detaching so the shell delivers
SIGINT to the foreground process group, actually stopping the command.
The terminal is left open for reuse, and cancellation still succeeds even
if the interrupt write throws (e.g. terminal already disposed).
2026-07-29 21:45:43 -07:00
Saoud Rizwan 6b668fefdc fix(core): carry legacy OpenAI-compatible model-info overrides into seeded models.json (#12697)
The legacy-provider migration seeded the openai-compatible models.json
entry with hardcoded defaults (contextWindow 128k, no pricing/temperature/
maxTokens/R1 flag). Because later override migrations skip models that
already exist in models.json, the user's legacy planMode/actModeOpenAiModelInfo
overrides were silently discarded on first upgrade: context window,
max output tokens, input/output prices, temperature, supportsImages=false,
and isR1FormatRequired all reset to defaults.

Seed the entry from the mode-appropriate legacy model-info snapshot
instead, treating legacy sentinels (maxTokens -1, temperature 0,
prices 0) as unset.
2026-07-29 21:31:55 -07:00
Saoud Rizwan 8224ad1634 feat(llms): default OpenRouter provider to anthropic/claude-sonnet-5 (#12707)
Matches the legacy extension's OpenRouter default (openRouterDefaultModelId),
so users migrating from the legacy build without an explicitly selected model
keep the same default model instead of being silently moved to
anthropic/claude-sonnet-4.6.
2026-07-29 17:50:57 -07:00
Saoud Rizwan 96e8f51436 fix(core): don't abort config scan when .clinerules is a legacy single file (#12702)
A legacy single-file .clinerules at the workspace root made the config
watcher's scans of .clinerules/skills and .clinerules/workflows throw
ENOTDIR, which aborted the entire user-instruction refresh: workspace
rules, global rules, and the Skills view all silently failed to load.

Treat ENOTDIR like ENOENT in isIgnorableDirectoryError so a file in a
directory position simply yields no candidates. The .clinerules file
itself is still picked up by the file branch of discoverRulesLikeFiles.
2026-07-29 17:48:23 -07:00
Saoud Rizwan 5e5c4475bc fix(vscode): surface VS Code LM as a host provider (#12711)
* fix(vscode): register VS Code LM provider in catalog

* fix(vscode): use empty selector as vscode-lm catalog default model
2026-07-29 17:43:10 -07:00
Saoud Rizwan 66228fb30b Hide Plugins tab in extension Customize view (#12720) 2026-07-29 17:40:11 -07:00
Saoud Rizwan 7b8798c996 Fix built-in slash commands on the SDK runtime: /newtask aliases /compact, port /deep-planning expansion, hide /newrule and /reportbug (#12721)
* fix(vscode): hide /newrule and /deep-planning until their prompt expansions are ported to the SDK runtime

* feat(vscode): port the /newtask context handoff to the SDK runtime

Expand /newtask into explicit new_task-tool instructions in
SdkController.resolveSlashCommands (ported from legacy
newTaskToolResponse), register a custom new_task AgentTool that captures
the model-generated context summary and completes the run, and emit the
ask:"new_task" message on turn completion so the existing webview
"Start New Task with Context" button (which preloads a fresh task with
the ask text) becomes reachable again. Set the turn phase to
awaiting_followup when emitting the ask, since the completesRun
termination path skips the translator's usual end-of-turn status
handling.

* fix(vscode): hide /reportbug until its prompt expansion is ported to the SDK runtime

Also drop the feature tip promoting /reportbug so the UI doesn't
advertise a command that no longer autocompletes.

* Revert "feat(vscode): port the /newtask context handoff to the SDK runtime"

This reverts commit d9ad153aec.

* feat(vscode): make /newtask an alias of /compact

Condensing achieves /newtask's goal (continue working with a fresh,
summarized context window) without the legacy new_task tool, so the
webview intercepts /newtask alongside /compact and /smol and runs the
condense RPC. Menu description updated to match.

* feat(vscode): port the /deep-planning prompt expansion to the SDK runtime

Expand /deep-planning into the legacy generic-variant instructions
(silent investigation, targeted questions, implementation_plan.md) in
SdkController.resolveSlashCommands, ahead of workflow/skill expansion.
Legacy's STEP 4 created an implementation task via the new_task tool,
which doesn't exist on the SDK runtime; the ported prompt instead has
the agent present the plan and wait for explicit user confirmation.
Re-adds /deep-planning to the slash menu.

* refactor(vscode): simplify the /deep-planning expansion

Drop the custom regex/expander and shell-specific research-command
blocks: the builtin is now a plain AvailableRuntimeCommand appended to
the discovered workflow/skill commands, so the existing
expandSlashCommands machinery handles matching and replacement. The
prompt keeps the four-step protocol and implementation_plan.md
structure with a generic investigation paragraph instead of embedded
OS-specific commands.
2026-07-29 17:36:33 -07:00
John Choi dfd22bf79e refactor(ui): extract desktop approval card (#12693)
* refactor(ui): extract desktop approval card

* fix(ui): preserve approval card parity

* refactor(ui): keep approval labels fixed
2026-07-29 17:22:22 -07:00
John Choi 307707fd00 refactor(ui): extract desktop search combobox (#12663)
* refactor(ui): extract desktop search combobox

* fix(ui): preserve search selector visual parity

* fix(ui): preserve search combobox parity

* fix(ui): preserve combobox adoption parity

* test(desktop): reflect combobox accessible names

* fix(ui): disable open combobox options
2026-07-29 16:25:50 -07:00
Dominic Cooney 2a47c6ca08 fix(mcp): honor per-server timeout (seconds) across all clients (#12546)
* fix(mcp): honor per-server timeout (seconds) across all clients

The per-server timeout field in cline_mcp_settings.json was only read
by the VSCode extension's tools/call path. Everywhere else used
hardcoded constants: the SDK client timed out all requests at 5s and
initialize at 1.5s, and the extension's metadata requests (tools/list,
resources/*, prompts/*) timed out at 5s. Slow servers failed despite a
configured timeout (#7635, #12344).

Resolve the timeout once per client and apply it to every request:

- @cline/shared exports the default (60s) and bounds (1s-3600s) plus a
  resolver that clamps out-of-range values, so a milliseconds/seconds
  mix-up can no longer become hours.
- The SDK config loader parses timeout into
  McpServerRegistration.timeoutSeconds; StdioMcpClient and
  SdkUrlMcpClient apply it to initialize, tools/list, and tools/call.
  Unconfigured servers keep the fast 1.5s initialize probe so startup
  is no slower than before; a configured timeout raises that budget
  for slow-starting servers.
- The extension routes every request (including metadata) through one
  resolver and drops the hardcoded 5s DEFAULT_REQUEST_TIMEOUT_MS.
- createMcpTools derives the agent tool timeoutMs from the same value,
  keeping the wrapper and request timeouts in agreement.
- Timeout errors now name the bound and the field to increase; the
  VSCode server row and the CLI server list show the effective timeout
  and how to change it.

* fix(mcp): harden timeout lifecycle handling

* fix(mcp): address timeout review feedback

* fix(mcp): bound initialization and reconnect

* fix(mcp): keep timeout snapshots consistent

* fix(mcp): use standard stdio framing

* fix(mcp): bound legacy stdio fallback

* fix(mcp): honor timeout in framed fallback

* test(vscode): use SDK Vitest runner

* fix(mcp): fetch server capabilities in parallel

The four post-connect metadata requests (tools/list, resources/list,
resources/templates/list, prompts/list) ran sequentially, so a server
that hangs after initialize blocked connectToServer for four timeout
bounds. The MCP client correlates concurrent requests by JSON-RPC id
and the stdio transport writes each message atomically, so the fetches
now run in parallel and the worst case is one bound.

Also delete McpHub.readResource and McpHub.getPrompt and their response
types: nothing calls them since the SDK migration removed the
access_mcp_resource tool and prompt expansion.

* fix(mcp): keep failed servers and both framing errors visible

When both stdio framing attempts fail differently during initialize,
name each attempt's error instead of discarding the Content-Length
fallback's diagnostics. When they fail identically (both timed out),
rethrow the newline error unchanged so the timeout hint is the whole
message.

When connectToServer fails before the connection is registered (e.g.
the transport fails to start), register a disconnected entry carrying
the error so the server stays visible in the list instead of silently
disappearing, and notify the webview so the row leaves the connecting
state.

* fix(mcp): reject tool calls on connections without a client

A failed (re)connect registers a disconnected entry with a null client
so the server stays visible in the list. A tool wrapper captured by an
active session can still target that server; callTool now rejects it
with a controlled error naming the server and its last connection
error, instead of dereferencing the null client and throwing a
TypeError.
2026-07-29 16:25:23 -07:00
Edoardo Busano 704e953b69 fix(shared): keep valid OTEL headers when one entry is malformed (#12260)
parseKeyPairsIntoRecord wrapped the whole forEach in one try/catch, so a single entry that broke decodeURIComponent (e.g. a stray % in OTEL_EXPORTER_OTLP_HEADERS) aborted the loop and silently dropped every remaining header. Move the try/catch inside the loop to skip only the malformed entry. Adds regression tests.
2026-07-30 00:47:44 +02:00
John Choi c5661f8835 refactor(ui): extract desktop quick actions (#12664)
* refactor(ui): extract desktop quick actions

* fix(ui): preserve quick action visual parity

* style(ui): format quick actions import

* style(ui): format packaged component files
2026-07-29 14:52:05 -07:00
Ara 60c5a24eef Route reasoning controls from models.dev across providers (#12542)
* refactor(reasoning): route model controls from models.dev

Preserve typed reasoning capabilities from the model catalog, normalize requests once against advertised effort, budget, and toggle controls, and keep provider adapters focused on wire encoding. CLI presentation changes are intentionally deferred to a follow-up.

* fix(llms): encode catalog reasoning controls per provider

* fix(llms): clamp reasoning defaults and budgets

* refactor(shared): narrow reasoning exports

* refactor(llms): colocate reasoning controls

* fix(llms): handle mandatory Claude reasoning modes

* fix(llms): omit impossible Anthropic thinking

* fix(llms): reject impossible Anthropic thinking
2026-07-29 23:43:59 +02:00
John Choi 3f9ed573db refactor(ui): extract desktop aurora (#12665)
* refactor(ui): extract desktop aurora

* docs(ui): preserve aurora constraints

* docs(ui): document aurora container contract

* style(ui): format package file list
2026-07-29 14:30:56 -07:00
Bee ac432c87f0 fix(desktop): prevent long-running chat turns from timing out (#12671)
* fix(desktop): disable timeout for chat send commands

Add per-invocation timeout options to the desktop client and disable the deadline for long-running chat send requests. Extract the shared command response type and verify send commands use the timeout override.

* fix(desktop): clean up failed websocket sends
2026-07-29 23:30:28 +02:00
Etisha Garg 95b841a2dd docs: add screenshots for finding free models (#12690) 2026-07-29 12:50:43 -07:00
Saoud RizwanandRenee Huang 7d63376d98 Revert "Revert "docs: add Cline free models page (#12183)" (#12185)" (#12186)
This reverts commit ed3107f9ec.

Co-authored-by: Renee Huang <100229782+reneehuang1@users.noreply.github.com>
2026-07-29 07:41:43 -07:00
Sufiyan Khan 912c467818 fix(vscode): cancel Cline task on signout (#12657) 2026-07-29 00:41:33 -07:00
Saoud Rizwan c39c6d4479 feat(vscode): enable Cline Pass unconditionally, removing the ext-cline-pass feature flag (#12677) 2026-07-29 00:23:32 -07:00
Saoud Rizwan 071e5451b1 fix(webview): use theme-colored dropdowns for native select elements (#12676) 2026-07-28 23:55:28 -07:00
Saoud Rizwan 159961b3a3 Fix useDebouncedInput firing onChange on mount, which could wipe stored API keys (#12675)
useDebouncedInput scheduled its debounced onChange on mount and on every
external initialValue resync, not just user edits. Settings fields mount
with a placeholder value while their backing provider config is still
loading asynchronously, so the mount-fire echoed that placeholder back
to the backend ~100ms later.

For DebouncedTextField-backed secret fields (e.g. the OpenRouter API key,
which renders a masked value derived from the async readProviderConfig
response), losing that race meant writing apiKey: "" — silently deleting
the stored key from both providers.json and the legacy secrets store,
and leaving the field rendering empty despite a previously persisted key.
Non-secret fields similarly re-saved stale placeholder values on every
mount.

Gate the debounced save on an actual user edit: only values set through
the returned setter fire onChange; mount and external resyncs never do.
2026-07-28 22:39:13 -07:00
Saoud Rizwan 4a3f1ce310 fix(openai-compatible): carry custom model metadata across model-id changes (ENG-2341) (#12628)
* fix(openai-compatible): keep user model metadata when only the model id changes

Changing the OpenAI Compatible model id committed the new id without
overrides, so an id unknown to the catalog resolved to safe defaults
(inputPrice/outputPrice 0, supportsPromptCache false) and paid requests
billed as $0.0000. The legacy extension kept this user-authored metadata
in a single id-independent blob, so custom prices survived id edits.

Recommit the currently displayed overrides under the new id when the
model id changes, and let edits made while that commit is round-tripping
target the pending id instead of the stale read-back id.

Fixes ENG-2341

* fix(openai-compatible): scope pending selection state per mode

Review follow-up: the pending-override accumulator and pending-commit
counter were shared across Plan and Act. Changing the Act model id,
switching to Plan while that commit was round-tripping, then editing an
override committed the Plan edit under the pending Act model id (and the
shared pending count blocked Plan's reseed at the mode boundary).

Record the mode alongside the pending selection and only trust it for
edits in the same mode, keep per-mode pending counts so a mode switch
reseeds from that mode's committed state, and cover the deferred-commit
mode-switch scenario with a component test.

* fix(openai-compatible): give each mode its own pending-selection accumulator

Review follow-up: tagging the single shared accumulator with a mode still
lost state on a mode round trip. With an Act commit pending, visiting
Plan reseeded the shared slot to Plan; returning to Act could not reseed
(Act's read-back was still in flight), so the next Act edit merged onto
an empty set and silently dropped the pending prices/context/capabilities.

Keep one accumulator slot per mode so a round trip through the other
mode never disturbs a mode's pending state, and cover the scenario with
a deferred-commit round-trip test.
2026-07-28 22:30:19 -07:00
Saoud RizwanandSaoud Rizwan 10a658a767 fix: report OpenRouter Anthropic models' full 1m context window consistently (#12629)
The OpenRouter model picker (refreshOpenRouterModels) still applied the
legacy 200k context-window restriction to Anthropic Claude models, while
the task header and auto-compaction resolve model info through the SDK
catalog, which reports the full 1m extended context window. The same
model showed Context: 200K in the picker and 1.0m in the task header.

Per the current product direction the 200k restriction (and its :1m
opt-in variants) is dropped entirely — everyone gets the 1m context
window. Remove the artificial clamps from refreshOpenRouterModels
(keeping the prompt-cache pricing overrides) and update the
openRouterDefaultModelInfo fallback to match, so the picker, the task
header, and compaction thresholds all agree on 1m.

Closes ENG-2345.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-28 22:04:55 -07:00
9d63bfcb31 fix(vscode): restore foreground terminal default (#12672)
Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-28 22:01:31 -07:00
Saoud RizwanandSaoud Rizwan daa32ee138 fix(core): migrate legacy API keys for all secret-backed providers (ENG-2337) (#12626)
* fix(core): migrate legacy API keys for all secret-backed providers

collectCandidateProviderIds only nominated 11 provider ids while
buildLegacyProviderSettings can copy keys for 34, so stored keys for the
other 25 providers (deepseek, mistral, xai, groq, ...) were silently
dropped during migration unless the provider was the active plan/act
provider. Add the missing candidate checks so any stored key makes its
provider a migration candidate.

Also pick the legacy mode per candidate: a split plan/act config applied
the single globalState.mode to every provider, so the non-current mode's
configured model was replaced by the catalog default.

Migration re-runs on manager construction and never overwrites existing
entries, so users who already ran the buggy migration get dropped keys
backfilled from the still-present legacy secrets.json on next launch.

Fixes ENG-2337

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* fix(core): normalize legacy provider-id aliases during migration

Address review: the mode selection and model fallback compared raw
legacy provider ids, so a declared alias (togetherai -> together,
sap-ai-core -> sapaicore) in globalState would miss its canonical
secret-derived candidate, read the wrong mode, and could write duplicate
alias/canonical entries. Route candidate collection, mode comparison,
and the generic model fallback through the existing normalizeProviderId
boundary. resolveMigratedProviderId now delegates to normalizeProviderId
(identical for the openai -> openai-compatible case it already handled).

Legacy ApiProvider never actually stored alias forms, so this is
hardening for hand-edited state rather than a live regression.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-28 21:59:39 -07:00
Saoud RizwanandSaoud Rizwan a51fa646bb fix(vscode): reconcile the two provider state stores (ENG-2332) (#12640)
* fix(vscode): reconcile the two provider state stores (ENG-2332)

- createStorageContext now honors CLINE_DATA_DIR with the same priority as
  the SDK's resolveClineDataDir and the legacy reader's resolveDataDir
  (explicit option > CLINE_DATA_DIR > CLINE_DIR/data > ~/.cline/data), so
  globalState.json/secrets.json live in the same data dir as providers.json
  and legacy task state instead of silently splitting across directories.
- Add setLastUsedProvider and call it on active provider switches
  (SdkProviderChangeCoordinator) and when a session resolves its provider
  from StateManager (buildSessionConfig), so providers.json's
  lastUsedProvider no longer goes stale across provider switches.
- Trim env vars in legacy-state-reader's resolveDataDir to match the SDK's
  resolution exactly.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* refactor: trim ENG-2332 fix to the minimal change set

Revert the cosmetic legacy-state-reader trim, restore the original CLINE_DIR
line in createStorageContext, and tighten comments. No behavior change to
the two core fixes.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* refactor: drop lastUsedProvider sync, keep only the data-dir alignment fix

Scope ENG-2332 to the root-cause fix: createStorageContext honoring
CLINE_DATA_DIR like the SDK resolvers. The providers.json lastUsedProvider
staleness is deferred to a follow-up.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* fix: trim CLINE_DATA_DIR in resolveDataDir to match createStorageContext

Addresses Greptile P1: a whitespace-padded CLINE_DATA_DIR was trimmed by
createStorageContext but used verbatim by the legacy reader, which could
resolve the two stores to different directories again.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* chore: retrigger CI (windows e2e flake in chat.test.ts)

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* refactor: share one data-dir resolver between storage context and legacy reader

Per review feedback: extract resolveDataDirFromEnv in storage-context.ts and
have legacy-state-reader's resolveDataDir delegate to it, so the two stores
structurally cannot drift apart again.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* fix: trim CLINE_DIR in the shared data-dir resolver to match the SDK

The SDK's resolveClineDir trims CLINE_DIR; a whitespace-padded value would
otherwise still resolve VS Code state and providers.json to different
directories.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-28 21:36:02 -07:00
John Choi 5213070f67 refactor(ui): extract desktop hero heading (#12609)
* refactor(ui): extract desktop hero heading

* test(ui): preserve hero heading constraints

* docs(ui): trim hero heading comments
2026-07-28 19:48:32 -07:00
Saoud Rizwan 7d7cddb9d9 chore(desktop): release v0.0.7 2026-07-28 18:33:51 -07:00
Saoud Rizwan 55e169f1f8 feat(vscode): working-directory badge in task header for out-of-workspace tasks (#12637)
* feat(vscode): show working-directory badge when a task runs outside the open workspace

Tasks resumed from the CLI or another workspace keep their original cwd,
so Cline reads, edits, and runs commands in a directory that is not the
one visible in the window - previously with no indication anywhere.

- Add TaskWorkingDirectoryBadge: a persistent warning chip in the task
  header (folder icon + cwd basename, full path + explanation in the
  tooltip) shown only when the task cwd is neither an open workspace
  root nor inside one. Hidden when roots or cwd are unknown to avoid
  false positives.
- Fix SdkController.getStateToPostToWebview to pass its workspace
  manager into the shared state builder; the SDK path previously always
  sent workspaceRoots: [] to the webview.
- Unit tests for the outside-workspace predicate (case, separators,
  multi-root, prefix collisions) and badge render states.

* fix(vscode): platform-aware path comparison in working-directory badge

Address PR #12637 review findings:
- Case folding is now platform-aware (win32/darwin insensitive, linux
  and unknown strict), so case-only path differences on Linux are no
  longer hidden; mirrors arePathsEqual in src/utils/path.ts.
- Backslashes are treated as separators only on win32; on POSIX a
  backslash is an ordinary filename character.
- Containment prefix no longer doubles the separator when a workspace
  root already ends with one, fixing false warnings for '/' and drive
  roots.
- Tests cover case-only pairs under win32/darwin/linux/unknown,
  POSIX-backslash filenames, '/' and 'C:\' workspace roots.

* fix(vscode): make darwin path comparison strict in working-directory badge

Follow-up to PR #12637 review: darwin volumes can be case-sensitive, and
the host's canonical arePathsEqual (src/utils/path.ts) already treats
only win32 as case-insensitive. Align the badge predicate with that
convention: case folding and backslash separators apply on win32 only;
darwin, linux, and unknown compare strictly. For a warning badge a rare
spurious warning beats silently hiding a real mismatch.
2026-07-28 18:23:41 -07:00
Saoud Rizwan c227e1ae36 fix(vscode): restore legacy workflow invocation and management UI (#12562)
* fix(vscode): restore legacy workflow invocation and management UI

- Expand /workflow slash commands typed with the legacy .md filename
  spelling (what the autocomplete menu inserts) and mid-message, and
  honor the user's workflow enable/disable toggles, instead of only
  expanding a leading extension-less /name via the SDK resolver.
- Restore the Workflows tab in the rules modal (view, toggle, create,
  edit, delete; enterprise section) that was dropped in the SDK-backed
  extension while all its gRPC handlers remained wired.

* chore: add changeset for workflow fixes

* fix(vscode): refresh workflow toggles on webview launch

The slash command menu is driven by workflowToggles state, but nothing
refreshed it at startup in the SDK-backed extension (only opening the
rules modal or creating a rule file did), so workflows never appeared in
the chat autocomplete until the user opened the modal. Legacy refreshed
toggles on task init.

* feat(vscode): move Workflows tab last and add deprecation warning

Workflows tab now appears after Rules/Hooks/Skills, and its view leads
with a warning banner: workflows are being deprecated in favor of
skills, with a docs link.

* chore: update changeset for workflow deprecation notice

* fix(vscode): address review findings on workflow expansion

- Honor remoteWorkflowToggles (and locked alwaysEnabled remote
  workflows) when building the disabled set, so disabled enterprise
  workflows no longer expand.
- Treat a workflow as disabled only when no scope has it enabled, so a
  disabled workspace file no longer shadows a same-named enabled global
  one (legacy expanded the enabled scope).
- Strip all workflow extensions the SDK discovers (.md/.markdown/.txt)
  when matching typed commands, not just .md.
- Re-read toggle state after the async directory scan in
  refreshWorkflowToggles so a toggle flipped mid-scan is not overwritten
  by the stale snapshot.

* fix(vscode): map workflow toggles to records so frontmatter names are governed

Compute the disabled set from the discovered workflow records
(listRecords) instead of toggle-path basenames alone: a file's toggle is
matched by its basename and disables the record's actual command name,
so a frontmatter 'name' that differs from the filename is still governed
by the Workflows toggle. Remote-config-materialized records are governed
by the name-keyed remote toggles (locked alwaysEnabled remain on).

* fix(vscode): harden workflow toggle-name mapping for expansion

- A command name shared by several records now counts as enabled when
  any record is enabled, so a disabled local workflow can no longer
  suppress an enabled or locked (alwaysEnabled) enterprise workflow.
- Remote toggles/locks are matched via a sanitizeSegment-compatible key,
  so config names that get rewritten during materialization (e.g. 'Org
  Standards' -> org-standards.md) still govern expansion.
- Typed filenames (e.g. /my-workflow.md from autocomplete) now resolve
  to workflows whose frontmatter renames the command, via the record's
  file basename.

* fix(vscode): govern each workflow command by its own record's toggle

Key the disabled set by exact command name and decide each record
independently instead of OR-aggregating by canonical name: distinct
commands whose names only differ by case or extension (e.g. a local
'Release' and a remote 'release') no longer influence each other, so an
enabled local workflow cannot keep a disabled enterprise workflow
expandable, and a disabled one cannot suppress a locked enterprise
workflow.

* fix(vscode): exact remote-name sanitization and keep mid-scan toggle additions

- Port @cline/shared's sanitizeSegment verbatim (incl. the 80-char cap)
  for remote workflow name comparison, so long enterprise workflow names
  cannot bypass a disabled toggle after filename truncation.
- The post-scan toggle merge now also keeps entries added while the scan
  was running (e.g. a workflow created via the modal), instead of
  pruning them with the deleted files.

* fix(vscode): handle mid-scan deletions and sanitized remote-name collisions

- The post-scan toggle merge now also drops entries that were removed
  from state while the scan ran, so a workflow deleted mid-refresh is
  not restored by the stale scan result.
- Remote toggle names that sanitize to the same materialized name merge
  as enabled-if-any-enabled instead of last-write-wins.

* fix(vscode): serialize workflow toggle refreshes

Queue refreshWorkflowToggles runs on a promise chain so overlapping
refreshes (webview launch, modal open, file create/delete) cannot
interleave scans and writes. Combined with the post-scan merge for
direct toggle flips, this closes the remaining stale-refresh races.

* fix(vscode): key remote workflow toggles off the materialized filename

The materializer names remote workflow files from the config name, so
derive the remote toggle key from the file basename instead of the
parsed command name; a frontmatter alias can no longer bypass a
disabled remote toggle.
2026-07-28 18:20:54 -07:00
Saoud RizwanandSaoud Rizwan d0a0c802af fix(vscode): interrupted tasks disappear from History (ENG-2336) (#12613)
* fix(vscode): make interrupted tasks findable in History and restore Resume button

Interrupted/cancelled sessions were presented as gone (ENG-2336):

- History fuzzy search used location-based Fuse scoring (ignoreLocation:
  false, threshold 0.6), so any match more than ~60 characters into the
  task title scored above the threshold and the task silently vanished
  from search results even though it was in the list. Search now matches
  anywhere in the title.

- Opening a task from History never updated the authoritative TurnState,
  so the footer kept the previous context's phase (usually idle) and the
  Resume Task button never appeared for interrupted/failed sessions.
  showTaskWithId now derives the phase from the reopened conversation:
  resumable for interrupted tasks, completed for completed ones.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* fix(vscode): decide Resume vs Start New Task from persisted session status

SDK conversations do not record a completion tool call in the transcript
(a completed turn and one interrupted mid-stream both end with plain
assistant text), and history rendering appends a synthetic trailing
ask:"completion_result" either way, so the message tail always looked
"completed". Reopening a task from History now reads the persisted
session status: "completed" gets the Start New Task affordance, while
cancelled/failed (interrupted) sessions get Resume Task. When reopening
the currently-active task, the stop is awaited first so the status read
reflects how the last turn actually ended.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* fix(vscode): fence concurrent history opens and default unknown status to Resume

Address review feedback:

- showTaskWithId now takes a generation fence: a request that loses the
  race to a newer showTaskWithId or clearTask abandons installation after
  its awaited reads, so a slow older request can never clobber the user's
  latest selection (task proxy, messages, or turn phase). clearTask bumps
  the generation too so New Task wins over an in-flight history open.

- The resume affordance no longer falls back to the message tail when the
  persisted session status is unavailable: the tail always ends with the
  synthetic ask:"completion_result" that history rendering appends, which
  misclassified interrupted tasks as completed on a failed status read.
  Only an explicit "completed" status gets Start New Task; anything else
  (including unknown) gets Resume Task, the safe direction.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* fix(vscode): allocate history-open generation before the lookup and fence before session stop

Address review feedback: the latest-selection-wins fence started too late.
SdkController.showTaskWithId awaited findHistoryItem() before entering the
coordinator, so a stalled preflight for an older selection could re-enter
with a NEWER generation than a later selection and replace it — and since
the first fence check sat after endActiveSession, a superseded request
could also stop a session the newer selection had just installed.

The history lookup now lives inside the coordinator (skipHistoryLookup is
gone), the generation is allocated synchronously before all asynchronous
work, and a fence check runs before endActiveSession so a superseded open
never stops the newer selection's session. The coordinator returns the
HistoryItem so SdkController keeps its TaskResponse contract. Regression
test covers the exact reported sequence: stalled lookup for task A, task B
selected and loaded, A resolves last — B stays installed and A stops
nothing.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-28 18:16:19 -07:00
Saoud Rizwan f9227fead4 Show completion feedback box for inferred turn-final responses in SDK path (#12638)
* Show completion feedback box for inferred turn-final responses in SDK path

The SDK agent usually ends a turn with a plain text response instead of an
attempt_completion / plan_mode_respond tool call, so the legacy green 'Task
Completed' box (act) and 'Plan Created' box (plan) never rendered in the new
extension — making a finished turn look stuck or frozen.

Now, when a turn ends cleanly (done reason 'completed', no completion tool
used) and its last content is a text response, that text row is retagged in
place to say:'completion_result' (act, green box) or the new
say:'plan_completion_result' (plan, yellow-accented 'Plan Created' box).

- Track the turn-final text candidate in MessageTranslatorState; cleared on
  tool activity, errors, aborts, and new user turns
- Replay the same inference during history rehydration, recovering each
  turn's plan/act mode from the persisted <user_input mode="..."> wrapper
- Add plan_completion_result ClineSay type (+ proto enum) rendered via
  PlanCompletionOutputRow, restyled with the plan-yellow accent to match
  the plan/act toggle and the CLI's plan color
- Turn phase semantics unchanged: footer buttons still come from TurnState

* Remove attempt_completion tool and strip completion box headers

- Drop the attempt_completion extra tool (and its shell-command executor)
  from VS Code SDK sessions; the SDK's built-in submit_and_exit is already
  disabled for act/plan presets, so the agent now always ends its turn with
  a plain text response and the turn-end inference styles it.
- Translator keeps recognizing attempt_completion/submit_and_exit for
  replaying persisted transcripts from older sessions.
- Remove the 'Task Completed' header, check icon, and copy button from the
  green completion box, and the 'Plan Created' header, notepad icon, and
  copy button from the yellow plan box. The final text of a turn may be a
  question rather than an actual completion or plan, so the boxes are now
  quiet color cues that make no claim.

* Skip completion retag for terminal text of failed/cancelled sessions

The trailing text of a session whose last run failed or was cancelled is a
dangling partial response, not a completion. Gate the history converter's
final synthesized turn end on the session record's status so reopening a
broken task keeps its terminal text as a plain row instead of an inferred
completion box. Mid-transcript turns are unaffected: the user continued
after them and history carries no per-turn outcome.

* Require clean at-rest session status before retagging terminal text

Tighten the negative failed/cancelled check into an allowlist: the history
converter now only retags the transcript's terminal text when the session
record is 'completed' (formally stopped clean run) or 'idle' (the normal
at-rest state between interactive turns). 'running'/'pending' at rest means
the process died mid-turn, so its dangling partial response stays plain.

* Restrict history completion retag to the transcript's final turn

Persisted SDK transcripts carry no per-turn outcome, so a mid-conversation
turn the user cancelled mid-response (then followed up on) is
indistinguishable from one that ended cleanly. Retagging those presented
interrupted responses as deliberate turn ends. History rehydration now only
retags the final turn's terminal text, gated on the session record's
at-rest status; earlier turns always render as plain text. Live sessions
are unaffected — their per-turn boxes come from real done events.

* Trust only status 'completed' for the history completion retag

'idle' is written by markTurnIdle for every interactive finish reason,
including aborted turns, so an at-rest idle record cannot prove the last
turn ended cleanly. Terminal statuses are reliably written when sessions
are released (task switch, clear, dispose), so requiring 'completed' keeps
the box on normal reopened tasks while never styling an interrupted
response as a deliberate turn end.

* Treat missing session records as unknown outcome in history retag

A transcript with no session record has no recorded outcome, so its
terminal text stays a plain row instead of getting completion styling.
2026-07-28 17:38:00 -07:00
Saoud RizwanandSaoud Rizwan bd83980359 fix(vscode): also check file-backed stores before re-onboarding upgraders (ENG-2346) (#12639)
migrateWelcomeViewCompleted derived the flag solely from VS Code's
per-profile stores, which are empty for users upgrading from the live
4.x extension (file-backed config under ~/.cline/data). The flag landed
as false and fully configured users were pushed back through onboarding.

Purely additive: the existing VS Code checks are untouched; the same
signals (completed flag, provider secrets, keyless provider configs) are
now also read from the file-backed globalState.json/secrets.json and
OR-ed into the result.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-28 17:16:28 -07:00
Saoud Rizwan 4d3b161b9a fix(core): coerce line-number tool args (#12641)
Models sometimes emit numeric tool arguments as JSON strings. `insert_line`
and the `read_files` line bounds were plain `z.number()`, so an
`insert_line: "3"` rejected the whole tool call before it ran:

  1 tool call(s) failed: [editor] {"error":"✖ Invalid input: expected number,
  received string\n  → at insert_line"}

The model is handed that error and burns a round trip re-deriving the argument.

`z.coerce` leaves the JSON Schema advertised to the model untouched (still
`integer`), and `.int()` / `.positive()` still reject "abc", "3.5" and 3.5.
2026-07-28 16:49:01 -07:00
Saoud RizwanandSaoud Rizwan 255be9ad29 fix: stop Ollama model picker polling /api/tags once per second forever (ENG-2344) (#12621)
* fix(webview): stop unbounded polling of local model endpoints (ENG-2344)

The Ollama provider form polled /api/tags every 2s from two places at once
(OllamaProvider and a dead duplicate poll in ApiOptions whose result was
never read), producing ~1 req/s for as long as the settings pane was open.
Since the base URL is user-configurable, this could hammer a remote or
metered endpoint. VSCodeLmProvider and LMStudioProvider had the same
interval pattern.

- Remove all useInterval model polling; fetch on mount and when the
  base URL changes instead
- Refresh the Ollama model list when the picker field gains focus so a
  server started after the pane opened is still discovered
- Delete the dead _ollamaModels poll in ApiOptions

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* fix(webview): add on-demand model refresh for LM Studio and VS Code LM

Greptile review follow-up: removing the polling intervals left these two
pickers pinned to their mount-time snapshot. Mirror the Ollama picker's
interaction-driven refresh:

- LM Studio: refetch models when the model dropdown or the manual model
  id field gains focus
- VS Code LM: refetch when the dropdown gains focus, and add an explicit
  'Refresh the model list' link to the empty state

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-28 16:44:46 -07:00
Saoud Rizwan 77b181a472 fix(vscode): label editor insert_line edits as edits, not new-file creations (#12635)
An `editor` tool call with `insert_line` (e.g. a prepend) targets an
existing file — the SDK editor executor requires the file to already exist
for inserts — but sdkToolToClineSayTool only treated `old_text`/`replace_in_file`
as edits, so inserts were classified as newFileCreated and the approval card
read "Cline wants to create a new file:" for an existing file.

Treat insert_line as an edit so the card reads "Cline wants to edit this file:".
2026-07-28 16:30:38 -07:00
Saoud Rizwan 45476650b7 fix(vscode): stop Preferred Language from silently resetting on settings mount (#12632)
The webview-ui-toolkit VSCodeDropdown fires a spurious change event with
the wrong option (index 2, Portuguese - Brasil) while its slotted options
initialize after a window reload, and the handler persisted that value
unconditionally. Any saved language not at the top of the list could be
silently rewritten to Portuguese just by opening the General settings tab.

Replace the toolkit dropdown with the ui/select component already used by
the other settings dropdowns (Auto Compact Strategy, MCP Display Mode),
which only emits onValueChange for real user selections, and render the
options from the shared languageOptions list instead of a hardcoded copy.
2026-07-28 16:30:28 -07:00
Saoud Rizwan f847b06cfd Fix thinking indicator flashing when a turn completes (#12631)
At turn end the final message is finalized (partial: false) via the fast
partial-message stream a moment before the done event flips turnState out
of "streaming" via a full state post. During that gap the in-list
"Thinking..." loader row appeared and immediately disappeared, flashing
on every turn completion.

- Extract the loader show/hide logic from MessagesArea into a testable
  useThinkingLoaderRow hook.
- Debounce the loader when its trigger is the tail message finishing
  streaming: mid-turn a real wait outlives the grace period, while the
  turn-end phase change cancels it before it ever shows.
- Add the legacy path's say("completion_result") anti-flicker guard to
  the turnState path so attempt_completion turns never flash regardless
  of timing.
2026-07-28 16:30:18 -07:00
Saoud RizwanandSaoud Rizwan 598b3af7eb fix(cli): report correct default directories for --config and --data-dir in --help (#12627)
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-28 16:30:00 -07:00
Saoud RizwanandSaoud Rizwan fc936b7305 fix(vscode): restore Retry/Start New Task buttons after API failure (ENG-2339) (#12625)
* fix(vscode): restore Retry/Start New Task buttons after API failure

A provider stream error emits ask:'api_req_failed', but the session-event
coordinator resolved the turn-end phase to 'awaiting_followup', clobbering
the error state — so the footer never showed the error-recovery buttons and
the error surface offered no way to recover (ENG-2339).

Record the error outcome in MessageTranslatorState when the error event is
translated, and resolve turn end to the 'error' phase so the existing
api_req_failed button config (Retry / Start New Task) is reachable again,
matching legacy behavior.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* fix(vscode): also record error outcome for done(reason:'error') terminations

A turn can terminate with done(reason:'error') without a separate 'error'
event; record the error outcome there too so turn end still resolves to the
'error' phase and the Retry / Start New Task buttons appear.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-28 16:29:48 -07:00
Saoud Rizwan 9247dc30bb chore(vscode): remove dead task history command (#12624) 2026-07-28 16:29:35 -07:00
Saoud RizwanandSaoud Rizwan 1b499161cc Fix ignored China/international endpoint toggles for Qwen, Moonshot, Z AI (ENG-2340) (#12623)
* Fix ignored China/international API line toggles for Qwen, Moonshot, Z AI (ENG-2340)

The regional apiLine setting was persisted through both storage layers but
never consulted when resolving the request endpoint, silently sending
regional users to the wrong host.

- @cline/llms: record china/international base URLs on the builtin specs
  for qwen, qwen-code, moonshot, zai, zai-coding-plan, and minimax; expose
  resolveProviderApiLineBaseUrl; resolve options.apiLine against the
  registered apiLineBaseUrls in GatewayRegistry.createProvider (explicit
  base URLs still win).
- @cline/core: toProviderConfig now resolves the base URL from apiLine
  between the explicit setting and the static provider default.
- VS Code: buildSessionConfig resolves the API line from legacy state
  (qwenApiLine/moonshotApiLine/zaiApiLine/minimaxApiLine) with a
  providers.json fallback and forwards it on the provider config so the
  gateway can route regionally.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* Share the base provider's legacy API line with qwen-code and zai-coding-plan

The coding variants have regional endpoints in the SDK but no legacy
state field of their own, so a China-line user selecting them from the
VS Code UI would silently fall back to the international default. The
variant's own providers.json apiLine still wins over the shared legacy
field.

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-28 16:29:28 -07:00
Saoud RizwanandSaoud Rizwan 0e5cb433b0 fix(core): expose live in-memory session messages for mode-switch rebuilds (#12622)
Session rebuilds seed the replacement session from readMessages, but the
persisted transcript only catches up at assistant-message/turn boundaries
and abort() does not flush. Toggling plan/act mode while a task's first
turn is mid-flight (e.g. a command approval pending) therefore rebuilt the
session with no history at all and the new mode's model lost the task.

Add RuntimeHost.readLiveSessionMessages (optional) which prefers the
resident session's agent.getMessages() and falls back to the persisted
transcript, expose it as ClineCore.readLiveMessages, and use it in the
VS Code history loader that feeds session rebuilds. readSessionMessages
keeps its persisted-transcript semantics for existing callers (compaction
validation, session snapshots, history).

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-28 16:29:18 -07:00
Saoud Rizwan 53a46c309b Revert "Add a built-in cline-settings skill and broaden the legacy resume war…" (#12669)
This reverts commit 36c78267c6.
2026-07-28 16:15:25 -07:00
Dominic CooneyandSaoud Rizwan 36c78267c6 Add a built-in cline-settings skill and broaden the legacy resume warning (#12660)
* Add a built-in cline-settings skill and broaden the legacy resume
warning

Models diagnosing configuration problems have no authoritative source
for where Cline stores settings: the SDK migration removed the old MCP
documentation tool, and resumed legacy conversations can carry stale
paths and instructions from older runtimes (CLINE-2570).

Add a core-owned virtual skill, cline-settings, whose instructions are
generated at invocation from the shared storage path resolvers. It is
listed and invoked through the existing skills registry on both the
local and Hub session paths, is reserved against shadowing by
file-backed skills (case-insensitive), honors session skill allowlists
(an explicit empty allowlist disables all skills including built-ins),
and never appears in editable listRecords.

Broaden LEGACY_RESUME_MODEL_WARNING to cover stale configuration
paths, file formats, and product instructions, not just tool names.
Anchor the persisted history boundary on a stable marker; recognize
and upgrade the historical warning in place so previously resumed
tasks get the new wording without duplicate warnings, and preserve
resumed user text that shares a message with the warning.

* Fix Windows MCP stdio spawn for paths with spaces; add settings-skill
rule

The runtime-builder MCP test failed on Windows because the stdio
client spawns with shell: true there, and cmd.exe split the unquoted
executable path at the space in "C:\Program Files\nodejs\node.exe".
Quote the command and arguments for cmd.exe so any server whose
command or arguments contain spaces can start. Also raise the connect
timeout to match the request timeout: connect covers process spawn
plus the first initialize round-trip, and 1.5s is tight for cold
starts on loaded machines.

Add a brief .clinerule noting that settings/storage-path changes may
require updating the cline-settings built-in skill.

* Quote empty MCP arguments for cmd.exe

An empty-string argument passed through unquoted disappears when
cmd.exe re-parses the concatenated command line, silently shifting the
server's argument list. Quote empty values so they survive as "".

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-28 16:10:27 -07:00
Tomás Barreiro 76c30b1c60 Update ClineFreeModelLimitError wording (#12666) 2026-07-29 01:03:44 +02:00
Saoud Rizwan b496c5a1f3 chore(cli): release v3.0.47 2026-07-28 15:36:26 -07:00
Bee 2fc06ce446 feat(desktop): add pagination to session history view (#12618)
* fix(desktop): order sessions by last activity and unify status dot colors

* feat(desktop): add pagination to session history view

Display sessions in ten-item pages and fetch older history only after reaching the final local page. Add coverage for pagination, session opening, and compact token formatting.

* page numbers

* Support adding a session to favorite list

* apply feedback

* fix
2026-07-28 15:30:43 -07:00
Bee 64c50380f3 feat(chat): refine tool and reasoning message disclosures (#12616)
* feat(chat): refine tool and reasoning message disclosures

Add tool-specific icons with a fallback, display elapsed reasoning time, and restyle reasoning disclosures. Position hidden message actions outside the layout and update attachment sizing to valid Tailwind utilities.

* fix(desktop): improve chat message actions and scrolling

Refine action positioning, sizing, timestamps, and visibility for chat messages. Remove nested overflow constraints so scrolling remains controlled by the conversation viewport, and tighten tool disclosure spacing.

* tools icon mapping

* fix(desktop): align chat timestamps and tool icons
2026-07-28 15:30:33 -07:00
431 changed files with 22732 additions and 6523 deletions
+1
View File
@@ -0,0 +1 @@
../../.cline/skills/publish-extension
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
fix: restore workflow support regressions — expand `/workflow.md` slash commands (the legacy filename spelling the autocomplete inserts) and mid-message commands, honor workflow enable/disable toggles during expansion, refresh the slash menu's workflow list on webview launch, and bring back the Workflows management tab in the rules modal (now last in the tab list, with a deprecation notice pointing to Skills)
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Fix hidden plan/act mode-switch and task-resumption prompts reappearing as user messages when a task is reopened from history
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Enable Auto Compact by default so long chats automatically compress conversation history instead of failing at the model context limit. It can be disabled in Settings → Features → "Auto Compact".
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Fix /compact UX: clear the chat input as soon as the command is submitted, wrap the compaction divider row at narrow sidebar widths, and update the context-window header even when compacting a small conversation grows the estimated context
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Disable feature tips by default; they can be enabled in Settings → Features → "Feature Tips"
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Show the edited file in a regular editor tab after the diff preview closes, restoring the legacy post-edit behavior
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Show the user's message in chat immediately when sending to a task opened from history, instead of only a thinking indicator until the session resume finishes
+1
View File
@@ -0,0 +1 @@
../../.cline/skills/publish-extension
+176
View File
@@ -0,0 +1,176 @@
---
name: publish-extension
description: Use when releasing the Cline VS Code extension — stable (currently the combined legacy+next A/B VSIX via ext-vscode-ab-package), nightly (ext-vscode-publish-nightly), or a legacy-branch hotfix (ext-vscode-publish-legacy). Guides version selection, changelog, PostHog rollout-flag coordination, workflow dispatch, environment approvals, tagging, and post-publish verification, plus the eventual cutover to publishing the SDK extension standalone.
---
# VS Code Extension Release
Use this skill when the user asks to release, publish, or ship the VS Code extension — stable, nightly, or a legacy hotfix — or to dial the rollout, or to cut over to the SDK extension permanently.
> Working directory: repo root. All workflows are dispatched from `main` (GitHub requires the workflow file on the default branch; each workflow checks out the refs it actually builds).
## The current era: combined A/B rollout
We are mid-migration from the legacy (npm, pre-SDK) extension to the next (SDK-based, bun) extension. Until the cutover is complete, **the stable and nightly listings ship a combined VSIX**: a small loader + two complete extensions (`next/` built from `main`, `legacy/` built from the `legacy-extension` branch). The loader picks one per window based on the PostHog flag `ext-sdk-bundle-rollout`. Deep-dive docs: `apps/vscode-rollout/README.md` (authoritative) and PR #12253 (design + runbook comments).
Endgame (see "Cutover" at the bottom): once the next bundle is trusted at 100%, stable goes back to a plain build of `main` via `ext-vscode-publish-stable.yml` and all the legacy/rollout machinery is retired.
### The listings and the workflows
| Channel | Marketplace ID | Workflow | Trigger | Version |
|---|---|---|---|---|
| Stable (combined) | `saoudrizwan.claude-dev` | `ext-vscode-ab-package.yml` | dispatch only; `publish` input defaults false | manual input (semver, e.g. `4.1.0`) |
| Nightly (combined) | `saoudrizwan.cline-nightly` | `ext-vscode-publish-nightly.yml` | cron 12:00 UTC + dispatch | auto `<major>.<minor>.<unix-ts>` from main's `apps/vscode/package.json` |
| Legacy hotfix (standalone) | `saoudrizwan.claude-dev` | `ext-vscode-publish-legacy.yml` | dispatch | from `apps/vscode/package.json` on `legacy-extension` |
| Stable standalone (post-cutover) | `saoudrizwan.claude-dev` | `ext-vscode-publish-stable.yml` | dispatch | from `apps/vscode/package.json` on `main` |
All three publish paths gate on tests before publishing: nightly and ab-package run the reusable bun suite (`ext-vscode-test.yml`, tests `main`) — ab-package additionally runs the legacy branch's npm suite — and the legacy workflow inlines the npm suite. Environment gates: stable paths use `publish``Publish` environment (required reviewers approve in the Actions UI); nightly uses `PublishNightly` (branch policy only, no reviewers — a reviewer requirement would block the cron).
## Golden rules (read before any release)
1. **One listing, one version line.** `claude-dev` is published from multiple workflows/branches. Every stable publish must use a version **strictly above the highest version ever published to the listing from any branch** — marketplace versions are monotonic and cannot be unpublished (supersede, never delete). Check what's live first:
```bash
curl -s -X POST "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery" \
-H "Content-Type: application/json" -H "Accept: application/json;api-version=3.0-preview.1" \
-d '{"filters":[{"criteria":[{"filterType":7,"value":"saoudrizwan.claude-dev"}]}],"flags":16}' \
| python3 -c "import json,sys; v=json.load(sys.stdin)['results'][0]['extensions'][0]['versions'][0]; print(v['version'], v['lastUpdated'])"
```
2. **Check the flag BEFORE any stable combined publish.** `ext-sdk-bundle-rollout` is **shared between nightly and stable** — the loader sends only a machine id to `/decide`, no channel property, so there is no per-channel targeting. If the flag is high (nightly dogfooding) and you publish stable, stable users get the next bundle at that same percentage. Verify the effective percentage empirically (no PostHog admin needed — sample `/decide` with random ids using the key inlined in any shipped loader):
```bash
node -e '
const KEY = process.argv[1]; // phc_... extracted from a shipped VSIX loader
(async () => {
let t = 0, n = 200;
for (let i = 0; i < n; i += 20) {
const rs = await Promise.all(Array.from({length: 20}, (_, j) =>
fetch("https://data.cline.bot/decide?v=3", { method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({api_key: KEY, distinct_id: `probe-${i+j}-${Math.random()}`})
}).then(r => r.json())));
for (const r of rs) if ((r.featureFlags||{})["ext-sdk-bundle-rollout"] === true) t++;
}
console.log(`~${(100*t/n).toFixed(1)}% (${t}/${n})`);
})()' "$KEY"
```
Flag changes are made in the PostHog UI (Cline project). **0% is the kill switch** — the flag is two-way; there is no separate killswitch flag. Dialing down demotes machines back to legacy on their next window reload.
3. **Ask before pushing** commits or tags. Environment approvals are the maintainer's to give.
4. **Changelog lives at the repo ROOT** (`CHANGELOG.md`), on the branch being released — not `apps/vscode/CHANGELOG.md` (doesn't exist). The legacy and stable workflows hard-fail unless the first heading is exactly `## [<version>]`.
5. **Stuck concurrency groups**: `ext-vscode-ab-package` groups on the version with `cancel-in-progress: false`. A run left `waiting` on environment approval blocks every later dispatch of the same version — cancel it (`gh run cancel <id>`) before re-dispatching.
## Stable release (combined A/B VSIX) — the current stable path
### Pre-flight
```bash
# 1. What's live, and what version comes next (must exceed it — rule 1)
# 2. Flag percentage (rule 2) — decide where it should be for this release
# 3. Legacy tip = what the non-promoted cohort will run; confirm it's the shipped hotfix line
git fetch origin main legacy-extension
git log --oneline -3 origin/legacy-extension
# 4. Cheap local rehearsal of the most likely build failure: the union manifest
# hard-fails if views/viewsContainers/configuration diverged between branches.
git show origin/main:apps/vscode/package.json > /tmp/next.json
git show origin/legacy-extension:apps/vscode/package.json > /tmp/legacy.json
node apps/vscode-rollout/scripts/gen-manifest.mjs --next /tmp/next.json --legacy /tmp/legacy.json --version <VERSION>
# Expected warnings only: engines union (takes newer) + walkthrough copy drift.
```
Release prep on `main` (PR, not direct push):
- Add `## [<VERSION>]` entry at the top of root `CHANGELOG.md`.
- Bump `apps/vscode/package.json` to `<VERSION>` so the repo reflects the published line. Side effect: nightly versions become `<major>.<minor>.<unix-ts>` of the new base — harmless (separate listing, still monotonic).
### Dispatch
```bash
gh workflow run ext-vscode-ab-package.yml --ref main \
-f version=<VERSION> -f next-ref=main -f legacy-ref=legacy-extension -f publish=true
# publish=false builds an installable .vsix artifact without publishing, but the
# package job still requires the same Publish environment approval — an
# unapproved rehearsal sits in `waiting` and blocks that version's concurrency
# group (rule 5).
gh run list --workflow=ext-vscode-ab-package.yml --limit 1
```
Both test suites run first (no approval needed); the gated `package` job then **waits for `Publish` environment approval** (Actions → run → "Review deployments"). Both bundles build the exact revisions their test gates ran against (branch names are resolved once — commits landing on either branch mid-run or during the approval wait are not picked up); `publish=true` is additionally refused for any `next-ref` other than `main` (the bun gate only tests main — non-main next-refs are for build-only artifact rehearsals). Check what a run is waiting on:
```bash
gh api repos/cline/cline/actions/runs/<run-id>/pending_deployments
```
### Post-publish
1. Verify the marketplace serves the new version (query from rule 1).
2. This workflow does **not** tag or create a GitHub release — do it manually:
```bash
git tag v<VERSION> <main-sha-built> # ask before pushing
git push origin v<VERSION>
gh release create v<VERSION> --title "v<VERSION>" --notes "<curated notes from CHANGELOG>"
```
3. Thorough artifact check (`gh run download <run-id>`): union `package.json` is `saoudrizwan.claude-dev@<VERSION>`, `next/package.json` and `legacy/package.json` carry the SAME version, `grep -c 'phc_' extension/extension.js` ≥ 1 (loader key inlined), no leftover `process.env.TELEMETRY_SERVICE_API_KEY` / `process.env.CLINE_ROLLOUT_VARIANT` literals in either bundle's dist (leftovers = a build ran without its env and telemetry is silently dead).
4. Monitor: `extension.rollout.bundle_activated` in `otel.otel_logs` filtered to `extension_version = '<VERSION>'` (stable cohort is cleanly separable — nightly versions are timestamps). Watch the next/legacy ratio and the crash-fallback rate; Metabase dashboards 17 (rollout + task error rate) and 19 (error deep dive). `extension.rollout.loader_decision` (incl. `double_failure`) is PostHog-only, not in ClickHouse.
5. Dial the flag per the rollout plan (e.g. 0% at publish → 1% → up), verifying each change with the probe from rule 2. Announce demotions ahead of time — dialing down also demotes nightly dogfooders unless they set `"cline-nightly.rollout.bundleOverride": "next"`.
### Known caveats of this path
- **Marketplace only** — no Open VSX step (both standalone workflows have one). Open VSX users stay on the last standalone version until a standalone publish or the cutover.
- **`engines.vscode` unions upward** (main's floor wins, e.g. `^1.101.0` vs legacy's `^1.84.0`): users on older VS Code are never offered the combined VSIX. Fail-safe during rollout; must be resolved before 100%.
- A red run can still mean a successful publish on paths that tag (see Gotchas).
## Nightly release
Happens automatically (cron 12:00 UTC). Manual cut:
```bash
gh workflow run ext-vscode-publish-nightly.yml --ref main # real publish
gh workflow run ext-vscode-publish-nightly.yml --ref main -f dry-run=true # artifact only
gh run watch <run-id> --exit-status --interval 60
```
No changelog/version prep — the version is computed. Verify with the marketplace query against `saoudrizwan.cline-nightly`.
**Red run ≠ failed publish**: the final tag-push step fails whenever main's HEAD touches `.github/workflows/**` (default token cannot create such refs). If "Published" appears in the logs, the release went out; push the `nightly-main-<UTC ts>-<sha12>` tag manually with user credentials.
## Legacy hotfix release (and emergency full rollback)
For shipping a fix on the `legacy-extension` branch — or as the **structural rollback** from a bad combined stable VSIX: a standalone legacy publish at a higher version supersedes the combined VSIX entirely (loader and all) for every user. (For "next bundle misbehaving" you don't need this — dial the flag to 0% instead.)
```bash
# On legacy-extension: commit the fix, bump apps/vscode/package.json ABOVE the
# highest version ever published to the listing (rule 1 — including combined
# versions, e.g. combined 4.1.0 live -> hotfix is 4.1.1, not 4.0.13),
# add the matching `## [x.y.z]` entry to root CHANGELOG.md, push.
gh workflow run ext-vscode-publish-legacy.yml --ref main \
-f release-type=release -f branch=legacy-extension
```
npm test suite runs ungated; the publish job waits on the `Publish` environment. This workflow derives + pushes the `v<version>` tag itself and creates the GitHub release — no manual tagging. Publishes to Marketplace **and** Open VSX. The branch is the npm codebase: use `npm`, never `bun`, and expect the old monolith layout (`apps/vscode/src/core/...`).
## Cutover: retiring the A/B machinery (the endgame)
When the next bundle has held at 100% long enough to trust:
1. **Resolve the engines floor**: decide whether stranding VS Code < main's `engines.vscode` on the last combined version is acceptable, or lower main's floor first.
2. Bump `apps/vscode/package.json` on `main` above everything ever published; root `CHANGELOG.md` entry to match (both are enforced by the workflow).
3. Ship standalone from main: `gh workflow run ext-vscode-publish-stable.yml --ref main` — tests main, tags `v<version>` itself, creates the GitHub release, publishes Marketplace + Open VSX (this also heals the Open VSX gap).
4. Watch the same rollout telemetry through the transition — `extension_variant` disappears from events as users leave combined builds, which is itself the adoption signal.
5. Only after the standalone version dominates: retire `legacy-extension` (keep for history), delete `ext-vscode-publish-legacy.yml` and `ext-vscode-ab-package.yml`, convert the nightly workflow back to a plain build of main, remove `apps/vscode-rollout/`, and archive the `ext-sdk-bundle-rollout` flag in PostHog (harmless to machines still on a combined VSIX: absent flag fails safe to... nothing changing until they update, but their loader treats a deleted flag as legacy — leave the flag at 100% until combined-VSIX activations flatline, then archive).
6. Update this skill: delete the combined-era sections and keep the standalone flow.
## Gotchas index
- `inputs.*` are empty strings on `schedule` events — preserve `|| 'default'` fallbacks when editing the nightly workflow.
- `bun run package` in `apps/vscode` does not build `@cline/*` workspace deps — fresh checkouts need `bun run build:sdk` first (workflows handle this).
- Job-level `if:` ref checks in workflow YAML are advisory (a dispatched branch runs its own copy of the file); the enforced boundary is each environment's deployment-branch policy in repo settings.
- Marketplace PATs (`VSCE_PAT`/`OVSX_PAT`) are only mounted into publish steps; neither publish workflow has an untrusted trigger surface.
- Environment-approval runs left waiting don't time out quickly — they sit for days and (for ab-package) block their version's concurrency group.
- Local forcing for manual testing: `CLINE_BUNDLE_OVERRIDE=next|legacy` env (launch VS Code fresh from a terminal) or the `<prefix>.rollout.bundleOverride` setting + reload; both report as `override` in telemetry so they don't pollute cohort data.
+103 -2
View File
@@ -37,22 +37,123 @@ concurrency:
cancel-in-progress: false
jobs:
# Gate the build/publish on BOTH bundles' own test suites, mirroring the two
# standalone publish paths (nightly gates on the bun suite via the same
# reusable workflow; the legacy publish inlines the npm suite). The gated
# `package` job requests its `publish` environment approval only after both
# suites pass.
#
# Caveat (shared with the nightly workflow): the reusable bun suite tests the
# DISPATCH revision — main's tip at dispatch, since this workflow is only
# dispatched from main — not `next-ref`. The package job therefore pins the
# default next-ref checkout to that same revision (tested == built) and
# refuses publish=true for any other next-ref; build-only artifact runs may
# still build untested refs.
test-next:
name: Test next (SDK) bundle
permissions:
contents: read
pull-requests: read
uses: ./.github/workflows/ext-vscode-test.yml
# The legacy branch is the npm codebase, so the bun-based reusable workflow
# cannot test it. Inlined npm steps, kept in sync with the `test` job in
# ext-vscode-publish-legacy.yml (same suite, different ref input name).
test-legacy:
name: Test legacy bundle
runs-on: ubuntu-latest
# The tested revision, exported so the package job builds EXACTLY what
# this suite ran against. legacy-ref is a mutable branch name and the
# package job starts much later (test phase + environment-approval wait,
# potentially days) — re-resolving the name there could pick up commits
# this gate never saw.
outputs:
tested-sha: ${{ steps.rev.outputs.sha }}
defaults:
run:
working-directory: apps/vscode
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.legacy-ref }}
- name: Record tested revision
id: rev
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: "npm"
cache-dependency-path: |
apps/vscode/package-lock.json
apps/vscode/webview-ui/package-lock.json
- name: Install extension dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install webview-ui dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui ci
- name: Run Quality Checks (lint + typecheck)
run: npm run ci:check-all
- name: Build Tests and Extension
id: build_step
run: npm run ci:build
- name: Unit Tests
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
run: npm run test:unit
- name: Extension Integration Tests
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
run: xvfb-run -a npm run test:coverage
- name: Webview Tests
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
run: |
cd webview-ui
npm run test:coverage
package:
name: Build combined (legacy + next) VSIX
needs: [test-next, test-legacy]
runs-on: ubuntu-latest
environment: publish
steps:
# Refuse to publish a next bundle the test-next gate did not cover.
# The reusable suite tests the dispatch revision (main), so publishing
# any other next-ref would ship an untested bundle. Build-only runs
# (publish=false) may still use arbitrary next-refs for artifact
# rehearsals.
- name: Refuse to publish an untested next-ref
if: ${{ github.event.inputs.publish == 'true' && github.event.inputs.next-ref != 'main' }}
run: |
echo "Error: publish=true requires next-ref=main — the test gate only covers main."
exit 1
# For the default next-ref (main), pin the checkout to the exact
# revision the test-next gate ran against: a moving branch name could
# otherwise drift past the tested commit during the test phase.
- name: Checkout next (SDK) source
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.next-ref }}
ref: ${{ github.event.inputs.next-ref == 'main' && github.sha || github.event.inputs.next-ref }}
path: next-src
lfs: true
# Pin to the revision test-legacy actually tested (see that job's
# outputs comment) — never re-resolve the mutable branch name here.
- name: Checkout legacy source
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.legacy-ref }}
ref: ${{ needs.test-legacy.outputs.tested-sha }}
path: legacy-src
lfs: true
+6 -1
View File
@@ -44,7 +44,7 @@ jobs:
node-version: "24.x"
- name: Install dependencies
run: bun install --filter @cline/ui --filter @cline/code --frozen-lockfile
run: bun install --frozen-lockfile
- name: Typecheck UI
run: bun -F @cline/ui typecheck
@@ -58,6 +58,11 @@ jobs:
- name: Build UI package
run: bun -F @cline/ui build
# The desktop chat test imports @cline/shared/browser, which resolves to
# dist output that nothing else in this job builds.
- name: Build shared package
run: bun -F @cline/shared build
- name: Test desktop chat integration
run: bun -F @cline/code test:chat-ui
+14
View File
@@ -1,5 +1,19 @@
# Cline CLI Changelog
## 3.0.47
- Free Cline models are now supported end to end: free models show as "(free)", and hitting the free limit renders a dedicated card with the reset time (from SDK v0.0.66)
- `/settings` general toggles (plan/act mode, tool auto-approve, compaction mode) now persist across restarts
- Upgraded the TUI stack from opentui 0.1.102 to 0.4.3
- Fixed a grey panel left behind on screen after closing a dialog (model picker, help, command palette) — a leftover from the opentui upgrade
- Fixed a React duplicate-key warning when `read_files` listed the same path more than once
- Aborting a task no longer risks killing the shared hub daemon
- Connector status delivery failures are no longer fatal to the turn
- Agentic compaction is now the default context-compaction strategy, with fixes for it silently falling back to basic compaction and for tool-heavy transcripts that could never find a cut point (from SDK v0.0.66)
- Editor edits preserve a file's existing line endings, fixing failed exact-match edits on CRLF files (from SDK v0.0.66)
- Broader built-in provider coverage, now generated from models.dev (from SDK v0.0.66)
- Updated the bundled model catalog (from SDK v0.0.66)
## 3.0.46
- Fixed out-of-credits detection so the CLI reliably recognizes the Cline API's real `insufficient_credits` (402) error and shows the "add credits" card instead of a generic error
+1 -1
View File
@@ -260,7 +260,7 @@ Schedules can route results back to chat surfaces with `--delivery-adapter`, `--
| `--compaction <agentic\|basic\|off>` | Context compaction mode. Defaults to `agentic`; use `basic` for local truncation or `off` to disable. |
| `--retries <count>` | Maximum consecutive mistakes (retries) before halting (default: `3`) |
| `--json` | Output NDJSON instead of styled text |
| `--data-dir <path>` | Use isolated local state at `<path>` instead of `~/.cline` (enables sandbox mode automatically) |
| `--data-dir <path>` | Use isolated local state at `<path>` instead of `~/.cline/data` (enables sandbox mode automatically) |
| `--auto-approve [true\|false]` | Set tool auto-approval for all tools |
| `--kanban` | Run the external `kanban` app |
| `-y, --yolo` | Skip tool approval prompts, enable `submit_and_exit`, and disable spawn/team tools by default |
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.46",
"version": "3.0.47",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
+1 -1
View File
@@ -220,7 +220,7 @@ describe("cli interactive e2e", () => {
"claude-sonnet-4-6",
"-k",
"test-key",
"hello",
"seed history session",
],
{ cwd: cliRoot, encoding: "utf8", env, timeout: 60_000 },
);
@@ -0,0 +1,94 @@
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
const historyMocks = vi.hoisted(() => ({
runHistoryDelete: vi.fn(async () => 0),
runHistoryExport: vi.fn(async () => 0),
runHistoryList: vi.fn(async () => 0),
runHistoryUpdate: vi.fn(async () => 0),
}));
vi.mock("./history", () => historyMocks);
import { registerHistoryCommand } from "./history-command";
function createHarness(isInteractiveTTY: boolean) {
const program = new Command()
.exitOverride()
.option("--json", "Output as JSON");
program.configureOutput({
writeOut: vi.fn(),
writeErr: vi.fn(),
});
const io = {
writeln: vi.fn(),
writeErr: vi.fn(),
};
const setExitCode = vi.fn();
const setStartupTarget = vi.fn();
registerHistoryCommand({
program,
io,
setExitCode,
setStartupTarget,
isInteractiveTTY: () => isInteractiveTTY,
});
return { program, io, setExitCode, setStartupTarget };
}
describe("registerHistoryCommand", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("opens the in-app history picker for an interactive text terminal", async () => {
const { program, setExitCode, setStartupTarget } = createHarness(true);
await program.parseAsync(["history"], { from: "user" });
expect(setStartupTarget).toHaveBeenCalledOnce();
expect(setStartupTarget).toHaveBeenCalledWith("history");
expect(historyMocks.runHistoryList).not.toHaveBeenCalled();
expect(setExitCode).not.toHaveBeenCalled();
});
it("keeps explicit JSON output non-interactive even when a TTY is attached", async () => {
const { program, io, setExitCode, setStartupTarget } = createHarness(true);
await program.parseAsync(["history", "--json"], { from: "user" });
expect(setStartupTarget).not.toHaveBeenCalled();
expect(historyMocks.runHistoryList).toHaveBeenCalledWith({
limit: 50,
outputMode: "json",
io,
});
expect(setExitCode).toHaveBeenCalledWith(0);
});
it("prints text history when no interactive terminal is attached", async () => {
const { program, io, setExitCode, setStartupTarget } = createHarness(false);
await program.parseAsync(["history", "--limit", "12"], { from: "user" });
expect(setStartupTarget).not.toHaveBeenCalled();
expect(historyMocks.runHistoryList).toHaveBeenCalledWith({
limit: 12,
outputMode: "text",
io,
});
expect(setExitCode).toHaveBeenCalledWith(0);
});
it("returns an error when delete is missing --session-id", async () => {
const { program, io, setExitCode } = createHarness(false);
await program.parseAsync(["history", "delete"], { from: "user" });
expect(io.writeErr).toHaveBeenCalledWith(
"history delete requires --session-id <id>",
);
expect(historyMocks.runHistoryDelete).not.toHaveBeenCalled();
expect(setExitCode).toHaveBeenCalledWith(1);
});
});
+117
View File
@@ -0,0 +1,117 @@
import type { Command } from "commander";
import type { TuiStartupTarget } from "../tui/types";
import type { CliOutputMode } from "../utils/types";
import {
runHistoryDelete,
runHistoryExport,
runHistoryList,
runHistoryUpdate,
} from "./history";
type HistoryCommandIo = {
writeln: (text?: string) => void;
writeErr: (text: string) => void;
};
type RegisterHistoryCommandOptions = {
program: Command;
io: HistoryCommandIo;
setExitCode: (code: number) => void;
setStartupTarget: (target: TuiStartupTarget) => void;
isInteractiveTTY?: () => boolean;
};
function resolveHistoryOutputMode(
program: Command,
historyCmd: Command,
): CliOutputMode {
return program.opts().json || historyCmd.opts().json ? "json" : "text";
}
export function registerHistoryCommand({
program,
io,
setExitCode,
setStartupTarget,
isInteractiveTTY = () =>
process.stdin.isTTY === true && process.stdout.isTTY === true,
}: RegisterHistoryCommandOptions): void {
const historyCmd = program
.command("history")
.alias("h")
.description("List session history or manage saved sessions")
.option("--json", "Output as JSON")
.option("--limit <count>", "Maximum number of sessions to show", "50")
.option("--page <number>", "Page number for paginated results")
.option("--config <dir>", "configuration directory")
.action(async () => {
const opts = historyCmd.opts();
const limit = Number.parseInt(opts.limit, 10);
const outputMode = resolveHistoryOutputMode(program, historyCmd);
if (outputMode === "text" && isInteractiveTTY()) {
setStartupTarget("history");
return;
}
setExitCode(
await runHistoryList({
limit,
outputMode,
io,
}),
);
});
const historyDeleteCmd = historyCmd
.command("delete")
.description("Delete a session from history")
.option("--session-id <id>", "Session ID to delete")
.action(async () => {
const opts = historyDeleteCmd.opts();
if (!opts.sessionId) {
io.writeErr("history delete requires --session-id <id>");
setExitCode(1);
return;
}
const outputMode = resolveHistoryOutputMode(program, historyCmd);
setExitCode(await runHistoryDelete(opts.sessionId, outputMode, io));
});
const historyUpdateCmd = historyCmd
.command("update")
.description("Update a session in history")
.option("--metadata <json>", "Metadata as JSON string")
.option("--prompt <text>", "New prompt text")
.option("--session-id <id>", "Session ID to update")
.option("--title <text>", "New title")
.action(async () => {
const opts = historyUpdateCmd.opts();
if (!opts.sessionId) {
io.writeErr("history update requires --session-id <id>");
setExitCode(1);
return;
}
const outputMode = resolveHistoryOutputMode(program, historyCmd);
setExitCode(
await runHistoryUpdate(
opts.sessionId,
opts.prompt,
opts.title,
opts.metadata,
outputMode,
io,
),
);
});
const historyExportCmd = historyCmd
.command("export <sessionId>")
.description("Export a session as a standalone HTML file")
.option("-o, --output <path>", "Output HTML file path")
.action(async (sessionId: string) => {
const opts = historyExportCmd.opts();
const outputMode = resolveHistoryOutputMode(program, historyCmd);
setExitCode(
await runHistoryExport(sessionId, opts.output, outputMode, io),
);
});
}
+44 -10
View File
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import type { SessionHistoryRecord } from "@cline/core";
import { afterEach, describe, expect, it, vi } from "vitest";
import { exportHistorySession } from "../session/history-export";
import {
formatCheckpointDetail,
formatHistoryListLine,
@@ -16,18 +17,12 @@ vi.mock("../session/session", () => ({
readSessionMessagesArtifact: vi.fn(),
}));
vi.mock("../tui/history-standalone", () => ({
renderHistoryStandalone: vi.fn(async () => 0),
}));
import { listSessions, readSessionMessagesArtifact } from "../session/session";
import { renderHistoryStandalone } from "../tui/history-standalone";
const mockedReadSessionMessagesArtifact = vi.mocked(
readSessionMessagesArtifact,
);
const mockedListSessions = vi.mocked(listSessions);
const mockedRenderHistoryStandalone = vi.mocked(renderHistoryStandalone);
function createHistoryRow(
overrides: Partial<SessionHistoryRecord> = {},
@@ -200,8 +195,11 @@ describe("runHistoryList", () => {
vi.clearAllMocks();
});
it("hydrates interactive history rows so titles can be inferred from messages", async () => {
const row = createHistoryRow({ prompt: undefined, metadata: undefined });
it("requests hydrated text history rows so titles can come from messages", async () => {
const row = createHistoryRow({
prompt: undefined,
metadata: { title: "hydrated title", totalCost: 0.25 },
});
mockedListSessions.mockResolvedValue([row]);
const io = {
writeln: vi.fn(),
@@ -218,8 +216,8 @@ describe("runHistoryList", () => {
expect(mockedListSessions).toHaveBeenCalledWith(25, {
hydrate: true,
});
expect(mockedRenderHistoryStandalone).toHaveBeenCalledWith(
expect.objectContaining({ rows: [row] }),
expect(io.writeln).toHaveBeenCalledWith(
expect.stringContaining("hydrated title"),
);
});
@@ -313,6 +311,42 @@ describe("runHistoryExport", () => {
await expect(readFile(outputPath, "utf8")).resolves.toContain("world");
});
it("writes structured JSON from a persisted messages artifact", async () => {
tempDir = await mkdtemp(join(tmpdir(), "cline-history-export-"));
const artifact = {
version: 1,
updated_at: "2026-04-22T17:42:10.123Z",
sessionId: "sess_1",
systemPrompt: "Be helpful",
messages: [
{
id: "m1",
role: "user",
content: [{ type: "text", text: "hello" }],
},
{
id: "m2",
role: "assistant",
content: [{ type: "text", text: "world" }],
},
],
} satisfies NonNullable<
Awaited<ReturnType<typeof readSessionMessagesArtifact>>
>;
mockedReadSessionMessagesArtifact.mockResolvedValue(artifact);
const targetPath = await exportHistorySession({
sessionId: "sess_1",
format: "json",
outputDirectory: tempDir,
});
expect(targetPath).toBe(join(tempDir, "sess_1.json"));
await expect(
readFile(targetPath, "utf8").then((contents) => JSON.parse(contents)),
).resolves.toEqual(artifact);
});
it("exports run_commands history with structured command objects", async () => {
tempDir = await mkdtemp(join(tmpdir(), "cline-history-export-"));
const outputPath = join(tempDir, "export.html");
+14 -46
View File
@@ -1,13 +1,6 @@
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { generateConversationHTML } from "../session/export";
import {
deleteSession,
listSessions,
readSessionMessagesArtifact,
updateSession,
} from "../session/session";
import { disableOpenTuiGraphicsProbe } from "../tui/opentui-env";
import { exportHistorySession } from "../session/history-export";
import { deleteSession, listSessions, updateSession } from "../session/session";
import { formatHistoryListLine } from "../utils/history-format";
import { writeln } from "../utils/output";
import type { CliOutputMode } from "../utils/types";
@@ -22,22 +15,6 @@ type HistoryIo = {
writeErr: (text: string) => void;
};
async function exportHistorySession(
sessionId: string,
outputPath?: string,
): Promise<string> {
const data = await readSessionMessagesArtifact(sessionId);
if (!data) {
throw new Error(`Session ${sessionId} not found or has no messages.json`);
}
const targetPath = resolve(outputPath?.trim() || `${sessionId}.html`);
const html = generateConversationHTML(data, sessionId);
await mkdir(dirname(targetPath), { recursive: true });
await writeFile(targetPath, html, "utf8");
return targetPath;
}
async function runHistoryDelete(
sessionId: string | undefined,
outputMode: CliOutputMode,
@@ -136,7 +113,11 @@ async function runHistoryExport(
}
try {
const targetPath = await exportHistorySession(sessionId, outputPath);
const targetPath = await exportHistorySession({
sessionId,
format: "html",
outputPath,
});
if (outputMode === "json") {
process.stdout.write(
@@ -161,7 +142,7 @@ export async function runHistoryList(input: {
outputMode: CliOutputMode;
workspaceRoot?: string;
io?: HistoryIo;
}): Promise<number | string> {
}): Promise<number> {
const io = input.io ?? {
writeln,
writeErr: (text: string) => process.stderr.write(`${text}\n`),
@@ -186,23 +167,10 @@ export async function runHistoryList(input: {
return 0;
}
disableOpenTuiGraphicsProbe();
const { renderHistoryStandalone } = await import("../tui/history-standalone");
return await renderHistoryStandalone({
rows,
refreshRows: async () =>
await listSessions(limit, {
workspaceRoot: input.workspaceRoot,
hydrate: false,
}),
onExport: async (sessionId: string) =>
await exportHistorySession(sessionId, undefined),
});
for (const row of rows) {
io.writeln(formatHistoryListLine(row));
}
return 0;
}
export {
exportHistorySession,
runHistoryDelete,
runHistoryExport,
runHistoryUpdate,
};
export { runHistoryDelete, runHistoryExport, runHistoryUpdate };
+61
View File
@@ -0,0 +1,61 @@
import { relative, sep } from "node:path";
import {
resolveClineDataDir,
resolveClineDir,
setHomeDir,
} from "@cline/shared/storage";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createProgram } from "./program";
/** Render an absolute path under `home` the way help text does: `~/...`. */
function tildePath(absolutePath: string, home: string): string {
return `~/${relative(home, absolutePath).split(sep).join("/")}`;
}
describe("root option help text", () => {
const FAKE_HOME = "/home/cline-help-test";
const savedEnv: Record<string, string | undefined> = {};
beforeAll(() => {
// Pin the resolver inputs so the defaults below are the true defaults
// (no CLINE_DIR/CLINE_DATA_DIR overrides, known home directory).
for (const key of ["CLINE_DIR", "CLINE_DATA_DIR"]) {
savedEnv[key] = process.env[key];
delete process.env[key];
}
setHomeDir(FAKE_HOME);
});
afterAll(() => {
for (const [key, value] of Object.entries(savedEnv)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
it("reports the actual resolver defaults for --config and --data-dir", () => {
// A wide help width keeps each option description on one line so the
// full default text can be matched.
const help = createProgram()
.configureHelp({ helpWidth: 500 })
.helpInformation();
const configDefault = tildePath(resolveClineDir(), FAKE_HOME);
const dataDirDefault = tildePath(resolveClineDataDir(), FAKE_HOME);
// Sanity-check the resolvers themselves so the assertions below can't
// silently drift along with a resolver regression.
expect(configDefault).toBe("~/.cline");
expect(dataDirDefault).toBe("~/.cline/data");
expect(help).toContain(
`Configuration directory (default: ${configDefault})`,
);
expect(help).toContain(
`Use isolated local state at this directory path (default: ${dataDirDefault})`,
);
});
});
+2 -5
View File
@@ -64,13 +64,10 @@ export function addRootOptions(cmd: Command): Command {
"--acp",
"Run in Agent Client Protocol (ACP) mode for editor integration",
)
.option(
"--config <path>",
"Configuration directory (default: ~/.cline/data/settings)",
)
.option("--config <path>", "Configuration directory (default: ~/.cline)")
.option(
"--data-dir <path>",
"Use isolated local state at this directory path (default: ~/.cline)",
"Use isolated local state at this directory path (default: ~/.cline/data)",
)
.option(
"--hooks-dir <path>",
+4 -3
View File
@@ -1120,6 +1120,8 @@ class DiscordConnector extends ConnectorBase<
},
) => {
const queueKey = thread.id;
const enqueueTurn = (work: () => Promise<void>) =>
enqueueThreadTurn(threadQueues, queueKey, work);
const runTurn = async () => {
try {
await handleConnectorUserTurn({
@@ -1152,6 +1154,7 @@ class DiscordConnector extends ConnectorBase<
userInstructionService,
chatCommandHost,
activeTurns,
enqueueTurn,
turnKey: queueKey,
resolveMuteTarget: ({ target }) => resolveDiscordMuteTarget(target),
createEmptyRuntimeReplyResolver:
@@ -1291,9 +1294,7 @@ class DiscordConnector extends ConnectorBase<
await runTurn();
return;
}
await enqueueThreadTurn(threadQueues, queueKey, async () => {
await runTurn();
});
await enqueueTurn(runTurn);
};
bot.onNewMention(async (thread, message) => {
+4 -3
View File
@@ -615,6 +615,8 @@ class GoogleChatConnector extends ConnectorBase<
text: string,
) => {
const queueKey = thread.id;
const enqueueTurn = (work: () => Promise<void>) =>
enqueueThreadTurn(threadQueues, queueKey, work);
const runTurn = async () => {
try {
await handleConnectorUserTurn({
@@ -637,6 +639,7 @@ class GoogleChatConnector extends ConnectorBase<
userInstructionService,
chatCommandHost,
activeTurns,
enqueueTurn,
turnKey: queueKey,
getSessionMetadata: (currentThread, _clientId, currentState) => ({
userName: options.userName,
@@ -698,9 +701,7 @@ class GoogleChatConnector extends ConnectorBase<
await runTurn();
return;
}
await enqueueThreadTurn(threadQueues, queueKey, async () => {
await runTurn();
});
await enqueueTurn(runTurn);
};
bot.onNewMention(async (thread, message) => {
+4 -3
View File
@@ -638,6 +638,8 @@ class LinearConnector extends ConnectorBase<
text: string,
) => {
const queueKey = thread.id;
const enqueueTurn = (work: () => Promise<void>) =>
enqueueThreadTurn(threadQueues, queueKey, work);
const runTurn = async () => {
try {
await handleConnectorUserTurn({
@@ -662,6 +664,7 @@ class LinearConnector extends ConnectorBase<
userInstructionService,
chatCommandHost,
activeTurns,
enqueueTurn,
turnKey: queueKey,
getSessionMetadata: (currentThread, _clientId, currentState) => ({
userName: options.userName,
@@ -723,9 +726,7 @@ class LinearConnector extends ConnectorBase<
await runTurn();
return;
}
await enqueueThreadTurn(threadQueues, queueKey, async () => {
await runTurn();
});
await enqueueTurn(runTurn);
};
bot.onNewMention(async (thread, message) => {
+4 -3
View File
@@ -839,6 +839,8 @@ class SlackConnector extends ConnectorBase<
startRequest,
);
const queueKey = thread.id;
const enqueueTurn = (work: () => Promise<void>) =>
enqueueThreadTurn(threadQueues, queueKey, work);
const runTurn = async () => {
try {
await withSlackTeamBotToken({
@@ -867,6 +869,7 @@ class SlackConnector extends ConnectorBase<
userInstructionService,
chatCommandHost,
activeTurns,
enqueueTurn,
turnKey: queueKey,
getSessionMetadata: (
currentThread,
@@ -951,9 +954,7 @@ class SlackConnector extends ConnectorBase<
await runTurn();
return;
}
await enqueueThreadTurn(threadQueues, queueKey, async () => {
await runTurn();
});
await enqueueTurn(runTurn);
};
bot.onNewMention(async (thread, message) => {
+4 -3
View File
@@ -817,6 +817,8 @@ class TelegramConnector extends ConnectorBase<
text: string,
) => {
const queueKey = thread.id;
const enqueueTurn = (work: () => Promise<void>) =>
enqueueThreadTurn(threadQueues, queueKey, work);
const runTurn = async () => {
try {
await handleConnectorUserTurn({
@@ -841,6 +843,7 @@ class TelegramConnector extends ConnectorBase<
userInstructionService,
chatCommandHost,
activeTurns,
enqueueTurn,
turnKey: queueKey,
forceDisableTools: !options.enableTools,
postFinalReply: async ({
@@ -952,9 +955,7 @@ class TelegramConnector extends ConnectorBase<
await runTurn();
return;
}
await enqueueThreadTurn(threadQueues, queueKey, async () => {
await runTurn();
});
await enqueueTurn(runTurn);
};
bot.onNewMention(async (thread, message) => {
+4 -3
View File
@@ -609,6 +609,8 @@ class WhatsAppConnector extends ConnectorBase<
text: string,
) => {
const queueKey = thread.id;
const enqueueTurn = (work: () => Promise<void>) =>
enqueueThreadTurn(threadQueues, queueKey, work);
const runTurn = async () => {
try {
await handleConnectorUserTurn({
@@ -633,6 +635,7 @@ class WhatsAppConnector extends ConnectorBase<
userInstructionService,
chatCommandHost,
activeTurns,
enqueueTurn,
turnKey: queueKey,
getSessionMetadata: (currentThread, _clientId, currentState) => ({
userName: options.userName,
@@ -709,9 +712,7 @@ class WhatsAppConnector extends ConnectorBase<
await runTurn();
return;
}
await enqueueThreadTurn(threadQueues, queueKey, async () => {
await runTurn();
});
await enqueueTurn(runTurn);
};
bot.onNewMention(async (thread, message) => {
+351 -8
View File
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import type { SentMessage } from "chat";
import { afterEach, describe, expect, it, vi } from "vitest";
import { enqueueThreadTurn } from "./chat-runtime";
import { handleConnectorUserTurn } from "./connector-host";
vi.mock("./hooks", () => ({
@@ -43,7 +44,22 @@ function createThread(initialState: TestState = {}, isDM = true) {
state = { ...nextState };
},
async post(message: unknown) {
posts.push(message);
// Real thread implementations drain async-iterable replies; the
// connector streams runtime output straight into post() for
// transports without a custom final-reply hook.
if (
message &&
typeof message === "object" &&
Symbol.asyncIterator in message
) {
let streamed = "";
for await (const chunk of message as AsyncIterable<string>) {
streamed += chunk;
}
posts.push(streamed);
} else {
posts.push(message);
}
const sentMessage = {
edit: async (nextMessage: unknown) => {
posts.push(nextMessage);
@@ -99,13 +115,21 @@ function createRuntimeClient(
);
const abortRuntimeSession = vi.fn(async () => undefined);
const deleteSession = vi.fn(async () => undefined);
const sendRuntimeSession = vi.fn(async () => ({
result: {
text: responseText,
finishReason: "stop",
iterations: 1,
},
}));
const sendRuntimeSession = vi.fn(
async (
_sessionId: string,
_request?: unknown,
_options?: unknown,
): Promise<{
result?: { text: string; finishReason: string; iterations: number };
}> => ({
result: {
text: responseText,
finishReason: "stop",
iterations: 1,
},
}),
);
const readMessages = vi.fn(async () => messages);
return {
client: {
@@ -138,6 +162,10 @@ function messageText(message: unknown): string {
return String(message);
}
async function runTurnImmediately(work: () => Promise<void>): Promise<void> {
await work();
}
describe("handleConnectorUserTurn", () => {
const tempDirs: string[] = [];
@@ -542,6 +570,316 @@ describe("handleConnectorUserTurn", () => {
});
});
it("recovers from a stale thread session mapping by starting a new session", async () => {
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
tempDirs.push(dir);
const bindingsPath = join(dir, "threads.json");
const { thread, posts, getState } = createThread({
enableTools: false,
autoApproveTools: false,
cwd: "/tmp/work",
workspaceRoot: "/tmp/work",
sessionId: "dead-session",
welcomeSentAt: new Date().toISOString(),
});
const runtime = createRuntimeClient("recovered reply");
// The hub still reports the persisted session row, so the connector reuses
// the stale mapping...
runtime.getSession.mockImplementation(async (sessionId: string) => ({
sessionId,
}));
// ...but sending input to the dead session fails with session_not_found
// until a fresh session id is used.
runtime.startRuntimeSession.mockResolvedValue({
sessionId: "fresh-session",
});
runtime.sendRuntimeSession.mockImplementation(async (sessionId: string) => {
if (sessionId === "dead-session") {
throw Object.assign(new Error("session not found: dead-session"), {
code: "session_not_found",
});
}
return {
result: {
text: "recovered reply",
finishReason: "stop",
iterations: 1,
},
};
});
await handleConnectorUserTurn({
thread: thread as never,
text: "are you there?",
client: runtime.client as never,
pendingApprovals: new Map(),
baseStartRequest: baseStartRequest() as never,
explicitSystemPrompt: undefined,
clientId: "client-1",
logger: {
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
} as never,
transport: "slack",
botUserName: "ClineAdapterBot",
requestStop: vi.fn(),
bindingsPath,
systemRules: "rules",
errorLabel: "Slack",
getSessionMetadata: () => ({}),
reusedLogMessage: "reused",
startedLogMessage: "started",
});
expect(
runtime.sendRuntimeSession.mock.calls.map((call) => call[0]),
).toEqual(["dead-session", "fresh-session"]);
expect(runtime.startRuntimeSession).toHaveBeenCalledTimes(1);
expect(getState().sessionId).toBe("fresh-session");
expect(messageText(posts.at(-1))).toBe("recovered reply");
expect(
posts.some((message) =>
messageText(message).includes("session not found"),
),
).toBe(false);
});
it("does not retry forever when the replacement session is also missing", async () => {
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
tempDirs.push(dir);
const bindingsPath = join(dir, "threads.json");
const { thread } = createThread({
enableTools: false,
autoApproveTools: false,
cwd: "/tmp/work",
workspaceRoot: "/tmp/work",
sessionId: "dead-session",
welcomeSentAt: new Date().toISOString(),
});
const runtime = createRuntimeClient("never delivered");
runtime.getSession.mockImplementation(async (sessionId: string) => ({
sessionId,
}));
runtime.startRuntimeSession.mockResolvedValue({
sessionId: "also-dead-session",
});
runtime.sendRuntimeSession.mockImplementation(async () => {
throw Object.assign(new Error("session not found"), {
code: "session_not_found",
});
});
await expect(
handleConnectorUserTurn({
thread: thread as never,
text: "are you there?",
client: runtime.client as never,
pendingApprovals: new Map(),
baseStartRequest: baseStartRequest() as never,
explicitSystemPrompt: undefined,
clientId: "client-1",
logger: {
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
} as never,
transport: "slack",
botUserName: "ClineAdapterBot",
requestStop: vi.fn(),
bindingsPath,
systemRules: "rules",
errorLabel: "Slack",
getSessionMetadata: () => ({}),
reusedLogMessage: "reused",
startedLogMessage: "started",
}),
).rejects.toThrow(/session not found/);
expect(runtime.sendRuntimeSession).toHaveBeenCalledTimes(2);
});
it("starts a new session when steering an active turn hits a dead session", async () => {
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
tempDirs.push(dir);
const bindingsPath = join(dir, "threads.json");
const { thread, posts, getState } = createThread({
enableTools: false,
autoApproveTools: false,
cwd: "/tmp/work",
workspaceRoot: "/tmp/work",
sessionId: "dead-session",
welcomeSentAt: new Date().toISOString(),
});
const runtime = createRuntimeClient("recovered reply");
runtime.getSession.mockImplementation(async (sessionId: string) => ({
sessionId,
}));
runtime.startRuntimeSession.mockResolvedValue({
sessionId: "fresh-session",
});
runtime.sendRuntimeSession.mockImplementation(async (sessionId: string) => {
if (sessionId === "dead-session") {
throw Object.assign(new Error("session not found: dead-session"), {
code: "session_not_found",
});
}
return {
result: {
text: "recovered reply",
finishReason: "stop",
iterations: 1,
},
};
});
const activeTurns = new Map([
["thread-1", { sessionId: "dead-session", threadId: "thread-1" }],
]);
await handleConnectorUserTurn({
thread: thread as never,
text: "actually do this instead",
client: runtime.client as never,
pendingApprovals: new Map(),
baseStartRequest: baseStartRequest() as never,
explicitSystemPrompt: undefined,
clientId: "client-1",
logger: {
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
} as never,
transport: "slack",
botUserName: "ClineAdapterBot",
requestStop: vi.fn(),
bindingsPath,
systemRules: "rules",
errorLabel: "Slack",
getSessionMetadata: () => ({}),
reusedLogMessage: "reused",
startedLogMessage: "started",
activeTurns: activeTurns as never,
enqueueTurn: runTurnImmediately,
turnKey: "thread-1",
});
expect(runtime.startRuntimeSession).toHaveBeenCalledTimes(1);
expect(getState().sessionId).toBe("fresh-session");
expect(messageText(posts.at(-1))).toBe("recovered reply");
expect(
posts.some((message) =>
messageText(message).includes("Steering current task."),
),
).toBe(false);
});
it("serializes concurrent recovery from the same stale active turn", async () => {
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
tempDirs.push(dir);
const bindingsPath = join(dir, "threads.json");
const { thread, posts, getState } = createThread({
enableTools: false,
autoApproveTools: false,
cwd: "/tmp/work",
workspaceRoot: "/tmp/work",
sessionId: "dead-session",
welcomeSentAt: new Date().toISOString(),
});
const runtime = createRuntimeClient("unused");
runtime.getSession.mockImplementation(async (sessionId: string) => ({
sessionId,
}));
runtime.startRuntimeSession.mockResolvedValue({
sessionId: "fresh-session",
});
let releaseStaleSteers = () => {};
const bothStaleSteersStarted = new Promise<void>((resolve) => {
releaseStaleSteers = resolve;
});
let staleSteerCount = 0;
runtime.sendRuntimeSession.mockImplementation(
async (sessionId: string, request?: unknown) => {
if (sessionId === "dead-session") {
staleSteerCount += 1;
if (staleSteerCount === 2) {
releaseStaleSteers();
}
await bothStaleSteersStarted;
throw Object.assign(new Error("session not found: dead-session"), {
code: "session_not_found",
});
}
const prompt =
request && typeof request === "object" && "prompt" in request
? String((request as { prompt?: unknown }).prompt)
: "";
return {
result: {
text: `recovered: ${prompt}`,
finishReason: "stop",
iterations: 1,
},
};
},
);
const activeTurns = new Map([
["thread-1", { sessionId: "dead-session", threadId: "thread-1" }],
]);
const threadQueues = new Map<string, Promise<void>>();
const enqueueTurn = (work: () => Promise<void>) =>
enqueueThreadTurn(threadQueues, "thread-1", work);
const commonInput = {
thread: thread as never,
client: runtime.client as never,
pendingApprovals: new Map(),
baseStartRequest: baseStartRequest() as never,
explicitSystemPrompt: undefined,
clientId: "client-1",
logger: {
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
} as never,
transport: "slack",
botUserName: "ClineAdapterBot",
requestStop: vi.fn(),
bindingsPath,
systemRules: "rules",
errorLabel: "Slack",
getSessionMetadata: () => ({}),
reusedLogMessage: "reused",
startedLogMessage: "started",
activeTurns,
enqueueTurn,
turnKey: "thread-1",
};
await Promise.all([
handleConnectorUserTurn({
...commonInput,
text: "first recovery message",
}),
handleConnectorUserTurn({
...commonInput,
text: "second recovery message",
}),
]);
expect(runtime.startRuntimeSession).toHaveBeenCalledTimes(1);
expect(
runtime.sendRuntimeSession.mock.calls.map((call) => call[0]),
).toEqual([
"dead-session",
"dead-session",
"fresh-session",
"fresh-session",
]);
expect(getState().sessionId).toBe("fresh-session");
expect(activeTurns.size).toBe(0);
expect(posts.map(messageText)).toEqual([
"recovered: first recovery message",
"recovered: second recovery message",
]);
});
it("creates schedules with forced-disabled runtime options", async () => {
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
tempDirs.push(dir);
@@ -977,6 +1315,7 @@ describe("handleConnectorUserTurn", () => {
reusedLogMessage: "reused",
startedLogMessage: "started",
activeTurns,
enqueueTurn: runTurnImmediately,
turnKey: "current-participant",
});
@@ -1225,6 +1564,7 @@ describe("handleConnectorUserTurn", () => {
reusedLogMessage: "reused",
startedLogMessage: "started",
activeTurns,
enqueueTurn: runTurnImmediately,
resolveMuteTarget: () => ({
participantKey: "discord:user:bob",
participantLabel: "<@bob>",
@@ -1426,6 +1766,7 @@ describe("handleConnectorUserTurn", () => {
getSessionMetadata: () => ({}),
reusedLogMessage: "reused",
activeTurns,
enqueueTurn: runTurnImmediately,
});
expect(runtime.startRuntimeSession).not.toHaveBeenCalled();
@@ -1476,6 +1817,7 @@ describe("handleConnectorUserTurn", () => {
getSessionMetadata: () => ({}),
reusedLogMessage: "reused",
activeTurns,
enqueueTurn: runTurnImmediately,
turnKey: "thread-1",
});
@@ -1526,6 +1868,7 @@ describe("handleConnectorUserTurn", () => {
getSessionMetadata: () => ({}),
reusedLogMessage: "reused",
activeTurns,
enqueueTurn: runTurnImmediately,
turnKey: "thread-1",
});
+242 -54
View File
@@ -6,6 +6,7 @@ import type {
HubSessionClient,
UserInstructionConfigService,
} from "@cline/core";
import { isSessionNotFoundError } from "@cline/core";
import type { SentMessage, Thread } from "chat";
import type { CliLoggerAdapter } from "../logging/adapter";
import { buildUserInputMessage, resolveSystemPrompt } from "../runtime/prompt";
@@ -28,6 +29,7 @@ import {
import {
buildThreadStartRequest,
clearSession,
forgetThreadSession,
getOrCreateSessionId,
} from "./session-runtime";
import {
@@ -51,6 +53,20 @@ export type ActiveConnectorTurn = {
participantKey?: string;
};
type ConnectorTurnQueue = (work: () => Promise<void>) => Promise<void>;
type ConnectorTurnCoordination =
| {
activeTurns: Map<string, ActiveConnectorTurn>;
enqueueTurn: ConnectorTurnQueue;
turnKey?: string;
}
| {
activeTurns?: undefined;
enqueueTurn?: undefined;
turnKey?: string;
};
type EmptyRuntimeReplyResolver = () => Promise<string | undefined>;
type EmptyRuntimeReplyResolverFactory = (input: {
@@ -135,6 +151,45 @@ async function postConnectorRuntimeReply<TState extends ConnectorThreadState>(
await postConnectorText(thread, transport, text);
}
/**
* Clears a thread's stale session mapping after the hub reported the mapped
* session no longer exists, so the next turn starts a fresh session instead of
* failing forever against a dead session id.
*/
async function forgetStaleThreadSession<
TState extends ConnectorThreadState,
>(input: {
thread: Thread<TState>;
bindingsPath: string;
baseStartRequest: ChatStartSessionRequest;
errorLabel: string;
logger: CliLoggerAdapter;
transport: string;
sessionId: string;
}): Promise<boolean> {
const forgotten = await forgetThreadSession({
thread: input.thread,
bindingsPath: input.bindingsPath,
baseStartRequest: input.baseStartRequest,
errorLabel: input.errorLabel,
expectedSessionId: input.sessionId,
});
if (!forgotten) {
return false;
}
input.logger.core.log(
"Connector thread session no longer exists; starting a new session",
{
severity: "warn",
transport: input.transport,
threadId: input.thread.id,
channelId: input.thread.channelId,
sessionId: input.sessionId,
},
);
return true;
}
function applyForcedToolDisable<TState extends ConnectorThreadState>(
state: TState,
forceDisableTools: boolean | undefined,
@@ -200,9 +255,7 @@ function formatMuteTargetList(targets: ConnectorMuteTarget[]): string {
return targets.map(formatMuteTargetLabel).join(", ");
}
export async function handleConnectorUserTurn<
TState extends ConnectorThreadState,
>(input: {
type ConnectorUserTurnInput<TState extends ConnectorThreadState> = {
thread: Thread<TState>;
text: string;
runtimeText?: string;
@@ -231,8 +284,6 @@ export async function handleConnectorUserTurn<
firstContactMessage?: string | ((currentState: TState) => string | undefined);
chatCommandHost?: ChatCommandHost;
userInstructionService?: UserInstructionConfigService;
activeTurns?: Map<string, ActiveConnectorTurn>;
turnKey?: string;
resolveMuteTarget?: (input: {
target: string;
thread: Thread<TState>;
@@ -276,7 +327,11 @@ export async function handleConnectorUserTurn<
threadId: string;
error: Error;
}) => Promise<void>;
}): Promise<void> {
} & ConnectorTurnCoordination;
export async function handleConnectorUserTurn<
TState extends ConnectorThreadState,
>(input: ConnectorUserTurnInput<TState>): Promise<void> {
const resolvedInput = input.text.trim();
if (!resolvedInput) {
return;
@@ -892,30 +947,61 @@ export async function handleConnectorUserTurn<
input.baseStartRequest,
effectiveCurrentState,
);
const activeTurn =
input.activeTurns?.get(turnKey) ??
(input.activeTurns && currentState.sessionId?.trim()
? Array.from(input.activeTurns.values()).find(
(turn) =>
const keyedActiveTurn = input.activeTurns?.get(turnKey);
const activeTurnEntry = keyedActiveTurn
? ([turnKey, keyedActiveTurn] as const)
: input.activeTurns && currentState.sessionId?.trim()
? Array.from(input.activeTurns.entries()).find(
([, turn]) =>
turn.sessionId === currentState.sessionId?.trim() &&
turn.threadId === input.thread.id,
)
: undefined);
if (activeTurn?.sessionId?.trim()) {
: undefined;
if (activeTurnEntry?.[1].sessionId?.trim()) {
const [activeTurnKey, activeTurn] = activeTurnEntry;
const { prompt, userImages, userFiles } = await buildUserInputMessage(
runtimeInput,
input.userInstructionService,
);
await input.client.sendRuntimeSession(
activeTurn.sessionId,
{
config: startRequest,
prompt,
attachments: buildAttachments({ userImages, userFiles }),
delivery: "steer",
},
{ timeoutMs: null },
);
try {
await input.client.sendRuntimeSession(
activeTurn.sessionId,
{
config: startRequest,
prompt,
attachments: buildAttachments({ userImages, userFiles }),
delivery: "steer",
},
{ timeoutMs: null },
);
} catch (error) {
if (!isSessionNotFoundError(error)) {
throw error;
}
// The tracked turn points at a session the hub no longer knows about.
// Remove only the entry we attempted to steer, then route recovery
// through the normal per-thread queue. Concurrent messages that saw
// the same stale turn will line up behind this one instead of creating
// independent replacement sessions.
if (input.activeTurns?.get(activeTurnKey) === activeTurn) {
input.activeTurns.delete(activeTurnKey);
}
const enqueueTurn = input.enqueueTurn;
if (!enqueueTurn) {
throw new Error(
"Active connector turns require a per-thread turn queue",
);
}
await enqueueTurn(() =>
runConnectorRuntimeTurnWithRecovery({
input,
runtimeInput,
turnKey,
staleSessionId: activeTurn.sessionId,
}),
);
return;
}
await postConnectorText(
input.thread,
input.transport,
@@ -923,25 +1009,44 @@ export async function handleConnectorUserTurn<
);
return;
}
const sessionId = await getOrCreateSessionId({
thread: input.thread,
client: input.client,
startRequest,
logger: input.logger,
clientId: input.clientId,
transport: input.transport,
bindingsPath: input.bindingsPath,
errorLabel: input.errorLabel,
hookCommand: input.hookCommand,
hookBotUserName: input.botUserName,
sessionMetadata: input.getSessionMetadata(
input.thread,
input.clientId,
currentState,
),
reusedLogMessage: input.reusedLogMessage,
startedLogMessage: input.startedLogMessage,
await runConnectorRuntimeTurnWithRecovery({
input,
runtimeInput,
turnKey,
currentState,
});
}
/**
* Runs a queued connector turn, replacing a stale session mapping at most once
* before replaying the user's input.
*/
async function runConnectorRuntimeTurnWithRecovery<
TState extends ConnectorThreadState,
>(params: {
input: ConnectorUserTurnInput<TState>;
runtimeInput: string;
turnKey: string;
currentState?: TState;
staleSessionId?: string;
}): Promise<void> {
const { input, runtimeInput, turnKey } = params;
const currentState =
params.currentState ??
(await loadThreadState(
input.thread,
input.bindingsPath,
input.baseStartRequest,
));
const effectiveCurrentState = applyForcedToolDisable(
currentState,
input.forceDisableTools,
);
const startRequest = buildThreadStartRequest(
input.baseStartRequest,
effectiveCurrentState,
);
const { prompt, userImages, userFiles } = await buildUserInputMessage(
runtimeInput,
input.userInstructionService,
@@ -951,16 +1056,107 @@ export async function handleConnectorUserTurn<
prompt,
attachments: buildAttachments({ userImages, userFiles }),
};
if (params.staleSessionId) {
await forgetStaleThreadSession({
thread: input.thread,
bindingsPath: input.bindingsPath,
baseStartRequest: input.baseStartRequest,
errorLabel: input.errorLabel,
logger: input.logger,
transport: input.transport,
sessionId: params.staleSessionId,
});
}
// A thread binding can outlive its runtime session (hub restart, session
// deletion, retention cleanup). When that happens the turn fails with
// `session_not_found`; drop the stale mapping and replay the turn once
// against a brand new session instead of wedging the thread forever.
const resolveSessionId = () =>
getOrCreateSessionId({
thread: input.thread,
client: input.client,
startRequest,
logger: input.logger,
clientId: input.clientId,
transport: input.transport,
bindingsPath: input.bindingsPath,
errorLabel: input.errorLabel,
hookCommand: input.hookCommand,
hookBotUserName: input.botUserName,
sessionMetadata: input.getSessionMetadata(
input.thread,
input.clientId,
currentState,
),
reusedLogMessage: input.reusedLogMessage,
startedLogMessage: input.startedLogMessage,
});
let sessionId = await resolveSessionId();
let allowStaleSessionRetry = params.staleSessionId === undefined;
for (;;) {
try {
await runConnectorRuntimeTurn({
input,
sessionId,
request,
currentState,
turnKey,
});
break;
} catch (error) {
if (!allowStaleSessionRetry || !isSessionNotFoundError(error)) {
throw error;
}
allowStaleSessionRetry = false;
await forgetStaleThreadSession({
thread: input.thread,
bindingsPath: input.bindingsPath,
baseStartRequest: input.baseStartRequest,
errorLabel: input.errorLabel,
logger: input.logger,
transport: input.transport,
sessionId,
});
sessionId = await resolveSessionId();
}
}
await persistMergedThreadState(
input.thread,
input.bindingsPath,
{
...currentState,
sessionId,
},
input.errorLabel,
);
}
/**
* Runs a single connector turn against an already-resolved session and streams
* the reply back into the thread.
*/
async function runConnectorRuntimeTurn<
TState extends ConnectorThreadState,
>(params: {
input: ConnectorUserTurnInput<TState>;
sessionId: string;
request: ChatRunTurnRequest;
currentState: TState;
turnKey: string;
}): Promise<void> {
const { input, sessionId, request, currentState, turnKey } = params;
const resolveFallbackText = await input.createEmptyRuntimeReplyResolver?.({
client: input.client,
sessionId,
});
input.activeTurns?.set(turnKey, {
const activeTurn: ActiveConnectorTurn = {
sessionId,
threadId: input.thread.id,
participantKey: currentState.participantKey,
});
};
input.activeTurns?.set(turnKey, activeTurn);
await input.thread.startTyping();
let toolStatusMessage: SentMessage | undefined;
const postFinalReply = input.postFinalReply
@@ -1025,21 +1221,13 @@ export async function handleConnectorUserTurn<
);
} finally {
input.pendingApprovals.delete(input.thread.id);
input.activeTurns?.delete(turnKey);
if (input.activeTurns?.get(turnKey) === activeTurn) {
input.activeTurns.delete(turnKey);
}
if (toolStatusMessage) {
await toolStatusMessage.delete().catch(() => undefined);
}
}
await persistMergedThreadState(
input.thread,
input.bindingsPath,
{
...currentState,
sessionId,
},
input.errorLabel,
);
}
export async function maybeHandleConnectorApprovalReply<
@@ -283,6 +283,43 @@ export async function getOrCreateSessionId<
return sessionId;
}
/**
* Drops a thread's session mapping without touching the runtime session, but
* only when it still points at the session the caller observed as stale.
*
* Used when the hub reports the mapped session no longer exists: the thread
* binding may have been recovered concurrently, so a newer session id must
* never be cleared by an older failure.
*/
export async function forgetThreadSession<
TState extends ConnectorThreadState,
>(input: {
thread: Thread<TState>;
bindingsPath: string;
baseStartRequest: ChatStartSessionRequest;
errorLabel: string;
expectedSessionId: string;
}): Promise<boolean> {
const threadState = await loadThreadState(
input.thread,
input.bindingsPath,
input.baseStartRequest,
);
if (threadState.sessionId?.trim() !== input.expectedSessionId.trim()) {
return false;
}
await persistMergedThreadState(
input.thread,
input.bindingsPath,
{
...threadState,
sessionId: undefined,
},
input.errorLabel,
);
return true;
}
export async function clearSession<TState extends ConnectorThreadState>(input: {
thread: Thread<TState>;
client: HubSessionClient;
+20 -68
View File
@@ -96,16 +96,11 @@ const worktreeMocks = vi.hoisted(() => ({
createTaskWorktree: vi.fn(),
}));
const historyMocks = vi.hoisted(() => ({
runHistoryList: vi.fn<() => Promise<number | string>>(async () => 0),
runHistoryList: vi.fn<() => Promise<number>>(async () => 0),
runHistoryDelete: vi.fn(async () => 0),
runHistoryExport: vi.fn(async () => 0),
runHistoryUpdate: vi.fn(async () => 0),
}));
const historyResumeMocks = vi.hoisted(() => ({
spawnHistoryResume: vi.fn<() => Promise<number | undefined>>(
async () => undefined,
),
}));
const loggingMocks = vi.hoisted(() => ({
createCliLoggerAdapter: vi.fn(() => ({
core: {
@@ -212,7 +207,6 @@ vi.mock("./commands/connect", () => connectMocks);
vi.mock("./kanban-migration/notice", () => migrationNoticeMocks);
vi.mock("./commands/update", () => updateMocks);
vi.mock("./commands/history", () => historyMocks);
vi.mock("./utils/history-resume", () => historyResumeMocks);
vi.mock("./logging/adapter", () => loggingMocks);
vi.mock("./utils/hub-runtime", () => hubRuntimeMocks);
vi.mock("./utils/telemetry", () => telemetryMocks);
@@ -241,8 +235,6 @@ describe("runCli lightweight command dispatch", () => {
historyMocks.runHistoryExport.mockResolvedValue(0);
historyMocks.runHistoryUpdate.mockReset();
historyMocks.runHistoryUpdate.mockResolvedValue(0);
historyResumeMocks.spawnHistoryResume.mockReset();
historyResumeMocks.spawnHistoryResume.mockResolvedValue(undefined);
sessionMocks.getSessionRow.mockReset();
sessionMocks.getSessionRow.mockResolvedValue({
sessionId: "sess_123",
@@ -324,6 +316,10 @@ describe("runCli lightweight command dispatch", () => {
value: true,
configurable: true,
});
Object.defineProperty(process.stdout, "isTTY", {
value: true,
configurable: true,
});
});
afterEach(() => {
@@ -480,7 +476,7 @@ describe("runCli lightweight command dispatch", () => {
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(process.exitCode).toBe(0);
expect(process.exitCode).toBe(1);
expect(mockState.runAgentImports).toBe(0);
expect(mockState.runInteractiveImports).toBe(0);
});
@@ -617,10 +613,6 @@ describe("runCli lightweight command dispatch", () => {
});
it("creates a worktree for default interactive mode", async () => {
Object.defineProperty(process.stdout, "isTTY", {
value: true,
configurable: true,
});
process.argv = ["bun", "src/index.ts", "--worktree"];
const { runCli } = await import("./main");
@@ -723,7 +715,7 @@ describe("runCli lightweight command dispatch", () => {
expect.anything(),
undefined,
expect.objectContaining({
initialView: undefined,
startupTarget: undefined,
}),
);
});
@@ -734,10 +726,6 @@ describe("runCli lightweight command dispatch", () => {
title: "Try ClinePass",
};
migrationNoticeMocks.getClineCliMigrationNotice.mockReturnValue(notice);
Object.defineProperty(process.stdout, "isTTY", {
value: true,
configurable: true,
});
process.argv = ["bun", "src/index.ts"];
const { runCli } = await import("./main");
@@ -767,10 +755,6 @@ describe("runCli lightweight command dispatch", () => {
provider: "cline-pass",
model: "cline-pass/test-model",
});
Object.defineProperty(process.stdout, "isTTY", {
value: true,
configurable: true,
});
process.argv = ["bun", "src/index.ts"];
const { runCli } = await import("./main");
@@ -875,7 +859,7 @@ describe("runCli lightweight command dispatch", () => {
undefined,
expect.objectContaining({
initialPrompt: "sup",
initialView: undefined,
startupTarget: undefined,
}),
);
});
@@ -1115,65 +1099,33 @@ describe("runCli lightweight command dispatch", () => {
expect.anything(),
"sess_123",
expect.objectContaining({
initialView: "chat",
startupTarget: "chat",
}),
);
});
it("resumes a history-picked session in a child process", async () => {
historyMocks.runHistoryList.mockImplementationOnce(
async () => "sess_from_history",
);
historyResumeMocks.spawnHistoryResume.mockResolvedValueOnce(0);
process.argv = ["bun", "src/index.ts", "history"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(historyResumeMocks.spawnHistoryResume).toHaveBeenCalledTimes(1);
expect(historyResumeMocks.spawnHistoryResume).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: "sess_from_history",
normalizedArgs: ["history"],
remainingArgs: ["history"],
}),
);
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
expect(process.exitCode).toBe(0);
});
it("propagates the child exit code when resuming from history picker", async () => {
historyMocks.runHistoryList.mockImplementationOnce(
async () => "sess_from_history",
);
historyResumeMocks.spawnHistoryResume.mockResolvedValueOnce(3);
process.argv = ["bun", "src/index.ts", "history"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(process.exitCode).toBe(3);
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
});
it("forces chat view when the history-picker child cannot launch", async () => {
historyMocks.runHistoryList.mockImplementationOnce(
async () => "sess_from_history",
);
historyResumeMocks.spawnHistoryResume.mockResolvedValueOnce(undefined);
it("opens history inside the interactive TUI for the history picker", async () => {
migrationNoticeMocks.getClineCliMigrationNotice.mockReturnValue({
id: "cline-cli-cline-pass-intro",
title: "Try ClinePass",
});
process.argv = ["bun", "src/index.ts", "history"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(historyMocks.runHistoryList).not.toHaveBeenCalled();
expect(
migrationNoticeMocks.getClineCliMigrationNotice,
).not.toHaveBeenCalled();
expect(runtimeMocks.runInteractive).toHaveBeenCalledTimes(1);
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
expect.any(Object),
expect.anything(),
"sess_from_history",
undefined,
expect.objectContaining({
initialPrompt: undefined,
initialView: "chat",
startupTarget: "history",
}),
);
});
+39 -150
View File
@@ -5,6 +5,7 @@ import type { ToolPolicy } from "@cline/core";
import { registerDisposable } from "@cline/shared";
import type { Command } from "commander";
import { registerHistoryCommand } from "./commands/history-command";
import {
CommanderError,
commanderToParsedArgs,
@@ -15,6 +16,7 @@ import {
getPreferredKanbanInstaller,
} from "./commands/update";
import { CLI_DEFAULT_CHECKPOINT_CONFIG } from "./runtime/defaults";
import type { TuiStartupTarget } from "./tui/types";
import { getCliBuildInfo } from "./utils/common";
import {
buildCliCompactionConfig,
@@ -136,11 +138,19 @@ function writePromptArgError(args: string[]): void {
);
}
function startupTargetTakesPrecedenceOverMigrationNotice(
target: TuiStartupTarget | undefined,
): boolean {
return target === "config" || target === "history";
}
export async function runCli(): Promise<void> {
installStreamErrorGuards();
autoUpdateOnStartup();
const cliArgs = process.argv.slice(2);
const isFullTTY =
process.stdin.isTTY === true && process.stdout.isTTY === true;
const configDir = resolveConfigDirArg(cliArgs);
const { setClineDir, setHomeDir } = await import("@cline/shared/storage");
if (configDir) {
@@ -154,11 +164,13 @@ export async function runCli(): Promise<void> {
// `--config <dir>` rather than the default home/config location.
captureCliExtensionActivated();
let launchConfigView = false;
const normalizedArgs = normalizeAutoApproveArgs(cliArgs);
// Subcommand routing via Commander
const ctx: { exitCode?: number; resumeSessionId?: string } = {};
const ctx: {
exitCode?: number;
startupTarget?: TuiStartupTarget;
} = {};
const io = { writeln, writeErr };
const program = createProgram();
// Re-enable built-in help/version output for the routing program
@@ -249,7 +261,7 @@ export async function runCli(): Promise<void> {
ctx.exitCode = code;
},
() => {
launchConfigView = true;
ctx.startupTarget = "config";
},
);
return configCmd;
@@ -416,7 +428,7 @@ export async function runCli(): Promise<void> {
connectCmd.args.slice(1),
io,
);
} else if (process.stdin.isTTY && process.stdout.isTTY) {
} else if (isFullTTY) {
ctx.exitCode = await runConnectWizard();
} else {
writeln(`\nAdapters:\n${formatAdapterList()}`);
@@ -428,7 +440,7 @@ export async function runCli(): Promise<void> {
.command("mcp")
.description("Manage MCP servers")
.action(async () => {
if (process.stdin.isTTY && process.stdout.isTTY) {
if (isFullTTY) {
ctx.exitCode = await runMcpWizard();
} else {
writeln(
@@ -493,107 +505,17 @@ export async function runCli(): Promise<void> {
await doctorCmd.parseAsync(cmd.args, { from: "user" });
});
const historyCmd = program
.command("history")
.alias("h")
.description("List session history or manage saved sessions")
.option("--json", "Output as JSON")
.option("--limit <count>", "Maximum number of sessions to show", "50")
.option("--page <number>", "Page number for paginated results")
.option("--config <dir>", "configuration directory")
.action(async () => {
const opts = historyCmd.opts();
const limit = Number.parseInt(opts.limit, 10);
const outputMode =
program.opts().json || opts.json
? ("json" as const)
: ("text" as const);
const { runHistoryList } = await import("./commands/history");
const result = await runHistoryList({
limit,
outputMode,
io,
});
if (typeof result === "string") {
ctx.resumeSessionId = result;
// JSON listing should never return a session id; if it does, still exit here so
// we never fall through to agent bootstrap (which can block on stdin in CI).
if (outputMode === "json") {
ctx.exitCode = 0;
}
} else {
// Always set exit code for numeric results so `ctx.exitCode` is never left
// undefined (that would fall through and load the full CLI runtime).
ctx.exitCode = result ?? 0;
}
});
const historyDeleteCmd = historyCmd
.command("delete")
.description("Delete a session from history")
.option("--session-id <id>", "Session ID to delete")
.action(async () => {
const opts = historyDeleteCmd.opts();
if (!opts.sessionId) {
writeErr("history delete requires --session-id <id>");
ctx.exitCode = 0;
return;
}
const outputMode =
program.opts().json || historyCmd.opts().json
? ("json" as const)
: ("text" as const);
const { runHistoryDelete } = await import("./commands/history");
ctx.exitCode = await runHistoryDelete(opts.sessionId, outputMode, io);
});
const historyUpdateCmd = historyCmd
.command("update")
.description("Update a session in history")
.option("--metadata <json>", "Metadata as JSON string")
.option("--prompt <text>", "New prompt text")
.option("--session-id <id>", "Session ID to update")
.option("--title <text>", "New title")
.action(async () => {
const opts = historyUpdateCmd.opts();
if (!opts.sessionId) {
writeErr("history update requires --session-id <id>");
ctx.exitCode = 1;
return;
}
const outputMode =
program.opts().json || historyCmd.opts().json
? ("json" as const)
: ("text" as const);
const { runHistoryUpdate } = await import("./commands/history");
ctx.exitCode = await runHistoryUpdate(
opts.sessionId,
opts.prompt,
opts.title,
opts.metadata,
outputMode,
io,
);
});
const historyExportCmd = historyCmd
.command("export <sessionId>")
.description("Export a session as a standalone HTML file")
.option("-o, --output <path>", "Output HTML file path")
.action(async (sessionId: string) => {
const opts = historyExportCmd.opts();
const outputMode =
program.opts().json || historyCmd.opts().json
? ("json" as const)
: ("text" as const);
const { runHistoryExport } = await import("./commands/history");
ctx.exitCode = await runHistoryExport(
sessionId,
opts.output,
outputMode,
io,
);
});
registerHistoryCommand({
program,
io,
setExitCode: (code) => {
ctx.exitCode = code;
},
setStartupTarget: (target) => {
ctx.startupTarget = target;
},
isInteractiveTTY: () => isFullTTY,
});
program
.command("hook")
@@ -625,11 +547,7 @@ export async function runCli(): Promise<void> {
.allowExcessArguments()
.passThroughOptions()
.action(async (_opts: unknown, cmd: Command) => {
if (
cmd.args.length === 0 &&
process.stdin.isTTY &&
process.stdout.isTTY
) {
if (cmd.args.length === 0 && isFullTTY) {
ctx.exitCode = await runScheduleWizard();
return;
}
@@ -777,30 +695,8 @@ export async function runCli(): Promise<void> {
// Default flow: no subcommand matched, or fall-through from config/history.
let args = commanderToParsedArgs(program);
let resumeSessionId: string | undefined = ctx.resumeSessionId;
if (resumeSessionId) {
// The history picker already created (and tore down) an OpenTUI renderer
// in this process; starting the interactive TUI here would create a
// second one, which can crash natively during teardown. Resume in a
// fresh `cline --id <session-id>` child process instead.
const { spawnHistoryResume } = await import("./utils/history-resume");
const childExitCode = await spawnHistoryResume({
sessionId: resumeSessionId,
normalizedArgs,
remainingArgs: program.args,
configDir,
});
if (childExitCode !== undefined) {
process.exitCode = childExitCode;
return;
}
args = {
...args,
interactive: true,
prompt: undefined,
};
}
let startupTarget = ctx.startupTarget;
let resumeSessionId: string | undefined;
if (args.id !== undefined) {
const sessionId = args.id.trim();
if (!sessionId) {
@@ -809,16 +705,12 @@ export async function runCli(): Promise<void> {
return;
}
resumeSessionId = sessionId;
startupTarget = "chat";
process.env.CLINE_HOOK_AGENT_RESUME = "1";
args = {
...args,
interactive: true,
prompt: undefined,
};
} else {
delete process.env.CLINE_HOOK_AGENT_RESUME;
}
if (launchConfigView) {
if (startupTarget) {
args = {
...args,
interactive: true,
@@ -892,7 +784,7 @@ export async function runCli(): Promise<void> {
!args.prompt &&
!resumeSessionId &&
!stdinHasPipedInput() &&
(!process.stdin.isTTY || !process.stdout.isTTY)
!isFullTTY
) {
writeErr("--worktree without a prompt requires an interactive terminal.");
process.exitCode = 1;
@@ -1239,12 +1131,6 @@ export async function runCli(): Promise<void> {
return;
}
const runInteractive = await loadInteractiveRuntimeModule();
let initialView: "chat" | "config" | undefined;
if (launchConfigView) {
initialView = "config";
} else if (resumeSessionId) {
initialView = "chat";
}
const initialClineProviderSettings =
provider === "cline" ? selectedProviderSettings : undefined;
let initialNotice:
@@ -1255,7 +1141,10 @@ export async function runCli(): Promise<void> {
notice: import("./kanban-migration/notice").CliMigrationNotice,
) => void)
| undefined;
if (!launchConfigView && process.stdin.isTTY && process.stdout.isTTY) {
if (
!startupTargetTakesPrecedenceOverMigrationNotice(startupTarget) &&
isFullTTY
) {
const { getClineCliMigrationNotice, markClineCliMigrationNoticeShown } =
await import("./kanban-migration/notice");
initialNotice = getClineCliMigrationNotice(undefined, process.env, {
@@ -1271,7 +1160,7 @@ export async function runCli(): Promise<void> {
initialPrompt: args.prompt,
clineApiBaseUrl: initialClineProviderSettings?.baseUrl,
clineProviderSettings: initialClineProviderSettings,
initialView,
startupTarget,
initialNotice,
onInitialNoticeShown: markInitialNoticeShown,
});
@@ -1012,9 +1012,9 @@ Review with the bundled skill.`,
const linear = data.mcp.find((item) => item.name === "linear");
const docs = data.mcp.find((item) => item.name === "docs");
expect(linear?.description).toBe("streamableHttp, oauth error");
expect(linear?.description).toBe("streamableHttp, oauth error, timeout 60s");
expect(linear?.loadError).toBe("OAuth authorization failed");
expect(docs?.description).toBe("sse, oauth authorized");
expect(docs?.description).toBe("sse, oauth authorized, timeout 60s");
expect(docs?.loadError).toBeUndefined();
});
+89 -1
View File
@@ -1,10 +1,27 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Config } from "../utils/types";
import {
applyInteractiveModelChange,
assertHistorySessionIsDeletable,
resolveReasoningForModelChange,
resumeInteractiveSession,
} from "./run-interactive";
describe("assertHistorySessionIsDeletable", () => {
it("rejects deleting the active interactive session", () => {
expect(() => assertHistorySessionIsDeletable("sess_1", "sess_1")).toThrow(
"Cannot delete the active session",
);
});
it("allows deleting another or pre-startup session", () => {
expect(() =>
assertHistorySessionIsDeletable("sess_1", "sess_2"),
).not.toThrow();
expect(() => assertHistorySessionIsDeletable("sess_1", "")).not.toThrow();
});
});
describe("resolveReasoningForModelChange", () => {
it("persists disabled reasoning only when thinking is explicitly false", () => {
expect(
@@ -108,3 +125,74 @@ describe("applyInteractiveModelChange", () => {
);
});
});
describe("resumeInteractiveSession", () => {
const originalAgentResume = process.env.CLINE_HOOK_AGENT_RESUME;
afterEach(() => {
if (originalAgentResume === undefined) {
delete process.env.CLINE_HOOK_AGENT_RESUME;
} else {
process.env.CLINE_HOOK_AGENT_RESUME = originalAgentResume;
}
});
it("starts the selected session directly without ensuring an empty session first", async () => {
const messages = [
{ id: "message-1", role: "user" as const, content: "hello" },
];
const ensureReady = vi.fn(async () => {});
const resumeSession = vi.fn(async () => {
expect(process.env.CLINE_HOOK_AGENT_RESUME).toBe("1");
return messages;
});
const getAccumulatedUsage = vi.fn(async () => ({
inputTokens: 12,
outputTokens: 3,
totalCost: 0.42,
}));
const sessionRuntime = {
ensureReady,
resumeSession,
getAccumulatedUsage,
};
const result = await resumeInteractiveSession(
sessionRuntime,
"session-selected",
);
expect(ensureReady).not.toHaveBeenCalled();
expect(resumeSession).toHaveBeenCalledOnce();
expect(resumeSession).toHaveBeenCalledWith("session-selected");
expect(getAccumulatedUsage).toHaveBeenCalledWith({
inputTokens: 0,
outputTokens: 0,
});
expect(result).toMatchObject({
messages,
totalCost: 0.42,
});
expect(process.env.CLINE_HOOK_AGENT_RESUME).toBe("1");
});
it("restores the hook state when the selected session cannot resume", async () => {
delete process.env.CLINE_HOOK_AGENT_RESUME;
const resumeSession = vi.fn(async () => {
expect(process.env.CLINE_HOOK_AGENT_RESUME).toBe("1");
throw new Error("resume failed");
});
await expect(
resumeInteractiveSession(
{
resumeSession,
getAccumulatedUsage: vi.fn(),
},
"session-missing",
),
).rejects.toThrow("resume failed");
expect(process.env.CLINE_HOOK_AGENT_RESUME).toBeUndefined();
});
});
+65 -16
View File
@@ -10,6 +10,8 @@ import {
import { formatModeSwitchNotice } from "@cline/shared";
import type { CliMigrationNotice } from "../kanban-migration/notice";
import { logCliError } from "../logging/errors";
import { exportHistorySession } from "../session/history-export";
import { deleteSession } from "../session/session";
import {
loadClineAccountSnapshot,
loadIndividualSubscriptionPlans,
@@ -26,7 +28,7 @@ import {
resolveClineWelcomeLine,
} from "../tui/interactive-welcome";
import { disableOpenTuiGraphicsProbe } from "../tui/opentui-env";
import type { QueuedPromptItem } from "../tui/types";
import type { QueuedPromptItem, TuiStartupTarget } from "../tui/types";
import { type ChatCommandState, chatCommandHost } from "../utils/chat-commands";
import { applyCliCompactionMode } from "../utils/compaction-mode";
import {
@@ -73,6 +75,17 @@ type ModelChangeReasoningConfig = {
reasoningEffort?: Config["reasoningEffort"];
};
export function assertHistorySessionIsDeletable(
sessionId: string,
activeSessionId: string,
): void {
if (activeSessionId && sessionId === activeSessionId) {
throw new Error(
"Cannot delete the active session. Start or resume another session first.",
);
}
}
export function resolveReasoningForModelChange(
config: ModelChangeReasoningConfig,
existing: Pick<ProviderSettings, "reasoning">,
@@ -130,6 +143,37 @@ export async function applyInteractiveModelChange(input: {
});
}
export async function resumeInteractiveSession(
sessionRuntime: Pick<
ReturnType<typeof createInteractiveSessionRuntime>,
"resumeSession" | "getAccumulatedUsage"
>,
sessionId: string,
) {
const previousAgentResume = process.env.CLINE_HOOK_AGENT_RESUME;
process.env.CLINE_HOOK_AGENT_RESUME = "1";
let messages: Awaited<ReturnType<typeof sessionRuntime.resumeSession>>;
try {
messages = await sessionRuntime.resumeSession(sessionId);
} catch (error) {
if (previousAgentResume === undefined) {
delete process.env.CLINE_HOOK_AGENT_RESUME;
} else {
process.env.CLINE_HOOK_AGENT_RESUME = previousAgentResume;
}
throw error;
}
const usage = await sessionRuntime.getAccumulatedUsage({
inputTokens: 0,
outputTokens: 0,
});
return {
messages,
totalCost: usage.totalCost,
currentContextSize: getCurrentContextSize(messages),
};
}
export async function runInteractive(
config: Config,
userInstructionService?: UserInstructionConfigService,
@@ -137,7 +181,7 @@ export async function runInteractive(
options?: {
clineApiBaseUrl?: string;
clineProviderSettings?: ProviderSettings;
initialView?: "chat" | "config";
startupTarget?: TuiStartupTarget;
initialPrompt?: string;
initialNotice?: CliMigrationNotice;
onInitialNoticeShown?: (notice: CliMigrationNotice) => void | Promise<void>;
@@ -470,7 +514,7 @@ export async function runInteractive(
tuiApp = await renderOpenTui({
config,
initialView: options?.initialView,
startupTarget: options?.startupTarget,
initialPrompt: options?.initialPrompt,
initialNotice: options?.initialNotice,
onInitialNoticeShown: options?.onInitialNoticeShown,
@@ -764,18 +808,23 @@ export async function runInteractive(
});
await sessionRuntime.restartWithCurrentMessages();
},
onResumeSession: async (sessionId: string) => {
await sessionRuntime.ensureReady();
const messages = await sessionRuntime.resumeSession(sessionId);
const usage = await sessionRuntime.getAccumulatedUsage({
inputTokens: 0,
outputTokens: 0,
});
return {
messages,
totalCost: usage.totalCost,
currentContextSize: getCurrentContextSize(messages),
};
// resumeSession initializes the manager and starts the selected session
// directly. Ensuring a session first would mint an empty history entry
// when the TUI was launched through `cline history`.
onResumeSession: async (sessionId: string) =>
await resumeInteractiveSession(sessionRuntime, sessionId),
onExportHistorySession: async (sessionId, format) =>
await exportHistorySession({
sessionId,
format,
outputDirectory: config.cwd,
}),
onDeleteHistorySession: async (sessionId) => {
assertHistorySessionIsDeletable(
sessionId,
sessionRuntime.getActiveSessionId(),
);
return (await deleteSession(sessionId)).deleted;
},
onCompact: async () => {
await sessionRuntime.ensureReady();
@@ -804,7 +853,7 @@ export async function runInteractive(
},
});
if (!loadDeferredInitialMessages) {
if (!loadDeferredInitialMessages && options?.startupTarget !== "history") {
setTimeout(() => {
void sessionRuntime.ensureReady().catch((error) => {
if (sessionRuntime.isShutdownRequested() || startupErrorReported) {
+34
View File
@@ -0,0 +1,34 @@
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { generateConversationHTML } from "./export";
import { readSessionMessagesArtifact } from "./session";
export type HistoryExportFormat = "html" | "json";
export async function exportHistorySession(input: {
sessionId: string;
format: HistoryExportFormat;
outputPath?: string;
outputDirectory?: string;
}): Promise<string> {
const { sessionId, format, outputPath, outputDirectory } = input;
const data = await readSessionMessagesArtifact(sessionId);
if (!data) {
throw new Error(`Session ${sessionId} not found or has no messages.json`);
}
const targetPath = outputPath?.trim()
? resolve(outputPath)
: resolve(
outputDirectory?.trim() || process.cwd(),
`${sessionId}.${format}`,
);
const contents =
format === "html"
? generateConversationHTML(data, sessionId)
: `${JSON.stringify(data, null, 2)}\n`;
await mkdir(dirname(targetPath), { recursive: true });
await writeFile(targetPath, contents, "utf8");
return targetPath;
}
-71
View File
@@ -1,71 +0,0 @@
import type { SessionHistoryRecord } from "@cline/core";
import { createCliRenderer } from "@opentui/core";
import { createRoot } from "@opentui/react";
import React from "react";
import { deleteSession } from "../session/session";
import { HistoryStandaloneContent } from "./views/history-view";
export async function renderHistoryStandalone(input: {
rows: SessionHistoryRecord[];
onExport: (sessionId: string) => Promise<string | undefined>;
refreshRows?: () => Promise<SessionHistoryRecord[]>;
}): Promise<number | string> {
const renderer = await createCliRenderer({
exitOnCtrlC: true,
autoFocus: false,
enableMouseMovement: true,
});
return new Promise((resolve) => {
let result: number | string = 0;
let resolved = false;
let destroyStarted = false;
let unmounted = false;
const root = createRoot(renderer);
const unmountRoot = () => {
if (unmounted) {
return;
}
unmounted = true;
root.unmount();
};
// Resolve only once teardown has finished, so callers never run while
// the renderer is still restoring the terminal.
renderer.on("destroy", () => {
unmountRoot();
if (!resolved) {
resolved = true;
resolve(result);
}
});
const settle = (value: number | string) => {
if (destroyStarted) {
return;
}
destroyStarted = true;
result = value;
unmountRoot();
// Let OpenTUI finish parsing the current stdin batch before teardown.
queueMicrotask(() => {
renderer.destroy();
});
};
root.render(
React.createElement(HistoryStandaloneContent, {
rows: input.rows,
onResolve: (sessionId: string) => settle(sessionId),
onExport: input.onExport,
refreshRows: input.refreshRows,
onDelete: async (sessionId: string) => {
const result = await deleteSession(sessionId);
return result.deleted;
},
onDismiss: () => settle(0),
}),
);
});
}
@@ -27,6 +27,8 @@ export function useLocalCommandActions(input: {
setAppView: (view: AppView) => void;
onClearConversation: () => Promise<void>;
onResumeSession: TuiProps["onResumeSession"];
onExportHistorySession: TuiProps["onExportHistorySession"];
onDeleteHistorySession: TuiProps["onDeleteHistorySession"];
onCompact: TuiProps["onCompact"];
onFork: TuiProps["onFork"];
onUndo: () => Promise<void>;
@@ -47,6 +49,8 @@ export function useLocalCommandActions(input: {
setAppView,
onClearConversation,
onResumeSession,
onExportHistorySession,
onDeleteHistorySession,
onCompact,
onFork,
onUndo,
@@ -58,7 +62,11 @@ export function useLocalCommandActions(input: {
size: "large",
style: { maxHeight: termHeight - 2 },
content: (ctx: ChoiceContext<string>) => (
<HistoryDialogContent {...ctx} />
<HistoryDialogContent
{...ctx}
onExport={onExportHistorySession}
onDelete={onDeleteHistorySession}
/>
),
});
if (sessionId) {
@@ -98,6 +106,8 @@ export function useLocalCommandActions(input: {
refocusTextarea();
}, [
dialog,
onDeleteHistorySession,
onExportHistorySession,
onResumeSession,
refocusTextarea,
session,
@@ -244,5 +254,5 @@ export function useLocalCommandActions(input: {
],
);
return { handleSlashCommand };
return { handleSlashCommand, openHistory };
}
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { getMcpDescription } from "./interactive-config";
describe("getMcpDescription", () => {
it("discloses the fast initialize probe for unconfigured stdio servers", () => {
expect(
getMcpDescription({
name: "local",
transport: { type: "stdio", command: "node" },
}),
).toBe("stdio, local, request timeout 60s, initialize probe 1.5s");
});
it("shows one configured timeout when it also applies to initialize", () => {
expect(
getMcpDescription({
name: "local",
transport: { type: "stdio", command: "node" },
timeoutSeconds: 120,
}),
).toBe("stdio, local, timeout 120s");
});
it("shows the request default for URL transports", () => {
expect(
getMcpDescription({
name: "remote",
transport: {
type: "streamableHttp",
url: "https://mcp.example.test",
},
}),
).toBe("streamableHttp, no auth, timeout 60s");
});
it("reports malformed programmatic timeouts as unconfigured", () => {
expect(
getMcpDescription({
name: "local",
transport: { type: "stdio", command: "node" },
timeoutSeconds: Number.NaN,
}),
).toBe("stdio, local, request timeout 60s, initialize probe 1.5s");
});
});
+12 -2
View File
@@ -27,6 +27,10 @@ import {
type UserInstructionConfigService,
type WorkflowConfig,
} from "@cline/core";
import {
isMcpTimeoutConfigured,
resolveMcpTimeoutSeconds,
} from "@cline/shared";
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
import { getToolCatalog } from "../runtime/tools";
import {
@@ -174,8 +178,14 @@ function getMcpAuthLabel(registration: McpServerRegistration): string {
return "no auth";
}
function getMcpDescription(registration: McpServerRegistration): string {
return `${registration.transport.type}, ${getMcpAuthLabel(registration)}`;
export function getMcpDescription(registration: McpServerRegistration): string {
const timeoutSeconds = resolveMcpTimeoutSeconds(registration.timeoutSeconds);
const timeoutDescription =
registration.transport.type === "stdio" &&
!isMcpTimeoutConfigured(registration.timeoutSeconds)
? `request timeout ${timeoutSeconds}s, initialize probe 1.5s`
: `timeout ${timeoutSeconds}s`;
return `${registration.transport.type}, ${getMcpAuthLabel(registration)}, ${timeoutDescription}`;
}
function loadAgentConfigItems(workspaceRoot: string): InteractiveConfigItem[] {
+23 -10
View File
@@ -54,7 +54,7 @@ import { useRuntimeDialogBridge } from "./hooks/use-runtime-dialog-bridge";
import { useSlashCommands } from "./hooks/use-slash-commands";
import { TerminalColorsContext } from "./hooks/use-terminal-background";
import { useTerminalTitle } from "./hooks/use-terminal-title";
import type { AppView, TuiProps } from "./types";
import type { AppView, TuiProps, TuiStartupTarget } from "./types";
import { hydrateSessionMessages } from "./utils/hydrate-messages";
import { isProviderConfigured } from "./utils/provider-configured";
import { createSelectionCopyHandler } from "./utils/selection-copy";
@@ -64,6 +64,13 @@ import { ChatView } from "./views/chat-view";
import { HomeView } from "./views/home-view";
import { type OnboardingResult, OnboardingView } from "./views/onboarding";
function isChatBackedStartupTarget(
target: TuiStartupTarget | undefined,
): boolean {
// History is a dialog layered over chat, so dismissing it should reveal chat.
return target === "chat" || target === "history";
}
function App(props: TuiProps) {
const session = useSession();
const renderer = useRenderer();
@@ -84,7 +91,8 @@ function App(props: TuiProps) {
const [appView, setAppView] = useState<AppView>(() => {
if (process.env.CLINE_FORCE_ONBOARDING === "1") return "onboarding";
if (!isProviderConfigured(props.config)) return "onboarding";
return props.initialView === "chat" || session.entries.length > 0
return isChatBackedStartupTarget(props.startupTarget) ||
session.entries.length > 0
? "chat"
: "home";
});
@@ -627,14 +635,7 @@ function App(props: TuiProps) {
setSessionLastTotalTokens,
]);
// biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount
useEffect(() => {
if (props.initialView === "config") {
openConfig();
}
}, []);
const { handleSlashCommand } = useLocalCommandActions({
const { handleSlashCommand, openHistory } = useLocalCommandActions({
slashCommandRegistry,
canForkSession,
openAccount,
@@ -646,12 +647,24 @@ function App(props: TuiProps) {
setAppView,
onClearConversation: clearConversation,
onResumeSession: props.onResumeSession,
onExportHistorySession: props.onExportHistorySession,
onDeleteHistorySession: props.onDeleteHistorySession,
onCompact: props.onCompact,
onFork: props.onFork,
onUndo: openCheckpointRestore,
onExit: exitCline,
});
const startupActionsRef = useRef({ openConfig, openHistory });
startupActionsRef.current = { openConfig, openHistory };
useEffect(() => {
if (props.startupTarget === "config") {
void startupActionsRef.current.openConfig();
} else if (props.startupTarget === "history") {
void startupActionsRef.current.openHistory();
}
}, [props.startupTarget]);
const runCommandPaletteResult = useCallback(
async (result: CommandPaletteResult) => {
if (result.action === "change-provider") {
+8 -1
View File
@@ -15,6 +15,7 @@ import type {
PendingPromptSnapshot,
PendingPromptSubmittedEvent,
} from "../runtime/session-events";
import type { HistoryExportFormat } from "../session/history-export";
import type { RepoStatus } from "../utils/repo-status";
import type { CliCompactionMode, Config } from "../utils/types";
import type { ClineAccountSnapshot } from "./cline-account";
@@ -123,6 +124,7 @@ export interface PendingPromptMutationResult {
}
export type AppView = "onboarding" | "home" | "chat";
export type TuiStartupTarget = "chat" | "config" | "history";
export type RuntimeToolInteraction =
| {
@@ -139,7 +141,7 @@ export type RuntimeToolInteraction =
export interface TuiProps {
config: Config;
initialView?: "chat" | "config";
startupTarget?: TuiStartupTarget;
initialPrompt?: string;
initialNotice?: CliMigrationNotice;
onInitialNoticeShown?: (notice: CliMigrationNotice) => void | Promise<void>;
@@ -193,6 +195,11 @@ export interface TuiProps {
onSessionRestart: () => Promise<void>;
onAccountChange: () => Promise<void>;
onResumeSession: (sessionId: string) => Promise<ResumedSessionResult>;
onExportHistorySession: (
sessionId: string,
format: HistoryExportFormat,
) => Promise<string>;
onDeleteHistorySession: (sessionId: string) => Promise<boolean>;
onCompact: () => Promise<InteractiveCompactionResult>;
onFork: () => Promise<
| {
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import {
buildHistoryFooterText,
HISTORY_EXPORT_OPTIONS,
resolveHistoryExportPickerAction,
} from "./history-export-picker";
describe("history export picker", () => {
it("offers HTML and JSON exports", () => {
expect(HISTORY_EXPORT_OPTIONS.map((option) => option.format)).toEqual([
"html",
"json",
]);
});
it("selects JSON with the down arrow and Enter", () => {
const initialState = { sessionId: "sess_1", selectedIndex: 0 };
const navigation = resolveHistoryExportPickerAction(initialState, {
name: "down",
});
expect(navigation).toEqual({
kind: "update",
state: { sessionId: "sess_1", selectedIndex: 1 },
});
if (navigation.kind !== "update") {
throw new Error("Expected export picker navigation");
}
expect(
resolveHistoryExportPickerAction(navigation.state, { name: "enter" }),
).toEqual({
kind: "export",
sessionId: "sess_1",
format: "json",
});
});
it("wraps selection and supports cancelling", () => {
const initialState = { sessionId: "sess_1", selectedIndex: 0 };
expect(
resolveHistoryExportPickerAction(initialState, { name: "up" }),
).toEqual({
kind: "update",
state: { sessionId: "sess_1", selectedIndex: 1 },
});
expect(
resolveHistoryExportPickerAction(initialState, { name: "escape" }),
).toEqual({ kind: "cancel" });
});
it("includes available history actions in the footer", () => {
expect(
buildHistoryFooterText({ canDelete: true, canExport: true }),
).toContain("\u2190 delete, \u2192 export");
});
});
@@ -0,0 +1,105 @@
import type { HistoryExportFormat } from "../../session/history-export";
export const HISTORY_EXPORT_OPTIONS = [
{
format: "html",
label: "HTML",
description: "Standalone, readable conversation",
},
{
format: "json",
label: "JSON",
description: "Structured session messages and metadata",
},
] as const satisfies ReadonlyArray<{
format: HistoryExportFormat;
label: string;
description: string;
}>;
export type HistoryExportPickerState = {
sessionId: string;
selectedIndex: number;
};
export type HistoryPickerKey = {
name?: string;
ctrl?: boolean;
};
export type HistoryExportPickerAction =
| { kind: "cancel" }
| {
kind: "export";
sessionId: string;
format: HistoryExportFormat;
}
| { kind: "update"; state: HistoryExportPickerState }
| { kind: "ignore" };
export function resolveHistoryExportPickerAction(
state: HistoryExportPickerState,
key: HistoryPickerKey,
): HistoryExportPickerAction {
if (key.name === "escape") {
return { kind: "cancel" };
}
if (key.name === "return" || key.name === "enter") {
const option = HISTORY_EXPORT_OPTIONS[state.selectedIndex];
return option
? {
kind: "export",
sessionId: state.sessionId,
format: option.format,
}
: { kind: "ignore" };
}
if (
key.name === "up" ||
key.name === "left" ||
(key.ctrl && key.name === "p")
) {
return {
kind: "update",
state: {
...state,
selectedIndex:
state.selectedIndex <= 0
? HISTORY_EXPORT_OPTIONS.length - 1
: state.selectedIndex - 1,
},
};
}
if (
key.name === "down" ||
key.name === "right" ||
(key.ctrl && key.name === "n")
) {
return {
kind: "update",
state: {
...state,
selectedIndex:
state.selectedIndex >= HISTORY_EXPORT_OPTIONS.length - 1
? 0
: state.selectedIndex + 1,
},
};
}
return { kind: "ignore" };
}
export function buildHistoryFooterText(input: {
canDelete: boolean;
canExport: boolean;
}): string {
return [
"\u2191/\u2193 navigate",
"Enter to resume",
input.canDelete ? "\u2190 delete" : undefined,
input.canExport ? "\u2192 export" : undefined,
"Esc to close",
]
.filter((part): part is string => part !== undefined)
.join(", ");
}
+118 -60
View File
@@ -6,15 +6,22 @@ import {
formatHumanReadableDate,
truncateStr,
} from "@cline/shared";
import { useKeyboard, useTerminalDimensions } from "@opentui/react";
import { useTerminalDimensions } from "@opentui/react";
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { HistoryExportFormat } from "../../session/history-export";
import { listSessions } from "../../session/session";
import { mergeHistoryStatusRows } from "../../utils/history-format";
import { formatUsd } from "../../utils/output";
import { shouldShowCliUsageCost } from "../../utils/usage-cost-display";
import { palette } from "../palette";
import {
buildHistoryFooterText,
HISTORY_EXPORT_OPTIONS,
type HistoryExportPickerState,
resolveHistoryExportPickerAction,
} from "./history-export-picker";
function hasForkMetadata(row: SessionHistoryRecord): boolean {
const fork = row.metadata?.fork;
@@ -51,7 +58,10 @@ const DEFAULT_REFRESH_INTERVAL_MS = 2000;
type HistoryListActions = {
onResolve: (sessionId: string) => void;
onDismiss: () => void;
onExport?: (sessionId: string) => Promise<string | undefined>;
onExport?: (
sessionId: string,
format: HistoryExportFormat,
) => Promise<string>;
onDelete?: (sessionId: string) => Promise<boolean>;
};
@@ -81,7 +91,7 @@ function HistoryListContent({
onExport,
onDelete,
emptyMessage = "No sessions found",
footerText = "\u2191/\u2193 navigate, Enter to resume, Esc to close",
footerText,
title = "Session History",
loadRows = false,
refreshRows,
@@ -96,9 +106,16 @@ function HistoryListContent({
const [loading, setLoading] = useState(loadRows);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [exportPicker, setExportPickerState] =
useState<HistoryExportPickerState | null>(null);
const exportPickerRef = useRef(exportPicker);
const handlerRef = useRef<(key: HistoryKeyEvent | undefined) => void>(
() => {},
);
const setExportPicker = (next: HistoryExportPickerState | null) => {
exportPickerRef.current = next;
setExportPickerState(next);
};
useEffect(() => {
const loadInitialRows = () => listSessions(50, { hydrate: true });
@@ -188,6 +205,26 @@ function HistoryListContent({
return { items: rows.slice(start, end), startIndex: start };
}, [rows, safeSelected]);
const exportSelectedSession = (
sessionId: string,
format: HistoryExportFormat,
) => {
if (!onExport) {
return;
}
setExportPicker(null);
setStatusMessage(`Exporting ${sessionId} as ${format.toUpperCase()}...`);
void onExport(sessionId, format)
.then((path) => {
setStatusMessage(`Exported ${sessionId} to ${path}`);
})
.catch((error) => {
setStatusMessage(
error instanceof Error ? error.message : String(error),
);
});
};
handlerRef.current = (key: HistoryKeyEvent | undefined) => {
if (!key) {
return;
@@ -196,6 +233,18 @@ function HistoryListContent({
onDismiss();
return;
}
const currentExportPicker = exportPickerRef.current;
if (currentExportPicker) {
const action = resolveHistoryExportPickerAction(currentExportPicker, key);
if (action.kind === "cancel") {
setExportPicker(null);
} else if (action.kind === "update") {
setExportPicker(action.state);
} else if (action.kind === "export") {
exportSelectedSession(action.sessionId, action.format);
}
return;
}
if (confirmDelete) {
if (key.name === "y" || (key.shift && key.name === "y")) {
const sessionId = confirmDelete;
@@ -265,20 +314,11 @@ function HistoryListContent({
if (key.name === "right") {
const row = rowsRef.current[selectedRef.current];
if (row?.sessionId && onExport) {
setStatusMessage(`Exporting ${row.sessionId}...`);
void onExport(row.sessionId)
.then((path) => {
setStatusMessage(
path
? `Exported ${row.sessionId} to ${path}`
: `Exported ${row.sessionId}`,
);
})
.catch((error) => {
setStatusMessage(
error instanceof Error ? error.message : String(error),
);
});
setStatusMessage(null);
setExportPicker({
sessionId: row.sessionId,
selectedIndex: 0,
});
}
}
};
@@ -295,6 +335,53 @@ function HistoryListContent({
);
}
if (exportPicker) {
const selectedRow = rows.find(
(row) => row.sessionId === exportPicker.sessionId,
);
const exportTitle = selectedRow
? formatTitle(selectedRow, Math.max(20, width - 12))
: exportPicker.sessionId;
return (
<box flexDirection="column" paddingX={1}>
<text>Export Session</text>
<text fg="gray" marginTop={1}>
{exportTitle}
</text>
<box flexDirection="column" marginTop={1}>
{HISTORY_EXPORT_OPTIONS.map((option, index) => {
const isSelected = index === exportPicker.selectedIndex;
return (
<box
key={option.format}
flexDirection="column"
paddingX={1}
backgroundColor={isSelected ? palette.selection : undefined}
>
<text fg={isSelected ? palette.textOnSelection : undefined}>
{isSelected ? "\u276f " : " "}
{option.label}
</text>
<text
fg={isSelected ? palette.textOnSelection : "gray"}
paddingLeft={2}
>
{option.description}
</text>
</box>
);
})}
</box>
<text fg="gray" marginTop={1}>
<em>Arrow keys choose, Enter to export, Esc to go back</em>
</text>
</box>
);
}
if (rows.length === 0) {
return (
<box flexDirection="column" paddingX={1} gap={1}>
@@ -309,6 +396,12 @@ function HistoryListContent({
const aboveCount = window.startIndex;
const belowCount = rows.length - window.startIndex - window.items.length;
const resolvedFooterText =
footerText ??
buildHistoryFooterText({
canDelete: onDelete !== undefined,
canExport: onExport !== undefined,
});
return (
<box flexDirection="column" paddingX={1}>
@@ -399,14 +492,17 @@ function HistoryListContent({
)}
<text fg="gray" marginTop={1}>
<em>{footerText}</em>
<em>{resolvedFooterText}</em>
</text>
</box>
);
}
export function HistoryDialogContent(props: ChoiceContext<string>) {
const { resolve, dismiss, dialogId } = props;
export function HistoryDialogContent(
props: ChoiceContext<string> &
Pick<HistoryListActions, "onExport" | "onDelete">,
) {
const { resolve, dismiss, dialogId, onExport, onDelete } = props;
const [keyHandler, setKeyHandler] = useState<
((key: HistoryKeyEvent | undefined) => void) | undefined
>();
@@ -424,46 +520,8 @@ export function HistoryDialogContent(props: ChoiceContext<string>) {
loadRows
onResolve={resolve}
onDismiss={dismiss}
registerKeyHandler={registerKeyHandler}
/>
);
}
export function HistoryStandaloneContent(
props: HistoryListActions & {
rows: SessionHistoryRecord[];
title?: string;
footerText?: string;
refreshRows?: () => Promise<SessionHistoryRecord[]>;
refreshIntervalMs?: number;
},
) {
const [keyHandler, setKeyHandler] = useState<
((key: HistoryKeyEvent | undefined) => void) | undefined
>();
const registerKeyHandler = useCallback(
(handler: (key: HistoryKeyEvent | undefined) => void) => {
setKeyHandler(() => handler);
},
[],
);
useKeyboard((key) => keyHandler?.(key));
return (
<HistoryListContent
initialRows={props.rows}
onResolve={props.onResolve}
onDismiss={props.onDismiss}
onExport={props.onExport}
onDelete={props.onDelete}
refreshRows={props.refreshRows}
refreshIntervalMs={props.refreshIntervalMs}
title={props.title ?? "History"}
footerText={
props.footerText ??
"\u2191/\u2193 navigate, Enter to resume, \u2190 delete, \u2192 export, Esc to close"
}
onExport={onExport}
onDelete={onDelete}
registerKeyHandler={registerKeyHandler}
/>
);
-99
View File
@@ -1,99 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildHistoryResumeArgs } from "./history-resume";
describe("buildHistoryResumeArgs", () => {
it("replaces the history subcommand with --id", () => {
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: ["history"],
remainingArgs: ["history"],
}),
).toEqual(["--id", "sess_1"]);
});
it("preserves global flags that precede the subcommand", () => {
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: [
"--data-dir",
"/tmp/data",
"-m",
"claude-sonnet-4-6",
"history",
"--limit",
"5",
],
remainingArgs: ["history", "--limit", "5"],
}),
).toEqual([
"--data-dir",
"/tmp/data",
"-m",
"claude-sonnet-4-6",
"--id",
"sess_1",
]);
});
it("keeps a global flag value that matches the subcommand alias", () => {
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: ["-m", "h", "h"],
remainingArgs: ["h"],
}),
).toEqual(["-m", "h", "--id", "sess_1"]);
});
it("forwards a config dir passed as a subcommand option", () => {
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: ["history", "--config", "/tmp/conf"],
remainingArgs: ["history", "--config", "/tmp/conf"],
configDir: "/tmp/conf",
}),
).toEqual(["--config", "/tmp/conf", "--id", "sess_1"]);
});
it("does not duplicate a config dir already in the global flags", () => {
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: ["--config", "/tmp/conf", "history"],
remainingArgs: ["history"],
configDir: "/tmp/conf",
}),
).toEqual(["--config", "/tmp/conf", "--id", "sess_1"]);
});
it("recognizes the --config=<dir> spelling in global flags", () => {
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: ["--config=/tmp/conf", "history"],
remainingArgs: ["history"],
configDir: "/tmp/conf",
}),
).toEqual(["--config=/tmp/conf", "--id", "sess_1"]);
});
it("returns undefined when remaining args are not a suffix of argv", () => {
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: ["history", "--limit", "5"],
remainingArgs: ["history", "--limit", "9"],
}),
).toBeUndefined();
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: ["history"],
remainingArgs: ["extra", "history"],
}),
).toBeUndefined();
});
});
-115
View File
@@ -1,115 +0,0 @@
import { resolveCliLaunchSpec } from "./internal-launch";
export interface HistoryResumeCommand {
launcher: string;
childArgs: string[];
}
export interface BuildHistoryResumeArgsInput {
sessionId: string;
/** Full normalized CLI args (process.argv.slice(2) after normalization). */
normalizedArgs: string[];
/**
* Commander's `program.args` after parsing: the `history` subcommand token
* and everything following it. Must be a suffix of `normalizedArgs`.
*/
remainingArgs: string[];
/**
* Config dir resolved from the full argv. Forwarded explicitly because
* `--config` may have been passed as a `history` subcommand option, which
* would otherwise be dropped with the rest of the subcommand args.
*/
configDir?: string;
}
/**
* Builds argv for relaunching the CLI as `cline <globalFlags> --id <sessionId>`
* after a session is picked in `cline history`. Returns undefined when the
* global-flag prefix cannot be derived safely (caller falls back to resuming
* in-process).
*/
export function buildHistoryResumeArgs(
input: BuildHistoryResumeArgsInput,
): string[] | undefined {
const { sessionId, normalizedArgs, remainingArgs, configDir } = input;
const splitIndex = normalizedArgs.length - remainingArgs.length;
if (splitIndex < 0) {
return undefined;
}
for (let i = 0; i < remainingArgs.length; i++) {
if (normalizedArgs[splitIndex + i] !== remainingArgs[i]) {
return undefined;
}
}
const globalArgs = normalizedArgs.slice(0, splitIndex);
const args = [...globalArgs];
const hasConfigFlag = globalArgs.some(
(arg) => arg === "--config" || arg.startsWith("--config="),
);
if (configDir && !hasConfigFlag) {
args.push("--config", configDir);
}
args.push("--id", sessionId);
return args;
}
export function buildHistoryResumeCommand(
input: BuildHistoryResumeArgsInput,
): HistoryResumeCommand | undefined {
const childArgs = buildHistoryResumeArgs(input);
if (!childArgs) {
return undefined;
}
const spec = resolveCliLaunchSpec();
if (!spec) {
return undefined;
}
return {
launcher: spec.launcher,
childArgs: [...spec.childArgsPrefix, ...childArgs],
};
}
/**
* Resumes a history-picked session in a fresh `cline --id <sessionId>` child
* process with inherited stdio, and returns its exit code. Creating a second
* OpenTUI renderer in the picker's process can crash natively during teardown
* (Bun "panic(main thread): Segmentation fault" on Ctrl+C), so the resumed
* interactive TUI must get a process of its own.
*
* Returns undefined when the child cannot be launched; the caller should fall
* back to resuming in-process.
*/
export async function spawnHistoryResume(
input: BuildHistoryResumeArgsInput,
): Promise<number | undefined> {
const command = buildHistoryResumeCommand(input);
if (!command) {
return undefined;
}
const { spawn } = await import("node:child_process");
return await new Promise<number | undefined>((resolve) => {
let child: ReturnType<typeof spawn>;
try {
child = spawn(command.launcher, command.childArgs, {
stdio: "inherit",
});
} catch {
resolve(undefined);
return;
}
// The child shares this foreground process group, so terminal-generated
// Ctrl+C already reaches it. Keep the parent alive to reap the child
// without re-forwarding a second signal into the TUI teardown path.
const suppressParentSignal = () => {};
process.on("SIGINT", suppressParentSignal);
process.on("SIGTERM", suppressParentSignal);
const finish = (value: number | undefined) => {
process.off("SIGINT", suppressParentSignal);
process.off("SIGTERM", suppressParentSignal);
resolve(value);
};
child.once("error", () => finish(undefined));
child.once("exit", (code, signal) => finish(signal ? 1 : (code ?? 0)));
});
}
+12
View File
@@ -1,5 +1,17 @@
# Cline Code Desktop Changelog
## 0.0.7
- New system tray icon showing app status and how many agent sessions are currently running.
- Session history is now paginated in ten-session pages, fetching older history only when you reach the end.
- You can favorite sessions, and sessions are now ordered by most recent activity with consistent status dot colors across views.
- Subagent and teammate runs from a session now show up in the app with their status and results.
- Chat polish: tool-specific icons on tool disclosures, elapsed thinking time and restyled reasoning sections, aligned timestamps, and message actions that no longer shift the layout while scrolling stays anchored to the conversation viewport.
- Free Cline models are now supported and labeled "(free)" in model pickers, with a clear message — including reset time — when you hit the free-tier limit.
- Fixed the China/international endpoint toggles for Qwen, Moonshot, Z AI, and MiniMax being ignored, which silently routed regional users to the wrong host.
- Fixed tool calls failing when a model emitted a line number as a string (e.g. `insert_line: "3"`), forcing the agent to waste a round trip retrying.
- Refreshed the bundled provider and model catalog.
## 0.0.6
- Queued messages now appear in a collapsible list above the composer with a count — expand it to edit, send-now, or delete individual queued turns.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/code",
"version": "0.0.6",
"version": "0.0.7",
"private": true,
"scripts": {
"build:ui": "bun -F @cline/ui build",
@@ -146,6 +146,7 @@ describe("pathless session starts", () => {
});
const ctx = {
liveSessions: new Map(),
restoringWorkspacePaths: new Set(),
sessionManager: { start },
} as unknown as SidecarContext;
@@ -174,6 +175,439 @@ describe("pathless session starts", () => {
});
});
describe("session forks", () => {
it("restores the selected workspace checkpoint before forking for message editing", async () => {
const sourceSessionId = `source-fork-${Date.now()}`;
const sourceMessages = [
{ role: "user" as const, content: "first prompt" },
{ role: "assistant" as const, content: "first response" },
{ role: "user" as const, content: "prompt to edit" },
{ role: "assistant" as const, content: "response to replace" },
];
const expectedMessages = sourceMessages.slice(0, 2);
const start = vi.fn(async () => ({ sessionId: "edited-fork" }));
const restore = vi.fn(async () => ({
sessionId: "edited-fork",
messages: sourceMessages.slice(0, 3),
checkpoint: {
ref: "second",
createdAt: 2,
runCount: 2,
},
}));
const readMessages = vi.fn(async () => expectedMessages);
const ctx = {
liveSessions: new Map([
[
sourceSessionId,
{
config: {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
},
messages: sourceMessages,
promptsInQueue: [],
busy: false,
startedAt: Date.now(),
status: "completed",
},
],
]),
restoringWorkspacePaths: new Set(),
sessionManager: {
get: vi.fn(async () => ({
sessionId: sourceSessionId,
source: "desktop",
status: "completed",
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/workspace/project",
workspaceRoot: "/workspace/project",
metadata: {
checkpoint: {
latest: { ref: "second", createdAt: 2, runCount: 2 },
history: [
{ ref: "first", createdAt: 1, runCount: 1 },
{ ref: "second", createdAt: 2, runCount: 2 },
],
},
},
})),
readMessages,
restore,
start,
},
streamIndices: new Map(),
wsClients: new Set(),
} as unknown as SidecarContext;
const result = (await handleChatSessionCommand(ctx, {
action: "fork",
sessionId: sourceSessionId,
forkBeforeRunCount: 2,
config: {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
},
})) as { sessionId: string; messages: unknown[] };
expect(restore).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: sourceSessionId,
checkpointRunCount: 2,
cwd: "/workspace/project",
restore: {
messages: true,
workspace: true,
omitCheckpointMessageFromSession: true,
},
start: expect.objectContaining({
sessionMetadata: expect.objectContaining({
fork: expect.objectContaining({
forkedFromSessionId: sourceSessionId,
beforeRunCount: 2,
}),
}),
}),
}),
);
expect(start).not.toHaveBeenCalled();
expect(readMessages).toHaveBeenCalledWith("edited-fork");
expect(result).toEqual({
sessionId: "edited-fork",
forkedFromSessionId: sourceSessionId,
messages: expectedMessages,
});
expect(ctx.liveSessions.get("edited-fork")?.messages).toEqual(
expectedMessages,
);
expect(ctx.restoringWorkspacePaths.size).toBe(0);
});
it("holds the workspace lock for the full edit restore", async () => {
const sourceSessionId = `locking-source-${Date.now()}`;
const siblingSessionId = `locking-sibling-${Date.now()}`;
const sourceMessages = [
{ role: "user" as const, content: "first prompt" },
{ role: "assistant" as const, content: "first response" },
];
let releaseRestore = () => {};
const restoreGate = new Promise<void>((resolve) => {
releaseRestore = resolve;
});
let markRestoreStarted = () => {};
const restoreStarted = new Promise<void>((resolve) => {
markRestoreStarted = resolve;
});
const send = vi.fn();
const restore = vi.fn(async () => {
markRestoreStarted();
await restoreGate;
return {
sessionId: "locked-edited-fork",
messages: sourceMessages,
checkpoint: { ref: "first", createdAt: 1, runCount: 1 },
};
});
const ctx = {
liveSessions: new Map([
[
sourceSessionId,
{
config: {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/workspace/project",
},
messages: sourceMessages,
promptsInQueue: [],
busy: false,
startedAt: Date.now(),
status: "idle",
},
],
[
siblingSessionId,
{
config: { workspaceRoot: "/workspace/project/." },
messages: [],
promptsInQueue: [],
busy: false,
startedAt: Date.now(),
status: "idle",
},
],
]),
restoringWorkspacePaths: new Set(),
sessionManager: {
get: vi.fn(async () => ({
sessionId: sourceSessionId,
source: "desktop",
status: "completed",
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/workspace/project",
workspaceRoot: "/workspace/project",
metadata: {
checkpoint: {
latest: { ref: "first", createdAt: 1, runCount: 1 },
history: [{ ref: "first", createdAt: 1, runCount: 1 }],
},
},
})),
readMessages: vi.fn(async () => sourceMessages),
restore,
send,
},
streamIndices: new Map(),
wsClients: new Set(),
} as unknown as SidecarContext;
const fork = handleChatSessionCommand(ctx, {
action: "fork",
sessionId: sourceSessionId,
forkBeforeRunCount: 1,
});
await restoreStarted;
try {
expect(ctx.restoringWorkspacePaths).toEqual(
new Set(["/workspace/project"]),
);
await expect(
handleChatSessionCommand(ctx, {
action: "send",
sessionId: siblingSessionId,
prompt: "race",
}),
).rejects.toThrow(
"Cannot send a prompt while the session workspace is being restored",
);
expect(send).not.toHaveBeenCalled();
} finally {
releaseRestore();
}
await expect(fork).resolves.toMatchObject({
sessionId: "locked-edited-fork",
});
expect(ctx.restoringWorkspacePaths.size).toBe(0);
});
it("keeps a full-history fork on the current workspace without restoring", async () => {
const sourceSessionId = `source-full-fork-${Date.now()}`;
const sourceMessages = [
{ role: "user" as const, content: "first prompt" },
{ role: "assistant" as const, content: "first response" },
];
const start = vi.fn(async () => ({ sessionId: "full-fork" }));
const restore = vi.fn();
const readMessages = vi.fn(async () => sourceMessages);
const ctx = {
liveSessions: new Map([
[
sourceSessionId,
{
config: {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
},
messages: sourceMessages,
promptsInQueue: [],
busy: false,
startedAt: Date.now(),
status: "completed",
},
],
]),
restoringWorkspacePaths: new Set(),
sessionManager: {
get: vi.fn(async () => ({
sessionId: sourceSessionId,
source: "desktop",
status: "completed",
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/workspace/project",
workspaceRoot: "/workspace/project",
})),
readMessages,
restore,
start,
},
streamIndices: new Map(),
wsClients: new Set(),
} as unknown as SidecarContext;
await handleChatSessionCommand(ctx, {
action: "fork",
sessionId: sourceSessionId,
config: {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
},
});
expect(restore).not.toHaveBeenCalled();
expect(start).toHaveBeenCalledWith(
expect.objectContaining({ initialMessages: sourceMessages }),
);
});
it("rejects an edit fork while the source session is running", async () => {
const restore = vi.fn();
const sourceSessionId = "busy-source-session";
const ctx = {
liveSessions: new Map([
[
sourceSessionId,
{
config: {},
messages: [{ role: "user", content: "prompt" }],
promptsInQueue: [],
busy: true,
startedAt: Date.now(),
status: "running",
},
],
]),
restoringWorkspacePaths: new Set(),
sessionManager: { restore },
} as unknown as SidecarContext;
await expect(
handleChatSessionCommand(ctx, {
action: "fork",
sessionId: sourceSessionId,
forkBeforeRunCount: 1,
}),
).rejects.toThrow("Wait for all turns in this workspace to finish");
expect(restore).not.toHaveBeenCalled();
});
it("rejects an edit fork when the persisted session is still active", async () => {
const restore = vi.fn();
const sourceSessionId = "persisted-running-session";
const ctx = {
liveSessions: new Map([
[
sourceSessionId,
{
config: {},
messages: [{ role: "user", content: "prompt" }],
promptsInQueue: [],
busy: false,
startedAt: Date.now(),
status: "idle",
},
],
]),
restoringWorkspacePaths: new Set(),
sessionManager: {
get: vi.fn(async () => ({
sessionId: sourceSessionId,
status: "running",
})),
restore,
},
} as unknown as SidecarContext;
await expect(
handleChatSessionCommand(ctx, {
action: "fork",
sessionId: sourceSessionId,
forkBeforeRunCount: 1,
}),
).rejects.toThrow("Wait for all turns in this workspace to finish");
expect(restore).not.toHaveBeenCalled();
expect(ctx.restoringWorkspacePaths.size).toBe(0);
});
it("rejects an edit fork while a sibling session in the workspace is running", async () => {
const sourceSessionId = "idle-source-session";
const siblingSessionId = "busy-sibling-session";
const restore = vi.fn();
const ctx = {
liveSessions: new Map([
[
sourceSessionId,
{
config: { cwd: "/workspace/project" },
messages: [{ role: "user", content: "prompt" }],
promptsInQueue: [],
busy: false,
startedAt: Date.now(),
status: "idle",
},
],
[
siblingSessionId,
{
config: { workspaceRoot: "/workspace/project/." },
messages: [],
promptsInQueue: [],
busy: true,
startedAt: Date.now(),
status: "running",
},
],
]),
restoringWorkspacePaths: new Set(),
sessionManager: {
get: vi.fn(async () => ({
sessionId: sourceSessionId,
status: "completed",
cwd: "/workspace/project",
workspaceRoot: "/workspace/project",
})),
restore,
},
} as unknown as SidecarContext;
await expect(
handleChatSessionCommand(ctx, {
action: "fork",
sessionId: sourceSessionId,
forkBeforeRunCount: 1,
}),
).rejects.toThrow("Wait for all turns in this workspace to finish");
expect(restore).not.toHaveBeenCalled();
expect(ctx.restoringWorkspacePaths.size).toBe(0);
});
it("blocks sends from sibling sessions while their workspace is restored", async () => {
const send = vi.fn();
const sessionId = "workspace-sibling-session";
const ctx = {
liveSessions: new Map([
[
sessionId,
{
config: { workspaceRoot: "/workspace/project/." },
messages: [{ role: "user", content: "prompt" }],
promptsInQueue: [],
busy: false,
startedAt: Date.now(),
status: "idle",
},
],
]),
restoringWorkspacePaths: new Set(["/workspace/project"]),
sessionManager: { send },
} as unknown as SidecarContext;
await expect(
handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "race",
}),
).rejects.toThrow(
"Cannot send a prompt while the session workspace is being restored",
);
expect(send).not.toHaveBeenCalled();
});
});
describe("first-send connection updates", () => {
const baseConfig = {
provider: "cline",
@@ -215,6 +649,7 @@ describe("first-send connection updates", () => {
},
],
]),
restoringWorkspacePaths: new Set(),
streamIndices: new Map(),
wsClients: new Set(),
sessionManager: {
+251 -66
View File
@@ -10,8 +10,10 @@ import {
projectSessionCompactionState,
type SessionCompactionState,
type SessionPendingPrompt,
type SessionRecord,
SessionSource,
splitCoreSessionConfig,
trimMessagesBeforeUserRun,
} from "@cline/core";
import type { Message } from "@cline/llms";
import { buildClineSystemPrompt } from "@cline/shared";
@@ -45,6 +47,80 @@ const workspaceMetadataPromises = new Map<
string,
WorkspaceMetadataCacheEntry
>();
const ACTIVE_WORKSPACE_SESSION_STATUSES = new Set([
"starting",
"pending",
"running",
"stopping",
]);
const WORKSPACE_RESTORE_SEND_ERROR =
"Cannot send a prompt while the session workspace is being restored";
const WORKSPACE_RESTORE_BUSY_ERROR =
"Wait for all turns in this workspace to finish before restoring it";
type WorkspacePathSource = {
cwd?: unknown;
workspaceRoot?: unknown;
workspace_root?: unknown;
};
function readWorkspacePath(
source: WorkspacePathSource | undefined,
): string | undefined {
const cwd = typeof source?.cwd === "string" ? source.cwd.trim() : "";
if (cwd) return cwd;
const workspaceRoot =
typeof source?.workspaceRoot === "string"
? source.workspaceRoot.trim()
: "";
if (workspaceRoot) return workspaceRoot;
const snakeCaseWorkspaceRoot =
typeof source?.workspace_root === "string"
? source.workspace_root.trim()
: "";
return snakeCaseWorkspaceRoot || undefined;
}
function workspacePathKey(
source: WorkspacePathSource | undefined,
): string | undefined {
const workspacePath = readWorkspacePath(source);
return workspacePath ? resolve(workspacePath) : undefined;
}
function hasActiveWorkspaceTurn(session: LiveSession): boolean {
return (
session.busy ||
session.transitioningProvider === true ||
ACTIVE_WORKSPACE_SESSION_STATUSES.has(session.status)
);
}
async function withWorkspaceRestoreLock<T>(
ctx: SidecarContext,
workspacePath: string,
work: () => Promise<T>,
): Promise<T> {
const key = resolve(workspacePath);
if (ctx.restoringWorkspacePaths.has(key)) {
throw new Error(WORKSPACE_RESTORE_BUSY_ERROR);
}
for (const session of ctx.liveSessions.values()) {
if (
workspacePathKey(session.config) === key &&
hasActiveWorkspaceTurn(session)
) {
throw new Error(WORKSPACE_RESTORE_BUSY_ERROR);
}
}
ctx.restoringWorkspacePaths.add(key);
try {
return await work();
} finally {
ctx.restoringWorkspacePaths.delete(key);
}
}
function getWorkspaceMetadataPromise(
cwd: string,
@@ -723,6 +799,15 @@ async function handleSend(
}
const manager = getSessionManager(ctx);
const session = ctx.liveSessions.get(sessionId);
const lockedWorkspaceKey = workspacePathKey(
session?.config ?? request.config,
);
if (
lockedWorkspaceKey &&
ctx.restoringWorkspacePaths.has(lockedWorkspaceKey)
) {
throw new Error(WORKSPACE_RESTORE_SEND_ERROR);
}
if (session?.transitioningProvider) {
throw new Error("A provider switch is already in progress");
}
@@ -932,6 +1017,65 @@ async function handleFork(
): Promise<unknown> {
const sourceSessionId = request.sessionId?.trim();
if (!sourceSessionId) throw new Error("sessionId is required");
const forkBeforeRunCount = request.forkBeforeRunCount;
if (
forkBeforeRunCount !== undefined &&
(!Number.isInteger(forkBeforeRunCount) || forkBeforeRunCount < 1)
) {
throw new Error("forkBeforeRunCount must be a positive integer");
}
const manager = getSessionManager(ctx);
const liveSourceSession = ctx.liveSessions.get(sourceSessionId);
if (
forkBeforeRunCount !== undefined &&
liveSourceSession &&
hasActiveWorkspaceTurn(liveSourceSession)
) {
throw new Error(WORKSPACE_RESTORE_BUSY_ERROR);
}
const sourceSession = await manager.get(sourceSessionId);
if (
forkBeforeRunCount !== undefined &&
(sourceSession?.status === "running" || sourceSession?.status === "pending")
) {
throw new Error(WORKSPACE_RESTORE_BUSY_ERROR);
}
if (forkBeforeRunCount === undefined) {
return handleForkUnlocked(
ctx,
request,
sourceSessionId,
forkBeforeRunCount,
sourceSession,
);
}
const restoreWorkspacePath =
readWorkspacePath(sourceSession) ??
readWorkspacePath(liveSourceSession?.config) ??
readWorkspacePath(request.config);
if (!restoreWorkspacePath) {
throw new Error("cwd or workspaceRoot is required to edit a message");
}
return withWorkspaceRestoreLock(ctx, restoreWorkspacePath, () =>
handleForkUnlocked(
ctx,
request,
sourceSessionId,
forkBeforeRunCount,
sourceSession,
restoreWorkspacePath,
),
);
}
async function handleForkUnlocked(
ctx: SidecarContext,
request: ChatSessionCommandRequest,
sourceSessionId: string,
forkBeforeRunCount: number | undefined,
sourceSession: SessionRecord | undefined,
restoreWorkspacePath?: string,
): Promise<unknown> {
const manager = getSessionManager(ctx);
const sourceMessages =
readPersistedChatMessages(sourceSessionId) ??
@@ -940,7 +1084,6 @@ async function handleFork(
throw new Error(`No messages found for session ${sourceSessionId}`);
}
const sourceSession = await manager.get(sourceSessionId);
const sourceMetadata =
(sourceSession?.metadata && typeof sourceSession.metadata === "object"
? (sourceSession.metadata as JsonRecord)
@@ -983,31 +1126,77 @@ async function handleFork(
sourceMetadata?.checkpoint !== undefined
? { checkpoints: sourceMetadata.checkpoint }
: {};
let forkMessages =
forkBeforeRunCount === undefined
? sourceMessages
: trimMessagesBeforeUserRun(
sourceMessages as Message[],
forkBeforeRunCount,
);
const forkMetadata: JsonRecord = {
...(sourceMetadata ?? {}),
fork: {
forkedFromSessionId: sourceSessionId,
forkedAt: new Date().toISOString(),
source: sourceSession?.source ?? "desktop",
...(forkBeforeRunCount !== undefined
? { beforeRunCount: forkBeforeRunCount }
: {}),
...checkpointMetadata,
},
};
const systemPrompt = await resolveSystemPrompt(forkConfig);
const startResult = await manager.start({
const startInput = {
...splitCoreSessionConfig(
buildCoreSessionConfig({
...forkConfig,
systemPrompt,
initialMessages: sourceMessages,
}) as unknown as ClineCoreStartConfig,
),
source: SessionSource.DESKTOP,
interactive: true,
initialMessages: sourceMessages as Message[],
sessionMetadata: forkMetadata,
toolPolicies: resolveToolPolicies(forkConfig),
});
const newSessionId = startResult.sessionId;
};
let newSessionId: string;
if (forkBeforeRunCount !== undefined) {
const cwd =
restoreWorkspacePath ||
(typeof forkConfig.cwd === "string" && forkConfig.cwd.trim()) ||
(typeof forkConfig.workspaceRoot === "string" &&
forkConfig.workspaceRoot.trim()) ||
"";
if (!cwd) {
throw new Error("cwd or workspaceRoot is required to edit a message");
}
const restored = await manager.restore({
sessionId: sourceSessionId,
checkpointRunCount: forkBeforeRunCount,
cwd,
restore: {
messages: true,
workspace: true,
omitCheckpointMessageFromSession: true,
},
start: startInput,
});
if (!restored.sessionId) {
throw new Error("Message edit restore did not return a new session");
}
newSessionId = restored.sessionId;
} else {
const started = await manager.start({
...startInput,
initialMessages: forkMessages as Message[],
});
newSessionId = started.sessionId;
}
try {
const read = await manager.readMessages(newSessionId);
if (forkBeforeRunCount !== undefined || read.length > 0) {
forkMessages = read;
}
} catch {}
discardAllTrackedAttachments(
sourceSessionId,
ctx.liveSessions.get(sourceSessionId),
@@ -1016,23 +1205,18 @@ async function handleFork(
ctx.liveSessions.set(
newSessionId,
createLiveSession(forkConfig, {
messages: sourceMessages,
prompt: derivePromptFromMessages(sourceMessages),
messages: forkMessages,
prompt: derivePromptFromMessages(forkMessages),
title: readSessionMetadataTitle(sourceSessionId),
status: "idle",
}),
);
sendPromptsInQueueSnapshot(ctx, sourceSessionId);
sendPromptsInQueueSnapshot(ctx, newSessionId);
let messages: unknown[] = sourceMessages;
try {
const read = await manager.readMessages(newSessionId);
if (read?.length > 0) messages = read;
} catch {}
return {
sessionId: newSessionId,
forkedFromSessionId: sourceSessionId,
messages,
messages: forkMessages,
};
}
@@ -1071,63 +1255,64 @@ async function handleRestoreCheckpoint(
runCount < 1
)
throw new Error("checkpointRunCount must be a positive integer");
if (!request.config)
throw new Error("config is required to restore a checkpoint");
const config = request.config;
if (!config) throw new Error("config is required to restore a checkpoint");
const cwd =
(typeof request.config.cwd === "string" && request.config.cwd.trim()) ||
(typeof request.config.workspaceRoot === "string" &&
request.config.workspaceRoot.trim()) ||
(typeof config.cwd === "string" && config.cwd.trim()) ||
(typeof config.workspaceRoot === "string" && config.workspaceRoot.trim()) ||
"";
if (!cwd) throw new Error("config.cwd or config.workspaceRoot is required");
const manager = getSessionManager(ctx);
const restored = await manager.restore({
sessionId: sourceSessionId,
checkpointRunCount: runCount,
cwd,
restore: { messages: true, workspace: true },
start: {
...splitCoreSessionConfig(
buildCoreSessionConfig({
...request.config,
systemPrompt: await resolveSystemPrompt(request.config),
}) as unknown as ClineCoreStartConfig,
),
source: SessionSource.DESKTOP,
interactive: true,
toolPolicies: resolveToolPolicies(request.config),
},
return withWorkspaceRestoreLock(ctx, cwd, async () => {
const restored = await manager.restore({
sessionId: sourceSessionId,
checkpointRunCount: runCount,
cwd,
restore: { messages: true, workspace: true },
start: {
...splitCoreSessionConfig(
buildCoreSessionConfig({
...config,
systemPrompt: await resolveSystemPrompt(config),
}) as unknown as ClineCoreStartConfig,
),
source: SessionSource.DESKTOP,
interactive: true,
toolPolicies: resolveToolPolicies(config),
},
});
const sessionId = restored.sessionId;
const restoredMessages = restored.messages;
if (!sessionId || !restoredMessages) {
throw new Error("Checkpoint restore did not return a new session");
}
discardAllTrackedAttachments(
sourceSessionId,
ctx.liveSessions.get(sourceSessionId),
);
ctx.liveSessions.delete(sourceSessionId);
ctx.liveSessions.set(
sessionId,
createLiveSession(config, {
messages: restoredMessages,
prompt: derivePromptFromMessages(restoredMessages),
title: readSessionMetadataTitle(sourceSessionId),
status: "idle",
}),
);
sendPromptsInQueueSnapshot(ctx, sourceSessionId);
sendPromptsInQueueSnapshot(ctx, sessionId);
let messages: unknown[] = restoredMessages;
try {
const read = await manager.readMessages(sessionId);
if (read?.length > 0) messages = read;
} catch {}
return {
sessionId,
messages,
restoredCheckpoint: restored.checkpoint,
};
});
const sessionId = restored.sessionId;
const restoredMessages = restored.messages;
if (!sessionId || !restoredMessages) {
throw new Error("Checkpoint restore did not return a new session");
}
discardAllTrackedAttachments(
sourceSessionId,
ctx.liveSessions.get(sourceSessionId),
);
ctx.liveSessions.delete(sourceSessionId);
ctx.liveSessions.set(
sessionId,
createLiveSession(request.config, {
messages: restoredMessages,
prompt: derivePromptFromMessages(restoredMessages),
title: readSessionMetadataTitle(sourceSessionId),
status: "idle",
}),
);
sendPromptsInQueueSnapshot(ctx, sourceSessionId);
sendPromptsInQueueSnapshot(ctx, sessionId);
let messages: unknown[] = restoredMessages;
try {
const read = await manager.readMessages(sessionId);
if (read?.length > 0) messages = read;
} catch {}
return {
sessionId,
messages,
restoredCheckpoint: restored.checkpoint,
};
}
async function handlePendingPrompts(
@@ -1282,6 +1282,44 @@ export async function handleCommand(
if (liveSession) liveSession.title = title;
return true;
}
if (command === "update_chat_session_metadata") {
const sessionId = String(args?.sessionId ?? args?.session_id ?? "").trim();
if (!sessionId) throw new Error("session id is required");
const patch = args?.metadata;
if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
throw new Error("metadata patch is required");
}
// updateSession replaces metadata wholesale in both the session row and
// the manifest, so merge over what each already holds. A null value
// removes the key, which is how callers clear a flag.
const store = new SqliteSessionStore();
const asRecord = (value: unknown): JsonRecord =>
value && typeof value === "object" && !Array.isArray(value)
? (value as JsonRecord)
: {};
const existing = store.get(sessionId);
const merged: JsonRecord = {
...asRecord(readSessionManifest(sessionId)?.metadata),
...asRecord(existing?.metadata),
};
for (const [key, value] of Object.entries(patch as JsonRecord)) {
if (value === null) delete merged[key];
else merged[key] = value;
}
const backend = await resolveSessionBackend({ backendMode: "local" });
const result = await backend.updateSession({ sessionId, metadata: merged });
if (!result.updated) throw new Error(`Session ${sessionId} not found`);
// Annotating a session is not session activity. updateSession stamps
// updated_at, which clients sort and label rows by, so a favorite would
// otherwise make an old session look like it just ran.
if (existing?.updatedAt) {
store.run("UPDATE sessions SET updated_at = ? WHERE session_id = ?", [
existing.updatedAt,
sessionId,
]);
}
return merged;
}
if (command === "delete_chat_session" || command === "delete_cli_session") {
const sessionId = String(args?.sessionId ?? args?.session_id ?? "").trim();
if (!sessionId) throw new Error("session id is required");
@@ -456,6 +456,7 @@ export function createSidecarContext(
): SidecarContext {
return {
liveSessions: new Map(),
restoringWorkspacePaths: new Set(),
streamIndices: new Map(),
wsClients: new Set(),
pendingApprovals: new Map(),
@@ -2,6 +2,64 @@ import { describe, expect, it } from "vitest";
import { readSessionMessages } from "./messages";
describe("readSessionMessages", () => {
it("preserves each stored message timestamp across projected blocks", async () => {
const sessionId = `timestamp-projection-${Date.now()}`;
const userTimestamp = 1_781_041_621_282;
const assistantTimestamp = 1_781_041_621_946;
const liveSessions = new Map([
[
sessionId,
{
messages: [
{
id: "user-message",
role: "user",
content: [{ type: "text", text: "Question" }],
ts: userTimestamp,
},
{
id: "assistant-message",
role: "assistant",
content: [
{ type: "thinking", thinking: "Consider it" },
{ type: "text", text: "Answer" },
{
type: "tool_use",
id: "tool-use",
name: "read_files",
input: { paths: ["a.ts"] },
},
],
ts: assistantTimestamp,
},
],
},
],
]);
await expect(
readSessionMessages(
{ liveSessions } as Parameters<typeof readSessionMessages>[0],
sessionId,
),
).resolves.toEqual([
expect.objectContaining({
id: "user-message_text_0",
createdAt: userTimestamp,
meta: { runCount: 1 },
}),
expect.objectContaining({
id: "assistant-message_text_0",
createdAt: assistantTimestamp,
reasoning: "Consider it",
}),
expect.objectContaining({
id: "assistant-message_tool_use_2",
createdAt: assistantTimestamp,
}),
]);
});
it("projects image content blocks without replacing them with placeholder text", async () => {
const sessionId = `image-projection-${Date.now()}`;
const liveSessions = new Map([
@@ -45,4 +103,154 @@ describe("readSessionMessages", () => {
}),
]);
});
it("preserves absolute user run counts when older messages are omitted", async () => {
const sessionId = `run-count-projection-${Date.now()}`;
const liveSessions = new Map([
[
sessionId,
{
messages: [
{ role: "user", content: "First prompt" },
{ role: "assistant", content: "First response" },
{ role: "user", content: "Second prompt" },
{ role: "assistant", content: "Second response" },
],
},
],
]);
await expect(
readSessionMessages(
{ liveSessions } as Parameters<typeof readSessionMessages>[0],
sessionId,
2,
),
).resolves.toEqual([
expect.objectContaining({
role: "user",
content: "Second prompt",
meta: { runCount: 2 },
}),
expect.objectContaining({
role: "assistant",
content: "Second response",
}),
]);
await expect(
readSessionMessages(
{ liveSessions } as Parameters<typeof readSessionMessages>[0],
sessionId,
1,
),
).resolves.toEqual([
expect.objectContaining({
role: "assistant",
content: "Second response",
meta: { runCount: 2 },
}),
]);
});
it("marks later display segments from one user message as the same run", async () => {
const sessionId = `segmented-user-run-${Date.now()}`;
const liveSessions = new Map([
[
sessionId,
{
messages: [
{
role: "user",
content: [
{ type: "text", text: "First segment" },
{
type: "tool_result",
tool_use_id: "orphan-tool",
content: "Tool output",
},
{ type: "text", text: "Second segment" },
],
},
],
},
],
]);
const projected = (await readSessionMessages(
{ liveSessions } as Parameters<typeof readSessionMessages>[0],
sessionId,
)) as Array<Record<string, unknown>>;
expect(projected).toEqual([
expect.objectContaining({
role: "user",
content: "First segment",
meta: { runCount: 1 },
}),
expect.objectContaining({ role: "tool" }),
expect.objectContaining({
role: "user",
content: "Second segment",
meta: { userRunSpan: 0 },
}),
]);
});
it("preserves absolute run counts across system-displayed compaction messages", async () => {
const sessionId = `compaction-run-count-${Date.now()}`;
const liveSessions = new Map([
[
sessionId,
{
messages: [
{
role: "user",
content: "Compacted context",
metadata: {
kind: "compaction",
displayRole: "system",
userRunSpan: 3,
},
},
{ role: "user", content: "First visible prompt" },
{ role: "assistant", content: "First response" },
{ role: "user", content: "Second visible prompt" },
],
},
],
]);
const projected = (await readSessionMessages(
{ liveSessions } as Parameters<typeof readSessionMessages>[0],
sessionId,
)) as Array<Record<string, unknown>>;
expect(projected[0]).toMatchObject({
role: "system",
content: "Compacted context",
meta: { runCount: 3, userRunSpan: 3 },
});
expect(projected[1]).toMatchObject({
role: "user",
content: "First visible prompt",
meta: { runCount: 4 },
});
expect(projected[3]).toMatchObject({
role: "user",
content: "Second visible prompt",
meta: { runCount: 5 },
});
const truncated = (await readSessionMessages(
{ liveSessions } as Parameters<typeof readSessionMessages>[0],
sessionId,
2,
)) as Array<Record<string, unknown>>;
expect(truncated[1]).toMatchObject({
role: "user",
content: "Second visible prompt",
meta: { runCount: 5 },
});
});
});
@@ -1,5 +1,6 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { getUserRunSpan, resolveMessageDisplayRole } from "@cline/core";
import { validateImageMedia } from "@cline/shared";
import {
readSessionManifest,
@@ -29,30 +30,23 @@ type ChatTurnResult = {
const nowMs = () => Date.now();
function resolveMessageCreatedAt(
message: JsonRecord,
fallbackCreatedAt: number,
): number {
return (
parseU64Value(message.ts) ??
parseU64Value(message.createdAt) ??
fallbackCreatedAt
);
}
function readMessageMetadata(message: JsonRecord): JsonRecord | undefined {
return message.metadata && typeof message.metadata === "object"
? (message.metadata as JsonRecord)
: undefined;
}
function resolveDisplayRole(
role: string,
metadata: JsonRecord | undefined,
): string {
const displayRole =
typeof metadata?.displayRole === "string"
? metadata.displayRole.trim().toLowerCase()
: "";
if (
displayRole === "system" ||
displayRole === "status" ||
displayRole === "error"
) {
return displayRole;
}
return role;
}
function extractStoredMessageMeta(message: JsonRecord): JsonRecord | undefined {
const metadata = readMessageMetadata(message);
if (!metadata) {
@@ -66,7 +60,19 @@ function extractStoredMessageMeta(message: JsonRecord): JsonRecord | undefined {
typeof metadata.displayRole === "string" ? metadata.displayRole : undefined;
const reason =
typeof metadata.reason === "string" ? metadata.reason : undefined;
if (!hookEventName && !messageKind && !displayRole && !reason) {
const userRunSpan =
typeof metadata.userRunSpan === "number" &&
Number.isInteger(metadata.userRunSpan) &&
metadata.userRunSpan >= 0
? metadata.userRunSpan
: undefined;
if (
!hookEventName &&
!messageKind &&
!displayRole &&
!reason &&
userRunSpan === undefined
) {
return undefined;
}
return {
@@ -74,6 +80,7 @@ function extractStoredMessageMeta(message: JsonRecord): JsonRecord | undefined {
messageKind,
displayRole,
reason,
userRunSpan,
};
}
@@ -333,8 +340,20 @@ export async function readSessionMessages(
const out: JsonRecord[] = [];
const checkpointsByRunCount = readCheckpointEntriesByRunCount(sessionId);
const pendingToolMessages = new Map<string, [number, string, unknown]>();
let nextCreatedAt = baseTs;
let userRunCount = 0;
for (let idx = 0; idx < start; idx += 1) {
const rawMessage = messages[idx];
if (!rawMessage || typeof rawMessage !== "object") {
continue;
}
const message = rawMessage as JsonRecord;
const metadata = readMessageMetadata(message);
userRunCount += getUserRunSpan({
role: normalizeRole(message.role),
content: message.content,
metadata,
});
}
for (let idx = start; idx < messages.length; idx += 1) {
const rawMessage = messages[idx];
@@ -342,20 +361,34 @@ export async function readSessionMessages(
continue;
}
const message = rawMessage as JsonRecord;
const createdAt = resolveMessageCreatedAt(message, baseTs + idx);
let textMeta = extractMessageUsageMeta(message);
const storedMeta = extractStoredMessageMeta(message);
if (storedMeta) {
textMeta = { ...(textMeta ?? {}), ...storedMeta };
}
const role = resolveDisplayRole(
normalizeRole(message.role),
readMessageMetadata(message),
);
const metadata = readMessageMetadata(message);
const isRecoveryNotice =
typeof metadata?.kind === "string" && metadata.kind === "recovery_notice";
if (role === "user" && !isRecoveryNotice) {
userRunCount += 1;
const userRunSpan = getUserRunSpan({
role: normalizeRole(message.role),
content: message.content,
metadata,
});
userRunCount += userRunSpan;
const role = resolveMessageDisplayRole({
role: normalizeRole(message.role),
metadata,
});
if (role === "user" && userRunSpan !== 1) {
textMeta = {
...(textMeta ?? {}),
userRunSpan,
};
}
if (userRunSpan === 1 && role === "user") {
textMeta = {
...(textMeta ?? {}),
runCount: userRunCount,
};
const checkpoint = checkpointsByRunCount.get(userRunCount);
if (checkpoint) {
textMeta = {
@@ -363,6 +396,14 @@ export async function readSessionMessages(
checkpoint,
};
}
} else if (userRunCount > 0) {
// This also anchors truncated histories whose visible window starts
// with an assistant/tool/system message. The webview can then number
// a newly appended optimistic user message from the absolute count.
textMeta = {
...(textMeta ?? {}),
runCount: userRunCount,
};
}
const messageIdBase =
(typeof message.id === "string" && message.id.trim()) ||
@@ -381,7 +422,7 @@ export async function readSessionMessages(
sessionId,
role,
content,
createdAt: nextCreatedAt++,
createdAt,
meta: textMeta,
});
continue;
@@ -407,8 +448,12 @@ export async function readSessionMessages(
sessionId,
role,
content: joined,
createdAt: nextCreatedAt++,
meta: textMeta,
createdAt,
// A persisted user message can project into more than one text
// segment around tool blocks. Only its first segment represents
// the run; later segments must not acquire a fallback ordinal in
// the webview.
meta: textMeta ?? (role === "user" ? { userRunSpan: 0 } : undefined),
});
textSegmentIndex += 1;
textMeta = undefined;
@@ -437,7 +482,7 @@ export async function readSessionMessages(
sessionId,
role: "tool",
content: buildToolPayloadJson(toolName, input, null, false),
createdAt: nextCreatedAt++,
createdAt,
meta: {
toolName,
hookEventName: "history_tool_use",
@@ -477,7 +522,7 @@ export async function readSessionMessages(
sessionId,
role: "tool",
content: buildToolPayloadJson("tool_result", null, result, isError),
createdAt: nextCreatedAt++,
createdAt,
meta: {
toolName: "tool_result",
hookEventName: "history_tool_result",
@@ -528,7 +573,7 @@ export async function readSessionMessages(
role,
content: "",
images,
createdAt: nextCreatedAt++,
createdAt,
meta: textMeta,
});
textMeta = undefined;
@@ -554,7 +599,7 @@ export async function readSessionMessages(
content: "",
reasoning: reasoning || undefined,
reasoningRedacted: reasoningRedacted || undefined,
createdAt: nextCreatedAt++,
createdAt,
meta: textMeta,
});
textMeta = undefined;
@@ -32,6 +32,7 @@ export type ChatSessionCommandRequest = {
prompt?: string;
promptId?: string;
checkpointRunCount?: number;
forkBeforeRunCount?: number;
delivery?: "queue" | "steer";
config?: JsonRecord;
attachments?: ChatTurnAttachments;
@@ -105,6 +106,7 @@ export type SidecarWebSocketClient = {
export type SidecarContext = {
liveSessions: Map<string, LiveSession>;
restoringWorkspacePaths: Set<string>;
streamIndices: Map<string, number>;
wsClients: Set<SidecarWebSocketClient>;
pendingApprovals: Map<string, PendingToolApproval>;
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cline Code",
"version": "0.0.6",
"version": "0.0.7",
"identifier": "bot.cline.app",
"build": {
"beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web",
+54 -217
View File
@@ -21,59 +21,6 @@
}
}
/*
* Hero heading cycling verb (components/views/chat/welcome-chat.tsx).
* Opacity + transform only: animating `filter: blur` on text forces
* main-thread repaints for every character each cycle.
*/
@keyframes hero-word-in {
0% {
opacity: 0;
transform: translateY(0.42em);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.hero-word-char {
display: inline-block;
white-space: pre;
/* Solid fallback so the word is never invisible if text clipping is unsupported. */
color: var(--brand-violet);
animation: hero-word-in 0.5s cubic-bezier(0.2, 0.65, 0.3, 1) both;
}
/*
* Gradient fill per character. The clip lives on each animated span (not a
* shared parent) because WebKit used by the Tauri webview on macOS drops
* the parent's background when a child paints on its own transform/filter
* layer, which would leave the animating letters blank. The -webkit- prefixes
* are required by WebKit; @supports keeps the solid fallback above otherwise.
*/
@supports ((-webkit-background-clip: text) or (background-clip: text)) {
.hero-word-char {
background-image: linear-gradient(
135deg,
var(--brand-periwinkle),
var(--brand-violet) 55%,
var(--brand-magenta)
);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
}
}
@media (prefers-reduced-motion: reduce) {
.hero-word-char {
/* biome-ignore lint/complexity/noImportantStyles: reduced motion must override inline animation delay */
animation: none !important;
}
}
/*
* Chat Markdown (components/ui/markdown.tsx). Streamdown ships its defaults
* as Tailwind utilities, which land in the layered @source output; these
@@ -132,6 +79,60 @@
font-size: 0.8125rem;
}
/* Running reasoning/tool titles use their inherited text color as the bright
* band, so the same shimmer fits muted thinking text and primary tool text. */
.cline-chat-streaming-title {
display: inline-block;
background-image: linear-gradient(
90deg,
color-mix(in oklab, currentcolor 55%, transparent) 0%,
currentcolor 50%,
color-mix(in oklab, currentcolor 55%, transparent) 100%
);
background-position: 200% 0;
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: cline-chat-title-shimmer 2.25s linear infinite;
}
@keyframes cline-chat-title-shimmer {
to {
background-position: -200% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.cline-chat-streaming-title {
background: none;
-webkit-text-fill-color: currentcolor;
animation: none;
}
}
/* Tool panels scroll on both axes, but a horizontal thumb inside a short
* disclosure eats a whole detail row. Keep the axis scrollable (wheel, trackpad,
* keyboard, drag-select) and hide only its bar; the vertical one stays visible.
* `:horizontal` is the one selector that separates the two axes the standard
* `scrollbar-width: none` would take out both. */
.cline-chat-scroll-x-bare::-webkit-scrollbar:horizontal {
display: none;
}
/* The shared reveal rule is `.cline-chat-message:hover`, so hovering anywhere in
* the message pops the action row including the reasoning disclosure nested in
* the message content. The actions act on the message body, so suppress the
* reveal while the pointer is inside the thinking panel. `[data-visible]` still
* wins, keeping the pending/error states pinned open. */
.cline-chat-message:has(
> .cline-chat-message-content .cline-chat-reasoning:hover
)
> .cline-chat-message-actions:not([data-visible="true"]) {
pointer-events: none;
opacity: 0;
}
/*
* Code blocks: Streamdown renders a double box an outer sidebar-tinted card
* holding a language header row plus an inner bordered body. Collapse it to a
@@ -222,170 +223,6 @@
background: var(--card);
}
/*
* Aurora background (components/ui/aurora-bg.tsx).
*
* Performance contract: no `filter: blur` and no animated properties beyond
* `opacity` + `transform`, so the layers rasterize once and every animation
* frame is compositor-only. Blurred full-viewport layers used to be
* re-rendered on every frame of their drift animations, which alone dropped
* the whole app below 10fps on modest hardware.
*/
@keyframes aurora-drift {
0% {
opacity: 0.55;
transform: translate3d(-8%, 5%, 0) scale(0.94);
}
50% {
opacity: 0.92;
transform: translate3d(13%, -10%, 0) scale(1.11);
}
100% {
opacity: 0.62;
transform: translate3d(-5%, -2%, 0) scale(1.02);
}
}
@keyframes aurora-drift-reverse {
0% {
opacity: 0.62;
transform: translate3d(10%, -6%, 0) scale(1.08);
}
50% {
opacity: 0.9;
transform: translate3d(-12%, -12%, 0) scale(0.96);
}
100% {
opacity: 0.58;
transform: translate3d(6%, 2%, 0) scale(1.04);
}
}
@keyframes aurora-horizon-breathe {
0% {
opacity: 0.48;
transform: translate3d(-3%, 9%, 0) scale(0.96, 0.84);
}
50% {
opacity: 0.88;
transform: translate3d(3%, -5%, 0) scale(1.08, 1.16);
}
100% {
opacity: 0.58;
transform: translate3d(-1%, 2%, 0) scale(1.02, 0.96);
}
}
@keyframes aurora-current-sweep {
0% {
opacity: 0.28;
transform: translate3d(-16%, 8%, 0) scaleX(0.84);
}
50% {
opacity: 0.72;
transform: translate3d(17%, -8%, 0) scaleX(1.08);
}
100% {
opacity: 0.38;
transform: translate3d(28%, 4%, 0) scaleX(0.94);
}
}
@keyframes aurora-twinkle {
0%,
100% {
opacity: 0.15;
}
50% {
opacity: 0.78;
}
}
/*
* Static vertical fade replacing the old `filter: blur(46-64px)` on the big
* gradient bands: the mask rasterizes once with the layer, so soft edges no
* longer cost anything per animation frame.
*/
.aurora-soft-band {
-webkit-mask-image: linear-gradient(
to bottom,
transparent,
black 32%,
black 68%,
transparent
);
mask-image: linear-gradient(
to bottom,
transparent,
black 32%,
black 68%,
transparent
);
}
.aurora-horizon {
animation: aurora-horizon-breathe 8s ease-in-out -3s infinite alternate;
transform-origin: center bottom;
will-change: opacity, transform;
}
.aurora-current {
animation-name: aurora-current-sweep;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
animation-direction: alternate;
will-change: opacity, transform;
}
.aurora-current-reverse {
animation-direction: alternate-reverse;
}
.aurora-motion {
animation-name: aurora-drift;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
animation-direction: alternate;
will-change: opacity, transform;
}
.aurora-motion-reverse {
animation-name: aurora-drift-reverse;
}
.aurora-star {
animation-name: aurora-twinkle;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@media (prefers-reduced-motion: reduce) {
.aurora-current,
.aurora-horizon,
.aurora-motion,
.aurora-star {
/* biome-ignore lint/complexity/noImportantStyles: reduced motion must override inline animation timing */
animation: none !important;
}
.aurora-horizon {
opacity: 0.68;
transform: translate3d(0, -1%, 0) scale(1.05);
}
.aurora-current,
.aurora-motion {
opacity: 0.58;
transform: none;
}
.aurora-current,
.aurora-horizon,
.aurora-motion {
will-change: auto;
}
}
/*
* Accent palettes (Settings -> General -> Accent color). The default
* "violet" accent is the @cline/ui brand tokens untouched; each block below
+60 -16
View File
@@ -172,9 +172,12 @@ export default function Home() {
handleNewThread();
}, [handleNewThread]);
const handleOpenSession = useCallback((session: SessionHistoryItem) => {
dispatchApp({ type: "open-session", session });
}, []);
const handleOpenSession = useCallback(
(session: SessionHistoryItem, initialPromptDraft?: string) => {
dispatchApp({ type: "open-session", session, initialPromptDraft });
},
[],
);
const handleDeleteSession = useCallback(
(deletedSessionId: string, deletedThreadId?: string) => {
@@ -252,6 +255,9 @@ export default function Home() {
const handleThreadStarted = useCallback((threadId: string) => {
dispatchApp({ type: "thread-started", threadId });
}, []);
const handleInitialPromptDraftConsumed = useCallback((threadId: string) => {
dispatchApp({ type: "consume-initial-prompt-draft", threadId });
}, []);
const sessionHistory = useSessionHistory({
activeSessionId: activeHistorySessionId,
onDeleteSession: handleDeleteSession,
@@ -355,7 +361,11 @@ export default function Home() {
<ChatThreadPane
key={activeThread.id}
historySession={activeThread.historySession}
initialPromptDraft={activeThread.initialPromptDraft}
knownWorkspacePaths={historyWorkspacePaths}
onInitialPromptDraftConsumed={
handleInitialPromptDraftConsumed
}
onUpdateSessionMetadata={handleUpdateSessionMetadata}
threadId={activeThread.id}
onDeleteSession={handleDeleteSession}
@@ -391,7 +401,9 @@ export default function Home() {
function ChatThreadPane({
threadId,
historySession,
initialPromptDraft,
knownWorkspacePaths,
onInitialPromptDraftConsumed,
onUpdateSessionMetadata,
onDeleteSession,
onNewThread,
@@ -402,14 +414,19 @@ function ChatThreadPane({
}: {
threadId: string;
historySession?: SessionHistoryItem;
initialPromptDraft?: string;
knownWorkspacePaths: string[];
onInitialPromptDraftConsumed?: (threadId: string) => void;
onUpdateSessionMetadata?: (
sessionId: string,
metadata: SessionMetadata,
) => void;
onDeleteSession?: (sessionId: string, threadId?: string) => void;
onNewThread?: () => void;
onOpenSession?: (session: SessionHistoryItem) => void;
onOpenSession?: (
session: SessionHistoryItem,
initialPromptDraft?: string,
) => void;
onOpenSessionById?: (sessionId: string) => void | Promise<void>;
parentSession?: { sessionId: string; title?: string };
onThreadStarted?: (threadId: string) => void;
@@ -820,11 +837,21 @@ function ChatThreadPane({
return;
}
hydratedSessionRef.current = historySession.sessionId;
setPromptInput("");
setPromptInput(initialPromptDraft ?? "");
if (initialPromptDraft !== undefined) {
onInitialPromptDraftConsumed?.(threadId);
}
setPendingAttachments([]);
setManualTitle(getSessionMetadataTitle(historySession.metadata));
void hydrateSession(historySession);
}, [historySession, hydrateSession, setPromptInput]);
}, [
historySession,
hydrateSession,
initialPromptDraft,
onInitialPromptDraftConsumed,
setPromptInput,
threadId,
]);
const handleSend = useCallback(
async (prompt: string) => {
@@ -889,16 +916,18 @@ function ChatThreadPane({
[answerAskQuestion],
);
const handleRestoreCheckpoint = useCallback(
(runCount: number) => {
void restoreCheckpoint(runCount);
},
(runCount: number) => restoreCheckpoint(runCount),
[restoreCheckpoint],
);
const handleForkSession = useCallback(async () => {
const result = await forkSession();
// Open the forked session as a new thread in the sidebar.
if (onOpenSession) {
const openForkedSession = useCallback(
(
result: Awaited<ReturnType<typeof forkSession>>,
editedPrompt?: string,
) => {
if (!onOpenSession) {
return;
}
const workspaceRoot = config.workspaceRoot;
const cwd = config.cwd ?? workspaceRoot;
const forkedHistorySession: SessionHistoryItem = {
@@ -916,9 +945,23 @@ function ChatThreadPane({
},
},
};
onOpenSession(forkedHistorySession);
}
}, [config, forkSession, onOpenSession]);
onOpenSession(forkedHistorySession, editedPrompt);
},
[config, onOpenSession],
);
const handleForkSession = useCallback(async () => {
const result = await forkSession();
openForkedSession(result);
}, [forkSession, openForkedSession]);
const handleEditMessage = useCallback(
async (_messageId: string, content: string, runCount: number) => {
const result = await forkSession({ beforeRunCount: runCount });
openForkedSession(result, content);
},
[forkSession, openForkedSession],
);
const visibleHistorySession =
historySession?.sessionId &&
@@ -1368,6 +1411,7 @@ function ChatThreadPane({
chatTransportState={chatTransportState}
error={displayedError}
messages={displayedMessages}
onEditMessage={handleEditMessage}
onRestoreCheckpoint={handleRestoreCheckpoint}
onForkSession={handleForkSession}
pendingToolApprovals={pendingToolApprovals}
@@ -17,7 +17,6 @@ import {
Loader2,
PanelLeftOpen,
Pencil,
Pin,
Plug,
Plus,
Radio,
@@ -25,6 +24,7 @@ import {
Server,
Settings,
SlidersHorizontal,
Star,
Trash2,
Wrench,
} from "lucide-react";
@@ -94,7 +94,7 @@ import { cn } from "@/lib/utils";
type Thread = SessionThread;
type AppView = "chat" | "sessions" | "settings";
const filterOptions = ["All", "Running", "Schedules", "Pinned"] as const;
const filterOptions = ["All", "Running", "Schedules", "Favorites"] as const;
type FilterOption = (typeof filterOptions)[number];
type SidebarSortMode = "time" | "project";
type DesktopProcessContext = {
@@ -244,6 +244,7 @@ export function AgentSidebar({
openThread: openHistoryThread,
pendingAction,
renameThread,
setThreadPinned,
threads,
unreadSessionIds,
} = sessionHistory;
@@ -329,7 +330,7 @@ export function AgentSidebar({
return filtered.filter((t) => t.status === "running");
case "Schedules":
return filtered.filter((t) => t.source === SCHEDULED_SESSION_SOURCE);
case "Pinned":
case "Favorites":
return filtered.filter((t) => t.pinned);
default:
return filtered;
@@ -404,6 +405,13 @@ export function AgentSidebar({
[forkHistoryThread],
);
const toggleFavorite = useCallback(
async (thread: Thread) => {
await setThreadPinned(thread.id, !thread.pinned);
},
[setThreadPinned],
);
const requestDeleteThread = useCallback((thread: Thread) => {
setDeleteConfirmThread(thread);
}, []);
@@ -533,6 +541,7 @@ export function AgentSidebar({
onEditTitleChange={setEditingTitle}
onFork={() => void forkThread(thread)}
onRename={() => startRenameThread(thread)}
onToggleFavorite={() => void toggleFavorite(thread)}
pendingAction={
pendingAction?.sessionId === thread.id ? pendingAction.action : null
}
@@ -759,7 +768,7 @@ export function AgentSidebar({
.map(threadItem)}
{project.threads.length > visibleCount ? (
<Button
className="pl-2"
className="pl-2!"
onClick={() =>
showMoreForProject(project.id)
}
@@ -787,7 +796,11 @@ export function AgentSidebar({
)}
{sortMode === "time" && showTimeShowMore && (
<Button
className="pl-0"
// `pl-0!`: the default button size adds
// `has-[>svg]:px-3`, and that modifier beats a plain
// `pl-0` on specificity, so the icon child was
// re-indenting the row.
className="pl-0!"
disabled={isLoadingMore}
onClick={() => {
const nextCount =
@@ -804,10 +817,10 @@ export function AgentSidebar({
Loading...
</>
) : (
<>
<div className="ml-2 flex items-center gap-1">
Show more
<ChevronDown className="size-3" />
</>
</div>
)}
</Button>
)}
@@ -816,7 +829,7 @@ export function AgentSidebar({
!searchQuery &&
mayHaveMoreSessions && (
<Button
className="pl-0"
className="pl-0!"
disabled={isLoadingMore}
onClick={() => void loadOlderSessions()}
type="button"
@@ -1003,6 +1016,7 @@ function ThreadItem({
onCommitRename,
onEditTitleChange,
onRename,
onToggleFavorite,
onFork,
onDelete,
pendingAction,
@@ -1017,6 +1031,7 @@ function ThreadItem({
onCommitRename: () => void;
onEditTitleChange: (title: string) => void;
onRename: () => void;
onToggleFavorite: () => void;
onFork: () => void;
onDelete: () => void;
pendingAction: "rename" | "fork" | "delete" | null;
@@ -1079,7 +1094,10 @@ function ThreadItem({
</span>
<span className="flex shrink-0 items-center gap-1.5 text-[11px] text-muted-foreground">
{thread.pinned ? (
<Pin aria-label="Pinned" className="size-3" />
<Star
aria-label="Favorited"
className="size-3 fill-current"
/>
) : statusDotClass ? (
<span
aria-hidden="true"
@@ -1119,9 +1137,11 @@ function ThreadItem({
</HoverCardContent>
</HoverCard>
<SessionContextMenuContent
favorited={Boolean(thread.pinned)}
onDelete={onDelete}
onFork={onFork}
onRename={onRename}
onToggleFavorite={onToggleFavorite}
pendingAction={pendingAction}
/>
</ContextMenu>
@@ -1210,12 +1230,16 @@ function EditableSessionTitle({
}
function SessionContextMenuContent({
favorited,
onRename,
onToggleFavorite,
onFork,
onDelete,
pendingAction,
}: {
favorited: boolean;
onRename: () => void;
onToggleFavorite: () => void;
onFork: () => void;
onDelete: () => void;
pendingAction: "rename" | "fork" | "delete" | null;
@@ -1223,6 +1247,10 @@ function SessionContextMenuContent({
const pending = pendingAction !== null;
return (
<ContextMenuContent className="w-40">
<ContextMenuItem disabled={pending} onSelect={onToggleFavorite}>
<Star className={cn("size-4", favorited && "fill-current")} />
{favorited ? "Unfavorite" : "Favorite"}
</ContextMenuItem>
<ContextMenuItem disabled={pending} onSelect={onRename}>
{pendingAction === "rename" ? (
<Loader2 className="size-4 animate-spin" />
@@ -1,147 +0,0 @@
"use client";
import { useMemo } from "react";
interface Star {
left: string;
top: string;
size: number;
delay: string;
duration: string;
opacity: number;
color: string;
}
// Big soft gradient blobs that slowly drift to fake an aurora. Softness is
// baked into the gradient stops (no `filter: blur`) so the layers rasterize
// once and every animation frame is compositor-only work.
const BLOBS = [
{
id: "periwinkle-left",
position: "left-[-20%] bottom-[-40%] w-[70%] h-[80%]",
gradient:
"radial-gradient(ellipse at center, color-mix(in oklab, var(--brand-periwinkle) 64%, transparent), color-mix(in oklab, var(--brand-periwinkle) 30%, transparent) 42%, transparent 70%)",
duration: "11s",
delay: "0s",
reverse: false,
},
{
id: "violet-right",
position: "right-[-15%] bottom-[-40%] w-[65%] h-[85%]",
gradient:
"radial-gradient(ellipse at center, color-mix(in oklab, var(--brand-violet) 58%, transparent), color-mix(in oklab, var(--brand-violet) 27%, transparent) 42%, transparent 70%)",
duration: "12.5s",
delay: "-12s",
reverse: true,
},
] as const;
function seededUnit(index: number, salt: number): number {
let value =
Math.imul(index + 1, 0x9e3779b1) ^ Math.imul(salt + 1, 0x85ebca6b);
value ^= value >>> 16;
value = Math.imul(value, 0x7feb352d);
value ^= value >>> 15;
value = Math.imul(value, 0x846ca68b);
value ^= value >>> 16;
return (value >>> 0) / 0x1_0000_0000;
}
/**
* A decorative aurora background built entirely from CSS: soft gradient
* blobs drifting on keyframe animations, plus twinkling star dots. No canvas,
* no WebGL, no per-frame JS. Absolutely positioned to fill its nearest
* positioned parent; pointer events pass through.
*
* Performance contract: no `filter: blur` anywhere (soft edges come from
* gradient falloff + static masks) and animations only touch `opacity` and
* `transform`, so the whole effect stays on the compositor. Blurring these
* full-viewport layers used to force a main-thread re-raster every frame and
* dragged the entire app below 10fps on modest hardware.
*
* Keyframes (`aurora-drift`, `aurora-twinkle`) live in app/globals.css.
*/
export function AuroraBackground({ starCount = 32 }: { starCount?: number }) {
// The field is deterministic so server and browser markup always agree.
const stars = useMemo<Star[]>(
() =>
Array.from({ length: starCount }, (_, index) => {
// Squared skew biases stars toward the bottom, where the glow lives.
const r = seededUnit(index, 1);
const sizeRoll = seededUnit(index, 3);
return {
left: `${seededUnit(index, 2) * 100}%`,
top: `${100 - (1 - r * r) * 45}%`,
size: sizeRoll < 0.14 ? 4 : sizeRoll < 0.52 ? 3 : 2,
delay: `${seededUnit(index, 4) * -5}s`,
duration: `${3.5 + seededUnit(index, 5) * 3.5}s`,
opacity: 0.35 + seededUnit(index, 6) * 0.6,
color:
seededUnit(index, 7) > 0.78
? "var(--brand-cyan)"
: "color-mix(in oklab, white 92%, var(--brand-lilac))",
};
}),
[starCount],
);
return (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 overflow-hidden"
>
<div
className="aurora-horizon aurora-soft-band absolute inset-x-[-8%] bottom-[-3%] h-[40%] opacity-60"
style={{
background:
"linear-gradient(90deg, color-mix(in oklab, var(--brand-lilac) 58%, transparent), color-mix(in oklab, var(--brand-magenta) 62%, transparent) 42%, color-mix(in oklab, var(--brand-periwinkle) 72%, transparent) 78%, color-mix(in oklab, var(--brand-cyan) 58%, transparent))",
}}
/>
<div
className="aurora-current aurora-soft-band absolute bottom-[3%] left-[-45%] h-[30%] w-[125%] opacity-50"
style={{
animationDelay: "-2s",
animationDuration: "9s",
background:
"linear-gradient(105deg, transparent 12%, color-mix(in oklab, var(--brand-magenta) 66%, transparent) 38%, color-mix(in oklab, var(--brand-periwinkle) 72%, transparent) 58%, transparent 82%)",
}}
/>
<div
className="aurora-current aurora-current-reverse aurora-soft-band absolute bottom-[-5%] right-[-42%] h-[34%] w-[120%] opacity-45"
style={{
animationDelay: "-6s",
animationDuration: "12s",
background:
"linear-gradient(75deg, transparent 10%, color-mix(in oklab, var(--brand-cyan) 62%, transparent) 42%, color-mix(in oklab, var(--brand-violet) 70%, transparent) 64%, transparent 88%)",
}}
/>
{BLOBS.map((blob) => (
<div
key={blob.id}
className={`aurora-motion absolute ${blob.reverse ? "aurora-motion-reverse" : ""} ${blob.position}`}
style={{
background: blob.gradient,
animationDuration: blob.duration,
animationDelay: blob.delay,
}}
/>
))}
{stars.map((s) => (
<span
key={`${s.left}-${s.top}`}
className="aurora-star absolute rounded-[1px]"
style={{
left: s.left,
top: s.top,
width: s.size,
height: s.size,
background: s.color,
opacity: s.opacity,
animationDelay: s.delay,
animationDuration: s.duration,
}}
/>
))}
</div>
);
}
@@ -9,6 +9,7 @@ import {
Streamdown,
} from "streamdown";
import { openExternalUrl } from "@/lib/desktop-client";
import { cn } from "@/lib/utils";
import {
AlertDialog,
AlertDialogAction,
@@ -298,13 +299,15 @@ const markdownComponents = {
export const MemoizedMarkdown = memo(
({
content,
classNames,
streaming = false,
}: {
content: string;
streaming?: boolean;
classNames?: string;
}) => (
<Streamdown
className="cline-markdown"
className={cn("cline-markdown", classNames)}
components={markdownComponents}
controls={streamdownControls}
dir="auto"
@@ -118,10 +118,12 @@ describe("ChatInputBar", () => {
await act(async () => compactModelTrigger?.click());
expect(compactModelTrigger?.getAttribute("aria-expanded")).toBe("true");
expect(
container.querySelectorAll<HTMLButtonElement>('[aria-label="Provider"]'),
container.querySelectorAll<HTMLButtonElement>(
'[aria-label^="Provider:"]',
),
).toHaveLength(2);
expect(
container.querySelectorAll<HTMLButtonElement>('[aria-label="Model"]'),
container.querySelectorAll<HTMLButtonElement>('[aria-label^="Model:"]'),
).toHaveLength(2);
await act(async () =>
container
@@ -154,7 +156,7 @@ describe("ChatInputBar", () => {
expect(onReasoningChange).not.toHaveBeenCalled();
const providerTrigger = container.querySelector<HTMLButtonElement>(
'[aria-label="Provider"]',
'[aria-label^="Provider:"]',
);
expect(providerTrigger?.parentElement?.parentElement?.className).toContain(
"max-[560px]:hidden",
@@ -1,6 +1,7 @@
"use client";
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared/browser";
import { SearchCombobox } from "@cline/ui";
import {
ArrowUp,
Brain,
@@ -46,7 +47,6 @@ import {
loadProviderModels,
} from "@/lib/provider-model-catalog";
import { cn } from "@/lib/utils";
import { SearchableSelect } from "./searchable-select";
import { WorkspaceSelector as WorkspaceSelectorImpl } from "./workspace-selector";
// Memoized: the workspace/branch selector fans out into popovers and lists
@@ -1524,15 +1524,16 @@ const ModelSelector = memo(function ModelSelector({
],
);
const renderProviderSelect = (triggerClassName: string) => (
<SearchableSelect
<SearchCombobox
ariaLabel="Provider"
className={triggerClassName}
disabled={isBusy || providers.length === 0}
emptyLabel="No providers found."
items={providers}
onSelect={handleProviderSelect}
emptyText="No providers found."
onValueChange={handleProviderSelect}
options={providers.map((value) => ({ label: value, value }))}
placeholder="Provider"
placement="top"
searchPlaceholder="Search providers"
triggerClassName={triggerClassName}
value={resolvedProvider}
/>
);
@@ -1540,18 +1541,19 @@ const ModelSelector = memo(function ModelSelector({
triggerClassName: string,
closeMobileMenu = false,
) => (
<SearchableSelect
<SearchCombobox
ariaLabel="Model"
className={triggerClassName}
disabled={isBusy || modelsForProvider.length === 0}
emptyLabel="No models found."
items={modelsForProvider}
onSelect={(value) => {
emptyText="No models found."
onValueChange={(value) => {
onModelChange(value);
if (closeMobileMenu) setMobileOpen(false);
}}
options={modelsForProvider.map((value) => ({ label: value, value }))}
placeholder="Model"
placement="top"
searchPlaceholder="Search models"
triggerClassName={triggerClassName}
value={resolvedModel}
/>
);
@@ -47,6 +47,53 @@ async function renderMessages(
}
describe("ChatMessages tool disclosures", () => {
it.each([
["run_commands", "lucide-terminal"],
["read_files", "lucide-files"],
["search_codebase", "lucide-search-code"],
["editor", "lucide-pencil"],
["apply_patch", "lucide-pencil"],
["ask_question", "lucide-message-circle-question-mark"],
["fetch_web_content", "lucide-panels-top-left"],
["skills", "lucide-library"],
["mcp", "lucide-box"],
["plugins", "lucide-blocks"],
["submit_and_exit", "lucide-square-arrow-right"],
["spawn_agent", "lucide-user"],
["spawn-agent", "lucide-user"],
["spawn_agent_tool", "lucide-user"],
["subagent_subagent", "lucide-user"],
["subagent_code_reviewer", "lucide-user"],
["team_status", "lucide-users"],
["bash", "lucide-terminal"],
["file_read", "lucide-files"],
["file-read", "lucide-files"],
["edit", "lucide-pencil"],
["edit_file", "lucide-pencil"],
["apply-patch", "lucide-pencil"],
["search", "lucide-search-code"],
["web-fetch", "lucide-panels-top-left"],
["web_fetch", "lucide-panels-top-left"],
["unknown_tool", "lucide-wrench"],
])("uses the expected icon for %s", async (toolName, iconClass) => {
await renderMessages([
{
id: `tool-icon-${toolName}`,
sessionId: "session-1",
role: "tool",
content: JSON.stringify({
toolName,
input: {},
result: {},
}),
createdAt: 1,
},
]);
const icon = container.querySelector(".cline-chat-tool-icon svg");
expect(icon?.classList.contains(iconClass)).toBe(true);
});
it("renders a detail-less tool summary as static text", async () => {
await renderMessages([
{
@@ -64,6 +111,49 @@ describe("ChatMessages tool disclosures", () => {
);
expect(summary).toBeDefined();
expect(summary?.closest("button")).toBeNull();
expect(
container.querySelector(".cline-chat-tool")?.classList.contains("my-0"),
).toBe(true);
});
it("shimmers a tool title only while its result is pending", async () => {
const pendingTool: ChatMessage = {
id: "tool-pending",
sessionId: "session-1",
role: "tool",
content: JSON.stringify({
toolName: "read_files",
input: { paths: ["pending.ts"] },
result: null,
}),
createdAt: 1,
};
await renderMessages([pendingTool]);
const pendingTitle = container.querySelector(
".cline-chat-tool-label > span",
);
expect(pendingTitle?.classList.contains("cline-chat-streaming-title")).toBe(
true,
);
await renderMessages([
{
...pendingTool,
content: JSON.stringify({
toolName: "read_files",
input: { paths: ["pending.ts"] },
result: { content: "done" },
}),
},
]);
const completedTitle = container.querySelector(
".cline-chat-tool-label > span",
);
expect(
completedTitle?.classList.contains("cline-chat-streaming-title"),
).toBe(false);
});
it("exposes and toggles expandable tool details", async () => {
@@ -249,6 +339,330 @@ describe("ChatMessages tool disclosures", () => {
container.querySelector('button[aria-label="Copy assistant message"]'),
).toBeNull();
});
it("spaces reasoning, content, and tool blocks with a single gap-2", async () => {
await renderMessages([
{
id: "assistant-reasoning",
sessionId: "session-1",
role: "assistant",
content: "Assistant message",
reasoning: "Thinking about it",
createdAt: 2,
},
{
id: "tool-after",
sessionId: "session-1",
role: "tool",
content: JSON.stringify({
toolName: "run_commands",
input: { commands: ["ls"] },
result: {},
}),
createdAt: 3,
},
]);
const message = container.querySelector(
'.cline-chat-message[data-role="assistant"]',
);
const messageList = message?.parentElement;
const content = message?.querySelector(".cline-chat-message-content");
// The list and the content column each own their spacing via gap-2...
expect(messageList?.classList.contains("gap-2")).toBe(true);
expect(content?.classList.contains("flex")).toBe(true);
expect(content?.classList.contains("flex-col")).toBe(true);
expect(content?.classList.contains("gap-2")).toBe(true);
// ...so no block may contribute vertical margins of its own.
for (const element of [
message,
content?.querySelector(".cline-chat-reasoning"),
content?.lastElementChild,
container.querySelector(".cline-chat-tool"),
]) {
const classes = [...(element?.classList ?? [])];
expect(classes.some((name) => /^-?m[ytb]-[1-9]/.test(name))).toBe(false);
}
});
it("positions hidden message actions outside the message layout", async () => {
await renderMessages(
[
{
id: "user-actions",
sessionId: "session-1",
role: "user",
content: "User message",
createdAt: 1,
},
{
id: "assistant-actions",
sessionId: "session-1",
role: "assistant",
content: "Assistant message",
createdAt: 2,
},
],
{ onForkSession: vi.fn() },
);
const userMessage = container.querySelector(
'.cline-chat-message[data-role="user"]',
);
const userActions = userMessage?.querySelector(
":scope > .cline-chat-message-actions",
);
const assistantMessage = container.querySelector(
'.cline-chat-message[data-role="assistant"]',
);
const assistantActions = assistantMessage?.querySelector(
":scope > .cline-chat-message-actions",
);
expect(userMessage?.classList.contains("relative")).toBe(true);
expect(userActions?.classList.contains("absolute")).toBe(true);
expect(userActions?.classList.contains("right-0")).toBe(true);
expect(userActions?.classList.contains("top-full")).toBe(true);
expect(userActions?.classList.contains("-translate-y-2")).toBe(true);
expect(assistantMessage?.classList.contains("relative")).toBe(true);
expect(assistantActions?.classList.contains("absolute")).toBe(true);
expect(assistantActions?.classList.contains("left-0")).toBe(true);
expect(assistantActions?.classList.contains("top-full")).toBe(true);
expect(assistantActions?.classList.contains("-translate-y-2")).toBe(true);
expect(assistantActions?.getAttribute("data-visible")).toBe("true");
const userAction = userActions?.querySelector(".cline-chat-message-action");
expect(userAction?.classList.contains("min-w-0")).toBe(true);
expect(userAction?.classList.contains("p-0")).toBe(true);
const assistantActionButtons = [
...(assistantActions?.querySelectorAll(".cline-chat-message-action") ??
[]),
];
expect(assistantActionButtons).toHaveLength(2);
expect(
assistantActionButtons.every(
(action) =>
action.classList.contains("min-w-0") &&
action.classList.contains("p-0"),
),
).toBe(true);
expect(userActions?.querySelector("time")?.getAttribute("datetime")).toBe(
new Date(1).toISOString(),
);
expect(
assistantActions?.querySelector("time")?.getAttribute("datetime"),
).toBe(new Date(2).toISOString());
});
it("confirms before restoring a checkpoint", async () => {
const onRestoreCheckpoint = vi.fn(async () => undefined);
await renderMessages(
[
{
id: "checkpoint-user",
sessionId: "session-1",
role: "user",
content: "Change the implementation",
createdAt: 1,
meta: {
runCount: 2,
checkpoint: {
ref: "checkpoint-ref",
createdAt: 1,
runCount: 2,
},
},
},
],
{ onRestoreCheckpoint },
);
const restoreButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Restore checkpoint"]',
);
await act(async () => restoreButton?.click());
expect(onRestoreCheckpoint).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("Revert to this checkpoint?");
const confirmButton = [...document.body.querySelectorAll("button")].find(
(button) => button.textContent === "Revert",
);
await act(async () => confirmButton?.click());
expect(onRestoreCheckpoint).toHaveBeenCalledOnce();
expect(onRestoreCheckpoint).toHaveBeenCalledWith(2);
});
it("edits a user message by restarting before its user run", async () => {
const onEditMessage = vi.fn(async () => undefined);
await renderMessages(
[
{
id: "earlier-user",
sessionId: "session-1",
role: "user",
content: "Earlier prompt",
createdAt: 1,
},
{
id: "earlier-assistant",
sessionId: "session-1",
role: "assistant",
content: "Earlier response",
createdAt: 2,
},
{
id: "editable-user",
sessionId: "session-1",
role: "user",
content: '<user_input mode="act">Original prompt</user_input>',
createdAt: 3,
},
],
{ onEditMessage },
);
const editButton = container.querySelectorAll<HTMLButtonElement>(
'button[aria-label="Edit user message"]',
)[1];
await act(async () => editButton?.click());
expect(onEditMessage).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("Edit and restart from here?");
const continueButton = [...document.body.querySelectorAll("button")].find(
(button) => button.textContent === "Continue",
);
await act(async () => continueButton?.click());
expect(onEditMessage).toHaveBeenCalledOnce();
expect(onEditMessage).toHaveBeenCalledWith(
"editable-user",
"Original prompt",
2,
);
});
it("counts folded system-displayed runs before an editable user message", async () => {
const onEditMessage = vi.fn(async () => undefined);
await renderMessages(
[
{
id: "compacted-history",
sessionId: "session-1",
role: "system",
content: "Compacted context",
createdAt: 1,
meta: {
messageKind: "compaction",
userRunSpan: 3,
},
},
{
id: "post-compaction-user",
sessionId: "session-1",
role: "user",
content: "Fourth prompt",
createdAt: 2,
},
],
{ onEditMessage },
);
const editButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Edit user message"]',
);
await act(async () => editButton?.click());
const continueButton = [...document.body.querySelectorAll("button")].find(
(button) => button.textContent === "Continue",
);
await act(async () => continueButton?.click());
expect(onEditMessage).toHaveBeenCalledWith(
"post-compaction-user",
"Fourth prompt",
4,
);
});
it("continues from a non-user run anchor in a truncated history", async () => {
const onEditMessage = vi.fn(async () => undefined);
await renderMessages(
[
{
id: "truncated-assistant",
sessionId: "session-1",
role: "assistant",
content: "Most recent response",
createdAt: 1,
meta: { runCount: 3 },
},
{
id: "optimistic-user",
sessionId: "session-1",
role: "user",
content: "Next prompt",
createdAt: 2,
},
],
{ onEditMessage },
);
const editButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Edit user message"]',
);
await act(async () => editButton?.click());
const continueButton = [...document.body.querySelectorAll("button")].find(
(button) => button.textContent === "Continue",
);
await act(async () => continueButton?.click());
expect(onEditMessage).toHaveBeenCalledWith(
"optimistic-user",
"Next prompt",
4,
);
});
it("does not offer editing for a message that represents multiple runs", async () => {
await renderMessages(
[
{
id: "folded-user-runs",
sessionId: "session-1",
role: "user",
content: "Merged prompts",
createdAt: 1,
meta: { userRunSpan: 2 },
},
],
{ onEditMessage: vi.fn(async () => undefined) },
);
expect(
container.querySelector('button[aria-label="Edit user message"]'),
).toBeNull();
});
it("leaves vertical scrolling to the conversation viewport", async () => {
await renderMessages([
{
id: "assistant-scroll",
sessionId: "session-1",
role: "assistant",
content: "Assistant message",
createdAt: 1,
},
]);
const content = container.querySelector(".cline-chat-conversation-content");
const messageList = content?.querySelector(":scope > div");
expect(content?.classList.contains("overflow-x-hidden")).toBe(false);
expect(messageList?.classList.contains("overflow-x-hidden")).toBe(false);
});
});
describe("ChatMessages image attachments", () => {
@@ -270,8 +684,8 @@ describe("ChatMessages image attachments", () => {
'img[alt="Attachment 1"]',
);
expect(image?.src).toBe("data:image/png;base64,aGVsbG8=");
expect(image?.className).toContain("max-h-[225px]");
expect(image?.className).toContain("max-w-[225px]");
expect(image?.className).toContain("max-h-56.25");
expect(image?.className).toContain("max-w-56.25");
expect(container.textContent).toContain("Describe this");
});
@@ -312,6 +726,318 @@ describe("ChatMessages image attachments", () => {
});
});
describe("ChatMessages reasoning disclosure", () => {
it("shimmers the thinking title only while reasoning is streaming", async () => {
const messages: ChatMessage[] = [
{
id: "user-before-streaming-reasoning",
sessionId: "session-1",
role: "user",
content: "Think this through",
createdAt: 1_000,
},
{
id: "streaming-reasoning",
sessionId: "session-1",
role: "assistant",
content: "",
reasoning: "Still considering the answer.",
createdAt: 2_000,
},
];
await renderMessages(messages, {
status: "running",
streamingMessageId: "streaming-reasoning",
});
const streamingTitle = container.querySelector(
".cline-chat-reasoning-trigger > span",
);
expect(
streamingTitle?.classList.contains("cline-chat-streaming-title"),
).toBe(true);
await renderMessages(messages, {
status: "completed",
streamingMessageId: null,
});
const completedTitle = container.querySelector(
".cline-chat-reasoning-trigger > span",
);
expect(
completedTitle?.classList.contains("cline-chat-streaming-title"),
).toBe(false);
});
it("shows elapsed thinking time with the border-left disclosure style", async () => {
await renderMessages([
{
id: "user-before-reasoning",
sessionId: "session-1",
role: "user",
content: "Solve this",
createdAt: 1_000,
},
{
id: "assistant-reasoning",
sessionId: "session-1",
role: "assistant",
content: "Done",
reasoning: "Carefully considered the request.",
createdAt: 7_500,
},
]);
const trigger = [...container.querySelectorAll("button")].find((element) =>
element.textContent?.includes("Thought for 7s"),
);
expect(trigger?.getAttribute("aria-expanded")).toBe("false");
expect(trigger?.querySelector(".lucide-brain")).not.toBeNull();
expect(trigger?.querySelector(".cline-chat-disclosure-icon")).toBeNull();
expect(trigger?.classList.contains("text-sm")).toBe(true);
expect(trigger?.classList.contains("text-xs")).toBe(false);
await act(async () => trigger?.click());
const content = container.querySelector(".cline-chat-reasoning-content");
expect(trigger?.getAttribute("aria-expanded")).toBe("true");
expect(content?.textContent).toContain("Carefully considered the request.");
expect(content?.classList.contains("border-l")).toBe(true);
expect(content?.classList.contains("rounded-none")).toBe(true);
expect(content?.classList.contains("bg-transparent")).toBe(true);
// Inset off the rail, without pinning the exact step — the shared-rail
// test owns the specific values.
expect(
[...(content?.classList ?? [])].some((name) => /^p[lx]-/.test(name)),
).toBe(true);
});
it("hangs expanded reasoning and tool panels off the same left rail", async () => {
await renderMessages([
{
id: "assistant-rail-reasoning",
sessionId: "session-1",
role: "assistant",
content: "Done",
reasoning: "Considered the request.",
createdAt: 2_000,
},
{
id: "tool-rail",
sessionId: "session-1",
role: "tool",
content: JSON.stringify({
toolName: "run_commands",
input: { commands: ["git status"] },
result: {},
}),
createdAt: 3_000,
},
]);
const reasoningTrigger = container.querySelector(
".cline-chat-reasoning-trigger",
) as HTMLButtonElement | null;
const toolTrigger = container.querySelector(
".cline-chat-tool-trigger",
) as HTMLButtonElement | null;
await act(async () => {
reasoningTrigger?.click();
toolTrigger?.click();
});
const reasoningContent = container.querySelector(
".cline-chat-reasoning-content",
);
const toolContent = container.querySelector(".cline-chat-tool-content");
expect(reasoningContent).not.toBeNull();
expect(toolContent).not.toBeNull();
// Compared as sets rather than pinned to literals, so retuning the rail
// stays a one-line change but can never drift between the two panels.
const railClasses = (element: Element | null) =>
[...(element?.classList ?? [])]
.filter((name) =>
/^(-?m[a-z]?|p[a-z]?|border|rounded|bg|max)-/.test(name),
)
.sort();
expect(railClasses(reasoningContent).length).toBeGreaterThan(0);
expect(railClasses(toolContent)).toEqual(railClasses(reasoningContent));
// Both panels are capped on both axes so neither can stretch the column.
for (const panel of [reasoningContent, toolContent]) {
const classes = [...(panel?.classList ?? [])];
expect(classes.some((name) => name.startsWith("max-h-"))).toBe(true);
expect(classes.some((name) => name.startsWith("max-w-"))).toBe(true);
}
// Reasoning wraps, so it scrolls Y only; tool output scrolls both axes.
expect(reasoningContent?.classList.contains("overflow-y-auto")).toBe(true);
expect(reasoningContent?.classList.contains("overflow-x-hidden")).toBe(
true,
);
expect(reasoningContent?.classList.contains("overflow-auto")).toBe(false);
expect(toolContent?.classList.contains("overflow-auto")).toBe(true);
expect(toolContent?.classList.contains("overflow-x-hidden")).toBe(false);
// The X axis is only reachable if the detail rows keep their lines intact.
const details = toolContent?.querySelector(".cline-chat-tool-details");
expect(details?.classList.contains("whitespace-pre")).toBe(true);
// The X axis stays live but loses its bar; reasoning has no X bar to hide.
expect(toolContent?.classList.contains("cline-chat-scroll-x-bare")).toBe(
true,
);
expect(
reasoningContent?.classList.contains("cline-chat-scroll-x-bare"),
).toBe(false);
});
it("keeps the reasoning panel inside the shape the hover-suppress rule targets", async () => {
await renderMessages([
{
id: "assistant-hover-scope",
sessionId: "session-1",
role: "assistant",
content: "Answer body",
reasoning: "Weighed the options.",
createdAt: 2_000,
},
// A trailing message keeps the assistant row off the always-visible
// last-message path, so its actions are genuinely hover-driven.
{
id: "user-after-hover-scope",
sessionId: "session-1",
role: "user",
content: "Follow-up",
createdAt: 3_000,
},
]);
const message = container.querySelector(
'.cline-chat-message[data-role="assistant"]',
);
const actions = message?.querySelector(
":scope > .cline-chat-message-actions",
);
expect(actions).not.toBeNull();
// globals.css suppresses the hover reveal via this selector plus `:hover`.
// jsdom has no pointer state, so assert the structural half: if the DOM is
// ever reshaped, the rule stops matching and the reveal silently returns.
expect(
actions?.matches(
".cline-chat-message:has(> .cline-chat-message-content .cline-chat-reasoning) > .cline-chat-message-actions:not([data-visible='true'])",
),
).toBe(true);
// The reveal itself must stay opt-out-able, i.e. driven by hover, not by a
// pinned data-visible that would defeat the suppression.
expect(actions?.getAttribute("data-visible")).toBeNull();
});
it("combines consecutive assistant reasoning into one disclosure", async () => {
await renderMessages([
{
id: "user-before-combined-reasoning",
sessionId: "session-1",
role: "user",
content: "Investigate this",
createdAt: 1_000,
},
{
id: "assistant-reasoning-first",
sessionId: "session-1",
role: "assistant",
content: "",
reasoning: "First reasoning segment.",
createdAt: 2_000,
},
{
id: "assistant-reasoning-second",
sessionId: "session-1",
role: "assistant",
content: "Investigation complete.",
reasoning: "Second reasoning segment.",
createdAt: 3_000,
},
]);
const disclosures = container.querySelectorAll(".cline-chat-reasoning");
expect(disclosures).toHaveLength(1);
const trigger = disclosures[0]?.querySelector("button");
expect(trigger?.textContent).toContain("Thought for 2s");
await act(async () => trigger?.click());
const content = disclosures[0]?.querySelector(
".cline-chat-reasoning-content",
);
const contentText = content?.textContent ?? "";
expect(contentText).toContain("First reasoning segment.");
expect(contentText).toContain("Second reasoning segment.");
expect(contentText.indexOf("First reasoning segment.")).toBeLessThan(
contentText.indexOf("Second reasoning segment."),
);
expect(container.textContent).toContain("Investigation complete.");
});
it("keeps reasoning disclosures separate across tool activity", async () => {
await renderMessages([
{
id: "user-before-separated-reasoning",
sessionId: "session-1",
role: "user",
content: "Investigate this",
createdAt: 1_000,
},
{
id: "assistant-reasoning-before-tool",
sessionId: "session-1",
role: "assistant",
content: "",
reasoning: "Reasoning before the tool.",
createdAt: 2_000,
},
{
id: "tool-between-reasoning",
sessionId: "session-1",
role: "tool",
content: "not-json",
createdAt: 2_500,
meta: { toolName: "search" },
},
{
id: "assistant-reasoning-after-tool",
sessionId: "session-1",
role: "assistant",
content: "Investigation complete.",
reasoning: "Reasoning after the tool.",
createdAt: 3_000,
},
]);
expect(container.querySelectorAll(".cline-chat-reasoning")).toHaveLength(2);
});
it("falls back to Thinking when there is no previous timestamp", async () => {
await renderMessages([
{
id: "first-reasoning",
sessionId: "session-1",
role: "assistant",
content: "",
reasoning: "Starting from scratch.",
createdAt: 1_000,
},
]);
expect(container.textContent).toContain("Thinking");
expect(container.textContent).not.toContain("Thought for");
});
});
describe("ChatMessages thinking indicator", () => {
const userMessage: ChatMessage = {
id: "user-1",
@@ -324,6 +1050,11 @@ describe("ChatMessages thinking indicator", () => {
it("shows while starting", async () => {
await renderMessages([userMessage], { status: "starting" });
expect(container.textContent).toContain("Thinking...");
expect(
[...container.querySelectorAll("span")]
.find((element) => element.textContent === "Thinking...")
?.classList.contains("cline-chat-streaming-title"),
).toBe(true);
});
it("keeps showing while running until the first assistant output arrives", async () => {
@@ -403,3 +1134,46 @@ describe("ChatMessages thinking indicator", () => {
expect(container.textContent).not.toContain("Thinking...");
});
});
describe("ChatMessages tool approvals", () => {
it("renders the shared card and forwards its decisions", async () => {
const onApprove = vi.fn();
const onReject = vi.fn();
await renderMessages(
[
{
id: "user-1",
sessionId: "session-1",
role: "user",
content: "Run pwd",
createdAt: 1,
},
],
{
onApproveToolApproval: onApprove,
onRejectToolApproval: onReject,
pendingToolApprovals: [
{
requestId: "req-1",
sessionId: "session-1",
createdAt: new Date(1).toISOString(),
toolCallId: "call-1",
toolName: "execute_command",
input: { command: "pwd" },
},
],
},
);
const card = container.querySelector(".cline-ui-agent-approval-card");
expect(card?.textContent).toContain("execute_command");
expect(card?.textContent).toContain('"command": "pwd"');
const [approve, reject] = card?.querySelectorAll("button") ?? [];
await act(async () => approve?.click());
expect(onApprove).toHaveBeenCalledWith("req-1");
await act(async () => reject?.click());
expect(onReject).toHaveBeenCalledWith("req-1");
});
});
File diff suppressed because it is too large Load Diff
@@ -1,147 +0,0 @@
"use client";
import { Check, Search } from "lucide-react";
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
/**
* A button-styled select whose menu is a searchable, filterable list the same
* interaction the workspace and branch pickers use. The trigger shows the
* current value with no chevron; clicking it opens the popover.
*/
export function SearchableSelect({
value,
items,
onSelect,
disabled = false,
ariaLabel,
searchPlaceholder = "Search...",
emptyLabel = "No results",
placeholder = "Select",
icon,
triggerClassName,
align = "start",
placement = "top",
}: {
value: string;
items: string[];
onSelect: (value: string) => void;
disabled?: boolean;
ariaLabel: string;
searchPlaceholder?: string;
emptyLabel?: string;
placeholder?: string;
icon?: ReactNode;
triggerClassName?: string;
align?: "start" | "end";
placement?: "top" | "bottom";
}) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
// Close on outside click; reset the filter each time the menu opens.
useEffect(() => {
if (!open) {
setSearch("");
return;
}
const handlePointerDown = (event: PointerEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setOpen(false);
}
};
// pointerdown in the capture phase so we still fire before a portaled menu
// (e.g. the Radix effort Select) handles its own trigger's pointerdown and
// calls preventDefault, which would otherwise suppress a mousedown listener.
document.addEventListener("pointerdown", handlePointerDown, true);
return () =>
document.removeEventListener("pointerdown", handlePointerDown, true);
}, [open]);
const filtered = useMemo(
() =>
items.filter((item) => item.toLowerCase().includes(search.toLowerCase())),
[items, search],
);
const handleSelect = (item: string) => {
if (item !== value) onSelect(item);
setOpen(false);
};
return (
<div className="relative" ref={containerRef}>
<button
aria-expanded={open}
aria-haspopup="listbox"
aria-label={ariaLabel}
className={cn(
"inline-flex items-center gap-1.5 rounded-md px-2 py-1 font-medium text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
triggerClassName,
)}
disabled={disabled}
onClick={() => setOpen((current) => !current)}
title={value}
type="button"
>
{icon}
<span className="truncate">{value || placeholder}</span>
</button>
{open && (
<div
className={cn(
"absolute z-50 w-64 rounded-lg border border-border bg-popover shadow-xl",
align === "end" ? "right-0" : "left-0",
placement === "top" ? "bottom-full mb-2" : "top-full mt-2",
)}
>
<div className="border-b border-border p-2">
<div className="flex items-center gap-2 rounded-md bg-background px-2.5 py-1.5">
<Search className="size-3 shrink-0 text-muted-foreground" />
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<Input
autoFocus
className="h-auto flex-1 border-0 bg-transparent px-0 py-0 text-xs shadow-none focus-visible:ring-0"
onChange={(event) => setSearch(event.target.value)}
placeholder={searchPlaceholder}
value={search}
/>
</div>
</div>
<div className="flex max-h-56 flex-col gap-0.5 overflow-y-auto p-1.5">
{filtered.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">
{emptyLabel}
</div>
) : (
filtered.map((item) => (
<button
className={cn(
"flex items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left transition-colors",
item === value ? "bg-accent" : "hover:bg-accent/50",
)}
key={item}
onClick={() => handleSelect(item)}
type="button"
>
<span className="truncate text-xs text-foreground">
{item}
</span>
{item === value && (
<Check className="ml-2 size-3 shrink-0 text-foreground" />
)}
</button>
))
)}
</div>
</div>
)}
</div>
);
}
@@ -30,6 +30,7 @@ afterEach(async () => {
async function renderWelcomeScreen({
workspaceRoot,
workspaces,
onStartChat = vi.fn(),
selectChat = vi.fn(async () => true),
onListGitBranches = vi.fn(async () => ({
current: "main",
@@ -38,6 +39,7 @@ async function renderWelcomeScreen({
}: {
workspaceRoot: string;
workspaces: string[];
onStartChat?: (prompt: string) => void;
selectChat?: () => Promise<boolean>;
onListGitBranches?: () => Promise<{
current: string;
@@ -63,7 +65,7 @@ async function renderWelcomeScreen({
composer={null}
gitBranch="main"
onListGitBranches={onListGitBranches}
onStartChat={vi.fn()}
onStartChat={onStartChat}
onSwitchGitBranch={vi.fn(async () => true)}
quickActions={[]}
/>
@@ -86,6 +88,21 @@ async function clickButton(text: string, last = false): Promise<void> {
}
describe("WelcomeScreen", () => {
it("starts chat with the selected quick-action prompt", async () => {
const onStartChat = vi.fn();
await renderWelcomeScreen({
onStartChat,
workspaceRoot: "/projects/project-1",
workspaces: ["/projects/project-1"],
});
await clickButton("Check for build errors");
expect(onStartChat).toHaveBeenCalledWith(
"Check this project for build errors and help me fix any failures.",
);
});
it("renders every known project in the opened workspace menu", async () => {
const workspaces = Array.from(
{ length: 6 },
@@ -96,6 +113,12 @@ describe("WelcomeScreen", () => {
workspaces,
});
expect(
container.querySelectorAll(".cline-ui-agent-aurora__star"),
).toHaveLength(32);
expect(
container.querySelector(".cline-ui-agent-hero-heading"),
).not.toBeNull();
await clickButton("project-1");
for (let index = 1; index <= workspaces.length; index += 1) {
@@ -1,79 +1,32 @@
"use client";
import { ArrowRight } from "lucide-react";
import {
AgentAurora,
AgentHeroHeading,
type AgentQuickAction,
AgentQuickActions,
} from "@cline/ui";
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import { AuroraBackground } from "@/components/ui/aurora-bg";
import { useEffect } from "react";
import { useWorkspace } from "@/contexts/workspace-context";
import { cn } from "@/lib/utils";
import { WelcomeWorkspaceControls } from "./welcome-workspace-controls";
interface QuickAction {
id: string;
label: string;
description: string;
prompt: string;
}
const HERO_VERBS = ["build", "create", "fix", "know"] as const;
const HERO_CYCLE_MS = 2600;
const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
const DEFAULT_QUICK_ACTIONS: AgentQuickAction[] = [
{
id: "review-changes",
label: "Review changes",
description: "Review the current changes and call out anything risky.",
prompt: "Review the current changes and call out anything risky.",
value: "Review the current changes and call out anything risky.",
},
{
id: "check-build",
label: "Check for build errors",
description: "Run the relevant checks and help me fix any failures.",
prompt: "Check this project for build errors and help me fix any failures.",
value: "Check this project for build errors and help me fix any failures.",
},
];
function HeroHeading() {
const [verbIndex, setVerbIndex] = useState(0);
useEffect(() => {
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
if (media.matches) return;
const interval = setInterval(() => {
setVerbIndex((prev) => (prev + 1) % HERO_VERBS.length);
}, HERO_CYCLE_MS);
return () => clearInterval(interval);
}, []);
const verb = HERO_VERBS[verbIndex];
return (
<h1
id="hero-header"
className="text-balance text-left text-[clamp(2rem,3vw,2.6rem)] font-semibold leading-[1.12] tracking-tight text-foreground"
>
<span className="sr-only">What would you like to build?</span>
<span aria-hidden="true">
What would you like to{" "}
{/* key remounts the word each cycle so the chars re-trigger their entrance */}
<span key={verb}>
{verb.split("").map((char, index) => (
<span
className="hero-word-char"
// biome-ignore lint/suspicious/noArrayIndexKey: the word remounts via the parent key each cycle, so char position is a stable, non-reordering identity
key={`${verb}-${index}`}
style={{ animationDelay: `${index * 45}ms` }}
>
{char}
</span>
))}
</span>
?
</span>
</h1>
);
}
export function WelcomeScreen({
active,
body,
@@ -88,7 +41,7 @@ export function WelcomeScreen({
body: ReactNode;
composer: ReactNode;
onStartChat: (prompt: string) => void;
quickActions: QuickAction[];
quickActions: AgentQuickAction[];
gitBranch: string;
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
onSwitchGitBranch: (branch: string) => Promise<boolean>;
@@ -116,7 +69,7 @@ export function WelcomeScreen({
: "contents",
)}
>
{active ? <AuroraBackground /> : null}
{active ? <AgentAurora /> : null}
<div
className={cn(
active
@@ -133,7 +86,7 @@ export function WelcomeScreen({
>
{active ? (
<>
<HeroHeading />
<AgentHeroHeading />
<div className="mt-11 flex min-w-0 items-center">
<WelcomeWorkspaceControls
@@ -166,28 +119,11 @@ export function WelcomeScreen({
</div>
{active ? (
<div className="mt-11 w-full divide-y divide-border/80 overflow-hidden rounded-xl border border-border/60 bg-background/95 px-2 shadow-sm">
{actions.map((action) => (
<button
className="group flex w-full items-center justify-between gap-5 px-3 py-3 text-left transition-colors hover:bg-background/55 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
key={action.id}
onClick={() => onStartChat(action.prompt)}
type="button"
>
<span className="min-w-0">
<span className="block text-[15px] font-medium text-foreground">
{action.label}
</span>
<span className="mt-0.5 block truncate text-sm text-muted-foreground">
{action.description}
</span>
</span>
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
<ArrowRight className="size-3" />
</span>
</button>
))}
</div>
<AgentQuickActions
actions={actions}
className="mt-11"
onSelect={(action) => onStartChat(action.value)}
/>
) : null}
</div>
</div>
@@ -1,5 +1,6 @@
"use client";
import { AgentAurora } from "@cline/ui";
import {
ArrowLeft,
CheckCircle2,
@@ -9,7 +10,6 @@ import {
LogIn,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { AuroraBackground } from "@/components/ui/aurora-bg";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -696,7 +696,7 @@ export function OnboardingView({
return (
<div className="relative flex h-full w-full items-center justify-center overflow-hidden bg-background p-6">
<AuroraBackground />
<AgentAurora />
{step === "welcome" ? (
<WelcomeStep onContinue={() => setStep("connect")} />
) : step === "connect" ? (
@@ -0,0 +1,370 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
formatCompactTokens,
paginationItems,
SessionsView,
} from "@/components/views/sessions/sessions-view";
import type { SessionThread } from "@/hooks/use-session-history";
import type { SessionHistoryItem } from "@/lib/session-history";
let container: HTMLDivElement;
let root: Root;
const thread: SessionThread = {
id: "session-1",
title: "help me rewrite this sql",
codebase: "ai-data-suite",
workspacePath: "/Users/dev/ai-data-suite",
time: "1d",
provider: "cline-pass",
model: "cline-pass/glm-5.2-with-a-very-long-identifier",
inputTokens: 13_837_938,
outputTokens: 132_579,
status: "completed",
};
const session: SessionHistoryItem = {
sessionId: thread.id,
status: "completed",
provider: thread.provider,
model: thread.model,
cwd: thread.workspacePath,
workspaceRoot: thread.workspacePath,
startedAt: new Date("2026-07-24T10:00:00Z").toISOString(),
endedAt: new Date("2026-07-26T10:00:00Z").toISOString(),
};
function renderView({
openThread = vi.fn(),
loadAllSessions = vi.fn(async () => true),
loadOlderSessions = vi.fn(),
mayHaveMoreSessions = false,
threads = [thread],
}: {
openThread?: ReturnType<typeof vi.fn>;
loadAllSessions?: ReturnType<typeof vi.fn>;
loadOlderSessions?: ReturnType<typeof vi.fn>;
mayHaveMoreSessions?: boolean;
threads?: SessionThread[];
} = {}) {
const history = {
deleteThread: vi.fn(),
forkThread: vi.fn(),
isLoadingHistory: false,
isLoadingMore: false,
loadAllSessions,
loadOlderSessions,
mayHaveMoreSessions,
openThread,
pendingAction: null,
renameThread: vi.fn(),
setThreadPinned: vi.fn(),
sessionById: new Map(
threads.map((item) => [item.id, { ...session, sessionId: item.id }]),
),
threads,
};
return {
history,
loadAllSessions,
loadOlderSessions,
openThread,
render: () =>
act(async () => {
root.render(
<SessionsView
history={
history as unknown as React.ComponentProps<
typeof SessionsView
>["history"]
}
/>,
);
}),
};
}
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.restoreAllMocks();
});
describe("formatCompactTokens", () => {
it("abbreviates millions and thousands and leaves small counts alone", () => {
expect(formatCompactTokens(13_837_938)).toBe("13.8m");
expect(formatCompactTokens(132_579)).toBe("132.6k");
expect(formatCompactTokens(17_218)).toBe("17.2k");
expect(formatCompactTokens(11)).toBe("11");
expect(formatCompactTokens(0)).toBe("0");
});
});
describe("SessionsView table", () => {
it("labels the title and time columns", async () => {
const view = renderView();
await view.render();
const headers = Array.from(
container.querySelectorAll("div > span:not(.sr-only)"),
)
.slice(0, 6)
.map((node) => node.textContent);
expect(headers).toEqual([
"Title",
"Workspace",
"Model",
"Tokens",
"Cost",
"Time",
]);
});
it("shows compact token counts and truncates the model to a fixed row height", async () => {
const view = renderView();
await view.render();
const row = container.querySelector<HTMLDivElement>('[role="button"]');
expect(row?.textContent).toContain("13.8m/132.6k");
const modelCell = Array.from(row?.children ?? []).find((node) =>
node.textContent?.includes(thread.model),
);
expect(modelCell?.className).toContain("truncate");
// Full value stays reachable on hover.
expect(modelCell?.getAttribute("title")).toBe(
`${thread.provider}:${thread.model}`,
);
expect(row?.parentElement?.className).toContain("h-14");
expect(row?.parentElement?.className).not.toContain("min-h-14");
});
it("marks favorited sessions with a star", async () => {
const plain = renderView();
await plain.render();
expect(container.querySelector('[aria-label="Favorited"]')).toBeNull();
await act(async () => root.unmount());
root = createRoot(container);
const favorited = renderView({
threads: [{ ...thread, pinned: true }],
});
await favorited.render();
expect(container.querySelector('[aria-label="Favorited"]')).not.toBeNull();
});
it("opens a session on click but not while text is selected", async () => {
const view = renderView({});
await view.render();
const row = container.querySelector<HTMLDivElement>('[role="button"]');
expect(row).not.toBeNull();
vi.spyOn(window, "getSelection").mockReturnValue({
toString: () => "rewrite this sql",
} as unknown as Selection);
await act(async () => {
row?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(view.openThread).not.toHaveBeenCalled();
vi.spyOn(window, "getSelection").mockReturnValue({
toString: () => "",
} as unknown as Selection);
await act(async () => {
row?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(view.openThread).toHaveBeenCalledWith(thread.id);
});
it("loads complete history before treating search results as exhaustive", async () => {
const view = renderView({ mayHaveMoreSessions: true });
await view.render();
const search = container.querySelector<HTMLInputElement>(
'input[aria-label="Search sessions"]',
);
expect(search).not.toBeNull();
await act(async () => {
if (search) {
const setValue = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)?.set;
setValue?.call(search, "older match");
search.dispatchEvent(new Event("input", { bubbles: true }));
}
});
expect(view.loadAllSessions).toHaveBeenCalledOnce();
});
it("loads complete history for filters and oldest-first sorting", async () => {
const view = renderView({ mayHaveMoreSessions: true });
await view.render();
const filterButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Filter sessions"]',
);
await act(async () => {
filterButton?.dispatchEvent(
new MouseEvent("pointerdown", {
bubbles: true,
cancelable: true,
button: 0,
}),
);
});
expect(view.loadAllSessions).toHaveBeenCalledOnce();
view.loadAllSessions.mockClear();
const sortButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Sort sessions"]',
);
await act(async () => {
sortButton?.dispatchEvent(
new MouseEvent("pointerdown", {
bubbles: true,
cancelable: true,
button: 0,
}),
);
});
const oldestItem = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'),
).find((item) => item.textContent === "Oldest first");
expect(oldestItem).not.toBeUndefined();
await act(async () => {
oldestItem?.click();
});
expect(view.loadAllSessions).toHaveBeenCalledOnce();
});
});
describe("SessionsView pagination", () => {
const manyThreads = Array.from({ length: 25 }, (_, index) => ({
...thread,
id: `session-${index}`,
title: `Session ${index}`,
}));
const rowTitles = () =>
Array.from(container.querySelectorAll('[role="button"]')).map(
(row) => row.querySelector("span > span:last-child")?.textContent,
);
const clickButton = async (label: string) => {
const button = container.querySelector<HTMLButtonElement>(
`button[aria-label="${label}"]`,
);
expect(button).not.toBeNull();
await act(async () => {
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
};
const clickNext = () => clickButton("Next page");
it("shows ten sessions per page", async () => {
const view = renderView({ threads: manyThreads });
await view.render();
expect(rowTitles()).toHaveLength(10);
expect(rowTitles()[0]).toBe("Session 0");
expect(container.textContent).toContain("1-10 of 25");
await clickNext();
expect(rowTitles()[0]).toBe("Session 10");
expect(container.textContent).toContain("11-20 of 25");
});
it("only asks the backend for older sessions at the last page", async () => {
const view = renderView({
threads: manyThreads,
mayHaveMoreSessions: true,
});
await view.render();
await clickNext();
await clickNext();
expect(view.loadOlderSessions).not.toHaveBeenCalled();
await clickNext();
expect(view.loadOlderSessions).toHaveBeenCalledTimes(1);
});
it("stays on the last page when no older sessions come back", async () => {
const view = renderView({
threads: manyThreads,
mayHaveMoreSessions: true,
});
await view.render();
await clickNext();
await clickNext();
await clickNext();
expect(container.textContent).toContain("21-25 of 25");
expect(rowTitles()).toHaveLength(5);
});
it("jumps to a numbered page and back to the first page in one click", async () => {
const view = renderView({ threads: manyThreads });
await view.render();
const pageButtons = Array.from(
container.querySelectorAll('button[aria-label^="Page "]'),
).map((button) => button.textContent);
expect(pageButtons).toEqual(["1", "2", "3"]);
expect(container.textContent).not.toContain("Page 1 of");
await clickButton("Page 3");
expect(rowTitles()[0]).toBe("Session 20");
expect(
container
.querySelector('button[aria-label="Page 3"]')
?.getAttribute("aria-current"),
).toBe("page");
await clickButton("First page");
expect(rowTitles()[0]).toBe("Session 0");
expect(
container.querySelector<HTMLButtonElement>(
'button[aria-label="First page"]',
)?.disabled,
).toBe(true);
});
});
describe("paginationItems", () => {
it("lists every page while the pager is short", () => {
expect(paginationItems(1, 3)).toEqual([1, 2, 3]);
expect(paginationItems(4, 7)).toEqual([1, 2, 3, 4, 5, 6, 7]);
});
it("keeps first, last and a window around the current page", () => {
expect(paginationItems(1, 12)).toEqual([1, 2, 3, 4, 5, "gap-end", 12]);
expect(paginationItems(6, 12)).toEqual([
1,
"gap-start",
5,
6,
7,
"gap-end",
12,
]);
expect(paginationItems(12, 12)).toEqual([1, "gap-start", 8, 9, 10, 11, 12]);
});
});
@@ -4,6 +4,9 @@ import { SessionStatus } from "@cline/ui";
import {
ArrowUpDown,
Check,
ChevronLeft,
ChevronRight,
ChevronsLeft,
Filter,
Folder,
GitFork,
@@ -11,10 +14,11 @@ import {
MoreHorizontal,
Pencil,
Search,
Star,
Trash2,
X,
} from "lucide-react";
import { type CSSProperties, useMemo, useState } from "react";
import { type CSSProperties, useEffect, useMemo, useState } from "react";
import {
AlertDialog,
AlertDialogAction,
@@ -54,6 +58,8 @@ type SessionsViewProps = {
history: UseSessionHistoryResult;
};
const PAGE_SIZE = 10;
function modelLabel(thread: SessionThread): string {
if (thread.provider && thread.model) {
return `${thread.provider}:${thread.model}`;
@@ -61,11 +67,61 @@ function modelLabel(thread: SessionThread): string {
return thread.model || thread.provider || "No model";
}
export function formatCompactTokens(value: number): string {
if (!Number.isFinite(value) || value < 0) {
return "0";
}
if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(1)}m`;
}
if (value >= 1_000) {
return `${(value / 1_000).toFixed(1)}k`;
}
return `${value}`;
}
const MAX_PAGE_BUTTONS = 7;
/**
* Page buttons for a 1-indexed pager: every page while the list is short, and
* first/last plus a window around the current page once it grows.
*/
export function paginationItems(
currentPage: number,
pageCount: number,
): Array<number | "gap-start" | "gap-end"> {
if (pageCount <= MAX_PAGE_BUTTONS) {
return Array.from({ length: pageCount }, (_, index) => index + 1);
}
const windowStart = Math.max(
2,
Math.min(currentPage - 1, pageCount - MAX_PAGE_BUTTONS + 3),
);
const windowEnd = Math.min(
pageCount - 1,
Math.max(currentPage + 1, MAX_PAGE_BUTTONS - 2),
);
const items: Array<number | "gap-start" | "gap-end"> = [1];
if (windowStart > 2) {
items.push("gap-start");
}
for (let page = windowStart; page <= windowEnd; page += 1) {
items.push(page);
}
if (windowEnd < pageCount - 1) {
items.push("gap-end");
}
items.push(pageCount);
return items;
}
function tokensLabel(thread: SessionThread): string {
if (thread.inputTokens == null && thread.outputTokens == null) {
return "-";
}
return `${thread.inputTokens ?? 0}/${thread.outputTokens ?? 0}`;
const input = formatCompactTokens(thread.inputTokens ?? 0);
const output = formatCompactTokens(thread.outputTokens ?? 0);
return `${input}/${output}`;
}
function sessionFilterDetails(
@@ -75,6 +131,7 @@ function sessionFilterDetails(
const workspacePath = session?.workspaceRoot || session?.cwd || "";
const workspace = workspacePath ? basenamePath(workspacePath) : "";
return [
thread.pinned ? "favorite:yes" : undefined,
workspace ? `workspace:${workspace}` : undefined,
thread.status ? `status:${thread.status}` : undefined,
thread.provider ? `provider:${thread.provider}` : undefined,
@@ -101,6 +158,22 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
const [deleteCandidate, setDeleteCandidate] = useState<SessionThread | null>(
null,
);
const [page, setPage] = useState(0);
const requiresCompleteHistory =
query.trim().length > 0 ||
sessionFilters.length > 0 ||
sortDirection === "oldest";
useEffect(() => {
if (!requiresCompleteHistory || !history.mayHaveMoreSessions) {
return;
}
void history.loadAllSessions();
}, [
history.loadAllSessions,
history.mayHaveMoreSessions,
requiresCompleteHistory,
]);
const filterOptions = useMemo(
() =>
@@ -154,6 +227,45 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
sortDirection,
]);
const pageCount = Math.max(1, Math.ceil(filteredThreads.length / PAGE_SIZE));
const currentPage = Math.min(page, pageCount - 1);
const pageStart = currentPage * PAGE_SIZE;
const visibleThreads = useMemo(
() => filteredThreads.slice(pageStart, pageStart + PAGE_SIZE),
[filteredThreads, pageStart],
);
const canGoNext =
currentPage + 1 < pageCount ||
(history.mayHaveMoreSessions && !requiresCompleteHistory);
// Snap back when a page disappears (filters changed, or "next" asked the
// backend for older sessions and there were none left).
useEffect(() => {
setPage((current) => Math.min(current, pageCount - 1));
}, [pageCount]);
// biome-ignore lint/correctness/useExhaustiveDependencies: restart paging whenever the result set changes
useEffect(() => {
setPage(0);
}, [query, sessionFilters, sortDirection]);
const goToNextPage = async () => {
const nextPage = currentPage + 1;
if (
nextPage >= pageCount &&
history.mayHaveMoreSessions &&
!requiresCompleteHistory
) {
// Only page boundaries hit the backend; the mount fetch stays small.
// Stay put when the fetch fails so the user keeps the page they can
// see and the same click retries.
if (!(await history.loadOlderSessions())) {
return;
}
}
setPage(nextPage);
};
const toggleFilter = (detail: string, checked: boolean) => {
setSessionFilters((current) => {
if (checked) {
@@ -163,6 +275,17 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
});
};
const openRow = (thread: SessionThread) => {
if (history.pendingAction?.sessionId === thread.id) {
return;
}
// Don't open the session when the click only finished a text selection.
if (window.getSelection()?.toString()) {
return;
}
history.openThread(thread.id);
};
const startRename = (thread: SessionThread) => {
setEditingSessionId(thread.id);
setEditingTitle(thread.title);
@@ -234,7 +357,17 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenu
onOpenChange={(open) => {
// Filter choices are derived from the loaded rows, so
// complete the history as soon as the user opens this
// menu. This keeps both the options and their results
// global rather than limited to the newest batch.
if (open && history.mayHaveMoreSessions) {
void history.loadAllSessions();
}
}}
>
<DropdownMenuTrigger asChild>
<Button
aria-label="Filter sessions"
@@ -285,13 +418,13 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
<section className="min-h-0 flex-1 overflow-auto px-18 pb-10 max-[1200px]:px-8 max-[720px]:px-4">
<div className="min-w-240 overflow-hidden rounded-lg border bg-card">
<div className="grid grid-cols-[minmax(14rem,1.35fr)_minmax(9rem,0.8fr)_minmax(12rem,1fr)_7rem_5rem_6rem_2.5rem] gap-x-4 bg-muted/40 px-4 py-3 text-sm font-medium text-muted-foreground">
<span>Session</span>
<div className="grid grid-cols-[minmax(14rem,1.35fr)_minmax(9rem,0.8fr)_minmax(12rem,1fr)_7rem_5rem_6rem_1.75rem] gap-x-4 bg-muted/40 px-4 py-3 text-sm font-medium text-muted-foreground">
<span>Title</span>
<span>Workspace</span>
<span>Model</span>
<span>Tokens</span>
<span>Cost</span>
<span>Updated</span>
<span>Time</span>
<span className="sr-only">Actions</span>
</div>
<div>
@@ -308,7 +441,7 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
: "No sessions match the current filters."}
</div>
) : null}
{filteredThreads.map((thread) => {
{visibleThreads.map((thread) => {
const session = history.sessionById.get(thread.id);
const isEditing = editingSessionId === thread.id;
const isPending = history.pendingAction?.sessionId === thread.id;
@@ -322,7 +455,9 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
return (
<div
className={cn(
"grid min-h-14 grid-cols-[minmax(14rem,1.35fr)_minmax(9rem,0.8fr)_minmax(12rem,1fr)_7rem_5rem_6rem_2.5rem] items-center gap-x-4 border-t px-4 py-3 text-sm transition-colors",
// Fixed height: every row is the same size so the table
// never reflows as long values wrap or hydrate in.
"grid h-14 grid-cols-[minmax(14rem,1.35fr)_minmax(9rem,0.8fr)_minmax(12rem,1fr)_7rem_5rem_6rem_1.75rem] items-center gap-x-4 border-t px-4 text-sm transition-colors",
activeSessionId === thread.id
? "bg-accent/50"
: "hover:bg-accent/30",
@@ -381,7 +516,10 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
<X className="size-4" />
</Button>
</div>
<span className="truncate text-muted-foreground">
<span
className="truncate text-muted-foreground"
title={modelLabel(thread)}
>
{modelLabel(thread)}
</span>
<span className="text-muted-foreground">
@@ -395,20 +533,27 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
</span>
</form>
) : (
<button
className="col-span-6 grid cursor-pointer select-text grid-cols-[minmax(14rem,1.35fr)_minmax(9rem,0.8fr)_minmax(12rem,1fr)_7rem_5rem_6rem] items-center gap-x-4 border-0 bg-transparent p-0 text-left font-inherit text-inherit focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-default"
disabled={Boolean(pendingKind)}
onClick={() => {
if (pendingKind) {
// A native <button> suppresses drag-to-select, so the row is a
// plain container with button semantics: the cells stay
// selectable and a click that ends a selection does not open
// the session.
// biome-ignore lint/a11y/useSemanticElements: buttons are not text-selectable
<div
aria-disabled={Boolean(pendingKind)}
className={cn(
"col-span-6 grid select-text grid-cols-[minmax(14rem,1.35fr)_minmax(9rem,0.8fr)_minmax(12rem,1fr)_7rem_5rem_6rem] items-center gap-x-4 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
pendingKind ? "cursor-default" : "cursor-pointer",
)}
onClick={() => openRow(thread)}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") {
return;
}
// Don't open the session when the user is selecting text.
if (window.getSelection()?.toString()) {
return;
}
history.openThread(thread.id);
event.preventDefault();
openRow(thread);
}}
type="button"
role="button"
tabIndex={pendingKind ? -1 : 0}
>
<span className="flex min-w-0 items-center gap-3 font-semibold">
<span className="sr-only">Open session: </span>
@@ -425,6 +570,12 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
tone={sessionStatusTone(thread.status)}
/>
<span className="truncate">{thread.title}</span>
{thread.pinned ? (
<Star
aria-label="Favorited"
className="size-3.5 shrink-0 fill-current text-muted-foreground"
/>
) : null}
</span>
<span className="flex min-w-0 items-center gap-2 text-muted-foreground">
<Folder className="size-3.5 shrink-0" />
@@ -432,19 +583,22 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
{workspace ? basenamePath(workspace) : "No workspace"}
</span>
</span>
<span className="truncate text-muted-foreground">
<span
className="truncate text-muted-foreground"
title={modelLabel(thread)}
>
{modelLabel(thread)}
</span>
<span className="text-muted-foreground">
<span className="truncate text-muted-foreground">
{tokensLabel(thread)}
</span>
<span className="text-muted-foreground">
<span className="truncate text-muted-foreground">
{formatCostUsd(thread.totalCostUsd) ?? "-"}
</span>
<span className="text-muted-foreground">
<span className="truncate text-muted-foreground">
{updated || thread.time}
</span>
</button>
</div>
)}
<div>
<DropdownMenu>
@@ -463,6 +617,22 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={6}>
<DropdownMenuItem
onClick={() =>
void history.setThreadPinned(
thread.id,
!thread.pinned,
)
}
>
<Star
className={cn(
"size-4",
thread.pinned && "fill-current",
)}
/>
{thread.pinned ? "Unfavorite" : "Favorite"}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => startRename(thread)}>
<Pencil className="size-4" />
Rename
@@ -487,22 +657,81 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
</div>
);
})}
{history.mayHaveMoreSessions ? (
<div className="border-t px-4 py-3">
<Button
className="h-8 rounded-md px-3 text-xs"
disabled={history.isLoadingMore}
onClick={() =>
void history.loadMoreSessions(history.threads.length + 100)
}
type="button"
variant="outline"
>
{history.isLoadingMore ? (
<Loader2 className="size-3.5 animate-spin" />
) : null}
Load more
</Button>
{filteredThreads.length > 0 ? (
<div className="flex items-center justify-between gap-4 border-t px-4 py-3 text-xs text-muted-foreground">
<span>
{`${pageStart + 1}-${pageStart + visibleThreads.length} of ${filteredThreads.length}`}
{history.mayHaveMoreSessions ? "+" : ""}
</span>
<div className="flex items-center gap-1">
<Button
aria-label="First page"
className="h-8 rounded-md px-2.5"
disabled={currentPage === 0 || history.isLoadingMore}
onClick={() => setPage(0)}
size="sm"
title="First page"
type="button"
variant="outline"
>
<ChevronsLeft className="size-4" />
</Button>
<Button
aria-label="Previous page"
className="h-8 rounded-md px-2.5"
disabled={currentPage === 0 || history.isLoadingMore}
onClick={() => setPage(currentPage - 1)}
size="sm"
title="Previous page"
type="button"
variant="outline"
>
<ChevronLeft className="size-4" />
</Button>
{paginationItems(currentPage + 1, pageCount).map((item) =>
typeof item === "number" ? (
<Button
aria-current={
item === currentPage + 1 ? "page" : undefined
}
aria-label={`Page ${item}`}
className="h-8 min-w-8 rounded-md px-2 tabular-nums"
disabled={history.isLoadingMore}
key={item}
onClick={() => setPage(item - 1)}
size="sm"
type="button"
variant={item === currentPage + 1 ? "default" : "ghost"}
>
{item}
</Button>
) : (
<span
aria-hidden="true"
className="px-1 text-muted-foreground"
key={item}
>
...
</span>
),
)}
<Button
aria-label="Next page"
className="h-8 rounded-md px-2.5"
disabled={!canGoNext || history.isLoadingMore}
onClick={() => void goToNextPage()}
size="sm"
title="Next page"
type="button"
variant="outline"
>
{history.isLoadingMore ? (
<Loader2 className="size-4 animate-spin" />
) : (
<ChevronRight className="size-4" />
)}
</Button>
</div>
</div>
) : null}
</div>
@@ -87,6 +87,19 @@ export type ChatApiResult = {
messages?: unknown[];
};
export type ChatSessionCommandResponse = {
sessionId?: string;
cwd?: string;
workspaceRoot?: string;
result?: ChatApiResult;
ok?: boolean;
queued?: boolean;
promptsInQueue?: PromptInQueue[];
prompt?: PromptInQueue;
updated?: boolean;
removed?: boolean;
};
export type ChatWsResponseEvent = {
type: "chat_response";
requestId: string;
@@ -104,6 +104,16 @@ describe("useChatSession", () => {
config: expect.objectContaining({ cwd: "", workspaceRoot: "" }),
}),
});
expect(invokeMock).toHaveBeenCalledWith(
"chat_session_command",
{
request: expect.objectContaining({
action: "send",
prompt: "Start the task",
}),
},
{ timeoutMs: null },
);
});
it("preserves server validation errors", async () => {
@@ -465,6 +475,56 @@ describe("useChatSession", () => {
]);
});
it("keeps live stream timestamps in milliseconds", async () => {
invokeMock.mockImplementation(
async (command: string, args?: Record<string, unknown>) => {
if (command === "get_process_context") {
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
}
if (command === "chat_session_command") {
const request = args?.request as
| { action?: string; config?: { sessionId?: string } }
| undefined;
if (request?.action === "start") {
return { sessionId: request.config?.sessionId };
}
if (request?.action === "send") {
return { ok: true };
}
}
return [];
},
);
await act(async () => {
await current.sendPrompt("Think about this");
});
const chatEventHandler = subscribeMock.mock.calls.find(
([eventName]) => eventName === "chat_event",
)?.[1] as ((payload: unknown) => void) | undefined;
const userMessage = current.messages.find(
(message) => message.role === "user",
);
expect(chatEventHandler).toBeDefined();
expect(userMessage).toBeDefined();
const thinkingTimestamp = (userMessage?.createdAt ?? Date.now()) + 5_000;
await act(async () => {
chatEventHandler?.({
sessionId: current.sessionId,
stream: "chat_reasoning",
chunk: JSON.stringify({ text: "Considering the request." }),
ts: thinkingTimestamp,
index: 42,
});
});
expect(
current.messages.find((message) => message.role === "assistant")
?.createdAt,
).toBe(thinkingTimestamp);
});
it("returns to a completed status when a queued turn finishes via chat_done", async () => {
invokeMock.mockImplementation(
async (command: string, args?: Record<string, unknown>) => {
@@ -18,6 +18,7 @@ import type {
AgentChunkEvent,
AskQuestionRequestItem,
ChatApiResult,
ChatSessionCommandResponse,
ChatSessionHookEvent,
ChatTransportState,
CoreLogChunk,
@@ -132,9 +133,7 @@ function sortMessagesChronologically(messages: ChatMessage[]): ChatMessage[] {
}
function chunkCreatedAt(payload: AgentChunkEvent): number {
const ts = payload.ts || Date.now();
const index = payload.index ?? 0;
return ts * 1000 + index;
return payload.ts || Date.now();
}
function mergeHydratedMessagesWithLive(options: {
@@ -331,18 +330,18 @@ export function useChatSession() {
// ---- Data fetching ----
const postSession = useCallback(async (body: Record<string, unknown>) => {
return await desktopClient.invoke<{
sessionId?: string;
cwd?: string;
workspaceRoot?: string;
result?: ChatApiResult;
ok?: boolean;
queued?: boolean;
promptsInQueue?: PromptInQueue[];
prompt?: PromptInQueue;
updated?: boolean;
removed?: boolean;
}>("chat_session_command", { request: body });
const request = { request: body };
if (body.action === "send") {
return await desktopClient.invoke<ChatSessionCommandResponse>(
"chat_session_command",
request,
{ timeoutMs: null },
);
}
return await desktopClient.invoke<ChatSessionCommandResponse>(
"chat_session_command",
request,
);
}, []);
const refreshPromptsInQueue = useCallback(
@@ -1938,44 +1937,50 @@ export function useChatSession() {
],
);
const forkSession = useCallback(async (): Promise<{
newSessionId: string;
forkedFromSessionId: string;
messages: ChatMessage[];
}> => {
const activeSessionId = activeSessionIdRef.current;
if (!activeSessionId) {
throw new Error("No active session to fork.");
}
if (BUSY_STATUSES.has(status)) {
throw new Error("Wait for the current turn to finish before forking.");
}
const payload = (await postSession({
action: "fork",
sessionId: activeSessionId,
config,
})) as {
sessionId?: string;
forkedFromSessionId?: string;
messages?: ChatMessage[];
};
const newSessionId =
typeof payload.sessionId === "string" ? payload.sessionId.trim() : "";
if (!newSessionId) {
throw new Error("Fork did not return a new session id.");
}
const forkedFromSessionId =
typeof payload.forkedFromSessionId === "string"
? payload.forkedFromSessionId
: activeSessionId;
const nextMessages = Array.isArray(payload.messages)
? (payload.messages as ChatMessage[])
: await desktopClient.invoke<ChatMessage[]>("read_session_messages", {
sessionId: newSessionId,
maxMessages: MAX_MESSAGES,
});
return { newSessionId, forkedFromSessionId, messages: nextMessages };
}, [config, postSession, status]);
const forkSession = useCallback(
async (options?: {
beforeRunCount?: number;
}): Promise<{
newSessionId: string;
forkedFromSessionId: string;
messages: ChatMessage[];
}> => {
const activeSessionId = activeSessionIdRef.current;
if (!activeSessionId) {
throw new Error("No active session to fork.");
}
if (BUSY_STATUSES.has(status)) {
throw new Error("Wait for the current turn to finish before forking.");
}
const payload = (await postSession({
action: "fork",
sessionId: activeSessionId,
config,
forkBeforeRunCount: options?.beforeRunCount,
})) as {
sessionId?: string;
forkedFromSessionId?: string;
messages?: ChatMessage[];
};
const newSessionId =
typeof payload.sessionId === "string" ? payload.sessionId.trim() : "";
if (!newSessionId) {
throw new Error("Fork did not return a new session id.");
}
const forkedFromSessionId =
typeof payload.forkedFromSessionId === "string"
? payload.forkedFromSessionId
: activeSessionId;
const nextMessages = Array.isArray(payload.messages)
? (payload.messages as ChatMessage[])
: await desktopClient.invoke<ChatMessage[]>("read_session_messages", {
sessionId: newSessionId,
maxMessages: MAX_MESSAGES,
});
return { newSessionId, forkedFromSessionId, messages: nextMessages };
},
[config, postSession, status],
);
const steerPromptInQueue = useCallback(
async (promptId: string) => {
@@ -0,0 +1,325 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useSessionHistory } from "./use-session-history";
const { invokeMock, subscribeMock } = vi.hoisted(() => ({
invokeMock: vi.fn(),
subscribeMock: vi.fn(() => () => undefined),
}));
vi.mock("@/lib/desktop-client", () => ({
desktopClient: {
invoke: invokeMock,
subscribe: subscribeMock,
},
}));
type SessionHistoryHook = ReturnType<typeof useSessionHistory>;
type PendingList = {
limit: number;
resolve: (rows: unknown[]) => void;
reject: (error: Error) => void;
};
function sessionRow(sessionId: string) {
return {
sessionId,
status: "completed",
provider: "cline",
model: "glm-5.2",
cwd: "/workspace",
workspaceRoot: "/workspace",
startedAt: "2026-07-20T10:00:00.000Z",
endedAt: "2026-07-20T11:00:00.000Z",
};
}
let container: HTMLDivElement;
let root: Root;
let current: SessionHistoryHook;
let pendingLists: PendingList[];
function HookHarness() {
current = useSessionHistory({});
return null;
}
/** Runs queued timers and lets the resulting promise chains settle. */
async function flush(ms = 1) {
await act(async () => {
vi.advanceTimersByTime(ms);
await Promise.resolve();
});
}
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
vi.useFakeTimers();
pendingLists = [];
invokeMock.mockReset();
subscribeMock.mockClear();
invokeMock.mockImplementation(
async (command: string, args?: { limit?: number }) => {
if (command === "list_discovered_sessions") {
return await new Promise<unknown[]>((resolve, reject) => {
pendingLists.push({ limit: args?.limit ?? 0, resolve, reject });
});
}
return [];
},
);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.useRealTimers();
vi.restoreAllMocks();
});
describe("useSessionHistory refresh coalescing", () => {
it("reuses an in-flight refresh that already covers the requested limit", async () => {
await act(async () => {
root.render(<HookHarness />);
});
await flush();
expect(pendingLists).toHaveLength(1);
expect(pendingLists[0].limit).toBe(50);
let second: Promise<boolean> | undefined;
await act(async () => {
second = current.refreshSessions();
});
expect(pendingLists).toHaveLength(1);
await act(async () => {
pendingLists[0].resolve([]);
await second;
});
});
it("does not let an in-flight smaller refresh satisfy a load-more", async () => {
await act(async () => {
root.render(<HookHarness />);
});
await flush();
expect(pendingLists).toHaveLength(1);
expect(pendingLists[0].limit).toBe(50);
// Click "next" while the periodic refresh is still running.
let loadMore: Promise<boolean> | undefined;
await act(async () => {
loadMore = current.loadMoreSessions(100);
});
// The in-flight request only asked for 50 rows, so it must not be
// reused: the larger batch has to be requested before load-more resolves.
await act(async () => {
pendingLists[0].resolve([]);
await Promise.resolve();
});
expect(pendingLists).toHaveLength(2);
expect(pendingLists[1].limit).toBe(100);
let settled = false;
void loadMore?.then(() => {
settled = true;
});
await act(async () => {
await Promise.resolve();
});
expect(settled).toBe(false);
await act(async () => {
pendingLists[1].resolve([]);
await loadMore;
});
expect(pendingLists).toHaveLength(2);
});
});
describe("useSessionHistory failed refresh", () => {
async function renderWithSessions() {
await act(async () => {
root.render(<HookHarness />);
});
await flush();
await act(async () => {
pendingLists[0].resolve(
Array.from({ length: 50 }, (_, index) =>
sessionRow(`session-${index}`),
),
);
await Promise.resolve();
});
}
it("keeps the loaded history when the list request fails", async () => {
await renderWithSessions();
expect(current.sessions).toHaveLength(50);
expect(current.mayHaveMoreSessions).toBe(true);
let loadMore: Promise<boolean> | undefined;
await act(async () => {
loadMore = current.loadMoreSessions(100);
await Promise.resolve();
});
expect(pendingLists[1].limit).toBe(100);
let loaded: boolean | undefined;
await act(async () => {
pendingLists[1].reject(new Error("transport closed"));
loaded = await loadMore;
});
// A rejected request must not read as "no sessions": the list stays put
// and the backend is still considered to have more.
expect(loaded).toBe(false);
expect(current.sessions).toHaveLength(50);
expect(current.threads).toHaveLength(50);
expect(current.mayHaveMoreSessions).toBe(true);
});
it("does not lower a limit an overlapping call already raised", async () => {
await renderWithSessions();
// Two "next page" clicks overlap: the first expands to 100 and is still
// in flight when the second expands to 150.
let first: Promise<boolean> | undefined;
let second: Promise<boolean> | undefined;
await act(async () => {
first = current.loadMoreSessions(100);
await Promise.resolve();
second = current.loadMoreSessions(150);
await Promise.resolve();
});
expect(pendingLists).toHaveLength(2);
expect(pendingLists[1].limit).toBe(100);
// The 100-row request fails. Rolling the shared limit back to 50 here
// would make the waiting call fetch 50 rows and still report success.
await act(async () => {
pendingLists[1].reject(new Error("transport closed"));
expect(await first).toBe(false);
await Promise.resolve();
});
expect(pendingLists).toHaveLength(3);
expect(pendingLists[2].limit).toBe(150);
await act(async () => {
pendingLists[2].resolve(
Array.from({ length: 150 }, (_, index) =>
sessionRow(`session-${index}`),
),
);
expect(await second).toBe(true);
});
expect(current.sessions).toHaveLength(150);
});
it("retries the oldest unfetched batch when overlapping calls both fail", async () => {
await renderWithSessions();
let first: Promise<boolean> | undefined;
let second: Promise<boolean> | undefined;
await act(async () => {
first = current.loadMoreSessions(100);
await Promise.resolve();
second = current.loadMoreSessions(150);
await Promise.resolve();
});
await act(async () => {
pendingLists[1].reject(new Error("transport closed"));
expect(await first).toBe(false);
await Promise.resolve();
});
await act(async () => {
pendingLists[2].reject(new Error("transport closed"));
expect(await second).toBe(false);
await Promise.resolve();
});
// Neither batch landed, so the next attempt must go back to the first
// unfetched one (100) rather than resuming from a limit that was only
// ever requested.
await act(async () => {
const retry = current.loadOlderSessions();
await Promise.resolve();
pendingLists[3].resolve([]);
await retry;
});
expect(pendingLists[3].limit).toBe(100);
});
it("retries the same batch after a failure instead of skipping it", async () => {
await renderWithSessions();
await act(async () => {
const attempt = current.loadMoreSessions(100);
await Promise.resolve();
pendingLists[1].reject(new Error("transport closed"));
await attempt;
});
await act(async () => {
const retry = current.loadOlderSessions();
await Promise.resolve();
pendingLists[2].resolve([]);
await retry;
});
expect(pendingLists[2].limit).toBe(100);
});
});
describe("useSessionHistory complete history loading", () => {
it("expands requests until the backend returns fewer rows than requested", async () => {
await act(async () => {
root.render(<HookHarness />);
});
await flush();
await act(async () => {
pendingLists[0].resolve(
Array.from({ length: 50 }, (_, index) =>
sessionRow(`session-${index}`),
),
);
await Promise.resolve();
});
let complete: Promise<boolean> | undefined;
await act(async () => {
complete = current.loadAllSessions();
await Promise.resolve();
});
expect(pendingLists[1].limit).toBe(100);
await act(async () => {
pendingLists[1].resolve(
Array.from({ length: 100 }, (_, index) =>
sessionRow(`session-${index}`),
),
);
await Promise.resolve();
});
expect(pendingLists[2].limit).toBe(200);
await act(async () => {
pendingLists[2].resolve(
Array.from({ length: 120 }, (_, index) =>
sessionRow(`session-${index}`),
),
);
expect(await complete).toBe(true);
});
expect(current.sessions).toHaveLength(120);
expect(current.mayHaveMoreSessions).toBe(false);
});
});
@@ -12,8 +12,10 @@ import type {
} from "@/lib/session-history";
import {
getSessionMetadataGitBranch,
getSessionMetadataPinned,
getSessionMetadataTitle,
getSessionSource,
PINNED_METADATA_KEY,
} from "@/lib/session-history";
type CliDiscoveredSession = Omit<SessionHistoryItem, "status"> & {
@@ -92,7 +94,10 @@ export type UseSessionHistoryOptions = {
) => void;
};
const INITIAL_HISTORY_FETCH_LIMIT = 300;
// Kept small on purpose: the sidebar shows 10 threads and the sessions view
// pages 10 at a time, so the mount fetch (and every 12s poll after it) only
// needs enough rows for the first few pages. Older pages are fetched on demand.
const INITIAL_HISTORY_FETCH_LIMIT = 50;
const HISTORY_REFRESH_INTERVAL_MS = 12_000;
const MIN_EVENT_HISTORY_REFRESH_INTERVAL_MS = 2_000;
const HISTORY_EVENT_REFRESH_DELAY_MS = 1_000;
@@ -255,6 +260,7 @@ function toThread(session: SessionHistoryItem): SessionThread {
model: session.model || "",
gitBranch: getSessionMetadataGitBranch(session.metadata) || undefined,
status: normalizeDiscoveredStatus(session.status, session.prompt),
pinned: getSessionMetadataPinned(session.metadata),
};
}
@@ -356,6 +362,8 @@ function areSessionsEquivalent(
getSessionMetadataGitBranch(b.metadata) ||
getSessionMetadataTitle(a.metadata) !==
getSessionMetadataTitle(b.metadata) ||
getSessionMetadataPinned(a.metadata) !==
getSessionMetadataPinned(b.metadata) ||
a.workspaceRoot !== b.workspaceRoot ||
a.cwd !== b.cwd ||
a.provider !== b.provider ||
@@ -480,12 +488,18 @@ export function useSessionHistory({
const [threads, setThreads] = useState<SessionThread[]>([]);
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [mayHaveMoreSessions, setMayHaveMoreSessions] = useState(false);
const [pendingAction, setPendingAction] =
useState<SessionPendingAction>(null);
const [unreadSessionIds, setUnreadSessionIds] = useState<Set<string>>(
() => new Set(),
);
const fetchLimitRef = useRef(INITIAL_HISTORY_FETCH_LIMIT);
// Limit of the most recent refresh that actually returned sessions. Failed
// attempts roll back to this rather than to a caller-local snapshot, which
// may itself name a batch that was never fetched.
const loadedLimitRef = useRef(0);
const mayHaveMoreSessionsRef = useRef(false);
const usageLoadingRef = useRef<Set<string>>(new Set());
const usageHydratedStatusRef = useRef<Map<string, SessionHistoryStatus>>(
new Map(),
@@ -498,7 +512,9 @@ export function useSessionHistory({
const threadsRef = useRef<SessionThread[]>([]);
const refreshTimeoutRef = useRef<number | null>(null);
const scheduledRefreshAtRef = useRef<number | null>(null);
const refreshPromiseRef = useRef<Promise<void> | null>(null);
const refreshPromiseRef = useRef<Promise<boolean> | null>(null);
const refreshLimitRef = useRef(0);
const loadAllPromiseRef = useRef<Promise<boolean> | null>(null);
const lastRefreshStartedAtRef = useRef(0);
useEffect(() => {
@@ -524,13 +540,22 @@ export function useSessionHistory({
}, [activeSessionId]);
const refreshSessions = useCallback(async () => {
if (refreshPromiseRef.current) {
return refreshPromiseRef.current;
// Reuse an in-flight refresh only when it already asked for at least as
// many sessions as we need now. "Load more" raises the limit and then
// awaits a refresh; sharing a request that captured the smaller limit
// would resolve without the larger batch ever being fetched.
while (refreshPromiseRef.current) {
const pending = refreshPromiseRef.current;
if (refreshLimitRef.current >= fetchLimitRef.current) {
return pending;
}
await pending;
}
const refreshPromise = (async () => {
const refreshPromise = (async (): Promise<boolean> => {
lastRefreshStartedAtRef.current = Date.now();
const limit = fetchLimitRef.current;
refreshLimitRef.current = limit;
// Only surface the loading state before anything has been fetched:
// consumers only render it for an empty list, and toggling it on
// every background poll re-rendered the whole app twice per refresh.
@@ -540,7 +565,20 @@ export function useSessionHistory({
try {
const discovered = await desktopClient
.invoke<CliDiscoveredSession[]>("list_discovered_sessions", { limit })
.catch(() => []);
.catch(() => null);
// A rejected request is not an empty history. Treating it as one
// would blank the list (the merge below is keyed off the response)
// and mark the backend exhausted, hiding sessions that still exist
// and disabling "load more" until some later poll happened to work.
if (!Array.isArray(discovered)) {
return false;
}
// Ask the raw response, not the filtered list: subagents and
// sessions without a known model are dropped below, so a filtered
// count under the limit does not mean the backend is exhausted.
const hasMoreSessions = discovered.length >= limit;
mayHaveMoreSessionsRef.current = hasMoreSessions;
setMayHaveMoreSessions(hasMoreSessions);
const topLevelSessions = discovered
.map((session) => {
const normalized: SessionHistoryItem = {
@@ -610,21 +648,26 @@ export function useSessionHistory({
});
return areThreadsEquivalent(current, next) ? current : next;
});
loadedLimitRef.current = Math.max(loadedLimitRef.current, limit);
return true;
} catch {
// Ignore in browser mode or when tauri command is unavailable.
return false;
} finally {
setIsLoadingHistory(false);
}
})();
refreshPromiseRef.current = refreshPromise;
try {
await refreshPromise;
} finally {
// Release the slot from the promise itself rather than from this caller,
// so a waiter in the loop above always observes a cleared ref when it
// resumes instead of spinning on a settled promise.
refreshPromise.finally(() => {
if (refreshPromiseRef.current === refreshPromise) {
refreshPromiseRef.current = null;
}
}
});
return await refreshPromise;
}, []);
const scheduleRefresh = useCallback(
@@ -1126,6 +1169,56 @@ export function useSessionHistory({
[getSessionByThreadId, onUpdateSessionMetadata, pendingAction],
);
const setThreadPinned = useCallback(
async (threadId: string, pinned: boolean) => {
const applyPinned = (next: boolean) => {
setThreads((current) =>
updateThreadById(current, threadId, (thread) =>
thread.pinned === next ? thread : { ...thread, pinned: next },
),
);
setSessions((current) =>
updateSessionById(current, threadId, (session) => ({
...session,
metadata: {
...(session.metadata ?? {}),
[PINNED_METADATA_KEY]: next || undefined,
},
})),
);
};
// Favoriting is a single click, so apply it locally first and roll back
// if the write fails rather than blocking the row on a round trip.
applyPinned(pinned);
try {
await desktopClient.invoke("update_chat_session_metadata", {
sessionId: threadId,
metadata: { [PINNED_METADATA_KEY]: pinned ? true : null },
});
const sourceSession = getSessionByThreadId(threadId);
onUpdateSessionMetadata?.(threadId, {
...(sourceSession?.metadata ?? {}),
[PINNED_METADATA_KEY]: pinned || undefined,
});
scheduleRefresh(HISTORY_FAST_REFRESH_DELAY_MS);
return true;
} catch (error) {
applyPinned(!pinned);
toast({
variant: "destructive",
title: pinned ? "Favorite failed" : "Unfavorite failed",
description:
error instanceof Error
? error.message
: "The session could not be updated.",
});
return false;
}
},
[getSessionByThreadId, onUpdateSessionMetadata, scheduleRefresh],
);
const forkThread = useCallback(
async (threadId: string) => {
const thread = threadsRef.current.find((item) => item.id === threadId);
@@ -1243,13 +1336,30 @@ export function useSessionHistory({
const loadMoreSessions = useCallback(
async (nextLimit: number) => {
if (fetchLimitRef.current >= nextLimit) {
return;
if (loadedLimitRef.current >= nextLimit) {
return true;
}
fetchLimitRef.current = nextLimit;
const requestedLimit = Math.max(fetchLimitRef.current, nextLimit);
fetchLimitRef.current = requestedLimit;
setIsLoadingMore(true);
try {
await refreshSessions();
const loaded = await refreshSessions();
// Roll back to what was last fetched so a retry asks for this batch
// again instead of skipping past it — but only when no overlapping
// call has raised the limit further in the meantime, since lowering
// it would make that call fetch a smaller batch than it asked for
// and still report success.
if (!loaded && fetchLimitRef.current === requestedLimit) {
fetchLimitRef.current = loadedLimitRef.current;
}
if (!loaded) {
toast({
variant: "destructive",
title: "Could not load more sessions",
description: "Session history is unavailable right now.",
});
}
return loaded;
} finally {
setIsLoadingMore(false);
}
@@ -1260,8 +1370,45 @@ export function useSessionHistory({
() => loadMoreSessions(fetchLimitRef.current + INITIAL_HISTORY_FETCH_LIMIT),
[loadMoreSessions],
);
const loadAllSessions = useCallback(() => {
if (loadAllPromiseRef.current) {
return loadAllPromiseRef.current;
}
const loadAllPromise = (async () => {
// A global search, filter, or oldest-first sort can be selected while
// the mount request is still in flight. Wait for that request before
// deciding whether there is any older history to fetch.
if (loadedLimitRef.current === 0 && !(await refreshSessions())) {
return false;
}
// Grow exponentially so complete-history operations need only
// logarithmically many requests while ordinary paging stays in
// predictable 50-session increments.
while (mayHaveMoreSessionsRef.current) {
const currentLimit = Math.max(
fetchLimitRef.current,
loadedLimitRef.current,
INITIAL_HISTORY_FETCH_LIMIT,
);
const nextLimit = Math.max(
currentLimit + INITIAL_HISTORY_FETCH_LIMIT,
currentLimit * 2,
);
if (!(await loadMoreSessions(nextLimit))) {
return false;
}
}
return true;
})();
loadAllPromiseRef.current = loadAllPromise;
loadAllPromise.finally(() => {
if (loadAllPromiseRef.current === loadAllPromise) {
loadAllPromiseRef.current = null;
}
});
return loadAllPromise;
}, [loadMoreSessions, refreshSessions]);
const mayHaveMoreSessions = sessions.length >= fetchLimitRef.current;
const sessionById = useMemo(
() => new Map(sessions.map((session) => [session.sessionId, session])),
[sessions],
@@ -1271,6 +1418,7 @@ export function useSessionHistory({
getSessionByThreadId,
isLoadingHistory,
isLoadingMore,
loadAllSessions,
loadOlderSessions,
loadMoreSessions,
mayHaveMoreSessions,
@@ -1278,6 +1426,7 @@ export function useSessionHistory({
pendingAction,
refreshSessions,
renameThread,
setThreadPinned,
deleteThread,
forkThread,
sessionById,
@@ -72,6 +72,8 @@ export const ChatMessageSchema = z.object({
totalCost: z.number().nonnegative().optional(),
providerId: z.string().optional(),
modelId: z.string().optional(),
userRunSpan: z.number().int().nonnegative().optional(),
runCount: z.number().int().positive().optional(),
checkpoint: z
.object({
ref: z.string(),
@@ -17,6 +17,30 @@ function createSession(sessionId: string): SessionHistoryItem {
}
describe("desktopAppReducer", () => {
it("hands an edited prompt to a fork exactly once", () => {
let state = createDesktopAppState("welcome", settingsSection);
state = desktopAppReducer(state, {
type: "open-session",
session: createSession("forked-session"),
initialPromptDraft: "Revise this prompt",
});
expect(
state.threads.find((thread) => thread.id === "session_forked-session")
?.initialPromptDraft,
).toBe("Revise this prompt");
state = desktopAppReducer(state, {
type: "consume-initial-prompt-draft",
threadId: "session_forked-session",
});
expect(
state.threads.find((thread) => thread.id === "session_forked-session")
?.initialPromptDraft,
).toBeUndefined();
});
it("keeps both sessions deleted when deletion actions are queued together", () => {
let state = createDesktopAppState("welcome", settingsSection);
state = desktopAppReducer(state, {
@@ -11,6 +11,7 @@ export type DesktopThread = {
id: string;
historySession?: SessionHistoryItem;
hasStarted?: boolean;
initialPromptDraft?: string;
};
export type DesktopAppLocation<SettingsSection extends string> = {
@@ -29,7 +30,12 @@ export type DesktopAppAction<SettingsSection extends string> =
| { type: "back" }
| { type: "forward" }
| { type: "new-thread"; threadId: string }
| { type: "open-session"; session: SessionHistoryItem }
| {
type: "open-session";
session: SessionHistoryItem;
initialPromptDraft?: string;
}
| { type: "consume-initial-prompt-draft"; threadId: string }
| {
type: "delete-session";
deletedSessionId: string;
@@ -115,6 +121,7 @@ export function desktopAppReducer<SettingsSection extends string>(
...thread,
hasStarted: true,
historySession: action.session,
initialPromptDraft: action.initialPromptDraft,
}
: thread,
)
@@ -124,6 +131,7 @@ export function desktopAppReducer<SettingsSection extends string>(
id: threadId,
hasStarted: true,
historySession: action.session,
initialPromptDraft: action.initialPromptDraft,
},
];
return {
@@ -138,6 +146,16 @@ export function desktopAppReducer<SettingsSection extends string>(
}),
};
}
case "consume-initial-prompt-draft":
return {
...state,
threads: state.threads.map((thread) =>
thread.id === action.threadId &&
thread.initialPromptDraft !== undefined
? { ...thread, initialPromptDraft: undefined }
: thread,
),
};
case "delete-session": {
const historyThreadId = `session_${action.deletedSessionId}`;
const deletedThreadIds = new Set(
@@ -0,0 +1,172 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
type SentDesktopRequest = {
id: string;
command: string;
};
class FakeWebSocket {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSING = 2;
static readonly CLOSED = 3;
readonly sent: string[] = [];
readyState = FakeWebSocket.CONNECTING;
sendError: Error | null = null;
onopen: (() => void) | null = null;
onmessage: ((event: { data: string }) => void) | null = null;
onerror: (() => void) | null = null;
onclose: (() => void) | null = null;
constructor(readonly url: string) {
sockets.push(this);
}
open(): void {
this.readyState = FakeWebSocket.OPEN;
this.onopen?.();
}
send(data: string): void {
if (this.sendError) {
throw this.sendError;
}
this.sent.push(data);
}
close(): void {
this.readyState = FakeWebSocket.CLOSED;
this.onclose?.();
}
respond(result: unknown): void {
const request = this.lastRequest();
this.onmessage?.({
data: JSON.stringify({
type: "response",
id: request.id,
ok: true,
result,
}),
});
}
lastRequest(): SentDesktopRequest {
const raw = this.sent.at(-1);
if (!raw) {
throw new Error("No desktop request was sent");
}
return JSON.parse(raw) as SentDesktopRequest;
}
}
const sockets: FakeWebSocket[] = [];
const originalWebSocket = globalThis.WebSocket;
async function connectLatestSocket(options?: {
sendError?: Error;
}): Promise<FakeWebSocket> {
await Promise.resolve();
await Promise.resolve();
const socket = sockets.at(-1);
if (!socket) {
throw new Error("Desktop client did not create a WebSocket");
}
socket.sendError = options?.sendError ?? null;
socket.open();
for (let attempt = 0; attempt < 10 && socket.sent.length === 0; attempt++) {
await Promise.resolve();
}
return socket;
}
beforeEach(() => {
vi.useFakeTimers();
vi.resetModules();
sockets.length = 0;
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
(window as unknown as Record<string, unknown>).__SIDECAR_WS_ENDPOINT__ =
"ws://127.0.0.1:3126/transport";
});
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
globalThis.WebSocket = originalWebSocket;
delete (window as unknown as Record<string, unknown>).__SIDECAR_WS_ENDPOINT__;
});
describe("DesktopClient command deadlines", () => {
it("keeps an explicitly unbounded command pending past the default deadline", async () => {
const { desktopClient } = await import("./desktop-client");
let settled = false;
const invocation = desktopClient
.invoke<{ ok: boolean }>(
"chat_session_command",
{ request: { action: "send" } },
{ timeoutMs: null },
)
.finally(() => {
settled = true;
});
const socket = await connectLatestSocket();
await vi.advanceTimersByTimeAsync(10 * 60_000);
expect(settled).toBe(false);
socket.respond({ ok: true });
await expect(invocation).resolves.toEqual({ ok: true });
});
it("retains the default deadline for ordinary commands", async () => {
const { desktopClient } = await import("./desktop-client");
const invocation = desktopClient.invoke("get_process_context");
await connectLatestSocket();
const rejection = expect(invocation).rejects.toThrow(
"Desktop command timed out waiting for get_process_context",
);
await vi.advanceTimersByTimeAsync(120_000);
await rejection;
});
it("rejects an unbounded command when the transport closes", async () => {
const { desktopClient } = await import("./desktop-client");
const invocation = desktopClient.invoke(
"chat_session_command",
{ request: { action: "send" } },
{ timeoutMs: null },
);
const socket = await connectLatestSocket();
const rejection = expect(invocation).rejects.toThrow(
"Desktop backend transport closed",
);
socket.close();
await rejection;
});
it("removes an unbounded request when WebSocket.send throws", async () => {
const { desktopClient } = await import("./desktop-client");
const invocation = desktopClient.invoke(
"chat_session_command",
{ request: { action: "send" } },
{ timeoutMs: null },
);
await connectLatestSocket({
sendError: new Error("WebSocket send failed"),
});
await expect(invocation).rejects.toThrow("WebSocket send failed");
expect(
(
desktopClient as unknown as {
pending: Map<string, unknown>;
}
).pending.size,
).toBe(0);
});
});
@@ -87,12 +87,20 @@ export async function resolveDesktopBackendHttpEndpoint(): Promise<string> {
type PendingRequest = {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timeoutId: ReturnType<typeof setTimeout>;
timeoutId?: ReturnType<typeof setTimeout>;
};
type EventHandler = (payload: unknown) => void;
type TransportStateHandler = (state: DesktopTransportState) => void;
export type DesktopInvokeOptions = {
/**
* Override the default command deadline. Use `null` for commands whose
* response represents completion of a legitimately long-running operation.
*/
timeoutMs?: number | null;
};
const REQUEST_TIMEOUT_MS = 120_000;
const RECONNECT_BASE_DELAY_MS = 400;
const RECONNECT_MAX_DELAY_MS = 4_000;
@@ -142,11 +150,21 @@ class DesktopClient {
return this.endpoint;
}
private rejectPending(errorMessage: string) {
for (const [requestId, pending] of this.pending.entries()) {
private takePending(requestId: string): PendingRequest | undefined {
const pending = this.pending.get(requestId);
if (!pending) {
return undefined;
}
if (pending.timeoutId !== undefined) {
clearTimeout(pending.timeoutId);
this.pending.delete(requestId);
pending.reject(new Error(errorMessage));
}
this.pending.delete(requestId);
return pending;
}
private rejectPending(errorMessage: string) {
for (const requestId of this.pending.keys()) {
this.takePending(requestId)?.reject(new Error(errorMessage));
}
}
@@ -174,12 +192,10 @@ class DesktopClient {
}
const response = parsed as DesktopTransportResponse;
const pending = this.pending.get(response.id);
const pending = this.takePending(response.id);
if (!pending) {
return;
}
clearTimeout(pending.timeoutId);
this.pending.delete(response.id);
if (!response.ok) {
pending.reject(new Error(response.error || "Desktop command failed"));
return;
@@ -265,7 +281,11 @@ class DesktopClient {
return this.connectPromise;
}
async invoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
async invoke<T>(
command: string,
args?: Record<string, unknown>,
options?: DesktopInvokeOptions,
): Promise<T> {
// Route native OS commands (directory picker, file opener) through Tauri
// only when running inside the full Tauri app shell. In sidecar/web mode
// these are handled by the sidecar over WebSocket.
@@ -288,22 +308,33 @@ class DesktopClient {
};
return await new Promise<T>((resolve, reject) => {
const timeoutId = setTimeout(() => {
const pending = this.pending.get(id);
if (!pending) {
return;
}
this.pending.delete(id);
pending.reject(
new Error(`Desktop command timed out waiting for ${command}`),
);
}, REQUEST_TIMEOUT_MS);
const timeoutMs =
options?.timeoutMs === undefined
? REQUEST_TIMEOUT_MS
: options.timeoutMs;
const timeoutId =
timeoutMs === null
? undefined
: setTimeout(() => {
const pending = this.takePending(id);
if (!pending) {
return;
}
pending.reject(
new Error(`Desktop command timed out waiting for ${command}`),
);
}, timeoutMs);
this.pending.set(id, {
resolve: (value) => resolve(value as T),
reject,
timeoutId,
});
socket.send(JSON.stringify(request));
try {
socket.send(JSON.stringify(request));
} catch (error) {
this.takePending(id);
throw error;
}
});
}
@@ -7,6 +7,11 @@ export type SessionHistoryStatus =
export type SessionMetadata = {
title?: string;
/**
* Favorited sessions. Stored in session metadata rather than desktop-local
* state so every client reading the session sees the same flag.
*/
pinned?: boolean;
git?: {
url?: string;
branch?: string;
@@ -14,6 +19,8 @@ export type SessionMetadata = {
[key: string]: unknown;
};
export const PINNED_METADATA_KEY = "pinned";
export interface SessionHistoryItem {
sessionId: string;
source?: string;
@@ -51,6 +58,10 @@ export function getSessionMetadataTitle(metadata?: SessionMetadata): string {
return typeof metadata.title === "string" ? metadata.title.trim() : "";
}
export function getSessionMetadataPinned(metadata?: SessionMetadata): boolean {
return metadata?.[PINNED_METADATA_KEY] === true;
}
export function getSessionMetadataGitBranch(
metadata?: SessionMetadata,
): string {
+2 -2
View File
@@ -34,7 +34,7 @@ Thanks to [Claude Sonnet's agentic coding capabilities](https://www.anthropic.c
4. When a task is completed, Cline will present the result to you with a terminal command like `open -a "Google Chrome" index.html`, which you run with a click of a button.
> [!TIP]
> Follow [this guide](https://docs.cline.bot/features/customization/opening-cline-in-sidebar) to open Cline on the right side of your editor. This lets you use Cline side-by-side with your file explorer, and see how he changes your workspace more clearly.
> Follow [this guide](https://docs.cline.bot/usage/ide#move-cline-to-the-right-sidebar) to open Cline on the right side of your editor. This lets you use Cline side-by-side with your file explorer, and see how he changes your workspace more clearly.
---
@@ -56,7 +56,7 @@ The extension also keeps track of total tokens and API usage cost for the entire
Cline can execute commands and receive their output to install packages, run build scripts, deploy applications, manage databases, and run tests, all while adapting to your dev environment & toolchain to get the job done right.
By default, commands run in a background process, which works everywhere without extra setup. You can switch to running commands in a visible VS Code terminal instead (Settings → Terminal → Terminal Execution Mode), which uses the [shell integration API introduced in VSCode v1.93](https://code.visualstudio.com/updates/v1_93#_terminal-shell-integration-api) to stream a command's output as it runs and lets you watch or interact with it directly.
By default, commands run in a visible VS Code terminal, using the [shell integration API introduced in VSCode v1.93](https://code.visualstudio.com/updates/v1_93#_terminal-shell-integration-api) to stream output as commands run and let you watch or interact with them directly. You can switch to running commands in a background process instead (Settings → Terminal → Terminal Execution Mode).
<!-- Transparent pixel to create line break after floating image -->
-5
View File
@@ -228,11 +228,6 @@
"command": "cline.openWalkthrough",
"title": "Open Walkthrough",
"category": "Cline"
},
{
"command": "cline.reconstructTaskHistory",
"title": "Reconstruct Task History",
"category": "Cline"
}
],
"keybindings": [
+8 -4
View File
@@ -130,11 +130,13 @@ message OpenRouterModelInfo {
// strings are silently dropped by the host. The array is additive over
// the base metadata; the explicit supports_* booleans win when both are
// present.
// - `is_r1_format_required` is a legacy alias that forces the R1 chat
// format only when true; `api_format` is canonical.
// - Invalid numbers (non-positive token limits, negative prices or
// temperature, non-finite values) are silently discarded, not rejected.
message ModelOverrides {
// Field 15 was the removed legacy `is_r1_format_required` alias.
reserved 15;
reserved "is_r1_format_required";
optional string name = 1;
optional int64 max_tokens = 2;
optional int64 context_window = 3;
@@ -149,7 +151,6 @@ message ModelOverrides {
optional double cache_writes_price = 12;
optional double temperature = 13;
optional ApiFormat api_format = 14;
optional bool is_r1_format_required = 15;
}
// Shared response message for model information
@@ -626,6 +627,10 @@ enum ApiFormat {
// Model info for OpenAI-compatible models
message OpenAiCompatibleModelInfo {
// Field 14 was the removed legacy `is_r1_format_required` flag.
reserved 14;
reserved "is_r1_format_required";
optional int64 max_tokens = 1;
optional int64 context_window = 2;
optional bool supports_images = 3;
@@ -639,7 +644,6 @@ message OpenAiCompatibleModelInfo {
optional string description = 11;
repeated ModelTier tiers = 12;
optional double temperature = 13;
optional bool is_r1_format_required = 14;
optional ApiFormat api_format = 15;
}
+1 -1
View File
@@ -29,7 +29,7 @@ service TaskService {
rpc newTask(NewTaskRequest) returns (String);
// Shows a task with the specified ID
rpc showTaskWithId(StringRequest) returns (TaskResponse);
// Exports a task with the given ID to markdown
// Opens the on-disk session directory (messages json, logs) for the task with the given ID
rpc exportTaskWithId(StringRequest) returns (Empty);
// Toggles the favorite status of a task
rpc toggleTaskFavorite(TaskFavoriteRequest) returns (Empty);
+1
View File
@@ -75,6 +75,7 @@ enum ClineSay {
USE_SUBAGENTS_SAY = 35;
SUBAGENT_USAGE = 36;
COMPACTION = 37;
PLAN_COMPLETION_RESULT = 38;
}
// Enum for ClineSayTool tool types
+42 -5
View File
@@ -94,8 +94,15 @@ function parseCounts(output: string): { pass: number; fail: number } {
const PER_FILE_TIMEOUT_MS = 120_000
// bun test's default per-test timeout is 5000ms. The Windows CI runners are
// slow enough under load (several suites spawn powershell + node per test via
// the hook bridge) that real, passing tests intermittently crossed 5s. Give
// them more headroom; genuinely hung files are still bounded by
// PER_FILE_TIMEOUT_MS.
const PER_TEST_TIMEOUT_ARGS = process.platform === "win32" ? ["--timeout", "15000"] : []
async function runOne(file: string): Promise<FileResult> {
const proc = Bun.spawn(["bun", "test", file], {
const proc = Bun.spawn(["bun", "test", ...PER_TEST_TIMEOUT_ARGS, file], {
cwd: projectRoot,
stdout: "pipe",
stderr: "pipe",
@@ -178,13 +185,43 @@ async function main(): Promise<void> {
const results = await runPool(files, concurrency)
const totalPass = results.reduce((sum, r) => sum + r.pass, 0)
const totalFail = results.reduce((sum, r) => sum + r.fail, 0)
const failedFiles = results.filter((r) => r.fail > 0 || r.code !== 0).sort((a, b) => a.file.localeCompare(b.file))
// Flake guard: rerun failed files once, serially, so a retry is never
// competing with other suites for CPU/process slots. This mirrors the
// retry the CI workflow already applies to the non-Linux integration
// tests; deterministic failures still fail both attempts and stay red.
const resultByFile = new Map(results.map((r) => [r.file, r]))
const firstPassFailures = results.filter((r) => r.fail > 0 || r.code !== 0).sort((a, b) => a.file.localeCompare(b.file))
const recoveredFiles: string[] = []
if (firstPassFailures.length > 0) {
console.log(`\nRetrying ${firstPassFailures.length} failed file(s) serially (flake guard)…`)
for (const failure of firstPassFailures) {
const retry = await runOne(failure.file)
const failedAgain = retry.fail > 0 || retry.code !== 0
const status = failedAgain ? "FAIL" : "ok (flaky)"
console.log(`[retry] ${status.padEnd(10)} ${retry.pass} pass / ${retry.fail} fail ${failure.file}`)
if (failedAgain) {
process.stdout.write(retry.output.trimEnd() + "\n")
} else {
recoveredFiles.push(failure.file)
}
resultByFile.set(failure.file, retry)
}
}
const finalResults = [...resultByFile.values()]
const totalPass = finalResults.reduce((sum, r) => sum + r.pass, 0)
const totalFail = finalResults.reduce((sum, r) => sum + r.fail, 0)
const failedFiles = finalResults.filter((r) => r.fail > 0 || r.code !== 0).sort((a, b) => a.file.localeCompare(b.file))
const elapsed = ((Date.now() - started) / 1000).toFixed(1)
console.log("\n──────────────────────────────────────────────")
console.log(`Files: ${results.length} Pass: ${totalPass} Fail: ${totalFail} Time: ${elapsed}s`)
console.log(`Files: ${finalResults.length} Pass: ${totalPass} Fail: ${totalFail} Time: ${elapsed}s`)
if (recoveredFiles.length > 0) {
console.log(`\nFlaky files that passed on serial retry (${recoveredFiles.length}):`)
for (const file of recoveredFiles) {
console.log(` ${file}`)
}
}
if (failedFiles.length > 0) {
console.log(`\nFailing files (${failedFiles.length}):`)
for (const r of failedFiles) {
@@ -4,10 +4,68 @@ import { ClineRulesToggles } from "@shared/cline-rules"
import path from "path"
import { Controller } from "@/core/controller"
/**
* Merge a directory-scan result with the toggle state as it stands *after* the
* scan. The scan in `synchronizeRuleToggles` is async, so state can change
* while it runs:
* - a toggle the user flips mid-scan must win over the stale snapshot value;
* - an entry added mid-scan (e.g. a workflow file created via the modal) must
* be kept even though the older scan didn't see the file;
* - an entry removed from state mid-scan (e.g. the workflow was deleted via
* the modal, which deletes the file and its entry) must stay removed even
* though the older scan still saw the file;
* - entries that existed before the scan but whose files the scan no longer
* found are pruned (the file was deleted).
*/
function mergeToggleStateAfterScan(
scanned: ClineRulesToggles,
preScan: ClineRulesToggles,
current: ClineRulesToggles,
): ClineRulesToggles {
const merged: ClineRulesToggles = {}
for (const [key, value] of Object.entries(scanned)) {
if (key in current) {
merged[key] = current[key]
} else if (!(key in preScan)) {
merged[key] = value
}
// else: the entry was removed from state while the scan ran — keep it removed.
}
for (const [key, value] of Object.entries(current)) {
if (!(key in scanned) && !(key in preScan)) {
merged[key] = value
}
}
return merged
}
/**
* Serializes refresh runs. Overlapping refreshes (webview launch, the rules
* modal opening, workflow file creation) would otherwise interleave their
* scans and writes and could publish stale state; queueing them makes each
* scan atomic relative to other refreshes, and any file create/delete that
* happens mid-scan triggers its own refresh that queues behind the running
* one and corrects the outcome. The merge in `mergeToggleStateAfterScan`
* covers the remaining non-refresh writer: direct toggle flips.
*/
let refreshQueue: Promise<unknown> = Promise.resolve()
/**
* Refresh the workflow toggles
*/
export async function refreshWorkflowToggles(
export function refreshWorkflowToggles(
controller: Controller,
workingDirectory: string,
): Promise<{
globalWorkflowToggles: ClineRulesToggles
localWorkflowToggles: ClineRulesToggles
}> {
const run = refreshQueue.then(() => doRefreshWorkflowToggles(controller, workingDirectory))
refreshQueue = run.catch(() => undefined)
return run
}
async function doRefreshWorkflowToggles(
controller: Controller,
workingDirectory: string,
): Promise<{
@@ -17,12 +75,24 @@ export async function refreshWorkflowToggles(
// Global workflows
const globalWorkflowToggles = controller.stateManager.getGlobalSettingsKey("globalWorkflowToggles")
const globalClineWorkflowsFilePath = await ensureWorkflowsDirectoryExists()
const updatedGlobalWorkflowToggles = await synchronizeRuleToggles(globalClineWorkflowsFilePath, globalWorkflowToggles)
const scannedGlobalToggles = await synchronizeRuleToggles(globalClineWorkflowsFilePath, globalWorkflowToggles)
// Re-read state after the async scans: no `await` between here and the
// writes below, so concurrent toggle updates cannot be lost.
const updatedGlobalWorkflowToggles = mergeToggleStateAfterScan(
scannedGlobalToggles,
globalWorkflowToggles,
controller.stateManager.getGlobalSettingsKey("globalWorkflowToggles"),
)
controller.stateManager.setGlobalState("globalWorkflowToggles", updatedGlobalWorkflowToggles)
const workflowRulesToggles = controller.stateManager.getWorkspaceStateKey("workflowToggles")
const workflowsDirPath = path.resolve(workingDirectory, GlobalFileNames.workflows)
const updatedWorkflowToggles = await synchronizeRuleToggles(workflowsDirPath, workflowRulesToggles)
const scannedWorkspaceToggles = await synchronizeRuleToggles(workflowsDirPath, workflowRulesToggles)
const updatedWorkflowToggles = mergeToggleStateAfterScan(
scannedWorkspaceToggles,
workflowRulesToggles,
controller.stateManager.getWorkspaceStateKey("workflowToggles"),
)
controller.stateManager.setWorkspaceState("workflowToggles", updatedWorkflowToggles)
return {
@@ -1,10 +1,14 @@
import * as assert from "assert"
import { afterEach, beforeEach, describe, it } from "mocha"
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import sinon from "sinon"
import { Logger } from "@/shared/services/Logger"
import type { Controller } from "../../index"
import { clearOrganizationForClinePassProviderSelection } from "../handleClinePassProviderSelection"
/** Let the fire-and-forget switchAccount promise chain settle. */
function flushMicrotasks(): Promise<void> {
return new Promise((resolve) => setImmediate(resolve))
}
describe("clearOrganizationForClinePassProviderSelection", () => {
let sandbox: sinon.SinonSandbox
let switchAccount: sinon.SinonStub
@@ -25,36 +29,37 @@ describe("clearOrganizationForClinePassProviderSelection", () => {
} as unknown as Controller
}
it("does nothing when ClinePass is not selected", async () => {
await clearOrganizationForClinePassProviderSelection(createController(), {
it("does nothing when ClinePass is not selected", () => {
clearOrganizationForClinePassProviderSelection(createController(), {
planModeApiProvider: "cline",
actModeApiProvider: "openrouter",
})
assert.strictEqual(switchAccount.callCount, 0)
expect(switchAccount.callCount).toBe(0)
})
it("switches to the personal account when ClinePass is selected", async () => {
await clearOrganizationForClinePassProviderSelection(createController(), {
it("switches to the personal account when ClinePass is selected without blocking the caller", () => {
clearOrganizationForClinePassProviderSelection(createController(), {
planModeApiProvider: "cline-pass",
actModeApiProvider: "openrouter",
})
assert.strictEqual(switchAccount.callCount, 1)
assert.strictEqual(switchAccount.firstCall.args[0], null)
expect(switchAccount.callCount).toBe(1)
expect(switchAccount.firstCall.args[0]).toBeUndefined()
})
it("logs and swallows account switch failures", async () => {
const error = new Error("not signed in")
switchAccount.rejects(error)
await clearOrganizationForClinePassProviderSelection(createController(), {
clearOrganizationForClinePassProviderSelection(createController(), {
planModeApiProvider: "cline",
actModeApiProvider: "cline-pass",
})
await flushMicrotasks()
assert.strictEqual(switchAccount.callCount, 1)
assert.strictEqual(switchAccount.firstCall.args[0], null)
assert.ok((Logger.debug as sinon.SinonStub).calledOnce)
expect(switchAccount.callCount).toBe(1)
expect(switchAccount.firstCall.args[0]).toBeUndefined()
expect((Logger.debug as sinon.SinonStub).calledOnce).toBe(true)
})
})
@@ -1,113 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import { refreshGroqModels } from "../refreshGroqModels"
const mocks = vi.hoisted(() => ({
axiosGet: vi.fn(),
captureProviderApiError: vi.fn(),
getModelsCache: vi.fn(),
getProviderCollectionSync: vi.fn(),
getSecretKey: vi.fn(),
setModelsCache: vi.fn(),
writeFile: vi.fn(),
}))
vi.mock("@cline/llms", () => ({
getProviderCollectionSync: mocks.getProviderCollectionSync,
}))
vi.mock("@core/storage/disk", () => ({
GlobalFileNames: {
groqModels: "groq_models.json",
},
ensureCacheDirectoryExists: vi.fn(async () => "/tmp/cline-cache"),
}))
vi.mock("@/core/storage/StateManager", () => ({
StateManager: {
get: () => ({
getModelsCache: mocks.getModelsCache,
setModelsCache: mocks.setModelsCache,
}),
},
}))
vi.mock("@/services/telemetry", () => ({
telemetryService: {
captureProviderApiError: mocks.captureProviderApiError,
},
}))
vi.mock("@/shared/net", () => ({
getAxiosSettings: () => ({}),
}))
vi.mock("@/shared/services/Logger", () => ({
Logger: {
error: vi.fn(),
log: vi.fn(),
},
}))
vi.mock("@utils/fs", () => ({
fileExistsAtPath: vi.fn(async () => false),
}))
vi.mock("axios", () => ({
default: {
get: mocks.axiosGet,
isAxiosError: vi.fn(() => false),
},
}))
vi.mock("fs/promises", () => ({
default: {
readFile: vi.fn(),
writeFile: mocks.writeFile,
},
}))
describe("refreshGroqModels", () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getModelsCache.mockReturnValue(null)
mocks.getProviderCollectionSync.mockReturnValue({ models: {} })
mocks.getSecretKey.mockReturnValue("gsk_test_key")
mocks.axiosGet.mockResolvedValue({
data: {
data: [
{
id: "groq-new-chat-model",
object: "model",
active: true,
max_completion_tokens: 4096,
context_window: 8192,
owned_by: "Groq",
},
],
},
})
})
it("defaults cache pricing for live models missing SDK catalog metadata", async () => {
const controller = {
stateManager: {
getSecretKey: mocks.getSecretKey,
},
task: {
ulid: "task-1",
},
} as unknown as Parameters<typeof refreshGroqModels>[0]
const models = await refreshGroqModels(controller)
expect(models["groq-new-chat-model"]).toMatchObject({
maxTokens: 4096,
contextWindow: 8192,
cacheWritesPrice: 0,
cacheReadsPrice: 0,
description: "Groq model with 8,192 token context window",
})
expect(mocks.captureProviderApiError).not.toHaveBeenCalled()
expect(mocks.setModelsCache).toHaveBeenCalledWith("groq", expect.objectContaining(models))
})
})
@@ -0,0 +1,77 @@
import type { ModelInfo } from "@shared/api"
import { describe, expect, it, vi } from "vitest"
import type { ProviderCatalog } from "@/sdk/model-catalog/contracts"
import type { ProviderCatalogController } from "../providerCatalogShared"
import { refreshBasetenModels } from "../refreshBasetenModels"
import { refreshGroqModels } from "../refreshGroqModels"
import { refreshOpenRouterModels } from "../refreshOpenRouterModels"
import { refreshVercelAiGatewayModels } from "../refreshVercelAiGatewayModels"
vi.mock("@/shared/services/Logger", () => ({
Logger: {
error: vi.fn(),
log: vi.fn(),
warn: vi.fn(),
},
}))
const MODEL: ModelInfo = {
name: "Rich Model",
contextWindow: 200_000,
maxTokens: 8192,
supportsPromptCache: true,
inputPrice: 3,
outputPrice: 15,
}
function makeController(result: unknown): { controller: ProviderCatalogController; resolveModels: ReturnType<typeof vi.fn> } {
const resolveModels = vi.fn(async () => result)
const catalog = { resolveModels } as unknown as ProviderCatalog
const controller = {
getProviderCatalog: () => catalog,
getProviderConfigStore: () => {
throw new Error("not used")
},
} as unknown as ProviderCatalogController
return { controller, resolveModels }
}
function okResult(providerId: string) {
return {
ok: true as const,
providerId,
configFingerprint: "fp",
models: new Map([["vendor/rich-model", MODEL]]),
defaultModelId: "vendor/rich-model",
source: "sdk-dynamic",
fetchedAt: 0,
}
}
describe("provider model refresh handlers (SDK catalog delegation)", () => {
it.each([
["openrouter", refreshOpenRouterModels],
["groq", refreshGroqModels],
["baseten", refreshBasetenModels],
["vercel-ai-gateway", refreshVercelAiGatewayModels],
] as const)("resolves %s models through the SDK provider catalog", async (providerId, refresh) => {
const { controller, resolveModels } = makeController(okResult(providerId))
const models = await refresh(controller)
expect(resolveModels).toHaveBeenCalledWith(providerId, undefined)
expect(models).toEqual({ "vendor/rich-model": MODEL })
})
it("throws the catalog error when model resolution fails", async () => {
const { controller } = makeController({
ok: false as const,
providerId: "openrouter",
configFingerprint: "fp",
error: { kind: "unknown", message: "catalog exploded" },
fetchedAt: 0,
})
await expect(refreshOpenRouterModels(controller)).rejects.toThrow("catalog exploded")
})
})
@@ -139,6 +139,48 @@ describe("resolveModelInfo", () => {
expect(catalog.resolveModels).not.toHaveBeenCalled()
})
it("returns the committed selection when the request omits a model id (post-migration cold start)", async () => {
const { resolveModelInfo } = await import("../resolveModelInfo")
const providerId = parseProviderId("openrouter")
const store = makeStore({ providerId })
// The mode-specific state field is empty (migration wrote providers.json
// only), so readSelection falls through to the committed providers.json
// selection. The handler must surface that instead of a catalog default.
vi.mocked(store.readSelection).mockImplementation((_, mode) =>
mode === "act"
? {
providerId,
modelId: "anthropic/claude-sonnet-5",
modelInfo: { name: "Claude Sonnet 5", supportsPromptCache: true, contextWindow: 200_000 },
}
: undefined,
)
const catalog = makeCatalog()
vi.mocked(catalog.peekModels).mockReturnValue(
peekResult(
"openrouter",
[
[
"anthropic/claude-sonnet-4.5",
{ name: "Claude Sonnet 4.5", supportsPromptCache: true, contextWindow: 1_000_000 },
],
],
"anthropic/claude-sonnet-4.5",
),
)
const response = await resolveModelInfo(makeController(store, catalog), {
providerId: "openrouter",
})
expect(response.source).toBe("committed-selection")
expect(response.modelId).toBe("anthropic/claude-sonnet-5")
expect(response.modelInfo?.contextWindow).toBe(200_000)
// The committed selection short-circuits before any catalog lookup.
expect(catalog.peekModels).not.toHaveBeenCalled()
expect(catalog.resolveModels).not.toHaveBeenCalled()
})
it("returns sdk-default when the request omits a model id and the catalog has a default", async () => {
const { resolveModelInfo } = await import("../resolveModelInfo")
const store = makeStore({ providerId: parseProviderId("deepseek") })
@@ -7,13 +7,18 @@ export const CLINE_PASS_PROVIDER_ID = "cline-pass"
/**
* ClinePass always uses the user's personal Cline account balance.
*
* The account switch is a network round-trip (plus a possible token refresh),
* so it runs fire-and-forget: callers must not block the config update or
* the state post that re-renders the settings UI on it. Auth state changes
* propagate to the webview separately once the switch completes.
*
* This is intentionally best-effort: selecting the provider should still be
* saved even if the account switch fails.
*/
export async function clearOrganizationForClinePassProviderSelection(
export function clearOrganizationForClinePassProviderSelection(
controller: Controller,
apiConfiguration: Pick<ApiConfiguration, "planModeApiProvider" | "actModeApiProvider">,
): Promise<void> {
): void {
if (
apiConfiguration.planModeApiProvider !== CLINE_PASS_PROVIDER_ID &&
apiConfiguration.actModeApiProvider !== CLINE_PASS_PROVIDER_ID
@@ -21,9 +26,7 @@ export async function clearOrganizationForClinePassProviderSelection(
return
}
try {
await controller.accountService.switchAccount(undefined)
} catch (error) {
controller.accountService.switchAccount(undefined).catch((error) => {
Logger.debug("Failed to switch ClinePass to personal account", { error })
}
})
}
@@ -52,6 +52,23 @@ export function hasProviderCatalogStateController(
return typeof candidate.stateManager?.setGlobalStateBatch === "function"
}
/**
* Resolve a provider's models through the SDK provider catalog and return
* them as a plain record, throwing on catalog errors. Shared by the
* per-provider refresh handlers, which are thin RPC adapters over this call.
*/
export async function resolveProviderModelsRecord(
controller: ProviderCatalogController,
providerId: string,
options?: { readonly forceRefresh?: boolean },
): Promise<Record<string, ModelInfo>> {
const result = await controller.getProviderCatalog().resolveModels(parseProviderId(providerId), options)
if (!result.ok) {
throw new Error(result.error.message)
}
return Object.fromEntries(result.models)
}
export function parseProviderIdRequest(rawProviderId: string | undefined, fieldName = "provider_id"): ProviderId {
const providerId = rawProviderId?.trim()
if (!providerId) {
@@ -1,304 +1,14 @@
import fs from "node:fs/promises"
import path from "node:path"
import { getProviderCollectionSync } from "@cline/llms"
import { ensureCacheDirectoryExists, GlobalFileNames } from "@core/storage/disk"
import { ANTHROPIC_MAX_THINKING_BUDGET, ModelInfo } from "@shared/api"
import { fileExistsAtPath } from "@utils/fs"
import { parsePrice } from "@utils/model-utils"
import axios from "axios"
import { StateManager } from "@/core/storage/StateManager"
import { adaptSdkModelInfo } from "@/sdk/model-catalog/shape-adapter"
import { getAxiosSettings } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
import { Controller } from ".."
import type { ModelInfo } from "@shared/api"
import { type ProviderCatalogController, resolveProviderModelsRecord } from "./providerCatalogShared"
/**
* Baseten's curated model catalog from the SDK. Used as a fallback
* when the live API fetch fails. Adapted to the extension `ModelInfo`
* shape so the shape matches what `models` is collecting.
* Refreshes the Baseten models and returns application types.
*
* Model catalogs are consolidated in the SDK: `resolveProviderConfig` serves
* the models.dev-backed catalog and, when a Baseten API key is configured,
* merges in the SDK's authenticated Baseten model fetch the same source
* the CLI uses.
*/
function getBasetenSdkModels(): Record<string, ModelInfo> {
const collection = getProviderCollectionSync("baseten")
if (!collection) {
return {}
}
const result: Record<string, ModelInfo> = {}
for (const [modelId, sdkInfo] of Object.entries(collection.models)) {
result[modelId] = adaptSdkModelInfo(sdkInfo)
}
return result
}
// Track pending refresh promise to prevent duplicate concurrent fetches
let pendingRefresh: Promise<Record<string, ModelInfo>> | null = null
/**
* Core function: Refreshes the Baseten models and returns application types
* @param controller The controller instance
* @returns Record of model ID to ModelInfo (application types)
*/
// TODO(sdk-consolidation): Live-fetches Baseten's /models endpoint and parses
// live pricing + reasoning support into ModelInfo. The SDK has a generic
// models-URL fetcher but it returns ids-only and (for providers with a
// registered modelsSourceUrl) REPLACES the curated catalog rather than merging,
// so a naive migration would regress metadata. See the detailed note in
// refreshGroqModels.ts; share via the SDK + delete this handler + RPC once the
// SDK supports rich/merged per-provider live models for all clients.
export async function refreshBasetenModels(controller: Controller): Promise<Record<string, ModelInfo>> {
// Check in-memory cache first
const cache = StateManager.get().getModelsCache("baseten")
if (cache) {
return cache
}
// If a fetch is already in progress, return the same promise
if (pendingRefresh) {
return pendingRefresh
}
// Start new fetch and track the promise
pendingRefresh = (async () => {
try {
return await fetchAndCacheModels(controller)
} finally {
// Clear pending promise when done (success or error)
pendingRefresh = null
}
})()
return pendingRefresh
}
async function fetchAndCacheModels(controller: Controller): Promise<Record<string, ModelInfo>> {
const basetenModelsFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.basetenModels)
// Get the Baseten API key from the controller's state
const basetenApiKey = controller.stateManager.getSecretKey("basetenApiKey")
const models: Record<string, Partial<ModelInfo> & { supportedFeatures?: string[] }> = {}
// The SDK catalog is Baseten's curated model list. Used here for
// pricing/capability defaults when the live API doesn't surface
// them, and as the offline fallback below when the API fetch fails.
const sdkModels = getBasetenSdkModels()
try {
if (basetenApiKey) {
// Ensure the API key is properly formatted
const cleanApiKey = basetenApiKey.trim()
if (!cleanApiKey) {
throw new Error("Invalid Baseten API key format")
}
const response = await axios.get("https://inference.baseten.co/v1/models", {
headers: {
Authorization: `Bearer ${cleanApiKey}`,
"Content-Type": "application/json",
"User-Agent": "Cline-VSCode-Extension",
},
timeout: 10000, // 10 second timeout
...getAxiosSettings(),
})
const rawModels = response?.data?.data
if (rawModels && Array.isArray(rawModels)) {
for (const rawModel of rawModels) {
// Filter out non-chat models and validate model capabilities
if (!isValidChatModel(rawModel)) {
continue
}
// SDK pricing/capability defaults for this model id, if any.
const staticModelInfo = sdkModels[rawModel.id]
const supportThinking = rawModel?.supported_features?.some(
(p: string) => p === "reasoning_effort" || p === "reasoning",
)
const modelInfo: Partial<ModelInfo> & { supportedFeatures?: string[] } = {
maxTokens: rawModel.max_completion_tokens || staticModelInfo?.maxTokens,
contextWindow: rawModel.context_length || staticModelInfo?.contextWindow,
supportsImages: false, // Baseten model APIs does not support image input
supportsPromptCache: staticModelInfo?.supportsPromptCache || false,
inputPrice: parsePrice(rawModel.pricing?.prompt) || staticModelInfo?.inputPrice || 0,
outputPrice: parsePrice(rawModel.pricing?.completion) || staticModelInfo?.outputPrice || 0,
cacheWritesPrice: staticModelInfo?.cacheWritesPrice || 0,
cacheReadsPrice: staticModelInfo?.cacheReadsPrice || 0,
description: generateModelDescription(rawModel, staticModelInfo),
supportedFeatures: rawModel.supported_features || [],
supportsReasoning: supportThinking || false,
// If thinking is supported, set maxBudget with a default value as a placeholder
// to ensure it has a valid thinkingConfig that lets the application know thinking is supported.
thinkingConfig: supportThinking ? { maxBudget: ANTHROPIC_MAX_THINKING_BUDGET } : undefined,
}
models[rawModel.id] = modelInfo
}
}
// Cache the fetched models to disk
await fs.writeFile(basetenModelsFilePath, JSON.stringify(models))
}
// If no API key is set or models is empty, throw an error to trigger fallback
if (Object.keys(models).length === 0) {
throw new Error("No Baseten API key set or no models fetched")
}
} catch (error) {
Logger.error("Error fetching Baseten models:", error)
// Provide more specific error messages
let errorMessage = "Unknown error occurred"
if (axios.isAxiosError(error)) {
if (error.response?.status === 401) {
errorMessage = "Invalid Baseten API key. Please check your API key in settings."
} else if (error.response?.status === 403) {
errorMessage = "Access forbidden. Please verify your Baseten API key has the correct permissions."
} else if (error.response?.status === 429) {
errorMessage = "Rate limit exceeded. Please try again later."
} else if (error.code === "ECONNABORTED") {
errorMessage = "Request timeout. Please check your internet connection."
} else {
errorMessage = `API request failed: ${error.response?.status || error.code || "Unknown error"}`
}
} else if (error instanceof Error) {
errorMessage = error.message
}
Logger.error("Baseten API Error:", errorMessage)
// If we failed to fetch models, try to read cached models first
const cachedModels = await readBasetenModels()
if (cachedModels && Object.keys(cachedModels).length > 0) {
// Use all cached models (no filtering)
for (const [modelId, modelInfo] of Object.entries(cachedModels)) {
models[modelId] = modelInfo
}
} else {
// Fall back to the SDK's curated Baseten catalog. Same shape
// the live-fetch path produces, just with no API response to
// merge.
for (const [modelId, modelInfo] of Object.entries(sdkModels)) {
models[modelId] = {
maxTokens: modelInfo.maxTokens,
contextWindow: modelInfo.contextWindow,
supportsImages: modelInfo.supportsImages,
supportsPromptCache: modelInfo.supportsPromptCache,
inputPrice: modelInfo.inputPrice,
outputPrice: modelInfo.outputPrice,
cacheWritesPrice: modelInfo.cacheWritesPrice ?? 0,
cacheReadsPrice: modelInfo.cacheReadsPrice ?? 0,
description: modelInfo.description ?? `${modelId} model`,
supportsReasoning: modelInfo.supportsReasoning ?? false,
thinkingConfig: modelInfo.supportsReasoning ? { maxBudget: ANTHROPIC_MAX_THINKING_BUDGET } : undefined,
}
}
}
}
// Convert the Record<string, Partial<ModelInfo>> to Record<string, ModelInfo>
// by filling in any missing required fields with defaults
const typedModels: Record<string, ModelInfo> = {}
for (const [key, model] of Object.entries(models)) {
typedModels[key] = {
maxTokens: model.maxTokens ?? 8192,
contextWindow: model.contextWindow ?? 8192,
supportsImages: model.supportsImages ?? false,
supportsPromptCache: model.supportsPromptCache ?? false,
inputPrice: model.inputPrice ?? 0,
outputPrice: model.outputPrice ?? 0,
cacheWritesPrice: model.cacheWritesPrice ?? 0,
cacheReadsPrice: model.cacheReadsPrice ?? 0,
description: model.description ?? "",
tiers: model.tiers,
supportsReasoning: model.supportsReasoning || false,
thinkingConfig: model.supportsReasoning ? { maxBudget: ANTHROPIC_MAX_THINKING_BUDGET } : undefined,
}
}
// Store in StateManager's in-memory cache
StateManager.get().setModelsCache("baseten", typedModels)
return typedModels
}
/**
* Reads cached Baseten models from disk (application types)
*/
async function readBasetenModels(): Promise<Record<string, Partial<ModelInfo>> | undefined> {
const basetenModelsFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.basetenModels)
const fileExists = await fileExistsAtPath(basetenModelsFilePath)
if (fileExists) {
try {
const fileContents = await fs.readFile(basetenModelsFilePath, "utf8")
return JSON.parse(fileContents)
} catch (error) {
Logger.error("Error reading cached Baseten models:", error)
return undefined
}
}
return undefined
}
/**
* Validates if a model is suitable for chat completions
*/
function isValidChatModel(rawModel: any): boolean {
// Filter out non-chat models (whisper, TTS, guard models, etc.)
if (rawModel.id.includes("whisper") || rawModel.id.includes("tts") || rawModel.id.includes("embedding")) {
return false
}
// Check if model supports chat completions
if (rawModel.object === "model" && rawModel.id) {
return true
}
return false
}
/**
* Generates a descriptive name for the model
*/
function generateModelDescription(rawModel: any, staticModelInfo?: any): string {
// Use static description if available and preferred
if (staticModelInfo?.description) {
return staticModelInfo.description
}
// Use API description if available
if (rawModel.description) {
const contextWindow = rawModel.context_length
const quantization = rawModel.quantization
const features = rawModel.supported_features || []
let description = rawModel.description
// Add technical details if available
const technicalDetails = []
if (contextWindow) {
technicalDetails.push(`${contextWindow.toLocaleString()} token context`)
}
if (quantization) {
technicalDetails.push(`${quantization} precision`)
}
if (features.length > 0) {
const featureList = features.join(", ")
technicalDetails.push(`supports ${featureList}`)
}
if (technicalDetails.length > 0) {
description += ` (${technicalDetails.join(", ")})`
}
return description
}
// Fallback: use name or model ID
const modelName = rawModel.name || rawModel.id
const contextWindow = rawModel.context_length
const ownedBy = rawModel.owned_by || "Baseten"
if (contextWindow) {
return `${ownedBy} ${modelName} with ${contextWindow.toLocaleString()} token context window`
}
return `${ownedBy} model: ${modelName}`
export async function refreshBasetenModels(controller: ProviderCatalogController): Promise<Record<string, ModelInfo>> {
return resolveProviderModelsRecord(controller, "baseten")
}
@@ -1,316 +1,13 @@
import { getProviderCollectionSync } from "@cline/llms"
import { ensureCacheDirectoryExists, GlobalFileNames } from "@core/storage/disk"
import { ModelInfo } from "@shared/api"
import { fileExistsAtPath } from "@utils/fs"
import axios from "axios"
import fs from "fs/promises"
import path from "path"
import { StateManager } from "@/core/storage/StateManager"
import { adaptSdkModelInfo } from "@/sdk/model-catalog/shape-adapter"
import { telemetryService } from "@/services/telemetry"
import { getAxiosSettings } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
import { Controller } from ".."
import type { ModelInfo } from "@shared/api"
import { type ProviderCatalogController, resolveProviderModelsRecord } from "./providerCatalogShared"
/**
* Groq's curated catalog from the SDK, used as the static-pricing
* source for live API responses and as the offline fallback when the
* live fetch fails.
* Refreshes the Groq models and returns application types.
*
* Model catalogs are consolidated in the SDK: this resolves through the
* models.dev-backed catalog (bundled + live refresh) via
* `resolveProviderConfig`, the same source the CLI uses.
*/
function getGroqSdkModels(): Record<string, ModelInfo> {
const collection = getProviderCollectionSync("groq")
if (!collection) {
return {}
}
const result: Record<string, ModelInfo> = {}
for (const [modelId, sdkInfo] of Object.entries(collection.models)) {
result[modelId] = adaptSdkModelInfo(sdkInfo)
}
return result
}
// Track pending refresh promise to prevent duplicate concurrent fetches
let pendingRefresh: Promise<Record<string, ModelInfo>> | null = null
/**
* Core function: Refreshes the Groq models and returns application types
* @param controller The controller instance
* @returns Record of model ID to ModelInfo (application types)
*/
// TODO(sdk-consolidation): This handler live-fetches Groq's /models endpoint and
// enriches each model with curated pricing/capabilities. The SDK HAS a generic
// models-URL fetcher (sdk/packages/core/src/services/providers/model-source.ts
// `fetchModelIdsFromSource` + `resolveModelsSourceUrl`), but it is NOT a drop-in
// replacement:
// 1. It returns model *ids only*; ids unknown to the curated catalog get
// placeholder ModelInfo (no real pricing/context/capabilities).
// 2. Worse, `mergeKnownModels` treats a registered `modelsSourceUrl` as the
// "authoritative installed list" (Ollama/LM Studio semantics) and DISCARDS
// the bundled curated catalog when the live fetch returns results.
// So simply registering `modelsSourceUrl` for Groq would regress rich model
// metadata. Proper consolidation needs an SDK enhancement first: either a
// merge-mode that layers live ids on top of the curated catalog, or a richer
// per-provider fetch that parses full ModelInfo. Once the SDK supports that for
// all clients (incl. CLI), delete this extension-only handler + its RPC.
export async function refreshGroqModels(controller: Controller): Promise<Record<string, ModelInfo>> {
// Check in-memory cache first
const cache = StateManager.get().getModelsCache("groq")
if (cache) {
return cache
}
// If a fetch is already in progress, return the same promise
if (pendingRefresh) {
return pendingRefresh
}
// Start new fetch and track the promise
pendingRefresh = (async () => {
try {
return await fetchAndCacheModels(controller)
} finally {
// Clear pending promise when done (success or error)
pendingRefresh = null
}
})()
return pendingRefresh
}
async function fetchAndCacheModels(controller: Controller): Promise<Record<string, ModelInfo>> {
const groqModelsFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.groqModels)
const groqApiKey = controller.stateManager.getSecretKey("groqApiKey")
let models: Record<string, Partial<ModelInfo>> = {}
const sdkModels = getGroqSdkModels()
try {
if (!groqApiKey) {
Logger.log("No Groq API key found, using SDK catalog as fallback")
// Don't throw an error, just use SDK catalog.
for (const [modelId, modelInfo] of Object.entries(sdkModels)) {
models[modelId] = {
maxTokens: modelInfo.maxTokens,
contextWindow: modelInfo.contextWindow,
supportsImages: modelInfo.supportsImages,
supportsPromptCache: modelInfo.supportsPromptCache,
inputPrice: modelInfo.inputPrice,
outputPrice: modelInfo.outputPrice,
cacheWritesPrice: modelInfo.cacheWritesPrice ?? 0,
cacheReadsPrice: modelInfo.cacheReadsPrice ?? 0,
description: modelInfo.description ?? `${modelId} model`,
}
}
} else {
// Ensure the API key is properly formatted
const cleanApiKey = groqApiKey.trim()
if (!cleanApiKey.startsWith("gsk_")) {
throw new Error("Invalid Groq API key format. Groq API keys should start with 'gsk_'")
}
Logger.log("Fetching Groq models with API key:", cleanApiKey.substring(0, 10) + "...")
const response = await axios.get("https://api.groq.com/openai/v1/models", {
headers: {
Authorization: `Bearer ${cleanApiKey}`,
"Content-Type": "application/json",
"User-Agent": "Cline-VSCode-Extension",
},
timeout: 10000, // 10 second timeout
...getAxiosSettings(),
})
if (response.data?.data) {
const rawModels = response.data.data
for (const rawModel of rawModels) {
// Filter out non-chat models and validate model capabilities
if (!isValidChatModel(rawModel)) {
continue
}
// Check if we have static pricing information for this model
const staticModelInfo = sdkModels[rawModel.id as keyof typeof sdkModels]
const modelInfo: Partial<ModelInfo> = {
maxTokens: rawModel.max_completion_tokens || staticModelInfo?.maxTokens || 8192,
contextWindow: rawModel.context_window || staticModelInfo?.contextWindow || 8192,
supportsImages: detectImageSupport(rawModel, staticModelInfo),
supportsPromptCache: staticModelInfo?.supportsPromptCache || false,
inputPrice: staticModelInfo?.inputPrice || 0,
outputPrice: staticModelInfo?.outputPrice || 0,
cacheWritesPrice: staticModelInfo?.cacheWritesPrice || 0,
cacheReadsPrice: staticModelInfo?.cacheReadsPrice || 0,
description: generateModelDescription(rawModel, staticModelInfo),
}
models[rawModel.id] = modelInfo
}
await fs.writeFile(groqModelsFilePath, JSON.stringify(models))
Logger.log("Groq models fetched and saved", models)
} else {
Logger.error("Invalid response from Groq API")
}
}
} catch (error) {
Logger.error("Error fetching Groq models:", error)
// Provide more specific error messages
let errorMessage = "Unknown error occurred"
if (axios.isAxiosError(error)) {
if (error.response?.status === 401) {
errorMessage = "Invalid Groq API key. Please check your API key in settings."
} else if (error.response?.status === 403) {
errorMessage = "Access forbidden. Please verify your Groq API key has the correct permissions."
} else if (error.response?.status === 429) {
errorMessage = "Rate limit exceeded. Please try again later."
} else if (error.code === "ECONNABORTED") {
errorMessage = "Request timeout. Please check your internet connection."
} else {
errorMessage = `API request failed: ${error.response?.status || error.code || "Unknown error"}`
}
} else if (error instanceof Error) {
errorMessage = error.message
}
telemetryService.captureProviderApiError({
ulid: controller.task?.ulid || "",
errorMessage,
errorStatus: error.status,
model: "groq",
})
// If we failed to fetch models, try to read cached models first
const cachedModels = await readGroqModels()
if (cachedModels && Object.keys(cachedModels).length > 0) {
Logger.log("Using cached Groq models")
models = cachedModels
} else {
// Fall back to the SDK's curated Groq catalog.
Logger.log("Using SDK Groq catalog as fallback")
for (const [modelId, modelInfo] of Object.entries(sdkModels)) {
models[modelId] = {
maxTokens: modelInfo.maxTokens,
contextWindow: modelInfo.contextWindow,
supportsImages: modelInfo.supportsImages,
supportsPromptCache: modelInfo.supportsPromptCache,
inputPrice: modelInfo.inputPrice,
outputPrice: modelInfo.outputPrice,
cacheWritesPrice: modelInfo.cacheWritesPrice ?? 0,
cacheReadsPrice: modelInfo.cacheReadsPrice ?? 0,
description: modelInfo.description ?? `${modelId} model`,
}
}
}
}
// Convert the Record<string, Partial<ModelInfo>> to Record<string, ModelInfo>
// by filling in any missing required fields with defaults
const typedModels: Record<string, ModelInfo> = {}
for (const [key, model] of Object.entries(models)) {
typedModels[key] = {
maxTokens: model.maxTokens ?? 8192,
contextWindow: model.contextWindow ?? 8192,
supportsImages: model.supportsImages ?? false,
supportsPromptCache: model.supportsPromptCache ?? false,
inputPrice: model.inputPrice ?? 0,
outputPrice: model.outputPrice ?? 0,
cacheWritesPrice: model.cacheWritesPrice ?? 0,
cacheReadsPrice: model.cacheReadsPrice ?? 0,
description: model.description ?? "",
tiers: model.tiers,
}
}
// Store in StateManager's in-memory cache
StateManager.get().setModelsCache("groq", typedModels)
return typedModels
}
/**
* Reads cached Groq models from disk (application types)
*/
async function readGroqModels(): Promise<Record<string, Partial<ModelInfo>> | undefined> {
const groqModelsFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.groqModels)
const fileExists = await fileExistsAtPath(groqModelsFilePath)
if (fileExists) {
try {
const fileContents = await fs.readFile(groqModelsFilePath, "utf8")
return JSON.parse(fileContents)
} catch (error) {
Logger.error("Error reading cached Groq models:", error)
return undefined
}
}
return undefined
}
/**
* Validates if a model is suitable for chat completions
*/
function isValidChatModel(rawModel: any): boolean {
// Check if model is active (if the property exists)
if (Object.hasOwn(rawModel, "active") && !rawModel.active) {
return false
}
// Filter out non-chat models (whisper, TTS, guard models, etc.)
if (
rawModel.id.includes("whisper") ||
rawModel.id.includes("tts") ||
rawModel.id.includes("guard") ||
rawModel.id.includes("embedding") ||
rawModel.id.includes("moderation") ||
rawModel.id.includes("allam")
) {
return false
}
// Check if model supports chat completions
if (rawModel.object === "model" && rawModel.id) {
return true
}
return false
}
/**
* Detects if a model supports image input
*/
function detectImageSupport(rawModel: any, staticModelInfo?: any): boolean {
// Use static info if available
if (staticModelInfo?.supportsImages !== undefined) {
return staticModelInfo.supportsImages
}
// Detect based on model name patterns
const modelId = rawModel.id.toLowerCase()
if (modelId.includes("vision") || modelId.includes("maverick") || modelId.includes("scout")) {
return true
}
return false
}
/**
* Generates a descriptive name for the model
*/
function generateModelDescription(rawModel: any, staticModelInfo?: any): string {
// Use static description if available
if (staticModelInfo?.description) {
return staticModelInfo.description
}
// Generate description based on model characteristics
const modelId = rawModel.id
const contextWindow = rawModel.context_window || 8192
const ownedBy = rawModel.owned_by || "Unknown"
// Special handling for new models
if (modelId.includes("compound")) {
return `${ownedBy}'s ${modelId} model with ${contextWindow.toLocaleString()} token context window - Advanced compound architecture`
}
return `${ownedBy} model with ${contextWindow.toLocaleString()} token context window`
export async function refreshGroqModels(controller: ProviderCatalogController): Promise<Record<string, ModelInfo>> {
return resolveProviderModelsRecord(controller, "groq")
}
@@ -1,78 +1,20 @@
import { GlobalFileNames } from "@core/storage/disk"
import { EmptyRequest } from "@shared/proto/cline/common"
import { OpenRouterCompatibleModelInfo, OpenRouterModelInfo } from "@shared/proto/cline/models"
import axios from "axios"
import fs from "fs/promises"
import path from "path"
import { getAxiosSettings } from "@/shared/net"
import { Controller } from ".."
import { OpenRouterCompatibleModelInfo } from "@shared/proto/cline/models"
import { toProtobufModels } from "@/shared/proto-conversions/models/typeConversion"
import { type ProviderCatalogController, resolveProviderModelsRecord } from "./providerCatalogShared"
/**
* The raw model information returned by the Hicap API to list models
* Refreshes the Hicap models and returns the updated model list.
*
* Model catalogs are consolidated in the SDK: `resolveProviderConfig` serves
* the models.dev-backed catalog and, when a Hicap API key is configured,
* merges in the SDK's authenticated Hicap model fetch the same source the
* CLI uses.
*/
interface HicapRawModelInfo {
id: string
object: string
}
/**
* Refreshes the Hicap models and returns the updated model list
* @param controller The controller instance
* @param request Empty request object
* @returns Response containing the OpenRouter models
*/
// TODO(sdk-consolidation): Live-fetches Hicap's /models endpoint. The SDK's
// generic models-URL fetcher returns ids-only and (for providers with a
// registered modelsSourceUrl) REPLACES rather than merges the curated catalog,
// so a naive migration would regress metadata. See the detailed note in
// refreshGroqModels.ts; share via the SDK + delete this handler + RPC once the
// SDK supports rich/merged per-provider live models for all clients (incl. CLI).
export async function refreshHicapModels(controller: Controller, _request: EmptyRequest): Promise<OpenRouterCompatibleModelInfo> {
const hicapModelsFilePath = path.join(await ensureCacheDirectoryExists(controller), GlobalFileNames.hicapModels)
const models: Record<string, OpenRouterModelInfo> = {}
try {
// Get the Hicap API key from the controller's state
const hicapApiKey = controller.stateManager.getSecretKey("hicapApiKey")
const response = await axios.get("https://api.hicap.ai/v2/openai/models", {
headers: {
"api-key": hicapApiKey,
},
...getAxiosSettings(),
})
if (response.data?.data) {
const rawModels = response.data.data
for (const rawModel of rawModels as HicapRawModelInfo[]) {
models[rawModel.id] = {
maxTokens: -1,
contextWindow: 128_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 0,
outputPrice: 0,
cacheWritesPrice: 0,
cacheReadsPrice: 0,
tiers: [],
description: "",
}
}
}
await fs.writeFile(hicapModelsFilePath, JSON.stringify(models))
} catch (_error) {
// If we failed to fetch models, keep whatever we have.
}
return OpenRouterCompatibleModelInfo.create({ models })
}
/**
* Ensures the cache directory exists and returns its path
*/
async function ensureCacheDirectoryExists(controller: Controller): Promise<string> {
const cacheDir = path.join(controller.context.globalStorageUri.fsPath, "cache")
await fs.mkdir(cacheDir, { recursive: true })
return cacheDir
export async function refreshHicapModels(
controller: ProviderCatalogController,
_request: EmptyRequest,
): Promise<OpenRouterCompatibleModelInfo> {
const models = await resolveProviderModelsRecord(controller, "hicap")
return OpenRouterCompatibleModelInfo.create({ models: toProtobufModels(models) })
}

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