Compare commits

...
Author SHA1 Message Date
Saoud RizwanandClaude Opus 5 bf4a364e08 fix(desktop): pin the macOS app-icon closure's error type
set_app_icon's main-thread closure ends in Ok(()) and uses ? on String
errors, so its error type was only constrained by E: From<String> —
ambiguous, since String has many From impls (E0282 + E0283). The block is
behind #[cfg(target_os = "macos")], so the Windows build compiled past it
and desktop-publish.yml is the only workflow that builds the Tauri macOS
binary, which is why this reached main and only surfaced when cutting
v0.0.24.

Annotate the closure as Result<(), String>.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KBdgHNDCfm7WKP8qbvn7cr
2026-09-09 00:56:59 -07:00
Saoud Rizwan fee4fb96f2 Improve README CLI section formatting
Removed line break for better readability.
2026-09-09 00:38:16 -07:00
Saoud RizwanandClaude Opus 5 eb18ce3407 chore(desktop): note the queued-prompt and observer-gate fixes in v0.0.24
Adds #13979 (queued prompt's user bubble) and #13981 (new-task idle
flicker), and rewrites the stream-duplication entry: #13978 replaced the
timer-based observer standdown from #13968/#13976 with a direct
ClineCore subscription check, so the shipped mechanism is no longer the
5s/busy-run window the note described.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KBdgHNDCfm7WKP8qbvn7cr
2026-09-09 00:31:47 -07:00
Saoud RizwanandClaude Fable 5.1 9fb1e6fc25 fix(desktop): keep a new task on "starting" while the hub reports the fresh session idle (#13981)
* fix(desktop): keep a new task on "starting" while the hub reports the fresh session idle

Sending the first prompt of a new task sets the session to "starting" and
issues the start RPC. While that RPC is in flight the hub publishes
session.created and two session.updated events carrying the new record's
status, "idle", and the sidecar forwards each as chat_session_status. The
webview applied them over "starting", then flipped back to "running" once
run.started arrived, so on every new task the composer placeholder and the
request indicator switched to the idle state and back for a frame.

The status handler already drops a "running" that trails a settled turn
as stale. Add the reverse guard: while a local prompt submission is in
flight, a non-busy status predates the run it is about to start and is
dropped. The submission owns status until it hands off, to the queued
start event or to its own completion for a blocking send. Once nothing is
in flight the hub's status applies as before, so the idle a drained turn
relies on is unaffected.

Test replays the sequence with the sidecar reusing the planned session id
(as it does): idle during the start RPC leaves "starting", the queued send
then reaches "running", and an idle afterwards applies. Disabling the
guard fails it with "expected starting, received idle".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wnz9AtY5MrXddi9sfdzJnr

* fix(desktop): hold back only the transient idle while a submission is in flight

Narrow the new guard from every non-busy status to "idle". The
created-session flicker is always an idle, and a terminal status (failed,
aborted) that lands during a submission is real: it must still unstick the
UI if the send response never arrives. Test pins that a failed status
during the start RPC is applied.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wnz9AtY5MrXddi9sfdzJnr

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 00:29:48 -07:00
Saoud RizwanandClaude Fable 5.1 40f1d75bd2 fix(desktop): keep a queued prompt's bubble when the previous send resolves after it starts (#13979)
* fix(desktop): keep a queued prompt's bubble when the previous send resolves after it starts

When a prompt is queued behind a blocking send, the runtime drains the queue
before it answers that send: `LocalRuntimeHost.runTurn` schedules the drain
as a microtask right before returning the result. So the queued prompt's
`chat_queued_prompt_start` reaches the webview ahead of the previous turn's
send response, the webview appends the new user bubble, and then the
blocking send's completion path runs.

That path treated the transcript as its own: it re-applied the result's
assistant text (minting a fresh bubble, since the queued start had already
reset the active assistant id), materialized tool rows, and replaced the
whole transcript from a canonical read. The transcript is persisted only at
iteration boundaries, so at that moment the canonical read ends at the
previous turn's assistant message and the replace erased the queued user
bubble. The reply then streamed in under no user message; the bubble only
came back when the queued turn's own reconcile ran, or on re-hydrate.

Make the blocking-send completion path defer to a newer turn the same way
the queued branch already does: once the epoch has moved past the one
captured at dispatch, skip every transcript write (assistant text, media,
tool rows, canonical recovery and replace) and leave the live refs alone in
the `finally`, since they belong to the turn in flight. The newer turn's
completion reconciles history when it ends. Token and cost bookkeeping
still applies.

Regression test replays the exact order: blocking send in flight, its
text streamed, queued-start for the next prompt, then the send resolves
with a canonical read that predates the queued message. Disabling the guard
fails it (the user bubble is gone and the essay appears twice).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wnz9AtY5MrXddi9sfdzJnr

* fix(desktop): settle a queued turn from the session status when chat_done never arrives

A turn drained from the prompt queue does not always deliver `chat_done`.
The hub runtime host suppresses a second `done` for a session until it
sees `run.started`, and a drained turn never publishes one; when the
queue command lands while the previous turn is still running (the normal
way to queue), the previous turn's `done` re-arms that suppression and
the drained turn's `done` is swallowed. The sidecar's chunk log for every
such turn today ends at `chat_usage`.

Without `chat_done` nothing settles the turn in the webview: the assistant
bubble stays in its streaming state and the persisted-history reconcile
never runs, so live rows keep whatever the stream produced. This is why a
queued prompt's reasoning row stayed on "Thinking" after its reply, and
why the earlier wiped user bubble never came back on its own.

The hub's session status is authoritative and already reaches the webview
as `chat_session_status`. When it reports a non-busy status and the stream
has not settled the current turn, settle it there: clear the streaming
state and schedule the same reconcile `chat_done` would have. A turn that
`chat_done` already settled is left alone (epoch equality), as is an
aborted one. The idle the hub publishes between a finished turn and the
drained one it hands off to also lands here; the queued prompt's start
bumps the epoch before that reconcile fires, so it is skipped.

Tests: one replays queued-start → reasoning → text → status idle with no
`chat_done` and asserts the streaming id clears and the transcript is
reconciled; one asserts a trailing idle after `chat_done` schedules no
second reconcile. Disabling the fallback fails the first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wnz9AtY5MrXddi9sfdzJnr

* Revert "fix(desktop): settle a queued turn from the session status when chat_done never arrives"

This reverts commit 8ee3f40edf.

* fix(desktop): stamp live chat rows on the webview clock, not the sidecar's

The thought duration on a reasoning row is the row's timestamp minus the
preceding row's. For a typed prompt the preceding row is the optimistic
user bubble, stamped with the webview's clock, while every live row the
stream produced was stamped with the sidecar's `ts`. Those are the same
clock in the packaged app but not when the sidecar runs elsewhere (the
browser-dev container, a remote hub). With the sidecar's clock trailing the
browser by more than the time to first token, the subtraction went
negative, the duration was dropped, and a finished reasoning row rendered
as a durationless "Thinking" under its brain icon instead of "Thought for
Ns". The previous turn's canonical replace used to hide it by re-rendering
from runtime-stamped rows; now that a newer turn correctly keeps its live
transcript, it showed.

Every timestamp a live row is compared against is this process's clock:
the optimistic bubble, `hydrationStartedAt`, `turnStartedAt`, and the
preceding row in the duration subtraction. Two of those comparisons were
already mixing clocks. Stamp live rows with `Date.now()` on arrival so all
of them are consistent; persisted rows keep the runtime's timestamps and
stay consistent among themselves.

Tests: a new one streams reasoning with a sidecar `ts` 15s behind the
browser and asserts the row still yields a thought duration; the old
"keeps live stream timestamps in milliseconds" test pinned the sidecar
timestamp and is rewritten to assert the webview clock.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wnz9AtY5MrXddi9sfdzJnr

* fix(desktop): leave status to the queued turn when the previous send resolves late

After a queued prompt has started its turn, the previous blocking send's
completion path still ended by settling status: `setStatus("completed")`
and `turnSettledEpochRef = turnEpochRef`, i.e. it settled the new turn's
epoch, not its own. Two visible effects. The composer left the busy state
while the queued reply was still pending, so nothing indicated a request
was in flight after the queued message went out. And the hub's
`session.updated running` for the new turn, which arrives afterwards, was
dropped by the stale-"running" guard, since a "running" at a settled epoch
reads as stale.

When a newer turn owns the transcript, leave status and the settled epoch
to it: its start set "running", and its own completion settles it. The
failure text for an errored previous turn is still appended.

The queued-bubble test now asserts the session stays "running" after the
late response and that a following "running" status is applied; forcing
the guard off fails it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wnz9AtY5MrXddi9sfdzJnr

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 00:12:10 -07:00
Saoud RizwanandSaoud Rizwan b18de0904f fix(desktop): gate the observer stream on ClineCore's subscription, not a timer (#13978)
* feat(core): expose whether ClineCore is subscribed to a hub session

HubRuntimeHost subscribes to a session as a side effect of starting,
sending to, or listing pending prompts for it, and unsubscribes on stop.
Clients that also observe the hub directly need that fact to decide
which copy of a session's events to render.

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

* fix(desktop): gate the observer stream on ClineCore's subscription, not a timer

The sidecar has two hub sockets that both receive a session's events:
ClineCore's own client and the observer client. #13968 and #13976 muted
the observer's copy by inferring whether ClineCore was serving the
session from a per-session timestamp (refreshed on every core event,
expired after 5 s, held while busy, and manually forgotten on every
stop path). That inference had to be patched twice and still raced the
first event of each run.

Ask ClineCore instead. hasSessionSubscription is the fact the timestamp
was approximating, it is set before the subscribe frame is even sent
and cleared by the same stop that disposes the subscription, so there
is nothing to refresh, expire, or forget. The observer projection is
skipped as a whole (status and ended too, which the core pipe also
carries), and the boot-id fix from #13968 is unchanged.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-08 21:24:58 -07:00
Saoud RizwanandClaude Opus 5 de1d2a322c chore(desktop): release v0.0.24
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KBdgHNDCfm7WKP8qbvn7cr
2026-09-08 20:03:56 -07:00
Bee 245a7d0ccb fix(desktop): show token usage and cost for every visible session-history page (#13971)
* fix(desktop): hydrate session usage for the visible page, not just the first four rows

Discovery rows carry no token or cost totals; the history hook sums them
from each session's transcript in a second read, and that read was
hard-coded to the four most recent sessions. Every other row in the
sessions view rendered "-" for tokens and cost, and paging never asked
for more.

Widen the default window to the first ten rows (one page of the sessions
view, and the sidebar's initial threads), expose requestUsage() so a view
can ask for the rows it is showing, and have the sessions view report its
visible page so moving to older pages fills them in on demand. Transcript
reads are capped at four in flight, since each one parses a whole session
file in the sidecar.

* fix(desktop): enforce the usage-read cap across hydration effect restarts

The four-read cap was a counter local to one run of the hydration effect.
A session refresh or page change restarts the effect while reads are
still pending, and the new run started from zero, so four more reads
could join the four already in flight.

Check the cap against usageLoadingRef, which counts every read in flight
across runs, and have a finishing read pump the current run's queue via
usagePumpRef so a freed slot goes to the newest queue. Track hydrated
usage in a synchronous usageByIdRef instead of threadsRef, which lags
React's commit and made a just-finished row look unhydrated and get read
again. The delete handler no longer clears the in-flight gauge for a row
whose read is still running.

The regression test restarts the effect with four reads pending and
checks that no fifth read starts, that the restarted queue still drains
as the earlier reads finish, and that no session is read twice.

* fix(desktop): re-read a session whose status changed while its usage read was pending

A restarted hydration run dropped any row that already had a read in
flight. If the row's status had changed in the meantime (a running
session finishing is the common case), the pending read's result was
already stale when it landed, and nothing read the row again until some
later refresh happened to restart the effect. The row could sit on the
totals from before its last turn indefinitely.

Record the status each in-flight read was started under in
usageLoadingRef. A restarted run skips a row whose pending read was
started under its current status and defers one whose status has moved:
the row stays queued, and when the pending read settles and records the
status it was started under, the mismatch makes the next pump read the
row again ahead of rows never read.

Also bound the on-demand set: requestUsage now replaces the requested
ids instead of accumulating them, and the sessions view releases its page
on unmount, so a running session on a page the user has left is not
re-read on every refresh. The equality guard keeps the same Set instance
when the members are unchanged, so the view re-reporting its page on
every threads change does not restart the effect.

Tests: the status-change case (four reads pending, session-3 goes
running -> completed, its stale read finishes, it is read once more
before session-4) and the release case (a running row is re-read while
requested and left alone after the request is cleared). Both fail on the
previous commit.
2026-09-08 19:52:59 -07:00
Saoud RizwanandClaude Fable 5.1 23f2197c53 fix(desktop): ask the user how to continue when the mistake limit trips instead of stopping silently (#13969)
* desktop: ask the user how to continue when the mistake limit trips

The core's loop detector stops a run after 5 identical consecutive tool
calls (and the mistake tracker after 6 consecutive failures) by asking the
client for a decision via onConsecutiveMistakeLimitReached. The desktop
never registered that callback, so the SDK default "stop" applied and the
webview rendered the result exactly like the Stop button: the composer went
idle with no message. Users on models that fall into identical-call loops
(reported with cline-pass/kimi-k3 re-sending `editor` with old_text: null)
saw Cline "randomly stop" mid-task, and a nudge died after one more call
because the identical-call counter survives across turns.

Mirror the CLI's interactive handling (apps/cli/src/runtime/interactive/
mistakes.ts): route the decision through the sidecar's existing ask-question
channel with "Try a different approach" / "Stop this run". The prompt reads
the session id lazily because fresh starts only learn it after
manager.start() resolves and the webview matches prompts by active session.

On continue, steer the guidance into the running turn via manager.send
delivery "steer". The core appends its own guidance to a transcript store
the live runtime never reads mid-run, so without this the model would
resume with no idea why it was paused and repeat the same call.

Wired into every desktop start path: start, provider-change rebuild, fork,
and checkpoint restore. No webview changes; it already renders ask-question
requests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XJb3VtvdSm9xk9opJ3Q9G6

* fix(desktop): make mistake-limit recovery pause and cancel reliably

* fix(desktop): keep mistake recovery within the desktop app

* fix(desktop): wait for mistake recovery decisions before continuing

* fix(desktop): settle unfinished tool rows when a run stops

* fix(desktop): harden mistake recovery and terminal cleanup

* fix(desktop): confirm session status before settling tools

* fix(desktop): scope stopped tool recovery to mistake prompts

* fix(desktop): deliver mistake guidance only through steering

* fix(desktop): simplify stopped tools to a rendering change

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 19:45:24 -07:00
Saoud RizwanandClaude Fable 5.1 9df2876c89 fix(desktop): keep the observer stream stood down for the whole busy run (#13976)
* fix(desktop): keep the observer stream stood down for the whole busy run

The core-pipe liveness mark from #13968 expired after 5s of silence even
mid-turn. Long commands, slow first tokens, and unanswered tool approvals
stall both hub pipes together, so the observer's copy of the first event
after such a gap arrived ahead of the core copy and was emitted, doubling
a delta or leaving a duplicate tool row stuck at "start" until the
turn-end reconcile.

While the session is busy, treat the mark as active regardless of age;
the 5s window now only governs idle sessions. The mark is still cleared
on session end, so an observer-only session is unaffected.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011kHp3XEsLYaZ1Bgp4yUxf3

* fix(desktop): forget the core pipe mark when the sidecar stops a session

stop() disposes the ClineCore subscription without any local `ended`
event, so the activity mark from the previous run survived. With the
mark now treated as active for the whole busy run, a later run another
client started on the same session would set busy via the observer's
run.started and every observer chunk would be dropped with no core
subscription left to serve it.

Clear the mark on every sidecar stop path (stop command, provider-change
rebuild and its rollback, reset). Abort keeps the subscription and needs
nothing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011kHp3XEsLYaZ1Bgp4yUxf3

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 19:26:43 -07:00
John Choi b63c738c22 feat(sdk): support authenticated remote Hub connections (#13519)
* feat(sdk): support authenticated remote Hub connections

* fix(sdk): pin compatible SAP connectivity

* chore(sdk): defer SAP smoke fix to main

* fix(sdk): preserve hub connection failures

* docs(sdk): clarify remote Hub connection headers

* test(sdk): tighten hub header coverage

* test(core): batch root history fixture inserts

* test(sdk): await daemon health after discovery publication
2026-09-08 16:59:57 -07:00
Saoud Rizwan bbacedd437 fix(desktop): preserve Cline Pass model selection across new chats (#13975) 2026-09-08 16:57:58 -07:00
Dominic CooneyandSaoud Rizwan 4096eca8a1 feat(desktop): custom Windows title bar (#13831)
* feat(desktop): add custom Windows title bar

* fix(desktop): keep Windows caption controls inside the compact title-bar row

The fixed caption controls stayed h-12 when the title-bar row shrinks to
its max-md:h-7 compact height, so they overlapped page content in narrow
windows. The controls now follow the same responsive height.

Also cover the resize-driven Maximize/Restore label transitions with a
test that invokes the captured onResized listener.

* fix(desktop): keep Windows caption controls above overlays

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-09-08 16:45:26 -07:00
Saoud RizwanandClaude Fable 5.1 2f0f78bb8e fix(tools): tell the model how to recover when editor old_text is null (#13970)
* fix(tools): tell the model how to recover when editor old_text is null

The editor schema declares old_text as nullable+optional, so the JSON
schema the model receives is anyOf[string, null]. Models that fill optional
parameters with null (observed with cline-pass/kimi-k3) then hit a terse
"old_text is required" error for existing files and re-send the identical
call until the loop detector stops the run.

Spell out in the schema description that null/omitted is only valid when
creating a file or inserting via insert_line, and make the executor error
name the file, say whether old_text was null or omitted, and state the
recovery so the next call has a reason to differ.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XJb3VtvdSm9xk9opJ3Q9G6

* revert schema description and test changes; keep only the executor error message

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XJb3VtvdSm9xk9opJ3Q9G6

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 16:14:57 -07:00
Saoud RizwanandClaude Opus 5 62ba65397f fix(desktop): stop the live chat stream from doubling and dropping chunks (#13968)
* fix(desktop): emit each streamed chat chunk once

The sidecar has two pipes into `emitChunk`: the ClineCore session
subscription (`handleCoreSessionEvent`) and the hub observer client
(`handleHubLiveEvent`, gated on `attachedViaHub`). Opening a session arms
both — the hydrate's `pending_prompts` call makes `HubRuntimeHost`
subscribe to the session, and `attach` sets `attachedViaHub` — so for a
session streaming through the hub every delta was emitted twice.

Neither existing guard caught it. Both copies go through `emitChunk`, so
each gets its own increasing `index`, which is what the webview's replay
guard compares; and the webview's `endsWith` fallback is defeated by the
50ms coalescing buffer, which concatenates the duplicated deltas before
comparing them. `attachedViaHub` is cleared when the webview *sends*, so
this only showed on sessions that stream without a local send first — a
run already in flight when the task is opened, a resumed or scheduled run
— and the canonical store was always clean, so reopening the task
rendered correct text.

Arbitrate instead of guessing which pipe owns a session: the first source
to deliver a contended stream wins and the other is muted until the owner
falls silent for 5s. Ownership is per stream, so a pipe that wins one
stream cannot mute another it does not itself carry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNoho8fGgnh71xjQF5xMqy

* fix(desktop): keep rendering the live stream when the sidecar restarts

`shouldApplyStreamChunk` drops any chunk whose `index` is not above the
highest one already seen for that session. That counter lives in the
sidecar process (`ctx.streamIndices`), but the webview only cleared its
high-water mark in `hydrateSession`, never on reconnect. So when a sidecar
was replaced under a live webview — crash-respawn, hub drain-and-replace,
a stale-sidecar swap — the replacement started numbering at 1 again and
the webview silently discarded everything for that session until the new
process counted past the old run.

The guard runs ahead of any per-stream handling, so this dropped far more
than assistant text: `chat_queued_prompt_start` (the user's own message
bubbles) and the tool-call rows went with it. The transcript only looked
broken live — the turn-end reconcile and switching tasks both re-read
canonical history and repaired it, which is why it presented as rows that
vanish mid-turn and come back afterwards.

Stamp each chunk with the emitting sidecar's boot id so a counter reset is
a fact rather than an inference: a changed boot id means a new process, so
the mark is rebased instead of swallowing the stream. Replays from the
same process are still dropped exactly as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNoho8fGgnh71xjQF5xMqy

* refactor(desktop): let the core pipe decide, not a list of stream names

The first pass arbitrated a hardcoded set of six `chat_*` stream names —
a third copy of knowledge already encoded in the two switch statements
that emit them, and one a later stream would silently drift out of.

The two pipes are not peers, so they do not need symmetric arbitration.
ClineCore's session subscription is the primary; the hub observer's
projection exists to cover sessions ClineCore is not subscribed to. Any
event reaching `handleCoreSessionEvent` proves it is subscribed, so that
pipe records its own liveness and the observer stands down while it is
serving. Dropping an observer chunk for a stream the observer never
produces is a no-op, so the list has nothing left to do.

Liveness is marked from the pipe rather than from `emitChunk`, so chunks
the sidecar synthesizes locally never claim to be the core subscription.

Net: the stream-name list, the per-stream owner map and its type are
gone, and detection now starts at the session's first core event of any
kind instead of its first contended chunk.

Also fills in `coreStreamActivity` on the partial `as unknown as
SidecarContext` fixtures in chat-session.test.ts — they bypass the type
checker, so a missing field only surfaces as a runtime crash.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNoho8fGgnh71xjQF5xMqy

* docs(desktop): record why core-pipe liveness marks on every event

Review asked why status and queue events mark the pipe active when they
carry no chat content. Marking only on content would be worse, and the
reason is not local to this function: the hub fans out to listeners in
registration order, so the observer's global subscription (registered at
sidecar boot) sees each delta before this per-session one (registered at
hydrate). Waiting for core content to establish the mark would let the
observer's copy of a turn's first delta through before the mark existed,
doubling it every turn.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNoho8fGgnh71xjQF5xMqy

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 16:04:14 -07:00
Saoud RizwanandSaoud Rizwan 79bf1e8c48 Imported sessions: warn in the chat and summarize the foreign history on resume (#13964)
* desktop: flag sessions imported from other coding agents in the chat

Imported Claude Code / Codex / opencode transcripts keep the source tool's
own tool names and schemas, so resuming them in Cline can behave worse than
a native session. Lead the transcript with a notice naming the source tool
so the user knows why.

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

* core: summarize imported sessions on their first resumed turn

Imported transcripts keep the source agent's tool names and schemas, which a
model continuing them may try to call. When a session marked importedFrom is
resumed without a compaction sidecar, run a manual agentic compaction over
the whole foreign history before the first model request. The summary lands
in the sidecar, so it runs once and the canonical transcript stays intact;
on failure the turn falls back to the raw history.

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

* desktop: show a status row while an imported session's history is summarized

Core tags the resume-time compaction notices with the source tool; the sidecar
now forwards notice metadata and the webview turns the started/completed pair
into one in-place status row in the transcript.

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

* desktop: keep the imported-history summary row through canonical rehydration

The row is client-only, so applyCanonicalHistory re-seats it by timestamp
instead of dropping it when the persisted transcript replaces live state.

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

* desktop: name the imported-history summary in the pre-output indicator instead of a transcript row

The summary is not part of the persisted transcript, so a client-only row
had to be re-seated after every canonical rehydration. Show it where the
ephemeral state already lives: the "Thinking..." indicator reads
"Summarizing the imported <tool> history..." while it runs, and the
imported-session notice states that the model works from a summary.

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

* review cleanup: extract imported-history compaction policy, fold client helper into session-import

- core: createImportedHistoryCompactionPrepareTurn lives in compaction.ts
  next to the other prepareTurn builders; the host only decides when it
  applies. Fix a tool_result fixture missing its name (tsc, not vitest).
- desktop: readImportedHistorySummaryActivity moves into session-import.ts
  alongside readImportedFromTool, with one test file for both.
- trim comments; note the policy in sdk/ARCHITECTURE.md.

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

* docs: note the imported-session compaction policy in sdk/ARCHITECTURE.md

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

* address review: retry imported-history summary after abort, stand down on a projected sidecar, clear the activity label on hydrate

- An aborted summarizer call no longer consumes the single attempt, so the
  next turn retries instead of replaying the raw foreign transcript.
- The policy now applies to every imported resume and skips only when the
  working context already opens with a compaction summary. A stale sidecar
  that fails projection therefore gets re-summarized rather than bypassed,
  and the host no longer gates on the sidecar's mere existence.
- hydrateSession resets activityLabel like the rest of its per-turn state.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-08 15:21:57 -07:00
Saoud Rizwan ffd65af086 fix(core): keep root sessions in history when child rows crowd the scan window (#13887)
* fix(core): keep root sessions in history when child rows crowd the scan window

listSessionHistory over-fetched a fixed 2x window from the backend and then
dropped subagent/team-task child rows. Children always sort after the root
that spawned them, so one session with more children than the window hid
itself and every older root, and the desktop sidebar (limit 50) rendered an
empty history with no way to load more. Widen the scan until the requested
page of roots fills or the backend runs out of rows.

* fix(core): filter root sessions in persistence for history listing

Review follow-up: widening the client-side scan still hit the 2000-row cap,
so 2000+ child rows ahead of a root left history empty. Add a rootOnly option
to the persistence adapters (SQL WHERE on is_subagent / parent_session_id),
carry it through UnifiedSessionPersistenceService, RuntimeHost, both host
implementations, and the hub session.list payload, and have history listing
request it. The client-side filter and widening stay as a fallback for older
hubs that ignore the flag.

* test(core): pin rootOnly forwarding in LocalRuntimeHost and document root-only history listing
2026-09-08 14:32:00 -07:00
Saoud RizwanandSaoud Rizwan 119fa4ea03 feat(core): mark imported sessions with an import history origin and stamp it on telemetry (#13886)
* feat(core): stamp imported sessions with an import history origin

Imported sessions now carry sessionHistoryOrigin { mode: "import", trigger: <tool> }
alongside the existing importedFrom marker, so the messages file origin block and
downstream telemetry can separate transcripts that did not originate in Cline. The
top-level source stays the client surface (desktop), matching how scheduled runs
record automation/hub-schedule.

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

* fix(core): keep the stored history origin when resuming a session

The start input always carries a default user origin, which the resume path merged
over the manifest's metadata and then persisted on the first git metadata refresh,
so automation and import provenance was lost as soon as a session was continued.

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

* feat(desktop): show the source agent icon on imported sessions

Sidebar rows and the Sessions list render a Claude / OpenAI / opencode mark (Simple
Icons, CC0) next to sessions imported from that agent, and the hover card lists the
import source.

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

* Revert "feat(desktop): show the source agent icon on imported sessions"

This reverts commit c9dd674487.

* refactor(core): inline the import history origin mode

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

* fix(core): drop the stored trigger when a resume overrides the mode

An explicit start-input mode now replaces the stored history origin as a
whole instead of pairing the new mode with the previous trigger.

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

* feat(core): stamp session_origin on every telemetry event a session emits

The runtime host resolves the session's history origin before bootstrap and
the bootstrap scopes the session telemetry with session_origin (mode) and
session_origin_trigger, using the same non-owning scope the Hub already
applies for client identity. Errors from imported transcripts can now be
filtered with session_origin = import.

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

* test(core): use CORE_TELEMETRY_EVENTS constants in session origin tests

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-08 14:31:22 -07:00
Saoud RizwanandSaoud Rizwan 40a7e6526d fix(core): do not build a file index for the home directory or filesystem root (#13960)
* fix(core): do not build a file index for the home directory or filesystem root

Running cline from $HOME and typing an @ mention could take the TUI to
many GB of RSS and get it OOM-killed: the file index listed every file
under the home directory and the mention picker re-ranks the whole index
on each keystroke. Skip indexing entirely when the workspace root is the
home directory or filesystem root.

Refs #13930, #13905

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

* fix(core): canonicalize paths in the home/root index guard

Compare realpaths so a symlinked or differently-cased (Windows) spelling
of the home directory still hits the guard. Add a filesystem-root test.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-08 13:55:43 -07:00
Bee 3d5070705c feat(desktop): Enable web search by default outside YOLO mode CLINE-3214 (#13957)
* feat(core): enable web search by default outside yolo

* fix(core): fail closed when tool settings cannot load
2026-09-08 22:11:14 +02:00
Mikołaj Kondratek 5a3b870d85 fix(core): stop checkpoints from re-hashing unchanged untracked files every turn (#13199)
* fix(core): stop checkpoints from re-hashing unchanged untracked files every turn

Checkpoint creation rebuilt a throwaway GIT_INDEX_FILE on every user
turn, so git re-read and re-hashed every untracked file before each
model call — with multi-GB untracked data files this blocks every
message for seconds to minutes (~90s in the report from #13131, on a
cloud-synced Windows workspace).

Keep one snapshot index per session in a scratch dir instead: git's
stat cache then skips unchanged files, and from the second turn the
snapshot cost is roughly git process overhead. Entries that fall out
of the untracked set (file deleted or became tracked) are removed each
turn so they cannot ghost into snapshot trees; a corrupt index heals
with one rebuild-and-retry; deleteCheckpointRefs removes the scratch
dir with the refs.

Also adds two telemetry events so checkpoint cost is observable in the
field: checkpoint.snapshot (outcome + duration per snapshot attempt)
and checkpoint.restore (outcome + duration per restore). Durations and
outcomes only — never file paths.

Snapshot contents are byte-identical to before — no size caps, no
timeouts, no restore-behavior changes. Those are tracked separately
pending the team decision on checkpoint semantics.

Part of #13131

* fix(core): harden the persistent checkpoint scratch index

Self-review findings on the persistent-index change, applied together
because they share one root cause: a throwaway mkdtemp directory became
a durable, addressable one, which changed the failure model.

- Relocate the scratch dir from the world-shared OS tmpdir to
  <cline-data-dir>/checkpoint-scratch/<sha256(cwd+sessionId)>, created
  0700. The index and pathspec files enumerate workspace paths, so they
  no longer live world-readable at a guessable path; hashing removes
  sanitization collisions between distinct session ids and keys the
  cache to the workspace it was built from.
- Clear index.lock alongside index in the rebuild path: a git process
  killed mid-add leaves the lock behind, and without this every later
  turn of the session failed the add and degraded to HEAD-only
  checkpoints permanently.
- Do not rebuild on pathspec-match failures (a listed file vanishing
  before the add): that is a per-turn race, and rebuilding threw away
  the whole cross-turn stat cache for it.
- Normalize trailing slashes in the stale-entry sweep: ls-files reports
  an untracked nested repo as "sub/" while the index records the
  gitlink as "sub", so the sweep purged the gitlink the same turn it
  was added and snapshots silently lost nested repos.
- Replace the argv-batched update-index loop with one "-z --stdin"
  invocation (the no-pathspec-from-file workaround was built on a false
  premise).
- Reap scratch dirs idle for 14 days when hooks are created; explicit
  session deletion still removes them immediately. Previously only
  deleted sessions ever cleaned up, leaking one index per session.
- Emit checkpoint.restore from the hub restore handler too (the path
  most hosts use), and capture restore failures that happen during
  validation and planning — previously the most common failures
  produced no event and durations excluded the message read.

Regression tests: stale-lock recovery, nested-repo gitlink retention
across turns, and a failed-validation restore event.

* fix(core): pin scratch-index git config so change detection survives core.ignorestat

A persistent GIT_INDEX_FILE inherits whatever the repo's config makes git
write into it. With core.ignorestat=true, git marks every entry it adds
as assume-unchanged and stops stat-checking it, so a file changed after
the first turn kept its stale content in every later snapshot. The
original per-turn throwaway index never carried that bit across turns.

Pin core.ignorestat=false (and core.splitIndex=false, which would scatter
shared-index files for our private index into the user's .git) on every
command that touches the scratch index. Regression test reproduces the
before/after! case from review.
2026-09-08 20:41:46 +02:00
Mikołaj Kondratek 43bb575b6d fix(llms, cli): mark claude-code as local-auth and describe local CLIs in the provider spec (#13407)
* fix(llms): mark claude-code as local-auth so keyless entries are usable

The Claude Code provider authenticates from the local `claude` CLI's own
credential store (Pro/Max subscription login) and never reads an API key:
createClaudeCodeProviderModule builds its settings purely from
config.options, so any stored key is inert.

Without the local-auth capability, getProviderConfigFields reported
authMethod "api-key" with a single apiKey field. The CLI's onboarding gate
(isProviderConfigured -> isProviderSettingsUsable) therefore refused a
keyless claude-code entry and dropped straight to the sign-in wizard, and
configure dialogs asked for a key that does nothing. The workaround was to
store a dummy key.

Add local-auth to the spec, and derive the CLI's local-auth UX from the
capability instead of a hardcoded `openai-codex-cli` id check. The codex
descriptor generalizes into a small registry naming each provider's CLI,
probed executable, and install URL, so the readiness screen (previously
Codex-only copy) now serves both providers. Tests pin the registry against
the set of providers the SDK reports as local auth, in both directions.

Stored keys still short-circuit the readiness check, so anyone who saved a
placeholder key keeps working.

* fix(cli): ignore stale local-CLI readiness probes

The local-CLI setup screen shares one status slot across providers. Probing
spawns the CLI with a 3s timeout, so switching providers while a probe is in
flight let the previous provider's result land on the new provider's screen —
marking it ready off another CLI's success, or blocking it off another CLI's
failure.

Tag each probe and let only the newest one write the status, the checking
flag, and the error reason.

Unreachable before: codex was the only local-CLI provider, so there was no
second provider to switch to.

* refactor(cli): keep local-CLI provider ids module-private

Both id constants are only referenced by the registry literal in the same
file, so exporting them added public API with no callers.

* refactor(llms, cli): describe local CLI providers in the provider spec

The CLI kept its own table of which local CLIs back which providers, holding
the executable, install URL and display name. That duplicated facts the
provider spec already owns (name) and made a second place to update when a
provider changes.

Move the descriptor to the spec: `executable` is the CLI analogue of
`defaults.baseUrl` (the vendor-defined command that reaches the provider,
not a resolved path), and the install link reuses the existing `docsUrl`.
Both surface on the provider info the CLI already reads.

The CLI now derives everything from the capability plus the spec, so it holds
no provider list of its own and a new local-auth provider needs no change on
that side. Drops the id constants, the hardcoded registry, and the drift-guard
test that only existed to keep the two copies in sync.

* refactor(llms, core, cli): resolve local CLI facts from the provider catalog

Ports the shape from Bee's branch. The command a local-auth provider borrows
credentials from is declared as `metadata.localCliCommand` beside the existing
`docsUrl`, and `resolveProviderLocalCli` reads it, so hosts get both without a
new top-level spec field.

Splits two things this previously conflated. `isLocalAuthProvider` routes on
the capability alone, while the CLI descriptor is optional: a local-auth
provider whose credentials come from somewhere unprobeable now reaches the
local setup screen and can connect, instead of falling through to an API-key
form with no fields.

* fix(cli): declare localCli in the save callback deps

Relaxing the save gate to allow local-auth providers that name no CLI made
saveLocalCliConfig read localCli without listing it, so the callback could
decide against a stale value after switching providers.

* fix(cli): route local-auth setup on the capability and stop gating on the PATH probe

Two integration gaps between the capability and the screens it drives.

Onboarding and provider switching branched on whether a CLI descriptor was
found, so a provider that declares local-auth without naming a CLI fell
through to the API-key form, which renders no fields for it. Both call sites
now take the same resolveProviderSetupRoute decision, which reads the
capability; the descriptor is used only to decide whether there is anything
to probe.

The readiness probe only looks on PATH, while the runtime also accepts an
explicit pathToClaudeCodeExecutable and a bundled platform binary, and Codex
falls back through npx. A PATH miss therefore means 'not on PATH', not
'unusable', so the screens report it without blocking and a provider that
really cannot start says so on the first turn.
2026-09-08 20:40:57 +02:00
Saoud RizwanandSaoud Rizwan 50f5664683 docs(readme): replace Kanban with the desktop app (#13953)
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-08 10:27:05 -07:00
Bee 8a520150f8 feat(desktop): attribute lifecycle telemetry across the shared Hub (#13820)
* feat(desktop): add scoped lifecycle telemetry

* fix(telemetry): clear account identity on signout
2026-09-08 09:43:23 -07:00
TheRealSpencer 7e8063857b security: collapse nested undici@5.29.0 onto 7.x (CVE-2026-1525) — residual of #13223 (#13809)
* security: collapse nested undici@5.29.0 onto 7.x via root override (CVE-2026-1525)

Adds the root override "undici": ">=7.29.0 <8" so the last undici@5.29.0 copy (dify-ai-provider -> @ai-sdk/provider-utils@3.0.33) resolves to the undici@7.29.0 already in the lock. @fastify/busboy, pulled in only by undici 5, leaves the lock. No new package version enters bun.lock.

The version-scoped key from #13223 ("undici@<6.0.0") is a silent no-op on bun 1.3.13; only a plain key resolves. Side effect: discord.js/@discordjs/rest move undici 6.28.0 -> 7.29.0 (smoke-tested).

Residual of #13223; complements #13675.

Produced by the VMP Automation, 2026-09-03 run

* chore: drop inert scoped undici override superseded by 7.x pin
2026-09-08 18:39:05 +02:00
Mikołaj Kondratek 8ff5f22cf9 fix(webview): flag attached images the selected model can't use and offer a model switch (#13943)
* fix(webview): warn when an image is pasted or dropped for a text-only model

Pasting or dropping an image into the chat box did not check whether the
selected model accepts image input. The image was attached and shown as a
thumbnail, then silently replaced by a text placeholder before the API call,
so the user never learned it was ignored.

The chat box now refuses the image and shows an inline hint, using the same
overlay pattern as the existing dimension and unsupported-file errors.
Unknown capability data fails open, matching core.

* fix(model-catalog): let declared input modalities decide supportsImages

The provider layer prefers a model's declared input modalities over its
capability list when deciding whether image parts are sent. The adapter that
produces the legacy supportsImages flag only looked at capabilities, so a
model declaring text-only input without a capabilities array was reported as
image-capable to the UI while the request formatter still stripped images.

* fix(webview): keep unsupported images attached, badge them and offer a model switch

Refusing the paste/drop was the wrong shape: it dropped user content and
could not cover images attached before switching to a text-only model.

Images are now attached regardless. While the selected model has no image
input, each image thumbnail carries a warning badge and a notice under the
composer says the images will be ignored and links to the model picker. Both
are derived from the current model, so they appear and disappear as the model
changes.

* fix(webview): make the model-picker link in the images notice a native button

An anchor with role=button and no href is focusable but ignores Enter and
Space, so keyboard users could not open the model picker from the notice.

* fix(webview): keep the link colour on the model-picker button in the images notice

A native button inherits the notice's warning colour, which made the link
blend into the sentence; use the VS Code text-link colours instead.

* refactor(webview): rename imagesUnsupported to unsupportedImagesAttached

The flag also requires images to be attached, so the old name read like a
plain negation of modelSupportsImages.
2026-09-08 21:56:14 +09:00
Mikołaj Kondratek f5af82140b fix(core): sanitize credential fields when saving provider settings (#13716)
Strip Unicode control/format characters and surrounding whitespace from
credential-bearing fields (apiKey, auth tokens, AWS credentials, GCP
fields, SAP client credentials, header values) in saveProviderSettings,
so a pasted key carrying an invisible BOM/zero-width character no longer
persists corrupted and 401s indistinguishably from a wrong key. A value
that is only whitespace and invisible characters clears the field.
2026-09-08 14:07:57 +02:00
Mikołaj Kondratek fc28a5fe33 feat(hostbridge): send the spawn token on host bridge calls (#13734)
* feat(hostbridge): send the spawn token on host bridge calls

The Host Bridge listens on loopback with insecure credentials, so the
host can pin where it listens but cannot prove who is calling: any local
process or OS user can dial the port and drive the IDE. Hosts cannot
authenticate bridge calls until the core identifies itself.

Attach the token the host already issues for this spawn
(CLINE_CORE_CONNECTION_TOKEN) as the cline-hostbridge-token header on
every outgoing bridge call, reusing that credential rather than adding a
second secret with its own lifetime.

Covers every path a core reaches the bridge through: the generated
service clients (via the host-bridge client factory the generator now
emits), the startup health check, and the core connection stream. A core
spawned without a token sends no header, so hosts that do not check it
are unaffected.

This is the core half; hosts can only warn on a missing or mismatched
token until it ships, and enforce once every core in the wild sends it.

* test(hostbridge): cover the generated clients end to end

The middleware unit tests call the auth middleware directly, so nothing
verified the wiring that actually carries the token in production: the
generator emitting createHostBridgeClient into each generated client,
and that factory putting the header on the wire. A broken generator
template would have shipped unauthenticated calls with a green suite.

Stand up a real nice-grpc server and assert, through a generated client,
that unary and streaming calls both arrive with the token and that no
header is sent when the core was spawned without one. Verified to fail
when the generated clients are reverted to a plain createClient.

* chore(hostbridge): drop a dead eslint directive from the auth test

cline lints with biome and has no eslint config, so the
eslint-disable-next-line implied tooling that does not run here.

* fix(hostbridge): keep the spawn token after bootstrap scrubs the environment

Startup captures CLINE_CORE_CONNECTION_TOKEN and deletes it from
process.env before the health check, host initialization or the core
connection run, so descendants never inherit it and it is absent when
the environment is logged. The metadata helpers read the variable at
call time, so on a normally spawned core they saw undefined and no
header was ever sent on the one path this feature exists for. Only the
in-band hello, which uses the captured value, carried the token.

Move capture and scrub into one function in the auth module that also
retains the token in process memory, and have bootstrap call it in the
same first position. Scrubbing is preserved, and capture and share can
no longer drift apart. The helpers now read the retained copy.

Route both test files through that same bootstrap function so tokens
enter the way they do in production, and assert the environment is
already scrubbed before the call the header is observed on. The
generated-client end-to-end test therefore covers startup to receiver,
which the previous env-setting tests could not.
2026-09-08 10:18:41 +02:00
Dominic CooneyandCline Agent 5746f74ee7 fix(core): complete run_commands when background children hold the stdio pipes (#13817)
* fix(core): complete run_commands when background children hold the stdio pipes

The shell executor settled commands on the child process close event,
which fires only after the stdio streams drain. A command that
backgrounds a child (cmd &, nohup, and the same from Git Bash on
Windows) leaves the inherited pipe write-ends held open, so after the
shell itself exits close never arrives: the command hangs until the
timeout kills the whole tree, even though it finished - the same result
an interactive terminal gives when the prompt returns while a
background job keeps printing.

When the process has exited and the streams stay open past a one-second
grace period, settle with the exit code and the output collected so far,
append a note that background processes are still running and their
output is no longer captured, and unref the stream handles so the host
process is not kept alive by the orphaned pipes. Normal commands are
unchanged: close follows exit within milliseconds and the grace never
fires. Kill, abort, and timeout paths still win their races.

A detached command gets the same treatment for its log: a detached
shell that exits while a descendant holds the pipes would otherwise
never receive its exit record or completion marker, leaving the log in
the active state for the startup reaper to retire as stale.

Fixes #12417

* test(core): run inherited-stdio regressions wherever Bash exists

---------

Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
2026-09-08 16:54:18 +09:00
d56cd10a6c fix(core): resolve session import paths correctly on Windows (#13827)
* Fix Windows session import paths

* fix(core): preserve nonblank session import paths

Use trim only to detect blank environment overrides. Keep meaningful
whitespace and the supported Windows HOMEDRIVE/HOMEPATH contract.

Cover whitespace through the real adapters, blank fallback, constructor
precedence, and Windows drive-root and runtime-home fallback paths.

---------

Co-authored-by: Cline Bot <noreply@cline.bot>
Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
2026-09-08 16:54:01 +09:00
Dominic CooneyandCline Agent 6abb7cee76 fix(desktop): keep desktop backend startup off the command path (#13829)
* fix(desktop): keep startup off the UI thread

* test(desktop): make queued-startup shutdown test exercise the recheck

The test spawns the startup thread and immediately signals shutdown. If
the thread has not yet passed ensure's first shutdown check it returns
there, never queues on the process lock, and the recheck under the lock
is not exercised. With the recheck deleted the test failed only 84 of
200 runs; a 50 ms settle before signalling shutdown makes it fail
200/200 while leaving the correct code at 0/200.

* test(desktop): make the queued-startup shutdown test deterministic

Split the check-and-spawn that runs under the process lock out of
ensure_desktop_backend_started_with into
ensure_desktop_backend_started_locked, which takes the MutexGuard. The
test now plays out the exact interleaving on one thread: pass the
unlocked shutdown check, mark shutdown, take the lock, call the locked
step. No sleep, no second thread, no scheduler dependence. With the
recheck under the lock removed the test fails on every run.

Restore the comment in get_desktop_backend_endpoint explaining why a
child that dies mid-poll produces an error instead of a respawn.

---------

Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
2026-09-08 09:45:52 +09:00
Dominic CooneyandCline Agent afa1b01be1 fix(vscode): report silent shell-integration successes as empty output (#13816)
* fix(vscode): report silent shell-integration successes as empty output

When a terminal command completed with exit 0 and no output, the
VscodeTerminalProcess fallback treated the empty capture as a shell
integration failure: it read a clipboard snapshot of the terminal and
reported the command as failed, even though the OSC 633 CommandExecuted
marker proved the read() stream was working. Silent commands such as
$null or git add -A on a clean tree then failed intermittently and
returned dirty snapshots.

Gate the snapshot fallback on the CommandExecuted marker: it is parsed
from the same stream as the output, so when it was seen an empty output
is a genuine silent success. Completion without any markers keeps the
existing fallback.

Also serve mocha imports from the runner interface in test-setup: a
test file that resolves a second, un-setup mocha instance crashes at
import with "Cannot read properties of undefined (reading describe)".

Fixes #13272

* test(vscode): diagnose unsupported Mocha shim exports

Keep the six runner-owned BDD functions and derive unsupported exports

from the installed package without trapping module interop probes.

Exercise compiled imports through the real extension-host setup and

clarify terminal stream/end-event ordering without changing behavior.

---------

Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
2026-09-08 09:45:03 +09:00
Dominic CooneyandCline Bot c21b17255b fix(desktop): update Windows taskbar app icon (#13823)
Co-authored-by: Cline Bot <cline-bot@users.noreply.github.com>
2026-09-07 10:36:42 +09:00
Saoud Rizwan dac3b35ba4 ui: keep section headers visible while searching in SearchCombobox (#13854)
ClinePass lists the same model in both the Subscribed and Free tiers
(e.g. cline-pass/deepseek-v4-flash and deepseek/deepseek-v4-flash), so
flattening the sections during search produced two identical-looking rows.
Section headers now stay rendered for whichever sections still have
matches.
2026-09-04 16:40:39 -07:00
Saoud Rizwan adbfbd97d3 fix(core): make apply_patch Add File refuse to overwrite existing files (#13835)
loadFiles only pre-loaded UPDATE/DELETE targets, so the parser's
"File already exists" guard never saw ADD targets and fs.writeFile
silently replaced existing files. Load existing ADD targets too so the
guard fires.

Fixes #13833
2026-09-04 16:19:50 -07:00
Saoud Rizwan 952df213ee fix(cli): make TUI toasts wrap instead of clipping after the first line (#13818)
The toast box only set maxWidth, so a message longer than the 44-column
cap was clipped to its first line rather than wrapped. Every toast in the
Hub update flow is longer than that: the keep-Hub reminder after Esc
rendered as 'The running Cline Hub stays on the' and never reached the
'cline hub upgrade' instruction it exists to deliver. Give the box an
explicit width (message length plus border and padding, capped as
before) so the text has a real edge to wrap at.
2026-09-03 16:27:09 -07:00
Saoud Rizwan d7cb79aea5 chore(desktop): release v0.0.23 2026-09-03 11:06:02 -07:00
5de79a75d0 docs: add .clineignore hook example (#13649)
* docs: add enforced .clineignore guard plugin example

Adds clineignore-read-files-guard.ts, a beforeTool hook plugin that blocks
read_files, editor, apply_patch, and run_commands calls targeting paths
matching gitignore-style patterns in a workspace .clineignore file, and
protects .clineignore itself from modification. Features it on the
.clineignore docs page as the enforced replacement for the deprecated
built-in feature.

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

* docs: use a PreToolUse file hook for the enforced .clineignore example

Replaces the plugin-based guard with a PreToolUse hook script that works in
the VS Code extension today (.clinerules/hooks/PreToolUse plus the Enable
Hooks setting) as well as the CLI (.cline/hooks/PreToolUse.sh). The script
handles both hook payload shapes, blocks read_files/editor/apply_patch/
run_commands calls matching .clineignore patterns, and protects .clineignore
itself from modification.

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

* fix: canonicalize paths in .clineignore guard hook

Lexically collapse '.', '..', and empty segments before the ignore match
and the .clineignore self-protection check, closing bypasses via
noncanonical paths like ./.clineignore, secrets/../.env, or
/root/./file (Greptile review finding on #13649).

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

* docs: note symlink limitation in .clineignore guard hook docs

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-02 19:48:49 -07:00
Saoud RizwanandSaoud Rizwan ab3acd6a8a fix(vscode): render pending Supports Images override so stale checkbox re-syncs stop reverting it (#13694) (#13792)
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-02 19:33:11 -07:00
Saoud RizwanandSaoud Rizwan c322ab2bbc Show device sign-in confirmation code in desktop app while waiting for browser (#13791)
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-02 19:27:20 -07:00
Saoud RizwanandSaoud Rizwan 72b714b6be fix(desktop): open voice settings for speech input provider errors (#13726)
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-02 19:24:09 -07:00
Saoud RizwanandSaoud Rizwan f0de3a2c20 fix(desktop): keep the scheduled-task report visible when a finished run collapses (#13793)
* fix(desktop): keep the scheduled-task report visible when a finished run collapses

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

* test(desktop): cover collapse edge cases around submit_and_exit

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-02 19:19:44 -07:00
Bee 4ae53292d3 fix(hub): never prompt about a hub running the same core version (#13785)
Two artifacts of the same release cut from different commits never share
a build fingerprint or epoch: desktop-v0.0.22 and cli-v3.0.61 both bundle
core 0.0.82, yet every desktop user with the CLI installed gets the
'Cline Hub was updated' dialog on every launch and webview reconnect, and
'Update and restart' loops on 'no app update available' because nothing
newer exists to install.

checkManagedHubBuildMismatch now returns nothing when the hub's
coreVersion equals this client's own, in both directions (build_mismatch
and outdated_hub). The fingerprint keeps its role in the reuse/retire
total order, where antisymmetry matters; it no longer drives prompts on
its own. Genuinely different releases still prompt.
2026-09-02 17:54:00 -07:00
Bee 3d0531238e fix(desktop): show the newer-hub dialog only when an app update is staged, and persist Later (#13787)
* fix(desktop): show the newer-hub dialog only when an app update is staged, and persist Later

Hardens the 'Cline Hub was updated' prompt against release skew:

- The build_mismatch modal renders only when the auto-updater reports a
  staged update ('ready'), so it can never loop on 'no app update is
  available yet'. A mismatch kicks one immediate updater check (deduped
  per hub build per page lifetime) so the prompt opens actionable as soon
  as a release exists, and the shared polled status opens it reactively
  when the background cycle stages one later. unsupported_protocol and
  outdated_hub keep their unconditional dialogs.
- 'Later' now persists in localStorage per reason:hubBuildId. The sidecar
  replays a pending mismatch on every webview connection (session
  switches, reloads, relaunches), and the previous in-memory dismissal
  resurrected the modal on each one. A different hub build still prompts.

* fix(desktop): never persist Later for an unsupported-protocol hub

Review follow-up: the persisted dismissal also stuck for
unsupported_protocol, silencing a warning about a Hub the app genuinely
cannot talk to across every reconnect and relaunch. Dismissal for that
reason is session-local again (the pre-existing behavior); only the
advisory build_mismatch key persists, enforced on both write and read so
a key stored by any other path is ignored too.

* fix(desktop): reopen a dismissed protocol warning when the mismatch is redelivered

Review follow-up: an in-place transport reconnect replays the pending
mismatch to a still-mounted dialog whose in-memory dismissedKey is
unchanged, so a dismissed unsupported_protocol warning stayed closed
while the app could not talk to the Hub.

Every delivered mismatch now passes the dismissal through
retainDismissalForIncomingMismatch: a matching non-persistable dismissal
(unsupported_protocol) is cleared so the warning reopens on the replay;
the advisory build_mismatch dismissal and dismissals for unrelated keys
stand.
2026-09-02 17:31:23 -07:00
b318c84f52 docs: add deprecation notices page (#13458)
* docs: add deprecation notices page

* docs: add primary surface to deprecations

* chore: drop unrelated formatting changes from docs PR

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

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-02 13:12:38 -07:00
Bee 9e0af5c010 feat(desktop): manage Agent Plugins through the Hub (#13658)
* feat(desktop): manage Agent Plugins through the Hub

* fix(desktop): show Agent Plugin inventory
2026-09-02 12:37:36 -07:00
Bee 97b0700151 feat(cli): manage Agent Plugins through the Hub (#13657) 2026-09-02 12:37:24 -07:00
Bee b46cf77ed1 feat(core): Hub-managed Agent Plugins support (#13652)
* feat(sdk): add hub-managed Agent Plugins

* fix(sdk): restrict Agent Plugin auto-discovery

* test(sdk): canonicalize Windows plugin paths

* fix(sdk): await stdio MCP process shutdown

* fix(sdk): select Agent Plugin MCP clients by source

* fix(core): defer Agent Plugin data directory creation

* docs(sdk): clarify Agent Plugin discovery scope

* fix(sdk): reject individual Agent Plugin skill toggles

settings.toggle({type: "skills"}) unconditionally called
toggleSkillFrontmatter() for any resolved skill record, including ones
sourced from an Agent Plugin. That writes a `disabled` key into the
skill's SKILL.md frontmatter, but the strict Agent Skills parser used
for these skills only permits a closed field set (name, description,
license, compatibility, metadata, allowed-tools). The very next reload
then rejects the file as invalid and the skill silently disappears
until someone hand-edits the installed plugin's SKILL.md.

Guard the toggle: an agent-plugin-sourced skill record now throws a
clear error pointing at the plugin-level toggle instead, matching how
whole-plugin enable/disable already works (setDisabledAgentPlugin,
keyed by manifest name, no file mutation).

* fix(sdk): keep disposing MCP servers when one disconnect fails

InMemoryMcpManager.dispose() unregistered servers sequentially and let
the first disconnect() rejection abort the loop. Since disconnect() can
now reject when a stdio child never exits, one wedged server would leak
every remaining server's process. Catch per-server errors, disconnect
the rest, and rethrow as an AggregateError so upstream cleanup-error
reporting still sees the failure.

Also log agent plugin discovery failures in CoreSettingsService.list
instead of swallowing them silently, so a plugin missing from settings
is diagnosable.
2026-09-02 10:11:01 -07:00
Mikołaj Kondratek c85384431d fix(standalone): decode core-connection protobus requests from proto3 JSON (#13758)
The core connection delivers protobus requests as the proto3 JSON the
webview's ts-proto toJSON encoders produce: enums arrive as string names
and default-valued fields — empty repeated fields included — are omitted.
The handlers assume ts-proto message shapes (numeric enums, repeated
fields always present), so dispatching the parsed JSON directly broke
every RPC relying on those invariants on JetBrains: changing the API
provider threw 'Cannot read properties of undefined (reading length)'
in fromProtobufModelInfo, and the plan/act toggle rejected its own mode
as invalid. The old standalone gRPC server restored these defaults
during protobuf decoding; the tunnel skipped that step.

Generate a per-method request-decoder map (request type fromJSON)
alongside the service handlers and apply it in the core-connection
dispatcher before dispatch. The in-process VS Code webview path is
untouched: it posts structured-cloned ts-proto objects that never pass
through JSON.
2026-09-02 15:59:42 +02:00
Saoud Rizwan be59305d7a chore(desktop): release v0.0.22 2026-09-01 22:04:39 -07:00
Saoud Rizwan 833be95cfb chore(vscode): release v4.1.17 (#13755) 2026-09-01 21:59:50 -07:00
240 changed files with 15293 additions and 1130 deletions
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Warn when attached images will be ignored because the selected model does not support image input: image thumbnails get a warning badge and the composer offers to switch to an image-capable model, instead of the images being silently dropped before the API call
+16 -2
View File
@@ -61,8 +61,9 @@ Emission ownership:
- `user.extension_activated`: emitted **once per host process** by host-specific helpers
(`captureCliExtensionActivated` for the CLI, `captureExtensionActivated` for VS Code).
- `workspace.initialized` / `workspace.init_error`: emitted by a per-process de-duplicated
emitter in `prepareLocalRuntimeBootstrap`. Hosts must NOT re-emit these.
- `workspace.initialized` / `workspace.init_error`: emitted by a de-duplicated emitter in
`prepareLocalRuntimeBootstrap`. A dedicated host emits once per workspace; a shared Hub emits
once per client surface and workspace. Hosts must NOT re-emit these.
- `workspace.path_resolved`: emitted from default tool executors **only when**
`WorkspaceManager` exposes more than one root.
- `task.*`: emitted by core session lifecycle code in `sdk/packages/core/src/cline-core/` and
@@ -104,6 +105,19 @@ hub-backed session, so the daemon must own its own `ITelemetryService`. It build
identifies from the cached cline account (re-resolved periodically, since the daemon often
starts before login) and flushes on every shutdown path, including startup failure.
The Hub transport forwards the serializable `ExtensionContext.client` and
`ExtensionContext.user` values with session create/restore requests. The daemon wraps its
process-owned service with `createClientScopedTelemetryService()` so lifecycle events use the
originating client's `cline_type`, platform/version, and current account/organization without
mutating the singleton shared by concurrent clients. Keep canonical task fields named
`provider` and `model`; do not reintroduce host-specific aliases such as `apiProvider` or
`modelId` for `task.created`, `task.restarted`, or `task.completed`.
`UserContext.distinctId` may be an anonymous machine ID. Set `UserContext.accountId` to the
authenticated account ID (or `null` for an explicitly signed-out client) whenever a client
forwards user context; this prevents machine IDs and stale daemon identity from becoming
`user_id` / `organization_id` on task events.
Flag changes that remove this wiring, construct runtime hosts inside the daemon without
passing its telemetry handle, or add daemon exit paths that skip the flush — hub-backed
sessions would silently drop their lifecycle telemetry (this exact bug shipped once).
+33
View File
@@ -1,5 +1,38 @@
# Changelog
## [4.1.17]
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
### Added
- ClinePass is now surfaced across the app: a card on the account page describing what the plan covers, a hint in provider settings, and a banner on the home screen. Dismissed banners stay dismissed.
### Fixed
- Fixed the background Hub process ballooning in memory during long sessions. Session status updates broadcast a full copy of the conversation transcript to every connected client, so on a large task each status change shipped megabytes and could grow the process to tens of gigabytes. Snapshots now carry state only.
- Hook scripts that fail to spawn no longer crash the extension's core process and take the running task down with them.
- Fixed a chat render crash on malformed `api_req` payloads.
- Cost estimates no longer appear in task history for subscription-billed tasks (ClinePass, ChatGPT via Codex, and Claude Code), matching the task header.
- Pasted provider API keys are now stripped of the invisible characters clipboards smuggle in (newlines, zero-width spaces, BOM). A key corrupted that way was hidden by the masked field and rejected by the provider with a 401 indistinguishable from a genuinely wrong key. Credential rejections now say that the API key is the problem and point at its configuration, keeping the provider's raw response as a diagnostic tail.
- Signing in to OpenAI Codex (ChatGPT subscription) now fails with a clear "port in use" error when callback port 1455 is occupied. Previously the button opened a browser to a flow whose callback could never arrive, and nothing else happened. OAuth redirect errors such as `access_denied` are surfaced instead of being reported as a missing authorization code.
- A transient network failure while refreshing OpenAI Codex or OpenAI-compatible-account tokens no longer signs you out. Only a genuinely rejected refresh token now requires re-authentication.
- Fixed tool calling being silently disabled for Dify, SAP AI Core, opencode, and Codex CLI models. Their catalog entries declare no capabilities, and the empty list was read as an authoritative denial that stripped every tool from the request.
- Fixed images being dropped from file reads on models whose capability list is empty.
- Restoring a checkpoint now refuses to run when commits were made after it, instead of silently knocking them off the branch where only the reflog could recover them. Chat-only restore is unaffected.
- `apply_patch` now preserves a file's existing CRLF line endings.
- Global rules are now also read from `~/Cline/Rules`, which is where the Rules tab writes them on WSL and headless installs whose Documents folder resolves to the home directory.
- An enabled but unreachable remote (SSE or streamable HTTP) MCP server no longer stalls session startup; remote connects now have a 10 second budget.
- Aborting a task now also cancels the delegated subagents and teammates it spawned, instead of leaving their work running.
- Langfuse tracing now works in released builds. Detection identified the OpenTelemetry provider by class name, which minification renames, so tracing silently initialized as not ready in every published build while working in development.
- Cline provider models are now read from the live catalog, so newly published models appear without an extension update.
- Hook execution telemetry now fires; the task id was not threaded into hook runner creation, so those events were dropped.
### Changed
- Refreshed the built-in model catalog. Adds ten providers (Bothub, OpenReason, SenseNova (China), TokenGo, TokenRouter, Vancine, Volcengine Ark, Volcengine Ark Coding Plan, above.dev, and klokintegration.se) and updates model lists and pricing throughout. This is an unusually wide refresh: the resolved default model changes for 57 providers, most consequentially Anthropic, which now resolves to Claude Fable 5.1 instead of Claude Opus 5, with Amazon Bedrock, Vertex, OpenRouter, Vercel AI Gateway, Kilo Gateway, LLM Gateway, DevPass, DigitalOcean, CrossModel, Eden AI, and NanoGPT following. If you use a provider without pinning a model, expect a different default.
- The message the model receives when you reject a tool call now names the rejected tool and reads as your decision rather than an error.
## [4.1.16]
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
+9 -14
View File
@@ -5,7 +5,7 @@
<h1 align="center">Cline</h1>
<p align="center">
The open source coding agent in your IDE and terminal.
The open source coding agent in your IDE, terminal, and desktop.
</p>
<div align="center">
@@ -44,8 +44,7 @@ The open source coding agent in your IDE and terminal.
### CLI
Run Cline in your terminal.
Interactive chat or fully headless
for CI/CD and scripting.
Interactive chat or fully headless for CI/CD and scripting.
```
npm i -g cline
@@ -57,17 +56,13 @@ npm i -g cline
</td>
<td align="center" width="50%">
### Kanban
### Desktop App
Run many agents in parallel from a
web-based task board. Each card gets its own
worktree, auto-commit, and dependency chains.
Cline as a native app for macOS and Windows.
Run agent sessions in any folder, schedule
routines, and manage models, plugins, and MCP servers.
```
npm i -g kanban
```
<a href="https://github.com/cline/kanban">Learn more</a>
<a href="https://github.com/cline/cline/releases?q=desktop-v&expanded=true">Download for macOS and Windows</a>
<br><br>
</td>
@@ -108,7 +103,7 @@ the JetBrains family.
### SDK
Build your own AI agents and integrations powered by the same engine that runs the CLI, Kanban, VS Code extension, and JetBrains plugin. Custom tools, multi-agent teams, connectors, scheduled automations, and more.
Build your own AI agents and integrations powered by the same engine that runs the CLI, desktop app, VS Code extension, and JetBrains plugin. Custom tools, multi-agent teams, connectors, scheduled automations, and more.
```
npm install @cline/sdk
@@ -131,8 +126,8 @@ npm install @cline/sdk
| **SDK** | Node.js programmatic agent API and extension exports. | [`sdk/`](https://github.com/cline/cline/tree/main/sdk) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/sdk/CHANGELOG.md) |
| **CLI** | Terminal UI, headless mode, shell commands, and CLI-specific flows. | [`apps/cli/`](https://github.com/cline/cline/tree/main/apps/cli) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/apps/cli/CHANGELOG.md) |
| **VS Code Extension** | The Marketplace extension and extension host integration. | [`/`](https://github.com/cline/cline/tree/main) (WIP migrating) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/CHANGELOG.md) |
| **Desktop App** | Native macOS and Windows app (Tauri shell, Bun sidecar, Next.js UI). | [`apps/examples/desktop-app/`](https://github.com/cline/cline/tree/main/apps/examples/desktop-app) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/apps/examples/desktop-app/CHANGELOG.md) |
| **JetBrains Plugin** | JetBrains-hosted client that talks to the shared agent core. | Currently we are not open-sourcing JetBrains plugins | - |
| **Kanban** | Web-based multi-agent task board. | [`cline/kanban`](https://github.com/cline/kanban) | [CHANGELOG.md](https://github.com/cline/kanban/blob/main/CHANGELOG.md) |
| **Docs site** | Public documentation pages. | [`docs/`](https://docs.cline.bot/) | - |
## Edits Code Across Your Project
+1
View File
@@ -23,6 +23,7 @@
## 3.0.60
- The config screen now separates Cline Plugins from Agent Plugins discovered by the Hub. Agent Plugins can be enabled or disabled with Space; the Hub persists the state and their skills and MCP servers follow it when the interactive runtime is rebuilt
- Fixed the background hub process ballooning in memory during long sessions — session status updates were broadcasting a full copy of the conversation transcript to every connected client, which on a large task could grow the process to tens of gigabytes. Upgrading retires the running hub so the fix takes effect on the next command
- New files are now created with your platform's native line endings
- Fixed the codebase search tool crashing on files that contain a single enormous line
+16 -1
View File
@@ -19,6 +19,7 @@ import {
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
import { Command } from "commander";
import { getToolCatalog } from "../runtime/tools";
import { createCliCore } from "../session/session";
import { loadInteractiveConfigData } from "../tui/interactive-config";
import type { CliOutputMode } from "../utils/types";
@@ -423,8 +424,20 @@ async function loadInteractiveConfigDataForCommand(
cwd: string,
): Promise<Awaited<ReturnType<typeof loadInteractiveConfigData>>> {
const userInstructionService = createConfigUserInstructionService(cwd);
const core = await createCliCore({
backendMode: "auto",
cwd,
workspaceRoot: cwd,
});
try {
await userInstructionService.start();
const [, agentPluginSettings] = await Promise.all([
userInstructionService.start(),
core.settings.list({
cwd,
workspaceRoot: cwd,
includePluginTools: true,
}),
]);
return await loadInteractiveConfigData({
userInstructionService,
cwd,
@@ -432,9 +445,11 @@ async function loadInteractiveConfigDataForCommand(
availabilityContext: {
mode: "act",
},
agentPluginSettings,
});
} finally {
userInstructionService.stop();
await core.dispose("cli_config_command_complete");
}
}
+2 -1
View File
@@ -2,7 +2,8 @@ import {
createDiscordAdapter,
type DiscordAdapter,
} from "@chat-adapter/discord";
// TODO: Remove the root Undici 6 override when discord.js no longer requires Undici ^6.27.0.
// Note: discord.js@14 declares undici ^6.27.0, but the root package.json
// override ("undici": ">=7.29.0 <8") forces undici 7.x for CVE-2026-1525.
import type { ChatStartSessionRequest } from "@cline/core";
import {
createUserInstructionConfigService,
@@ -17,6 +17,7 @@ import {
import {
applyPluginFailures,
type InteractiveConfigItem,
isToggleableInteractiveConfigItem,
} from "../../tui/interactive-config";
import type { Config } from "../../utils/types";
import { createInteractiveConfigDataLoader } from "./config-data";
@@ -113,6 +114,172 @@ describe("interactive config data loader", () => {
return pluginPath;
}
it("merges the hub-owned Agent Plugin inventory into the config view", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-agent-plugin-"));
tempRoots.push(tempRoot);
const pluginRoot = "/remote/home/.agents/plugins/portable-review";
const calls: unknown[] = [];
const loader = createInteractiveConfigDataLoader({
config: createConfig(tempRoot),
loadCoreSettings: async (input) => {
calls.push(input);
return {
workflows: [],
rules: [],
tools: [],
plugins: [
{
id: "agent-plugin:portable-review",
name: "portable-review",
path: pluginRoot,
kind: "plugin",
source: "global-plugin",
enabled: true,
toggleable: true,
agentPlugin: true,
},
],
skills: [
{
id: "portable-review:review",
name: "review",
path: `${pluginRoot}/skills/review/SKILL.md`,
kind: "skill",
source: "global-plugin",
enabled: true,
toggleable: false,
agentPlugin: true,
pluginName: "portable-review",
pluginPath: pluginRoot,
},
],
mcp: [
{
id: "portable-review.docs",
name: "portable-review.docs",
path: `${pluginRoot}/mcp.json`,
kind: "mcp",
source: "global-plugin",
enabled: true,
toggleable: false,
agentPlugin: true,
pluginName: "portable-review",
pluginPath: pluginRoot,
},
],
};
},
});
const data = await loader.loadConfigData({ includePluginTools: false });
expect(calls).toEqual([
expect.objectContaining({
includePluginTools: false,
}),
]);
expect(data.plugins).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "portable-review",
agentPlugin: true,
toggleable: true,
deletable: false,
}),
]),
);
const skill = data.skills.find(
(item) => item.id === "portable-review:review",
);
expect(skill).toMatchObject({
pluginName: "portable-review",
agentPlugin: true,
});
expect(skill && isToggleableInteractiveConfigItem(skill)).toBe(false);
expect(data.mcp).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "portable-review.docs" }),
]),
);
});
it("toggles Agent Plugins through the hub without mutating client settings", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-agent-toggle-"));
tempRoots.push(tempRoot);
const globalSettingsPath = join(tempRoot, "global-settings.json");
process.env.CLINE_GLOBAL_SETTINGS_PATH = globalSettingsPath;
const pluginRoot = "/hub/home/.agents/plugins/portable-review";
const toggleCalls: unknown[] = [];
const loader = createInteractiveConfigDataLoader({
config: {
...createConfig(tempRoot),
agentPluginPaths: ["./portable-review"],
},
toggleCoreSettings: async (input) => {
toggleCalls.push(input);
return {
changedTypes: ["plugins", "skills", "mcp"],
snapshot: {
workflows: [],
rules: [],
tools: [],
skills: [],
mcp: [],
plugins: [
{
id: "agent-plugin:portable-review",
name: "portable-review",
path: pluginRoot,
kind: "plugin",
source: "global-plugin",
enabled: false,
toggleable: true,
agentPlugin: true,
},
],
},
};
},
});
const data = await loader.onToggleConfigItem(
{
id: "agent-plugin:portable-review",
name: "portable-review",
path: pluginRoot,
kind: "plugin",
source: "global-plugin",
enabled: true,
toggleable: true,
deletable: false,
agentPlugin: true,
},
{ includePluginTools: false },
);
expect(toggleCalls).toEqual([
expect.objectContaining({
type: "plugins",
id: "agent-plugin:portable-review",
path: pluginRoot,
name: "portable-review",
enabled: false,
agentPluginPaths: ["./portable-review"],
includePluginTools: false,
}),
]);
expect(data?.plugins).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "portable-review",
enabled: false,
agentPlugin: true,
}),
]),
);
await expect(readFile(globalSettingsPath, "utf8")).rejects.toThrow();
});
it("toggles a skill item to the opposite enabled state and refreshes before reload", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
@@ -1,4 +1,8 @@
import {
type CoreSettingsListInput,
type CoreSettingsMutationResult,
type CoreSettingsSnapshot,
type CoreSettingsToggleInput,
createCoreSettingsService,
disablePluginMcpServersInSettings,
setDisabledPlugin,
@@ -10,6 +14,7 @@ import {
import {
type InteractiveConfigData,
type InteractiveConfigItem,
isToggleableInteractiveConfigItem,
type LoadInteractiveConfigDataOptions,
loadInteractiveConfigData,
} from "../../tui/interactive-config";
@@ -18,6 +23,12 @@ import type { Config } from "../../utils/types";
export function createInteractiveConfigDataLoader(input: {
config: Config;
userInstructionService?: UserInstructionConfigService;
loadCoreSettings?: (
input: CoreSettingsListInput,
) => Promise<CoreSettingsSnapshot>;
toggleCoreSettings?: (
input: CoreSettingsToggleInput,
) => Promise<CoreSettingsMutationResult>;
}) {
const workspaceRoot = () =>
input.config.workspaceRoot?.trim() || input.config.cwd;
@@ -28,16 +39,36 @@ export function createInteractiveConfigDataLoader(input: {
enableSpawnAgent: input.config.enableSpawnAgent,
enableAgentTeams: input.config.enableAgentTeams,
});
const loadConfigData = async (
const buildSettingsInput = (
options: LoadInteractiveConfigDataOptions = {},
): Promise<InteractiveConfigData> =>
await loadInteractiveConfigData({
): CoreSettingsListInput => ({
cwd: input.config.cwd,
workspaceRoot: workspaceRoot(),
availabilityContext: availabilityContext(),
agentPluginPaths: input.config.agentPluginPaths,
includePluginTools: options.includePluginTools,
});
const buildConfigData = async (
options: LoadInteractiveConfigDataOptions,
agentPluginSettings: CoreSettingsSnapshot | undefined,
): Promise<InteractiveConfigData> => {
return await loadInteractiveConfigData({
userInstructionService: input.userInstructionService,
cwd: input.config.cwd,
workspaceRoot: workspaceRoot(),
availabilityContext: availabilityContext(),
includePluginTools: options.includePluginTools,
agentPluginSettings,
});
};
const loadConfigData = async (
options: LoadInteractiveConfigDataOptions = {},
): Promise<InteractiveConfigData> => {
const agentPluginSettings = await input
.loadCoreSettings?.(buildSettingsInput(options))
.catch(() => undefined);
return await buildConfigData(options, agentPluginSettings);
};
const refreshUserInstructionConfigs = async (): Promise<void> => {
const service = input.userInstructionService;
@@ -55,6 +86,9 @@ export function createInteractiveConfigDataLoader(input: {
item: InteractiveConfigItem,
options: LoadInteractiveConfigDataOptions = {},
): Promise<InteractiveConfigData | undefined> => {
if (!isToggleableInteractiveConfigItem(item)) {
return undefined;
}
const settings = createCoreSettingsService();
if (item.kind === "skill" && typeof item.enabled === "boolean") {
await settings.toggle({
@@ -72,6 +106,22 @@ export function createInteractiveConfigDataLoader(input: {
}
if (item.kind === "plugin" && typeof item.enabled === "boolean") {
if (item.agentPlugin === true) {
if (!input.toggleCoreSettings) {
throw new Error(
"Agent Plugin settings require a connected Cline Hub.",
);
}
const result = await input.toggleCoreSettings({
...buildSettingsInput(options),
type: "plugins",
id: item.id,
path: item.path,
name: item.name,
enabled: !item.enabled,
});
return await buildConfigData(options, result.snapshot);
}
if (item.enabled) {
disablePluginMcpServersInSettings({ pluginPaths: [item.path] });
setDisabledPlugin(item.path, true);
@@ -150,7 +200,11 @@ export function createInteractiveConfigDataLoader(input: {
item: InteractiveConfigItem,
options: LoadInteractiveConfigDataOptions = {},
): Promise<InteractiveConfigData | undefined> => {
if (item.kind !== "plugin") {
if (
item.kind !== "plugin" ||
item.agentPlugin === true ||
item.deletable === false
) {
return undefined;
}
await uninstallPlugin({
@@ -2,6 +2,10 @@ import {
type AgentEvent,
type AgentHooks,
type CheckpointEntry,
type CoreSettingsListInput,
type CoreSettingsMutationResult,
type CoreSettingsSnapshot,
type CoreSettingsToggleInput,
createSessionCompactionState,
isSessionNotFoundError,
type PendingPromptMutationResult,
@@ -299,6 +303,19 @@ export function createInteractiveSessionRuntime(input: {
return await startupPromise;
};
const listCoreSettings = async (
settingsInput: CoreSettingsListInput,
): Promise<CoreSettingsSnapshot> => {
const manager = await ensureSessionManager();
return await manager.settings.list(settingsInput);
};
const toggleCoreSettings = async (
settingsInput: CoreSettingsToggleInput,
): Promise<CoreSettingsMutationResult> => {
const manager = await ensureSessionManager();
return await manager.settings.toggle(settingsInput);
};
const readCurrentMessages = async (): Promise<CurrentMessagesRead> => {
const manager = sessionManager;
const sessionId = activeSessionId;
@@ -883,6 +900,8 @@ export function createInteractiveSessionRuntime(input: {
return {
ensureReady,
listCoreSettings,
toggleCoreSettings,
sendCurrentTurn,
updatePendingPrompt,
getAccumulatedUsage,
+6 -4
View File
@@ -200,10 +200,6 @@ export async function runInteractive(
let pluginChatCommandHostPromise:
| Promise<InteractiveSlashCommand[]>
| undefined;
const configDataLoader = createInteractiveConfigDataLoader({
config,
userInstructionService,
});
const ensurePluginChatCommandHost = async (): Promise<
InteractiveSlashCommand[]
> => {
@@ -302,6 +298,12 @@ export async function runInteractive(
uiEvents.emit("pending-prompt-submitted", event);
},
});
const configDataLoader = createInteractiveConfigDataLoader({
config,
userInstructionService,
loadCoreSettings: sessionRuntime.listCoreSettings,
toggleCoreSettings: sessionRuntime.toggleCoreSettings,
});
let modeChangePromise: Promise<void> | undefined;
let modeChangeTarget: "plan" | "act" | undefined;
const modeSwitchNotice = createModeSwitchNoticeTracker();
+7 -41
View File
@@ -11,6 +11,8 @@ import {
getValidClineCredentials,
type ProviderSettings,
ProviderSettingsManager,
persistClineAccountTelemetryIdentity,
resolveClineAccountTelemetryIdentity,
saveLocalProviderOAuthCredentials,
type UserCurrentPlan,
} from "@cline/core";
@@ -158,38 +160,6 @@ export async function createClineAccountService(input: {
});
}
/**
* Persist the active organization so headless runs and the hub daemon can
* attach it to telemetry identity. Personal account clears stale org fields.
*/
function persistClineOrganizationContext(
activeOrganization: ClineAccountOrganization | null,
userId: string,
): void {
try {
const manager = new ProviderSettingsManager();
const persisted = manager.getProviderSettings("cline");
if (!persisted) {
return;
}
manager.saveProviderSettings(
{
...persisted,
auth: {
...persisted.auth,
accountId: persisted.auth?.accountId ?? userId,
organizationId: activeOrganization?.organizationId,
organizationName: activeOrganization?.name,
memberId: activeOrganization?.memberId,
},
},
{ setLastUsed: false },
);
} catch {
// Best-effort only.
}
}
export async function loadClineAccountSnapshot(input: {
config: ClineAccountConfig;
clineApiBaseUrl?: string;
@@ -213,16 +183,12 @@ export async function loadClineAccountSnapshot(input: {
const displayedBalance = activeOrganization
? (organizationBalance?.balance ?? balance.balance)
: balance.balance;
const accountContext = {
id: user.id,
email: user.email,
provider: "cline",
organizationId: activeOrganization?.organizationId,
organizationName: activeOrganization?.name,
memberId: activeOrganization?.memberId,
};
const accountContext = resolveClineAccountTelemetryIdentity(user);
identifyTelemetryAccount(accountContext, input.config.logger);
persistClineOrganizationContext(activeOrganization, user.id);
persistClineAccountTelemetryIdentity(
new ProviderSettingsManager(),
accountContext,
);
return {
user,
@@ -47,7 +47,10 @@ export function shouldCloseExtDetailForKey(keyName: string): boolean {
export function shouldToggleExtDetailForKey(
keyName: string,
item: Pick<InteractiveConfigItem, "kind" | "source" | "enabled">,
item: Pick<
InteractiveConfigItem,
"kind" | "source" | "enabled" | "pluginName" | "toggleable"
>,
): boolean {
return (
keyName === "space" &&
@@ -57,7 +60,10 @@ export function shouldToggleExtDetailForKey(
}
export function getExtDetailFooterText(
item: Pick<InteractiveConfigItem, "kind" | "source" | "enabled">,
item: Pick<
InteractiveConfigItem,
"kind" | "source" | "enabled" | "pluginName" | "toggleable"
>,
): string {
return typeof item.enabled === "boolean" &&
isToggleableInteractiveConfigItem(item)
@@ -1,6 +1,7 @@
import {
completeClineDeviceAuth,
getProviderConfigFields,
isLocalAuthProvider,
isOAuthProvider,
loginLocalProvider,
type ProviderConfigFieldKey,
@@ -15,11 +16,10 @@ import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
CODEX_CLI_INSTALL_URL,
type CodexCliStatus,
checkCodexCliInstalled,
isOpenAICodexCliProvider,
} from "../../../utils/codex-cli";
checkLocalCliInstalled,
type LocalCliStatus,
type ProviderLocalCli,
} from "../../../utils/local-cli";
import open from "../../../utils/open";
import { listLocalProviders } from "../../../utils/provider-catalog";
import { useDialogPalette } from "../../hooks/use-theme";
@@ -33,6 +33,7 @@ import {
updateProviderConfigValue,
} from "../../utils/provider-config-values";
import { getProviderSection } from "../../utils/provider-sections";
import { canContinueLocalCliSetup } from "../../views/onboarding/model";
import {
getSearchableListRowsWindow,
type SearchableItem,
@@ -82,7 +83,7 @@ export function ProviderPickerContent(
// just a model id and base URL) still render as configured.
isConfigured: p.enabled === true,
isOAuth: isOAuthProvider(p.id),
isLocalAuth: isOpenAICodexCliProvider(p.id),
isLocalAuth: isLocalAuthProvider(p.id),
capabilities: p.capabilities,
}));
setProviders(providerItems);
@@ -654,29 +655,25 @@ export function ProviderConfigInputContent(
);
}
export function CodexCliStatusContent(
export function LocalCliStatusContent(
props: ChoiceContext<boolean> & {
cli?: ProviderLocalCli;
providerName: string;
},
) {
const { resolve, dismiss, dialogId, providerName } = props;
const { resolve, dismiss, dialogId, cli, providerName } = props;
const palette = useDialogPalette();
const [status, setStatus] = useState<CodexCliStatus | undefined>();
const [status, setStatus] = useState<LocalCliStatus | undefined>();
const [checking, setChecking] = useState(false);
const refresh = useCallback(() => {
if (!cli) return;
setStatus(undefined);
setChecking(true);
checkCodexCliInstalled()
checkLocalCliInstalled(cli)
.then(setStatus)
.catch((error: unknown) => {
setStatus({
installed: false,
reason: error instanceof Error ? error.message : String(error),
});
})
.finally(() => setChecking(false));
}, []);
}, [cli]);
useEffect(() => {
refresh();
@@ -691,7 +688,7 @@ export function CodexCliStatusContent(
refresh();
return;
}
if (key.name === "return" && status?.installed) {
if (key.name === "return" && canContinueLocalCliSetup(cli, status)) {
resolve(true);
}
}, dialogId);
@@ -702,31 +699,37 @@ export function CodexCliStatusContent(
<strong>{providerName}</strong>
</text>
{checking && <text fg="gray">Checking for Codex CLI...</text>}
{checking && <text fg="gray">Checking for {providerName}...</text>}
{status?.installed && (
<box flexDirection="column" gap={1}>
<text fg={palette.success}>{"\u25cf"} Codex CLI installed</text>
<text fg={palette.success}>
{"\u25cf"} {providerName} installed
</text>
<text fg="gray">{status.version}</text>
</box>
)}
{status && !status.installed && (
<box flexDirection="column" gap={1}>
<text fg="yellow">Codex CLI was not found</text>
<text fg="yellow">{providerName} was not found</text>
<text fg="gray">{status.reason}</text>
<text fg="gray">Install Codex CLI from:</text>
<text fg={palette.act} selectable>
{CODEX_CLI_INSTALL_URL}
</text>
{cli?.docsUrl && (
<box flexDirection="column">
<text fg="gray">Install {providerName} from:</text>
<text fg={palette.act} selectable>
{cli.docsUrl}
</text>
</box>
)}
</box>
)}
<text fg="gray">
<em>
{status?.installed
{cli
? "Enter to continue, R to recheck, Esc to go back"
: "R to recheck, Esc to go back"}
: "Enter to continue, Esc to go back"}
</em>
</text>
</box>
+4 -1
View File
@@ -23,6 +23,9 @@ export function Toast(props: { toast: ToastState | null }) {
};
const availableWidth = Math.max(1, width - 4);
const maxWidth = Math.min(44, availableWidth);
// Border and horizontal padding take four columns. An explicit width (not
// maxWidth) is what makes the text wrap instead of clipping at the edge.
const boxWidth = Math.min(maxWidth, props.toast.message.length + 4);
const right = width < 32 ? 0 : 2;
const color = variantColor[props.toast.variant];
@@ -32,7 +35,7 @@ export function Toast(props: { toast: ToastState | null }) {
zIndex={100}
top={1}
right={right}
maxWidth={maxWidth}
width={boxWidth}
border
borderStyle="rounded"
borderColor={color}
+12 -4
View File
@@ -10,7 +10,7 @@ import { isClineProvider } from "@cline/shared";
import type { ChoiceContext } from "@opentui-ui/dialog";
import type { DialogActions } from "@opentui-ui/dialog/react";
import { useCallback } from "react";
import { isOpenAICodexCliProvider } from "../../utils/codex-cli";
import { getLocalCliInfo } from "../../utils/local-cli";
import {
getPersistedProviderApiKey,
isOAuthProvider,
@@ -20,8 +20,8 @@ import type { Config } from "../../utils/types";
import { withLoadingDialog } from "../components/dialogs/loading-dialog";
import {
ClinePassSubscriptionContent,
CodexCliStatusContent,
type ExistingProviderOption,
LocalCliStatusContent,
OAuthApiKeyInputContent,
OAuthLoginContent,
type OAuthLoginResult,
@@ -43,6 +43,7 @@ import {
type ThinkingLevel,
ThinkingLevelContent,
} from "../components/model-selector/model-selector";
import { resolveProviderSetupRoute } from "../views/onboarding/model";
export interface OpenModelSelectorOptions {
onCancel?: () => Promise<void> | void;
@@ -178,6 +179,9 @@ async function runProviderChange(
async () => await getProviderDisplayName(newProviderId),
);
const existingSettings = manager.getProviderSettings(newProviderId);
const needsLocalCliSetup =
resolveProviderSetupRoute(newProviderId) === "local_cli";
const localCliProvider = getLocalCliInfo(newProviderId);
// Manual API key entry is the escape hatch for when OAuth login isn't
// working; only the Cline providers accept a dashboard API key.
@@ -246,12 +250,16 @@ async function runProviderChange(
loginResult === "use_api_key"
? await openManualApiKeyDialog()
: loginResult;
} else if (isOpenAICodexCliProvider(newProviderId)) {
} else if (needsLocalCliSetup) {
saved = await dialog.choice<boolean>({
style: { maxHeight: termHeight - 2 },
closeOnEscape: false,
content: (ctx: ChoiceContext<boolean>) => (
<CodexCliStatusContent {...ctx} providerName={displayName} />
<LocalCliStatusContent
{...ctx}
cli={localCliProvider}
providerName={displayName}
/>
),
});
if (saved) {
+72 -8
View File
@@ -9,6 +9,8 @@ import {
} from "node:path";
import {
type BuiltinToolAvailabilityContext,
type CoreSettingsItem,
type CoreSettingsSnapshot,
DEFAULT_MCP_CONNECT_TIMEOUT_MS,
discoverPluginModulePaths,
getPluginDisplayName,
@@ -80,6 +82,12 @@ export interface InteractiveConfigItem {
| "global-plugin"
| "workspace-plugin";
description?: string;
/** True when the hub discovered this through agent-plugins.org. */
agentPlugin?: boolean;
/** Explicitly overrides the default toggle policy for this item. */
toggleable?: boolean;
/** Explicitly overrides the default delete policy for this item. */
deletable?: boolean;
}
export interface InteractiveConfigData {
@@ -100,8 +108,14 @@ export interface LoadInteractiveConfigDataOptions {
}
export function isToggleableInteractiveConfigItem(
item: Pick<InteractiveConfigItem, "kind" | "source" | "pluginName">,
item: Pick<
InteractiveConfigItem,
"kind" | "source" | "pluginName" | "toggleable"
>,
): boolean {
if (item.toggleable !== undefined) {
return item.toggleable;
}
if (item.kind === "mcp") {
return !item.pluginName;
}
@@ -324,12 +338,53 @@ export function applyPluginFailures(
}
}
function toAgentPluginInteractiveItem(
item: CoreSettingsItem,
): InteractiveConfigItem {
return {
id: item.id,
name: item.name,
path: item.path,
enabled: item.enabled,
kind: item.kind,
source: item.source,
description: item.description,
pluginName: item.pluginName,
pluginPath: item.pluginPath,
loadError: item.loadError,
agentPlugin: true,
toggleable: item.toggleable ?? false,
deletable: false,
...(item.kind === "plugin" ? { configKind: "plugin" as const } : {}),
};
}
function appendAgentPluginSnapshotItems(
target: InteractiveConfigItem[],
items: readonly CoreSettingsItem[],
): void {
const existing = new Set(
target.map((item) => `${item.kind}\0${item.id}\0${item.path}`),
);
for (const item of items) {
if (item.agentPlugin !== true) {
continue;
}
const key = `${item.kind}\0${item.id}\0${item.path}`;
if (!existing.has(key)) {
target.push(toAgentPluginInteractiveItem(item));
existing.add(key);
}
}
}
export async function loadInteractiveConfigData(input: {
userInstructionService?: UserInstructionConfigService;
cwd: string;
workspaceRoot: string;
availabilityContext?: BuiltinToolAvailabilityContext;
includePluginTools?: boolean;
agentPluginSettings?: CoreSettingsSnapshot;
}): Promise<InteractiveConfigData> {
const workflows: InteractiveConfigItem[] = [];
const rules: InteractiveConfigItem[] = [];
@@ -518,14 +573,23 @@ export async function loadInteractiveConfigData(input: {
}
}
if (input.agentPluginSettings) {
appendAgentPluginSnapshotItems(plugins, input.agentPluginSettings.plugins);
appendAgentPluginSnapshotItems(skills, input.agentPluginSettings.skills);
appendAgentPluginSnapshotItems(mcp, input.agentPluginSettings.mcp);
}
const existsLocallyOrComesFromHub = (item: InteractiveConfigItem) =>
item.agentPlugin === true || existsSync(item.path);
return {
workflows: toSorted(workflows.filter((item) => existsSync(item.path))),
rules: toSorted(rules.filter((item) => existsSync(item.path))),
skills: toSorted(skills.filter((item) => existsSync(item.path))),
hooks: toSorted(hooks.filter((item) => existsSync(item.path))),
agents: toSorted(agents.filter((item) => existsSync(item.path))),
plugins: toSorted(plugins.filter((item) => existsSync(item.path))),
mcp: toSorted(mcp.filter((item) => existsSync(item.path))),
workflows: toSorted(workflows.filter(existsLocallyOrComesFromHub)),
rules: toSorted(rules.filter(existsLocallyOrComesFromHub)),
skills: toSorted(skills.filter(existsLocallyOrComesFromHub)),
hooks: toSorted(hooks.filter(existsLocallyOrComesFromHub)),
agents: toSorted(agents.filter(existsLocallyOrComesFromHub)),
plugins: toSorted(plugins.filter(existsLocallyOrComesFromHub)),
mcp: toSorted(mcp.filter(existsLocallyOrComesFromHub)),
tools: toSorted(tools),
workflowSlashCommands,
pluginDiagnosticsLoaded: input.includePluginTools !== false,
+50 -1
View File
@@ -128,12 +128,61 @@ export function resolveActiveConfigItems(
}
}
export interface ConfigPluginSection {
label: string;
items: InteractiveConfigItem[];
}
export function getConfigPluginSections(
items: readonly InteractiveConfigItem[],
): ConfigPluginSection[] {
const clinePlugins = items.filter((item) => item.agentPlugin !== true);
const agentPlugins = items.filter((item) => item.agentPlugin === true);
return [
...(clinePlugins.length > 0
? [
{
label: `Cline Plugins (${clinePlugins.length})`,
items: clinePlugins,
},
]
: []),
...(agentPlugins.length > 0
? [
{
label: `Agent Plugins (${agentPlugins.length})`,
items: agentPlugins,
},
]
: []),
];
}
export function getConfigTabCountHeading(
tab: InteractiveConfigTab,
itemCount: number,
): string | undefined {
return tab === "plugins" ? undefined : `${toTabLabel(tab)} (${itemCount})`;
}
export function shouldRenderConfigItemAsEnabled(
item: InteractiveConfigItem,
enabledState: "enabled" | "disabled" | "partial",
): boolean {
return (
enabledState === "enabled" &&
(isToggleableInteractiveConfigItem(item) || item.agentPlugin === true)
);
}
export function isToggleableConfigItem(item: InteractiveConfigItem): boolean {
return isToggleableInteractiveConfigItem(item);
}
export function isDeletableConfigItem(item: InteractiveConfigItem): boolean {
return item.kind === "plugin";
return (
item.deletable ?? (item.kind === "plugin" && item.agentPlugin !== true)
);
}
export function resolveConfigItemSelectAction(
@@ -5,11 +5,16 @@ import {
getAdjacentConfigTab,
getConfigFooterText,
getConfigItemDisplayName,
getConfigPluginSections,
getConfigTabCountHeading,
isDeletableConfigItem,
isInlineConfigAction,
isToggleableConfigItem,
resolveConfigItemDeleteAction,
resolveConfigItemSelectAction,
resolveConfigItemToggleAction,
resolveInitialConfigTab,
shouldRenderConfigItemAsEnabled,
} from "./config-view-helpers";
function createItem(
@@ -71,6 +76,65 @@ describe("config view helpers", () => {
).toBe(false);
});
it("lets users toggle hub-discovered Agent Plugins without deleting them", () => {
const plugin = createItem({
kind: "plugin",
agentPlugin: true,
toggleable: true,
deletable: false,
source: "global-plugin",
});
expect(isToggleableConfigItem(plugin)).toBe(true);
expect(isDeletableConfigItem(plugin)).toBe(false);
expect(resolveConfigItemToggleAction(plugin)).toEqual({
kind: "toggle-item",
item: plugin,
});
expect(resolveConfigItemDeleteAction(plugin)).toBeUndefined();
expect(resolveConfigItemSelectAction(plugin)).toEqual({
kind: "toggle-item",
item: plugin,
});
});
it("separates Cline and Agent Plugins into labeled sections", () => {
const clinePlugin = createItem({
kind: "plugin",
name: "cline-plugin",
source: "workspace-plugin",
});
const agentPlugin = createItem({
kind: "plugin",
name: "portable-plugin",
source: "global-plugin",
agentPlugin: true,
});
expect(getConfigPluginSections([clinePlugin, agentPlugin])).toEqual([
{ label: "Cline Plugins (1)", items: [clinePlugin] },
{ label: "Agent Plugins (1)", items: [agentPlugin] },
]);
});
it("uses section counts instead of a combined Plugins heading", () => {
expect(getConfigTabCountHeading("plugins", 10)).toBeUndefined();
expect(getConfigTabCountHeading("skills", 20)).toBe("Skills (20)");
});
it("renders a loaded Agent Plugin as enabled", () => {
const agentPlugin = createItem({
kind: "plugin",
agentPlugin: true,
toggleable: true,
});
expect(shouldRenderConfigItemAsEnabled(agentPlugin, "enabled")).toBe(true);
expect(shouldRenderConfigItemAsEnabled(agentPlugin, "disabled")).toBe(
false,
);
});
it("resolves Enter/Tab on a skill row to details", () => {
const skill = createItem({
kind: "skill",
+36 -19
View File
@@ -25,6 +25,8 @@ import {
getAdjacentConfigTab,
getConfigFooterText,
getConfigItemDisplayName,
getConfigPluginSections,
getConfigTabCountHeading,
getConfigTabs,
getPluginDiagnosticsLoadingText,
isInlineConfigAction,
@@ -34,6 +36,7 @@ import {
resolveConfigItemSelectAction,
resolveConfigItemToggleAction,
resolveInitialConfigTab,
shouldRenderConfigItemAsEnabled,
toTabLabel,
} from "./config-view-helpers";
@@ -298,6 +301,16 @@ function appendSkillRows(
}
}
function appendPluginRows(
rows: ConfigRow[],
items: InteractiveConfigItem[],
): void {
for (const section of getConfigPluginSections(items)) {
rows.push({ kind: "head", label: section.label });
appendExtRows(rows, section.items);
}
}
function withOptimisticToggle(
data: InteractiveConfigData,
item: InteractiveConfigItem,
@@ -477,10 +490,13 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
r.push({ kind: "toggle", id: "verbose", label: "Verbose" });
} else {
const activeItems = resolveActiveConfigItems(configData, activeTab);
r.push({
kind: "head",
label: `${toTabLabel(activeTab)} (${activeItems.length})`,
});
const countHeading = getConfigTabCountHeading(
activeTab,
activeItems.length,
);
if (countHeading) {
r.push({ kind: "head", label: countHeading });
}
if (activeItems.length === 0 && !pluginToolsLoading) {
r.push({
@@ -504,6 +520,21 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
}
} else if (activeTab === "skills") {
appendSkillRows(r, activeItems);
} else if (activeTab === "plugins") {
appendPluginRows(r, activeItems);
if (pluginToolsLoading) {
const loadingText = getPluginDiagnosticsLoadingText(activeTab);
r.push({
kind: "detail",
text: loadingText ?? "Loading plugin diagnostics...",
});
}
if (pluginToolsError) {
r.push({
kind: "detail",
text: pluginToolsError,
});
}
} else {
for (const item of activeItems) {
r.push({
@@ -523,19 +554,6 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
: getPluginLoadErrorLabel(item),
});
}
if (activeTab === "plugins" && pluginToolsLoading) {
const loadingText = getPluginDiagnosticsLoadingText(activeTab);
r.push({
kind: "detail",
text: loadingText ?? "Loading plugin diagnostics...",
});
}
if (activeTab === "plugins" && pluginToolsError) {
r.push({
kind: "detail",
text: pluginToolsError,
});
}
}
if (activeTab === "mcp") {
@@ -871,11 +889,10 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
: "○ "
: "";
const rightLabel = row.rightLabel ?? "";
const toggleable = isToggleableConfigItem(row.item);
const prefix = " ".repeat(row.indent ?? 0);
const rowColor = row.item.loadError
? "red"
: toggleable && enabledState === "enabled"
: shouldRenderConfigItemAsEnabled(row.item, enabledState)
? palette.success
: enabledState === "partial"
? "yellow"
+54 -31
View File
@@ -17,10 +17,11 @@ import {
getIndividualPlanFeatures,
} from "../../../utils/cline-pass-errors";
import {
type CodexCliStatus,
checkCodexCliInstalled,
isOpenAICodexCliProvider,
} from "../../../utils/codex-cli";
checkLocalCliInstalled,
getLocalCliInfo,
type LocalCliStatus,
type ProviderLocalCli,
} from "../../../utils/local-cli";
import open from "../../../utils/open";
import { getPersistedProviderApiKey } from "../../../utils/provider-auth";
import { listLocalProviders } from "../../../utils/provider-catalog";
@@ -59,6 +60,7 @@ import { useOnboardingKeyboard } from "./keyboard";
import {
CLINE_PASS_SUBSCRIPTION_OPTIONS,
type ClinePassSubscriptionStatus,
canContinueLocalCliSetup,
DEFAULT_THINKING_LEVEL_INDEX,
getMainMenuOptions,
type ModelEntry,
@@ -66,6 +68,7 @@ import {
type OnboardingStep,
type ProviderEntry,
type ReasoningEffort,
resolveProviderSetupRoute,
shouldUseFeaturedClineModelPicker,
type ThinkingLevel,
toModelEntriesFromKnownModels,
@@ -103,6 +106,10 @@ export function useOnboardingController(props: OnboardingControllerProps) {
const [authError, setAuthError] = useState("");
const [activeProviderId, setActiveProviderId] = useState("");
const [activeProviderName, setActiveProviderName] = useState("");
const localCli = useMemo(
() => getLocalCliInfo(activeProviderId),
[activeProviderId],
);
const [byoFields, setByoFields] = useState<ProviderConfigFields["fields"]>(
{},
);
@@ -110,10 +117,11 @@ export function useOnboardingController(props: OnboardingControllerProps) {
const [byoValues, setByoValues] = useState<ProviderConfigValues>({});
const [byoFocusedField, setByoFocusedField] =
useState<ProviderConfigFieldKey>("apiKey");
const [codexCliStatus, setCodexCliStatus] = useState<
CodexCliStatus | undefined
const [localCliStatus, setLocalCliStatus] = useState<
LocalCliStatus | undefined
>();
const [codexCliChecking, setCodexCliChecking] = useState(false);
const [localCliChecking, setLocalCliChecking] = useState(false);
const localCliProbeRef = useRef(0);
const authAbortRef = useRef(false);
// Device code flow
@@ -486,18 +494,23 @@ export function useOnboardingController(props: OnboardingControllerProps) {
}
}, [step, clinePassSubscriptionStatus, transitionToModelPicker]);
const refreshCodexCliStatus = useCallback(() => {
setCodexCliStatus(undefined);
setCodexCliChecking(true);
checkCodexCliInstalled()
.then(setCodexCliStatus)
.catch((error: unknown) => {
setCodexCliStatus({
installed: false,
reason: error instanceof Error ? error.message : String(error),
});
const refreshLocalCliStatus = useCallback((provider: ProviderLocalCli) => {
// Probing spawns the provider's CLI, so a result can land long after the
// user moved on. Two local-CLI providers share this single status, so an
// unlabelled result could mark the selected provider ready off a probe of
// the previous one (or block it off a stale failure). Only the newest
// probe may write.
const probeId = ++localCliProbeRef.current;
const isCurrentProbe = () => localCliProbeRef.current === probeId;
setLocalCliStatus(undefined);
setLocalCliChecking(true);
checkLocalCliInstalled(provider)
.then((status) => {
if (isCurrentProbe()) setLocalCliStatus(status);
})
.finally(() => setCodexCliChecking(false));
.finally(() => {
if (isCurrentProbe()) setLocalCliChecking(false);
});
}, []);
const selectProvider = useCallback(
@@ -510,12 +523,14 @@ export function useOnboardingController(props: OnboardingControllerProps) {
}
return;
}
if (provider.isLocalAuth || isOpenAICodexCliProvider(provider.id)) {
if (resolveProviderSetupRoute(provider.id) === "local_cli") {
setActiveProviderId(provider.id);
setActiveProviderName(provider.name);
setCodexCliStatus(undefined);
setStep("codex_cli_setup");
refreshCodexCliStatus();
setStep("local_cli_setup");
// Only providers that name a CLI have something to probe; the
// rest reach the screen with readiness simply unknown.
const localCliProvider = getLocalCliInfo(provider.id);
if (localCliProvider) refreshLocalCliStatus(localCliProvider);
return;
}
const config = getProviderConfigFields(provider.id);
@@ -575,11 +590,17 @@ export function useOnboardingController(props: OnboardingControllerProps) {
setByoFocusedField(firstField ?? "apiKey");
setStep("byo_apikey");
},
[providers, startOAuthFlow, refreshCodexCliStatus, providerSettingsManager],
[providers, startOAuthFlow, refreshLocalCliStatus, providerSettingsManager],
);
const saveCodexCliConfig = useCallback(() => {
if (!codexCliStatus?.installed) {
const recheckLocalCli = useCallback(() => {
if (localCli) {
refreshLocalCliStatus(localCli);
}
}, [localCli, refreshLocalCliStatus]);
const saveLocalCliConfig = useCallback(() => {
if (!canContinueLocalCliSetup(localCli, localCliStatus)) {
return;
}
saveLocalProviderSettings(providerSettingsManager, {
@@ -588,7 +609,8 @@ export function useOnboardingController(props: OnboardingControllerProps) {
transitionToModelPicker(activeProviderId);
}, [
activeProviderId,
codexCliStatus,
localCli,
localCliStatus,
providerSettingsManager,
transitionToModelPicker,
]);
@@ -798,13 +820,13 @@ export function useOnboardingController(props: OnboardingControllerProps) {
deviceAbortRef.current = true;
},
resetAuth,
refreshCodexCliStatus,
refreshLocalCliStatus: recheckLocalCli,
startOAuthFlow,
startDeviceCodeFlow,
selectProvider,
loadModelsForProvider,
saveClineModelSelection,
saveCodexCliConfig,
saveLocalCliConfig,
saveByoConfig,
saveModelSelection,
saveThinkingLevel,
@@ -820,8 +842,9 @@ export function useOnboardingController(props: OnboardingControllerProps) {
byoFields,
byoFocusedField,
byoValues,
codexCliChecking,
codexCliStatus,
localCli,
localCliChecking,
localCliStatus,
clineEntries,
clineModelSelected,
clinePassCurrentPlanName,
@@ -860,7 +883,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
providersLoading,
recommendedLoading: recommended.loading,
saveByoConfig,
saveCodexCliConfig,
saveLocalCliConfig,
saveCustomModelId,
selectedModelName,
step,
@@ -51,13 +51,13 @@ export function useOnboardingKeyboard(input: {
abortOAuth: () => void;
abortDeviceCode: () => void;
resetAuth: () => void;
refreshCodexCliStatus: () => void;
refreshLocalCliStatus: () => void;
startOAuthFlow: (providerId: OnboardingOAuthProviderId) => void;
startDeviceCodeFlow: (providerId: OnboardingOAuthProviderId) => void;
selectProvider: (providerId: string) => void;
loadModelsForProvider: (providerId: string) => void;
saveClineModelSelection: (modelId: string, modelName: string) => void;
saveCodexCliConfig: () => void;
saveLocalCliConfig: () => void;
saveByoConfig: () => void;
saveModelSelection: () => void;
saveThinkingLevel: (level: ThinkingLevel) => void;
@@ -98,7 +98,7 @@ export function useOnboardingKeyboard(input: {
input.setMenuSelected(0);
return;
}
if (input.step === "codex_cli_setup") {
if (input.step === "local_cli_setup") {
input.setStep("byo_provider");
return;
}
@@ -227,13 +227,13 @@ export function useOnboardingKeyboard(input: {
return;
}
if (input.step === "codex_cli_setup") {
if (input.step === "local_cli_setup") {
if (key.name === "r") {
input.refreshCodexCliStatus();
input.refreshLocalCliStatus();
return;
}
if (key.name === "return") {
input.saveCodexCliConfig();
input.saveLocalCliConfig();
}
return;
}
@@ -1,7 +1,15 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { getLocalCliInfo } from "../../../utils/local-cli";
vi.mock("../../../utils/local-cli", () => ({
getLocalCliInfo: () => undefined,
}));
import {
canContinueLocalCliSetup,
getMainMenuOptions,
getOAuthProviderLabel,
resolveProviderSetupRoute,
shouldUseFeaturedClineModelPicker,
toModelEntriesFromKnownModels,
toModelEntry,
@@ -77,6 +85,20 @@ describe("onboarding model helpers", () => {
});
});
it("marks the Claude Code provider as local auth", () => {
expect(
toProviderEntry({
id: "claude-code",
name: "Claude Code",
models: null,
}),
).toMatchObject({
id: "claude-code",
isOAuth: false,
isLocalAuth: true,
});
});
it("maps model names and reasoning support strictly", () => {
expect(
toModelEntry({
@@ -166,3 +188,31 @@ describe("onboarding model helpers", () => {
expect(shouldUseFeaturedClineModelPicker("anthropic")).toBe(false);
});
});
describe("local-auth setup routing", () => {
// A provider can declare `local-auth` without naming a CLI we can probe.
// Routing must follow the capability; the descriptor is only for probing.
// Otherwise it falls through to the API-key form, which renders no fields
// for a local-auth provider.
it("routes a local-auth provider with no CLI descriptor to local setup", () => {
expect(getLocalCliInfo("claude-code")).toBeUndefined();
expect(resolveProviderSetupRoute("claude-code")).toBe("local_cli");
});
it("routes OAuth and API-key providers unchanged", () => {
expect(resolveProviderSetupRoute("anthropic")).toBe("api_key");
});
// The probe only looks on PATH, while the runtime also accepts an explicit
// pathToClaudeCodeExecutable and a bundled platform binary. A PATH miss
// therefore means "not on PATH", not "unusable", so it must not block.
it("lets the user continue when the CLI is not found on PATH", () => {
const cli = { command: "claude", docsUrl: "https://example.invalid" };
expect(
canContinueLocalCliSetup(cli, {
installed: false,
reason: "The claude executable was not found on PATH.",
}),
).toBe(true);
});
});
+39 -4
View File
@@ -4,8 +4,14 @@ import type {
ModelOperation,
} from "@cline/shared";
import { isChatProviderModel } from "../../../utils/chat-models";
import { isOpenAICodexCliProvider } from "../../../utils/codex-cli";
import { isOAuthProvider } from "../../../utils/provider-auth";
import type {
LocalCliStatus,
ProviderLocalCli,
} from "../../../utils/local-cli";
import {
isLocalAuthProvider,
isOAuthProvider,
} from "../../../utils/provider-auth";
export type OnboardingStep =
| "menu"
@@ -13,7 +19,7 @@ export type OnboardingStep =
| "device_code"
| "byo_provider"
| "byo_apikey"
| "codex_cli_setup"
| "local_cli_setup"
| "cline_pass_subscription"
| "cline_model"
| "model_picker"
@@ -85,6 +91,35 @@ export const MAIN_MENU: MenuOption[] = [
},
];
/**
* Which setup flow a provider needs. Keyed off how the provider authenticates,
* so every caller routes the same way.
*/
export type ProviderSetupRoute = "oauth" | "local_cli" | "api_key";
export function resolveProviderSetupRoute(
providerId: string,
): ProviderSetupRoute {
if (isOAuthProvider(providerId)) return "oauth";
if (isLocalAuthProvider(providerId)) return "local_cli";
return "api_key";
}
/**
* Whether the local-CLI setup screen lets the user connect.
*/
export function canContinueLocalCliSetup(
_cli: ProviderLocalCli | undefined,
_status: LocalCliStatus | undefined,
): boolean {
// The probe only looks on PATH, while the runtime also accepts an explicit
// pathToClaudeCodeExecutable and a bundled platform binary, and Codex falls
// back through `npx`. A PATH miss therefore means "not on PATH", not
// "unusable", so the screen reports it without blocking — a provider that
// really cannot start says so on the first turn, in its own words.
return true;
}
export function getMainMenuOptions(options?: {
isClinePassEnabled?: boolean;
}): MenuOption[] {
@@ -174,7 +209,7 @@ export function toProviderEntry(provider: ProviderCatalogItem): ProviderEntry {
id: provider.id,
name: provider.name,
isOAuth: isOAuthProvider(provider.id),
isLocalAuth: isOpenAICodexCliProvider(provider.id),
isLocalAuth: isLocalAuthProvider(provider.id),
hasAuth:
Boolean(provider.apiKey) || provider.oauthAccessTokenPresent === true,
...(provider.capabilities ? { capabilities: provider.capabilities } : {}),
+24 -15
View File
@@ -2,10 +2,10 @@ import "opentui-spinner/react";
import type { ScrollBoxRenderable } from "@opentui/core";
import type { ReactNode } from "react";
import { useEffect, useRef } from "react";
import {
CODEX_CLI_INSTALL_URL,
type CodexCliStatus,
} from "../../../utils/codex-cli";
import type {
LocalCliStatus,
ProviderLocalCli,
} from "../../../utils/local-cli";
import {
ClineModelPicker,
type ClineModelPickerEntry,
@@ -25,6 +25,7 @@ import { FIELD_ORDER } from "./fields";
import {
type ClinePassSubscriptionOption,
type ClinePassSubscriptionStatus,
canContinueLocalCliSetup,
type MenuOption,
THINKING_LEVELS,
} from "./model";
@@ -362,18 +363,20 @@ export function OnboardingProviderConfigScreen(props: {
);
}
export function OnboardingCodexCliScreen(props: {
export function OnboardingLocalCliScreen(props: {
activeProviderName: string;
checking: boolean;
cli?: ProviderLocalCli;
compact: boolean;
contentWidth: number;
mouse: MouseTrackerState;
status?: CodexCliStatus;
status?: LocalCliStatus;
}) {
const defaultFg = useDefaultFg();
const colors = useOnboardingColors();
const installedStatus =
props.status?.installed === true ? props.status : undefined;
const canContinue = canContinueLocalCliSetup(props.cli, props.status);
return (
<OnboardingFrame
compact={props.compact}
@@ -386,31 +389,37 @@ export function OnboardingCodexCliScreen(props: {
{props.checking && (
<box flexDirection="row" gap={1}>
<spinner name="dots" color="gray" />
<text fg="gray">Checking for Codex CLI...</text>
<text fg="gray">Checking for {props.activeProviderName}...</text>
</box>
)}
{installedStatus && (
<box flexDirection="column" gap={1} alignItems="center">
<text fg={colors.success}>{"\u25cf"} Codex CLI installed</text>
<text fg={colors.success}>
{"\u25cf"} {props.activeProviderName} installed
</text>
<text fg="gray">{installedStatus.version}</text>
</box>
)}
{props.status && !props.status.installed && (
{props.cli && props.status && !props.status.installed && (
<box flexDirection="column" gap={1} width={props.contentWidth}>
<text fg="yellow">Codex CLI was not found</text>
<text fg="yellow">{props.activeProviderName} was not found</text>
<text fg="gray">{props.status.reason}</text>
<text fg="gray">Install Codex CLI from:</text>
<text fg={colors.accent} selectable>
{CODEX_CLI_INSTALL_URL}
</text>
{props.cli.docsUrl && (
<box flexDirection="column">
<text fg="gray">Install {props.activeProviderName} from:</text>
<text fg={colors.accent} selectable>
{props.cli.docsUrl}
</text>
</box>
)}
</box>
)}
<text fg="gray">
<em>
{installedStatus
{canContinue
? "Enter to continue, R to recheck, Esc to go back, Ctrl+C to exit"
: "R to recheck, Esc to go back, Ctrl+C to exit"}
</em>
+6 -5
View File
@@ -7,10 +7,10 @@ import { getOAuthProviderLabel, type OnboardingResult } from "./model";
import {
OnboardingClineModelScreen,
OnboardingClinePassSubscriptionScreen,
OnboardingCodexCliScreen,
OnboardingCustomModelIdScreen,
OnboardingDeviceCodeScreen,
OnboardingDoneScreen,
OnboardingLocalCliScreen,
OnboardingMainMenuScreen,
OnboardingModelPickerScreen,
OnboardingOAuthPendingScreen,
@@ -83,15 +83,16 @@ export function OnboardingView(props: OnboardingViewProps) {
);
}
if (state.step === "codex_cli_setup") {
if (state.step === "local_cli_setup" && state.localCli) {
return (
<OnboardingCodexCliScreen
<OnboardingLocalCliScreen
activeProviderName={state.activeProviderName}
checking={state.codexCliChecking}
checking={state.localCliChecking}
cli={state.localCli}
compact={compact}
contentWidth={contentWidth}
mouse={mouse}
status={state.codexCliStatus}
status={state.localCliStatus}
/>
);
}
-55
View File
@@ -1,55 +0,0 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
export const OPENAI_CODEX_CLI_PROVIDER_ID = "openai-codex-cli";
export const CODEX_CLI_INSTALL_URL = "https://developers.openai.com/codex/cli";
export type CodexCliStatus =
| {
installed: true;
version: string;
}
| {
installed: false;
reason: string;
};
export function isOpenAICodexCliProvider(providerId: string): boolean {
return providerId.trim().toLowerCase() === OPENAI_CODEX_CLI_PROVIDER_ID;
}
export async function checkCodexCliInstalled(): Promise<CodexCliStatus> {
try {
const result = await execFileAsync("codex", ["--version"], {
timeout: 3000,
windowsHide: true,
});
const version = (result.stdout || result.stderr).trim();
return {
installed: true,
version: version || "codex",
};
} catch (error) {
const details =
error && typeof error === "object"
? (error as { code?: unknown; message?: unknown })
: undefined;
const code = typeof details?.code === "string" ? details.code : "";
if (code === "ENOENT") {
return {
installed: false,
reason: "The codex executable was not found on PATH.",
};
}
const message =
typeof details?.message === "string"
? details.message
: "Could not run codex --version.";
return {
installed: false,
reason: message,
};
}
}
+30
View File
@@ -0,0 +1,30 @@
import { isLocalAuthProvider } from "@cline/core";
import { describe, expect, it } from "vitest";
import { getLocalCliInfo } from "./local-cli";
describe("local CLI providers", () => {
it("reads the CLI a local-auth provider borrows credentials from", () => {
expect(getLocalCliInfo("openai-codex-cli")).toEqual({
command: "codex",
docsUrl: "https://developers.openai.com/codex/cli",
});
expect(getLocalCliInfo("claude-code")).toEqual({
command: "claude",
docsUrl: "https://code.claude.com/docs/en/setup",
});
});
it("names no CLI for providers that authenticate with an API key", () => {
expect(getLocalCliInfo("anthropic")).toBeUndefined();
expect(getLocalCliInfo("openai-codex")).toBeUndefined();
});
// Routing is keyed off the capability alone, so a local-auth provider whose
// credentials come from somewhere unprobeable still reaches the local setup
// screen instead of an empty API-key form.
it("routes on the capability, not on knowing a CLI", () => {
expect(isLocalAuthProvider("claude-code")).toBe(true);
expect(isLocalAuthProvider("openai-codex-cli")).toBe(true);
expect(isLocalAuthProvider("anthropic")).toBe(false);
});
});
+59
View File
@@ -0,0 +1,59 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { Llms } from "@cline/core";
const execFileAsync = promisify(execFile);
export type ProviderLocalCli = Llms.ProviderLocalCli;
export type LocalCliStatus =
| {
installed: true;
version: string;
}
| {
installed: false;
reason: string;
};
/**
* The CLI a `local-auth` provider borrows credentials from, as declared in
* the provider catalog. `undefined` for providers that name none those are
* connected without a readiness check rather than probing a guessed command.
*/
export function getLocalCliInfo(
providerId: string,
): ProviderLocalCli | undefined {
return Llms.resolveProviderLocalCli(providerId);
}
export async function checkLocalCliInstalled(
cli: ProviderLocalCli,
): Promise<LocalCliStatus> {
try {
const result = await execFileAsync(cli.command, ["--version"], {
timeout: 3000,
windowsHide: true,
});
const version = (result.stdout || result.stderr).trim();
return {
installed: true,
version: version || cli.command,
};
} catch (error) {
const details = error as NodeJS.ErrnoException | undefined;
if (details?.code === "ENOENT") {
return {
installed: false,
reason: `The ${cli.command} executable was not found on PATH.`,
};
}
return {
installed: false,
reason:
error instanceof Error
? error.message
: `Could not run ${cli.command} --version.`,
};
}
}
+2 -1
View File
@@ -1,6 +1,7 @@
import {
formatProviderOAuthApiKey,
getPersistedProviderApiKey as getCorePersistedProviderApiKey,
isLocalAuthProvider,
isOAuthProvider,
Llms,
type ProviderOAuthCredentials,
@@ -21,7 +22,7 @@ export function normalizeAuthProviderId(providerId: string): string {
return normalizeProviderId(normalized);
}
export { isOAuthProvider };
export { isLocalAuthProvider, isOAuthProvider };
export function toProviderApiKey(
providerId: string,
+48
View File
@@ -1,5 +1,52 @@
# Cline Desktop Changelog
## 0.0.24
- Fixed the live chat stream doubling text and dropping messages mid-turn. The sidecar has two Hub sockets that both receive a session's events — ClineCore's own client and the observer client — and a session that streams without a local send first (a run already in flight when you open the task, a resumed run, a scheduled run) had every delta rendered twice. The observer's copy is now skipped whenever ClineCore is subscribed to the session, asked directly rather than inferred from a timer, so long commands, slow first tokens, and unanswered tool approvals cannot let a duplicate slip through ahead of the core copy. Separately, when the sidecar was replaced under a live webview (crash-respawn, Hub drain-and-replace, stale-sidecar swap) its stream counter restarted at 1 and the webview silently discarded everything until the new process counted past the old run — this dropped your own message bubbles and tool rows, not just assistant text, which is why rows appeared to vanish mid-turn and come back afterwards
- Fixed a queued prompt's own message vanishing from the chat. When you queue a prompt behind a running turn, the runtime drains the queue just before it answers the previous send, so the previous turn's completion path replaced the whole transcript from a canonical read that predated your queued message — erasing your bubble and leaving the reply streaming in under no user message. That path now defers to the newer turn instead of treating the transcript as its own. Two symptoms rode on the same bug: the composer no longer drops out of its busy state while the queued reply is still pending, and a finished reasoning row now reads "Thought for Ns" instead of a stuck "Thinking" — live rows are stamped on the webview's clock, so a sidecar whose clock trails it (a remote Hub, the browser-dev setup) no longer produces a negative duration that gets dropped
- Cline no longer stops silently mid-task when a model gets stuck repeating itself. The loop detector stops a run after 5 identical tool calls and the mistake tracker after 6 consecutive failures, but the desktop never registered a decision callback, so the run just ended and the composer went idle with no message. You are now asked how to continue — "Try a different approach" or "Stop this run" — and the guidance is steered into the running turn so the model knows why it was paused instead of repeating the same call
- The `editor` tool's error message now names the file, says whether `old_text` was null or omitted, and states how to recover. Models that fill optional parameters with null (seen with kimi-k3) hit a terse "old_text is required" and re-sent the identical call until the loop detector stopped the run
- Fixed your Cline Pass model selection being replaced when you start a new chat. Catalogs are discovery data, not validation — the bundled catalog can omit live Cline Pass models and refreshes can return partial lists, so a model missing from the catalog was treated as invalid and silently swapped for a default
- Cline Desktop now has a custom title bar on Windows, with caption controls that follow the compact title-bar height in narrow windows and stay above overlays. The Windows taskbar icon was also updated
- Token counts and costs now fill in for every session you can see. The sessions view only ever hydrated the four most recent rows, so every other row showed "-" and paging never asked for more; the visible page is now hydrated on demand, with reads capped and re-run when a session's status changes underneath them
- Sessions imported from Claude Code, Codex, and opencode now say so in the chat, and their foreign history is summarized on the first resumed turn. Imported transcripts keep the source tool's own tool names and schemas, which a model continuing them may try to call — the summary runs once, the original transcript stays intact, and the "Thinking..." indicator reads "Summarizing the imported <tool> history..." while it happens
- Fixed session history rendering empty when one session had many subagent or team-task children. Child rows always sort after the root that spawned them, so a single busy session could hide itself and every older session from the sidebar with no way to load more
- Checkpoints no longer re-hash every untracked file before each message. Checkpoint creation rebuilt a throwaway git index each turn, so multi-GB untracked data blocked every message for seconds to minutes (~90s in one report on a cloud-synced Windows workspace). One snapshot index is now kept per session, so from the second turn the cost is roughly git process overhead. Snapshot contents are byte-identical to before
- Commands that background a child process (`cmd &`, `nohup`, and the same from Git Bash) no longer hang until the timeout. The inherited stdio pipes stay open after the shell exits, so the completion event never arrived even though the command was done; these now settle with the real exit code and a note that background output is no longer captured
- Typing an `@` mention from your home directory no longer indexes your entire home folder. That could take memory into the gigabytes and get the process killed; the home directory and filesystem root are now skipped entirely
- Web search is now enabled by default outside YOLO mode, and tool settings fail closed if they cannot be loaded
- Claude Code no longer asks for an API key it never reads. It authenticates from the local `claude` CLI's own credential store, but was reported as an API-key provider, so a keyless entry was refused and the workaround was to save a dummy key
- Pasted credentials with invisible characters no longer persist corrupted. A BOM or zero-width character carried in from a copy-paste produced 401s indistinguishable from a wrong key; credential fields are now stripped of control and format characters on save
- Starting a new task no longer flickers through the idle state. The Hub publishes the new session's record as "idle" while the start request is still in flight, so the composer placeholder and the request indicator switched to idle and back for a frame on every new task. A transient idle arriving during a submission is now held back; a real failure or abort still applies immediately
- The model picker keeps section headers visible while you search. Cline Pass lists the same model in both the Subscribed and Free tiers, so flattening the sections during search produced two identical-looking rows
- `apply_patch` "Add File" now refuses to overwrite an existing file instead of silently replacing it
- Fixed session import paths resolving incorrectly on Windows
- The desktop backend now starts off the command path, so startup no longer blocks the UI
- The SDK can now connect to authenticated remote Hubs
## 0.0.23
- Agent Plugins are now discovered and run by the shared Hub. Packages under `~/.agents/plugins` are validated from their `plugin.json`, their valid Agent Skills become available to the agent, and their stdio / Streamable HTTP / SSE MCP servers start automatically. Settings → Customize lists Agent Plugins separately from Cline Plugins, with each plugin's description, badge, and contributed tools, and enable/disable is Hub-managed per plugin. Workspace `.agents/plugins` directories are intentionally ignored
- The "Cline Hub was updated" dialog no longer appears on every launch and reconnect. The app no longer prompts about a Hub running the same core version it does — a desktop and CLI release cut from different commits bundle the same core but never share a build fingerprint, so anyone with both installed got a dialog whose "Update and restart" looped on "no app update available". The build-mismatch dialog now also waits until an app update is actually staged, and "Later" sticks across session switches, reloads, and relaunches instead of resurfacing every time. A Hub the app genuinely cannot talk to still warns every time
- Signing in now shows the device confirmation code in the app while you wait on the browser, so you can match it against the code the browser asks you to confirm — in onboarding, Account settings, and the provider list
- Voice input failures caused by provider setup — missing credentials, transcription config — now take you straight to voice settings instead of a toast you cannot act on. Genuine microphone permission failures still toast, with a clearer message
- Fixed the scheduled-task report vanishing when a finished run's step collapsed
- Fixed one wedged MCP server blocking the rest from shutting down, leaking their processes
## 0.0.22
- Import your history from Claude Code, Codex, and opencode. An Import button in the Sessions header (and a row in Settings → General) scans your local stores from all three tools and turns the conversations you pick into fully resumable Cline sessions. Sessions are grouped per tool with select-all and a search across title, folder, and first prompt; already-imported ones are shown as such so re-opening the dialog is safe. Imported sessions resume on your configured provider and model, not the source tool's. If you have history from any of these tools, onboarding now offers the import as a step
- Runs of a schedule now fold into a single collapsible sidebar row named after the schedule, with its run count, instead of one row per run all carrying the same prompt title. Expanding lists them newest-first as "Run N" with the usual status dot, time, hover card, context menu, and delete; the group holding the active session opens on its own
- Voice input now works on macOS. The app shipped without a microphone usage description or entitlement, so dictation failed silently
- Web search is now on by default
- The marketplace detail panel now opens on click rather than hover, with left-aligned content, a single "Learn more" link, and the selected entry staying open while you filter the list
- When the Hub is older than the app, you are now offered a choice — replace it, with a count of the sessions that would be interrupted, or keep it running — instead of the app quietly working against stale code. Replacing drains the Hub first so in-flight turns finish
- Editing and resending a message now works on sessions with no checkpoint history, such as imported ones, instead of failing with "No checkpoint found at or before run N"
- Fixed tool calling being silently disabled for Dify, SAP AI Core, opencode, and Codex CLI models. Their catalog entries declare no capabilities, and the empty list was read as an authoritative denial that stripped every tool from the request
- Fixed images being dropped from file reads on models whose capability list is empty
- The message the model receives when you reject a tool call now names the tool and reads as your decision rather than an error
- Refreshed the model catalog. Adds eight providers (Bothub, OpenReason, SenseNova (China), TokenRouter, Vancine, Volcengine Ark Coding Plan, above.dev, and klokintegration.se) and changes the resolved default model for 36 providers — most consequentially Anthropic, which now resolves to Claude Fable 5.1 instead of Claude Opus 5, with Amazon Bedrock, Vertex, OpenRouter, Kilo Gateway, DevPass, DigitalOcean, CrossModel, and Eden AI following. If you use a provider without pinning a model, expect a different default
## 0.0.21
- Marketplace is now a two-pane explorer: a browsable list on the left and full catalog metadata for the selected item on the right, with category tag filters that collapse behind a "more" toggle
@@ -13,6 +60,7 @@
## 0.0.20
- Customize now separates Cline Plugins from Agent Plugins discovered by the Hub. Agent Plugin switches use Hub-managed enablement, contributed skills appear in the Skills inventory, and connected desktop views refresh when Hub settings change
- Cline Desktop now ships on Windows: releases include a code-signed x64 installer, and installed apps auto-update on the same feed macOS does
- Windows shell fixes: background processes (the sidecar, git) no longer pop visible console windows; updates now download in the background and install when you restart the app; the MCP settings path falls back to `USERPROFILE` when `HOME` is unset
- Tool results that return images — screenshots from browser or MCP tools — now render as inline images you can click to expand, with a carousel for stepping through multiple images, instead of raw base64 text
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/code",
"version": "0.0.21",
"version": "0.0.24",
"private": true,
"scripts": {
"build:ui": "bun -F @cline/ui build",
@@ -16,6 +16,7 @@ sidecar/
├── index.ts # Entry point: starts HTTP+WS server
├── server.ts # Bun HTTP server + WebSocket handlers
├── context.ts # SidecarContext type and factory
├── client-context.ts # Desktop client/account identity for shared telemetry
├── commands.ts # Command router
├── chat-session.ts # Shared-Hub chat session adapter
├── session-data/ # Shared discovery, messages, artifacts, search helpers
@@ -81,6 +82,15 @@ The compiled sidecar also recognizes Core's Hub-daemon launch mode. This lets
the desktop start the same detached Hub when no CLI process has started it yet.
Startup discovery and locking ensure concurrent clients converge on one Hub.
Every create, restart, fork, and restore also attaches the serializable Desktop
`ExtensionContext.client` and current `ExtensionContext.user`. Core forwards
that context across the Hub transport and scopes the daemon-owned telemetry
service to the originating surface. This keeps lifecycle events centralized in
Core while reporting Desktop dimensions (`cline_type: "desktop"`, `platform:
"Cline Desktop"`, and the Desktop app version) and the current account and
organization. The shared Hub telemetry singleton is never mutated per session,
so concurrent CLI and Desktop tasks retain their own attribution.
### 2. Tool Approval — Client-Owned Promise Resolution
The shared Hub routes approval requests back to the client that created the
@@ -13,6 +13,8 @@ import { materializeUserFiles } from "./attachments";
import {
buildSessionConnectionUpdate,
consumeWorkspaceMetadata,
createDesktopMistakeLimitPrompt,
createDesktopMistakeRecovery,
handleChatSessionCommand,
hasProviderChanged,
mergeSessionConfig,
@@ -22,7 +24,11 @@ import {
shouldUpdateSessionConnection,
WORKSPACE_METADATA_PREWARM_TTL_MS,
} from "./chat-session";
import { handleCoreSessionEvent } from "./context";
import {
handleCoreSessionEvent,
requestSidecarAskQuestion,
resolveSidecarAskQuestion,
} from "./context";
import type { SidecarContext } from "./types";
describe("resolveDesktopSessionMode", () => {
@@ -195,25 +201,49 @@ describe("hasProviderChanged", () => {
describe("pathless session starts", () => {
it("omits workspace paths and returns the SDK-resolved chat workspace", async () => {
const start = vi.fn(async (input: { config: Record<string, unknown> }) => {
expect(input.config).not.toHaveProperty("cwd");
expect(input.config).not.toHaveProperty("workspaceRoot");
expect(input.config).not.toHaveProperty("enableSpawnAgent");
expect(input.config).not.toHaveProperty("enableAgentTeams");
return {
sessionId: "session-pathless",
manifest: {
cwd: "/home/host/.cline/data/workspaces/chat",
workspace_root: "/home/host/.cline/data/workspaces/chat",
},
manifestPath: "/tmp/session-pathless.json",
messagesPath: "/tmp/session-pathless.messages.json",
};
});
const start = vi.fn(
async (input: {
config: Record<string, unknown>;
localRuntime?: {
extensionContext?: {
client?: Record<string, unknown>;
user?: Record<string, unknown>;
};
};
}) => {
expect(input.config).not.toHaveProperty("cwd");
expect(input.config).not.toHaveProperty("workspaceRoot");
expect(input.config).not.toHaveProperty("enableSpawnAgent");
expect(input.config).not.toHaveProperty("enableAgentTeams");
expect(input.localRuntime?.extensionContext?.client).toMatchObject({
name: "cline-desktop",
platform: "Cline Desktop",
});
expect(input.localRuntime?.extensionContext?.user).toEqual({
distinctId: "account-1",
accountId: "account-1",
organizationId: "org-1",
});
return {
sessionId: "session-pathless",
manifest: {
cwd: "/home/host/.cline/data/workspaces/chat",
workspace_root: "/home/host/.cline/data/workspaces/chat",
},
manifestPath: "/tmp/session-pathless.json",
messagesPath: "/tmp/session-pathless.messages.json",
};
},
);
const ctx = {
liveSessions: new Map(),
restoringWorkspacePaths: new Set(),
sessionManager: { start },
telemetryUser: {
distinctId: "account-1",
accountId: "account-1",
organizationId: "org-1",
},
} as unknown as SidecarContext;
const result = (await handleChatSessionCommand(ctx, {
@@ -545,7 +575,7 @@ describe("session forks", () => {
expect(ctx.restoringWorkspacePaths.size).toBe(0);
});
it("keeps a full-history fork on the current workspace without restoring", async () => {
it("keeps a full-history fork on the current workspace and cancels source questions", async () => {
const sourceSessionId = `source-full-fork-${Date.now()}`;
const sourceMessages = [
{ role: "user" as const, content: "first prompt" },
@@ -588,8 +618,19 @@ describe("session forks", () => {
},
streamIndices: new Map(),
wsClients: new Set(),
pendingQuestions: new Map(),
} as unknown as SidecarContext;
const pendingDecision = createDesktopMistakeLimitPrompt(
ctx,
() => sourceSessionId,
)({
iteration: 5,
consecutiveMistakes: 6,
maxConsecutiveMistakes: 6,
reason: "tool_execution_failed",
});
expect(ctx.pendingQuestions.size).toBe(1);
await handleChatSessionCommand(ctx, {
action: "fork",
sessionId: sourceSessionId,
@@ -599,6 +640,8 @@ describe("session forks", () => {
},
});
await expect(pendingDecision).resolves.toMatchObject({ action: "stop" });
expect(ctx.pendingQuestions.size).toBe(0);
expect(restore).not.toHaveBeenCalled();
expect(start).toHaveBeenCalledWith(
expect.objectContaining({ initialMessages: sourceMessages }),
@@ -1753,3 +1796,487 @@ Follow the desktop send workflow instructions.`,
);
});
});
describe("mistake-limit prompt", () => {
function createPromptContext() {
const send = vi.fn();
const steer = vi.fn(async () => undefined);
const ctx = {
wsClients: new Set([{ send }]),
streamIndices: new Map(),
pendingQuestions: new Map(),
liveSessions: new Map(),
sessionManager: {
send: steer,
stop: vi.fn(async () => {}),
abort: vi.fn(async () => {}),
},
} as unknown as SidecarContext;
const readQuestionRequest = () => {
const raw = send.mock.calls
.map(
([encoded]) =>
JSON.parse(String(encoded)) as {
event: { name: string; payload: Record<string, unknown> };
},
)
.find((message) => message.event.name === "ask_question_requested");
return raw?.event.payload as
| {
requestId: string;
sessionId: string;
question: string;
options: string[];
}
| undefined;
};
return { ctx, steer, readQuestionRequest };
}
const limitContext = {
iteration: 15,
consecutiveMistakes: 6,
maxConsecutiveMistakes: 6,
reason: "tool_execution_failed" as const,
details:
"Detected 5 consecutive identical calls to `editor`; stopping to avoid a loop.",
};
it("holds tool and model hooks until Continue has queued recovery guidance", async () => {
const { ctx, steer, readQuestionRequest } = createPromptContext();
let finishSteering!: () => void;
steer.mockImplementationOnce(
() =>
new Promise<undefined>((resolve) => {
finishSteering = () => resolve(undefined);
}),
);
const recovery = createDesktopMistakeRecovery(ctx, () => "session-1");
const decision = recovery.onConsecutiveMistakeLimitReached(limitContext);
expect(recovery.onConsecutiveMistakeLimitReached(limitContext)).toBe(
decision,
);
let released = false;
const waiting = Promise.all([
recovery.hooks.beforeModel(),
recovery.hooks.beforeTool(),
recovery.hooks.afterTool(),
]).then((results) => {
released = true;
return results;
});
await Promise.resolve();
expect(released).toBe(false);
expect(ctx.pendingQuestions.size).toBe(1);
resolveSidecarAskQuestion(
ctx,
readQuestionRequest()?.requestId ?? "",
"Try a different approach",
);
await Promise.resolve();
expect(steer).toHaveBeenCalledTimes(1);
expect(released).toBe(false);
finishSteering();
await expect(decision).resolves.toMatchObject({ action: "continue" });
await expect(waiting).resolves.toEqual([undefined, undefined, undefined]);
expect(released).toBe(true);
await expect(recovery.hooks.beforeModel()).resolves.toBeUndefined();
});
it("leaves ordinary questions alone when cancelling a mistake prompt", async () => {
const { ctx, readQuestionRequest } = createPromptContext();
const decision = createDesktopMistakeLimitPrompt(
ctx,
() => "session-1",
)(limitContext);
const mistakeRequestId = readQuestionRequest()?.requestId;
const normalQuestion = requestSidecarAskQuestion(
ctx,
"Which file?",
["a", "b"],
{ sessionId: "session-1", agentId: "desktop", iteration: 1 },
);
await handleChatSessionCommand(ctx, {
action: "abort",
sessionId: "session-1",
});
await expect(decision).resolves.toMatchObject({ action: "stop" });
expect(ctx.pendingQuestions.size).toBe(1);
const remaining = [...ctx.pendingQuestions.values()][0];
expect(remaining.item.requestId).not.toBe(mistakeRequestId);
resolveSidecarAskQuestion(ctx, remaining.item.requestId, "a");
await expect(normalQuestion).resolves.toBe("a");
});
it.each([
"answer",
"abort",
] as const)("releases waiting hooks with Stop on %s", async (action) => {
const { ctx, steer, readQuestionRequest } = createPromptContext();
const recovery = createDesktopMistakeRecovery(ctx, () => "session-1");
const decision = recovery.onConsecutiveMistakeLimitReached(limitContext);
const waiting = Promise.all([
recovery.hooks.beforeModel(),
recovery.hooks.beforeTool(),
recovery.hooks.afterTool(),
]);
if (action === "answer") {
resolveSidecarAskQuestion(
ctx,
readQuestionRequest()?.requestId ?? "",
"Stop this run",
);
} else {
await handleChatSessionCommand(ctx, {
action: "abort",
sessionId: "session-1",
});
}
await expect(decision).resolves.toMatchObject({ action: "stop" });
for (const control of await waiting)
expect(control).toMatchObject({ stop: true });
expect(ctx.pendingQuestions.size).toBe(0);
expect(steer).not.toHaveBeenCalled();
});
it("asks the active session's user instead of stopping silently", async () => {
const { ctx, readQuestionRequest } = createPromptContext();
// Session ids are only known after start() resolves; the prompt must
// read the id at prompt time, not at construction time.
let sessionId = "";
const decide = createDesktopMistakeLimitPrompt(ctx, () => sessionId);
sessionId = "session-late";
const decision = decide(limitContext);
const request = readQuestionRequest();
expect(request).toMatchObject({
sessionId: "session-late",
options: ["Try a different approach", "Stop this run"],
});
expect(request?.question).toContain("repeated mistakes or tool calls");
expect(request?.question).toContain("identical calls to `editor`");
expect(
resolveSidecarAskQuestion(ctx, request?.requestId ?? "", "Stop this run"),
).toBe(true);
await expect(decision).resolves.toEqual({
action: "stop",
reason: "stopped after mistake_limit_reached prompt",
});
});
it("delivers recovery guidance only through steering", async () => {
const { ctx, steer, readQuestionRequest } = createPromptContext();
const decide = createDesktopMistakeLimitPrompt(ctx, () => "session-1");
const decision = decide(limitContext);
const request = readQuestionRequest();
resolveSidecarAskQuestion(
ctx,
request?.requestId ?? "",
"Try a different approach",
);
const result = await decision;
expect(result).toEqual({ action: "continue" });
expect(steer).toHaveBeenCalledExactlyOnceWith({
sessionId: "session-1",
prompt: expect.stringContaining("Do not repeat the same call"),
delivery: "steer",
});
expect(steer).toHaveBeenCalledWith(
expect.objectContaining({
prompt: expect.stringContaining("identical calls to `editor`"),
}),
);
});
it.each([
"stop",
" STOP THIS RUN ",
"2",
"no",
])("treats the free-text answer %s as Stop, like the CLI", async (answer) => {
const { ctx, steer, readQuestionRequest } = createPromptContext();
const decision = createDesktopMistakeLimitPrompt(
ctx,
() => "session-1",
)(limitContext);
resolveSidecarAskQuestion(
ctx,
readQuestionRequest()?.requestId ?? "",
answer,
);
await expect(decision).resolves.toMatchObject({ action: "stop" });
expect(steer).not.toHaveBeenCalled();
});
it.each([
"rejected",
"unavailable",
])("stops waiting hooks when steering is %s", async (failure) => {
const { ctx, steer, readQuestionRequest } = createPromptContext();
if (failure === "rejected")
steer.mockRejectedValueOnce(new Error("Disconnected"));
else ctx.sessionManager = null;
const recovery = createDesktopMistakeRecovery(ctx, () => "session-1");
const decision = recovery.onConsecutiveMistakeLimitReached(limitContext);
const waiting = Promise.all([
recovery.hooks.beforeModel(),
recovery.hooks.beforeTool(),
recovery.hooks.afterTool(),
]);
resolveSidecarAskQuestion(
ctx,
readQuestionRequest()?.requestId ?? "",
"Try a different approach",
);
await expect(decision).resolves.toMatchObject({
action: "stop",
reason: expect.stringContaining("Could not send recovery guidance"),
});
for (const result of await waiting)
expect(result).toMatchObject({ stop: true });
expect(ctx.pendingQuestions.size).toBe(0);
});
it("passes free-text answers through as user guidance", async () => {
const { ctx, steer, readQuestionRequest } = createPromptContext();
const decide = createDesktopMistakeLimitPrompt(ctx, () => "session-1");
const decision = decide(limitContext);
resolveSidecarAskQuestion(
ctx,
readQuestionRequest()?.requestId ?? "",
"read the file first, then edit",
);
await expect(decision).resolves.toEqual({ action: "continue" });
expect(steer).toHaveBeenCalledExactlyOnceWith({
sessionId: "session-1",
prompt: expect.stringContaining(
"User guidance: read the file first, then edit",
),
delivery: "steer",
});
});
it("reuses Continue for already-started iterations and asks again for new mistakes", async () => {
const { ctx, steer } = createPromptContext();
ctx.liveSessions.set("session-1", {
config: {},
messages: [],
promptsInQueue: [],
busy: true,
startedAt: 0,
status: "running",
});
const startIteration = (iteration: number) =>
handleCoreSessionEvent(ctx, {
type: "agent_event",
payload: {
sessionId: "session-1",
event: { type: "iteration_start", iteration },
},
});
const answer = (value: string) => {
const pending = [...ctx.pendingQuestions.values()][0];
expect(pending).toBeDefined();
resolveSidecarAskQuestion(ctx, pending.item.requestId, value);
};
const decide = createDesktopMistakeLimitPrompt(ctx, () => "session-1");
startIteration(15);
const first = decide(limitContext);
// The model can advance while the client decision is pending.
startIteration(20);
// Do not extend the covered iterations while waiting for the hub's
// steering acknowledgement: a newer step may already have the guidance.
steer.mockImplementationOnce(async () => {
startIteration(21);
return undefined;
});
answer("Try a different approach");
await expect(first).resolves.toMatchObject({ action: "continue" });
// A batch can have many failures in one iteration, followed by more
// failures queued before the user answered. None needs another prompt.
for (const iteration of [
...Array<number>(20).fill(15),
16,
17,
18,
19,
20,
]) {
await expect(decide({ ...limitContext, iteration })).resolves.toEqual({
action: "continue",
});
}
expect(ctx.pendingQuestions.size).toBe(0);
expect(steer).toHaveBeenCalledTimes(1);
startIteration(21);
const next = decide({ ...limitContext, iteration: 21 });
answer("Try a different approach");
await expect(next).resolves.toMatchObject({ action: "continue" });
expect(steer).toHaveBeenCalledTimes(2);
// A new user run must not inherit the previous run's decision, even
// though its iteration numbers start over.
startIteration(1);
startIteration(5);
const newRun = decide({ ...limitContext, iteration: 5 });
answer("Stop this run");
await expect(newRun).resolves.toMatchObject({ action: "stop" });
expect(ctx.pendingQuestions.size).toBe(0);
expect(steer).toHaveBeenCalledTimes(2);
});
it("falls back to stopping when no session owns the question", async () => {
const { ctx, steer } = createPromptContext();
const decide = createDesktopMistakeLimitPrompt(ctx, () => "");
await expect(decide(limitContext)).resolves.toEqual({
action: "stop",
reason: `mistake_limit_reached: ${limitContext.details}`,
});
expect(steer).not.toHaveBeenCalled();
});
it("removes an aborted run's question and rejects late answers", async () => {
const { ctx, steer, readQuestionRequest } = createPromptContext();
const decide = createDesktopMistakeLimitPrompt(ctx, () => "session-1");
const decision = decide(limitContext);
const request = readQuestionRequest();
expect(ctx.pendingQuestions.size).toBe(1);
await handleChatSessionCommand(ctx, {
action: "abort",
sessionId: "session-1",
});
await expect(decision).resolves.toMatchObject({ action: "stop" });
expect(ctx.pendingQuestions.size).toBe(0);
expect(
resolveSidecarAskQuestion(
ctx,
request?.requestId ?? "",
"Try a different approach",
),
).toBe(false);
expect(steer).not.toHaveBeenCalled();
});
it.each([
"stop",
"abort",
"reset",
] as const)("cancels only the owning session's questions on %s", async (action) => {
const { ctx } = createPromptContext();
const decide = createDesktopMistakeLimitPrompt(ctx, () => "session-1");
const other = createDesktopMistakeLimitPrompt(
ctx,
() => "session-2",
)(limitContext);
const decision = decide(limitContext);
await handleChatSessionCommand(ctx, { action, sessionId: "session-1" });
await expect(decision).resolves.toMatchObject({ action: "stop" });
expect(
[...ctx.pendingQuestions.values()].map((p) => p.item.sessionId),
).toEqual(["session-2"]);
await handleChatSessionCommand(ctx, {
action: "abort",
sessionId: "session-2",
});
await other;
expect(ctx.pendingQuestions.size).toBe(0);
});
it("times out an unanswered question and removes it from polling", async () => {
vi.useFakeTimers();
try {
const { ctx, steer } = createPromptContext();
const decide = createDesktopMistakeLimitPrompt(ctx, () => "session-1");
const decision = decide(limitContext);
await vi.advanceTimersByTimeAsync(5 * 60_000);
await expect(decision).resolves.toMatchObject({ action: "stop" });
expect(ctx.pendingQuestions.size).toBe(0);
expect(steer).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it.each([
"run",
"session",
])("cancels the question when the %s ends externally", async (kind) => {
const { ctx, steer, readQuestionRequest } = createPromptContext();
const decision = createDesktopMistakeLimitPrompt(
ctx,
() => "session-1",
)(limitContext);
const requestId = readQuestionRequest()?.requestId ?? "";
if (kind === "session")
handleCoreSessionEvent(ctx, {
type: "ended",
payload: { sessionId: "session-1", reason: "stopped", ts: Date.now() },
});
else
handleCoreSessionEvent(ctx, {
type: "agent_event",
payload: {
sessionId: "session-1",
event: {
type: "done",
reason: "aborted",
text: "",
iterations: 5,
usage: { inputTokens: 0, outputTokens: 0 },
},
},
});
await expect(decision).resolves.toMatchObject({ action: "stop" });
expect(ctx.pendingQuestions.size).toBe(0);
expect(
resolveSidecarAskQuestion(ctx, requestId, "Try a different approach"),
).toBe(false);
expect(steer).not.toHaveBeenCalled();
});
it("is wired into freshly started sessions as a local runtime option", async () => {
const start = vi.fn(
async (input: {
config: Record<string, unknown>;
localRuntime?: Record<string, unknown>;
}) => {
expect(input.config).not.toHaveProperty(
"onConsecutiveMistakeLimitReached",
);
expect(
typeof input.localRuntime?.onConsecutiveMistakeLimitReached,
).toBe("function");
expect(input.config).not.toHaveProperty("hooks");
expect(input.localRuntime?.hooks).toMatchObject({
beforeModel: expect.any(Function),
beforeTool: expect.any(Function),
afterTool: expect.any(Function),
});
return {
sessionId: "session-limit",
manifest: { cwd: "/tmp/ws", workspace_root: "/tmp/ws" },
manifestPath: "/tmp/session-limit.json",
messagesPath: "/tmp/session-limit.messages.json",
};
},
);
const ctx = {
liveSessions: new Map(),
restoringWorkspacePaths: new Set(),
sessionManager: { start },
} as unknown as SidecarContext;
await handleChatSessionCommand(ctx, {
action: "start",
config: {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/tmp/ws",
},
});
expect(start).toHaveBeenCalledTimes(1);
});
});
+245 -21
View File
@@ -22,14 +22,26 @@ import {
trimMessagesBeforeUserRun,
} from "@cline/core";
import type { MessageWithMetadata } from "@cline/llms";
import { buildClineSystemPrompt, formatUserCommandBlock } from "@cline/shared";
import {
buildClineSystemPrompt,
type ConsecutiveMistakeLimitContext,
type ConsecutiveMistakeLimitDecision,
formatUserCommandBlock,
} from "@cline/shared";
import {
deleteMaterializedAttachments,
discardAllTrackedAttachments,
materializeUserFiles,
trackQueuedAttachments,
} from "./attachments";
import { emitChunk, nowMs, sendEvent } from "./context";
import { createDesktopExtensionContext } from "./client-context";
import {
cancelSidecarMistakeQuestions,
emitChunk,
nowMs,
requestSidecarAskQuestion,
sendEvent,
} from "./context";
import { readSessionManifest, sharedSessionDataDir } from "./paths";
import { persistSessionMessages } from "./session-data/messages";
import type {
@@ -401,7 +413,177 @@ function readPositiveInteger(value: unknown): number | undefined {
return undefined;
}
function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
type MistakeLimitDecider = (
context: ConsecutiveMistakeLimitContext,
) => Promise<ConsecutiveMistakeLimitDecision>;
const MISTAKE_LIMIT_CONTINUE_OPTION = "Try a different approach";
const MISTAKE_LIMIT_STOP_OPTION = "Stop this run";
const MISTAKE_LIMIT_DETAIL_MAX_CHARS = 600;
/**
* Desktop counterpart of the CLI's mistake-limit prompt
* (apps/cli/src/runtime/interactive/mistakes.ts).
*
* When the core's loop detector or mistake tracker trips, it asks the client
* how to proceed. Without a decision callback the default is "stop", which
* reaches the webview as a plain aborted turn: indistinguishable from the
* user pressing Stop, with no explanation. A model stuck re-issuing the same
* failing tool call therefore looked like Cline randomly gave up mid-task.
* Route the decision through the existing ask-question channel instead so
* the user sees what went wrong and can choose.
*
* `getSessionId` is read at prompt time: for fresh starts the session id is
* only known after `manager.start()` resolves, and the webview matches the
* prompt to its active session by id.
*/
export function createDesktopMistakeLimitPrompt(
ctx: SidecarContext,
getSessionId: () => string,
): MistakeLimitDecider {
return async (context) => {
const sessionId = getSessionId().trim();
const recovery = ctx.liveSessions.get(sessionId)?.mistakeRecovery;
if (
recovery?.continuedThroughIteration !== undefined &&
context.iteration <= recovery.continuedThroughIteration
) {
// The tracker serializes decisions, so old failures can arrive after
// Continue. The user has already answered for these in-flight steps.
return { action: "continue" };
}
const detail = context.details?.trim() ?? "";
const truncatedDetail =
detail.length > MISTAKE_LIMIT_DETAIL_MAX_CHARS
? `${detail.slice(0, MISTAKE_LIMIT_DETAIL_MAX_CHARS)}`
: detail;
const question = [
"Cline detected repeated mistakes or tool calls and needs your guidance.",
truncatedDetail ? `Latest: ${truncatedDetail}` : "",
"How should Cline continue?",
]
.filter((line) => line.length > 0)
.join("\n");
let answer: string;
try {
answer = await requestSidecarAskQuestion(
ctx,
question,
[MISTAKE_LIMIT_CONTINUE_OPTION, MISTAKE_LIMIT_STOP_OPTION],
{
sessionId,
agentId: "desktop-mistake-limit",
iteration: context.iteration,
},
);
} catch (error) {
// Prompt timed out or the session was torn down: fall back to the
// core's default decision, but keep the reason so the stop is
// attributable.
ctx.logger?.log("Mistake-limit prompt unanswered; stopping run", {
sessionId,
error: error instanceof Error ? error.message : String(error),
});
return {
action: "stop",
reason: `mistake_limit_reached: ${detail || context.reason}`,
};
}
const normalized = answer.trim().toLowerCase();
if (["2", "stop this run", "stop", "n", "no"].includes(normalized)) {
return {
action: "stop",
reason: "stopped after mistake_limit_reached prompt",
};
}
const customGuidance =
normalized.length > 0 &&
normalized !== "1" &&
normalized !== MISTAKE_LIMIT_CONTINUE_OPTION.toLowerCase()
? answer.trim()
: "";
const guidance = [
"The run reached the limit for repeated mistakes or tool calls.",
truncatedDetail ? `Latest: ${truncatedDetail}` : "",
"Do not repeat the same call. Re-check the tool's parameter requirements, fix the call, and try a different approach.",
customGuidance ? `User guidance: ${customGuidance}` : "",
]
.filter((line) => line.length > 0)
.join(" ");
// Use the existing steering queue so the running model receives the
// guidance, including any instructions entered in the desktop prompt.
const manager = ctx.sessionManager;
try {
if (!manager) throw new Error("Desktop session manager is unavailable");
const continuedThroughIteration = Math.max(
context.iteration,
recovery?.latestIteration ?? context.iteration,
);
await manager.send({ sessionId, prompt: guidance, delivery: "steer" });
if (recovery) {
recovery.continuedThroughIteration = continuedThroughIteration;
}
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
ctx.logger?.log("Failed to steer mistake-limit guidance", {
sessionId,
error: detail,
});
// Releasing the hooks without the guidance would resume the same
// failing loop. Only Continue after the steering request succeeds.
return {
action: "stop",
reason: `Could not send recovery guidance: ${detail}`,
};
}
// Steering already delivers the guidance; do not also append it via
// the mistake tracker's recovery-notice path.
return { action: "continue" };
};
}
export function createDesktopMistakeRecovery(
ctx: SidecarContext,
getSessionId: () => string,
) {
const prompt = createDesktopMistakeLimitPrompt(ctx, getSessionId);
let pendingDecision: Promise<ConsecutiveMistakeLimitDecision> | undefined;
const waitForDecision = async () => {
const decision = await pendingDecision;
return decision?.action === "stop"
? { stop: true, reason: decision.reason }
: undefined;
};
return {
onConsecutiveMistakeLimitReached: (
context: ConsecutiveMistakeLimitContext,
) => {
if (!pendingDecision) {
pendingDecision = prompt(context).finally(() => {
pendingDecision = undefined;
});
}
return pendingDecision;
},
hooks: {
// The decision callback alone does not pause the SDK. These existing
// awaited hooks hold desktop runs at tool/model boundaries until the
// user answers. afterTool holds before the next iteration consumes
// the recovery guidance queued by the prompt's Continue action.
beforeModel: waitForDecision,
beforeTool: waitForDecision,
afterTool: waitForDecision,
},
};
}
function buildCoreSessionConfig(
config: JsonRecord,
telemetryUser?: SidecarContext["telemetryUser"],
mistakeRecovery?: ReturnType<typeof createDesktopMistakeRecovery>,
): JsonRecord {
const rawWorkspaceRoot = config.workspaceRoot ?? config.workspace_root;
const workspaceRoot =
typeof rawWorkspaceRoot === "string" ? rawWorkspaceRoot.trim() : "";
@@ -444,6 +626,8 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
checkpoint: { enabled: true },
sessions: config.sessions,
initialMessages: config.initialMessages,
extensionContext: createDesktopExtensionContext(telemetryUser),
...mistakeRecovery,
};
}
@@ -674,8 +858,14 @@ async function handleStart(
: requestedSessionId
? (readPersistedChatMessages(requestedSessionId) ?? undefined)
: undefined;
// Resolved once start() returns; the mistake-limit prompt reads it lazily.
let startedSessionId = requestedSessionId;
const coreConfig: JsonRecord = {
...buildCoreSessionConfig(request.config),
...buildCoreSessionConfig(
request.config,
ctx.telemetryUser,
createDesktopMistakeRecovery(ctx, () => startedSessionId),
),
systemPrompt,
...(initialMessages ? { initialMessages } : {}),
};
@@ -697,6 +887,7 @@ async function handleStart(
toolPolicies: resolveToolPolicies(request.config),
});
const sessionId = startResult.sessionId;
startedSessionId = sessionId;
const workspaceRoot = startResult.manifest.workspace_root;
const cwd = startResult.manifest.cwd;
ctx.logger?.log("Desktop chat session started", { sessionId });
@@ -794,6 +985,7 @@ async function handleAttach(
async function startRebuiltSession(
manager: ClineCore,
ctx: SidecarContext,
sessionId: string,
config: JsonRecord,
systemPrompt: string,
@@ -805,11 +997,15 @@ async function startRebuiltSession(
: undefined;
const restarted = await manager.start({
...splitCoreSessionConfig(
buildCoreSessionConfig({
...config,
sessionId,
systemPrompt,
}) as unknown as ClineCoreStartConfig,
buildCoreSessionConfig(
{
...config,
sessionId,
systemPrompt,
},
ctx.telemetryUser,
createDesktopMistakeRecovery(ctx, () => sessionId),
) as unknown as ClineCoreStartConfig,
),
source: SessionSource.DESKTOP,
interactive: true,
@@ -854,11 +1050,13 @@ async function rebuildSessionForProviderChange(
resolveSystemPrompt(nextConfig),
]);
cancelSidecarMistakeQuestions(ctx, sessionId, "Session provider changed");
await manager.stop(sessionId);
let replacementStarted = false;
try {
await startRebuiltSession(
manager,
ctx,
sessionId,
nextConfig,
nextSystemPrompt,
@@ -880,6 +1078,7 @@ async function rebuildSessionForProviderChange(
}
await startRebuiltSession(
manager,
ctx,
sessionId,
previousConfig,
previousSystemPrompt,
@@ -996,9 +1195,9 @@ async function handleSend(
request.attachments?.userFiles,
);
if (session?.attachedViaHub) {
// Once ClineCore sends a turn, its HubRuntimeHost owns the session
// subscription. Stop projecting the observer stream as well or every
// assistant/tool update (including command chunks) is emitted twice.
// Once ClineCore sends a turn it owns the session: the attach-time
// connection refresh above has happened and the observer projection is
// muted by its subscription, so the session is no longer attach-only.
session.attachedViaHub = false;
}
if (delivery === "queue") {
@@ -1133,6 +1332,7 @@ async function handleStop(
): Promise<unknown> {
const sessionId = request.sessionId?.trim();
if (!sessionId) throw new Error("sessionId is required");
cancelSidecarMistakeQuestions(ctx, sessionId, "Session stopped");
await getSessionManager(ctx).stop(sessionId);
const session = ctx.liveSessions.get(sessionId);
if (session) {
@@ -1148,6 +1348,7 @@ async function handleAbort(
): Promise<unknown> {
const sessionId = request.sessionId?.trim();
if (!sessionId) throw new Error("sessionId is required");
cancelSidecarMistakeQuestions(ctx, sessionId, "Run aborted");
await getSessionManager(ctx).abort(sessionId, "user_abort");
const session = ctx.liveSessions.get(sessionId);
if (session) {
@@ -1289,12 +1490,18 @@ async function handleForkUnlocked(
},
};
const systemPrompt = await resolveSystemPrompt(forkConfig);
// Assigned below once the forked session exists; read lazily by the prompt.
let newSessionId = "";
const startInput = {
...splitCoreSessionConfig(
buildCoreSessionConfig({
...forkConfig,
systemPrompt,
}) as unknown as ClineCoreStartConfig,
buildCoreSessionConfig(
{
...forkConfig,
systemPrompt,
},
ctx.telemetryUser,
createDesktopMistakeRecovery(ctx, () => newSessionId),
) as unknown as ClineCoreStartConfig,
),
source: SessionSource.DESKTOP,
interactive: true,
@@ -1311,7 +1518,6 @@ async function handleForkUnlocked(
readSessionCheckpointHistory({ metadata: sourceMetadata }),
forkBeforeRunCount,
) !== undefined;
let newSessionId: string;
if (forkBeforeRunCount !== undefined && canRestoreWorkspace) {
const cwd =
restoreWorkspacePath ||
@@ -1354,6 +1560,11 @@ async function handleForkUnlocked(
sourceSessionId,
ctx.liveSessions.get(sourceSessionId),
);
cancelSidecarMistakeQuestions(
ctx,
sourceSessionId,
"Session replaced by fork",
);
ctx.liveSessions.delete(sourceSessionId);
ctx.liveSessions.set(
newSessionId,
@@ -1378,6 +1589,7 @@ async function handleReset(
): Promise<unknown> {
const sessionId = request.sessionId?.trim();
if (sessionId) {
cancelSidecarMistakeQuestions(ctx, sessionId, "Session reset");
const session = ctx.liveSessions.get(sessionId);
if (
session?.busy ||
@@ -1416,6 +1628,8 @@ async function handleRestoreCheckpoint(
if (!cwd) throw new Error("config.cwd or config.workspaceRoot is required");
const manager = getSessionManager(ctx);
return withWorkspaceRestoreLock(ctx, cwd, async () => {
// Updated once restore() returns; read lazily by the mistake-limit prompt.
let restoredSessionId = sourceSessionId;
const restored = await manager.restore({
sessionId: sourceSessionId,
checkpointRunCount: runCount,
@@ -1423,10 +1637,14 @@ async function handleRestoreCheckpoint(
restore: { messages: true, workspace: true },
start: {
...splitCoreSessionConfig(
buildCoreSessionConfig({
...config,
systemPrompt: await resolveSystemPrompt(config),
}) as unknown as ClineCoreStartConfig,
buildCoreSessionConfig(
{
...config,
systemPrompt: await resolveSystemPrompt(config),
},
ctx.telemetryUser,
createDesktopMistakeRecovery(ctx, () => restoredSessionId),
) as unknown as ClineCoreStartConfig,
),
source: SessionSource.DESKTOP,
interactive: true,
@@ -1438,10 +1656,16 @@ async function handleRestoreCheckpoint(
if (!sessionId || !restoredMessages) {
throw new Error("Checkpoint restore did not return a new session");
}
restoredSessionId = sessionId;
discardAllTrackedAttachments(
sourceSessionId,
ctx.liveSessions.get(sourceSessionId),
);
cancelSidecarMistakeQuestions(
ctx,
sourceSessionId,
"Session checkpoint restored",
);
ctx.liveSessions.delete(sourceSessionId);
ctx.liveSessions.set(
sessionId,
@@ -0,0 +1,56 @@
import * as os from "node:os";
import { resolveCoreDistinctId } from "@cline/core";
import type {
ClientContext,
ExtensionContext,
TelemetryMetadata,
UserContext,
} from "@cline/shared";
import { version } from "../package.json";
/** Shared identity for request headers, Hub attribution, and telemetry. */
export const DESKTOP_CLIENT_CONTEXT = {
name: "cline-desktop",
version,
platform: "Cline Desktop",
platformVersion: version,
isMultiRoot: false,
} as const satisfies ClientContext;
export const DESKTOP_TELEMETRY_METADATA = {
extension_version: version,
cline_type: "desktop",
platform: DESKTOP_CLIENT_CONTEXT.platform,
platform_version: DESKTOP_CLIENT_CONTEXT.platformVersion,
os_type: os.platform(),
os_version: os.version(),
} satisfies TelemetryMetadata;
export function resolveDesktopTelemetryUser(input?: {
accountId?: string;
email?: string;
organizationId?: string;
}): UserContext {
const accountId = input?.accountId?.trim();
return accountId
? {
distinctId: accountId,
accountId,
email: input?.email,
organizationId: input?.organizationId,
}
: {
distinctId: resolveCoreDistinctId(),
accountId: null,
};
}
/** Serializable context attached to every Desktop session sent to the Hub. */
export function createDesktopExtensionContext(
user?: UserContext,
): ExtensionContext {
return {
client: DESKTOP_CLIENT_CONTEXT,
...(user ? { user: { ...user } } : {}),
};
}
@@ -6,6 +6,7 @@ const clineAccountServiceCtorMock = vi.hoisted(() => vi.fn());
const executeClineAccountActionMock = vi.hoisted(() => vi.fn());
const getProviderSettingsMock = vi.hoisted(() => vi.fn());
const saveProviderSettingsMock = vi.hoisted(() => vi.fn());
const persistProviderSettingsMock = vi.hoisted(() => vi.fn());
const resolveProviderApiKeyMock = vi.hoisted(() => vi.fn());
vi.mock("@cline/core", async () => {
@@ -21,6 +22,7 @@ vi.mock("@cline/core", async () => {
executeClineAccountAction: executeClineAccountActionMock,
ProviderSettingsManager: class {
getProviderSettings = getProviderSettingsMock;
saveProviderSettings = persistProviderSettingsMock;
},
saveLocalProviderSettings: saveProviderSettingsMock,
RuntimeOAuthTokenManager: class {
@@ -31,11 +33,13 @@ vi.mock("@cline/core", async () => {
function createContext() {
const capture = vi.fn();
const setDistinctId = vi.fn();
const updateCommonProperties = vi.fn();
const ctx = {
telemetry: { capture },
telemetry: { capture, setDistinctId, updateCommonProperties },
logger: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
} as unknown as SidecarContext;
return { ctx, capture };
return { ctx, capture, setDistinctId, updateCommonProperties };
}
const FETCH_ME_ARGS = {
@@ -53,12 +57,14 @@ beforeEach(() => {
executeClineAccountActionMock.mockReset();
getProviderSettingsMock.mockReset();
saveProviderSettingsMock.mockReset();
persistProviderSettingsMock.mockReset();
resolveProviderApiKeyMock.mockReset();
});
describe("cline_account command auth states", () => {
it("returns a typed not-authenticated result when signed out, without telemetry or a thrown error", async () => {
const { ctx, capture } = createContext();
it("returns a typed not-authenticated result and restores anonymous telemetry when signed out", async () => {
const { ctx, capture, setDistinctId, updateCommonProperties } =
createContext();
resolveProviderApiKeyMock.mockResolvedValue(null);
getProviderSettingsMock.mockReturnValue(undefined);
@@ -72,6 +78,14 @@ describe("cline_account command auth states", () => {
expect(executeClineAccountActionMock).not.toHaveBeenCalled();
expect(clineAccountServiceCtorMock).not.toHaveBeenCalled();
expect(capture).not.toHaveBeenCalled();
expect(setDistinctId).toHaveBeenCalledWith(expect.any(String));
expect(updateCommonProperties).toHaveBeenCalledWith(
expect.objectContaining({
user_id: undefined,
account_id: undefined,
organization_id: undefined,
}),
);
});
it("runs the account action unchanged when a fresh token resolves", async () => {
@@ -171,7 +185,7 @@ describe("cline_account keeps feature-flag identity in sync", () => {
});
it("adopts the account identity on login", async () => {
const { ctx } = createContext();
const { ctx, setDistinctId, updateCommonProperties } = createContext();
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
getProviderSettingsMock.mockReturnValue({});
executeClineAccountActionMock.mockResolvedValue({
@@ -182,6 +196,62 @@ describe("cline_account keeps feature-flag identity in sync", () => {
await runOperation(ctx, "fetchMe");
expect(await currentFlagsUserId()).toBe("acct-1");
expect(setDistinctId).toHaveBeenCalledWith("acct-1");
expect(updateCommonProperties).toHaveBeenCalledWith(
expect.objectContaining({ user_id: "acct-1", account_id: "acct-1" }),
);
expect(ctx.telemetryUser).toEqual({
distinctId: "acct-1",
accountId: "acct-1",
email: "dev@example.com",
organizationId: undefined,
});
});
it("applies and persists the active organization for task telemetry", async () => {
const { ctx, updateCommonProperties } = createContext();
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
getProviderSettingsMock.mockReturnValue({
provider: "cline",
auth: { accountId: "acct-1", accessToken: "token" },
});
executeClineAccountActionMock.mockResolvedValue({
id: "acct-1",
email: "dev@example.com",
organizations: [
{
active: true,
memberId: "member-1",
name: "Acme",
organizationId: "org-1",
roles: ["member"],
},
],
});
await runOperation(ctx, "fetchMe");
expect(updateCommonProperties).toHaveBeenCalledWith(
expect.objectContaining({
user_id: "acct-1",
organization_id: "org-1",
}),
);
expect(persistProviderSettingsMock).toHaveBeenCalledWith(
expect.objectContaining({
auth: expect.objectContaining({
organizationId: "org-1",
memberId: "member-1",
}),
}),
{ setLastUsed: false },
);
expect(ctx.telemetryUser).toEqual({
distinctId: "acct-1",
accountId: "acct-1",
email: "dev@example.com",
organizationId: "org-1",
});
});
it("leaves the signed-in identity intact across an organization switch", async () => {
@@ -219,7 +289,7 @@ describe("cline_account keeps feature-flag identity in sync", () => {
});
it("clears the account identity on logout", async () => {
const { ctx } = createContext();
const { ctx, setDistinctId, updateCommonProperties } = createContext();
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
getProviderSettingsMock.mockReturnValue({});
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
@@ -233,10 +303,28 @@ describe("cline_account keeps feature-flag identity in sync", () => {
await runOperation(ctx, "fetchMe");
expect(await currentFlagsUserId()).toBeUndefined();
expect(ctx.telemetryUser).toEqual(
expect.objectContaining({
accountId: null,
distinctId: expect.any(String),
}),
);
expect(setDistinctId).toHaveBeenLastCalledWith(
ctx.telemetryUser?.distinctId,
);
expect(ctx.telemetryUser?.distinctId).not.toBe("acct-1");
expect(updateCommonProperties).toHaveBeenLastCalledWith(
expect.objectContaining({
user_id: undefined,
account_id: undefined,
account_email: undefined,
organization_id: undefined,
}),
);
});
it("clears the identity when sign-out blanks the cline auth settings", async () => {
const { ctx } = createContext();
const { ctx, setDistinctId, updateCommonProperties } = createContext();
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
getProviderSettingsMock.mockReturnValue({});
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
@@ -259,6 +347,22 @@ describe("cline_account keeps feature-flag identity in sync", () => {
});
expect(await currentFlagsUserId()).toBeUndefined();
expect(ctx.telemetryUser).toEqual(
expect.objectContaining({
accountId: null,
distinctId: expect.any(String),
}),
);
expect(setDistinctId).toHaveBeenLastCalledWith(
ctx.telemetryUser?.distinctId,
);
expect(updateCommonProperties).toHaveBeenLastCalledWith(
expect.objectContaining({
user_id: undefined,
account_id: undefined,
organization_id: undefined,
}),
);
});
it("ignores settings writes for other providers", async () => {
+93 -11
View File
@@ -15,7 +15,9 @@ import type {
import {
addLocalProvider,
ClineAccountService,
type ClineAccountUser,
captureAuthRefreshSoftFailure,
clearAccountTelemetryIdentity,
createConfiguredStreamingTranscriptionSession,
createUserInstructionConfigService,
ensureCustomProvidersLoaded,
@@ -23,14 +25,17 @@ import {
fetchClineRecommendedModels,
getCoreBuiltinToolCatalog,
getLocalProviderModels,
identifyAccount,
listHookConfigFiles,
listLocalProviders,
normalizeOAuthProvider,
ProviderSettingsManager,
parseMcpServerRegistration,
persistClineAccountTelemetryIdentity,
probeMcpServerConnection,
RuntimeOAuthTokenManager,
readGlobalSettings,
resolveClineAccountTelemetryIdentity,
resolveLocalClineAuthToken,
resolveMcpServerRegistration,
resolveSessionBackend,
@@ -66,6 +71,7 @@ import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
import packageJson from "../package.json";
import { CLINE_ACCOUNT_NOT_AUTHENTICATED_RESULT } from "../webview/lib/cline-account-state";
import { MAX_RECORDED_AUDIO_BYTES } from "../webview/lib/voice-input-limits";
import { resolveDesktopTelemetryUser } from "./client-context";
import {
listClineGitHubRepositories,
listClineIntegrations,
@@ -310,14 +316,23 @@ function removePathIfExists(
// refreshes would invalidate each other.
let clineOAuthTokenManager: RuntimeOAuthTokenManager | undefined;
function syncFeatureFlagsAccountFromResult(
function syncAccountContextFromResult(
ctx: SidecarContext,
manager: ProviderSettingsManager,
operation: string,
result: unknown,
): void {
if (operation === "fetchMe") {
const user = result as { id?: string; email?: string } | undefined;
const user = result as ClineAccountUser | undefined;
if (user?.id) {
const identity = resolveClineAccountTelemetryIdentity(user);
ctx.telemetryUser = resolveDesktopTelemetryUser({
accountId: identity.id,
email: identity.email,
organizationId: identity.organizationId,
});
identifyAccount(ctx.telemetry, identity);
persistClineAccountTelemetryIdentity(manager, identity);
void identifyDesktopFeatureFlagsAccount(
{ id: user.id, email: user.email },
{ logger: ctx.logger, telemetry: ctx.telemetry },
@@ -327,12 +342,39 @@ function syncFeatureFlagsAccountFromResult(
}
}
function syncFeatureFlagsAccountFromSettings(
function syncAccountContextFromSettings(
ctx: SidecarContext,
manager: ProviderSettingsManager,
): void {
const auth = manager.getProviderSettings("cline")?.auth;
const accountId = auth?.accountId?.trim();
if (!auth || !accountId) {
syncSignedOutAccountContext(ctx);
return;
}
ctx.telemetryUser = resolveDesktopTelemetryUser({
accountId,
organizationId: auth.organizationId,
});
identifyAccount(ctx.telemetry, {
id: accountId,
provider: "cline",
organizationId: auth.organizationId,
organizationName: auth.organizationName,
memberId: auth.memberId,
});
void identifyDesktopFeatureFlagsAccount(
{ id: manager.getProviderSettings("cline")?.auth?.accountId },
{ id: accountId },
{ logger: ctx.logger, telemetry: ctx.telemetry },
);
}
function syncSignedOutAccountContext(ctx: SidecarContext): void {
const telemetryUser = resolveDesktopTelemetryUser();
ctx.telemetryUser = telemetryUser;
clearAccountTelemetryIdentity(ctx.telemetry, telemetryUser.distinctId);
void identifyDesktopFeatureFlagsAccount(
{},
{ logger: ctx.logger, telemetry: ctx.telemetry },
);
}
@@ -1036,6 +1078,34 @@ async function listUserInstructionConfigs(
} finally {
userInstructionService.stop();
}
const knownSkillPaths = new Set(
skills.flatMap((skill) => {
if (!skill || typeof skill !== "object") return [];
const path = (skill as JsonRecord).path;
return typeof path === "string" ? [path] : [];
}),
);
for (const skill of hubSettings.skills) {
if (
skill.agentPlugin !== true ||
skill.enabled === false ||
knownSkillPaths.has(skill.path)
) {
continue;
}
skills.push({
id: skill.id,
name: skill.name,
description: skill.description,
instructions: "",
path: skill.path,
enabled: true,
source: skill.source,
agentPlugin: true,
pluginName: skill.pluginName,
});
knownSkillPaths.add(skill.path);
}
const disabledTools = new Set(readGlobalSettings().disabledTools ?? []);
// Pin spawn/teams availability so this listing matches the hub's
@@ -1055,9 +1125,15 @@ async function listUserInstructionConfigs(
runtimeCommands,
agents: loadAgents(),
plugins: hubSettings.plugins.map((plugin) => ({
id: plugin.id,
name: plugin.name,
path: plugin.path,
enabled: plugin.enabled !== false,
source: plugin.source,
toggleable: plugin.toggleable === true,
agentPlugin: plugin.agentPlugin === true,
description: plugin.description,
loadError: plugin.loadError,
contributions: plugin.contributions,
})),
tools: [
@@ -1797,10 +1873,7 @@ export async function handleCommand(
// an expired or server-revoked token. Explicit sign-out is handled
// at its source in `save_provider_settings`; this catches the rest
// so a stale account never keeps serving its rollout cohort.
void identifyDesktopFeatureFlagsAccount(
{},
{ logger: ctx.logger, telemetry: ctx.telemetry },
);
syncSignedOutAccountContext(ctx);
return CLINE_ACCOUNT_NOT_AUTHENTICATED_RESULT;
}
const settings = manager.getProviderSettings("cline");
@@ -1813,7 +1886,7 @@ export async function handleCommand(
args as ClineAccountActionRequest,
accountService,
);
syncFeatureFlagsAccountFromResult(ctx, operation, result);
syncAccountContextFromResult(ctx, manager, operation, result);
return result;
}
@@ -2016,7 +2089,7 @@ export async function handleCommand(
// authoritative signal — it fires the moment credentials are cleared
// rather than waiting for the next account fetch.
if (saved.providerId === "cline" || saved.providerId === "cline-pass") {
syncFeatureFlagsAccountFromSettings(ctx, manager);
syncAccountContextFromSettings(ctx, manager);
}
return saved;
}
@@ -2084,7 +2157,16 @@ export async function handleCommand(
);
});
},
{ owner: options?.connection },
{
owner: options?.connection,
// Push the device sign-in confirmation code so the webview can
// show it while the user confirms it in the browser.
onUserCode: (userCode) =>
broadcastEvent(ctx, "provider_oauth_user_code", {
provider: providerId,
userCode,
}),
},
);
}
if (command === "cancel_provider_oauth_login") {
@@ -1193,6 +1193,33 @@ describe("Code sidecar runtime capabilities", () => {
},
]);
});
it("forwards Hub settings changes so open desktop views can refresh", async () => {
const { createSidecarContext, handleHubLiveEvent } = await import(
"./context"
);
const ctx = createSidecarContext("/workspace/project");
ctx.wsClients.add({ send: vi.fn() } as never);
handleHubLiveEvent(ctx, {
event: "settings.changed",
payload: {
types: ["plugins", "skills", "mcp"],
},
});
expect(readEvents(ctx)).toEqual([
{
type: "event",
event: {
name: "settings.changed",
payload: {
types: ["plugins", "skills", "mcp"],
},
},
},
]);
});
});
describe("disposeSidecarContext attachment cleanup", () => {
@@ -1244,3 +1271,215 @@ describe("disposeSidecarContext attachment cleanup", () => {
expect(ctx.liveSessions.size).toBe(0);
});
});
describe("Chat chunk pipe selection", () => {
async function createStreamingContext(
sessionId: string,
coreSubscriptions: Set<string> = new Set(),
) {
const { createSidecarContext } = await import("./context");
const ctx = createSidecarContext("/workspace/project");
ctx.wsClients.add({ send: vi.fn() });
ctx.liveSessions.set(sessionId, {
config: {},
messages: [],
promptsInQueue: [],
busy: true,
startedAt: Date.now(),
status: "running",
attachedViaHub: true,
});
ctx.sessionManager = {
hasSessionSubscription: (id: string) => coreSubscriptions.has(id),
} as never;
return ctx;
}
function coreTextEvent(sessionId: string, text: string) {
return {
type: "agent_event",
payload: {
sessionId,
event: { type: "content_start", contentType: "text", text },
},
} as never;
}
function eventsFor(ctx: SidecarContext, name: string) {
return readEvents(ctx)
.filter((message) => message.event.name === name)
.map((message) => message.event.payload);
}
function chunksFor(ctx: SidecarContext, stream: string): string[] {
return eventsFor(ctx, "chat_event")
.filter((payload) => (payload as { stream?: string }).stream === stream)
.map((payload) => String((payload as { chunk?: string }).chunk));
}
it("emits one copy when both pipes carry the same delta", async () => {
const { handleCoreSessionEvent, handleHubLiveEvent } = await import(
"./context"
);
// Opening a session arms both pipes: ClineCore subscribes to the session
// and `attach` enables the observer projection, so the hub publishes each
// delta to both sockets.
const ctx = await createStreamingContext(
"session-1",
new Set(["session-1"]),
);
handleHubLiveEvent(ctx, {
event: "assistant.delta",
sessionId: "session-1",
payload: { text: "Pack " },
});
handleCoreSessionEvent(ctx, coreTextEvent("session-1", "Pack "));
handleHubLiveEvent(ctx, {
event: "assistant.delta",
sessionId: "session-1",
payload: { text: "my box" },
});
handleCoreSessionEvent(ctx, coreTextEvent("session-1", "my box"));
expect(chunksFor(ctx, "chat_text")).toEqual(["Pack ", "my box"]);
});
it("still streams sessions only the observer delivers", async () => {
const { handleHubLiveEvent } = await import("./context");
const ctx = await createStreamingContext("session-1");
handleHubLiveEvent(ctx, {
event: "assistant.delta",
sessionId: "session-1",
payload: { text: "remote " },
});
handleHubLiveEvent(ctx, {
event: "assistant.delta",
sessionId: "session-1",
payload: { text: "run" },
});
expect(chunksFor(ctx, "chat_text")).toEqual(["remote ", "run"]);
});
it("mutes the whole observer projection, not just text", async () => {
const { handleHubLiveEvent } = await import("./context");
const ctx = await createStreamingContext(
"session-1",
new Set(["session-1"]),
);
handleHubLiveEvent(ctx, {
event: "tool.started",
sessionId: "session-1",
payload: { toolCallId: "call-1", toolName: "run_commands" },
});
handleHubLiveEvent(ctx, {
event: "run.completed",
sessionId: "session-1",
payload: {},
});
expect(chunksFor(ctx, "chat_tool_call_start")).toEqual([]);
expect(eventsFor(ctx, "chat_session_ended")).toEqual([]);
expect(ctx.liveSessions.get("session-1")?.busy).toBe(true);
});
it("follows the subscription as it comes and goes", async () => {
const { handleHubLiveEvent } = await import("./context");
const coreSubscriptions = new Set<string>();
const ctx = await createStreamingContext("session-1", coreSubscriptions);
const delta = (text: string) =>
handleHubLiveEvent(ctx, {
event: "assistant.delta",
sessionId: "session-1",
payload: { text },
});
delta("observer first");
// A send (or pending-prompt list) subscribes ClineCore.
coreSubscriptions.add("session-1");
delta("muted");
// `stop` drops the subscription; a run another client starts on the
// same session is the observer's to render again.
coreSubscriptions.delete("session-1");
delta("observer again");
expect(chunksFor(ctx, "chat_text")).toEqual([
"observer first",
"observer again",
]);
});
it("decides per session", async () => {
const { handleHubLiveEvent } = await import("./context");
const ctx = await createStreamingContext(
"session-1",
new Set(["session-1"]),
);
ctx.liveSessions.set("session-2", {
config: {},
messages: [],
promptsInQueue: [],
busy: true,
startedAt: Date.now(),
status: "running",
attachedViaHub: true,
});
handleHubLiveEvent(ctx, {
event: "assistant.delta",
sessionId: "session-1",
payload: { text: "one" },
});
handleHubLiveEvent(ctx, {
event: "assistant.delta",
sessionId: "session-2",
payload: { text: "two" },
});
expect(chunksFor(ctx, "chat_text")).toEqual(["two"]);
});
it("never drops chunks the sidecar produces itself", async () => {
const { broadcastChunk } = await import("./context");
const ctx = await createStreamingContext(
"session-1",
new Set(["session-1"]),
);
broadcastChunk(ctx, "session-1", "chat_queued_prompt_start", "{}");
expect(chunksFor(ctx, "chat_queued_prompt_start")).toEqual(["{}"]);
});
it("stamps chunks with a stable per-process boot id", async () => {
const { createSidecarContext, handleHubLiveEvent } = await import(
"./context"
);
const first = await createStreamingContext("session-1");
handleHubLiveEvent(first, {
event: "assistant.delta",
sessionId: "session-1",
payload: { text: "a" },
});
handleHubLiveEvent(first, {
event: "assistant.delta",
sessionId: "session-1",
payload: { text: "b" },
});
const boots = readEvents(first)
.filter((message) => message.event.name === "chat_event")
.map((message) => (message.event.payload as { boot?: string }).boot);
expect(boots).toHaveLength(2);
expect(boots[0]).toBeTruthy();
expect(boots[1]).toBe(boots[0]);
// A replacement sidecar restarts `index` at 1, so it must be
// distinguishable by boot id.
const second = createSidecarContext("/workspace/project");
expect(second.bootId).not.toBe(first.bootId);
});
});
+55 -1
View File
@@ -187,6 +187,7 @@ function emitChunk(
chunk,
ts,
index: nextIndex,
boot: ctx.bootId,
});
}
@@ -344,6 +345,7 @@ function handleAgentEvent(
message: event.message,
noticeType: event.noticeType,
reason: event.reason,
metadata: event.metadata,
}),
);
break;
@@ -367,6 +369,7 @@ function handleAgentEvent(
break;
}
case "done": {
cancelSidecarMistakeQuestions(ctx, sessionId, "Run ended");
const session = ctx.liveSessions.get(sessionId);
if (session) {
session.busy = false;
@@ -401,7 +404,19 @@ function handleAgentEvent(
);
break;
}
case "iteration_start":
case "iteration_start": {
const session = ctx.liveSessions.get(sessionId);
if (session) {
// Iterations restart at one for each user run. Keep the previous
// answer only within the run in which it was supplied.
if (event.iteration === 1 || !session.mistakeRecovery) {
session.mistakeRecovery = { latestIteration: event.iteration };
} else {
session.mistakeRecovery.latestIteration = event.iteration;
}
}
break;
}
case "iteration_end":
break;
}
@@ -520,6 +535,7 @@ export function handleCoreSessionEvent(
}
case "ended": {
const { sessionId, reason } = event.payload;
cancelSidecarMistakeQuestions(ctx, sessionId, "Session ended");
const session = ctx.liveSessions.get(sessionId);
if (session) {
session.busy = false;
@@ -570,12 +586,14 @@ export function createSidecarContext(
observability: {
logger?: BasicLogger;
telemetry?: ITelemetryService;
telemetryUser?: SidecarContext["telemetryUser"];
} = {},
): SidecarContext {
return {
liveSessions: new Map(),
restoringWorkspacePaths: new Set(),
streamIndices: new Map(),
bootId: randomUUID(),
wsClients: new Set(),
pendingApprovals: new Map(),
pendingQuestions: new Map(),
@@ -584,6 +602,7 @@ export function createSidecarContext(
workspaceRoot,
logger: observability.logger,
telemetry: observability.telemetry,
telemetryUser: observability.telemetryUser,
unsubscribeSessionEvents: null,
hubBuildMismatch: null,
};
@@ -724,6 +743,28 @@ export function resolveSidecarAskQuestion(
return true;
}
/** Remove prompts before their session is stopped or replaced in the UI. */
export function cancelSidecarMistakeQuestions(
ctx: SidecarContext,
sessionId: string,
reason: string,
): void {
for (const pending of ctx.pendingQuestions?.values() ?? []) {
if (
pending.item.sessionId !== sessionId ||
pending.item.context?.agentId !== "desktop-mistake-limit"
)
continue;
ctx.pendingQuestions.delete(pending.item.requestId);
if (pending.timeoutId) clearTimeout(pending.timeoutId);
pending.reject(new Error(reason));
sendEvent(ctx, "ask_question_cancelled", {
requestId: pending.item.requestId,
reason,
});
}
}
export function createSidecarRuntimeCapabilities(
ctx: SidecarContext,
): RuntimeCapabilities {
@@ -811,6 +852,10 @@ export function handleHubLiveEvent(
});
return;
}
if (event.event === "settings.changed") {
sendEvent(ctx, event.event, event.payload ?? {});
return;
}
const sessionId = typeof event.sessionId === "string" ? event.sessionId : "";
if (!sessionId) {
@@ -820,6 +865,15 @@ export function handleHubLiveEvent(
if (!session?.attachedViaHub) {
return;
}
// The observer client and ClineCore's own hub client are separate sockets
// that both receive this session's events. This projection only exists for
// sessions ClineCore is not subscribed to (it subscribes as a side effect
// of start/send/pending_prompts and unsubscribes on stop); once it is,
// `handleCoreSessionEvent` carries everything below and a second copy here
// would double every delta, tool row, and status change.
if (ctx.sessionManager?.hasSessionSubscription(sessionId)) {
return;
}
switch (event.event) {
case "assistant.delta": {
@@ -187,4 +187,19 @@ describe("official plugin install detection", () => {
);
expect(populated.installedKeys).toEqual(["plugin:goal"]);
});
it("does not match portable Agent Plugins to Cline marketplace entries", () => {
const result = listMarketplaceInstalledEntries({ entries: [GOAL_ENTRY] }, {
plugins: [
{
id: "agent-plugin:goal",
name: "goal",
path: "/home/user/.agents/plugins/goal",
agentPlugin: true,
},
],
} as JsonRecord);
expect(result.installedKeys).toEqual([]);
});
});
@@ -714,6 +714,7 @@ function hasMatchingInventoryItem(
return items.some((item) => {
if (!item || typeof item !== "object") return false;
const record = item as JsonRecord;
if (record.agentPlugin === true) return false;
const values = [
typeof record.name === "string" ? record.name : undefined,
typeof record.id === "string" ? record.id : undefined,
@@ -14,6 +14,7 @@ function createContext(workspaceRoot: string): SidecarContext {
liveSessions: new Map(),
restoringWorkspacePaths: new Set(),
streamIndices: new Map(),
bootId: "test-boot",
wsClients: new Set(),
pendingApprovals: new Map(),
pendingQuestions: new Map(),
@@ -1,10 +1,13 @@
import type { ProviderSettingsManager } from "@cline/core";
import {
completeClineDeviceAuth,
getProviderAuthStorageId,
loginLocalProvider,
markLocalProviderEnabled,
saveLocalProviderOAuthCredentials,
startClineDeviceAuth,
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
export class OAuthLoginCancelledError extends Error {
constructor(providerId: string) {
@@ -26,13 +29,41 @@ type PendingOAuthLogin = {
const pendingOAuthLoginsByProvider = new Map<string, PendingOAuthLogin>();
export type OAuthLoginDependencies = {
login: typeof loginLocalProvider;
login: typeof loginProviderForDesktop;
save: typeof saveLocalProviderOAuthCredentials;
markEnabled: typeof markLocalProviderEnabled;
};
/**
* Cline account providers sign in with the WorkOS device-code grant, whose
* browser page asks the user to confirm a short code. `loginLocalProvider`
* runs that flow but discards the code, so use the split helpers instead and
* surface the code through `onUserCode` for the UI to display.
*/
async function loginProviderForDesktop(
providerId: string,
existing: Parameters<typeof loginLocalProvider>[1],
openUrl: (url: string) => void,
onUserCode?: (userCode: string) => void,
): ReturnType<typeof loginLocalProvider> {
if (providerId !== "cline" && providerId !== "cline-pass") {
return loginLocalProvider(providerId, existing, openUrl);
}
const device = await startClineDeviceAuth();
onUserCode?.(device.userCode);
openUrl(device.verificationUriComplete ?? device.verificationUri);
return completeClineDeviceAuth({
deviceCode: device.deviceCode,
expiresInSeconds: device.expiresInSeconds,
pollIntervalSeconds: device.pollIntervalSeconds,
apiBaseUrl:
existing?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
provider: providerId,
});
}
const defaultDependencies: OAuthLoginDependencies = {
login: loginLocalProvider,
login: loginProviderForDesktop,
save: saveLocalProviderOAuthCredentials,
markEnabled: markLocalProviderEnabled,
};
@@ -47,7 +78,11 @@ export async function runCancellableProviderOAuthLogin(
manager: ProviderSettingsManager,
providerId: string,
openUrl: (url: string) => void,
options: { owner?: object } = {},
options: {
owner?: object;
/** Receives the device sign-in confirmation code, when the flow has one. */
onUserCode?: (userCode: string) => void;
} = {},
dependencies: OAuthLoginDependencies = defaultDependencies,
): Promise<{ provider: string; accessToken: string }> {
const storageProviderId = getProviderAuthStorageId(providerId) ?? providerId;
@@ -74,7 +109,7 @@ export async function runCancellableProviderOAuthLogin(
// after cancellation is observed and cannot become an unhandled
// rejection that kills the sidecar.
const credentials = await Promise.race([
dependencies.login(providerId, existing, openUrl),
dependencies.login(providerId, existing, openUrl, options.onUserCode),
cancellation,
]);
if (entry.cancelled) {
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { version } from "../package.json";
const mocks = vi.hoisted(() => ({
captureExtensionActivated: vi.fn(),
@@ -28,7 +29,14 @@ vi.mock("@cline/core", async () => {
identifyAccount: mocks.identifyAccount,
ProviderSettingsManager: class {
getProviderSettings() {
return { auth: { accountId: "account-1" } };
return {
auth: {
accountId: "account-1",
organizationId: "org-1",
organizationName: "Acme",
memberId: "member-1",
},
};
}
},
setSdkLogger: mocks.setSdkLogger,
@@ -57,8 +65,10 @@ describe("desktop observability", () => {
expect(mocks.createClineTelemetryServiceConfig).toHaveBeenCalledWith({
metadata: expect.objectContaining({
extension_version: version,
cline_type: "desktop",
platform: "Cline",
platform: "Cline Desktop",
platform_version: version,
}),
});
expect(mocks.createConfiguredTelemetryHandle).toHaveBeenCalledWith(
@@ -67,9 +77,18 @@ describe("desktop observability", () => {
expect(mocks.identifyAccount).toHaveBeenCalledWith(telemetry, {
id: "account-1",
provider: "cline",
organizationId: "org-1",
organizationName: "Acme",
memberId: "member-1",
});
expect(mocks.captureExtensionActivated).toHaveBeenCalledWith(telemetry);
expect(mocks.setSdkLogger).toHaveBeenCalledWith(logger);
expect(observability.telemetryUser).toEqual({
distinctId: "account-1",
accountId: "account-1",
email: undefined,
organizationId: "org-1",
});
await observability.dispose();
await observability.dispose();
@@ -1,4 +1,3 @@
import * as os from "node:os";
import {
captureExtensionActivated,
createClineTelemetryServiceConfig,
@@ -8,7 +7,11 @@ import {
ProviderSettingsManager,
setSdkLogger,
} from "@cline/core";
import { version } from "../package.json";
import type { UserContext } from "@cline/shared";
import {
DESKTOP_TELEMETRY_METADATA,
resolveDesktopTelemetryUser,
} from "./client-context";
import { setDesktopFeatureFlagsAccountContext } from "./feature-flags";
import {
createDesktopLoggerAdapter,
@@ -18,6 +21,7 @@ import {
export interface DesktopObservability {
readonly logger: DesktopLoggerAdapter["core"];
readonly telemetry: ITelemetryService;
readonly telemetryUser?: UserContext;
dispose(): Promise<void>;
}
@@ -28,23 +32,23 @@ export function createDesktopObservability(): DesktopObservability {
const telemetryHandle = createConfiguredTelemetryHandle({
...createClineTelemetryServiceConfig({
metadata: {
extension_version: version,
cline_type: "desktop",
platform: "Cline",
platform_version: process.version,
os_type: os.platform(),
os_version: os.version(),
},
metadata: DESKTOP_TELEMETRY_METADATA,
}),
logger,
});
const telemetry = telemetryHandle.telemetry;
const auth = new ProviderSettingsManager().getProviderSettings("cline")?.auth;
const telemetryUser = resolveDesktopTelemetryUser({
accountId: auth?.accountId,
organizationId: auth?.organizationId,
});
if (auth?.accountId) {
identifyAccount(telemetry, {
id: auth.accountId,
provider: "cline",
organizationId: auth.organizationId,
organizationName: auth.organizationName,
memberId: auth.memberId,
});
setDesktopFeatureFlagsAccountContext({ id: auth.accountId });
}
@@ -54,6 +58,7 @@ export function createDesktopObservability(): DesktopObservability {
return {
logger,
telemetry,
telemetryUser,
async dispose() {
if (disposed) return;
disposed = true;
@@ -8,6 +8,7 @@ import type {
ToolApprovalResult,
} from "@cline/core";
import type { MessageWithMetadata } from "@cline/llms";
import type { UserContext } from "@cline/shared";
export type JsonRecord = Record<string, unknown>;
@@ -60,6 +61,11 @@ export type LiveSession = {
prompt?: string;
title?: string;
attachedViaHub?: boolean;
/** Iterations already in flight when the user supplied recovery guidance. */
mistakeRecovery?: {
latestIteration: number;
continuedThroughIteration?: number;
};
/** Materialized attachment files for prompts still waiting in the queue. */
queuedAttachmentFiles?: Map<string, string[]>;
/** Last prompt id announced via chat_queued_prompt_start, to dedupe emits. */
@@ -115,6 +121,12 @@ export type SidecarContext = {
liveSessions: Map<string, LiveSession>;
restoringWorkspacePaths: Set<string>;
streamIndices: Map<string, number>;
/**
* Identifies this sidecar process. `streamIndices` restarts whenever the
* sidecar does, so the webview needs to tell "index 1 of a new process"
* apart from a replay of the run it already rendered.
*/
bootId: string;
wsClients: Set<SidecarWebSocketClient>;
pendingApprovals: Map<string, PendingToolApproval>;
pendingQuestions: Map<string, PendingAskQuestion>;
@@ -123,6 +135,8 @@ export type SidecarContext = {
workspaceRoot: string;
logger?: BasicLogger;
telemetry?: ITelemetryService;
/** Analytics identity and explicit account state forwarded with each session. */
telemetryUser?: UserContext;
unsubscribeSessionEvents: (() => void) | null;
/**
* Latest managed Hub build mismatch, broadcast as `hub_build_mismatch` and
@@ -5,6 +5,10 @@
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-close",
"core:window:allow-is-maximized",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-set-title",
"core:window:allow-start-dragging",
"notification:default"

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

+130 -52
View File
@@ -9,7 +9,8 @@ use std::fs;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
use std::sync::{Arc, Mutex, MutexGuard};
use std::thread;
use std::time::Duration;
#[cfg(target_os = "macos")]
@@ -283,21 +284,16 @@ async fn run_update_loop(app: tauri::AppHandle, state: Arc<UpdateState>) {
struct DesktopBackendState {
ws_endpoint: Mutex<Option<String>>,
process: Mutex<Option<Child>>,
shutting_down: Mutex<bool>,
shutting_down: AtomicBool,
}
impl DesktopBackendState {
fn is_shutting_down(&self) -> bool {
self.shutting_down
.lock()
.map(|guard| *guard)
.unwrap_or(true)
self.shutting_down.load(AtomicOrdering::Acquire)
}
fn stop(&self) {
if let Ok(mut guard) = self.shutting_down.lock() {
*guard = true;
}
self.shutting_down.store(true, AtomicOrdering::Release);
if let Ok(mut process_guard) = self.process.lock() {
if let Some(child) = process_guard.as_mut() {
@@ -511,10 +507,26 @@ fn ensure_desktop_backend_started_with(
// callers (setup, the health-check loop, endpoint fetches from the
// webview) serialize: the second caller blocks here, then sees the live
// child and returns instead of spawning a duplicate.
let mut process_guard = state
let process_guard = state
.process
.lock()
.map_err(|_| "failed to lock desktop backend process state")?;
ensure_desktop_backend_started_locked(state, process_guard, spawn_backend)
}
/// The check-and-spawn that runs under the process lock. Split from the lock
/// acquisition so a test can establish "shutdown began after the unlocked
/// check but before the lock was taken" deterministically.
fn ensure_desktop_backend_started_locked(
state: &Arc<DesktopBackendState>,
mut process_guard: MutexGuard<'_, Option<Child>>,
spawn_backend: impl FnOnce() -> Result<Child, String>,
) -> Result<(), String> {
// stop() marks shutdown before taking this same process lock. Recheck
// under the lock so a queued startup cannot spawn after shutdown.
if state.is_shutting_down() {
return Ok(());
}
if let Some(existing) = process_guard.as_mut() {
match existing.try_wait() {
// A live child owns startup even while its endpoint is still
@@ -667,17 +679,29 @@ fn open_path_with_default_app(path: &Path) -> Result<(), String> {
}
#[tauri::command]
fn get_desktop_backend_endpoint(
async fn get_desktop_backend_endpoint(
backend_state: State<'_, Arc<DesktopBackendState>>,
context: State<'_, AppContext>,
) -> Result<String, String> {
ensure_desktop_backend_started(backend_state.inner(), context.inner())?;
let backend_state = backend_state.inner().clone();
let context = context.inner().clone();
let state_for_start = backend_state.clone();
tauri::async_runtime::spawn_blocking(move || {
ensure_desktop_backend_started(&state_for_start, &context)
})
.await
.map_err(|error| format!("desktop backend startup task failed: {error}"))??;
// Sidecar startup includes login-shell PATH resolution (bounded at 3s,
// see sidecar/shell-path.ts) plus session-manager init, whose duration
// varies by machine. Poll well past that combined worst case; the loop
// returns as soon as the ready line arrives, so only failure waits long.
// While pending this only waits — respawning is ensure's job, and it
// refuses to start a second sidecar while the first one is still alive.
// A child that dies mid-poll makes this return an error rather than
// respawn: the next ensure call — the health-check loop within 5 seconds,
// or this command when the webview reconnects — replaces the dead child.
// Async sleeps keep Tauri's window event loop responsive while pending.
for _ in 0..150 {
if let Some(endpoint) = backend_state
.ws_endpoint
@@ -700,7 +724,7 @@ fn get_desktop_backend_endpoint(
if child_exited {
return Err("desktop backend exited before publishing its endpoint".to_string());
}
thread::sleep(Duration::from_millis(100));
tokio::time::sleep(Duration::from_millis(100)).await;
}
Err("desktop backend endpoint not ready".to_string())
}
@@ -789,56 +813,82 @@ async fn check_for_update_now(
}
/// Icon ids accepted by `set_app_icon`; kept in sync with APP_ICONS in
/// webview/lib/app-icon.ts. Every non-default id has a matching bundled
/// resource at icons/dock/<id>.png.
const APP_DOCK_ICONS: [&str; 4] = ["classic", "midnight", "hologram", "chip"];
/// webview/lib/app-icon.ts. Every id has a matching bundled resource at
/// icons/app/<id>.png.
const APP_ICONS: [&str; 4] = ["classic", "midnight", "hologram", "chip"];
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn resolve_app_icon(app: &tauri::AppHandle, icon: &str) -> Result<PathBuf, String> {
let icon_path = app
.path()
.resolve(
format!("icons/app/{icon}.png"),
tauri::path::BaseDirectory::Resource,
)
.map_err(|e| format!("failed resolving app icon resource: {e}"))?;
if !icon_path.exists() {
return Err(format!(
"app icon resource missing: {}",
icon_path.display()
));
}
Ok(icon_path)
}
#[tauri::command]
fn set_app_icon(app: tauri::AppHandle, icon: String) -> Result<bool, String> {
if !APP_DOCK_ICONS.contains(&icon.as_str()) {
async fn set_app_icon(app: tauri::AppHandle, icon: String) -> Result<bool, String> {
if !APP_ICONS.contains(&icon.as_str()) {
return Err(format!("unknown app icon: {icon}"));
}
#[cfg(target_os = "macos")]
{
// "classic" also ships as a dock resource, so every choice loads the
// same way; setApplicationIconImage's binding warns that passing nil
// to restore the bundled icon may not be allowed.
let icon_path = app
.path()
.resolve(
format!("icons/dock/{icon}.png"),
tauri::path::BaseDirectory::Resource,
)
.map_err(|e| format!("failed resolving dock icon resource: {e}"))?;
if !icon_path.exists() {
return Err(format!(
"dock icon resource missing: {}",
icon_path.display()
));
}
// Every choice uses a resource because AppKit does not support restoring
// the bundled icon by passing a nil application icon.
let icon_path = resolve_app_icon(&app, &icon)?;
let (result_tx, result_rx) = tokio::sync::oneshot::channel();
app.run_on_main_thread(move || {
use objc2::{AllocAnyThread, MainThreadMarker};
use objc2_app_kit::{NSApplication, NSImage};
use objc2_foundation::NSString;
let Some(mtm) = MainThreadMarker::new() else {
return;
};
let ns_app = NSApplication::sharedApplication(mtm);
let Some(image) = NSImage::initWithContentsOfFile(
NSImage::alloc(),
&NSString::from_str(&icon_path.to_string_lossy()),
) else {
eprintln!("[dock-icon] failed loading image: {}", icon_path.display());
return;
};
// SAFETY: called on the main thread with a valid, non-nil image.
unsafe { ns_app.setApplicationIconImage(Some(&image)) };
let result: Result<(), String> = (|| {
let mtm = MainThreadMarker::new().ok_or_else(|| {
"app icon update did not run on the main thread".to_string()
})?;
let ns_app = NSApplication::sharedApplication(mtm);
let image = NSImage::initWithContentsOfFile(
NSImage::alloc(),
&NSString::from_str(&icon_path.to_string_lossy()),
)
.ok_or_else(|| {
format!("failed loading app icon image: {}", icon_path.display())
})?;
// SAFETY: called on the main thread with a valid, non-nil image.
unsafe { ns_app.setApplicationIconImage(Some(&image)) };
Ok(())
})();
let _ = result_tx.send(result);
})
.map_err(|e| format!("failed switching dock icon: {e}"))?;
.map_err(|e| format!("failed switching app icon: {e}"))?;
result_rx
.await
.map_err(|_| "app icon update ended before AppKit completed".to_string())??;
Ok(true)
}
#[cfg(not(target_os = "macos"))]
#[cfg(target_os = "windows")]
{
let icon_path = resolve_app_icon(&app, &icon)?;
let image = tauri::image::Image::from_path(&icon_path)
.map_err(|e| format!("failed loading app icon image: {e}"))?;
let window = app
.get_webview_window(MAIN_WINDOW_LABEL)
.ok_or_else(|| "main window is unavailable".to_string())?;
window
.set_icon(image)
.map_err(|e| format!("failed switching taskbar icon: {e}"))?;
Ok(true)
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
let _ = app;
Ok(false)
@@ -1190,9 +1240,15 @@ fn main() {
setup_tray_icon(app)?;
let app_context = app.state::<AppContext>().inner().clone();
let backend_state = app.state::<Arc<DesktopBackendState>>().inner().clone();
if let Err(error) = ensure_desktop_backend_started(&backend_state, &app_context) {
eprintln!("[desktop-backend] startup failed: {error}");
}
let state_for_start = backend_state.clone();
let context_for_start = app_context.clone();
tauri::async_runtime::spawn_blocking(move || {
if let Err(error) =
ensure_desktop_backend_started(&state_for_start, &context_for_start)
{
eprintln!("[desktop-backend] startup failed: {error}");
}
});
// Dev builds are not installed app bundles, so there is nothing the
// updater could meaningfully check or replace.
if !cfg!(debug_assertions) {
@@ -1443,6 +1499,28 @@ mod tests {
state.stop();
}
/// The interleaving where only the recheck under the lock stands between
/// shutdown and a fresh spawn: startup has passed its unlocked shutdown
/// check, stop() marks shutdown while startup is still waiting for the
/// process lock, and then startup acquires the lock. Played out directly
/// on one thread so the ordering is exact rather than scheduled.
#[test]
fn startup_queued_on_process_lock_does_not_spawn_after_shutdown() {
let state = Arc::new(DesktopBackendState::default());
let spawn_count = AtomicUsize::new(0);
assert!(!state.is_shutting_down(), "the unlocked check passes");
state.shutting_down.store(true, AtomicOrdering::Release);
let process_guard = state.process.lock().expect("process lock should succeed");
ensure_desktop_backend_started_locked(&state, process_guard, || {
spawn_count.fetch_add(1, Ordering::SeqCst);
spawn_pending_sidecar()
})
.expect("shutdown should make startup a no-op");
assert_eq!(spawn_count.load(Ordering::SeqCst), 0);
}
#[test]
fn exited_child_is_replaced_on_next_startup_check() {
let state = Arc::new(DesktopBackendState::default());
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cline",
"version": "0.0.21",
"version": "0.0.24",
"identifier": "bot.cline.app",
"build": {
"beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web",
@@ -38,7 +38,7 @@
"active": true,
"targets": "all",
"externalBin": ["bin/code-sidecar"],
"resources": ["icons/dock/*.png"],
"resources": ["icons/app/*.png"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
@@ -0,0 +1,17 @@
{
"$schema": "https://schema.tauri.app/config/2",
"app": {
"windows": [
{
"label": "main",
"title": "Cline",
"width": 1500,
"height": 980,
"resizable": true,
"decorations": false,
"shadow": true,
"dragDropEnabled": false
}
]
}
}
@@ -11,6 +11,16 @@
@source "../../node_modules/streamdown/dist";
:root {
--window-title-bar-height: 3rem;
}
@variant max-md {
:root {
--window-title-bar-height: 1.75rem;
}
}
@layer base {
html,
body {
@@ -53,6 +63,17 @@
-webkit-user-select: text;
user-select: text;
}
/* The Windows caption controls occupy the right edge of the shared title-bar row. */
html[data-windows-custom-titlebar]
[data-slot="window-title-bar-content-host"] {
padding-right: 9rem;
}
}
/* Mobile toasts start below the caption row so both close buttons remain reachable. */
html[data-windows-custom-titlebar] [data-slot="toast-viewport"] {
@apply max-sm:top-(--window-title-bar-height);
}
/* Chat Markdown polish and the streaming-title shimmer live in
@@ -89,6 +89,7 @@ import {
type SessionHistoryItem,
type SessionMetadata,
} from "@/lib/session-history";
import { readImportedFromTool } from "@/lib/session-import";
import { syncHubAccent, syncHubTheme, watchSystemHubTheme } from "@/lib/theme";
import {
filterWorkspacePaths,
@@ -217,8 +218,8 @@ export default function Home() {
}, []);
useEffect(() => {
// The dock reverts to the bundled icon every launch; re-apply the
// user's choice once the shell is up.
// The native app icon reverts to the bundled icon every launch; re-apply
// the user's choice once the shell is up.
void syncAppIcon();
}, []);
@@ -601,6 +602,7 @@ function ChatThreadPane({
chatTransportError,
isHydratingSession,
activeAssistantMessageId,
activityLabel,
config,
messages,
error,
@@ -1373,6 +1375,9 @@ function ChatThreadPane({
: (sessionId ?? visibleHistorySession?.sessionId ?? null);
const displayedMessages = hideDeletedSessionUi ? [] : messages;
const displayedError = hideDeletedSessionUi ? null : error;
const importedFromTool = readImportedFromTool(
visibleHistorySession?.metadata,
);
const displayedStatus = hideDeletedSessionUi ? "idle" : status;
const displayedSessionId = hideDeletedSessionUi ? null : sessionId;
const displayedIsSwitching = hideDeletedSessionUi
@@ -1594,7 +1599,9 @@ function ChatThreadPane({
onApproveToolApproval={handleApproveToolApproval}
onRejectToolApproval={handleRejectToolApproval}
chatTransportState={chatTransportState}
activityLabel={activityLabel}
error={displayedError}
importedFromTool={importedFromTool}
messages={displayedMessages}
onEditMessage={handleEditMessage}
onRestoreCheckpoint={handleRestoreCheckpoint}
@@ -14,11 +14,15 @@ import {
import {
checkForUpdateNow,
restartToApplyUpdate,
useAppUpdateStatus,
} from "@/hooks/use-app-update";
import { desktopClient } from "@/lib/desktop-client";
import {
describeOutdatedHubSessions,
isPersistableHubMismatchKey,
resolveHubUpdateRestartDecision,
retainDismissalForIncomingMismatch,
shouldShowHubMismatchDialog,
} from "./hub-update-required-helpers";
type HubBuildMismatchPayload = {
@@ -38,6 +42,36 @@ type UpdatePhase = "idle" | "updating" | "restarting";
/** Generous deadline: drain wait + graceful retire + fresh daemon startup. */
const HUB_UPGRADE_TIMEOUT_MS = 60_000;
/**
* "Later" must survive webview remounts and reconnects: the sidecar replays
* a pending mismatch on every new webview connection (session switches,
* reloads, relaunches), and in-memory dismissal state resurrected the modal
* each time. Storage keeps one key - a different hub build prompts again.
*/
const DISMISSED_MISMATCH_STORAGE_KEY = "cline.hub-mismatch-dismissed";
function readPersistedDismissedKey(): string | null {
try {
const key = localStorage.getItem(DISMISSED_MISMATCH_STORAGE_KEY);
return isPersistableHubMismatchKey(key) ? key : null;
} catch {
return null;
}
}
function persistDismissedKey(key: string): void {
try {
localStorage.setItem(DISMISSED_MISMATCH_STORAGE_KEY, key);
} catch {
// Best effort: without storage the dismissal lasts this mount only.
}
}
// One updater kick per observed mismatch per page lifetime. Module scope
// survives component remounts (session switches) so the update feed is not
// re-hit every time the dialog mounts.
let updateCheckKickedForKey: string | null = null;
/**
* Blocking prompt shown when the sidecar reports that the shared Cline Hub
* does not match this app's build.
@@ -58,9 +92,12 @@ export function HubUpdateRequiredDialog() {
const [mismatch, setMismatch] = useState<HubBuildMismatchPayload | null>(
null,
);
const [dismissedKey, setDismissedKey] = useState<string | null>(null);
const [dismissedKey, setDismissedKey] = useState<string | null>(
readPersistedDismissedKey,
);
const [phase, setPhase] = useState<UpdatePhase>("idle");
const [updateHint, setUpdateHint] = useState<string | null>(null);
const updateStatus = useAppUpdateStatus();
useEffect(() => {
return desktopClient.subscribe("hub_build_mismatch", (payload) => {
@@ -74,16 +111,43 @@ export function HubUpdateRequiredDialog() {
if (!payload || typeof payload !== "object") {
return;
}
setMismatch(payload as HubBuildMismatchPayload);
const incoming = payload as HubBuildMismatchPayload;
setMismatch(incoming);
// A new mismatch is a fresh prompt: drop any "no update available"
// hint left over from a previous dialog so it reopens in its
// initial state instead of pre-set to "Try again".
setUpdateHint(null);
// Delivery includes replays on in-place transport reconnects, where
// this component never remounts: a non-persistable dismissal
// (unsupported_protocol) must not survive them, or the warning about
// a Hub the app cannot talk to stays silenced indefinitely.
setDismissedKey((previous) =>
retainDismissalForIncomingMismatch(previous, mismatchKeyOf(incoming)),
);
});
}, []);
const mismatchKey = mismatch ? mismatchKeyOf(mismatch) : null;
// When a newer Hub appears, stage the matching app update right away (if
// a release exists) so the prompt can open actionable instead of waiting
// for the next 30s background cycle. Without a staged update the
// build_mismatch modal stays hidden entirely - see
// shouldShowHubMismatchDialog.
useEffect(() => {
if (
!mismatch ||
mismatch.reason !== "build_mismatch" ||
mismatchKey === null ||
mismatchKey === dismissedKey ||
updateCheckKickedForKey === mismatchKey
) {
return;
}
updateCheckKickedForKey = mismatchKey;
void checkForUpdateNow();
}, [mismatch, mismatchKey, dismissedKey]);
const handleUpdateAndRestart = useCallback(async () => {
setPhase("updating");
setUpdateHint(null);
@@ -191,14 +255,23 @@ export function HubUpdateRequiredDialog() {
);
}
const open = mismatchKey !== null && mismatchKey !== dismissedKey;
const open =
mismatchKey !== null &&
mismatchKey !== dismissedKey &&
shouldShowHubMismatchDialog(mismatch?.reason, updateStatus.state);
return (
<AlertDialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen && phase === "idle") {
if (!nextOpen && phase === "idle" && mismatchKey !== null) {
setDismissedKey(mismatchKey);
// unsupported_protocol never persists: hub-backed features
// stay broken against that Hub, so its warning must return
// on the next reconnect or relaunch.
if (isPersistableHubMismatchKey(mismatchKey)) {
persistDismissedKey(mismatchKey);
}
}
}}
>
@@ -1,9 +1,82 @@
import { describe, expect, it } from "vitest";
import {
describeOutdatedHubSessions,
isPersistableHubMismatchKey,
resolveHubUpdateRestartDecision,
retainDismissalForIncomingMismatch,
shouldShowHubMismatchDialog,
} from "./hub-update-required-helpers";
describe("shouldShowHubMismatchDialog", () => {
it("always allows the truly-broken and blocking reasons", () => {
for (const state of [
"idle",
"checking",
"downloading",
"ready",
"error",
undefined,
] as const) {
expect(shouldShowHubMismatchDialog("unsupported_protocol", state)).toBe(
true,
);
expect(shouldShowHubMismatchDialog("outdated_hub", state)).toBe(true);
}
});
it("persists dismissals only for the advisory build_mismatch case", () => {
expect(isPersistableHubMismatchKey("build_mismatch:abc123")).toBe(true);
expect(isPersistableHubMismatchKey("unsupported_protocol:abc123")).toBe(
false,
);
expect(isPersistableHubMismatchKey("outdated_hub:abc123")).toBe(false);
expect(isPersistableHubMismatchKey(null)).toBe(false);
expect(isPersistableHubMismatchKey("")).toBe(false);
});
it("reopens a dismissed protocol warning on redelivery, keeps advisory and unrelated dismissals", () => {
// A replayed unsupported_protocol mismatch clears its own dismissal:
// the app cannot talk to that Hub, so "Later" must not outlive an
// in-place reconnect replay.
expect(
retainDismissalForIncomingMismatch(
"unsupported_protocol:abc",
"unsupported_protocol:abc",
),
).toBeNull();
// The advisory newer-hub dismissal stands across replays.
expect(
retainDismissalForIncomingMismatch(
"build_mismatch:abc",
"build_mismatch:abc",
),
).toBe("build_mismatch:abc");
// A dismissal for a different mismatch is untouched.
expect(
retainDismissalForIncomingMismatch(
"build_mismatch:abc",
"unsupported_protocol:def",
),
).toBe("build_mismatch:abc");
expect(retainDismissalForIncomingMismatch(null, "build_mismatch:abc")).toBe(
null,
);
});
it("allows a newer-hub prompt only once an app update is staged", () => {
expect(shouldShowHubMismatchDialog("build_mismatch", "ready")).toBe(true);
for (const state of [
"idle",
"checking",
"downloading",
"error",
undefined,
] as const) {
expect(shouldShowHubMismatchDialog("build_mismatch", state)).toBe(false);
}
});
});
describe("describeOutdatedHubSessions", () => {
it("quantifies sessions and clients when the hub reported both", () => {
expect(
@@ -4,6 +4,62 @@ export type HubUpdateRestartDecision =
| { action: "restart" }
| { action: "stay"; hint: string };
/**
* Whether a hub build mismatch may interrupt with a modal at all.
*
* - `unsupported_protocol` and `outdated_hub` always may: the first means
* the app cannot talk to the Hub, the second is the blocking
* replace-or-quit decision.
* - `build_mismatch` may only once an app update is actually staged. A
* newer Hub is advisory while the wire protocol still works, and without
* a staged update the modal's only exit is "no update available yet",
* which loops on every launch and webview reconnect until a release
* ships - so it stays silent until it can offer a real action.
*/
export function shouldShowHubMismatchDialog(
reason: string | undefined,
updateState: AppUpdateStatus["state"] | undefined,
): boolean {
if (reason === "unsupported_protocol" || reason === "outdated_hub") {
return true;
}
return updateState === "ready";
}
/**
* Only the advisory `build_mismatch` dismissal may persist across webview
* mounts and app relaunches. An `unsupported_protocol` Hub leaves hub-backed
* features broken, so that warning must return on every reconnect and
* relaunch - its "Later" lasts only for the current mount. Applied on both
* write and read, so a key persisted by any other path is ignored too.
* (Mismatch keys are `${reason}:${hubBuildId}`.)
*/
export function isPersistableHubMismatchKey(key: string | null): key is string {
return typeof key === "string" && key.startsWith("build_mismatch:");
}
/**
* What a dismissal becomes when the sidecar delivers a mismatch again - it
* replays the pending mismatch on every webview (re)connection, including
* in-place transport reconnects where the dialog never remounts. A reason
* whose dismissal may not outlive the moment (`unsupported_protocol`: the
* app cannot talk to the Hub) drops its matching in-memory "Later" so the
* warning reopens on the replay; the advisory `build_mismatch` dismissal
* stands. An unrelated dismissed key is kept either way.
*/
export function retainDismissalForIncomingMismatch(
previousDismissedKey: string | null,
incomingKey: string,
): string | null {
if (
previousDismissedKey === incomingKey &&
!isPersistableHubMismatchKey(incomingKey)
) {
return null;
}
return previousDismissedKey;
}
/**
* Human phrase for the live work an outdated Hub is serving, used by the
* blocking "Hub update required" dialog. Falls back to an unquantified
@@ -15,6 +15,7 @@ const ToastViewport = React.forwardRef<
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
data-slot="toast-viewport"
className={cn(
"fixed top-0 z-100 flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-105",
className,
@@ -4,11 +4,13 @@ import { act, type MouseEvent as ReactMouseEvent } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WorkspaceProvider } from "@/contexts/workspace-context";
import { getInitialChatConfig } from "@/hooks/chat-session/constants";
import type { ChatSessionStatus } from "@/lib/chat-schema";
import {
MODEL_SELECTION_STORAGE_KEY,
parseModelSelectionStorage,
} from "@/lib/model-selection";
import type { ProviderModel } from "@/lib/provider-schema";
import {
buildUserInstructionSlashCommands,
ChatInputBar,
@@ -27,7 +29,11 @@ const {
current: null as MockSpeechInputProps | null,
},
startVercelStreamingTranscriptionMock: vi.fn(),
subscribeToProviderModelsMock: vi.fn(() => vi.fn()),
subscribeToProviderModelsMock: vi.fn<
(
listener: (providerId: string, models: ProviderModel[]) => void,
) => () => void
>(() => vi.fn()),
}));
type MockSpeechInputProps = {
@@ -817,7 +823,7 @@ describe("ChatInputBar", () => {
subscribeToProviderModelsMock.mock.calls[0]?.[0];
await act(async () => {
providerModelsListener?.("cline", [
{ id: "refreshed-model", name: "Refreshed model" },
{ id: "test-model", name: "Refreshed model" },
]);
});
await vi.waitFor(() => {
@@ -1551,6 +1557,185 @@ describe("ChatInputBar", () => {
window.localStorage.removeItem(MODEL_SELECTION_STORAGE_KEY);
});
const kimi: ProviderModel = {
id: "cline-pass/kimi-k3",
name: "Kimi K3",
featured: { tier: "subscribed", rank: 0, tags: [] },
};
const flash: ProviderModel = {
id: "deepseek/deepseek-v4-flash",
name: "DeepSeek V4 Flash",
featured: { tier: "free", rank: 0, tags: [] },
};
function mockBundledCatalog() {
loadProviderModelCatalogMock.mockResolvedValue({
providers: [],
enabledProviderIds: ["cline", "cline-pass"],
providerModels: {
cline: ["test-model"],
"cline-pass": [flash.id],
},
providerModelDetails: { "cline-pass": [flash] },
providerNames: { cline: "Cline", "cline-pass": "ClinePass" },
providerReasoningModels: { cline: [], "cline-pass": [] },
});
}
it.each([
"success",
"failure",
])("preserves the saved model through a delayed live catalog %s", async (outcome) => {
mockBundledCatalog();
const selection = {
lastProvider: "cline-pass",
lastModelByProvider: { "cline-pass": kimi.id },
};
window.localStorage.setItem(
MODEL_SELECTION_STORAGE_KEY,
JSON.stringify(selection),
);
let resolveModels!: (models: ProviderModel[]) => void;
let rejectModels!: (error: Error) => void;
loadProviderModelsMock.mockReturnValue(
new Promise<ProviderModel[]>((resolve, reject) => {
resolveModels = resolve;
rejectModels = reject;
}),
);
const onModelChange = vi.fn();
await renderComposer({
model: kimi.id,
provider: "cline-pass",
onModelChange,
});
expect(loadProviderModelsMock).toHaveBeenCalledWith("cline-pass");
expect(onModelChange).not.toHaveBeenCalled();
expect(
container.querySelector('[aria-label^="Model:"]')?.textContent,
).toContain(kimi.id);
await act(async () => {
if (outcome === "success") resolveModels([flash, kimi]);
else rejectModels(new Error("offline"));
});
expect(onModelChange).not.toHaveBeenCalled();
expect(
container.querySelector('[aria-label^="Model:"]')?.textContent,
).toContain(outcome === "success" ? kimi.name : kimi.id);
expect(
parseModelSelectionStorage(
window.localStorage.getItem(MODEL_SELECTION_STORAGE_KEY),
),
).toEqual(selection);
});
it.each([
"catalog refresh",
"new chat",
])("preserves an explicit pick through a %s with an incomplete catalog", async (transition) => {
mockBundledCatalog();
loadProviderModelsMock.mockResolvedValue([flash, kimi]);
let publishModels!: (providerId: string, models: ProviderModel[]) => void;
subscribeToProviderModelsMock.mockImplementation((listener) => {
publishModels = listener;
return vi.fn();
});
const onModelChange = vi.fn();
await renderComposer({
model: flash.id,
provider: "cline-pass",
onModelChange,
});
await act(async () =>
container
.querySelector<HTMLButtonElement>('[aria-label^="Model:"]')
?.click(),
);
const option = [
...document.querySelectorAll<HTMLButtonElement>('[role="option"]'),
].find((entry) => entry.textContent?.includes(kimi.name));
expect(option).toBeTruthy();
await act(async () => option?.click());
expect(onModelChange).toHaveBeenCalledWith(kimi.id);
await renderComposer({
model: kimi.id,
provider: "cline-pass",
onModelChange,
});
onModelChange.mockClear();
if (transition === "new chat") {
// New chat remounts the pane and seeds its config from storage.
// The app stays open, but this picker has to load live models again.
await act(async () => root.unmount());
root = createRoot(container);
const initial = getInitialChatConfig();
expect(initial).toMatchObject({
provider: "cline-pass",
model: kimi.id,
});
let resolveModels!: (models: ProviderModel[]) => void;
loadProviderModelsMock.mockReturnValue(
new Promise<ProviderModel[]>((resolve) => {
resolveModels = resolve;
}),
);
await renderComposer({
model: initial.model,
provider: initial.provider,
onModelChange,
});
expect(onModelChange).not.toHaveBeenCalled();
expect(
container.querySelector('[aria-label^="Model:"]')?.textContent,
).toContain(kimi.id);
await act(async () => resolveModels([flash, kimi]));
} else {
await act(async () => publishModels("cline-pass", [flash]));
}
expect(onModelChange).not.toHaveBeenCalled();
expect(
container.querySelector('[aria-label^="Model:"]')?.textContent,
).toContain(transition === "new chat" ? kimi.name : kimi.id);
});
it("restores a remembered live model when switching back before live models load", async () => {
mockBundledCatalog();
window.localStorage.setItem(
MODEL_SELECTION_STORAGE_KEY,
JSON.stringify({
lastProvider: "cline",
lastModelByProvider: { cline: "test-model", "cline-pass": kimi.id },
}),
);
const onModelChange = vi.fn();
const onProviderChange = vi.fn();
await renderComposer({
model: "test-model",
provider: "cline",
onModelChange,
onProviderChange,
});
await act(async () =>
container
.querySelector<HTMLButtonElement>('[aria-label^="Provider:"]')
?.click(),
);
const option = [
...document.querySelectorAll<HTMLButtonElement>('[role="option"]'),
].find((entry) => entry.textContent?.includes("ClinePass"));
expect(option).toBeTruthy();
await act(async () => option?.click());
expect(onProviderChange).toHaveBeenCalledWith("cline-pass");
expect(onModelChange).toHaveBeenCalledWith(kimi.id);
expect(onModelChange).not.toHaveBeenCalledWith(flash.id);
expect(
parseModelSelectionStorage(
window.localStorage.getItem(MODEL_SELECTION_STORAGE_KEY),
).lastModelByProvider["cline-pass"],
).toBe(kimi.id);
});
it("does not resurrect a stale remembered model the picker hides", async () => {
window.localStorage.setItem(
MODEL_SELECTION_STORAGE_KEY,
@@ -1582,6 +1767,25 @@ describe("ChatInputBar", () => {
expect(panel?.textContent).not.toContain("Current model");
});
it("does not apply another provider's remembered model to an empty selection", async () => {
mockBundledCatalog();
window.localStorage.setItem(
MODEL_SELECTION_STORAGE_KEY,
JSON.stringify({
lastProvider: "cline",
lastModelByProvider: { cline: "test-model" },
}),
);
const onModelChange = vi.fn();
await renderComposer({
model: "",
provider: "cline-pass",
onModelChange,
});
expect(onModelChange).toHaveBeenCalledWith(flash.id);
expect(onModelChange).not.toHaveBeenCalledWith("test-model");
});
it("keeps an explicitly active out-of-offer model visible and selectable", async () => {
const onModelChange = vi.fn();
await renderComposer({
@@ -348,6 +348,7 @@ function ChatInputBarImpl({
onSteerPromptInQueue,
onEditPromptInQueue,
onRemovePromptInQueue,
onOpenVoiceInputSettings,
summary,
}: ChatInputBarProps) {
const {
@@ -752,24 +753,38 @@ function ChatInputBarImpl({
[transcriptionTarget],
);
const handleSpeechInputError = useCallback((error: unknown) => {
const message =
error instanceof Error
? error.message
: "Check microphone permission and audio provider settings.";
writeDesktopDebugLog({
scope: "voice-input",
level: "error",
message: "Speech input failed in the webview",
timestamp: new Date().toISOString(),
metadata: { failure: message },
});
toast({
variant: "destructive",
title: "Speech input failed",
description: message,
});
}, []);
const handleSpeechInputError = useCallback(
(error: unknown) => {
// Microphone failures surface as DOMExceptions (getUserMedia) or
// capture-layer events; provider failures (credentials, transcription
// setup) as plain Errors, and are fixed in Settings → Voice.
const isMicrophoneError =
error instanceof DOMException || error instanceof Event;
const message =
error instanceof Error
? error.message
: "Check microphone permission and audio provider settings.";
writeDesktopDebugLog({
scope: "voice-input",
level: "error",
message: "Speech input failed in the webview",
timestamp: new Date().toISOString(),
metadata: { failure: message },
});
if (!isMicrophoneError && onOpenVoiceInputSettings) {
onOpenVoiceInputSettings();
return;
}
toast({
variant: "destructive",
title: "Speech input failed",
description: isMicrophoneError
? "Check the microphone permission for Cline and try again."
: message,
});
},
[onOpenVoiceInputSettings],
);
const effortIndex = useMemo(
() => resolveEffortIndex(thinking, reasoningEffort),
@@ -1603,21 +1618,29 @@ const ModelSelector = memo(function ModelSelector({
[modelPicker],
);
const resolvedModel = useMemo(() => {
if (modelsForProvider.length === 0) {
return "";
}
const rememberedModel =
lastSelection.lastModelByProvider[resolvedProvider] ??
lastSelection.lastModelByProvider[rememberedLastProvider];
// An explicitly configured model stays active even when the picker's
// offer hides it (the picker preserves it as a visible option below);
// remembered and default selections are our own bookkeeping, so they
// must resolve to a visible option — otherwise a stale remembered id
// gets silently resurrected into a selection the picker cannot show.
if (model && modelsForProvider.includes(model)) {
(normalizeProviderId(rememberedLastProvider) === resolvedProvider
? lastSelection.lastModelByProvider[rememberedLastProvider]
: undefined);
// Catalogs are discovery data, not validation: the bundled catalog can
// omit live ClinePass models, and refreshes can return partial lists.
// Keep the configured model for the current provider even if absent;
// otherwise loading the catalog silently changes the session's model.
if (
model &&
(normalizedProvider === resolvedProvider ||
modelsForProvider.includes(model))
) {
return model;
}
if (rememberedModel && pickerModelIds.has(rememberedModel)) {
// Missing remembered models may also be live-only. Models present in
// the catalog but deliberately hidden from the offer still fall back.
if (
rememberedModel &&
(pickerModelIds.has(rememberedModel) ||
!modelsForProvider.includes(rememberedModel))
) {
return rememberedModel;
}
return (
@@ -1629,6 +1652,7 @@ const ModelSelector = memo(function ModelSelector({
lastSelection.lastModelByProvider,
model,
modelsForProvider,
normalizedProvider,
pickerModelIds,
rememberedLastProvider,
resolvedProvider,
@@ -1636,8 +1660,8 @@ const ModelSelector = memo(function ModelSelector({
// The picker can intentionally hide catalog models (the ClinePass offer
// is exactly its subscribed/free tiers), but the active model must stay
// visible and selectable — e.g. a hydrated session configured with a
// model outside the current offer. Surface it under its own section
// rather than selecting a value that does not exist in the list.
// model outside the current offer or missing from the catalog. Surface it
// under its own section so the selected value always exists in the list.
const visibleModelPicker = useMemo((): ModelPickerData => {
if (!resolvedModel || pickerModelIds.has(resolvedModel)) {
return modelPicker;
@@ -1865,14 +1889,15 @@ const ModelSelector = memo(function ModelSelector({
onProviderChange(value);
const rememberedModel = lastSelection.lastModelByProvider[value];
const providerModelIds = visibleProviderModels[value] ?? [];
// Validate against the target provider's visible picker options,
// not its full catalog: a remembered model the picker hides (e.g.
// outside the ClinePass offer) must not become the selection.
// Preserve live-only remembered models missing from the bundled
// catalog. Only fall back when a known model is hidden by the offer.
const providerOptionIds = new Set(
pickerDataForProvider(value).options.map((option) => option.value),
);
const nextModel =
rememberedModel && providerOptionIds.has(rememberedModel)
rememberedModel &&
(providerOptionIds.has(rememberedModel) ||
!providerModelIds.includes(rememberedModel))
? rememberedModel
: (providerModelIds.find((id) => providerOptionIds.has(id)) ??
providerModelIds[0]);
@@ -1930,7 +1955,7 @@ const ModelSelector = memo(function ModelSelector({
<SearchCombobox
ariaLabel="Model"
className={triggerClassName}
disabled={isBusy || modelsForProvider.length === 0}
disabled={isBusy || visibleModelPicker.options.length === 0}
emptyText="No models found."
onValueChange={(value) => {
handleModelSelect(value);
@@ -134,7 +134,7 @@ describe("ChatMessages tool disclosures", () => {
}),
createdAt: 1,
};
await renderMessages([pendingTool]);
await renderMessages([pendingTool], { status: "running" });
const pendingTitle = container.querySelector(
".cline-chat-tool-label > span",
@@ -162,6 +162,93 @@ describe("ChatMessages tool disclosures", () => {
).toBe(false);
});
it.each([
"cancelled",
"failed",
"completed",
"idle",
] as const)("stops animating missing tool results when the run is %s, including on reopen", async (status) => {
const tool: ChatMessage = {
id: "unfinished",
sessionId: "session-1",
role: "tool",
createdAt: 1,
content: JSON.stringify({
toolName: "read_files",
input: { paths: ["pending.ts"] },
result: null,
}),
meta: { toolName: "read_files", hookEventName: "tool_call_start" },
};
const snapshot = JSON.stringify(tool);
await renderMessages([tool], { status: "running" });
expect(
container.querySelector(".cline-chat-streaming-title"),
).not.toBeNull();
await renderMessages([tool], { status });
expect(container.querySelector(".cline-chat-streaming-title")).toBeNull();
expect(container.querySelector(".cline-chat-tool-progress")).toBeNull();
expect(JSON.stringify(tool)).toBe(snapshot);
await renderMessages(
[{ ...tool, meta: { ...tool.meta, hookEventName: "history_tool_use" } }],
{ status },
);
expect(container.querySelector(".cline-chat-streaming-title")).toBeNull();
expect(container.querySelector(".cline-chat-tool-progress")).toBeNull();
// A later turn must not reactivate the old unfinished tool.
await renderMessages(
[
tool,
{
id: "next-turn",
sessionId: "session-1",
role: "user",
content: "Continue",
createdAt: 2,
},
],
{ status: "running" },
);
expect(container.querySelector(".cline-chat-tool-progress")).toBeNull();
});
it("keeps late results renderable after an inactive status", async () => {
const tool: ChatMessage = {
id: "late-result",
sessionId: "session-1",
role: "tool",
createdAt: 1,
content: JSON.stringify({
toolName: "custom_tool",
input: { paths: ["pending.ts"] },
result: null,
}),
meta: { toolName: "custom_tool", hookEventName: "history_tool_use" },
};
await renderMessages([tool], { status: "completed" });
expect(container.querySelector(".cline-chat-tool-progress")).toBeNull();
await renderMessages([tool], { status: "running" });
expect(container.querySelector(".cline-chat-tool-progress")).not.toBeNull();
await renderMessages(
[
{
...tool,
content: JSON.stringify({
toolName: "custom_tool",
input: { paths: ["pending.ts"] },
result: "Actual late result",
}),
meta: { ...tool.meta, hookEventName: "tool_call_end" },
},
],
{ status: "running" },
);
expect(container.querySelector(".cline-chat-tool-progress")).toBeNull();
const trigger = container.querySelector("button.cline-chat-tool-trigger");
await act(async () => (trigger as HTMLButtonElement).click());
expect(container.textContent).toContain("Actual late result");
});
it("exposes and toggles expandable tool details", async () => {
await renderMessages([
{
@@ -331,6 +418,61 @@ describe("ChatMessages tool disclosures", () => {
expect(container.textContent).not.toContain("Scheduled task completed");
});
it("keeps the scheduled-task report visible when the run collapses", async () => {
// A follow-up prompt settles the scheduled run's span and folds its
// working rows into the work summary; the submit_and_exit row is the
// run's final report and must stay visible below it.
const summary = "All feeds healthy.";
await renderMessages([
{
id: "user-schedule",
sessionId: "session-1",
role: "user",
content: "Check the feeds",
createdAt: 1_000,
},
{
id: "tool-read",
sessionId: "session-1",
role: "tool",
content: JSON.stringify({
toolName: "read_files",
input: { paths: ["feeds.json"] },
result: {},
}),
createdAt: 2_000,
},
{
id: "tool-submit",
sessionId: "session-1",
role: "tool",
content: JSON.stringify({
toolName: "submit_and_exit",
input: { summary, verified: true },
result: summary,
}),
createdAt: 3_000,
},
{
id: "user-followup",
sessionId: "session-1",
role: "user",
content: "Thanks!",
createdAt: 9_000,
},
]);
// The working rows folded into a collapsed work summary…
const workTrigger = container.querySelector(
"button.cline-chat-work-trigger",
);
expect(workTrigger?.getAttribute("aria-expanded")).toBe("false");
// …but the report row did not fold with them: it stays visible and
// expanded outside the summary.
expect(container.textContent).toContain("Scheduled task completed");
expect(container.textContent).toContain(summary);
});
it("renders consecutive tool calls as individual rows", async () => {
const tools: ChatMessage[] = [
{
@@ -2062,4 +2204,25 @@ describe("ChatMessages tool approvals", () => {
await act(async () => reject?.click());
expect(onReject).toHaveBeenCalledWith("req-1");
});
it("leads an imported transcript with a notice naming the source tool", async () => {
const messages: ChatMessage[] = [
{
id: "user-1",
sessionId: "session-1",
role: "user",
content: "imported prompt",
createdAt: 1,
},
];
await renderMessages(messages, { importedFromTool: "claude-code" });
const notice = container.querySelector("output");
expect(notice?.textContent).toContain("Imported from Claude Code");
expect(notice?.parentElement?.firstElementChild).toBe(notice);
expect(notice?.parentElement?.textContent).toContain("imported prompt");
await renderMessages(messages);
expect(container.querySelector("output")).toBeNull();
});
});
@@ -26,7 +26,9 @@ import type {
ChatMessageImage,
ChatSessionStatus,
} from "@/lib/chat-schema";
import type { SessionImportTool } from "@/lib/session-import";
import { cn } from "@/lib/utils";
import { ImportedSessionNotice } from "./imported-session-notice";
import { STREAMING_TITLE_CLASS } from "./messages/constants";
import {
buildPreviousTimestampMap,
@@ -34,6 +36,7 @@ import {
collapseCompletedWork,
getThoughtDurationMilliseconds,
groupChatMessages,
isSystemSteeringMessage,
} from "./messages/group-messages";
import { ChatImageLightbox } from "./messages/image-lightbox";
import { MessageBubble } from "./messages/message-bubble";
@@ -57,6 +60,10 @@ type ChatMessagesProps = {
isSessionSwitching?: boolean;
messages: ChatMessage[];
error: string | null;
/** Set when the session's history was imported from another coding agent. */
importedFromTool?: SessionImportTool;
/** Replaces "Thinking..." while the runtime reports a named pre-output step. */
activityLabel?: string | null;
streamingMessageId?: string | null;
pendingToolApprovals: ToolApprovalRequestItem[];
pendingAskQuestions: AskQuestionRequestItem[];
@@ -99,6 +106,8 @@ function ChatMessagesImpl({
isSessionSwitching = false,
messages,
error,
importedFromTool,
activityLabel = null,
streamingMessageId = null,
pendingToolApprovals,
pendingAskQuestions,
@@ -205,6 +214,14 @@ function ChatMessagesImpl({
}),
[messages, collapseTrailingRun],
);
const isRunActive =
status === "starting" || status === "running" || status === "stopping";
const lastUserItemIndex = renderItems.findLastIndex(
(item) =>
item.type === "message" &&
item.message.role === "user" &&
!isSystemSteeringMessage(item.message),
);
// Mid-run the thinking indicator's replacement (the next tool or thinking
// row) joins the tight run group, so the indicator must sit at that same
// tight offset; only at the start of a run, directly under the user
@@ -532,6 +549,9 @@ function ChatMessagesImpl({
>
{showIdleDetails ? null : (
<div className="flex min-h-full w-full min-w-0 flex-col gap-4">
{importedFromTool ? (
<ImportedSessionNotice tool={importedFromTool} />
) : null}
{renderItems.map((item, itemIndex) => {
// Working rows — live (`run`) or folded (`work`) — render
// through one child renderer so a row keeps its exact look
@@ -543,6 +563,9 @@ function ChatMessagesImpl({
if (child.type === "tools") {
return (
<ToolMessageBlock
isRunActive={
isRunActive && itemIndex > lastUserItemIndex
}
key={`tools_${child.messages[0]?.id ?? "empty"}`}
messages={child.messages}
onExpandImage={handleExpandImage}
@@ -676,7 +699,9 @@ function ChatMessagesImpl({
)}
>
<Loader2 className="size-4 animate-spin" />
<span className={STREAMING_TITLE_CLASS}>Thinking...</span>
<span className={STREAMING_TITLE_CLASS}>
{activityLabel ?? "Thinking..."}
</span>
</div>
) : null}
{pendingToolApprovals.length > 0 ? (
@@ -0,0 +1,35 @@
"use client";
import { Import } from "lucide-react";
import {
SESSION_IMPORT_TOOL_LABELS,
type SessionImportTool,
} from "@/lib/session-import";
/**
* Heads a transcript imported from another coding agent. Its turns keep that
* agent's tool names and schemas, which Cline does not translate; without the
* notice the session looks native and the user has no way to know why
* continuing it may go differently.
*/
export function ImportedSessionNotice({ tool }: { tool: SessionImportTool }) {
const label = SESSION_IMPORT_TOOL_LABELS[tool];
return (
<output className="flex items-start gap-3 rounded-xl border border-amber-400/40 bg-amber-500/5 px-4 py-3">
<span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-amber-500/15 text-amber-500">
<Import className="size-4" />
</span>
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground">
Imported from {label}
</p>
<p className="mt-0.5 text-[13px] text-muted-foreground">
The earlier turns were recorded by {label}, whose tools and workflow
differ from Cline&apos;s. When you continue, the model works from a
summary of them rather than the original tool calls, so results may
not be as reliable as in a session started with Cline.
</p>
</div>
</output>
);
}
@@ -420,6 +420,224 @@ describe("collapseCompletedWork", () => {
expect(work.durationMilliseconds).toBe(4_000);
});
function makeSubmitTool(id: string, createdAt: number): ChatMessage {
return makeMessage({
id,
role: "tool",
content: JSON.stringify({
toolName: "submit_and_exit",
input: { summary: "Report ready." },
result: "Report ready.",
}),
createdAt,
});
}
it("keeps a trailing submit_and_exit row visible as the collapsed run's answer", () => {
// Scheduled runs end on submit_and_exit — its row carries the final
// report, so it must not fold into the work summary.
const items = collapse(
[
makeMessage({
id: "u1",
role: "user",
content: "go",
createdAt: 1_000,
}),
makeTool("t1", 2_000),
makeSubmitTool("submit", 5_000),
],
true,
);
expect(items.map((item) => item.type)).toEqual([
"message",
"work",
"tools",
]);
const work = items[1];
if (work?.type !== "work") throw new Error("expected work item");
expect(work.toolCallCount).toBe(1);
expect(work.durationMilliseconds).toBe(4_000);
const submit = items[2];
if (submit?.type !== "tools") throw new Error("expected tools item");
expect(submit.messages.map((message) => message.id)).toEqual(["submit"]);
});
it("keeps the submit_and_exit row visible once a later user message exists", () => {
// A follow-up prompt in a finished scheduled session settles the run's
// span; the report row must survive the collapse instead of hiding
// inside the work summary.
const items = collapse(
[
makeMessage({
id: "u1",
role: "user",
content: "go",
createdAt: 1_000,
}),
makeTool("t1", 2_000),
makeSubmitTool("submit", 5_000),
makeMessage({
id: "u2",
role: "user",
content: "thanks, one more thing",
createdAt: 9_000,
}),
],
false,
);
expect(items.map((item) => item.type)).toEqual([
"message",
"work",
"tools",
"message",
]);
const submit = items[2];
if (submit?.type !== "tools") throw new Error("expected tools item");
expect(submit.messages.map((message) => message.id)).toEqual(["submit"]);
});
it("keeps a live run's trailing submit_and_exit with its working rows", () => {
const items = collapse(
[
makeMessage({
id: "u1",
role: "user",
content: "go",
createdAt: 1_000,
}),
makeMessage({
id: "r1",
reasoning: "wrapping up",
createdAt: 1_500,
}),
makeTool("t1", 2_000),
makeSubmitTool("submit", 3_000),
],
false,
);
expect(items.map((item) => item.type)).toEqual(["message", "run"]);
const run = items[1];
if (run?.type !== "run") throw new Error("expected run item");
const tools = run.items.at(-1);
if (tools?.type !== "tools") throw new Error("expected tools item");
expect(tools.messages.map((message) => message.id)).toEqual([
"t1",
"submit",
]);
});
it("treats a mid-run submit_and_exit as ordinary work", () => {
// Only a submit the run actually ended on is its deliverable; one
// followed by more work folds with everything else.
const items = collapse(
[
makeMessage({
id: "u1",
role: "user",
content: "go",
createdAt: 1_000,
}),
makeSubmitTool("submit", 2_000),
makeTool("t1", 3_000),
makeMessage({ id: "a1", content: "Done.", createdAt: 5_000 }),
],
true,
);
expect(items.map((item) => item.type)).toEqual([
"message",
"work",
"message",
]);
const work = items[1];
if (work?.type !== "work") throw new Error("expected work item");
expect(work.toolCallCount).toBe(2);
});
it("prefers trailing assistant text over an earlier submit as the answer", () => {
// When the model narrates after submitting, the narration is the
// answer and the run folds exactly as it did before the submit
// special-case existed.
const items = collapse(
[
makeMessage({
id: "u1",
role: "user",
content: "go",
createdAt: 1_000,
}),
makeTool("t1", 2_000),
makeSubmitTool("submit", 3_000),
makeMessage({ id: "a1", content: "All wrapped up.", createdAt: 4_000 }),
],
true,
);
expect(items.map((item) => item.type)).toEqual([
"message",
"work",
"message",
]);
const work = items[1];
if (work?.type !== "work") throw new Error("expected work item");
expect(work.toolCallCount).toBe(2);
const answer = items[2];
if (answer?.type !== "message") throw new Error("expected message item");
expect(answer.message.id).toBe("a1");
});
it("detects submit_and_exit from message meta when the content is not JSON", () => {
const items = collapse(
[
makeMessage({
id: "u1",
role: "user",
content: "go",
createdAt: 1_000,
}),
makeTool("t1", 2_000),
makeMessage({
id: "submit-meta",
role: "tool",
content: "not-json",
meta: { toolName: "submit_and_exit" },
createdAt: 3_000,
}),
],
true,
);
expect(items.map((item) => item.type)).toEqual([
"message",
"work",
"tools",
]);
});
it("does not treat other trailing tool calls as the run's answer", () => {
// A finished tail ending on an ordinary tool call still reads as an
// interrupted run: rows stay visible, nothing collapses.
const items = collapse(
[
makeMessage({
id: "u1",
role: "user",
content: "go",
createdAt: 1_000,
}),
makeTool("t1", 2_000),
makeTool("t2", 3_000),
],
true,
);
expect(items.map((item) => item.type)).toEqual(["message", "tools"]);
});
it("measures duration from the first working row when no user message precedes it", () => {
const items = collapse(
[
@@ -1,5 +1,6 @@
import type { AgentMessageRole } from "@cline/ui/components/agent-chat";
import type { ChatMessage } from "@/lib/chat-schema";
import { parseToolPayload } from "./tool-summaries";
export type ChatRenderItem =
| {
@@ -174,6 +175,18 @@ function maxFiniteTimestamp(
return max;
}
/**
* A `submit_and_exit` call carries the run's final report (scheduled tasks
* end with it), so a run that ends on one treats that row as its deliverable
* it must stay visible when the working rows fold into a work summary.
*/
function isSubmitAndExitMessage(message: ChatMessage): boolean {
if (message.role !== "tool") return false;
const toolName =
message.meta?.toolName || parseToolPayload(message.content)?.toolName;
return toolName?.toLowerCase() === "submit_and_exit";
}
function firstMessageId(item: ChatRenderItem): string | undefined {
if (item.type === "tools") return item.messages[0]?.id;
if (item.type === "message") {
@@ -185,7 +198,8 @@ function firstMessageId(item: ChatRenderItem): string | undefined {
/**
* Folds each finished run's working rows (tool calls, thinking traces,
* intermediate narration) into a single expandable `work` item, keeping the
* run's final answer the assistant text the run ended on visible after it.
* run's final answer the assistant text or submit_and_exit report the run
* ended on visible after it.
* Working rows that stay visible (live stream, tool-less runs, tails that
* never produced an answer) are grouped into a `run` item instead, so they
* share one tight rhythm and hold their position when the collapse happens.
@@ -215,15 +229,33 @@ export function collapseCompletedWork(
const flushSpan = (nextIndex: number) => {
if (span.length === 0) return;
// "Done" means assistant text not followed by more tool calls: that
// message is the run's answer and stays visible below the summary.
// "Done" means the run ended on its deliverable: assistant text not
// followed by more tool calls, or a submit_and_exit call carrying the
// run's final report. That item is the run's answer and stays visible
// below the summary.
const last = span.at(-1);
const answer =
let answer: ChatRenderItem | undefined;
let workRows = span;
if (
last?.type === "message" &&
last.message.role === "assistant" &&
last.message.content.trim()
? last
: undefined;
) {
answer = last;
workRows = span.slice(0, -1);
} else if (last?.type === "tools") {
const lastToolMessage = last.messages.at(-1);
if (lastToolMessage && isSubmitAndExitMessage(lastToolMessage)) {
answer = { type: "tools", messages: [lastToolMessage] };
workRows =
last.messages.length > 1
? [
...span.slice(0, -1),
{ type: "tools", messages: last.messages.slice(0, -1) },
]
: span.slice(0, -1);
}
}
// A span is settled once a later user message exists. The trailing span
// settles only when the session stopped running AND the run actually
// ended on an answer — a cancelled or failed tail keeps its rows
@@ -231,7 +263,7 @@ export function collapseCompletedWork(
const complete =
nextIndex <= lastUserIndex ||
(collapseTrailingRun && answer !== undefined);
const collapsed = complete && answer ? span.slice(0, -1) : span;
const collapsed = complete && answer ? workRows : span;
const toolCallCount = collapsed.reduce(
(count, item) =>
item.type === "tools" ? count + item.messages.length : count,
@@ -242,8 +274,11 @@ export function collapseCompletedWork(
// Not collapsed: group the working rows (everything but a trailing
// answer-looking message) so they render with the tight in-run
// rhythm instead of full transcript spacing. Pure prose spans have
// no tool work to group and keep normal spacing.
const body = answer ? span.slice(0, -1) : span;
// no tool work to group and keep normal spacing. A trailing submit
// row stays inside the group here — it only pops out once the run
// actually collapses.
const messageAnswer = answer?.type === "message" ? answer : undefined;
const body = messageAnswer ? span.slice(0, -1) : span;
const firstBody = body[0];
const hasToolWork = body.some((item) => item.type === "tools");
if (body.length >= 2 && hasToolWork && firstBody !== undefined) {
@@ -252,8 +287,8 @@ export function collapseCompletedWork(
id: firstMessageId(firstBody) ?? "run",
items: body,
});
if (answer) {
out.push(answer);
if (messageAnswer) {
out.push(messageAnswer);
}
} else {
out.push(...span);
@@ -61,15 +61,20 @@ function ToolLabel({
const ToolCallRow = memo(function ToolCallRow({
message,
isRunActive,
onExpandImage,
onProceedWhileRunning,
}: {
message: ChatMessage;
isRunActive: boolean;
onExpandImage?: (image: ChatMessageImage) => void;
onProceedWhileRunning?: ProceedWhileRunningHandler;
}) {
const { payload, toolName, inProgress, summary } =
buildToolPresentation(message);
// A missing result can outlive its run. Only gate the running display;
// keep the message intact so a later result can still replace it.
const isRunning = inProgress && isRunActive;
const isCommand = summary.kind === "command";
// submit_and_exit carries the run's final answer (scheduled tasks end with
// it), so surface it expanded and rendered as markdown rather than leaving
@@ -103,7 +108,7 @@ const ToolCallRow = memo(function ToolCallRow({
const toolSessionId = message.sessionId;
const toolCallId = message.meta?.toolCallId;
const canProceed = Boolean(
inProgress &&
isRunning &&
isCommand &&
message.meta?.toolDetachable === true &&
toolSessionId &&
@@ -216,9 +221,9 @@ const ToolCallRow = memo(function ToolCallRow({
<Icon className="size-4" />
)
}
label={<ToolLabel isRunning={inProgress} parts={labelParts} />}
label={<ToolLabel isRunning={isRunning} parts={labelParts} />}
showDisclosureIcon={false}
status={hasError ? "error" : inProgress ? "running" : "success"}
status={hasError ? "error" : isRunning ? "running" : "success"}
/>
<ToolActivityContent presentation="rail">
{details.length > 0 ? (
@@ -253,10 +258,7 @@ const ToolCallRow = memo(function ToolCallRow({
),
)}
{commandOutput ? (
<CommandOutputTerminal
isRunning={inProgress}
output={commandOutput}
/>
<CommandOutputTerminal isRunning={isRunning} output={commandOutput} />
) : submitText ? (
// The summary is the run's final answer: full foreground color,
// not the panel's muted tool-detail gray.
@@ -391,10 +393,12 @@ function CommandOutputTerminal({
export const ToolMessageBlock = memo(
function ToolMessageBlock({
messages,
isRunActive,
onExpandImage,
onProceedWhileRunning,
}: {
messages: ChatMessage[];
isRunActive: boolean;
onExpandImage?: (image: ChatMessageImage) => void;
onProceedWhileRunning?: ProceedWhileRunningHandler;
}) {
@@ -403,6 +407,7 @@ export const ToolMessageBlock = memo(
<div className="flex flex-col gap-1">
{messages.map((message) => (
<ToolCallRow
isRunActive={isRunActive}
key={message.id}
message={message}
onExpandImage={onExpandImage}
@@ -413,6 +418,7 @@ export const ToolMessageBlock = memo(
);
},
(prev, next) =>
prev.isRunActive === next.isRunActive &&
prev.messages.length === next.messages.length &&
prev.messages.every((message, index) => message === next.messages[index]) &&
prev.onExpandImage === next.onExpandImage &&
@@ -17,7 +17,7 @@ import {
const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() }));
vi.mock("@/lib/desktop-client", () => ({
desktopClient: { invoke },
desktopClient: { invoke, subscribe: vi.fn(() => () => {}) },
openExternalUrl: vi.fn(),
}));
@@ -26,6 +26,7 @@ import { GitHubConnectStep } from "@/components/views/onboarding/onboarding-gith
import { useAccount } from "@/contexts/account-context";
import { OAUTH_MANAGED_PROVIDERS } from "@/hooks/chat-session/constants";
import { isFeatureEnabled, useFeatureFlags } from "@/hooks/use-feature-flags";
import { useOAuthUserCode } from "@/hooks/use-oauth-user-code";
import { isClineAccountNotAuthenticatedResult } from "@/lib/cline-account-state";
import { desktopClient, openExternalUrl } from "@/lib/desktop-client";
import {
@@ -322,6 +323,7 @@ function ConnectStep({
}) {
const { user, refreshAccount } = useAccount();
const [signingIn, setSigningIn] = useState(false);
const deviceUserCode = useOAuthUserCode(signingIn);
const [signInError, setSignInError] = useState<string | null>(null);
const [clineApiKey, setClineApiKey] = useState("");
const [clineKeySaving, setClineKeySaving] = useState(false);
@@ -619,6 +621,14 @@ function ConnectStep({
)}
</div>
)}
{!user && signingIn && deviceUserCode ? (
<p className="mt-4 ml-12 text-sm text-muted-foreground max-[720px]:ml-0">
Confirm this code in your browser:{" "}
<span className="font-mono font-medium text-foreground">
{deviceUserCode}
</span>
</p>
) : null}
{signInError ? (
<p
className="mt-6 ml-12 text-xs text-destructive max-[720px]:ml-0"
@@ -43,6 +43,7 @@ function renderView({
openThread = vi.fn(),
loadAllSessions = vi.fn(async () => true),
loadOlderSessions = vi.fn(),
requestUsage = vi.fn(),
mayHaveMoreSessions = false,
threads = [thread],
hasLoadedHistory = true,
@@ -50,6 +51,7 @@ function renderView({
openThread?: ReturnType<typeof vi.fn>;
loadAllSessions?: ReturnType<typeof vi.fn>;
loadOlderSessions?: ReturnType<typeof vi.fn>;
requestUsage?: ReturnType<typeof vi.fn>;
mayHaveMoreSessions?: boolean;
threads?: SessionThread[];
hasLoadedHistory?: boolean;
@@ -65,6 +67,7 @@ function renderView({
openThread,
pendingAction: null,
renameThread: vi.fn(),
requestUsage,
setThreadPinned: vi.fn(),
sessionById: new Map(
threads.map((item) => [item.id, { ...session, sessionId: item.id }]),
@@ -76,6 +79,7 @@ function renderView({
loadAllSessions,
loadOlderSessions,
openThread,
requestUsage,
render: () =>
act(async () => {
root.render(
@@ -308,6 +312,33 @@ describe("SessionsView pagination", () => {
expect(container.textContent).toContain("11-20 of 25");
});
it("asks the history hook for usage of the rows on the visible page", async () => {
const view = renderView({ threads: manyThreads });
await view.render();
expect(view.requestUsage).toHaveBeenLastCalledWith(
manyThreads.slice(0, 10).map((item) => item.id),
);
await clickNext();
expect(view.requestUsage).toHaveBeenLastCalledWith(
manyThreads.slice(10, 20).map((item) => item.id),
);
});
it("releases its usage request when it unmounts", async () => {
const view = renderView({ threads: manyThreads });
await view.render();
expect(view.requestUsage).toHaveBeenLastCalledWith(
manyThreads.slice(0, 10).map((item) => item.id),
);
await act(async () => {
root.render(<div />);
});
expect(view.requestUsage).toHaveBeenLastCalledWith([]);
});
it("only asks the backend for older sessions at the last page", async () => {
const view = renderView({
threads: manyThreads,
@@ -241,6 +241,24 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
currentPage + 1 < pageCount ||
(history.mayHaveMoreSessions && !requiresCompleteHistory);
// Tokens and cost are not part of the discovery rows; the hook reads them
// from each transcript on demand, so tell it which rows are on screen.
// Paging (or a fresh batch of older sessions) changes the visible rows and
// the new page fills in the same way.
useEffect(() => {
history.requestUsage(visibleThreads.map((thread) => thread.id));
}, [history.requestUsage, visibleThreads]);
// Leaving the view releases its page, so running sessions on it stop being
// re-read while nobody is looking at them. Separate from the effect above
// on purpose: a per-change cleanup would clear and re-set the same ids and
// restart the hook's hydration each time a row filled in.
useEffect(
() => () => {
history.requestUsage([]);
},
[history.requestUsage],
);
// Snap back when a page disappears (filters changed, or "next" asked the
// backend for older sessions and there were none left).
useEffect(() => {
@@ -25,6 +25,7 @@ import {
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useAccount } from "@/contexts/account-context";
import { useOAuthUserCode } from "@/hooks/use-oauth-user-code";
import { isClineAccountNotAuthenticatedResult } from "@/lib/cline-account-state";
import { desktopClient, openExternalUrl } from "@/lib/desktop-client";
import { invalidateProviderCatalogCache } from "@/lib/provider-model-catalog";
@@ -181,6 +182,7 @@ export function AccountView() {
const [accountActionPending, setAccountActionPending] = useState<
"sign-in" | "sign-out" | null
>(null);
const deviceUserCode = useOAuthUserCode(accountActionPending === "sign-in");
// Organization id being switched to, "" while switching to the personal
// account, null when no switch is in flight.
const [switchTargetId, setSwitchTargetId] = useState<string | null>(null);
@@ -502,6 +504,14 @@ export function AccountView() {
<ExternalLink className="h-4 w-4" />
</button>
</div>
{accountActionPending === "sign-in" && deviceUserCode ? (
<p className="text-sm text-muted-foreground">
Confirm this code in your browser:{" "}
<span className="font-mono font-medium text-foreground">
{deviceUserCode}
</span>
</p>
) : null}
</div>
</div>
);
@@ -6,7 +6,10 @@ import { Button } from "@/components/ui/button";
import { desktopClient } from "@/lib/desktop-client";
import { cn } from "@/lib/utils";
import { PageFrame, PageHeader } from "../page-layout";
import { CustomizationSectionView } from "./extensions-view";
import {
CustomizationSectionView,
invalidateExtensionInventoryCache,
} from "./extensions-view";
import { McpServersContent } from "./mcp-view";
/**
@@ -75,6 +78,15 @@ export function CustomizeView({
return () => window.clearTimeout(timeoutId);
}, [refreshCounts]);
useEffect(
() =>
desktopClient.subscribe("settings.changed", () => {
invalidateExtensionInventoryCache();
void refreshCounts();
}),
[refreshCounts],
);
const handleInventoryChanged = useCallback(() => {
void refreshCounts();
}, [refreshCounts]);
@@ -0,0 +1,139 @@
// @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 {
CustomizationSectionView,
invalidateExtensionInventoryCache,
} from "./extensions-view";
const { fetchMarketplaceCatalog, invoke } = vi.hoisted(() => ({
fetchMarketplaceCatalog: vi.fn(),
invoke: vi.fn(),
}));
vi.mock("@/lib/desktop-client", () => ({
desktopClient: { invoke },
openExternalUrl: vi.fn(),
}));
vi.mock("@/lib/marketplace", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/marketplace")>()),
fetchMarketplaceCatalog,
}));
const EMPTY_CATALOG = {
version: 1,
counts: { total: 0, plugins: 0, skills: 0, mcps: 0 },
tags: [],
entries: [],
};
const AGENT_PLUGIN = {
id: "agent-plugin:/Users/test/.agents/plugins/example",
name: "agent-plugins-example",
path: "/Users/test/.agents/plugins/example",
enabled: true,
source: "agent-plugin",
toggleable: true,
agentPlugin: true,
contributions: {
inspectionStatus: "available",
capabilities: ["skills"],
tools: [],
skills: ["example-skill"],
rules: [],
hooks: [],
commands: [],
mcpServers: [],
providers: [],
},
};
const AGENT_PLUGIN_SKILL = {
name: "example-skill",
description: "A skill contributed by an Agent Plugin.",
instructions: "",
path: "/Users/test/.agents/plugins/example/skills/example-skill/SKILL.md",
agentPlugin: true,
pluginName: "agent-plugins-example",
};
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
invalidateExtensionInventoryCache();
fetchMarketplaceCatalog.mockReset();
fetchMarketplaceCatalog.mockResolvedValue(EMPTY_CATALOG);
invoke.mockReset();
invoke.mockImplementation((command: string) => {
if (command === "list_marketplace_installed_entries") {
return Promise.resolve({ installedKeys: [] });
}
if (command === "list_user_instruction_configs") {
return Promise.resolve({
workspaceRoot: "/workspace",
rules: [],
workflows: [],
skills: [AGENT_PLUGIN_SKILL],
agents: [],
plugins: [AGENT_PLUGIN],
tools: [],
hooks: [],
mcp: { servers: [] },
warnings: [],
});
}
return Promise.reject(new Error(`Unexpected command: ${command}`));
});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
invalidateExtensionInventoryCache();
});
describe("CustomizationSectionView Agent Plugin inventory", () => {
it("shows Hub-managed Agent Plugins in the installed Plugins view", async () => {
await act(async () => {
root.render(
<CustomizationSectionView
catalogPrimitive="plugin"
chrome="embedded"
marketplaceVariant="installed"
section="Plugins"
/>,
);
});
await vi.waitFor(() => {
expect(container.textContent).toContain("agent-plugins-example");
expect(container.textContent).toContain("Agent Plugin");
});
});
it("shows Agent Plugin skills in the installed Skills view", async () => {
await act(async () => {
root.render(
<CustomizationSectionView
catalogPrimitive="skill"
chrome="embedded"
marketplaceVariant="installed"
section="Skills"
/>,
);
});
await vi.waitFor(() => {
expect(container.textContent).toContain("example-skill");
expect(container.textContent).toContain("Agent Plugin");
});
});
});
@@ -84,6 +84,8 @@ type SkillItem = {
description?: string;
instructions: string;
path: string;
agentPlugin?: boolean;
pluginName?: string;
};
type CommandItem = {
@@ -94,6 +96,8 @@ type CommandItem = {
instructions: string;
path: string;
scope: ItemScope;
agentPlugin?: boolean;
pluginName?: string;
};
type ItemScope = "Global" | "Project";
@@ -104,9 +108,15 @@ type AgentItem = {
};
type PluginItem = {
id: string;
name: string;
path: string;
enabled: boolean;
source?: string;
toggleable?: boolean;
agentPlugin?: boolean;
description?: string;
loadError?: string;
contributions?: PluginContributions;
};
@@ -774,6 +784,8 @@ export function CustomizationSectionView({
instructions: skill.instructions,
path: skill.path,
scope: getPathScope(skill.path, workspaceRoot),
agentPlugin: skill.agentPlugin,
pluginName: skill.pluginName,
}));
return [...workflowItems, ...skillItems].sort((a, b) =>
a.name.localeCompare(b.name),
@@ -825,9 +837,11 @@ export function CustomizationSectionView({
for (const plugin of plugins) {
const normalized = normalizePath(plugin.path);
if (
normalizedRoot &&
normalized.startsWith(`${normalizedRoot}/`) &&
normalized.includes("/.cline/plugins")
plugin.source === "workspace-plugin" ||
(normalizedRoot &&
normalized.startsWith(`${normalizedRoot}/`) &&
(normalized.includes("/.cline/plugins") ||
normalized.includes("/.agents/plugins")))
) {
project.push(plugin);
} else {
@@ -872,6 +886,14 @@ export function CustomizationSectionView({
],
[globalPlugins, projectPlugins],
);
const clinePlugins = useMemo(
() => scopedPlugins.filter(({ plugin }) => plugin.agentPlugin !== true),
[scopedPlugins],
);
const agentPlugins = useMemo(
() => scopedPlugins.filter(({ plugin }) => plugin.agentPlugin === true),
[scopedPlugins],
);
const scopedRules = useMemo(
() => [
@@ -976,15 +998,17 @@ export function CustomizationSectionView({
key={key}
className="relative grid min-w-0 gap-2 rounded-lg border bg-card p-4"
>
<div className="absolute top-4 right-4">
{renderLocalActionButton({
key,
type: item.type,
id: item.id,
name: item.name,
path: item.path,
})}
</div>
{item.agentPlugin !== true ? (
<div className="absolute top-4 right-4">
{renderLocalActionButton({
key,
type: item.type,
id: item.id,
name: item.name,
path: item.path,
})}
</div>
) : null}
<div className="flex min-w-0 items-center gap-2 pr-28">
{item.type === "workflow" ? (
<Play className="h-4 w-4 shrink-0 text-primary" />
@@ -998,6 +1022,11 @@ export function CustomizationSectionView({
<Badge variant="outline" className="shrink-0 text-muted-foreground">
{item.type}
</Badge>
{item.agentPlugin === true ? (
<Badge variant="outline" className="shrink-0 text-muted-foreground">
Agent Plugin
</Badge>
) : null}
{context?.matchedEntries?.length ? (
<Badge variant="outline" className="shrink-0 text-muted-foreground">
Marketplace
@@ -1057,6 +1086,9 @@ export function CustomizationSectionView({
{plugin.name}
</h3>
<ScopeBadge scope={scope} />
<Badge variant="outline" className="shrink-0 text-muted-foreground">
{plugin.agentPlugin === true ? "Agent Plugin" : "Cline Plugin"}
</Badge>
{context?.matchedEntries?.length ? (
<Badge variant="outline" className="shrink-0 text-muted-foreground">
Marketplace
@@ -1071,18 +1103,33 @@ export function CustomizationSectionView({
void setPluginEnabled(plugin);
}}
onClick={(event) => event.stopPropagation()}
disabled={togglingPluginPaths.has(plugin.path)}
disabled={
plugin.toggleable === false ||
togglingPluginPaths.has(plugin.path)
}
aria-label={`Toggle ${plugin.name}`}
/>
{renderPluginMenu({
key,
type: "plugin",
id: plugin.name,
name: plugin.name,
path: plugin.path,
})}
{plugin.agentPlugin !== true
? renderPluginMenu({
key,
type: "plugin",
id: plugin.name,
name: plugin.name,
path: plugin.path,
})
: null}
</summary>
<div className="mt-3">
{plugin.description?.trim() ? (
<p className="mb-2 whitespace-pre-line text-xs text-muted-foreground">
{plugin.description}
</p>
) : null}
{plugin.loadError?.trim() ? (
<p className="mb-2 whitespace-pre-line text-xs text-destructive">
{plugin.loadError}
</p>
) : null}
{plugin.contributions?.inspectionStatus === "disabled" ? (
<p className="mb-2 text-xs text-muted-foreground">
Enable this plugin to inspect its dynamic contributions.
@@ -1520,68 +1567,19 @@ export function CustomizationSectionView({
{activeTab === "Plugins" && !catalogPrimitive && (
<div>
<p className="mb-6 text-sm leading-relaxed text-muted-foreground">
Plugins discovered from workspace and global plugin directories.
Cline and portable Agent Plugins discovered by the shared Hub.
Changes apply when a session is rebuilt or started.
</p>
<div className="mb-6">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Global Plugins
Cline Plugins ({clinePlugins.length})
</h3>
<div className="flex flex-col gap-3">
{globalPlugins.map((plugin) => (
<div
key={plugin.path}
className="rounded-lg border border-border px-5 py-4"
>
<div className="flex items-center gap-3">
<h3 className="min-w-0 flex-1 text-sm font-semibold text-foreground">
{plugin.name}
</h3>
<span className="text-xs text-muted-foreground">
{plugin.enabled ? "Enabled" : "Disabled"}
</span>
<Switch
checked={plugin.enabled}
onCheckedChange={() => {
void setPluginEnabled(plugin);
}}
disabled={togglingPluginPaths.has(plugin.path)}
aria-label={`Toggle ${plugin.name}`}
/>
</div>
<div className="mt-3 ml-7 flex max-h-56 flex-col gap-2 overflow-y-auto">
{(pluginToolsByPluginKey.get(plugin.path) ?? []).map(
(tool) => {
return (
<div
key={tool.id}
className="flex items-center justify-between gap-4 rounded-md border border-border/70 px-3 py-2"
>
<div className="min-w-0">
<p className="text-xs font-medium text-foreground">
{tool.name}
</p>
<p className="text-xs text-muted-foreground">
{tool.description?.trim() ||
"No description available."}
</p>
</div>
</div>
);
},
)}
{(pluginToolsByPluginKey.get(plugin.path)?.length ?? 0) ===
0 && (
<p className="text-xs text-muted-foreground">
No plugin tools found.
</p>
)}
</div>
</div>
))}
{globalPlugins.length === 0 && (
{clinePlugins.map((plugin) => renderPluginCard(plugin))}
{clinePlugins.length === 0 && (
<p className="rounded-lg border border-dashed border-border px-4 py-3 text-sm text-muted-foreground">
No global plugins found.
No Cline Plugins found.
</p>
)}
</div>
@@ -1589,63 +1587,13 @@ export function CustomizationSectionView({
<div>
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Project Plugins
Agent Plugins ({agentPlugins.length})
</h3>
<div className="flex flex-col gap-3">
{projectPlugins.map((plugin) => (
<div
key={plugin.path}
className="rounded-lg border border-border px-5 py-4"
>
<div className="flex items-center gap-3">
<h3 className="min-w-0 flex-1 text-sm font-semibold text-foreground">
{plugin.name}
</h3>
<span className="text-xs text-muted-foreground">
{plugin.enabled ? "Enabled" : "Disabled"}
</span>
<Switch
checked={plugin.enabled}
onCheckedChange={() => {
void setPluginEnabled(plugin);
}}
disabled={togglingPluginPaths.has(plugin.path)}
aria-label={`Toggle ${plugin.name}`}
/>
</div>
<div className="mt-3 ml-7 flex max-h-56 flex-col gap-2 overflow-y-auto">
{(pluginToolsByPluginKey.get(plugin.path) ?? []).map(
(tool) => {
return (
<div
key={tool.id}
className="flex items-center justify-between gap-4 rounded-md border border-border/70 px-3 py-2"
>
<div className="min-w-0">
<p className="text-xs font-medium text-foreground">
{tool.name}
</p>
<p className="text-xs text-muted-foreground">
{tool.description?.trim() ||
"No description available."}
</p>
</div>
</div>
);
},
)}
{(pluginToolsByPluginKey.get(plugin.path)?.length ?? 0) ===
0 && (
<p className="text-xs text-muted-foreground">
No plugin tools found.
</p>
)}
</div>
</div>
))}
{projectPlugins.length === 0 && (
{agentPlugins.map((plugin) => renderPluginCard(plugin))}
{agentPlugins.length === 0 && (
<p className="rounded-lg border border-dashed border-border px-4 py-3 text-sm text-muted-foreground">
No project plugins found.
No Agent Plugins found.
</p>
)}
</div>
@@ -29,6 +29,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Switch } from "@/components/ui/switch";
import { useOAuthUserCode } from "@/hooks/use-oauth-user-code";
import { openExternalUrl } from "@/lib/desktop-client";
import {
getProviderAuthKind,
@@ -539,6 +540,7 @@ export function ProviderDetailContent({
onDisconnect?: () => void;
variant?: "page" | "panel";
}) {
const deviceUserCode = useOAuthUserCode(oauthLoginPending);
const [shownSecrets, setShownSecrets] = useState<Record<string, boolean>>({});
const [localConfigValues, setLocalConfigValues] = useState<
Record<string, ProviderConfigFieldPrimitive>
@@ -821,6 +823,14 @@ export function ProviderDetailContent({
</span>
</Button>
) : null}
{oauthLoginPending && deviceUserCode ? (
<p className="mt-3 text-xs text-muted-foreground">
Confirm this code in your browser:{" "}
<span className="font-mono font-medium text-foreground">
{deviceUserCode}
</span>
</p>
) : null}
{apiKeyField ? (
<div className="mt-3">
<Button
@@ -27,6 +27,7 @@ import {
APP_ICONS,
type AppIconId,
appIconAssetPath,
appIconSurface,
DEFAULT_APP_ICON,
readStoredAppIcon,
setStoredAppIcon,
@@ -679,6 +680,9 @@ function GeneralSettingsContent({
if (typeof window === "undefined") return DEFAULT_APP_ICON;
return readStoredAppIcon();
});
const [appIconLocation, setAppIconLocation] = useState<
"Dock" | "Taskbar" | "desktop"
>("desktop");
const [appIconError, setAppIconError] = useState<string | null>(null);
const appIconRequestRef = useRef(0);
const [telemetryOptOut, setTelemetryOptOut] = useState(false);
@@ -701,6 +705,7 @@ function GeneralSettingsContent({
>(null);
const [appVersion, setAppVersion] = useState<string | null>(null);
useEffect(() => setAppIconLocation(appIconSurface(navigator.userAgent)), []);
useEffect(() => subscribeToAppFontSize(setFontSize), []);
useEffect(() => {
@@ -884,9 +889,6 @@ function GeneralSettingsContent({
}
setAppIcon(previousIcon);
setAppIconError(error instanceof Error ? error.message : String(error));
// Storage was written before the native call failed; roll it back
// so the persisted choice matches what the dock actually shows.
await setStoredAppIcon(previousIcon).catch(() => {});
}
};
@@ -996,7 +998,7 @@ function GeneralSettingsContent({
<div className="flex flex-col gap-1">
<p className="text-base font-semibold text-foreground">App icon</p>
<p className="text-sm text-muted-foreground">
Pick the icon Cline shows in the Dock.
Pick the icon Cline shows in the {appIconLocation}.
</p>
{appIconError ? (
<p className="mt-2 text-xs text-destructive" role="alert">
@@ -0,0 +1,81 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vitest";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
import { WindowControls } from "@/components/window-title-bar";
const windowMocks = vi.hoisted(() => ({
close: vi.fn(),
isMaximized: vi.fn(async () => false),
minimize: vi.fn(),
onResized: vi.fn(async () => () => undefined),
toggleMaximize: vi.fn(),
}));
vi.mock("@tauri-apps/api/window", () => ({
getCurrentWindow: () => windowMocks,
}));
vi.mock("@/lib/desktop-client", () => ({ isTauriAvailable: () => true }));
it("invokes caption actions without dismissing or taking pointer focus from a modal", async () => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
vi.stubGlobal("navigator", { userAgent: "Windows NT 10.0" });
// Next hydrates document: React and Radix share its event listeners.
const root = createRoot(document);
const onOpenChange = vi.fn();
try {
await act(async () => {
root.render(
<html lang="en">
<head />
<body>
<WindowControls />
<Dialog defaultOpen onOpenChange={onOpenChange}>
<DialogContent>
<DialogTitle>Settings</DialogTitle>
<DialogDescription>Edit settings</DialogDescription>
<input aria-label="Setting" />
</DialogContent>
</Dialog>
</body>
</html>,
);
});
// Radix registers its document pointer listener on the next timer turn.
await act(async () => new Promise((resolve) => setTimeout(resolve, 0)));
expect(document.body.style.pointerEvents).toBe("none");
const modal = document.querySelector('[role="dialog"]');
const focused = document.activeElement;
expect(modal?.contains(focused)).toBe(true);
const buttons = document.querySelectorAll<HTMLButtonElement>(
'[data-slot="window-controls"] button',
);
expect(buttons).toHaveLength(3);
for (const button of buttons) {
const pointerDown = new MouseEvent("pointerdown", {
bubbles: true,
cancelable: true,
});
await act(async () => {
button.dispatchEvent(pointerDown);
button.click();
});
expect(pointerDown.defaultPrevented).toBe(true);
expect(document.activeElement).toBe(focused);
expect(onOpenChange).not.toHaveBeenCalled();
expect(document.querySelector('[role="dialog"]')).toBe(modal);
}
expect(windowMocks.minimize).toHaveBeenCalledOnce();
expect(windowMocks.toggleMaximize).toHaveBeenCalledOnce();
expect(windowMocks.close).toHaveBeenCalledOnce();
} finally {
await act(async () => root.unmount());
vi.unstubAllGlobals();
}
});
@@ -2,18 +2,42 @@
import { act, useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
WindowTitleBar,
WindowTitleBarContent,
WindowTitleBarProvider,
} from "@/components/window-title-bar";
const windowMocks = vi.hoisted(() => ({
close: vi.fn(),
isMaximized: vi.fn(async () => false),
minimize: vi.fn(),
onResized: vi.fn(async (_listener: () => void) => () => undefined),
toggleMaximize: vi.fn(),
}));
vi.mock("@tauri-apps/api/window", () => ({
getCurrentWindow: () => windowMocks,
}));
vi.mock("@/lib/desktop-client", () => ({
isTauriAvailable: () => "__TAURI_INTERNALS__" in window,
}));
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
windowMocks.close.mockReset();
windowMocks.isMaximized.mockReset();
windowMocks.isMaximized.mockResolvedValue(false);
windowMocks.minimize.mockReset();
windowMocks.onResized.mockReset();
windowMocks.onResized.mockResolvedValue(() => undefined);
windowMocks.toggleMaximize.mockReset();
delete (window as Window & { __TAURI_INTERNALS__?: unknown })
.__TAURI_INTERNALS__;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
@@ -21,6 +45,9 @@ beforeEach(() => {
afterEach(async () => {
await act(async () => root.unmount());
delete (window as Window & { __TAURI_INTERNALS__?: unknown })
.__TAURI_INTERNALS__;
vi.unstubAllGlobals();
container.remove();
});
@@ -56,6 +83,115 @@ function renderShell(contentEnabled: boolean) {
}
describe("WindowTitleBar", () => {
it("renders Windows caption controls and invokes the native window actions", async () => {
Object.defineProperty(window, "__TAURI_INTERNALS__", {
configurable: true,
value: {},
});
vi.stubGlobal("navigator", { userAgent: "Windows NT 10.0" });
windowMocks.isMaximized.mockResolvedValue(true);
await act(async () => root.render(renderShell(true)));
await act(async () => Promise.resolve());
const controls = container.querySelector<HTMLElement>(
'[data-slot="window-controls"]',
);
expect(controls).not.toBeNull();
if (!controls) {
throw new Error("Expected Windows caption controls");
}
expect(controls.querySelector('[aria-label="Restore"]')).not.toBeNull();
await act(async () => {
controls
.querySelector<HTMLButtonElement>('[aria-label="Minimize"]')
?.click();
controls
.querySelector<HTMLButtonElement>('[aria-label="Restore"]')
?.click();
controls
.querySelector<HTMLButtonElement>('[aria-label="Close"]')
?.click();
});
expect(windowMocks.minimize).toHaveBeenCalledOnce();
expect(windowMocks.toggleMaximize).toHaveBeenCalledOnce();
expect(windowMocks.close).toHaveBeenCalledOnce();
});
it("tracks the maximize state across window resizes", async () => {
Object.defineProperty(window, "__TAURI_INTERNALS__", {
configurable: true,
value: {},
});
vi.stubGlobal("navigator", { userAgent: "Windows NT 10.0" });
await act(async () => root.render(renderShell(true)));
await act(async () => Promise.resolve());
const controls = container.querySelector<HTMLElement>(
'[data-slot="window-controls"]',
);
expect(controls).not.toBeNull();
if (!controls) {
throw new Error("Expected Windows caption controls");
}
const toggleButtonLabel = () =>
controls
.querySelector(
'button[aria-label="Maximize"], button[aria-label="Restore"]',
)
?.getAttribute("aria-label");
expect(toggleButtonLabel()).toBe("Maximize");
const onResized = windowMocks.onResized.mock.calls.at(-1)?.[0];
expect(onResized).toBeTypeOf("function");
if (!onResized) {
throw new Error("Expected a resize listener");
}
windowMocks.isMaximized.mockResolvedValue(true);
await act(async () => {
onResized();
await Promise.resolve();
});
expect(toggleButtonLabel()).toBe("Restore");
windowMocks.isMaximized.mockResolvedValue(false);
await act(async () => {
onResized();
await Promise.resolve();
});
expect(toggleButtonLabel()).toBe("Maximize");
});
it.each([
{ name: "Windows browser", tauri: false, userAgent: "Windows NT 10.0" },
{
name: "macOS desktop",
tauri: true,
userAgent: "Macintosh; Intel Mac OS X 10_15_7",
},
{ name: "Linux desktop", tauri: true, userAgent: "X11; Linux x86_64" },
])("does not render caption controls in $name", async ({
tauri,
userAgent,
}) => {
if (tauri) {
Object.defineProperty(window, "__TAURI_INTERNALS__", {
configurable: true,
value: {},
});
}
vi.stubGlobal("navigator", { userAgent });
await act(async () => root.render(renderShell(true)));
expect(container.querySelector('[data-slot="window-controls"]')).toBeNull();
expect(
document.documentElement.hasAttribute("data-windows-custom-titlebar"),
).toBe(false);
expect(windowMocks.isMaximized).not.toHaveBeenCalled();
expect(windowMocks.onResized).not.toHaveBeenCalled();
});
it("reserves an in-flow draggable row before page content inside main", async () => {
await act(async () => root.render(renderShell(false)));
@@ -63,7 +199,7 @@ describe("WindowTitleBar", () => {
const titleBar = main?.querySelector('[data-slot="window-title-bar"]');
const page = main?.querySelector('[data-testid="page"]');
expect(titleBar?.getAttribute("data-tauri-drag-region")).toBe("deep");
expect(titleBar?.className).toContain("h-12");
expect(titleBar?.className).toContain("h-(--window-title-bar-height)");
expect(titleBar?.className).toContain("shrink-0");
expect(titleBar?.nextElementSibling).toBe(page);
});
@@ -1,7 +1,16 @@
"use client";
import { createContext, type ReactNode, useContext, useState } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { Minus, Square, X } from "lucide-react";
import {
createContext,
type ReactNode,
useContext,
useEffect,
useState,
} from "react";
import { createPortal } from "react-dom";
import { isTauriAvailable } from "@/lib/desktop-client";
import { cn } from "@/lib/utils";
type WindowTitleBarContextValue = {
@@ -31,10 +40,92 @@ export function WindowTitleBarProvider({
value={{ contentEnabled, portalTarget, setPortalTarget }}
>
{children}
<WindowControls />
</WindowTitleBarContext.Provider>
);
}
function isWindowsDesktop(): boolean {
return (
isTauriAvailable() &&
typeof navigator !== "undefined" &&
/Windows/i.test(navigator.userAgent)
);
}
/** Native window actions for the borderless Windows frame. */
export function WindowControls() {
const [isWindows, setIsWindows] = useState(false);
const [isMaximized, setIsMaximized] = useState(false);
useEffect(() => {
if (!isWindowsDesktop()) {
return;
}
setIsWindows(true);
document.documentElement.dataset.windowsCustomTitlebar = "";
const appWindow = getCurrentWindow();
void appWindow.isMaximized().then(setIsMaximized);
const unlisten = appWindow.onResized(() => {
void appWindow.isMaximized().then(setIsMaximized);
});
return () => {
delete document.documentElement.dataset.windowsCustomTitlebar;
void unlisten.then((stopListening) => stopListening());
};
}, []);
if (!isWindows) {
return null;
}
const appWindow = getCurrentWindow();
return (
<div
className="pointer-events-auto fixed top-0 right-0 z-[110] flex h-(--window-title-bar-height) bg-background"
data-slot="window-controls"
onPointerDownCapture={(event) => {
// Caption actions stay above app overlays, even when a modal disables
// body pointer events. Keep its focus and outside-click state unchanged.
event.preventDefault();
event.stopPropagation();
}}
>
<button
aria-label="Minimize"
className="flex w-12 items-center justify-center text-foreground hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
onClick={() => void appWindow.minimize()}
type="button"
>
<Minus aria-hidden="true" className="size-4" strokeWidth={1.5} />
</button>
<button
aria-label={isMaximized ? "Restore" : "Maximize"}
className="flex w-12 items-center justify-center text-foreground hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
onClick={() => void appWindow.toggleMaximize()}
type="button"
>
{isMaximized ? (
<span aria-hidden="true" className="relative size-3.5">
<span className="absolute top-0 right-0 size-2.5 border border-current" />
<span className="absolute bottom-0 left-0 size-2.5 border border-current bg-background" />
</span>
) : (
<Square aria-hidden="true" className="size-3.5" strokeWidth={1.5} />
)}
</button>
<button
aria-label="Close"
className="flex w-12 items-center justify-center text-foreground hover:bg-red-600 hover:text-white focus-visible:bg-red-600 focus-visible:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white"
onClick={() => void appWindow.close()}
type="button"
>
<X aria-hidden="true" className="size-4" strokeWidth={1.5} />
</button>
</div>
);
}
/**
* Reserves the native title-bar row at the shell boundary. The normal app
* shell hosts projected controls; full-screen shell overlays only need the
@@ -56,7 +147,10 @@ export function WindowTitleBar({
return (
<div
className={cn("isolate h-12 shrink-0 max-md:h-7", className)}
className={cn(
"isolate h-(--window-title-bar-height) shrink-0",
className,
)}
data-slot="window-title-bar"
data-tauri-drag-region="deep"
>
@@ -14,6 +14,12 @@ export type AgentChunkEvent = {
chunk: string;
ts: number;
index?: number;
/**
* Identifies the sidecar process that numbered this chunk. `index` restarts
* whenever the sidecar does, so a changed `boot` means the counter reset
* rather than the stream replaying.
*/
boot?: string;
};
export type ReasoningDeltaEvent = {
@@ -8,6 +8,10 @@ import {
MAX_LIVE_COMMAND_OUTPUT_CHARS,
} from "@/lib/command-output";
import { MODEL_SELECTION_STORAGE_KEY } from "@/lib/model-selection";
import {
buildPreviousTimestampMap,
getThoughtDurationMilliseconds,
} from "../components/views/chat/messages/group-messages";
import { useChatSession } from "./use-chat-session";
const { invokeMock, subscribeMock } = vi.hoisted(() => ({
@@ -108,6 +112,113 @@ describe("useChatSession", () => {
expect(current.status).toBe("idle");
});
it("keeps a new task on starting while the hub reports the just-created session idle", async () => {
// The hub publishes session.created / session.updated with the record's
// "idle" status during the start RPC, before the first prompt's run
// begins. Applying it over "starting" flipped the composer placeholder
// and hid the request indicator for a frame on every new task.
const startResponse = deferred<{ cwd: string; workspaceRoot: string }>();
let plannedSessionId = "";
invokeMock.mockImplementation(
async (command: string, args?: Record<string, unknown>) => {
if (command === "get_process_context") {
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
}
if (command === "chat_session_command") {
const request = args?.request as
| { action?: string; config?: { sessionId?: string } }
| undefined;
if (request?.action === "start") {
// The sidecar reuses the planned id the webview passes.
plannedSessionId = request.config?.sessionId ?? "";
return {
...(await startResponse.promise),
sessionId: plannedSessionId,
};
}
if (request?.action === "send") {
return {
ok: true,
queued: true,
promptsInQueue: [{ id: "p1", prompt: "hello", steer: false }],
};
}
}
return [];
},
);
let sendTask: Promise<void> | undefined;
await act(async () => {
sendTask = current.sendPrompt("hello");
await Promise.resolve();
await Promise.resolve();
});
expect(current.status).toBe("starting");
const statusHandler = handlerFor("chat_session_status");
await act(async () => {
statusHandler({ sessionId: plannedSessionId, status: "idle" });
statusHandler({ sessionId: plannedSessionId, status: "idle" });
});
expect(current.status).toBe("starting");
await act(async () => {
startResponse.resolve({
cwd: "/workspace/cline",
workspaceRoot: "/workspace/cline",
});
await sendTask;
});
expect(current.status).toBe("running");
// Once nothing is in flight, the hub's status applies as before.
await act(async () => {
statusHandler({ sessionId: plannedSessionId, status: "idle" });
});
expect(current.status).toBe("idle");
});
it("still applies a terminal status that lands while a submission is in flight", async () => {
// Only the transient "idle" is held back; a failed session must unstick
// the UI even if the send response never arrives.
const startResponse = deferred<{ cwd: string; workspaceRoot: string }>();
let plannedSessionId = "";
invokeMock.mockImplementation(
async (command: string, args?: Record<string, unknown>) => {
if (command === "get_process_context") {
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
}
if (command === "chat_session_command") {
const request = args?.request as
| { action?: string; config?: { sessionId?: string } }
| undefined;
if (request?.action === "start") {
plannedSessionId = request.config?.sessionId ?? "";
return {
...(await startResponse.promise),
sessionId: plannedSessionId,
};
}
}
return [];
},
);
await act(async () => {
void current.sendPrompt("hello");
await Promise.resolve();
await Promise.resolve();
});
expect(current.status).toBe("starting");
const statusHandler = handlerFor("chat_session_status");
await act(async () => {
statusHandler({ sessionId: plannedSessionId, status: "failed" });
});
expect(current.status).toBe("failed");
});
it("preserves authoritative completion across abort races", async () => {
vi.useFakeTimers();
try {
@@ -1425,7 +1536,7 @@ describe("useChatSession", () => {
expect(userMessages[1]?.id).toBe("queued_user_queued-prompt-2");
});
it("keeps live stream timestamps in milliseconds", async () => {
it("stamps live rows on the webview clock rather than the sidecar timestamp", async () => {
invokeMock.mockImplementation(
async (command: string, args?: Record<string, unknown>) => {
if (command === "get_process_context") {
@@ -1457,7 +1568,9 @@ describe("useChatSession", () => {
);
expect(chatEventHandler).toBeDefined();
expect(userMessage).toBeDefined();
const thinkingTimestamp = (userMessage?.createdAt ?? Date.now()) + 5_000;
// A sidecar `ts` from a different clock must not leak into the row.
const thinkingTimestamp = (userMessage?.createdAt ?? Date.now()) - 60_000;
const before = Date.now();
await act(async () => {
chatEventHandler?.({
@@ -1469,10 +1582,12 @@ describe("useChatSession", () => {
});
});
expect(
current.messages.find((message) => message.role === "assistant")
?.createdAt,
).toBe(thinkingTimestamp);
const assistantCreatedAt = current.messages.find(
(message) => message.role === "assistant",
)?.createdAt;
expect(assistantCreatedAt).not.toBe(thinkingTimestamp);
expect(assistantCreatedAt).toBeGreaterThanOrEqual(before);
expect(assistantCreatedAt).toBeLessThanOrEqual(Date.now());
});
it("updates current token usage from live usage events", async () => {
@@ -1537,6 +1652,193 @@ describe("useChatSession", () => {
expect(current.summary.totalCostUsd).toBeCloseTo(0.03);
});
it("keeps a queued prompt's user bubble when the preceding blocking send resolves after it starts", async () => {
const sessionId = "session-queued-bubble";
const sendResponse = deferred<unknown>();
// The runtime persists the transcript at iteration boundaries, so while
// the queued turn is in flight the canonical read still ends at the
// previous turn's assistant message.
const canonicalAfterFirstTurn = [
{
id: "canonical-user-1",
sessionId,
role: "user",
content: "write an essay",
createdAt: 1,
},
{
id: "canonical-assistant-1",
sessionId,
role: "assistant",
content: "Here is the essay.",
createdAt: 2,
},
];
invokeMock.mockImplementation(
async (command: string, args?: Record<string, unknown>) => {
if (command === "get_process_context") {
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
}
if (command === "read_session_messages") {
return canonicalAfterFirstTurn;
}
if (command === "chat_session_command") {
const request = args?.request as { action?: string } | undefined;
if (request?.action === "start") return { sessionId };
if (request?.action === "send") return await sendResponse.promise;
}
return [];
},
);
await act(async () => current.start(current.config));
let sendTask: Promise<void> | undefined;
await act(async () => {
sendTask = current.sendPrompt("write an essay");
await Promise.resolve();
await Promise.resolve();
});
const chatEventHandler = handlerFor("chat_event");
await act(async () => {
chatEventHandler({
sessionId,
stream: "chat_text",
chunk: "Here is the essay.",
ts: Date.now(),
index: 1,
});
});
// The runtime drains the queue before it answers the blocking send:
// the next prompt's start event lands first.
await act(async () => {
chatEventHandler({
sessionId,
stream: "chat_queued_prompt_start",
chunk: JSON.stringify({
promptId: "queued-how",
prompt: "how are you?",
attachmentCount: 0,
}),
ts: Date.now(),
index: 2,
});
});
expect(
current.messages.some(
(message) =>
message.role === "user" && message.content === "how are you?",
),
).toBe(true);
await act(async () => {
sendResponse.resolve({
ok: true,
result: { text: "Here is the essay.", finishReason: "completed" },
});
await sendTask;
});
// The queued turn is in flight: its request indicator depends on the
// session staying "running", and the late response must not settle
// the new turn's epoch either (the hub's "running" for it would then
// read as stale).
expect(current.status).toBe("running");
const statusHandler = handlerFor("chat_session_status");
await act(async () => {
statusHandler({ sessionId, status: "running" });
});
expect(current.status).toBe("running");
const userContents = current.messages
.filter((message) => message.role === "user")
.map((message) => message.content);
expect(userContents).toEqual(["write an essay", "how are you?"]);
// The previous turn's assistant text was already streamed into its own
// bubble; the late response must not append a second copy below the
// queued prompt.
expect(
current.messages.filter(
(message) =>
message.role === "assistant" &&
message.content === "Here is the essay.",
),
).toHaveLength(1);
expect(current.messages.at(-1)).toMatchObject({
id: "queued_user_queued-how",
role: "user",
content: "how are you?",
});
});
it("stamps live rows on the webview clock so a sidecar clock behind the browser cannot erase a thought duration", async () => {
const sessionId = "session-clock-skew";
const sendResponse = deferred<unknown>();
invokeMock.mockImplementation(
async (command: string, args?: Record<string, unknown>) => {
if (command === "get_process_context") {
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
}
if (command === "chat_session_command") {
const request = args?.request as { action?: string } | undefined;
if (request?.action === "start") return { sessionId };
if (request?.action === "send") return await sendResponse.promise;
}
return [];
},
);
await act(async () => current.start(current.config));
await act(async () => {
void current.sendPrompt("write an essay");
await Promise.resolve();
await Promise.resolve();
});
const chatEventHandler = handlerFor("chat_event");
// The sidecar's clock trails the browser by 15s: its `ts` predates the
// optimistic user bubble that was appended on the browser clock.
const sidecarTs = Date.now() - 15_000;
await act(async () => {
chatEventHandler({
sessionId,
stream: "chat_reasoning",
chunk: JSON.stringify({ text: "Let me think.", redacted: false }),
ts: sidecarTs,
index: 1,
});
chatEventHandler({
sessionId,
stream: "chat_text",
chunk: "Here is the essay.",
ts: sidecarTs + 1,
index: 2,
});
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
});
const userBubble = current.messages.find(
(message) =>
message.role === "user" && message.content === "write an essay",
);
const assistant = current.messages.find(
(message) => message.role === "assistant" && message.reasoning,
);
expect(userBubble).toBeDefined();
expect(assistant).toBeDefined();
expect(assistant?.createdAt).toBeGreaterThanOrEqual(
userBubble?.createdAt ?? 0,
);
const previous = assistant
? buildPreviousTimestampMap(current.messages).get(assistant)
: undefined;
expect(
getThoughtDurationMilliseconds(previous, assistant?.createdAt ?? 0),
).not.toBeUndefined();
});
it("preserves consecutive queued costs while the preceding turn is persisted", async () => {
type SendResponse = {
ok: true;
@@ -3368,4 +3670,102 @@ describe("coerced-queue first turn vs stale send response", () => {
expect(current.promptsInQueue).toHaveLength(1);
expect(current.promptsInQueue[0]?.prompt).toBe("second prompt");
});
it("keeps rendering the live stream after the sidecar restarts its chunk index", async () => {
const sessionId = "session-sidecar-restart";
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 } | undefined;
if (request?.action === "start") return { sessionId };
}
return [];
},
);
await act(async () => current.start(current.config));
const chatEventHandler = handlerFor("chat_event");
await act(async () => {
chatEventHandler({
sessionId,
stream: "chat_text",
chunk: "before ",
ts: Date.now(),
index: 42,
boot: "boot-a",
});
await new Promise((resolve) => setTimeout(resolve, 60));
});
// A replacement sidecar numbers its chunks from 1 again. Without the
// boot id the high-water mark would swallow the rest of the session.
await act(async () => {
chatEventHandler({
sessionId,
stream: "chat_text",
chunk: "after",
ts: Date.now(),
index: 1,
boot: "boot-b",
});
await new Promise((resolve) => setTimeout(resolve, 60));
});
const assistantText = current.messages
.filter((message) => message.role === "assistant")
.map((message) => message.content)
.join("");
expect(assistantText).toContain("before ");
expect(assistantText).toContain("after");
});
it("still drops a chunk the same sidecar already delivered", async () => {
const sessionId = "session-replayed-chunk";
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 } | undefined;
if (request?.action === "start") return { sessionId };
}
return [];
},
);
await act(async () => current.start(current.config));
const chatEventHandler = handlerFor("chat_event");
await act(async () => {
chatEventHandler({
sessionId,
stream: "chat_text",
chunk: "kept",
ts: Date.now(),
index: 7,
boot: "boot-a",
});
chatEventHandler({
sessionId,
stream: "chat_text",
chunk: "replayed",
ts: Date.now(),
index: 7,
boot: "boot-a",
});
await new Promise((resolve) => setTimeout(resolve, 60));
});
const assistantText = current.messages
.filter((message) => message.role === "assistant")
.map((message) => message.content)
.join("");
expect(assistantText).toContain("kept");
expect(assistantText).not.toContain("replayed");
});
});
@@ -53,6 +53,7 @@ import type {
SessionHistoryItem,
SessionHistoryStatus,
} from "@/lib/session-history";
import { readImportedHistorySummaryActivity } from "@/lib/session-import";
import {
normalizeWorkspacePath,
readWorkspaceSelectionFromWindow,
@@ -158,8 +159,17 @@ function sortMessagesChronologically(messages: ChatMessage[]): ChatMessage[] {
});
}
function chunkCreatedAt(payload: AgentChunkEvent): number {
return payload.ts || Date.now();
// Live rows are stamped on this process's clock, not the sidecar's `ts`.
// Every timestamp a live row is compared against comes from here: the
// optimistic user bubble a send appends, `hydrationStartedAt`,
// `turnStartedAt`, and the preceding row in the thought-duration
// subtraction. The sidecar can run on another machine's clock (the
// browser-dev container, a remote hub), and mixing the two turned a
// finished reasoning row into a durationless "Thinking" whenever that clock
// trailed the browser by more than the time to first token. Persisted rows
// keep the runtime's timestamps and are consistent among themselves.
function chunkCreatedAt(): number {
return Date.now();
}
function mergeHydratedMessagesWithLive(options: {
@@ -378,6 +388,9 @@ export function useChatSession() {
const [activeAssistantMessageId, setActiveAssistantMessageId] = useState<
string | null
>(null);
// Names what the runtime is doing before the first output of a turn (in
// place of "Thinking..."); ephemeral, cleared when the turn moves on.
const [activityLabel, setActivityLabel] = useState<string | null>(null);
const [hydratedHistorySessionId, setHydratedHistorySessionId] = useState<
string | null
>(null);
@@ -408,6 +421,7 @@ export function useChatSession() {
const activeSessionIdRef = useRef<string | null>(null);
const activeAssistantMessageIdRef = useRef<string | null>(null);
const lastStreamIndexBySessionRef = useRef<Record<string, number>>({});
const lastStreamBootBySessionRef = useRef<Record<string, string>>({});
const abortedRef = useRef(false);
const abortFallbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
@@ -518,9 +532,11 @@ export function useChatSession() {
const resetStreamDedupe = useCallback((targetSessionId?: string | null) => {
if (targetSessionId) {
delete lastStreamIndexBySessionRef.current[targetSessionId];
delete lastStreamBootBySessionRef.current[targetSessionId];
return;
}
lastStreamIndexBySessionRef.current = {};
lastStreamBootBySessionRef.current = {};
}, []);
const clearAbortFallbackTimeout = useCallback(() => {
@@ -816,6 +832,21 @@ export function useChatSession() {
if (typeof index !== "number") {
return true;
}
// `index` counts up inside one sidecar process. When the sidecar is
// replaced under a live webview the counter restarts at 1, and without
// this the high-water mark below would silently discard the whole
// stream — user messages and tool rows included — until the new
// process counted past the old run.
const boot = payload.boot;
if (boot !== undefined) {
const previousBoot =
lastStreamBootBySessionRef.current[payload.sessionId];
if (previousBoot !== boot) {
lastStreamBootBySessionRef.current[payload.sessionId] = boot;
lastStreamIndexBySessionRef.current[payload.sessionId] = index;
return true;
}
}
const previous = lastStreamIndexBySessionRef.current[payload.sessionId];
if (previous !== undefined && index <= previous) {
return false;
@@ -1256,7 +1287,7 @@ export function useChatSession() {
sessionId: listeningSessionId,
role: "assistant",
content: "",
createdAt: chunkCreatedAt(payload),
createdAt: chunkCreatedAt(),
});
activeAssistantMessageIdRef.current = assistantId;
setActiveAssistantMessageId(assistantId);
@@ -1280,7 +1311,7 @@ export function useChatSession() {
sessionId: listeningSessionId,
role: "assistant",
content: "",
createdAt: chunkCreatedAt(payload),
createdAt: chunkCreatedAt(),
});
activeAssistantMessageIdRef.current = assistantId;
setActiveAssistantMessageId(assistantId);
@@ -1383,7 +1414,7 @@ export function useChatSession() {
role: "assistant",
content: "",
media: [media.data],
createdAt: chunkCreatedAt(payload),
createdAt: chunkCreatedAt(),
},
]);
});
@@ -1395,6 +1426,7 @@ export function useChatSession() {
if (payload.stream === "chat_queued_prompt_start") {
activeTurnCostTrackerRef.current = { streamedCostUsd: 0 };
turnEpochRef.current += 1;
setActivityLabel(null);
// A new turn starts now: an error remembered from an earlier turn
// must not be attributed to this one if it fails without detail.
delete lastCoreErrorBySessionRef.current[listeningSessionId];
@@ -1514,7 +1546,7 @@ export function useChatSession() {
role: "user",
content: userLabel,
images: images.length > 0 ? images : undefined,
createdAt: chunkCreatedAt(payload),
createdAt: chunkCreatedAt(),
},
]);
});
@@ -1537,6 +1569,18 @@ export function useChatSession() {
lastCoreErrorBySessionRef.current[payload.sessionId] =
parsed.message.trim();
}
// An imported session's history is summarized before the first
// model call; name that wait instead of showing "Thinking...".
const summaryActivity = readImportedHistorySummaryActivity(
parsed.metadata,
);
if (summaryActivity) {
setActivityLabel(
summaryActivity.phase === "started"
? summaryActivity.label
: null,
);
}
} catch {
// Unstructured logs carry no level; nothing to remember.
}
@@ -1573,6 +1617,7 @@ export function useChatSession() {
}
if (payload.stream === "chat_done") {
setActivityLabel(null);
// The turn is over: any optimistic bubble still registered was
// consumed by a direct send and must not be re-keyed by a later
// queued prompt that happens to repeat the same text. Clear
@@ -1660,7 +1705,7 @@ export function useChatSession() {
input: parsed.input,
output: null,
}),
createdAt: chunkCreatedAt(payload),
createdAt: chunkCreatedAt(),
meta: {
toolName,
toolCallId,
@@ -1786,6 +1831,20 @@ export function useChatSession() {
) {
return;
}
// The reverse case: the hub publishes the session record's status
// as soon as the session is created, before the first prompt's
// run starts, so an "idle" that lands while a local submission is
// still in flight predates the run it is about to start. Applying
// it flipped the composer and the request indicator from
// "starting" to idle and back for a frame on every new task. The
// submission owns status until it hands off (the queued-start
// event, or its own completion for a blocking send). Only "idle"
// is held back: a terminal status (failed, aborted) during a
// submission is real and must still unstick the UI even if the
// send response never arrives.
if (nextStatus === "idle" && activePromptSubmissionsRef.current > 0) {
return;
}
authoritativeStatusRevisionRef.current += 1;
setStatus(nextStatus as ChatSessionStatus);
},
@@ -2294,6 +2353,17 @@ export function useChatSession() {
return;
}
// The runtime drains the queue before it answers a blocking send,
// so the next queued prompt's chat_queued_prompt_start can reach
// the webview ahead of this response. That event bumps the epoch
// and its turn owns the transcript from then on: its user bubble
// exists only in live state until the runtime persists it at the
// end of that turn, so replacing the transcript from a canonical
// read here would erase it. The newer turn's own completion
// reconciles history when it ends.
const newerTurnOwnsTranscript = () =>
turnEpochRef.current !== turnEpochAtDispatch;
const result = payload.result as ChatApiResult | undefined;
applyPromptsInQueue(payload.promptsInQueue);
if (settleAbortedSend()) return;
@@ -2308,7 +2378,7 @@ export function useChatSession() {
);
const rawAssistantText = assistantText || fallbackAssistantTurn.text;
const resolvedAssistantText = rawAssistantText;
if (resolvedAssistantText) {
if (resolvedAssistantText && !newerTurnOwnsTranscript()) {
const assistantMessageId =
activeAssistantMessageIdRef.current ?? makeId("assistant");
activeAssistantMessageIdRef.current = assistantMessageId;
@@ -2421,7 +2491,7 @@ export function useChatSession() {
});
}
const fallbackMedia = fallbackAssistantTurn.media;
if (fallbackMedia.length > 0) {
if (fallbackMedia.length > 0 && !newerTurnOwnsTranscript()) {
setMessages((prev) => {
const knownIds = new Set(
prev.flatMap((message) =>
@@ -2452,21 +2522,25 @@ export function useChatSession() {
fallbackAssistantTurn.reasoningRedacted ||
fallbackImages.length > 0 ||
fallbackMedia.length > 0;
if (!hasFallbackAssistantTurn) {
if (!hasFallbackAssistantTurn && !newerTurnOwnsTranscript()) {
// Recovery: load canonical messages if transport missed result text.
try {
const historyMessages = await desktopClient.invoke<ChatMessage[]>(
"read_session_messages",
{ sessionId: activeSessionId, maxMessages: MAX_MESSAGES },
);
if (historyMessages.length > 0) {
if (historyMessages.length > 0 && !newerTurnOwnsTranscript()) {
applyCanonicalHistory(activeSessionId, historyMessages);
}
} catch {
// Keep optimistic state if hydration read fails.
}
}
if (Array.isArray(result?.toolCalls) && result.toolCalls.length > 0) {
if (
Array.isArray(result?.toolCalls) &&
result.toolCalls.length > 0 &&
!newerTurnOwnsTranscript()
) {
materializeToolMessagesFromResult({
sessionId: activeSessionId,
turnStartedAt: now,
@@ -2484,6 +2558,7 @@ export function useChatSession() {
);
if (
historyMessages.length > 0 &&
!newerTurnOwnsTranscript() &&
(!hasFallbackAssistantTurn || hasCanonicalAssistantTurn)
) {
const assistantIndex = historyMessages.findLastIndex(
@@ -2594,6 +2669,14 @@ export function useChatSession() {
Array.isArray(payload.promptsInQueue) &&
payload.promptsInQueue.length > 0;
if (settleAbortedSend()) return;
// A queued prompt that already started its turn owns the status
// and the settled epoch from here: its start set "running", and
// its own completion settles it. Settling this turn on top of it
// flipped the composer to "completed" while the reply was still
// pending (no request indicator at all) and marked the new epoch
// settled, so the hub's "running" for that turn then read as
// stale and was dropped.
const newerTurnOwnsStatus = newerTurnOwnsTranscript();
if (result?.finishReason === "error") {
// On a failed run result.text is the runtime's error string
// (never assistant content — see isErrorResult above), so it
@@ -2607,11 +2690,17 @@ export function useChatSession() {
activeSessionId,
runError || toolError?.trim() || "",
);
turnSettledEpochRef.current = turnEpochRef.current;
setStatus("failed");
if (!newerTurnOwnsStatus) {
turnSettledEpochRef.current = turnEpochRef.current;
setStatus("failed");
}
} else if (result?.finishReason === "aborted") {
turnSettledEpochRef.current = turnEpochRef.current;
setStatus("cancelled");
if (!newerTurnOwnsStatus) {
turnSettledEpochRef.current = turnEpochRef.current;
setStatus("cancelled");
}
} else if (newerTurnOwnsStatus) {
// Leave status to the turn in flight.
} else if (hasQueuedFollowUps) {
setStatus("running");
} else {
@@ -2629,7 +2718,10 @@ export function useChatSession() {
setErrorState(errorMessage(err), activeSessionId);
} finally {
clearAbortFallbackTimeout();
if (!shouldQueue) {
// If a queued prompt already started its turn, these refs are that
// turn's now (chat_queued_prompt_start reset them for it); clearing
// them here would split its streaming assistant bubble.
if (!shouldQueue && turnEpochRef.current === turnEpochAtDispatch) {
activeAssistantMessageIdRef.current = null;
setActiveAssistantMessageId(null);
clearLiveToolRefs();
@@ -2862,6 +2954,7 @@ export function useChatSession() {
lastCoreErrorBySessionRef.current = {};
activeAssistantMessageIdRef.current = null;
setActiveAssistantMessageId(null);
setActivityLabel(null);
setHydratedHistorySessionId(null);
setPendingToolApprovals([]);
setPendingAskQuestions([]);
@@ -2906,6 +2999,7 @@ export function useChatSession() {
activeSessionIdRef.current = session.sessionId;
activeAssistantMessageIdRef.current = null;
setActiveAssistantMessageId(null);
setActivityLabel(null);
// A freshly hydrated session has no local turn in flight; without
// this the mount defaults (epoch 0, settled -1) read as an open
// turn and keep the stale-stream fallback inert forever.
@@ -3178,6 +3272,7 @@ export function useChatSession() {
chatTransportError,
isHydratingSession,
activeAssistantMessageId,
activityLabel,
config,
messages,
rawTranscript,
@@ -0,0 +1,24 @@
import { useEffect, useState } from "react";
import { desktopClient } from "@/lib/desktop-client";
/**
* Device sign-in confirmation code pushed by the sidecar while a provider
* OAuth login is pending, so the user can match it against the code shown in
* their browser. Cleared whenever the pending flow ends.
*/
export function useOAuthUserCode(pending: boolean): string | null {
const [userCode, setUserCode] = useState<string | null>(null);
useEffect(() => {
if (!pending) {
setUserCode(null);
return;
}
return desktopClient.subscribe("provider_oauth_user_code", (payload) => {
const code = (payload as { userCode?: unknown } | null)?.userCode;
if (typeof code === "string" && code) {
setUserCode(code);
}
});
}, [pending]);
return userCode;
}
@@ -549,3 +549,371 @@ describe("useSessionHistory complete history loading", () => {
expect(current.mayHaveMoreSessions).toBe(false);
});
});
describe("useSessionHistory usage hydration", () => {
// Distinct timestamps so the sorted list is session-0, session-1, ... and
// index-based assertions read naturally.
function usageRow(index: number) {
const startedAt = new Date(
Date.UTC(2026, 6, 20, 10, 0, 0) - index * 60_000,
);
return {
...sessionRow(`session-${index}`),
startedAt: startedAt.toISOString(),
endedAt: new Date(startedAt.getTime() + 30_000).toISOString(),
};
}
function usageMessages(inputTokens: number) {
return [
{
id: "m1",
role: "assistant",
content: "done",
meta: { inputTokens, outputTokens: 5, totalCost: 0.01 },
},
];
}
type ReadArgs = { limit?: number; sessionId?: string; maxMessages?: number };
/**
* Routes the usage reads (1200 messages) to the test. The 80-message
* title/status reads get a real assistant turn back so they do not flip
* statuses to "idle" and retrigger usage reads for those rows.
*/
function mockUsageReads(
onUsageRead: (sessionId: string) => unknown[] | Promise<unknown[]>,
) {
invokeMock.mockImplementation(async (command: string, args?: ReadArgs) => {
if (command === "list_discovered_sessions") {
return await new Promise<unknown[]>((resolve, reject) => {
pendingLists.push({ limit: args?.limit ?? 0, resolve, reject });
});
}
if (command === "read_session_messages") {
if (args?.maxMessages === 1200) {
return await onUsageRead(args?.sessionId ?? "");
}
return usageMessages(0);
}
return [];
});
}
/** Lets the invoke → summarize → setThreads → finally → pump chain settle. */
async function settle() {
for (let i = 0; i < 8; i += 1) {
await flush();
}
}
async function renderWithRows(count: number) {
await act(async () => {
root.render(<HookHarness />);
});
await flush();
await act(async () => {
pendingLists[0].resolve(
Array.from({ length: count }, (_, index) => usageRow(index)),
);
await Promise.resolve();
});
}
it("hydrates usage for the first ten inactive sessions, not just four", async () => {
const usageReads: string[] = [];
mockUsageReads((sessionId) => {
usageReads.push(sessionId);
return usageMessages(100);
});
await renderWithRows(12);
expect(current.threads.map((thread) => thread.id)).toEqual(
Array.from({ length: 12 }, (_, index) => `session-${index}`),
);
await flush(800);
await settle();
expect(usageReads).toHaveLength(10);
expect(usageReads).not.toContain("session-10");
expect(usageReads).not.toContain("session-11");
expect(current.threads[0]).toMatchObject({
inputTokens: 100,
outputTokens: 5,
totalCostUsd: 0.01,
});
expect(current.threads[9]).toMatchObject({ inputTokens: 100 });
expect(current.threads[10].inputTokens).toBeUndefined();
expect(current.threads[11].inputTokens).toBeUndefined();
});
it("hydrates usage on demand for sessions a view asks for", async () => {
const usageReads: string[] = [];
mockUsageReads((sessionId) => {
usageReads.push(sessionId);
return usageMessages(7);
});
await renderWithRows(12);
await flush(800);
await settle();
expect(usageReads).toHaveLength(10);
// The second page comes into view: only the rows it asks for are read.
await act(async () => {
current.requestUsage(["session-11", " ", "not-a-session"]);
});
await flush(800);
await settle();
expect(usageReads).toHaveLength(11);
expect(usageReads).toContain("session-11");
expect(usageReads).not.toContain("session-10");
expect(current.threads[11]).toMatchObject({ inputTokens: 7 });
expect(current.threads[10].inputTokens).toBeUndefined();
// Asking again for rows that already have usage is a no-op.
await act(async () => {
current.requestUsage(["session-0", "session-11"]);
});
await flush(800);
await settle();
expect(usageReads).toHaveLength(11);
});
it("reads at most four transcripts at a time", async () => {
const pendingReads: Array<(rows: unknown[]) => void> = [];
mockUsageReads(
() =>
new Promise<unknown[]>((resolve) => {
pendingReads.push(resolve);
}),
);
await renderWithRows(12);
await flush(800);
await settle();
expect(pendingReads).toHaveLength(4);
// Finishing one read lets exactly one more start.
await act(async () => {
pendingReads[0](usageMessages(1));
});
await settle();
expect(pendingReads).toHaveLength(5);
// Draining the rest works through the whole window and no further.
let resolved = 1;
for (
let round = 0;
round < 6 && resolved < pendingReads.length;
round += 1
) {
const batch = pendingReads.slice(resolved);
resolved = pendingReads.length;
await act(async () => {
for (const resolve of batch) {
resolve(usageMessages(1));
}
});
await settle();
}
expect(pendingReads).toHaveLength(10);
expect(
current.threads.filter((thread) => thread.inputTokens === 1),
).toHaveLength(10);
});
it("keeps the four-read cap when the effect restarts mid-flight", async () => {
const pendingReads: Array<(rows: unknown[]) => void> = [];
const readIds: string[] = [];
mockUsageReads((sessionId) => {
readIds.push(sessionId);
return new Promise<unknown[]>((resolve) => {
pendingReads.push(resolve);
});
});
await renderWithRows(12);
await flush(800);
await settle();
expect(pendingReads).toHaveLength(4);
// A page request restarts the effect while four reads are pending. The
// restarted run must not add four reads of its own on top of them.
await act(async () => {
current.requestUsage(["session-11"]);
});
await flush(800);
await settle();
expect(pendingReads).toHaveLength(4);
// The restarted run still drains as the earlier reads finish.
await act(async () => {
pendingReads[0](usageMessages(1));
});
await settle();
expect(pendingReads).toHaveLength(5);
let resolved = 1;
for (
let round = 0;
round < 8 && resolved < pendingReads.length;
round += 1
) {
const batch = pendingReads.slice(resolved);
resolved = pendingReads.length;
await act(async () => {
for (const resolve of batch) {
resolve(usageMessages(1));
}
});
await settle();
}
// Ten default rows plus the requested one, each read exactly once; the
// rows the restarted run found in flight were not read again once they
// finished, and session-10 was never asked for.
expect(pendingReads).toHaveLength(11);
expect(new Set(readIds).size).toBe(readIds.length);
expect(readIds).not.toContain("session-10");
expect(current.threads[10].inputTokens).toBeUndefined();
expect(current.threads[11]).toMatchObject({ inputTokens: 1 });
});
it("reads a row again when its status changed while its read was pending", async () => {
const pendingReads: Array<(rows: unknown[]) => void> = [];
const readIds: string[] = [];
mockUsageReads((sessionId) => {
readIds.push(sessionId);
return new Promise<unknown[]>((resolve) => {
pendingReads.push(resolve);
});
});
const rows = Array.from({ length: 12 }, (_, index) => usageRow(index));
const running = { ...rows[3], status: "running", prompt: "long task" };
await act(async () => {
root.render(<HookHarness />);
});
await flush();
await act(async () => {
pendingLists[0].resolve(
rows.map((row, index) => (index === 3 ? running : row)),
);
await Promise.resolve();
});
expect(current.threads[3].status).toBe("running");
await flush(800);
await settle();
expect(readIds).toEqual([
"session-0",
"session-1",
"session-2",
"session-3",
]);
// The periodic poll reports session-3 finished while its read (started
// under "running") is still pending.
await flush(12_000);
await flush();
expect(pendingLists).toHaveLength(2);
await act(async () => {
pendingLists[1].resolve(
rows.map((row, index) =>
index === 3 ? { ...running, status: "completed" } : row,
),
);
await Promise.resolve();
});
expect(current.threads[3].status).toBe("completed");
await flush(800);
await settle();
// Still four in flight: the restarted run neither stacks a second read
// of session-3 on the pending one nor forgets the row.
expect(pendingReads).toHaveLength(4);
// The stale read finishes: session-3 is read again, once, before the
// rows that have not been read at all.
await act(async () => {
pendingReads[3](usageMessages(1));
});
await settle();
expect(readIds.slice(4)).toEqual(["session-3"]);
expect(pendingReads).toHaveLength(5);
await act(async () => {
pendingReads[4](usageMessages(2));
});
await settle();
expect(current.threads[3]).toMatchObject({ inputTokens: 2 });
});
it("stops re-reading a running row once the view no longer asks for it", async () => {
const readIds: string[] = [];
mockUsageReads((sessionId) => {
readIds.push(sessionId);
return usageMessages(3);
});
const rows = Array.from({ length: 12 }, (_, index) => usageRow(index));
const running = { ...rows[11], status: "running", prompt: "still going" };
// Each refresh changes session-0's prompt so the list is not equivalent
// to the previous one and the hydration effect restarts.
const listRows = (marker: string) =>
rows.map((row, index) => {
if (index === 11) return running;
if (index === 0) return { ...row, prompt: marker };
return row;
});
const readsOfRunning = () =>
readIds.filter((sessionId) => sessionId === "session-11").length;
await act(async () => {
root.render(<HookHarness />);
});
await flush();
await act(async () => {
pendingLists[0].resolve(listRows("first"));
await Promise.resolve();
});
await flush(800);
await settle();
expect(readIds).toHaveLength(10);
expect(readsOfRunning()).toBe(0);
await act(async () => {
current.requestUsage(["session-11"]);
});
await flush(800);
await settle();
expect(readsOfRunning()).toBe(1);
// While a view shows the running row, a refresh re-reads it.
await flush(12_000);
await flush();
await act(async () => {
pendingLists[1].resolve(listRows("second"));
await Promise.resolve();
});
await flush(800);
await settle();
expect(readsOfRunning()).toBe(2);
// The view pages away or unmounts: the next refresh leaves it alone,
// and the completed rows it already hydrated are not read again either.
await act(async () => {
current.requestUsage([]);
});
await flush(12_000);
await flush();
await act(async () => {
pendingLists[2].resolve(listRows("third"));
await Promise.resolve();
});
await flush(800);
await settle();
expect(readsOfRunning()).toBe(2);
expect(readIds).toHaveLength(12);
});
});
@@ -75,6 +75,12 @@ type SessionMessage = {
meta?: SessionMessageMeta;
};
type SessionUsage = {
inputTokens: number;
outputTokens: number;
totalCostUsd: number;
};
type SessionTitleUpdatedEvent = CustomEvent<{
sessionId: string;
title: string;
@@ -113,6 +119,15 @@ export type UseSessionHistoryOptions = {
// 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;
// Discovery rows carry no token or cost totals; those are summed from each
// session's transcript in a second round trip. Only the rows that are on
// screen get that read: the first page of the sessions view (and the
// sidebar's first threads) by default, plus whatever page a view asks for
// via requestUsage as the user moves through older sessions.
const USAGE_HYDRATION_WINDOW = 10;
// Each usage read parses a whole transcript in the sidecar, so a page of rows
// is drained a few at a time instead of all at once.
const MAX_CONCURRENT_USAGE_FETCHES = 4;
const HISTORY_REFRESH_INTERVAL_MS = 12_000;
const MIN_EVENT_HISTORY_REFRESH_INTERVAL_MS = 2_000;
const HISTORY_EVENT_REFRESH_DELAY_MS = 1_000;
@@ -541,6 +556,14 @@ export function useSessionHistory({
const [unreadSessionIds, setUnreadSessionIds] = useState<Set<string>>(
() => new Set(),
);
// Rows a view currently has on screen beyond the default window (the
// sessions view reports its visible page and clears it on unmount). Each
// call replaces the previous set rather than adding to it, so the set is
// bounded by one page and a running session the user has paged away from
// is not re-read on every refresh.
const [requestedUsageIds, setRequestedUsageIds] = useState<Set<string>>(
() => new Set(),
);
// Sessions that schedule executions report as their own, keyed by session
// id. Scheduled runs executed by the local hub do not reliably carry the
// "hub-schedule" origin trigger in their session metadata (the runtime
@@ -557,7 +580,19 @@ export function useSessionHistory({
// may itself name a batch that was never fetched.
const loadedLimitRef = useRef(0);
const mayHaveMoreSessionsRef = useRef(false);
const usageLoadingRef = useRef<Set<string>>(new Set());
// Every usage read in flight, across effect runs, with the status the read
// was started under. Its size is the gauge the concurrency cap is enforced
// on; the status tells a restarted run whether the pending read already
// covers the row's current status or the row must be read again after it.
const usageLoadingRef = useRef<Map<string, SessionHistoryStatus>>(new Map());
// Queue drainer of the current hydration run. Finished reads call it so a
// freed slot goes to the newest run, not to the run that started the read.
const usagePumpRef = useRef<(() => void) | null>(null);
// Usage each row was last hydrated with, written the moment a read settles.
// threadsRef only catches up after React commits, so the queue consults
// this to tell "hydrated" from "not yet" without a stale window in between.
// Refreshes also rebuild threads from it, so a row keeps its totals.
const usageByIdRef = useRef<Map<string, SessionUsage>>(new Map());
const usageHydratedStatusRef = useRef<Map<string, SessionHistoryStatus>>(
new Map(),
);
@@ -762,16 +797,6 @@ export function useSessionHistory({
const existingById = new Map(
current.map((thread) => [thread.id, thread]),
);
const usageById = new Map(
current.map((thread) => [
thread.id,
{
inputTokens: thread.inputTokens,
outputTokens: thread.outputTokens,
totalCostUsd: thread.totalCostUsd,
},
]),
);
const next = mapped.map((thread) => {
const existing = existingById.get(thread.id);
const incomingMetadataTitle = metadataTitleById.get(thread.id);
@@ -783,7 +808,7 @@ export function useSessionHistory({
...thread,
title:
keepExistingTitle && existing ? existing.title : thread.title,
...usageById.get(thread.id),
...usageByIdRef.current.get(thread.id),
};
});
return areThreadsEquivalent(current, next) ? current : next;
@@ -882,45 +907,82 @@ export function useSessionHistory({
};
}, [scheduleRefresh]);
const requestUsage = useCallback((sessionIds: readonly string[]) => {
setRequestedUsageIds((current) => {
const next = new Set<string>();
for (const raw of sessionIds) {
const sessionId = raw?.trim();
if (sessionId) {
next.add(sessionId);
}
}
// Same members, same instance: the sessions view re-reports its
// page on every threads change, and a fresh Set would restart the
// hydration effect (and its 800ms delay) each time a row filled in.
if (
next.size === current.size &&
[...next].every((sessionId) => current.has(sessionId))
) {
return current;
}
return next;
});
}, []);
useEffect(() => {
const recent = sessions
.filter((session) => session.sessionId !== activeSessionId)
.slice(0, 4);
// The active session is skipped: its transcript is still being written
// and the chat tracks its usage live.
const inactiveSessions = sessions.filter(
(session) => session.sessionId !== activeSessionId,
);
const targets = inactiveSessions.slice(0, USAGE_HYDRATION_WINDOW);
if (requestedUsageIds.size > 0) {
const queued = new Set(targets.map((session) => session.sessionId));
for (const session of inactiveSessions) {
if (
requestedUsageIds.has(session.sessionId) &&
!queued.has(session.sessionId)
) {
targets.push(session);
queued.add(session.sessionId);
}
}
}
let cancelled = false;
const timer = window.setTimeout(() => {
for (const session of recent) {
if (cancelled) {
return;
}
// "fetch": the row was never hydrated, is running (its totals keep
// moving), or was hydrated under a different status.
// "defer": a read is in flight, but it was started under a different
// status than the row has now, so its result will already be stale;
// keep the row queued and read it again once that read finishes.
// "skip": nothing to do, drop the row from this run's queue.
const usageFetchVerdict = (
session: SessionHistoryItem,
): "fetch" | "defer" | "skip" => {
const sessionId = session.sessionId;
if (!sessionId) {
continue;
return "skip";
}
if (usageLoadingRef.current.has(sessionId)) {
continue;
const inFlightStatus = usageLoadingRef.current.get(sessionId);
if (inFlightStatus !== undefined) {
return inFlightStatus === session.status ? "skip" : "defer";
}
const existing = threadsRef.current.find(
(item) => item.id === sessionId,
);
const hasUsage =
existing?.inputTokens !== undefined ||
existing?.outputTokens !== undefined;
const lastHydratedStatus =
usageHydratedStatusRef.current.get(sessionId);
const shouldFetch =
!hasUsage ||
const needsFetch =
!usageByIdRef.current.has(sessionId) ||
session.status === "running" ||
lastHydratedStatus !== session.status;
if (!shouldFetch) {
continue;
}
usageLoadingRef.current.add(sessionId);
usageHydratedStatusRef.current.get(sessionId) !== session.status;
return needsFetch ? "fetch" : "skip";
};
const startUsageFetch = (session: SessionHistoryItem): void => {
const sessionId = session.sessionId;
usageLoadingRef.current.set(sessionId, session.status);
void desktopClient
.invoke<SessionMessage[]>("read_session_messages", {
sessionId,
maxMessages: 1200,
})
.then(async (sessionMessages) => {
.then(async (sessionMessages): Promise<SessionUsage> => {
const usage = summarizeUsageFromMessages(sessionMessages);
if (!usage) {
const events = await desktopClient.invoke<SessionHookEvent[]>(
@@ -947,52 +1009,101 @@ export function useSessionHistory({
}
return usage;
})
.then(({ inputTokens, outputTokens, totalCostUsd }) => {
.then((usage) => {
usageByIdRef.current.set(sessionId, usage);
setThreads((current) =>
updateThreadById(current, sessionId, (thread) => {
if (
thread.inputTokens === inputTokens &&
thread.outputTokens === outputTokens &&
thread.totalCostUsd === totalCostUsd
thread.inputTokens === usage.inputTokens &&
thread.outputTokens === usage.outputTokens &&
thread.totalCostUsd === usage.totalCostUsd
) {
return thread;
}
return { ...thread, inputTokens, outputTokens, totalCostUsd };
return { ...thread, ...usage };
}),
);
})
.catch(() => {
if (!hasUsage) {
setThreads((current) =>
updateThreadById(current, sessionId, (thread) => {
if (
thread.inputTokens === 0 &&
thread.outputTokens === 0 &&
(thread.totalCostUsd ?? 0) === 0
) {
return thread;
}
return {
...thread,
inputTokens: 0,
outputTokens: 0,
totalCostUsd: 0,
};
}),
);
// A failed read on a row that was never hydrated is recorded
// as zero usage so the row is not retried on every pass; a
// later status change reads it again. A row that already has
// totals keeps them.
if (usageByIdRef.current.has(sessionId)) {
return;
}
const usage: SessionUsage = {
inputTokens: 0,
outputTokens: 0,
totalCostUsd: 0,
};
usageByIdRef.current.set(sessionId, usage);
setThreads((current) =>
updateThreadById(current, sessionId, (thread) => {
if (
thread.inputTokens === 0 &&
thread.outputTokens === 0 &&
(thread.totalCostUsd ?? 0) === 0
) {
return thread;
}
return { ...thread, ...usage };
}),
);
})
.finally(() => {
// Record the status the read was started under, not the
// row's current one: if the status moved while the read was
// pending, the mismatch is what makes the row read again.
usageHydratedStatusRef.current.set(sessionId, session.status);
usageLoadingRef.current.delete(sessionId);
// Hand the freed slot to whichever run is current: this
// run may have been replaced while the read was pending.
usagePumpRef.current?.();
});
}
};
// The cap is checked against usageLoadingRef, which counts reads in
// flight across effect runs, not against a per-run counter. A refresh
// or page change restarts this effect while reads are still pending;
// a per-run counter would start at zero and let the new run add four
// more on top of them. Each pass walks the whole queue: rows that
// need nothing are dropped, rows waiting on a pending read that will
// be stale are kept for the next pass, and the rest start as slots
// allow, in order.
const queue = [...targets];
const pump = () => {
if (cancelled) {
return;
}
const remaining: SessionHistoryItem[] = [];
for (const session of queue) {
const verdict = usageFetchVerdict(session);
if (verdict === "skip") {
continue;
}
if (
verdict === "defer" ||
usageLoadingRef.current.size >= MAX_CONCURRENT_USAGE_FETCHES
) {
remaining.push(session);
continue;
}
startUsageFetch(session);
}
queue.splice(0, queue.length, ...remaining);
};
usagePumpRef.current = pump;
pump();
}, 800);
return () => {
// Rows still queued are picked up again by the next run; reads
// already in flight finish, land on their threads, and pump the run
// that is current by then.
cancelled = true;
window.clearTimeout(timer);
};
}, [activeSessionId, sessions]);
}, [activeSessionId, requestedUsageIds, sessions]);
useEffect(() => {
const handleTitleUpdated = (event: Event) => {
@@ -1025,9 +1136,12 @@ export function useSessionHistory({
if (!sessionId) {
return;
}
usageLoadingRef.current.delete(sessionId);
// usageLoadingRef is left to the pending read's own finally: it is
// the in-flight gauge for the read cap, so freeing the slot here would
// let a fifth read start while the deleted row's read is still running.
titleLoadingRef.current.delete(sessionId);
usageHydratedStatusRef.current.delete(sessionId);
usageByIdRef.current.delete(sessionId);
messageHydratedStatusRef.current.delete(sessionId);
setSessions((current) =>
current.filter((session) => session.sessionId !== sessionId),
@@ -1617,6 +1731,7 @@ export function useSessionHistory({
pendingAction,
refreshSessions,
renameThread,
requestUsage,
setThreadPinned,
deleteThread,
forkThread,
@@ -14,6 +14,7 @@ vi.mock("@/lib/desktop-client", () => ({
import {
APP_ICON_STORAGE_KEY,
appIconAssetPath,
appIconSurface,
DEFAULT_APP_ICON,
isAppIconId,
readStoredAppIcon,
@@ -44,6 +45,14 @@ describe("app icon", () => {
expect(isAppIconId("bogus")).toBe(false);
});
it.each([
["Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "Taskbar"],
["Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", "Dock"],
["Mozilla/5.0 (X11; Linux x86_64)", "desktop"],
])("names the app icon surface for %s", (userAgent, surface) => {
expect(appIconSurface(userAgent)).toBe(surface);
});
it.each([
["sunrise", "hologram"],
["steel", "midnight"],
@@ -72,6 +81,24 @@ describe("app icon", () => {
expect(invoke).toHaveBeenCalledWith("set_app_icon", { icon: "midnight" });
});
it("does not persist a native selection that fails to apply", async () => {
isTauriAvailable.mockReturnValue(true);
invoke.mockRejectedValue(new Error("native update failed"));
await expect(setStoredAppIcon("classic")).rejects.toThrow(
"native update failed",
);
expect(window.localStorage.getItem(APP_ICON_STORAGE_KEY)).toBeNull();
});
it("leaves the favicon unchanged when native application fails", async () => {
isTauriAvailable.mockReturnValue(true);
invoke.mockRejectedValue(new Error("native update failed"));
await expect(setStoredAppIcon("classic")).rejects.toThrow();
expect(document.querySelector('link[rel="icon"]')).toBeNull();
});
it("re-applies only non-bundled choices at boot", async () => {
isTauriAvailable.mockReturnValue(true);
invoke.mockResolvedValue(true);
@@ -5,7 +5,7 @@ export const APP_ICON_STORAGE_KEY = "cline.code.app-icon.v1";
/**
* App icon variants selectable in Settings. "midnight" is the icon bundled
* with the app; the others live in webview/public/app-icons (picker +
* browser favicon) and src-tauri/icons/dock (runtime dock icon resources).
* browser favicon) and src-tauri/icons/app (runtime app icon resources).
*/
export const APP_ICONS = [
{ id: "classic", label: "Classic" },
@@ -40,6 +40,14 @@ export function appIconAssetPath(icon: AppIconId): string {
return `/app-icons/${icon}.png`;
}
export function appIconSurface(
userAgent: string,
): "Dock" | "Taskbar" | "desktop" {
if (/Windows/i.test(userAgent)) return "Taskbar";
if (/(Macintosh|Mac OS X)/i.test(userAgent)) return "Dock";
return "desktop";
}
export function readStoredAppIcon(): AppIconId {
try {
const stored = window.localStorage.getItem(APP_ICON_STORAGE_KEY);
@@ -65,30 +73,29 @@ function applyFavicon(icon: AppIconId): void {
}
/**
* Applies the icon to whatever this runtime can control: the macOS dock
* Applies the icon to whatever this runtime can control: the Dock or taskbar
* icon in the Tauri shell (native `set_app_icon` command, best-effort) and
* the favicon in browser dev mode so the choice is still visible there.
*/
export async function applyAppIcon(icon: AppIconId): Promise<void> {
applyFavicon(icon);
if (!isTauriAvailable()) {
return;
if (isTauriAvailable()) {
await desktopClient.invoke("set_app_icon", { icon });
}
await desktopClient.invoke("set_app_icon", { icon });
applyFavicon(icon);
}
export async function setStoredAppIcon(icon: AppIconId): Promise<void> {
await applyAppIcon(icon);
try {
window.localStorage.setItem(APP_ICON_STORAGE_KEY, icon);
} catch {
// Selection falls back to default next launch; applying still works.
}
await applyAppIcon(icon);
}
/**
* Re-applies the persisted choice on launch. The dock reverts to the
* bundled icon on every restart, so the app shell calls this once at boot.
* Re-applies the persisted choice on launch. The native app icon reverts to
* the bundled icon on every restart, so the app shell calls this once at boot.
*/
export async function syncAppIcon(): Promise<void> {
const icon = readStoredAppIcon();
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import {
readImportedFromTool,
readImportedHistorySummaryActivity,
} from "./session-import";
describe("readImportedFromTool", () => {
it("reads a known tool off the importedFrom marker", () => {
expect(
readImportedFromTool({ importedFrom: { tool: "codex", sourceId: "x" } }),
).toBe("codex");
});
it("ignores missing, malformed, or unknown markers", () => {
expect(readImportedFromTool(undefined)).toBeUndefined();
expect(readImportedFromTool({ title: "native" })).toBeUndefined();
expect(readImportedFromTool({ importedFrom: "codex" })).toBeUndefined();
expect(
readImportedFromTool({ importedFrom: { tool: "cursor" } }),
).toBeUndefined();
});
});
describe("readImportedHistorySummaryActivity", () => {
it("labels the started notice and clears on completion", () => {
expect(
readImportedHistorySummaryActivity({
kind: "manual_compaction",
phase: "started",
importedFrom: "claude-code",
}),
).toEqual({
phase: "started",
label: "Summarizing the imported Claude Code history...",
});
expect(
readImportedHistorySummaryActivity({
kind: "manual_compaction",
phase: "completed",
importedFrom: "claude-code",
}),
).toEqual({ phase: "finished" });
});
it("ignores compactions that are not imported-history summaries", () => {
expect(
readImportedHistorySummaryActivity({
kind: "auto_compaction",
phase: "started",
}),
).toBeUndefined();
expect(readImportedHistorySummaryActivity(undefined)).toBeUndefined();
});
});
@@ -57,3 +57,58 @@ export interface SessionImportProgressEvent {
export function importSelectionKey(tool: string, sourceId: string): string {
return `${tool}:${sourceId}`;
}
/**
* The external tool a session was imported from, read off the
* `metadata.importedFrom` marker the core import service writes. Forks
* inherit the source session's metadata, so a fork of an imported session
* reports the same tool: its history is still the foreign transcript.
*/
export function readImportedFromTool(
metadata: Record<string, unknown> | null | undefined,
): SessionImportTool | undefined {
const value = metadata?.importedFrom;
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return undefined;
}
return asSessionImportTool((value as { tool?: unknown }).tool);
}
function asSessionImportTool(value: unknown): SessionImportTool | undefined {
return typeof value === "string" &&
(SESSION_IMPORT_TOOL_ORDER as string[]).includes(value)
? (value as SessionImportTool)
: undefined;
}
export type ImportedHistorySummaryActivity =
| { phase: "started"; label: string }
| { phase: "finished" };
/**
* Reads the compaction status notice core emits while it summarizes an
* imported session's history on the first resumed turn (core tags those
* notices with `importedFrom`). The label stands in for the generic
* "Thinking..." indicator while the summary runs; `finished` clears it.
* Other notices return undefined.
*/
export function readImportedHistorySummaryActivity(
metadata: unknown,
): ImportedHistorySummaryActivity | undefined {
if (!metadata || typeof metadata !== "object") return undefined;
const record = metadata as Record<string, unknown>;
const tool = asSessionImportTool(record.importedFrom);
if (!tool) return undefined;
switch (record.phase) {
case "started":
return {
phase: "started",
label: `Summarizing the imported ${SESSION_IMPORT_TOOL_LABELS[tool]} history...`,
};
case "completed":
case "skipped":
return { phase: "finished" };
default:
return undefined;
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "4.1.16",
"version": "4.1.17",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.101.0"
@@ -113,8 +113,9 @@ import { asyncIteratorToCallbacks } from "@/standalone/utils"
import * as niceGrpc from "@generated/nice-grpc/index"
import { StreamingCallbacks } from "@hosts/host-provider-types"
import * as proto from "@shared/proto/index"
import { Channel, createClient } from "nice-grpc"
import { Channel } from "nice-grpc"
import { BaseGrpcClient } from "@/hosts/external/grpc-types"
import { createHostBridgeClient } from "@/hosts/external/host-bridge-auth"
${imports.join("\n")}
@@ -178,7 +179,7 @@ export class ${serviceName}ClientImpl
implements ${serviceName}ClientInterface {
protected createClient(channel: Channel): niceGrpc.host.${serviceName}Client {
return createClient(niceGrpc.host.${serviceName}Definition, channel)
return createHostBridgeClient(niceGrpc.host.${serviceName}Definition, channel)
}
${methods}
@@ -126,6 +126,8 @@ async function generateVscodeProtobusServers(protobusServices) {
const imports = [];
const servers = [];
const serviceMap = [];
const decoders = [];
const decoderMap = [];
const streamingMethods = [];
for (const [serviceName, def] of Object.entries(protobusServices)) {
const domain = getDomainName(serviceName);
@@ -134,23 +136,34 @@ async function generateVscodeProtobusServers(protobusServices) {
servers.push(
`const ${serviceName}Handlers: serviceTypes.${serviceName}Handlers = {`,
);
decoders.push(
`const ${serviceName}RequestDecoders: Record<string, (json: unknown) => unknown> = {`,
);
for (const [rpcName, rpc] of Object.entries(def.service)) {
imports.push(
`import { ${rpcName} } from "@core/controller/${dir}/${rpcName}"`,
);
servers.push(` ${rpcName}: ${rpcName},`);
decoders.push(
` ${rpcName}: ${getFqn(rpc.requestType.type.name)}.fromJSON,`,
);
if (rpc.responseStream) {
streamingMethods.push(` "cline.${serviceName}.${rpcName}",`);
}
}
servers.push(`} \n`);
decoders.push(`} \n`);
serviceMap.push(` "cline.${serviceName}": ${serviceName}Handlers,`);
decoderMap.push(
` "cline.${serviceName}": ${serviceName}RequestDecoders,`,
);
imports.push("");
}
// Create output file
const output = `// GENERATED CODE -- DO NOT EDIT!
// Generated by ${SCRIPT_NAME}
import * as proto from "@shared/proto/index"
import * as serviceTypes from "@generated/hosts/vscode/protobus-service-types"
${imports.join("\n")}
@@ -159,6 +172,18 @@ export const serviceHandlers: Record<string, any> = {
${serviceMap.join("\n")}
}
${decoders.join("\n")}
/**
* Per-method request decoders (proto3 JSON -> ts-proto message) for hosts whose
* transport delivers protobus requests as JSON. Proto3 JSON spells enums as
* string names and omits default-valued fields (empty repeated fields included),
* while the handlers assume ts-proto message shapes: numeric enums, repeated
* fields always present. fromJSON restores those invariants.
*/
export const serviceRequestDecoders: Record<string, Record<string, (json: unknown) => unknown>> = {
${decoderMap.join("\n")}
}
/** Fully-qualified response-streaming methods, derived from the proto descriptors. */
export const responseStreamingMethods: ReadonlySet<string> = new Set([
${streamingMethods.join("\n")}
@@ -0,0 +1,112 @@
import { afterEach, describe, it } from "bun:test"
import { expect } from "chai"
import { Metadata } from "nice-grpc"
import {
captureHostBridgeTokenFromEnvironment,
HOST_BRIDGE_TOKEN_HEADER,
hostBridgeAuthMiddleware,
hostBridgeGrpcMetadata,
} from "../host-bridge-auth"
const originalToken = process.env.CLINE_CORE_CONNECTION_TOKEN
/**
* Runs the same bootstrap step a spawned core does: the host puts the token in
* the environment, startup captures it and scrubs the environment. Tests that
* bypassed this and read env directly missed that the header must come from
* the retained copy after bootstrap the variable is gone.
*/
function setToken(token: string | undefined) {
if (token === undefined) {
delete process.env.CLINE_CORE_CONNECTION_TOKEN
} else {
process.env.CLINE_CORE_CONNECTION_TOKEN = token
}
captureHostBridgeTokenFromEnvironment()
}
/** Drives the middleware and returns the options it passed downstream. */
async function runMiddleware(options: Record<string, unknown>): Promise<any> {
let forwarded: any
const call: any = {
request: { some: "request" },
requestStream: false,
responseStream: false,
next: async function* (_request: unknown, nextOptions: unknown) {
forwarded = nextOptions
return "response"
},
}
const iterator = (hostBridgeAuthMiddleware as any)(call, options)
await iterator.next()
return forwarded
}
describe("host bridge auth", () => {
afterEach(() => setToken(originalToken))
it("attaches the spawn token to outgoing calls", async () => {
setToken("spawn-token-1")
const forwarded = await runMiddleware({ metadata: Metadata() })
expect(forwarded.metadata.get(HOST_BRIDGE_TOKEN_HEADER)).to.equal("spawn-token-1")
})
it("preserves caller-supplied metadata", async () => {
setToken("spawn-token-1")
const forwarded = await runMiddleware({ metadata: Metadata({ "x-existing": "kept" }) })
expect(forwarded.metadata.get("x-existing")).to.equal("kept")
expect(forwarded.metadata.get(HOST_BRIDGE_TOKEN_HEADER)).to.equal("spawn-token-1")
})
it("sends no header when the core was spawned without a token", async () => {
setToken(undefined)
const forwarded = await runMiddleware({ metadata: Metadata() })
expect(forwarded.metadata.has(HOST_BRIDGE_TOKEN_HEADER)).to.equal(false)
})
it("keeps sending the token after bootstrap scrubbed it from the environment", async () => {
setToken("spawn-token-1")
expect(process.env.CLINE_CORE_CONNECTION_TOKEN).to.equal(undefined)
const forwarded = await runMiddleware({ metadata: Metadata() })
expect(forwarded.metadata.get(HOST_BRIDGE_TOKEN_HEADER)).to.equal("spawn-token-1")
})
describe("capture", () => {
it("returns the token and removes it from the environment", () => {
process.env.CLINE_CORE_CONNECTION_TOKEN = "spawn-token-3"
const captured = captureHostBridgeTokenFromEnvironment()
expect(captured).to.equal("spawn-token-3")
expect(process.env.CLINE_CORE_CONNECTION_TOKEN).to.equal(undefined)
})
it("treats an empty variable as no token", () => {
process.env.CLINE_CORE_CONNECTION_TOKEN = ""
expect(captureHostBridgeTokenFromEnvironment()).to.equal(undefined)
})
})
describe("grpc-js metadata", () => {
it("carries the token for hand-written clients", () => {
setToken("spawn-token-2")
expect(hostBridgeGrpcMetadata().get(HOST_BRIDGE_TOKEN_HEADER)).to.deep.equal(["spawn-token-2"])
})
it("is empty without a token", () => {
setToken(undefined)
expect(hostBridgeGrpcMetadata().get(HOST_BRIDGE_TOKEN_HEADER)).to.deep.equal([])
})
})
})
@@ -0,0 +1,116 @@
import { afterEach, beforeEach, describe, it } from "bun:test"
import { EnvServiceClientImpl } from "@generated/hosts/standalone/host-bridge-clients"
import * as niceGrpc from "@generated/nice-grpc/index"
import * as proto from "@shared/proto/index"
import { expect } from "chai"
import { createServer, type Server } from "nice-grpc"
import { captureHostBridgeTokenFromEnvironment, HOST_BRIDGE_TOKEN_HEADER } from "../host-bridge-auth"
type SeenCall = { method: string; token: string | undefined }
/**
* End-to-end cover for the wiring the middleware unit tests cannot see: the
* generator has to emit `createHostBridgeClient` into every generated client,
* and that factory has to put the token on the wire. Asserting against a real
* server means a regression in the generator template or in the factory
* fails here instead of silently shipping unauthenticated calls.
*
* Tokens enter the way they do in production set in the environment by the
* host, then captured and scrubbed by the same bootstrap function cline-core
* runs so this also covers the startup-to-receiver path, not just the
* middleware in isolation.
*
* EnvService stands in for all of them: the clients are generated from one
* template, so the wiring is identical per service.
*/
describe("generated host bridge clients (end to end)", () => {
const originalToken = process.env.CLINE_CORE_CONNECTION_TOKEN
let server: Server
let address: string
let seen: SeenCall[]
beforeEach(async () => {
seen = []
server = createServer()
server.add(
niceGrpc.host.EnvServiceDefinition,
recordingEnvService((call) => seen.push(call)),
)
const port = await server.listen("127.0.0.1:0")
address = `127.0.0.1:${port}`
})
afterEach(async () => {
setToken(originalToken)
await server.forceShutdown()
})
it("sends the spawn token on unary calls, after bootstrap scrubbed it from the environment", async () => {
setToken("e2e-token")
// The startup-to-receiver path: by the time any bridge call is made the
// variable is gone, so the header can only come from the retained copy.
expect(process.env.CLINE_CORE_CONNECTION_TOKEN).to.equal(undefined)
await new EnvServiceClientImpl(address).getHostVersion(proto.cline.EmptyRequest.create({}))
expect(seen).to.deep.equal([{ method: "getHostVersion", token: "e2e-token" }])
})
it("sends the spawn token on streaming calls", async () => {
// Streaming calls pass their own call options (an abort signal), so this
// also pins that the metadata survives that merge.
setToken("e2e-token")
const client = new EnvServiceClientImpl(address)
await new Promise<void>((resolve, reject) => {
client.subscribeToTelemetrySettings(proto.cline.EmptyRequest.create({}), {
onResponse: () => resolve(),
onError: reject,
})
})
expect(seen[0]).to.deep.equal({ method: "subscribeToTelemetrySettings", token: "e2e-token" })
})
it("omits the header when the core was spawned without a token", async () => {
setToken(undefined)
await new EnvServiceClientImpl(address).getHostVersion(proto.cline.EmptyRequest.create({}))
expect(seen).to.deep.equal([{ method: "getHostVersion", token: undefined }])
})
})
/** The bootstrap step a spawned core runs: capture the host's token, scrub the environment. */
function setToken(token: string | undefined) {
if (token === undefined) {
delete process.env.CLINE_CORE_CONNECTION_TOKEN
} else {
process.env.CLINE_CORE_CONNECTION_TOKEN = token
}
captureHostBridgeTokenFromEnvironment()
}
/**
* Implements every EnvService method from the service definition, recording the
* token each call carried. Built from the definition rather than hand-listed so
* new RPCs do not break this test.
*/
function recordingEnvService(record: (call: SeenCall) => void) {
const implementation: Record<string, unknown> = {}
for (const [method, definition] of Object.entries(niceGrpc.host.EnvServiceDefinition.methods)) {
const observe = (context: { metadata: { get(key: string): string | undefined } }) =>
record({ method, token: context.metadata.get(HOST_BRIDGE_TOKEN_HEADER) })
implementation[method] = definition.responseStream
? async function* (_request: unknown, context: any) {
observe(context)
yield {}
}
: async (_request: unknown, context: any) => {
observe(context)
return {}
}
}
return implementation as any
}
+75
View File
@@ -0,0 +1,75 @@
import * as grpc from "@grpc/grpc-js"
import { type Channel, type ClientMiddleware, type CompatServiceDefinition, createClientFactory, Metadata } from "nice-grpc"
/**
* Request header carrying the per-spawn token that identifies this core to the
* host that started it. The name is part of the host<->core contract keep it
* in sync with the host-side interceptor (JetBrains: SessionTokenInterceptor).
*/
export const HOST_BRIDGE_TOKEN_HEADER = "cline-hostbridge-token"
/**
* The Host Bridge listens on loopback with insecure credentials, so binding
* pins *where* it listens but cannot prove *who* is calling: any local process
* or OS user can dial the port and drive the IDE. Echoing the token the host
* generated for this spawn lets the host tell its own core apart from an
* orphaned/foreign one or from unrelated local code.
*
* This reuses the token already issued for the core connection stream
* (CLINE_CORE_CONNECTION_TOKEN) rather than introducing a second secret, so
* there is one credential per spawn with one lifetime.
*
* The token lives here, in process memory, not in `process.env`: bootstrap
* scrubs it from the environment immediately after capture so descendants
* (provider or MCP child processes) can never inherit it, and so it is absent
* when the environment is logged. Read it via [getHostBridgeToken].
*/
let hostBridgeToken: string | undefined
/**
* Captures the per-spawn token from the environment and removes it from
* `process.env`, retaining it only in process memory for the bridge clients.
*
* Call once, first thing at startup before anything logs the environment or
* can spawn a child process. Returns the token so bootstrap can also use it for
* the core connection hello. A core spawned without a token (standalone dev
* runs, older hosts) gets `undefined` and sends no header.
*/
export function captureHostBridgeTokenFromEnvironment(): string | undefined {
hostBridgeToken = process.env.CLINE_CORE_CONNECTION_TOKEN || undefined
delete process.env.CLINE_CORE_CONNECTION_TOKEN
return hostBridgeToken
}
/** The token captured at startup, or `undefined` when this core was spawned without one. */
export function getHostBridgeToken(): string | undefined {
return hostBridgeToken
}
/** Metadata for hand-written `@grpc/grpc-js` clients (health check, core connection stream). */
export function hostBridgeGrpcMetadata(): grpc.Metadata {
const metadata = new grpc.Metadata()
const token = getHostBridgeToken()
if (token) {
metadata.set(HOST_BRIDGE_TOKEN_HEADER, token)
}
return metadata
}
/** Attaches the token to every outgoing call, preserving any caller-supplied metadata. */
export const hostBridgeAuthMiddleware: ClientMiddleware = async function* (call, options) {
const token = getHostBridgeToken()
if (!token) {
return yield* call.next(call.request, options)
}
const metadata = Metadata(options.metadata).set(HOST_BRIDGE_TOKEN_HEADER, token)
return yield* call.next(call.request, { ...options, metadata })
}
/**
* Creates an authenticated Host Bridge client. Used by the generated clients in
* place of nice-grpc's `createClient` see scripts/generate-host-bridge-client.mjs.
*/
export function createHostBridgeClient<Service extends CompatServiceDefinition>(definition: Service, channel: Channel) {
return createClientFactory().use(hostBridgeAuthMiddleware).create(definition, channel)
}
@@ -4,6 +4,7 @@ import { setVscodeHostProviderMock } from "@/test/host-provider-test-utils"
import "should"
import * as sinon from "sinon"
import * as vscode from "vscode"
import * as getLatestOutputModule from "./get-latest-output"
import { VscodeTerminalProcess } from "./VscodeTerminalProcess"
import { TerminalRegistry } from "./VscodeTerminalRegistry"
@@ -641,6 +642,73 @@ describe("TerminalProcess (Integration Tests)", () => {
;(emitSpy as sinon.SinonSpy).calledWith("unobserved_command").should.be.false()
})
it("should report a silent command with the C marker as a success, not a capture failure", async () => {
const terminal = TerminalRegistry.createTerminal().terminal
createdTerminals.push(terminal)
// A command that completes with exit 0 and no output — $null,
// git add -A on a clean tree. The C marker proves the read() stream
// worked, so the emptiness is genuine (GitHub #13272).
const mockExecuteCommand = sandbox.stub().returns({
read: () => createMockStream([OSC633_C, OSC633_D]),
})
sandbox.stub(terminal, "shellIntegration").get(() => ({
executeCommand: mockExecuteCommand,
}))
// If the capture-failure fallback fired, it would read the terminal
// snapshot and emit a "could not be captured" line.
const snapshotStub = sandbox.stub(getLatestOutputModule, "getLatestTerminalOutput").resolves("should-not-be-used")
const emitSpy = sandbox.spy(process, "emit")
const runPromise = process.run(terminal, "$null")
// The mock never fires onDidEndTerminalShellExecution, so the
// exit-code race after the stream ends always times out.
await sandbox.clock.tickAsync(EXIT_CODE_EVENT_TIMEOUT_MS + 1_000)
await runPromise
;(emitSpy as sinon.SinonSpy).calledWith("completed").should.be.true()
;(emitSpy as sinon.SinonSpy).calledWith("continue").should.be.true()
// The only permitted "line" event is the empty start-of-output
// notification the UI spinner relies on — no content, and critically
// no "could not be captured" fallback message, may be emitted.
const lineEvents = (emitSpy as sinon.SinonSpy).args
.filter(([event]) => event === "line")
.map(([, line]) => String(line ?? ""))
lineEvents.every((line) => line === "").should.be.true()
;(emitSpy as sinon.SinonSpy).calledWith("unobserved_command").should.be.false()
;(snapshotStub as sinon.SinonStub).called.should.be.false()
})
it("should still use the terminal snapshot fallback when neither markers nor output arrive", async () => {
const terminal = TerminalRegistry.createTerminal().terminal
createdTerminals.push(terminal)
// No C marker ever arrives, so an empty output cannot be trusted as
// a silent success — the capture-failure fallback must still fire.
stubHangingShellIntegration(terminal, [])
const snapshotStub = sandbox
.stub(getLatestOutputModule, "getLatestTerminalOutput")
.resolves("terminal snapshot content")
const emitSpy = sandbox.spy(process, "emit")
const runPromise = process.run(terminal, "some-command")
// No data ever: the first idle timeout (10s) with prompt-stepped
// idle checks up to the 30s cap. Add slack for the exit-code race.
await sandbox.clock.tickAsync(60_000)
await runPromise
;(emitSpy as sinon.SinonSpy).calledWith("completed").should.be.true()
// The fallback message must reach the agent with the snapshot.
const fallbackLines = (emitSpy as sinon.SinonSpy).args
.filter(([event]) => event === "line")
.map(([, line]) => String(line))
fallbackLines.should.matchAny(/could not be captured through shell integration/)
fallbackLines.should.matchAny(/terminal snapshot content/)
;(snapshotStub as sinon.SinonStub).called.should.be.true()
})
it("should complete when the terminal closes mid-command", async () => {
const terminal = TerminalRegistry.createTerminal().terminal
createdTerminals.push(terminal)
@@ -101,13 +101,12 @@ export class VscodeTerminalProcess extends EventEmitter<TerminalProcessEvents> i
// Text after C is the command's actual output; everything before (prompt,
// command echo) is naturally excluded by the marker.
//
// NOTE: The CommandFinished (D) marker and its exit code do NOT appear in
// the read() stream. VS Code's shell integration addon consumes the D
// sequence synchronously and fires onDidEndTerminalShellExecution (with the
// exit code) before the debounced data event reaches the stream. We listen
// to that event to capture the exit code; the D-marker parsing in the parser
// is kept only to delimit command output segments (see below), not as an
// exit-code source.
// NOTE: VS Code excludes the CommandFinished (D) marker from read().
// On normal completion it flushes buffered data and ends the stream
// before firing onDidEndTerminalShellExecution with the exit code.
// Starting another execution can force the previous end event before
// its stream finishes flushing. We use the event for the exit code;
// D-marker parsing only delimits output segments, not exit codes.
const execution = terminal.shellIntegration.executeCommand(command)
const stream = execution.read()
const parser = new Osc633Parser()
@@ -118,7 +117,7 @@ export class VscodeTerminalProcess extends EventEmitter<TerminalProcessEvents> i
// Listen for the shell execution end event to capture the exit code and
// independently signal completion. The event normally follows the stream,
// but some shells leave read() open after reporting that execution ended.
// but a replacement execution can force the event before the stream ends.
//
// onDidEndTerminalShellExecution has been stable API since VS Code 1.93,
// below our minimum supported version (see package.json engines.vscode), so it is
@@ -405,7 +404,12 @@ export class VscodeTerminalProcess extends EventEmitter<TerminalProcessEvents> i
}
// the command process is finished, let's check the output to see if we need to use the terminal capture fallback
if (!this.fullOutput.trim()) {
// The CommandExecuted (C) marker is parsed out of the same read()
// stream as the output, so when it was seen an empty fullOutput is a
// genuine silent success ($null, git add -A on a clean tree) — the
// stream worked and the command simply printed nothing. Falling back
// there reported silent commands as capture failures (GitHub #13272).
if (!this.fullOutput.trim() && !didSeeCommandExecuted) {
// No output captured via shell integration, trying fallback
telemetryService.captureTerminalOutputFailure(
terminalClosed ? TerminalOutputFailureReason.TERMINAL_CLOSED : TerminalOutputFailureReason.TIMEOUT,
@@ -433,13 +437,15 @@ export class VscodeTerminalProcess extends EventEmitter<TerminalProcessEvents> i
telemetryService.captureTerminalExecution(false, "vscode", "none", fallbackDetails)
}
} else {
// Output was captured, but distinguish *how* it was completed: real
// OSC 633 C/D markers ("shell_integration") vs the idle/prompt
// heuristic fallback ("markerless_heuristic") when markers never
// arrived. Folding the latter into "shell_integration" successes
// would inflate the metric this PR's fixes are evaluated against.
// A terminal closed mid-command is not a success even though some
// output was captured — the command was interrupted.
// Output was captured — or the C marker proved the stream worked
// and the command legitimately printed nothing. Distinguish *how*
// completion was observed: real OSC 633 C/D markers
// ("shell_integration") vs the idle/prompt heuristic fallback
// ("markerless_heuristic") when markers never arrived. Folding the
// latter into "shell_integration" successes would inflate the
// metric this PR's fixes are evaluated against. A terminal closed
// mid-command is not a success even though some output was
// captured — the command was interrupted.
telemetryService.captureTerminalExecution(
!terminalClosed,
"vscode",

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