Compare commits

...
Author SHA1 Message Date
Saoud Rizwan b43fb7777c Merge PR #13069 head (cloud models, prompt dedupe, org scope fixes) into desktop-experimental
# Conflicts:
#	apps/examples/desktop-app/sidecar/chat-session.ts
2026-08-17 18:27:06 -07:00
John Choi 8ea71e3278 fix(desktop): align cloud header status 2026-08-17 18:23:38 -07:00
John Choi f4abb7e14b feat(desktop): load Cline Cloud agent models 2026-08-17 18:16:56 -07:00
John Choi 1e08d8f277 fix(desktop): refresh cloud organization scope 2026-08-17 18:16:49 -07:00
John Choi 8bebcd1f37 Merge remote-tracking branch 'origin/main' into work/pr-13069-four-fixes
# Conflicts:
#	apps/examples/desktop-app/webview/components/agent-sidebar.tsx
2026-08-17 18:07:11 -07:00
John Choi 14f9def7ed fix(desktop): reconcile terminal cloud replies 2026-08-17 17:56:42 -07:00
John Choi 2e6c25785e fix(desktop): dedupe live cloud prompts 2026-08-17 17:41:06 -07:00
Saoud Rizwan b565e5b9ca Merge PR #13069 head (cloud provisioning/handoff fixes) into desktop-experimental
# Conflicts:
#	apps/examples/desktop-app/webview/hooks/use-chat-session.ts
2026-08-17 17:36:14 -07:00
Saoud Rizwan 0313c34b1a chore(desktop): release v0.0.14-beta.1 2026-08-17 17:29:42 -07:00
Saoud Rizwan d0f49330a3 Merge remote-tracking branch 'origin/main' into desktop-experimental
# Conflicts:
#	apps/examples/desktop-app/webview/components/agent-sidebar.tsx
#	apps/examples/desktop-app/webview/components/views/settings/settings-view.tsx
2026-08-17 17:27:25 -07:00
John Choi db31380268 Merge remote-tracking branch 'origin/main' into work/pr-13069-four-fixes 2026-08-17 17:18:21 -07:00
Saoud Rizwan b123f2eb54 Merge remote-tracking branch 'origin/main' into desktop-experimental 2026-08-17 16:21:56 -07:00
John Choi 303722a559 fix(desktop): show prompt during cloud provisioning 2026-08-17 14:34:43 -07:00
John Choi 5fff9cbb4c fix(desktop): preserve prompt during cloud handoff 2026-08-17 12:30:16 -07:00
John Choi 70e0a3731d fix(desktop): harden cloud provisioning recovery 2026-08-17 12:18:18 -07:00
John Choi d52af3112d Merge remote-tracking branch 'origin/pr/13069' into feat/experimental-desktop-pr-bundle
# Conflicts:
#	apps/examples/desktop-app/webview/app/page.tsx
2026-08-14 19:21:03 -07:00
John Choi eccb0db669 fix(desktop): recover cloud provisioning handoff 2026-08-14 19:17:50 -07:00
John Choi a9af97a83f fix(desktop): preserve cloud session routing with SSH environments 2026-08-14 19:16:41 -07:00
John Choi 53388f5f9c Merge remote-tracking branch 'origin/pr/13069' into feat/experimental-desktop-pr-bundle
# Conflicts:
#	apps/examples/desktop-app/sidecar/cloud-sessions.test.ts
#	apps/examples/desktop-app/sidecar/commands.ts
#	apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.tsx
#	apps/examples/desktop-app/webview/components/views/settings/settings-view.tsx
#	apps/examples/desktop-app/webview/hooks/use-chat-session.test.tsx
#	apps/examples/desktop-app/webview/hooks/use-chat-session.ts
2026-08-14 19:00:05 -07:00
John Choi 093e9dd3d5 fix(desktop): harden cloud session creation 2026-08-14 18:54:31 -07:00
John Choi d35f4a043b fix: reconcile experimental desktop feature stack 2026-08-14 18:34:07 -07:00
John Choi bd9ee23f6c Merge origin/main into desktop cloud sessions 2026-08-14 18:32:55 -07:00
John Choi 10ff456e93 Merge remote-tracking branch 'origin/pr/13225' into feat/experimental-desktop-pr-bundle 2026-08-14 17:58:48 -07:00
John Choi cb34ba5cf9 Merge remote-tracking branch 'origin/pr/13031' into feat/experimental-desktop-pr-bundle 2026-08-14 17:58:44 -07:00
John Choi 2ed4b811be Merge remote-tracking branch 'origin/pr/12890' into feat/experimental-desktop-pr-bundle 2026-08-14 17:53:04 -07:00
John Choi 7bf542ddab Merge remote-tracking branch 'origin/pr/13069' into feat/experimental-desktop-pr-bundle
# Conflicts:
#	apps/examples/desktop-app/sidecar/commands.ts
#	apps/examples/desktop-app/sidecar/session-data/messages.test.ts
#	apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.test.tsx
#	apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.tsx
#	apps/examples/desktop-app/webview/components/views/settings/settings-view.tsx
#	apps/examples/desktop-app/webview/hooks/use-chat-session.test.tsx
#	apps/examples/desktop-app/webview/hooks/use-chat-session.ts
2026-08-14 17:43:00 -07:00
abeatrix 1cb61ab925 Merge remote-tracking branch 'origin/main' into bee/agent-voice
# Conflicts:
#	apps/examples/desktop-app/package.json
#	apps/examples/desktop-app/sidecar/commands.ts
#	apps/examples/desktop-app/webview/app/globals.css
#	apps/examples/desktop-app/webview/app/page.tsx
#	apps/examples/desktop-app/webview/components/agent-header.tsx
#	apps/examples/desktop-app/webview/components/agent-sidebar.tsx
#	apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.tsx
#	apps/examples/desktop-app/webview/components/views/chat/chat-messages.test.tsx
#	apps/examples/desktop-app/webview/components/views/chat/chat-messages.tsx
#	apps/examples/desktop-app/webview/components/views/chat/welcome-chat.test.tsx
#	apps/examples/desktop-app/webview/components/views/chat/welcome-chat.tsx
#	apps/examples/desktop-app/webview/components/views/chat/workspace-selector.tsx
#	apps/examples/desktop-app/webview/components/views/settings/provider-list-view.tsx
#	apps/examples/desktop-app/webview/components/views/settings/settings-view.tsx
#	apps/examples/desktop-app/webview/hooks/use-chat-session.test.tsx
#	apps/examples/desktop-app/webview/hooks/use-chat-session.ts
#	apps/examples/desktop-app/webview/lib/desktop-tray.test.ts
#	apps/examples/desktop-app/webview/lib/desktop-tray.ts
#	apps/examples/desktop-app/webview/lib/session-history.ts
#	bun.lock
#	sdk/packages/llms/package.json
#	sdk/packages/llms/src/catalog/catalog.generated.ts
#	sdk/packages/llms/src/providers/providers.generated.ts
#	sdk/packages/shared/src/llms/ai-sdk-format.ts
#	sdk/packages/ui/package.json
2026-08-13 15:54:16 -07:00
John Choi 7bf495878e fix(desktop): harden cloud session lifecycle 2026-08-13 15:52:04 -07:00
Tomás Barreiro 677a5cd915 Merge branch 'main' into add-integrations-onboarding-step 2026-08-13 19:05:59 -03:00
Tomás Barreiro 4524e884a7 Merge branch 'main' into add-integrations-onboarding-step 2026-08-13 15:42:32 -03:00
BarreiroT 75d1d6654b validate domain and fix errors on auth 2026-08-13 14:56:45 -03:00
BarreiroT 45027ee4e3 Add a GitHub integration step to the onboarding 2026-08-13 14:33:14 -03:00
John Choi 7f8fd0b62d fix(desktop): harden cloud session synchronization 2026-08-13 08:16:53 -07:00
John Choi 6df53ddf64 Merge origin/main into desktop cloud sessions 2026-08-13 08:15:59 -07:00
John Choi 243b6fe9d9 Merge remote-tracking branch 'origin/main' into work/pr-13069-four-fixes
# Conflicts:
#	apps/examples/desktop-app/webview/app/page.tsx
#	apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.test.tsx
#	apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.tsx
#	apps/examples/desktop-app/webview/components/views/chat/chat-messages.tsx
#	apps/examples/desktop-app/webview/components/views/chat/welcome-chat.tsx
#	apps/examples/desktop-app/webview/components/views/chat/welcome-workspace-controls.test.tsx
#	apps/examples/desktop-app/webview/components/views/chat/welcome-workspace-controls.tsx
#	apps/examples/desktop-app/webview/hooks/use-chat-session.test.tsx
#	apps/examples/desktop-app/webview/lib/workspace-paths.ts
2026-08-12 09:11:57 -07:00
John Choi edd6e2c29b fix(desktop): close cloud session interaction gaps
Forward image attachments, allow session-scoped model changes, recover queued streaming after abort, and surface relayed run failures.
2026-08-10 16:49:03 -07:00
Saoud Rizwan 1eb28e553e test(desktop): make the pagination-recovery test deterministic
Waiting on rendered text could pass in a stale window between the search
clearing and the base reload effect running, letting the scroll fire a
stale observer whose captured request key mismatched. Drop the captured
observer callback before clearing and wait for the effect to recreate it,
which only happens after the post-clear base list applied.
2026-08-08 18:58:56 +00:00
Saoud Rizwan 42225f9219 refactor(desktop): consolidate cloud-session helpers before review
No behavior changes; review-readiness cleanup of the cloud sessions diff:

- one cloudRepositoryLabel helper in webview/lib/cloud-repositories.ts
  replaces four copies of the owner/repo label parser (sidecar placeholder
  title, repository picker, composer context label, provisioning phases)
- the cloud-provisioning- placeholder id prefix moves behind
  isCloudProvisioningSessionId, shared by the sidecar that mints the ids
  and the webview affordance gates that check them
- the two identical cloud status mappers in use-chat-session collapse into
  mapCloudRuntimeStatus in chat-session/helpers.ts
- attach() and attachExpired() share one attachResultPayload builder
  instead of duplicating the reply literal
- settings-view drops the commented-out PostHog lookup block in favor of a
  short pointer comment
- welcome-chat derives its fallback connect URL from the environment
  config instead of hardcoding production
- refresh the stale claim-set comment in create-recovery to describe the
  post-fix wait-all semantics
2026-08-08 18:58:55 +00:00
Saoud Rizwan da759853ea test(desktop): wait out the post-clear branch reload before paginating again 2026-08-08 05:37:23 +00:00
Saoud Rizwan 6fed0f0d38 style(desktop): format new test fixtures 2026-08-08 05:34:04 +00:00
Saoud Rizwan b456ce73dd fix(desktop): webview polish for cloud sessions from deep review
- humanize cloud error envelopes in the sync-failed banner and the
  rehydration fetch fallback (and scope the fallback to the active session)
- align the recovered-send status mapper with the rehydrated handler:
  cover cancelled, and leave unknown statuses alone instead of flipping a
  running turn to completed
- disable Delete on provisioning placeholders in the sidebar and sessions
  view; the sidecar always rejects it until the create settles
- surface the CLINE_CODE_CLOUD_AGENTS override in Settings when it makes
  the toggle diverge from effective behavior, and load the settings
  sections concurrently
- gate the slash-command menu to local sessions like @-mentions; the
  sandbox cannot resolve local skills/workflows
- stop the model selector from silently 'correcting' a locked cloud
  session's model when its id is missing from the local catalog
2026-08-08 05:32:53 +00:00
Saoud Rizwan 2b9d1c3e6a fix(desktop): harden cloud session lifecycle edges
- reap connections whose session vanished from a successful list (deleted
  remotely or re-scoped): they otherwise redial the dead proxy every ~5s
  forever, with a REST list per attempt, until app restart
- clear desktop-visible state synchronously at the start of dispose() so
  a manager rebuilt mid-dispose (account/credential change) cannot have
  its fresh liveSessions/pendingApprovals entries deleted from under it
- report rehydrated failed runs with the same chat_session_ended reason
  (error) the live run.failed path uses
2026-08-08 05:32:53 +00:00
Saoud Rizwan 82277128aa fix(desktop): only honor Connect GitHub URLs from Cline app origins
The cloud error envelope travels in Error.message and is authenticated by
string prefix only, so error strings a session pod controls (hub command
replies pass through verbatim) could spoof a github_not_connected envelope
whose connectUrl pointed anywhere. The webview rendered that as a trusted
looking Connect GitHub button and open_external_url validates protocol,
not origin. Drop connectUrls whose origin is not a known Cline app base
URL before they reach the action button.
2026-08-08 05:26:59 +00:00
Saoud Rizwan ee4edfeaf6 fix(desktop): keep the cloud setup snapshot fresh across org switches
The stale-selection guard compared repoUrl against a repositoryUrls
snapshot refreshed only on mount, account-id change, focus, or the
onboarding poll (which stops in ready status). An in-app org switch
refreshed none of those, so picking a repository from the new scope's
correctly filtered picker got immediately wiped against the old scope's
list. Route the picker's own loads through the same request-id-guarded
snapshot application, and re-check setup on the sidecar's
cloud_sessions_changed broadcast.
2026-08-08 05:25:00 +00:00
Saoud Rizwan 17b0ad7a76 fix(desktop): close both directions of the create-recovery claim race
Recovery previously waited only for earlier identical peers, so an
earlier create failing fast (any request_failed, including an instant
5xx) could adopt a later in-flight POST's listed session and hand two
composers the same sandbox. Branchless and branch-specific creates also
hashed to different claim keys while the branchless recovery filter
ignores branch, allowing cross-key adoption with no ordering at all.

- key in-flight peers by repo/model/org (branch excluded) so
  branchless recoveries see branch-specific peers
- settle each create's peer entry when its POST settles (never after
  recovery), then make recovery wait for every other in-flight peer in
  both directions, re-snapshotting until stable; waits cannot cycle
- gate recovery on timeout/5xx/no-status failures: a fast 4xx never
  provisioned anything, and recovering on one risks adopting an
  identical-config session created by another device on the account
2026-08-08 05:21:55 +00:00
Saoud Rizwan be8bb302a8 fix(desktop): re-key the first cloud prompt's bubble to the server session id
A cloud create returns a server-assigned session id, but the optimistic
user bubble kept the client-planned id. mergeCloudSnapshotWithLive drops
other-session messages before consulting the optimistic map, so the first
prompt's bubble silently lost its retention semantics: a lagging snapshot
could merge to a transcript with no user prompt, and a failed first send
lost its bubble on the next rehydration.

Also pins the previously untested merge behaviors: the reflected-prompt
budget (zero-budget retention and one-consumption-per-new-copy) and
error-bubble preservation on the unmatched-live drop path.
2026-08-08 05:18:45 +00:00
Saoud Rizwan f4f573c395 fix(desktop): release the branch picker's loading flag when a page fetch goes stale
loadMore reset loadingMore only when the request key still matched. Typing
a search character while a page fetch was in flight changed the key, so
the stale fetch never released the flag and pagination was dead for the
rest of the welcome screen's life (the observer effect and loadMore both
short-circuit on loadingMore). Only one page fetch can be in flight, so
the reset can be unconditional.
2026-08-08 05:16:29 +00:00
Saoud Rizwan e03b4577b4 fix(sdk): close the hub client zombie-connection race around registration
The post-registration continuation was the one mutation window not guarded
by the connect generation: a close() landing after the register reply
resolved but before the continuation ran would mark a closed client
registered. That stale flag then made a later failed registration skip
closing its socket, leaving a permanently unregistered zombie connection
that isConnected() reported healthy.

- generation-guard the continuation so a superseded attempt closes its
  socket and rejects instead of touching shared state
- drop the registered-flag condition from the connect() catch guard; the
  socket identity check alone decides ownership and cannot be poisoned
- keep a stale attempt's late timeout/error/close handlers from clobbering
  lastCloseError and sawSocketClose for a newer attempt
- stop close() from wiping the real connect failure cause when no socket
  was ever opened
2026-08-08 05:15:03 +00:00
Saoud Rizwan 16d36e53f2 Merge branch 'main' into saoudrizwan/desktop-cloud-sessions-9a9b 2026-08-07 21:50:08 -07:00
Saoud Rizwan 74a1910505 Merge branch 'main' into saoudrizwan/desktop-cloud-sessions-9a9b 2026-08-07 20:50:44 -07:00
Saoud Rizwan f3db7a0a98 fix(desktop): order concurrent identical creates so recovery cannot steal an in-flight session
A session is listed the moment the server starts provisioning it, minutes
before its successful POST returns. Timeout recovery now waits for every
earlier identical in-flight create to record its claim before adopting a
listed candidate; later peers wait on earlier ones only, so waits cannot
cycle. Regression test covers the slow-success/fast-failure overlap.
2026-08-08 03:21:07 +00:00
Saoud Rizwan 2b62f7b447 fix(desktop): webview review fixes for cloud sessions
- Emit cloud_session_provisioning_failed from the sidecar and render a
  terminal error pane in an open placeholder thread instead of an
  infinite provisioning spinner.
- Cloud-aware delete confirmations in the sidebar and sessions view (the
  action destroys the remote workspace, not just local history).
- Humanize cloud rename failures instead of showing the raw envelope.
- Preserve UI error bubbles through cloud rehydration merges; ignore
  unknown snapshot statuses instead of flipping a running turn to done.
- Clear a stale repository selection when the account can no longer
  access it so the send gate re-engages.
- Migrate cloud optimistic bookkeeping across queued-prompt re-keys,
  clear cloud refs on reset, session-scope the cloud merge, fix the
  impure provisioning-phase updater, gate rename on provisioning
  placeholder rows, and stop advertising local-only mentions/commands in
  cloud composer placeholders.
2026-08-08 03:03:15 +00:00
Saoud Rizwan 6b8b5726c3 fix(desktop): cloud session lifecycle hardening from deep review
- Reap connections whose sandbox expired (attach, sidebar poll, and
  reconnect-failure paths) so dead sessions stop reconnect-looping and
  spamming sync-failure events; sync failures now notify on transition
  only.
- Tombstone sessions mid-delete so a concurrent attach/send cannot dial a
  fresh connection that outlives the delete; treat remotely-gone sessions
  (404/410) as deletable locally.
- Guard disposed connections against resurrection by late reconnect timers
  and approval responses; purge approvals stored during failed connection
  setup.
- Leave cloud approvals pending on app shutdown instead of denying tool
  calls on pods that outlive the app.
- Use a fresh auth token (with fallback) for create-timeout recovery;
  normalize list rows so one malformed record cannot crash discovery;
  widen the recovery clock-skew window now that claims prevent
  double-adoption.
- Drop the queue-shrink 'prompt started' inference on the hub path (the
  hub emits explicit submitted events; a shrink can also mean removal).
- Reset the transcript baseline on reconnect; answer pendingPrompts with
  [] for sessions with no inner session instead of throwing.
2026-08-08 02:55:37 +00:00
Saoud Rizwan f6d8e54089 fix(desktop): cross-cutting review fixes for cloud sessions
- Use core's canonical getProviderAuthHandler("cline") for the persisted
  token fallback instead of a hand-rolled prefix heuristic that could
  corrupt unprefixed API keys; drop the dead test-only reset export.
- Reset the cloud session manager and broadcast cloud_sessions_changed
  after a cline OAuth login, and broadcast on the save_provider_settings
  (sign-out) reset, so the sidebar re-scopes immediately.
- Log cloud discovery failures instead of silently emptying the sidebar.
- Atomic write-then-rename for the desktop settings file.
- Share the repository/branch wire types between sidecar and webview.
- Add command-layer tests for the settings/flag commands; refresh the
  stale sidecar ARCHITECTURE.md; delete an orphaned comment.
2026-08-08 02:47:00 +00:00
Saoud Rizwan ef09caa3e6 fix(sdk,desktop): harden hub header resolution and onboarding poll
Review findings: bound resolveConnectionHeaders with the connect timeout so
a hung token refresh cannot pin connect() and every deduped caller forever;
record resolver failures in lastCloseError so getConnectionError() reports
the real cause; add a connect-generation token so close() during header
resolution cannot leave a doomed attempt satisfying the next connect();
stop header-auth clients from inheriting registry tokens for loopback URLs;
use the shared extractSessionId in approval.list_pending. Desktop: make the
onboarding poll read status from a ref instead of running side effects in a
state updater, and re-check GitHub connectivity when the account changes.
2026-08-08 02:42:20 +00:00
Saoud Rizwan bdb5f30f5e refactor(desktop,sdk): remove the PostHog flag plumbing cloud sessions no longer use
Reverts the SDK feature-flags service/provider changes and barrel exports to
main, and strips the desktop sidecar's PostHog-backed flag service (context
targeting, cache file, refresh/dispose lifecycle). isCloudAgentsEnabled() is
now just the env override plus the Settings toggle, and get_feature_flags
answers synchronously.
2026-08-08 01:50:57 +00:00
Saoud Rizwan 2fa0be8940 refactor(desktop,sdk): drop the code-cloud-agents flag from the SDK catalog
Cloud sessions are gated by the explicit Settings toggle now, so the SDK no
longer registers the unused PostHog flag. The Settings row is wrapped in a
visibility gate that is hard-wired on, with the future flag lookup left
commented out until the flag actually exists in PostHog.
2026-08-08 01:21:40 +00:00
Saoud Rizwan 2e5a565090 fix(desktop): harden queue snapshot validation and recovery claiming
- applyQueueSnapshot now rejects replies without a prompts array instead of
  publishing an authoritative empty queue from the pending/update/remove
  command paths.
- Timeout-recovery candidate selection and claiming now happen in one
  synchronous helper so the claim can never be separated from the check,
  and the regression test exercises truly concurrent create requests.
2026-08-08 00:24:42 +00:00
Saoud Rizwan eb95908737 fix(desktop): only treat valid pending-prompts replies as authoritative queue snapshots
An unsuccessful or malformed session.pending_prompts reply during
rehydration no longer publishes an empty queue or discards buffered queue
events; the newest buffered queue snapshot is replayed instead.
2026-08-08 00:05:44 +00:00
Saoud Rizwan b091c72e40 fix(desktop): address cloud-session review findings
- Keep the newest buffered queue snapshot when rehydration's queue fetch
  fails instead of silently dropping queued/steered prompts (Greptile P1).
- Claim recovered/created session ids per process so overlapping identical
  create requests cannot adopt the same record and orphan a sandbox
  (Greptile P1).
- Use crypto.randomUUID() for provisioning placeholder ids (CodeQL
  insecure-randomness alerts).
2026-08-07 23:44:05 +00:00
Saoud Rizwan ebf5b52e9b Merge origin/main into saoudrizwan/desktop-cloud-sessions-9a9b
Resolves conflicts with #13028 (native-feel polish and render-path
performance): keep dynamic view imports and the memoized headerDiff from
main while preserving the cloud-session behaviors from this branch (Cloud
icon import, Connect GitHub error-action button in the chat error banner,
and hiding the diff header for cloud sessions).
2026-08-07 23:36:29 +00:00
Saoud Rizwan 13d801b578 fix(desktop): allow renaming cloud sessions from the sessions view
Rename was already supported by the sidecar and offered in the sidebar and
chat header; the sessions view context menu was the odd one out. Includes
biome format fixes picked up in touched files.
2026-08-07 22:52:44 +00:00
Saoud Rizwan 642c2f643b feat(desktop): cloud sessions onboarding panel for GitHub connect flow
When the cloud composer cannot start a session yet (signed out, GitHub not
connected, or the GitHub App has no repository access) replace the composer
with an onboarding panel that explains cloud sessions, walks through the
dashboard hand-off with visual steps, and auto-detects completion via polling
and window-focus refetches. Adds a teaching hint under the ready composer.
2026-08-07 22:52:44 +00:00
Saoud Rizwan 528c6b8ff2 feat(desktop): gate cloud sessions behind an explicit settings toggle
Cloud sessions are in preview, so replace the remote rollout flag with an
opt-in toggle in Settings -> General, persisted in a desktop-owned settings
file (kept out of global-settings.json so older CLI writers cannot strip it).
The CLINE_CODE_CLOUD_AGENTS env override still wins for development. Toggling
broadcasts feature_flags_changed so open composers react without a restart.
2026-08-07 22:52:36 +00:00
Saoud Rizwan 2f1c94505a Merge origin/main into desktop cloud sessions branch 2026-08-07 22:32:38 +00:00
John Choi 5b7005b003 fix(desktop): paginate cloud branch picker 2026-08-07 08:51:45 -07:00
abeatrix e48a7c9c91 feat(code): add SSH remote environments PoC 2026-08-07 00:20:13 -07:00
John Choi 71e1c59ab8 feat(desktop): switch models in cloud sessions 2026-08-06 20:38:53 -07:00
abeatrix d7b61de54f Merge remote-tracking branch 'origin/main' into bee/agent-voice
# Conflicts:
#	apps/examples/desktop-app/sidecar/context.test.ts
#	sdk/packages/llms/src/catalog/catalog.generated.ts
#	sdk/packages/llms/src/providers/ai-sdk.ts
#	sdk/packages/llms/src/providers/builtins.ts
#	sdk/packages/llms/src/providers/provider-ids.generated.ts
#	sdk/packages/llms/src/providers/providers.generated.ts
#	sdk/packages/llms/src/providers/routing/provider-option-rules.ts
#	sdk/packages/llms/src/providers/routing/provider-options.test.ts
2026-08-06 18:15:57 -07:00
John Choi 7350294f7d fix(desktop): harden cloud session lifecycle and flags 2026-08-06 10:01:27 -07:00
John Choi fe90cbfdf1 Merge remote-tracking branch 'origin/main' into codex/desktop-cloud-agents 2026-08-06 09:49:19 -07:00
John Choi 44cf50cee7 fix(desktop): cloud sessions follow the active account scope
The sidebar now shows exactly the active scope's cloud sessions (personal
or the active organization — matching the dashboard), instead of merging
both. On account/organization switch the sidecar broadcasts
cloud_sessions_changed so the sidebar re-scopes immediately rather than
on the next poll; the cloud manager reset already discards the org cache
and connections.

The session registry is now upsert-only: a session opened under another
scope stays routable (send/abort keep working) when the server-side
active org drifts mid-run, even though it leaves the visible list.
Display truth stays lastListedSessions (active scope only).

Known behavior: after a full account/org SWITCH (manager reset), stale
threads from the previous scope report session-not-found on cold reopen —
the list no longer shows them, so this is reachable only via stale
webview state.
2026-08-06 09:43:04 -07:00
John Choi b39c7e0319 feat(desktop,sdk): cloud transcript synchronization + approval recovery
Implements the agreed convergence design (mirrors experiment/mobile-app):
subscribe → buffer → attach → snapshot (messages/status/queue) → install →
replay unreflected events → live. Single-flight with one queued rerun;
failed snapshots never become an authoritative empty transcript;
segment-scoped substring supersession in the sidecar; multiset count-delta
optimistic reconciliation with first-hydrate gating in the webview.

Review-round fixes on top of the sync implementation:
- Recovery baseline advances on delivered sends — a lost duplicate prompt
  can no longer be falsely confirmed by an earlier identical delivery.
- Prompt occurrence matching normalizes the pod's <user_input> wrapper
  (real transcripts never matched raw prompts; tests used unwrapped
  fixtures, so recovery was inert in production).
- Streamed-text trim symmetry so whitespace cannot defeat supersession
  and duplicate an entire already-persisted reply on reconnect.
- Buffered queue snapshots are dropped during replay (always older than
  the synced queue; replaying could regress it and double-bubble).
- approval.list_pending advertised in HUB_CAPABILITIES (capability-gated
  clients could never discover it) and the sidecar's approvals refresh
  never wipes observed state unless the reply provably carries the list.
- Safety tests: replay-when-not-contained, whitespace supersession,
  queue-snapshot drop, wrapped-prompt recovery, baseline advance.

Tracked follow-ups (not in this change): approval.respond and event
delivery remain unscoped hub-wide (scoping naively would break the
desktop's second approvals client); sessionId is mandatory here vs
optional on the mobile branch.
2026-08-06 09:34:16 -07:00
abeatrix 378fdf147b feat: avatar overlay 2026-08-05 22:34:31 -07:00
John Choi 9c62406da9 fix(desktop,sdk): pre-PR review round — connection lifecycle and UX hardening
From a three-lens adversarial review of the branch:

Sidecar (cloud-sessions)
- BLOCKER: remove the connections-map entry when inner-session creation
  fails — the poisoned entry returned a disposed client whose event
  subscription was gone, silently streaming nothing for every later send.
- Single-flight inner-session creation: concurrent sends could fork two
  inner sessions on the pod, permanently dropping one run's events.
- Cache the active-organization lookup (60s, successes only) — the
  sidebar poll was making two authed REST calls per tick.
- delete() now drains an in-flight connect (zombie-connection race).
- Cold-cache expiry surfaces the clean session_expired envelope on
  send/read paths, not a raw WS upgrade failure.
- A provisioned-but-connect-failed create no longer reports failure for
  a live, billed sandbox; connect happens on demand instead.
- Guard against empty 2xx create responses (raw TypeError before).

SDK (hub client)
- Socket-identity guards in every connect-attempt cleanup path: a stale
  attempt's late timeout/error/close can no longer clobber a newer
  in-flight attempt's socket or dedupe state.

Webview
- Placeholder→real swap keeps the placeholder thread when opening the
  real session fails (was: deleted it and dumped the user on a blank
  fallback thread).
- Reset executionTarget to local when the cloud flag flips off on a
  fresh thread (was: permanently stranded cloud-gated composer).
- inferStatusFromMessages preserves 'provisioning' (the hydration pass
  was clobbering the sidebar state to idle within a second).
- Humanize cloud error envelopes in the delete toast and rename failure
  (rename previously had no catch at all).

Regression tests: poisoned-connection recovery, inner-session
single-flight.
2026-08-05 22:05:22 -07:00
John Choi a988d6a6ee fix(desktop): keep cloud loading continuous through the provisioning swap
The placeholder → real-session swap mounts a fresh thread whose hydration
briefly showed the skeleton between the provisioning row and the
conversation. An empty cloud session mid-hydration now shows the same
compact row ("Opening session...") so the loading treatment never
changes shape.
2026-08-05 21:36:52 -07:00
John Choi f1ec8c897c fix(desktop): cloud session dogfood round — billing, UX, and reliability
From live dogfooding of cloud agent sessions:

Billing & sessions
- Bill the user's ACTIVE organization (server-side active flag, cached
  resolver; personal fallback) instead of always personal credits, and
  list both personal and org-scoped sessions.
- Auto-title sessions from the first prompt; support rename via REST.
- Optional branch passthrough (picker + create body + recovery match).
- Forward autoApproveTools into cloud session creation.

Provisioning experience
- Sidebar placeholder while the synchronous create provisions (REST list
  cannot see the session yet), pulsing status dot, instant list nudge.
- Unified compact loading row (shared cycling phase line) for both the
  originating thread and the placeholder pane; phases advance once and
  hold rather than looping.
- cloud_session_provisioned event swaps placeholder threads to the real
  session when the sandbox is ready.
- Opening a placeholder is benign (loading state), reads return empty,
  only mutating actions error.

Correctness
- Surface run.failed error payloads in the chat (silent-failure fix; the
  raw CLOUD_SESSION_ERROR envelope can no longer reach the screen).
- Emit chat_session_status only on real status changes (pods stream
  periodic snapshots — every visited session was marked unread forever).
- expiredAt is a TTL deadline, not an end time; display uses createdAt
  (backend bumps updatedAt on every WS connect).
- provisioning is a first-class SessionHistoryStatus (the normalizer was
  collapsing it to idle).
- The new-prompt hero requires a thread WITHOUT a history session —
  fixes every flash-of-intro-screen path for existing sessions.
- Archived-history fallback only replaces a live failure when a snapshot
  actually exists (404 = null, not empty).

Plus GitHub repository/branch pickers, org-scoped integration URLs,
thinking-effort passthrough, and feature-flag targeting by account id.
2026-08-05 21:32:51 -07:00
John Choi 0b1f083531 fix(desktop): evaluate cloud-agents flag inside ChatThreadPane
The flag sync referenced ChatThreadPane state from Home, which only
surfaced at next-build prerender (tsconfig.dev does not cover webview).
2026-08-05 18:01:13 -07:00
John Choi d79ad49de3 Merge remote-tracking branch 'origin/main' into codex/desktop-cloud-agents 2026-08-05 17:58:02 -07:00
John Choi ce78c667cf feat(desktop): cloud agent sessions behind code-cloud-agents flag
Cloud sessions via core-platform remote-session API + Hub protocol v1:
sidecar CloudSessionManager (REST lifecycle, per-session NodeHubClient with
Bearer header auth, event translation, approvals, pending-prompt bridge,
expired-session archived history), webview Local/Cloud selector with repo +
optional branch picker, and a default-off PostHog feature flag gating
creation and the selector (existing sessions always remain accessible).
SDK: NodeHubClient resolveConnectionHeaders option; close unregistered
socket on registration failure; export FeatureFlag const from @cline/shared.
2026-08-05 17:57:57 -07:00
abeatrix 2140996303 feat(desktop): avatar pet
- Add native Tauri avatar-window lifecycle, positioning, tray integration, and persisted preferences.
- Add the `/avatar-overlay` webview route and v2 spritesheet animation runtime.
- Play the standard wave animation when the avatar first appears.
- Add bundled Cline Bot and Mom spritesheets.
- Add avatar discovery, selection, visibility, and manifest-precedence support.
- Add Settings controls for enabling and selecting desktop avatars.
- Add an animated voice orb driven by microphone intensity.
- Improve realtime voice panel visibility, mute/stop controls, playback coordination, and tests.
- Reorganize chat input controls so speech, realtime voice, stop, and send actions do not compete.
- Document avatar package structure and storage.
2026-08-05 14:19:28 -07:00
abeatrix 0d1a72f009 ui update 2026-08-05 12:13:53 -07:00
abeatrix a6c0cfd5c3 move audio-player to shared ui package 2026-08-05 11:19:05 -07:00
abeatrix 17e8c0cbc9 feat(desktop): add media player 2026-08-05 11:14:57 -07:00
abeatrix e2b97792b1 feat(desktop): play generated audio artifacts 2026-08-04 22:19:09 -07:00
abeatrix e4a7c19432 feat(desktop): add generated video support 2026-08-04 22:15:50 -07:00
abeatrix 589f32d13e feat(desktop): improve realtime voice sessions 2026-08-04 22:15:21 -07:00
abeatrix 879c085fdb feat(desktop): add realtime voice and UI refinements 2026-08-04 22:15:21 -07:00
211 changed files with 35570 additions and 2255 deletions
@@ -80,6 +80,7 @@ describe("applyInteractiveModelChange", () => {
}));
const saveProviderSettings = vi.fn(() => ({
version: 1 as const,
modes: {},
providers: {},
modes: {},
}));
@@ -55,6 +55,7 @@ function BashOutput(props: { fullText: string; theme: ResolvedTheme }) {
if (!expanded) {
return (
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse expands optional output.
<box
flexDirection="column"
paddingLeft={2}
@@ -75,6 +76,7 @@ function BashOutput(props: { fullText: string; theme: ResolvedTheme }) {
}
return (
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse collapses output.
<box
flexDirection="column"
paddingLeft={2}
@@ -160,6 +162,7 @@ function EditOutput(props: {
const diffPalette = props.theme.diff;
return (
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse toggles the diff.
<box
flexDirection="column"
paddingLeft={2}
@@ -216,6 +219,7 @@ function ApplyPatchOutput(props: {
const diffPalette = props.theme.diff;
return (
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse toggles the diff.
<box
flexDirection="column"
paddingLeft={2}
@@ -263,6 +267,7 @@ function GenericOutput(props: { outputSummary: string; fullText?: string }) {
: displayText;
return (
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse expands long output.
<box
flexDirection="column"
paddingLeft={2}
@@ -278,6 +283,7 @@ function GenericOutput(props: { outputSummary: string; fullText?: string }) {
}
return (
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse collapses output.
<box
flexDirection="column"
paddingLeft={2}
@@ -304,6 +310,7 @@ export function ToolOutput(props: ToolOutputProps) {
const showDetail =
errorExpanded && presentation.detail !== presentation.summary.trim();
return (
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse toggles error details.
<box
flexDirection="column"
paddingLeft={2}
+7 -1
View File
@@ -1,6 +1,6 @@
import { dirname, join, normalize } from "node:path";
import { fileURLToPath } from "node:url";
import { ProviderSettingsManager } from "@cline/core";
import { ClientSettingsManager, ProviderSettingsManager } from "@cline/core";
import { buildInviteUrl, resolveClineHubServerOptions } from "../options";
import type { BrowserConfig } from "./types";
@@ -19,6 +19,12 @@ export const cliIndexPath = normalize(
);
export const providerSettingsManager = new ProviderSettingsManager();
export const desktopClientSettingsManager = new ClientSettingsManager({
clientId: "desktop",
});
desktopClientSettingsManager.initializeModesIfMissing(
providerSettingsManager.read().modes,
);
export const browserConfig: BrowserConfig = {
inviteRequired: Boolean(roomSecret),
+21 -2
View File
@@ -17,19 +17,25 @@ import {
type ProviderClient,
type ProviderProtocol,
type ProviderSettings,
parseProviderModeSettings,
readGlobalSettings,
saveLocalProviderOAuthCredentials,
saveLocalProviderSettings,
saveModeSettings,
setAutoUpdateEnabledGlobally,
setTelemetryOptOutGlobally,
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import { getClineEnvironmentConfig, ProviderModeSchema } from "@cline/shared";
import {
connectorChannelsPayload,
startConnectorChannel,
stopConnectorChannel,
} from "./connectors";
import { providerSettingsManager, workspaceRoot } from "./deps";
import {
desktopClientSettingsManager,
providerSettingsManager,
workspaceRoot,
} from "./deps";
import {
installMarketplaceEntryForDesktopCommand,
listMarketplaceInstalledEntries,
@@ -103,6 +109,7 @@ export async function handleDesktopCommand(
await ensureCustomProvidersLoaded(providerSettingsManager);
return await listLocalProviders(providerSettingsManager, {
isClinePassEnabled: true,
modeSettings: desktopClientSettingsManager.read().modes,
});
}
if (command === "list_provider_models") {
@@ -112,6 +119,18 @@ export async function handleDesktopCommand(
providerSettingsManager.getProviderConfig(provider),
);
}
if (command === "save_mode_settings") {
const mode = ProviderModeSchema.parse(args?.mode);
const settings =
args?.settings == null
? undefined
: parseProviderModeSettings(mode, args.settings);
return await saveModeSettings(
providerSettingsManager,
{ mode, settings },
desktopClientSettingsManager,
);
}
if (command === "save_provider_settings") {
return saveLocalProviderSettings(providerSettingsManager, {
...readProviderSettingsUpdate(args),
+11 -6
View File
@@ -2,6 +2,7 @@ import process from "node:process";
import {
ensureCustomProvidersLoaded,
getLocalProviderModels,
isChatProviderModel,
Llms,
listLocalProviders,
loginAndSaveLocalProviderOAuthCredentials,
@@ -86,12 +87,16 @@ export async function loadModels(
provider,
providerSettingsManager.getProviderConfig(provider),
);
const models: WebviewProviderModel[] = payload.models.map((model) => ({
id: model.id,
name: model.name,
supportsReasoning: model.supportsReasoning,
supportsThinking: model.supportsReasoning,
}));
const models: WebviewProviderModel[] = payload.models
.filter(isChatProviderModel)
.map((model) => ({
id: model.id,
name: model.name,
supportsReasoning: model.supportsReasoning,
supportsThinking: model.supportsReasoning,
inputModalities: model.inputModalities,
outputModalities: model.outputModalities,
}));
ctx.send(peer, { type: "models", providerId: provider, models });
}
+1 -1
View File
@@ -15,7 +15,7 @@ export type WebviewUsage = {
export type WebviewProviderModel = Pick<
ProviderModel,
"id" | "name" | "supportsReasoning"
"id" | "name" | "supportsReasoning" | "inputModalities" | "outputModalities"
> & {
supportsThinking?: boolean;
};
@@ -16,12 +16,27 @@ export type ProviderModelCatalog = {
};
function toModelIds(models: ProviderModel[] | undefined): string[] {
return (models ?? []).map((model) => model.id);
return (models ?? [])
.filter(
(model) =>
(model.inputModalities === undefined ||
model.inputModalities.includes("text")) &&
(model.outputModalities === undefined ||
model.outputModalities.includes("text")),
)
.map((model) => model.id);
}
function toReasoningModelIds(models: ProviderModel[] | undefined): string[] {
return (models ?? [])
.filter((model) => model.supportsReasoning)
.filter(
(model) =>
(model.inputModalities === undefined ||
model.inputModalities.includes("text")) &&
(model.outputModalities === undefined ||
model.outputModalities.includes("text")) &&
model.supportsReasoning,
)
.map((model) => model.id);
}
@@ -31,7 +46,10 @@ export function buildProviderModelCatalog(
return {
providers,
enabledProviderIds: providers
.filter((provider) => provider.enabled)
.filter(
(provider) =>
provider.enabled && toModelIds(provider.modelList).length > 0,
)
.map((provider) => provider.id),
providerModels: Object.fromEntries(
providers.map((provider) => [
@@ -1,11 +1,17 @@
import type { ProviderModesSettings } from "@cline/shared/browser";
export interface ProviderModel {
id: string;
name: string;
supportsAttachments?: boolean;
supportsVision?: boolean;
supportsReasoning?: boolean;
inputModalities?: ModelModality[];
outputModalities?: ModelModality[];
}
export type ModelModality = "text" | "image" | "audio" | "video" | "pdf";
export type ProviderConfigFieldType =
| "text"
| "password"
@@ -63,6 +69,7 @@ export interface ProviderSettingsUpdate {
export interface ProviderCatalogResponse {
providers: Provider[];
settingsPath: string;
modes: ProviderModesSettings;
}
export interface ProviderModelsResponse {
+9
View File
@@ -1,5 +1,14 @@
# Cline Code Desktop Changelog
## 0.0.14-beta.1
- First beta release. Cline Code Beta installs side by side with the stable app so you can compare the two, and updates automatically from its own beta channel — stable installs are unaffected.
- Cloud sessions (preview): run sessions in Cline's cloud straight from the desktop app. Connect GitHub during onboarding, pick a repository and branch, and hand sessions off between devices — transcripts, approvals, and queued prompts stay in sync, and you can rename cloud sessions and switch models mid-session. Turn it on with the Cloud sessions toggle in Settings.
- Avatar overlay (preview): a floating desktop companion that reacts to what your sessions are doing.
- Onboarding now includes a GitHub integration step.
- Early proof of concept for running sessions in SSH remote environments.
- Includes everything from the upcoming stable release: microphone voice input in the composer, model-driven image generation, redesigned question prompts, animated reasoning and tool disclosures, and session list polish.
## 0.0.13
- Added an app font size setting. A slider in Settings scales the interface, and your size is applied before the window paints, so launching no longer flashes at the old size first.
+63
View File
@@ -30,6 +30,59 @@ API keys, `JAVA_HOME`-style tool roots) are not pulled in. Set
`CLINE_SIDECAR_SKIP_SHELL_PATH=1` to disable. Implementation and details:
[`sidecar/shell-path.ts`](./sidecar/shell-path.ts).
## SSH Remote Environments (v0)
Open **Settings → Remote** to add and test an SSH host. Saving or testing a
profile does not activate it. From the welcome chat, open the environment
selector beside the workspace picker and choose the saved host; that selection
starts the SSH connection at the remote user's home directory. Choose **Add
project…** from the normal workspace selector to browse that machine and select
a project, or choose **Local** in the environment selector to disconnect. Recent
and last-used workspaces are remembered separately for each SSH host and for
the local machine.
SSH config aliases are supported. Leave **Port** blank to use the alias's SSH
configuration (including its configured port), or enter a port to override it.
The desktop keeps its webview and native integration local; only the
authenticated Cline Hub protocol is forwarded through SSH. Agent tools,
workspace discovery, Git metadata, and session persistence therefore run on the
SSH host, while approvals and live session events return to the desktop.
The desktop stores host metadata at
`~/.cline/data/settings/remote-environments.json` with mode `0600`. It stores an
identity-file path, never private-key contents. On first connect it uploads a
content-addressed, branch-matched, self-contained Hub helper under
`~/.cline/code/remote/`, binds the Hub to remote loopback, and forwards it to a
random local loopback port. Linux x64 and arm64 helpers are bundled by
`bun run build:sidecar:bin`; 32-bit Raspberry Pi operating systems are not
supported in v0. The current helper is roughly 110 MB because it includes its
own runtime. It is copied once per matching desktop build and cached, with no
`apt`, `npm`, root access,
global CLI install, or public Hub port. Disconnecting stops the desktop-owned
remote Hub but leaves the helper cached for a faster reconnect. The helper
imports the remote login-shell `PATH`, so user-installed Git, GitHub CLI, and
MCP executables remain visible.
The desktop uses its own discovery record, so an existing Cline CLI/Hub on the
same account is neither replaced nor stopped. Both Hub processes can coexist
while the desktop is connected; this isolation is intentional for the proof of
concept so a branch-matched desktop helper cannot disrupt another Cline build.
v0 intentionally leaves file attachments and opening a remote file in a local
editor disabled. Text, images, file mentions/search, Git branch operations,
session history, and remote agent tools are supported. The current desktop
provider access/API token is sent through the authenticated tunnel for the
session; reusable OAuth refresh credentials are not copied into remote provider
settings.
For a real SSH acceptance run, `scripts/verify-ssh-poc.ts` accepts
`CLINE_SSH_TEST_HOST`, `CLINE_SSH_TEST_USER`, `CLINE_SSH_TEST_KEY`,
`CLINE_SSH_TEST_WORKSPACE`, and `CLINE_SSH_TEST_HELPER`. It starts a remote
connection at the SSH user's home, starts an agent session in the test
workspace with the selected desktop provider, asks the agent to read
`REMOTE_MARKER.txt`, then verifies the session appears in remote history and
that its messages can be read back.
## Web Visual System
The framework-neutral color, typography, radius, and navigation contract lives
@@ -141,6 +194,16 @@ Desktop transport envelope:
## Data + Storage
- Session artifacts are written under `~/.cline/data/sessions/<sessionId>/` (or `CLINE_SESSION_DATA_DIR`).
- Desktop avatar packages live under `~/.cline/avatars/<avatar-name>/`. Each package
contains a v2 `spritesheet.webp` (or PNG) and either an `avatar.json` or
`pet.json` manifest with
`id`, `displayName`, `description`, `spriteVersionNumber: 2`, and
`spritesheetPath`. If both manifests exist, `avatar.json` takes precedence.
The bundled Cline Bot avatar is selected and enabled by default, with Mom also
available as a bundled option.
Visibility and the selected installed avatar are configured independently under
**Settings → General → Desktop avatar**; both values are stored in
`~/.cline/avatars/selected.json`.
- Canonical replay/export artifact: `<sessionId>.messages.json`.
- `<sessionId>.messages.json` is expected to contain ordered messages plus assistant `modelInfo` and `metrics` (including cache token fields when provided by the model runtime).
- `<sessionId>.hooks.jsonl` is observability/debug telemetry and should not be required for normal history replay/export flows.
+7 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/code",
"version": "0.0.13",
"version": "0.0.14-beta.1",
"private": true,
"scripts": {
"build:ui": "bun -F @cline/ui build",
@@ -26,6 +26,10 @@
"clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)"
},
"dependencies": {
"@ai-sdk/gateway": "4.0.31",
"@ai-sdk/google": "4.0.44",
"@ai-sdk/openai": "4.0.41",
"@ai-sdk/react": "4.0.44",
"@base-ui/react": "^1.2.0",
"@cline/core": "workspace:*",
"@cline/llms": "workspace:*",
@@ -66,6 +70,7 @@
"@shikijs/themes": "^4.2.0",
"@streamdown/cjk": "^1.0.3",
"@tauri-apps/api": "^2.0.0",
"ai": "^7.0.58",
"autoprefixer": "^10.4.20",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -87,7 +92,7 @@
"streamdown": "^2.5.0",
"tailwind-merge": "^3.3.1",
"vaul": "^1.1.2",
"zod": "^3.24.1"
"zod": "^3.25.76"
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.0",
@@ -38,22 +38,54 @@ const sidecarOutfile = (targetTriple: string): string => {
return `./src-tauri/bin/code-sidecar-${targetTriple}${extension}`;
};
const buildSidecar = async (targetTriple: string): Promise<string> => {
const outfile = sidecarOutfile(targetTriple);
const buildSidecar = async (
targetTriple: string,
outfile = sidecarOutfile(targetTriple),
entrypoint = "./sidecar/index.ts",
minify = false,
): Promise<string> => {
const bunTarget = resolveBunCompileTarget(targetTriple);
// Telemetry config must be inlined into the compiled binary: a packaged
// app launched from Finder/the Dock has no OTEL_* env at runtime, so
// without this the sidecar silently ships with telemetry disabled.
// Verify with `<binary> --telemetry-selfcheck` after building.
const defines = telemetryDefineArgs();
const optimizationArgs = minify ? ["--minify"] : [];
// A compiled Bun executable otherwise reads .env and bunfig.toml from its
// launch directory before our entrypoint runs. Remote helpers are launched
// from an SSH user's home directory, so that behavior can both make the
// helper fail on an unrelated dotenv file and leak workspace credentials
// into the Hub process. Packaged binaries must depend only on their explicit
// process environment and compiled configuration.
const runtimeIsolationArgs = [
"--no-compile-autoload-dotenv",
"--no-compile-autoload-bunfig",
];
if (bunTarget) {
await $`bun build ./sidecar/index.ts --compile --target=${bunTarget} ${defines} --outfile ${outfile}`;
await $`bun build ${entrypoint} --compile --target=${bunTarget} ${runtimeIsolationArgs} ${optimizationArgs} ${defines} --outfile ${outfile}`;
} else {
await $`bun build ./sidecar/index.ts --compile ${defines} --outfile ${outfile}`;
await $`bun build ${entrypoint} --compile ${runtimeIsolationArgs} ${optimizationArgs} ${defines} --outfile ${outfile}`;
}
return outfile;
};
// SSH environments run the same Hub build as the desktop in a dedicated
// bootstrap/daemon binary. It intentionally excludes the desktop HTTP server,
// command router, and UI backend. Linux x64 and arm64 cover common SSH hosts.
const buildRemoteHelpers = async (): Promise<void> => {
for (const targetTriple of [
"x86_64-unknown-linux-gnu",
"aarch64-unknown-linux-gnu",
]) {
await buildSidecar(
targetTriple,
`./src-tauri/bin/remote-helpers/code-sidecar-${targetTriple}`,
"./sidecar/remote-helper.ts",
true,
);
}
};
// Tauri's universal-apple-darwin pseudo-target lipos the Rust binary itself
// but expects sidecars (externalBin) to already be fat binaries named
// `<name>-universal-apple-darwin`, so build both slices and merge them here.
@@ -68,12 +100,13 @@ const buildUniversalMacSidecar = async (): Promise<void> => {
const main = async () => {
const targetTriple = await resolveTargetTriple();
await $`mkdir -p src-tauri/bin`;
await $`mkdir -p src-tauri/bin src-tauri/bin/remote-helpers`;
if (targetTriple === "universal-apple-darwin") {
await buildUniversalMacSidecar();
return;
} else {
await buildSidecar(targetTriple);
}
await buildSidecar(targetTriple);
await buildRemoteHelpers();
};
main().catch((error: unknown) => {
@@ -65,6 +65,18 @@ describe("telemetryDefineArgs", () => {
expect(withKeys["process.env.ERROR_SERVICE_API_KEY"]).toBe('"ek"');
});
it("inlines the cloud-agents toggle only when set at build time", () => {
const withoutToggle = defineMap(telemetryDefineArgs({}));
expect(withoutToggle).not.toHaveProperty(
"process.env.CLINE_CODE_CLOUD_AGENTS",
);
const withToggle = defineMap(
telemetryDefineArgs({ CLINE_CODE_CLOUD_AGENTS: "1" }),
);
expect(withToggle["process.env.CLINE_CODE_CLOUD_AGENTS"]).toBe('"1"');
});
it("JSON-escapes values so headers with quotes survive the define", () => {
const defines = defineMap(
telemetryDefineArgs({
@@ -15,6 +15,9 @@ const OPTIONAL_SECRET_ENV_VARS = [
"ERROR_SERVICE_API_KEY",
] as const;
/** Optional build-time overrides for packaged dogfood builds. */
const OPTIONAL_FEATURE_ENV_VARS = ["CLINE_CODE_CLOUD_AGENTS"] as const;
/**
* Every env var `getTelemetryBuildTimeConfig` reads
* (sdk/packages/shared/src/services/telemetry-config.ts). Always inlined,
@@ -42,7 +45,10 @@ export function telemetryDefineArgs(
const define = (name: string, value: string) => {
args.push("--define", `process.env.${name}=${JSON.stringify(value)}`);
};
for (const name of OPTIONAL_SECRET_ENV_VARS) {
for (const name of [
...OPTIONAL_SECRET_ENV_VARS,
...OPTIONAL_FEATURE_ENV_VARS,
]) {
const value = env[name];
if (value) {
define(name, value);
@@ -0,0 +1,176 @@
import { mkdtemp, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
ClineCore,
ProviderSettingsManager,
RuntimeOAuthTokenManager,
resolveProviderApiKeyFromSettings,
SessionSource,
toProviderConfig,
} from "@cline/core";
import { RemoteEnvironmentService } from "../sidecar/remote-environments";
const required = (name: string): string => {
const value = process.env[name]?.trim();
if (!value) throw new Error(`${name} is required`);
return value;
};
async function main(): Promise<void> {
const temporaryDirectory = await mkdtemp(join(tmpdir(), "cline-ssh-proof-"));
const service = new RemoteEnvironmentService({
profilesPath: join(temporaryDirectory, "remote-environments.json"),
helperBinaryPath: required("CLINE_SSH_TEST_HELPER"),
knownHostsPath: join(temporaryDirectory, "known_hosts"),
commandTimeoutMs: 60_000,
uploadTimeoutMs: 5 * 60_000,
});
let core: ClineCore | undefined;
try {
const helperPath = required("CLINE_SSH_TEST_HELPER");
const workspaceRoot = required("CLINE_SSH_TEST_WORKSPACE");
const profile = await service.upsert({
name: "SSH proof host",
host: required("CLINE_SSH_TEST_HOST"),
user: process.env.CLINE_SSH_TEST_USER?.trim() || undefined,
identityFile: required("CLINE_SSH_TEST_KEY"),
});
const connection = await service.connect(profile.id);
const marker = await service.run(profile.id, {
command: "sed",
args: ["-n", "1p", "REMOTE_MARKER.txt"],
cwd: workspaceRoot,
});
const providerSettings = new ProviderSettingsManager();
const stored = providerSettings.read();
const providerId = stored.lastUsedProvider;
if (!providerId)
throw new Error("No configured desktop provider is available");
const settings = providerSettings.getProviderSettings(providerId);
if (!settings)
throw new Error(`No settings found for provider ${providerId}`);
const modelId = settings.model || "meta/muse-spark-1.2";
const providerConfig = {
...toProviderConfig(
{ ...settings, model: modelId },
{ includeKnownModels: false },
),
};
delete providerConfig.refreshToken;
const oauth = await new RuntimeOAuthTokenManager({
providerSettingsManager: providerSettings,
}).resolveProviderApiKey({ providerId });
const apiKey =
oauth?.apiKey ||
resolveProviderApiKeyFromSettings(providerSettings, providerId);
if (!apiKey)
throw new Error(`No credential found for provider ${providerId}`);
core = await ClineCore.create({
clientName: "cline-code",
backendMode: "remote",
remote: {
endpoint: connection.endpoint,
authToken: connection.authToken,
workspaceRoot: connection.workspaceRoot,
cwd: connection.workspaceRoot,
clientType: "code-sidecar-ssh",
},
});
const eventNames: string[] = [];
const unsubscribe = core.subscribe((event) => {
eventNames.push(event.type);
});
const started = await core.start({
config: {
providerId,
modelId,
apiKey,
providerConfig,
workspaceRoot,
cwd: workspaceRoot,
systemPrompt: "",
mode: "act",
enableTools: true,
enableSpawnAgent: false,
enableAgentTeams: false,
},
source: SessionSource.DESKTOP,
interactive: true,
toolPolicies: { "*": { autoApprove: true } },
});
const result = await core.send({
sessionId: started.sessionId,
prompt:
"Read REMOTE_MARKER.txt from this workspace with the file-reading tool, then reply with its exact contents. Do not change any files.",
});
const sessions = await core.list(20, { hydrate: false });
const messages = await core.readMessages(started.sessionId);
unsubscribe();
await core.dispose("desktop_ssh_proof_reconnect");
core = undefined;
await service.disconnect(profile.id);
const reconnected = await service.connect(profile.id);
core = await ClineCore.create({
clientName: "cline-code",
backendMode: "remote",
remote: {
endpoint: reconnected.endpoint,
authToken: reconnected.authToken,
workspaceRoot: reconnected.workspaceRoot,
cwd: reconnected.workspaceRoot,
clientType: "code-sidecar-ssh",
},
});
const sessionsAfterReconnect = await core.list(20, { hydrate: false });
const messagesAfterReconnect = await core.readMessages(started.sessionId);
const resultText = result?.text ?? "";
const report = {
connected: true,
remote: `${connection.platform}/${connection.arch}`,
connectionRoot: connection.workspaceRoot,
workspaceRoot: started.manifest.workspace_root,
sessionId: started.sessionId,
listContainsSession: sessions.some(
(session) => session.sessionId === started.sessionId,
),
messageCount: messages.length,
reconnected: true,
reconnectListContainsSession: sessionsAfterReconnect.some(
(session) => session.sessionId === started.sessionId,
),
reconnectMessageCount: messagesAfterReconnect.length,
helperBytes: (await stat(helperPath)).size,
sshMarker: marker.stdout.trim(),
agentText: resultText,
agentObservedMarker: resultText.includes("remote workspace proof"),
eventNames: [...new Set(eventNames)],
};
if (
report.sshMarker !== "remote workspace proof" ||
!report.agentObservedMarker ||
!report.listContainsSession ||
!report.reconnectListContainsSession ||
report.messageCount < 2 ||
report.reconnectMessageCount < 2 ||
!report.eventNames.includes("agent_event")
) {
throw new Error(`SSH proof failed: ${JSON.stringify(report)}`);
}
process.stdout.write(`${JSON.stringify(report)}\n`);
} finally {
await core?.dispose("desktop_ssh_proof_complete");
await service.dispose();
await rm(temporaryDirectory, { recursive: true, force: true });
}
}
void main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
@@ -17,7 +17,11 @@ sidecar/
├── server.ts # Bun HTTP server + WebSocket handlers
├── context.ts # SidecarContext type and factory
├── commands.ts # Command router
├── chat-session.ts # Shared-Hub chat session adapter
├── chat-session.ts # Shared-Hub chat session adapter (local + cloud routing)
├── cloud-sessions.ts # Cloud session REST client + Hub-proxy manager
├── cline-auth.ts # Refresh-aware Cline auth token resolution
├── desktop-settings.ts # Desktop-owned settings (cloud sessions opt-in)
├── feature-flags.ts # Cloud sessions gate (env override + settings toggle)
├── session-data/ # Shared discovery, messages, artifacts, search helpers
├── paths.ts # Path resolution
├── types.ts # Shared types
@@ -89,7 +93,7 @@ online:
```typescript
const pendingApprovals = new Map<string, {
resolve: (result: ToolApprovalResult) => void;
resolve: (result: ToolApprovalResult) => void | Promise<void>;
request: ToolApprovalRequest;
}>();
@@ -97,6 +101,10 @@ const pendingApprovals = new Map<string, {
// When frontend responds → resolve promise
```
Cloud sessions route approvals the same way, but the resolver forwards the
response to the sandbox Hub (`approval.respond`), which is why `resolve` may
be async.
### 3. Provider Management — Direct ProviderSettingsManager
```typescript
@@ -138,7 +146,7 @@ Supported commands:
| Command | Implementation |
|---------|---------------|
| `chat_session_command` | shared Hub through `ClineCore` |
| `chat_session_command` | shared Hub through `ClineCore`; cloud sessions route to `CloudSessionManager` |
| `list_provider_catalog` | `ProviderSettingsManager` + `listLocalProviders` |
| `list_provider_models` | `getLocalProviderModels` |
| `save_voice_input_settings` | validates and persists the selected transcription provider/model |
@@ -147,18 +155,30 @@ Supported commands:
| `save_provider_settings` | `saveLocalProviderSettings` |
| `add_provider` | `addLocalProvider` |
| `run_provider_oauth_login` | `loginLocalProvider` |
| `list_chat_sessions` | `SqliteSessionStore` + file discovery |
| `list_discovered_sessions` | Merged discovery |
| `read_session_messages` | Session data readers |
| `list_chat_sessions` | `SqliteSessionStore` + file discovery, merged with cloud sessions (2s budget) |
| `list_discovered_sessions` | Merged discovery (local + cloud) |
| `read_session_messages` | Session data readers; cloud sessions read through the sandbox Hub |
| `read_session_hooks` | Session data readers |
| `delete_chat_session` | `SqliteSessionStore.delete` + file cleanup |
| `update_chat_session_title` | `resolveSessionBackend().updateSession` |
| `delete_chat_session` | `SqliteSessionStore.delete` + file cleanup; cloud sessions also delete the sandbox |
| `update_chat_session_title` | `resolveSessionBackend().updateSession`; cloud sessions PATCH the cloud API |
| `get_feature_flags` | `isCloudAgentsEnabled()` (env override + settings toggle) |
| `get_desktop_settings` | `readDesktopSettings()` |
| `set_cloud_sessions_enabled` | `setCloudSessionsEnabled()` + `feature_flags_changed` broadcast |
| `list_cloud_repositories` | `CloudSessionManager.listRepositories()` (GitHub integration) |
| `list_cloud_branches` | `CloudSessionManager.listBranches()` (paginated) |
| `list_mcp_servers` | Direct file I/O |
| `authorize_mcp_server_oauth` | Explicit Connect action → cancellable `authorizeMcpServerOAuth` + system browser |
| `cancel_mcp_server_oauth` | Cancel the pending MCP OAuth callback wait |
| `upsert_mcp_server` | Direct file I/O |
| `delete_mcp_server` | Direct file I/O |
| `get_git_branch` | async `execFile("git", ...)` |
Realtime mode sessions expose only one browser-callable tool, `run_cline`, when
the selected realtime model supports tool calling. The webview implements that
tool by sending the request through the active Cline chat session and returning
its persisted result to the realtime provider for playback. Cline remains the
owner of workspace context, agent tools, MCP, approvals, and session history;
provider credentials remain in the sidecar.
| `list_git_branches` | async `execFile("git", ...)` |
| `checkout_git_branch` | async `execFile("git", ...)` |
| `search_workspace_files` | `getFileIndex` |
@@ -13,6 +13,7 @@ import { materializeUserFiles } from "./attachments";
import {
buildSessionConnectionUpdate,
consumeWorkspaceMetadata,
copySessionGeneratedArtifacts,
handleChatSessionCommand,
hasProviderChanged,
mergeSessionConfig,
@@ -23,6 +24,43 @@ import {
} from "./chat-session";
import type { SidecarContext } from "./types";
function localRuntimeContext(
sessionManager: Record<string, unknown>,
options: { sessionIds?: string[]; workspaceRoot?: string } = {},
) {
const workspaceRoot = options.workspaceRoot ?? "/workspace";
return {
runtimeBindings: new Map([
[
"local",
{
environmentId: "local",
kind: "local" as const,
workspaceRoot,
sessionManager,
hubClient: {
command: vi.fn(async () => undefined),
},
unsubscribeSessionEvents: () => {},
},
],
]),
sessionEnvironmentIds: new Map(
(options.sessionIds ?? []).map((sessionId) => [sessionId, "local"]),
),
activeEnvironmentId: "local",
remoteEnvironments: null,
localWorkspaceRoot: workspaceRoot,
};
}
function localSessionManager(ctx: SidecarContext): Record<string, unknown> {
return ctx.runtimeBindings.get("local")?.sessionManager as unknown as Record<
string,
unknown
>;
}
describe("rewriteDesktopTeamPrompt", () => {
it("rewrites /team for the core runtime", () => {
expect(
@@ -197,7 +235,7 @@ describe("pathless session starts", () => {
const ctx = {
liveSessions: new Map(),
restoringWorkspacePaths: new Set(),
sessionManager: { start },
...localRuntimeContext({ start }),
} as unknown as SidecarContext;
const result = (await handleChatSessionCommand(ctx, {
@@ -221,15 +259,139 @@ describe("pathless session starts", () => {
sessionId: "session-pathless",
cwd: "/home/host/.cline/data/workspaces/chat",
workspaceRoot: "/home/host/.cline/data/workspaces/chat",
environmentId: "local",
});
expect(ctx.liveSessions.get("session-pathless")?.config).toMatchObject({
cwd: "/home/host/.cline/data/workspaces/chat",
workspaceRoot: "/home/host/.cline/data/workspaces/chat",
});
});
it("marks sessions initiated by realtime voice with the realtime source", async () => {
const start = vi.fn(async () => ({
sessionId: "session-realtime",
manifest: {
cwd: "/workspace/project",
workspace_root: "/workspace/project",
},
manifestPath: "/tmp/session-realtime.json",
messagesPath: "/tmp/session-realtime.messages.json",
}));
const ctx = {
liveSessions: new Map(),
restoringWorkspacePaths: new Set(),
...localRuntimeContext({ start }),
} as unknown as SidecarContext;
await handleChatSessionCommand(ctx, {
action: "start",
source: "realtime",
config: {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/workspace/project",
workspaceRoot: "/workspace/project",
},
});
expect(start).toHaveBeenCalledWith(
expect.objectContaining({ source: "realtime" }),
);
});
});
describe("environment-bound session attach", () => {
it("does not fall through to another host when the requested environment lacks the session", async () => {
const sessionId = "same-session-id";
const localGet = vi.fn(async () => ({
sessionId,
status: "completed",
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/local/project",
workspaceRoot: "/local/project",
}));
const remoteGet = vi.fn(async () => undefined);
const ctx = {
liveSessions: new Map(),
sessionEnvironmentIds: new Map([[sessionId, "local"]]),
activeEnvironmentId: "local",
runtimeBindings: new Map([
[
"local",
{
environmentId: "local",
kind: "local",
workspaceRoot: "/local/project",
sessionManager: { get: localGet },
hubClient: { command: vi.fn() },
unsubscribeSessionEvents: () => {},
},
],
[
"pi-host",
{
environmentId: "pi-host",
kind: "ssh",
workspaceRoot: "/home/pi",
sessionManager: { get: remoteGet },
hubClient: { command: vi.fn() },
unsubscribeSessionEvents: () => {},
},
],
]),
} as unknown as SidecarContext;
await expect(
handleChatSessionCommand(ctx, {
action: "attach",
sessionId,
config: { environmentId: "pi-host" },
}),
).rejects.toThrow(`Session ${sessionId} not found`);
expect(remoteGet).toHaveBeenCalledWith(sessionId);
expect(localGet).not.toHaveBeenCalled();
});
});
describe("session forks", () => {
it("copies generated artifacts into the forked session", () => {
const previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
const sessionsDir = mkdtempSync(join(tmpdir(), "desktop-fork-artifacts-"));
const sourceSessionId = "source-session";
const targetSessionId = "forked-session";
const sourceArtifactsDir = join(sessionsDir, sourceSessionId, "artifacts");
try {
process.env.CLINE_SESSION_DATA_DIR = sessionsDir;
mkdirSync(sourceArtifactsDir, { recursive: true });
writeFileSync(join(sourceArtifactsDir, "generated.mp4"), "video");
writeFileSync(join(sourceArtifactsDir, "generated.mp3"), "audio");
copySessionGeneratedArtifacts(sourceSessionId, targetSessionId);
expect(
readFileSync(
join(sessionsDir, targetSessionId, "artifacts", "generated.mp4"),
"utf8",
),
).toBe("video");
expect(
readFileSync(
join(sessionsDir, targetSessionId, "artifacts", "generated.mp3"),
"utf8",
),
).toBe("audio");
} finally {
if (previousSessionDataDir === undefined) {
delete process.env.CLINE_SESSION_DATA_DIR;
} else {
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
}
rmSync(sessionsDir, { recursive: true, force: true });
}
});
it("restores the selected workspace checkpoint before forking for message editing", async () => {
const sourceSessionId = `source-fork-${Date.now()}`;
const sourceMessages = [
@@ -268,29 +430,32 @@ describe("session forks", () => {
],
]),
restoringWorkspacePaths: new Set(),
sessionManager: {
get: vi.fn(async () => ({
sessionId: sourceSessionId,
source: "desktop",
status: "completed",
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/workspace/project",
workspaceRoot: "/workspace/project",
metadata: {
checkpoint: {
latest: { ref: "second", createdAt: 2, runCount: 2 },
history: [
{ ref: "first", createdAt: 1, runCount: 1 },
{ ref: "second", createdAt: 2, runCount: 2 },
],
...localRuntimeContext(
{
get: vi.fn(async () => ({
sessionId: sourceSessionId,
source: "desktop",
status: "completed",
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/workspace/project",
workspaceRoot: "/workspace/project",
metadata: {
checkpoint: {
latest: { ref: "second", createdAt: 2, runCount: 2 },
history: [
{ ref: "first", createdAt: 1, runCount: 1 },
{ ref: "second", createdAt: 2, runCount: 2 },
],
},
},
},
})),
readMessages,
restore,
start,
},
})),
readMessages,
restore,
start,
},
{ sessionIds: [sourceSessionId] },
),
streamIndices: new Map(),
wsClients: new Set(),
} as unknown as SidecarContext;
@@ -392,26 +557,29 @@ describe("session forks", () => {
],
]),
restoringWorkspacePaths: new Set(),
sessionManager: {
get: vi.fn(async () => ({
sessionId: sourceSessionId,
source: "desktop",
status: "completed",
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/workspace/project",
workspaceRoot: "/workspace/project",
metadata: {
checkpoint: {
latest: { ref: "first", createdAt: 1, runCount: 1 },
history: [{ ref: "first", createdAt: 1, runCount: 1 }],
...localRuntimeContext(
{
get: vi.fn(async () => ({
sessionId: sourceSessionId,
source: "desktop",
status: "completed",
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/workspace/project",
workspaceRoot: "/workspace/project",
metadata: {
checkpoint: {
latest: { ref: "first", createdAt: 1, runCount: 1 },
history: [{ ref: "first", createdAt: 1, runCount: 1 }],
},
},
},
})),
readMessages: vi.fn(async () => sourceMessages),
restore,
send,
},
})),
readMessages: vi.fn(async () => sourceMessages),
restore,
send,
},
{ sessionIds: [sourceSessionId, siblingSessionId] },
),
streamIndices: new Map(),
wsClients: new Set(),
} as unknown as SidecarContext;
@@ -473,20 +641,23 @@ describe("session forks", () => {
],
]),
restoringWorkspacePaths: new Set(),
sessionManager: {
get: vi.fn(async () => ({
sessionId: sourceSessionId,
source: "desktop",
status: "completed",
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/workspace/project",
workspaceRoot: "/workspace/project",
})),
readMessages,
restore,
start,
},
...localRuntimeContext(
{
get: vi.fn(async () => ({
sessionId: sourceSessionId,
source: "desktop",
status: "completed",
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/workspace/project",
workspaceRoot: "/workspace/project",
})),
readMessages,
restore,
start,
},
{ sessionIds: [sourceSessionId] },
),
streamIndices: new Map(),
wsClients: new Set(),
} as unknown as SidecarContext;
@@ -524,7 +695,7 @@ describe("session forks", () => {
],
]),
restoringWorkspacePaths: new Set(),
sessionManager: { restore },
...localRuntimeContext({ restore }, { sessionIds: [sourceSessionId] }),
} as unknown as SidecarContext;
await expect(
@@ -555,13 +726,16 @@ describe("session forks", () => {
],
]),
restoringWorkspacePaths: new Set(),
sessionManager: {
get: vi.fn(async () => ({
sessionId: sourceSessionId,
status: "running",
})),
restore,
},
...localRuntimeContext(
{
get: vi.fn(async () => ({
sessionId: sourceSessionId,
status: "running",
})),
restore,
},
{ sessionIds: [sourceSessionId] },
),
} as unknown as SidecarContext;
await expect(
@@ -605,15 +779,18 @@ describe("session forks", () => {
],
]),
restoringWorkspacePaths: new Set(),
sessionManager: {
get: vi.fn(async () => ({
sessionId: sourceSessionId,
status: "completed",
cwd: "/workspace/project",
workspaceRoot: "/workspace/project",
})),
restore,
},
...localRuntimeContext(
{
get: vi.fn(async () => ({
sessionId: sourceSessionId,
status: "completed",
cwd: "/workspace/project",
workspaceRoot: "/workspace/project",
})),
restore,
},
{ sessionIds: [sourceSessionId, siblingSessionId] },
),
} as unknown as SidecarContext;
await expect(
@@ -645,7 +822,7 @@ describe("session forks", () => {
],
]),
restoringWorkspacePaths: new Set(["/workspace/project"]),
sessionManager: { send },
...localRuntimeContext({ send }, { sessionIds: [sessionId] }),
} as unknown as SidecarContext;
await expect(
@@ -705,17 +882,20 @@ describe("first-send connection updates", () => {
restoringWorkspacePaths: new Set(),
streamIndices: new Map(),
wsClients: new Set(),
sessionManager: {
readMessages,
readSessionCompactionState,
send,
start,
stop,
updateSessionConnection,
pendingPrompts: {
list: vi.fn(async () => []),
...localRuntimeContext(
{
readMessages,
readSessionCompactionState,
send,
start,
stop,
updateSessionConnection,
pendingPrompts: {
list: vi.fn(async () => []),
},
},
},
{ sessionIds: [sessionId] },
),
} as unknown as SidecarContext;
return {
ctx,
@@ -839,7 +1019,7 @@ describe("first-send connection updates", () => {
attachmentCount: number;
userFiles?: string[];
}> = [];
const manager = ctx.sessionManager as unknown as {
const manager = localSessionManager(ctx) as unknown as {
send: typeof send;
pendingPrompts: {
list: (input: unknown) => Promise<unknown[]>;
@@ -971,15 +1151,13 @@ describe("first-send connection updates", () => {
if (!session) throw new Error("missing session");
const queuedMap = new Map([["pending_1", [queuedFile]]]);
session.queuedAttachmentFiles = queuedMap;
(ctx.sessionManager as unknown as { get: unknown }).get = vi.fn(
async () => ({
status: "idle",
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/workspace",
workspaceRoot: "/workspace",
}),
);
(localSessionManager(ctx) as { get?: unknown }).get = vi.fn(async () => ({
status: "idle",
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/workspace",
workspaceRoot: "/workspace",
}));
await handleChatSessionCommand(ctx, {
action: "attach",
@@ -1295,6 +1473,97 @@ describe("first-send connection updates", () => {
});
});
describe("system prompt mode resolution", () => {
function createStartContext() {
const start = vi.fn(async (input: { config: Record<string, unknown> }) => ({
sessionId: "session-mode-test",
manifest: {
cwd: String(input.config.cwd ?? ""),
workspace_root: String(input.config.workspaceRoot ?? ""),
},
manifestPath: "/tmp/session-mode-test.json",
messagesPath: "/tmp/session-mode-test.messages.json",
}));
const ctx = {
liveSessions: new Map(),
...localRuntimeContext({ start }),
} as unknown as SidecarContext;
return { ctx, start };
}
async function startAndCaptureSystemPrompt(
config: Record<string, unknown>,
): Promise<string> {
const { ctx, start } = createStartContext();
const cwd = String(config.cwd ?? "");
// Seed the metadata cache so resolveSystemPrompt does not scan a real
// workspace during the test.
prewarmWorkspaceMetadata(cwd, async () => "test metadata");
await handleChatSessionCommand(ctx, { action: "start", config });
expect(start).toHaveBeenCalledTimes(1);
const input = start.mock.calls[0][0] as {
config: Record<string, unknown>;
};
return String(input.config.systemPrompt ?? "");
}
it("keeps the interactive act persona when autoApproveTools is enabled", async () => {
const systemPrompt = await startAndCaptureSystemPrompt({
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/tmp/cline-desktop-mode-act-auto-approve",
mode: "act",
autoApproveTools: true,
});
expect(systemPrompt).not.toContain("submit_and_exit");
expect(systemPrompt).not.toContain(
"user who you cannot communicate with directly",
);
expect(systemPrompt).toContain("assist users with various coding tasks");
});
it("defaults to act mode when mode is omitted", async () => {
const systemPrompt = await startAndCaptureSystemPrompt({
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/tmp/cline-desktop-mode-default",
autoApproveTools: true,
});
expect(systemPrompt).not.toContain("submit_and_exit");
expect(systemPrompt).toContain("assist users with various coding tasks");
});
it("appends plan-mode instructions when mode is plan", async () => {
const systemPrompt = await startAndCaptureSystemPrompt({
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/tmp/cline-desktop-mode-plan",
mode: "plan",
autoApproveTools: true,
});
expect(systemPrompt).not.toContain("submit_and_exit");
expect(systemPrompt).toContain("You are in Plan mode");
});
it("only uses the yolo persona when mode is explicitly yolo", async () => {
const systemPrompt = await startAndCaptureSystemPrompt({
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/tmp/cline-desktop-mode-yolo",
mode: "yolo",
autoApproveTools: true,
});
expect(systemPrompt).toContain("submit_and_exit");
expect(systemPrompt).toContain(
"user who you cannot communicate with directly",
);
});
});
describe("workspace metadata prewarming", () => {
it("reuses one in-flight scan and consumes it only once", async () => {
let resolveFirst: ((value: string) => void) | undefined;
@@ -1413,18 +1682,20 @@ Follow the desktop send skill instructions.`,
}),
);
const ctx = {
workspaceRoot: workspace,
liveSessions: new Map([[sessionId, session]]),
restoringWorkspacePaths: new Set(),
streamIndices: new Map(),
wsClients: new Set(),
sessionManager: {
send,
pendingPrompts: {
list: vi.fn(async () => []),
update: updatePendingPrompt,
...localRuntimeContext(
{
send,
pendingPrompts: {
list: vi.fn(async () => []),
update: updatePendingPrompt,
},
},
},
{ sessionIds: [sessionId], workspaceRoot: workspace },
),
} as unknown as SidecarContext;
return { ctx, send, session, sessionId, updatePendingPrompt };
}
+412 -56
View File
@@ -1,4 +1,10 @@
import { existsSync, readFileSync } from "node:fs";
import {
copyFileSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
} from "node:fs";
import { basename, join, resolve } from "node:path";
import { isDeepStrictEqual } from "node:util";
import {
@@ -9,13 +15,17 @@ import {
createSessionCompactionState,
createUserInstructionConfigService,
getCoreBuiltinToolCatalog,
ProviderSettingsManager,
projectSessionCompactionState,
readGlobalSettings,
resolveProviderApiKeyFromSettings,
RuntimeOAuthTokenManager,
type SessionCompactionState,
type SessionPendingPrompt,
type SessionRecord,
SessionSource,
splitCoreSessionConfig,
toProviderConfig,
trimMessagesBeforeUserRun,
} from "@cline/core";
import type { MessageWithMetadata } from "@cline/llms";
@@ -26,7 +36,15 @@ import {
materializeUserFiles,
trackQueuedAttachments,
} from "./attachments";
import { emitChunk, nowMs, sendEvent } from "./context";
import { getCloudSessionManager } from "./cloud-sessions";
import {
emitChunk,
findSessionRuntimeBinding,
getSessionRuntimeBinding,
nowMs,
sendEvent,
} from "./context";
import { isCloudAgentsEnabled } from "./feature-flags";
import { readSessionManifest, sharedSessionDataDir } from "./paths";
import { persistSessionMessages } from "./session-data/messages";
import type {
@@ -34,6 +52,7 @@ import type {
JsonRecord,
LiveSession,
PromptInQueue,
SessionRuntimeBinding,
SidecarContext,
} from "./types";
@@ -303,6 +322,22 @@ function readPersistedChatMessages(
}
}
export function copySessionGeneratedArtifacts(
sourceSessionId: string,
targetSessionId: string,
): void {
if (sourceSessionId === targetSessionId) return;
const sourceDir = join(sharedSessionDataDir(), sourceSessionId, "artifacts");
if (!existsSync(sourceDir)) return;
const targetDir = join(sharedSessionDataDir(), targetSessionId, "artifacts");
mkdirSync(targetDir, { recursive: true });
for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
if (!entry.isFile()) continue;
copyFileSync(join(sourceDir, entry.name), join(targetDir, entry.name));
}
}
function readSessionMetadataTitle(sessionId: string): string | undefined {
const manifest = readSessionManifest(sessionId);
const metadata =
@@ -342,6 +377,7 @@ function createLiveSession(
overrides?: Partial<LiveSession>,
): LiveSession {
return {
environmentId: overrides?.environmentId,
config,
messages: overrides?.messages ?? [],
promptsInQueue: overrides?.promptsInQueue ?? [],
@@ -541,11 +577,12 @@ async function resolveSystemPrompt(config: JsonRecord): Promise<string> {
return String(config.systemPrompt ?? config.system_prompt ?? "").trim();
}
const providerId = String(config.provider ?? config.providerId ?? "").trim();
const mode = config.autoApproveTools
? "yolo"
: config.mode === "plan"
? "plan"
: "act";
// Mode comes from config.mode only. `autoApproveTools` is a tool-approval
// policy (see resolveToolPolicies) and must NOT switch the session to the
// non-interactive "yolo" persona, whose prompt demands a `submit_and_exit`
// tool that interactive desktop sessions do not expose.
const mode =
config.mode === "plan" ? "plan" : config.mode === "yolo" ? "yolo" : "act";
const metadata = await consumeWorkspaceMetadata(cwd);
const inlineRules =
typeof config.rules === "string" && config.rules.trim().length > 0
@@ -627,9 +664,86 @@ function applyPendingPrompts(
}));
}
function getSessionManager(ctx: SidecarContext): ClineCore {
if (!ctx.sessionManager) throw new Error("Session manager not initialized");
return ctx.sessionManager;
function readEnvironmentId(config: JsonRecord | undefined): string | undefined {
const value = config?.environmentId ?? config?.environment_id;
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function readExplicitSystemPrompt(config: JsonRecord): string {
const value = config.systemPrompt ?? config.system_prompt;
return typeof value === "string" ? value : "";
}
/**
* A remote Hub intentionally has its own HOME and provider settings. Send the
* selected desktop provider configuration over the authenticated SSH tunnel so
* a normal signed-in desktop session can make model requests on the host. The
* refresh token is omitted: v0 uses the current access/API token and never
* writes the desktop's reusable OAuth credential to the remote settings file.
*/
async function withRemoteProviderCredentials(
config: JsonRecord,
): Promise<JsonRecord> {
const providerId = String(config.provider ?? config.providerId ?? "").trim();
if (!providerId) return config;
const manager = new ProviderSettingsManager();
const settings = manager.getProviderSettings(providerId);
if (!settings) return config;
const modelId = String(
config.model ?? config.modelId ?? settings.model ?? "",
).trim();
const storedConfig = {
...toProviderConfig(
{
...settings,
...(modelId ? { model: modelId } : {}),
},
{ includeKnownModels: false },
),
};
delete storedConfig.refreshToken;
const explicitProviderConfig =
config.providerConfig && typeof config.providerConfig === "object"
? (config.providerConfig as JsonRecord)
: undefined;
const explicitApiKey = String(config.apiKey ?? config.api_key ?? "").trim();
const oauth = explicitApiKey
? null
: await new RuntimeOAuthTokenManager({
providerSettingsManager: manager,
}).resolveProviderApiKey({ providerId });
const apiKey =
explicitApiKey ||
oauth?.apiKey ||
resolveProviderApiKeyFromSettings(manager, providerId) ||
String(storedConfig.apiKey ?? "").trim();
return {
...config,
...(apiKey ? { apiKey } : {}),
...(!config.baseUrl && storedConfig.baseUrl
? { baseUrl: storedConfig.baseUrl }
: {}),
...(!config.headers && storedConfig.headers
? { headers: storedConfig.headers }
: {}),
providerConfig: {
...storedConfig,
...(explicitProviderConfig ?? {}),
providerId,
...(modelId ? { modelId } : {}),
},
};
}
function getSessionManager(
ctx: SidecarContext,
sessionId?: string,
config?: JsonRecord,
): ClineCore {
return getSessionRuntimeBinding(ctx, sessionId, readEnvironmentId(config))
.sessionManager;
}
// ---------------------------------------------------------------------------
@@ -641,20 +755,34 @@ async function handleStart(
request: ChatSessionCommandRequest,
): Promise<unknown> {
if (!request.config) throw new Error("config is required");
const manager = getSessionManager(ctx);
const systemPrompt = await resolveSystemPrompt(request.config);
const binding = getSessionRuntimeBinding(
ctx,
undefined,
readEnvironmentId(request.config),
);
const manager = binding.sessionManager;
const config =
binding.kind === "ssh"
? await withRemoteProviderCredentials(request.config)
: request.config;
// Workspace discovery must happen where the files live. Local desktop
// sessions keep the eager prompt path; SSH sessions leave a blank prompt for
// the remote Hub's LocalRuntimeHost bootstrap to compose from remote metadata.
const systemPrompt =
binding.kind === "ssh"
? readExplicitSystemPrompt(config)
: await resolveSystemPrompt(config);
const requestedSessionId = String(
request.config.sessionId ?? request.config.session_id ?? "",
config.sessionId ?? config.session_id ?? "",
).trim();
const initialMessages =
Array.isArray(request.config.initialMessages) &&
request.config.initialMessages.length > 0
? request.config.initialMessages
Array.isArray(config.initialMessages) && config.initialMessages.length > 0
? config.initialMessages
: requestedSessionId
? (readPersistedChatMessages(requestedSessionId) ?? undefined)
: undefined;
const coreConfig: JsonRecord = {
...buildCoreSessionConfig(request.config),
...buildCoreSessionConfig(config),
systemPrompt,
...(initialMessages ? { initialMessages } : {}),
};
@@ -670,7 +798,10 @@ async function handleStart(
});
const startResult = await manager.start({
...splitCoreSessionConfig(coreConfig as unknown as ClineCoreStartConfig),
source: SessionSource.DESKTOP,
source:
request.source === SessionSource.REALTIME
? SessionSource.REALTIME
: SessionSource.DESKTOP,
interactive: true,
...(initialMessages ? { initialMessages } : {}),
toolPolicies: resolveToolPolicies(request.config),
@@ -680,8 +811,14 @@ async function handleStart(
const cwd = startResult.manifest.cwd;
ctx.logger?.log("Desktop chat session started", { sessionId });
const session = createLiveSession(
{ ...request.config, cwd, workspaceRoot },
{
...config,
cwd,
workspaceRoot,
environmentId: binding.environmentId,
},
{
environmentId: binding.environmentId,
messages: initialMessages,
prompt: initialMessages
? derivePromptFromMessages(initialMessages)
@@ -693,7 +830,13 @@ async function handleStart(
},
);
ctx.liveSessions.set(sessionId, session);
return { sessionId, cwd, workspaceRoot };
ctx.sessionEnvironmentIds.set(sessionId, binding.environmentId);
return {
sessionId,
cwd,
workspaceRoot,
environmentId: binding.environmentId,
};
}
async function handleAttach(
@@ -705,7 +848,18 @@ async function handleAttach(
throw new Error("sessionId is required");
}
const manager = getSessionManager(ctx);
const preferredEnvironmentId = readEnvironmentId(request.config);
// An explicit environment is a hard routing boundary. Falling through to
// another connected host can attach a same-id session from the wrong machine.
const binding = preferredEnvironmentId
? getSessionRuntimeBinding(ctx, sessionId, preferredEnvironmentId)
: await findSessionRuntimeBinding(ctx, sessionId);
if (!binding) {
throw new Error(
`Session ${sessionId} not found in a connected environment`,
);
}
const manager = binding.sessionManager;
const session = await manager.get(sessionId);
if (!session) {
throw new Error(`Session ${sessionId} not found`);
@@ -716,13 +870,12 @@ async function handleAttach(
? (session.metadata as JsonRecord)
: undefined;
const existing = ctx.liveSessions.get(sessionId);
if (ctx.hubClient) {
await ctx.hubClient.command("session.attach", { sessionId }, sessionId);
}
const attachedConfig: JsonRecord = {
await binding.hubClient.command("session.attach", { sessionId }, sessionId);
const baseAttachedConfig: JsonRecord = {
...(existing?.config ?? {}),
...(request.config ?? {}),
sessionId,
environmentId: binding.environmentId,
provider: session.provider || existing?.config.provider || "",
model: session.model || existing?.config.model || "",
cwd:
@@ -736,9 +889,14 @@ async function handleAttach(
String(request.config?.workspaceRoot ?? "").trim() ||
String(existing?.config.workspaceRoot ?? "").trim(),
};
const attachedConfig =
binding.kind === "ssh"
? await withRemoteProviderCredentials(baseAttachedConfig)
: baseAttachedConfig;
ctx.liveSessions.set(
sessionId,
createLiveSession(attachedConfig, {
environmentId: binding.environmentId,
messages: existing?.messages ?? [],
promptsInQueue: existing?.promptsInQueue ?? [],
status: session.status,
@@ -758,9 +916,11 @@ async function handleAttach(
consumedAttachmentFiles: existing?.consumedAttachmentFiles,
}),
);
ctx.sessionEnvironmentIds.set(sessionId, binding.environmentId);
return {
sessionId,
environmentId: binding.environmentId,
status: session.status,
provider: session.provider,
model: session.model,
@@ -813,11 +973,20 @@ async function startRebuiltSession(
async function rebuildSessionForProviderChange(
ctx: SidecarContext,
manager: ClineCore,
binding: SessionRuntimeBinding,
sessionId: string,
previousConfig: JsonRecord,
nextConfig: JsonRecord,
): Promise<void> {
const manager = binding.sessionManager;
const effectivePreviousConfig =
binding.kind === "ssh"
? await withRemoteProviderCredentials(previousConfig)
: previousConfig;
const effectiveNextConfig =
binding.kind === "ssh"
? await withRemoteProviderCredentials(nextConfig)
: nextConfig;
const [messages, compactionState, previousSystemPrompt, nextSystemPrompt] =
await Promise.all([
manager.readMessages(sessionId),
@@ -829,8 +998,12 @@ async function rebuildSessionForProviderChange(
});
return undefined;
}),
resolveSystemPrompt(previousConfig),
resolveSystemPrompt(nextConfig),
binding.kind === "ssh"
? readExplicitSystemPrompt(effectivePreviousConfig)
: resolveSystemPrompt(effectivePreviousConfig),
binding.kind === "ssh"
? readExplicitSystemPrompt(effectiveNextConfig)
: resolveSystemPrompt(effectiveNextConfig),
]);
await manager.stop(sessionId);
@@ -839,7 +1012,7 @@ async function rebuildSessionForProviderChange(
await startRebuiltSession(
manager,
sessionId,
nextConfig,
effectiveNextConfig,
nextSystemPrompt,
messages,
compactionState,
@@ -850,7 +1023,7 @@ async function rebuildSessionForProviderChange(
// persistence failure cannot leave runtime and cached state diverged.
await manager.updateSessionConnection(
sessionId,
buildSessionConnectionUpdate(nextConfig),
buildSessionConnectionUpdate(effectiveNextConfig),
);
} catch (replacementError) {
try {
@@ -860,14 +1033,14 @@ async function rebuildSessionForProviderChange(
await startRebuiltSession(
manager,
sessionId,
previousConfig,
effectivePreviousConfig,
previousSystemPrompt,
messages,
compactionState,
);
await manager.updateSessionConnection(
sessionId,
buildSessionConnectionUpdate(previousConfig),
buildSessionConnectionUpdate(effectivePreviousConfig),
);
} catch (rollbackError) {
throw new AggregateError(
@@ -892,8 +1065,13 @@ async function handleSend(
if (!prompt && !hasAttachments) {
throw new Error("prompt or attachment is required");
}
const manager = getSessionManager(ctx);
const session = ctx.liveSessions.get(sessionId);
const binding = getSessionRuntimeBinding(
ctx,
sessionId,
readEnvironmentId(request.config),
);
const manager = binding.sessionManager;
const lockedWorkspaceKey = workspacePathKey(
session?.config ?? request.config,
);
@@ -910,7 +1088,8 @@ async function handleSend(
// `/command` token as the session's display prompt.
const runtimePrompt = await resolveDesktopRuntimePrompt(
ctx,
readWorkspacePath(session?.config ?? request.config) ?? ctx.workspaceRoot,
readWorkspacePath(session?.config ?? request.config) ??
ctx.localWorkspaceRoot,
prompt,
request.config?.mode ?? session?.config?.mode,
);
@@ -947,7 +1126,7 @@ async function handleSend(
if (providerChanged && session) {
await rebuildSessionForProviderChange(
ctx,
manager,
binding,
sessionId,
session.config,
nextConfig,
@@ -970,6 +1149,14 @@ async function handleSend(
}
}
if (
binding.kind === "ssh" &&
(request.attachments?.userFiles?.length ?? 0) > 0
) {
throw new Error(
"File attachments are not available in the SSH proof of concept yet. Images and text prompts are supported.",
);
}
const userFiles = materializeUserFiles(
sessionId,
request.attachments?.userFiles,
@@ -1043,11 +1230,19 @@ async function handleSend(
} else {
deleteMaterializedAttachments(sessionId, userFiles);
}
ctx.logger?.log("Desktop chat prompt completed", {
const completionMetadata = {
sessionId,
finishReason: result?.finishReason,
textLength: result?.text?.length ?? 0,
});
};
if (result?.finishReason === "error") {
ctx.logger?.error?.("Desktop chat prompt returned an error result", {
...completionMetadata,
failure: result.text,
});
} else {
ctx.logger?.log("Desktop chat prompt completed", completionMetadata);
}
if (session && ownsBusyState) {
session.status = "idle";
if (result?.messages) session.messages = result.messages;
@@ -1106,7 +1301,7 @@ async function handleStop(
): Promise<unknown> {
const sessionId = request.sessionId?.trim();
if (!sessionId) throw new Error("sessionId is required");
await getSessionManager(ctx).stop(sessionId);
await getSessionManager(ctx, sessionId, request.config).stop(sessionId);
const session = ctx.liveSessions.get(sessionId);
if (session) {
session.busy = false;
@@ -1121,7 +1316,10 @@ async function handleAbort(
): Promise<unknown> {
const sessionId = request.sessionId?.trim();
if (!sessionId) throw new Error("sessionId is required");
await getSessionManager(ctx).abort(sessionId, "user_abort");
await getSessionManager(ctx, sessionId, request.config).abort(
sessionId,
"user_abort",
);
const session = ctx.liveSessions.get(sessionId);
if (session) {
session.busy = false;
@@ -1143,7 +1341,7 @@ async function handleFork(
) {
throw new Error("forkBeforeRunCount must be a positive integer");
}
const manager = getSessionManager(ctx);
const manager = getSessionManager(ctx, sourceSessionId, request.config);
const liveSourceSession = ctx.liveSessions.get(sourceSessionId);
if (
forkBeforeRunCount !== undefined &&
@@ -1195,10 +1393,18 @@ async function handleForkUnlocked(
sourceSession: SessionRecord | undefined,
restoreWorkspacePath?: string,
): Promise<unknown> {
const manager = getSessionManager(ctx);
const sourceMessages =
const binding = getSessionRuntimeBinding(
ctx,
sourceSessionId,
readEnvironmentId(request.config),
);
const manager = binding.sessionManager;
let sourceMessages =
readPersistedChatMessages(sourceSessionId) ??
ctx.liveSessions.get(sourceSessionId)?.messages;
if (!sourceMessages?.length && binding.kind === "ssh") {
sourceMessages = await manager.readMessages(sourceSessionId);
}
if (!sourceMessages?.length) {
throw new Error(`No messages found for session ${sourceSessionId}`);
}
@@ -1208,10 +1414,11 @@ async function handleForkUnlocked(
? (sourceSession.metadata as JsonRecord)
: undefined) ?? readSessionMetadata(sourceSessionId);
const liveConfig = ctx.liveSessions.get(sourceSessionId)?.config;
const forkConfig: JsonRecord = {
const baseForkConfig: JsonRecord = {
...(liveConfig ?? {}),
...(request.config ?? {}),
sessionId: undefined,
environmentId: binding.environmentId,
provider:
sourceSession?.provider ||
liveConfig?.provider ||
@@ -1241,6 +1448,10 @@ async function handleForkUnlocked(
request.config?.cwd ||
"",
};
const forkConfig =
binding.kind === "ssh"
? await withRemoteProviderCredentials(baseForkConfig)
: baseForkConfig;
const checkpointMetadata =
sourceMetadata?.checkpoint !== undefined
? { checkpoints: sourceMetadata.checkpoint }
@@ -1261,7 +1472,10 @@ async function handleForkUnlocked(
...checkpointMetadata,
},
};
const systemPrompt = await resolveSystemPrompt(forkConfig);
const systemPrompt =
binding.kind === "ssh"
? readExplicitSystemPrompt(forkConfig)
: await resolveSystemPrompt(forkConfig);
const startInput = {
...splitCoreSessionConfig(
buildCoreSessionConfig({
@@ -1307,6 +1521,7 @@ async function handleForkUnlocked(
});
newSessionId = started.sessionId;
}
copySessionGeneratedArtifacts(sourceSessionId, newSessionId);
try {
const read = await manager.readMessages(newSessionId);
if (forkBeforeRunCount !== undefined || read.length > 0) {
@@ -1321,12 +1536,14 @@ async function handleForkUnlocked(
ctx.liveSessions.set(
newSessionId,
createLiveSession(forkConfig, {
environmentId: binding.environmentId,
messages: forkMessages,
prompt: derivePromptFromMessages(forkMessages),
title: readSessionMetadataTitle(sourceSessionId),
status: "idle",
}),
);
ctx.sessionEnvironmentIds.set(newSessionId, binding.environmentId);
sendPromptsInQueueSnapshot(ctx, sourceSessionId);
sendPromptsInQueueSnapshot(ctx, newSessionId);
return {
@@ -1348,10 +1565,11 @@ async function handleReset(
session?.status === "running" ||
session?.status === "stopping"
) {
await getSessionManager(ctx).stop(sessionId);
await getSessionManager(ctx, sessionId, request.config).stop(sessionId);
}
discardAllTrackedAttachments(sessionId, session);
ctx.liveSessions.delete(sessionId);
ctx.sessionEnvironmentIds.delete(sessionId);
sendPromptsInQueueSnapshot(ctx, sessionId);
}
return { sessionId: request.sessionId, ok: true };
@@ -1370,14 +1588,25 @@ async function handleRestoreCheckpoint(
runCount < 1
)
throw new Error("checkpointRunCount must be a positive integer");
const config = request.config;
if (!config) throw new Error("config is required to restore a checkpoint");
const requestedConfig = request.config;
if (!requestedConfig)
throw new Error("config is required to restore a checkpoint");
const cwd =
(typeof config.cwd === "string" && config.cwd.trim()) ||
(typeof config.workspaceRoot === "string" && config.workspaceRoot.trim()) ||
(typeof requestedConfig.cwd === "string" && requestedConfig.cwd.trim()) ||
(typeof requestedConfig.workspaceRoot === "string" &&
requestedConfig.workspaceRoot.trim()) ||
"";
if (!cwd) throw new Error("config.cwd or config.workspaceRoot is required");
const manager = getSessionManager(ctx);
const binding = getSessionRuntimeBinding(
ctx,
sourceSessionId,
readEnvironmentId(requestedConfig),
);
const manager = binding.sessionManager;
const config =
binding.kind === "ssh"
? await withRemoteProviderCredentials(requestedConfig)
: requestedConfig;
return withWorkspaceRestoreLock(ctx, cwd, async () => {
const restored = await manager.restore({
sessionId: sourceSessionId,
@@ -1388,7 +1617,10 @@ async function handleRestoreCheckpoint(
...splitCoreSessionConfig(
buildCoreSessionConfig({
...config,
systemPrompt: await resolveSystemPrompt(config),
systemPrompt:
binding.kind === "ssh"
? readExplicitSystemPrompt(config)
: await resolveSystemPrompt(config),
}) as unknown as ClineCoreStartConfig,
),
source: SessionSource.DESKTOP,
@@ -1401,6 +1633,7 @@ async function handleRestoreCheckpoint(
if (!sessionId || !restoredMessages) {
throw new Error("Checkpoint restore did not return a new session");
}
copySessionGeneratedArtifacts(sourceSessionId, sessionId);
discardAllTrackedAttachments(
sourceSessionId,
ctx.liveSessions.get(sourceSessionId),
@@ -1409,12 +1642,14 @@ async function handleRestoreCheckpoint(
ctx.liveSessions.set(
sessionId,
createLiveSession(config, {
environmentId: binding.environmentId,
messages: restoredMessages,
prompt: derivePromptFromMessages(restoredMessages),
title: readSessionMetadataTitle(sourceSessionId),
status: "idle",
}),
);
ctx.sessionEnvironmentIds.set(sessionId, binding.environmentId);
// A restore that reuses the source session id leaves the persisted
// transcript describing the discarded turns, and read_session_messages
// prefers that file over the live session. Write the trimmed history so
@@ -1435,9 +1670,11 @@ async function handlePendingPrompts(
): Promise<unknown> {
const sessionId = request.sessionId?.trim();
if (!sessionId) throw new Error("sessionId is required");
const prompts = await getSessionManager(ctx).pendingPrompts.list({
const prompts = await getSessionManager(
ctx,
sessionId,
});
request.config,
).pendingPrompts.list({ sessionId });
return {
sessionId,
promptsInQueue: applyPendingPrompts(ctx, sessionId, prompts),
@@ -1452,7 +1689,7 @@ async function handleSteerPrompt(
const promptId = request.promptId?.trim();
if (!sessionId || !promptId)
throw new Error("sessionId and promptId are required");
const manager = getSessionManager(ctx);
const manager = getSessionManager(ctx, sessionId, request.config);
const result = await manager.pendingPrompts.update({
sessionId,
promptId,
@@ -1484,7 +1721,7 @@ async function handleUpdatePendingPrompt(
// through handleSend, so resolve slash commands here too.
const runtimePrompt = await resolveDesktopRuntimePrompt(
ctx,
readWorkspacePath(sessionConfig) ?? ctx.workspaceRoot,
readWorkspacePath(sessionConfig) ?? ctx.localWorkspaceRoot,
prompt,
sessionConfig?.mode,
);
@@ -1510,7 +1747,7 @@ async function handleRemovePendingPrompt(
if (!sessionId || !promptId) {
throw new Error("sessionId and promptId are required");
}
const manager = getSessionManager(ctx);
const manager = getSessionManager(ctx, sessionId, request.config);
const result = await manager.pendingPrompts.delete({
sessionId,
promptId,
@@ -1553,6 +1790,125 @@ export async function handleChatSessionCommand(
ctx: SidecarContext,
request: ChatSessionCommandRequest,
): Promise<unknown> {
const executionTarget = String(request.config?.executionTarget ?? "").trim();
const sessionId = request.sessionId?.trim();
const existingCloudSession =
sessionId &&
ctx.liveSessions.get(sessionId)?.config.executionTarget === "cloud";
if (executionTarget === "cloud" || existingCloudSession) {
const cloud = getCloudSessionManager(ctx);
// The approval preference lives only client-side; keep the live session
// current so a lazily created inner session inherits the user's choice.
const requestedAutoApprove = request.config?.autoApproveTools;
if (typeof requestedAutoApprove === "boolean" && sessionId) {
const live = ctx.liveSessions.get(sessionId);
if (live) {
live.config.autoApproveTools = requestedAutoApprove;
}
}
switch (request.action) {
case "start": {
const requestedSessionId = String(
request.config?.sessionId ?? request.config?.session_id ?? "",
).trim();
// Server ids attach even with a cold registry; client-planned ids create.
if (requestedSessionId && cloud.isCloudSession(requestedSessionId)) {
return await cloud.attach(requestedSessionId);
}
// Gate new sessions only; existing cloud sessions must remain usable.
if (!isCloudAgentsEnabled()) {
throw new Error(
"Cloud sessions are not enabled for this account yet.",
);
}
const repoUrl = String(request.config?.repoUrl ?? "").trim();
const modelId = String(
request.config?.model ?? request.config?.modelId ?? "",
).trim();
if (!repoUrl || !modelId) {
throw new Error("repoUrl and model are required for a cloud session");
}
const branch = String(request.config?.branch ?? "").trim();
const initialPrompt = request.prompt?.trim();
const reasoningEffort = readReasoningEffort(
request.config?.reasoningEffort,
);
return await cloud.create({
repoUrl,
modelId,
...(initialPrompt ? { initialPrompt } : {}),
...(branch ? { branch } : {}),
...(typeof request.config?.thinking === "boolean"
? { thinking: request.config.thinking }
: {}),
...(reasoningEffort ? { reasoningEffort } : {}),
...(typeof requestedAutoApprove === "boolean"
? { autoApproveTools: requestedAutoApprove }
: {}),
});
}
case "attach":
if (!sessionId) throw new Error("sessionId is required");
return await cloud.attach(sessionId);
case "send": {
if (!sessionId) throw new Error("sessionId is required");
const prompt = request.prompt?.trim();
if (!prompt) throw new Error("prompt is required");
if (request.attachments?.userFiles?.length) {
throw new Error(
"File attachments are not supported in cloud sessions",
);
}
const live = ctx.liveSessions.get(sessionId);
const delivery = request.delivery ?? (live?.busy ? "queue" : undefined);
const modelId = String(
request.config?.model ?? request.config?.modelId ?? "",
).trim();
return await cloud.send(
sessionId,
prompt,
delivery,
modelId || undefined,
request.attachments?.userImages,
);
}
case "stop":
case "abort":
if (!sessionId) throw new Error("sessionId is required");
return await cloud.abort(sessionId);
case "pending_prompts":
if (!sessionId) throw new Error("sessionId is required");
return await cloud.pendingPrompts(sessionId);
case "steer_prompt": {
const promptId = request.promptId?.trim();
if (!sessionId || !promptId)
throw new Error("sessionId and promptId are required");
return await cloud.updatePendingPrompt(sessionId, promptId, {
delivery: "steer",
});
}
case "update_pending_prompt": {
const promptId = request.promptId?.trim();
const prompt = request.prompt?.trim();
if (!sessionId || !promptId)
throw new Error("sessionId and promptId are required");
if (!prompt) throw new Error("prompt is required");
return await cloud.updatePendingPrompt(sessionId, promptId, {
prompt,
});
}
case "remove_pending_prompt": {
const promptId = request.promptId?.trim();
if (!sessionId || !promptId)
throw new Error("sessionId and promptId are required");
return await cloud.removePendingPrompt(sessionId, promptId);
}
default:
throw new Error(
`${request.action} is not supported for cloud sessions yet`,
);
}
}
const handler = ACTION_HANDLERS[request.action];
if (!handler) throw new Error("unsupported action");
return handler(ctx, request);
@@ -0,0 +1,54 @@
import {
captureAuthRefreshSoftFailure,
getProviderAuthHandler,
type ProviderSettingsManager,
RuntimeOAuthTokenManager,
} from "@cline/core";
import type { SidecarContext } from "./types";
// Cline access tokens expire between app launches, so account requests must
// resolve through the refresh-aware OAuth manager instead of reading the
// persisted token directly. A single shared instance keeps concurrent account
// requests single-flight; the refresh token is single-use, so parallel
// refreshes would invalidate each other.
let clineOAuthTokenManager: RuntimeOAuthTokenManager | undefined;
export async function resolveFreshClineAuthToken(
manager: ProviderSettingsManager,
ctx?: SidecarContext,
): Promise<string | undefined> {
let refreshError: Error | undefined;
try {
clineOAuthTokenManager ??= new RuntimeOAuthTokenManager();
const resolution = await clineOAuthTokenManager.resolveProviderApiKey({
providerId: "cline",
});
if (resolution?.apiKey) {
return resolution.apiKey;
}
} catch (error) {
// Fall back to the persisted token; when one exists the account request
// surfaces the auth failure to the caller.
refreshError = error instanceof Error ? error : new Error(String(error));
}
// The canonical handler applies the same formatting the refresh path uses:
// OAuth access tokens gain the `workos:` prefix core-platform expects,
// while raw API keys pass through untouched.
const persisted = getProviderAuthHandler("cline")?.getApiKey(
manager.getProviderSettings("cline"),
);
// Never-signed-in resolves to undefined without a refresh attempt and is
// silent. A refresh failure with no persisted fallback means credentials
// existed but yielded nothing — that is the signal a real auth regression
// would show up as, so report exactly one event for it.
if (!persisted && refreshError && ctx) {
ctx.logger?.error?.("Cline auth token refresh failed with no fallback", {
error: refreshError,
});
captureAuthRefreshSoftFailure(ctx.telemetry, "cline", {
errorName: refreshError.name,
errorCode: "desktop_refresh_failed_no_fallback_token",
});
}
return persisted;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -110,8 +110,10 @@ describe("cline_account command auth states", () => {
const serviceOptions = clineAccountServiceCtorMock.mock.calls[0][0] as {
getAuthToken: () => Promise<string | undefined>;
};
// Persisted OAuth tokens gain the `workos:` prefix required by
// core-platform (see cline-auth.ts).
await expect(serviceOptions.getAuthToken()).resolves.toBe(
"persisted-token",
"workos:persisted-token",
);
expect(capture).not.toHaveBeenCalled();
});
@@ -0,0 +1,280 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { isClineAccountNotAuthenticatedResult } from "../webview/lib/cline-account-state";
import {
listClineGitHubRepositories,
listClineIntegrations,
resolveGitHubInstallUrl,
} from "./commands-integrations";
import type { SidecarContext } from "./types";
const getProviderSettingsMock = vi.hoisted(() => vi.fn());
const resolveProviderApiKeyMock = vi.hoisted(() => vi.fn());
vi.mock("@cline/core", async () => {
const actual =
await vi.importActual<typeof import("@cline/core")>("@cline/core");
return {
...actual,
ProviderSettingsManager: class {
getProviderSettings = getProviderSettingsMock;
},
RuntimeOAuthTokenManager: class {
resolveProviderApiKey = resolveProviderApiKeyMock;
},
};
});
function createContext() {
const capture = vi.fn();
const ctx = {
telemetry: { capture },
logger: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
} as unknown as SidecarContext;
return { ctx, capture };
}
const REQUEST_OPTIONS = {
apiBaseUrl: "https://api.example.com",
appBaseUrl: "https://app.example.com",
authToken: "test-token",
} as const;
function requestOptions(fetchImpl: ReturnType<typeof vi.fn>) {
return {
...REQUEST_OPTIONS,
fetchImpl: fetchImpl as unknown as typeof fetch,
};
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
beforeEach(() => {
getProviderSettingsMock.mockReset();
resolveProviderApiKeyMock.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("listClineIntegrations", () => {
it("lists integrations through the envelope with a bearer token", async () => {
const fetchImpl = vi
.fn()
.mockResolvedValue(
jsonResponse({ success: true, data: [{ provider: "github" }] }),
);
const result = await listClineIntegrations(requestOptions(fetchImpl));
expect(result).toEqual([{ provider: "github" }]);
const [url, init] = fetchImpl.mock.calls[0] as [URL, RequestInit];
expect(String(url)).toBe("https://api.example.com/api/v1/integrations");
expect((init.headers as Record<string, string>).Authorization).toBe(
"Bearer test-token",
);
});
});
describe("listClineGitHubRepositories", () => {
it("lists GitHub repositories from the repositories endpoint", async () => {
const fetchImpl = vi
.fn()
.mockResolvedValue(
jsonResponse({ success: true, data: [{ full_name: "cline/cline" }] }),
);
const result = await listClineGitHubRepositories(requestOptions(fetchImpl));
expect(result).toEqual([{ full_name: "cline/cline" }]);
expect(String(fetchImpl.mock.calls[0][0])).toBe(
"https://api.example.com/api/v1/integrations/github/repositories",
);
});
it("surfaces the API envelope error message on failures", async () => {
const fetchImpl = vi
.fn()
.mockResolvedValue(
jsonResponse(
{ success: false, error: "failed to list integrations" },
500,
),
);
await expect(
listClineIntegrations(requestOptions(fetchImpl)),
).rejects.toThrow("failed to list integrations");
});
});
describe("resolveGitHubInstallUrl", () => {
it("resolves the GitHub install URL from the redirect location", async () => {
const fetchImpl = vi.fn().mockResolvedValue(
new Response(null, {
status: 302,
headers: {
location: "https://github.com/apps/cline/installations/new?state=abc",
},
}),
);
const result = await resolveGitHubInstallUrl(requestOptions(fetchImpl));
expect(result).toEqual({
url: "https://github.com/apps/cline/installations/new?state=abc",
});
const [url, init] = fetchImpl.mock.calls[0] as [URL, RequestInit];
expect(url.origin + url.pathname).toBe(
"https://api.example.com/api/v1/integrations/github/install",
);
// The post-install browser hop must land on the Cline dashboard.
expect(url.searchParams.get("redirect")).toBe(
"https://app.example.com/dashboard/integrations",
);
// The redirect must be read, not followed: the Location URL is the result.
expect(init.redirect).toBe("manual");
});
it("resolves a relative redirect location against the request URL", async () => {
const fetchImpl = vi.fn().mockResolvedValue(
new Response(null, {
status: 302,
headers: { location: "//github.com/apps/cline/installations/new" },
}),
);
const result = await resolveGitHubInstallUrl(requestOptions(fetchImpl));
// A bare relative Location would blow up later in the URL opener.
expect(result).toEqual({
url: "https://github.com/apps/cline/installations/new",
});
});
it.each([
["https://evil.example/apps/cline", "evil.example"],
["https://github.com.evil.example/apps/cline", "github.com.evil.example"],
// Subdomains are not part of the install flow, so they are not allowed
// either -- the host must be exactly github.com.
["https://gist.github.com/apps/cline", "gist.github.com"],
])("rejects a redirect to a non-GitHub host (%s)", async (location, host) => {
const fetchImpl = vi
.fn()
.mockResolvedValue(
new Response(null, { status: 302, headers: { location } }),
);
await expect(
resolveGitHubInstallUrl(requestOptions(fetchImpl)),
).rejects.toThrow(`unexpected host: ${host}`);
});
it("rejects a redirect that does not use https", async () => {
const fetchImpl = vi.fn().mockResolvedValue(
new Response(null, {
status: 302,
headers: { location: "http://github.com/apps/cline" },
}),
);
await expect(
resolveGitHubInstallUrl(requestOptions(fetchImpl)),
).rejects.toThrow("must use https");
});
it("rejects a redirect location that is not a usable URL", async () => {
const fetchImpl = vi.fn().mockResolvedValue(
new Response(null, {
status: 302,
headers: { location: "http://" },
}),
);
await expect(
resolveGitHubInstallUrl(requestOptions(fetchImpl)),
).rejects.toThrow("not a valid URL");
});
it("throws when the install endpoint does not answer with a redirect", async () => {
const fetchImpl = vi
.fn()
.mockResolvedValue(
jsonResponse({ error: "authentication required" }, 401),
);
await expect(
resolveGitHubInstallUrl(requestOptions(fetchImpl)),
).rejects.toThrow("authentication required");
});
});
describe("cline_integrations command auth states", () => {
it("returns a typed not-authenticated result when signed out, without calling the API", async () => {
const { ctx, capture } = createContext();
resolveProviderApiKeyMock.mockResolvedValue(null);
getProviderSettingsMock.mockReturnValue(undefined);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const { handleCommand } = await import("./commands");
const result = await handleCommand(ctx, "cline_integrations", {
operation: "list",
});
expect(isClineAccountNotAuthenticatedResult(result)).toBe(true);
expect(fetchMock).not.toHaveBeenCalled();
expect(capture).not.toHaveBeenCalled();
});
it("calls the Cline API with the resolved fresh token when signed in", async () => {
const { ctx } = createContext();
resolveProviderApiKeyMock.mockResolvedValue({
apiKey: "fresh-token",
refreshed: true,
});
getProviderSettingsMock.mockReturnValue(undefined);
const fetchMock = vi
.fn()
.mockResolvedValue(
jsonResponse({ success: true, data: [{ provider: "github" }] }),
);
vi.stubGlobal("fetch", fetchMock);
const { handleCommand } = await import("./commands");
const result = await handleCommand(ctx, "cline_integrations", {
operation: "list",
});
expect(result).toEqual([{ provider: "github" }]);
const [url, init] = fetchMock.mock.calls[0] as [URL, RequestInit];
expect(String(url)).toContain("/api/v1/integrations");
expect((init.headers as Record<string, string>).Authorization).toBe(
"Bearer fresh-token",
);
});
it("rejects unknown operations", async () => {
const { ctx } = createContext();
resolveProviderApiKeyMock.mockResolvedValue({
apiKey: "fresh-token",
refreshed: true,
});
getProviderSettingsMock.mockReturnValue(undefined);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const { handleCommand } = await import("./commands");
await expect(
handleCommand(ctx, "cline_integrations", {
operation: "dropIntegrations",
}),
).rejects.toThrow("Unsupported Cline integrations operation");
expect(fetchMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,183 @@
import type {
ClineGitHubRepository,
ClineIntegration,
} from "../webview/lib/cline-integrations-types";
const DEFAULT_TIMEOUT_MS = 30_000;
const GITHUB_INSTALL_HOST = "github.com";
function resolveInstallRedirect(location: string, requestUrl: URL): string {
let resolved: URL;
try {
resolved = new URL(location, requestUrl);
} catch {
throw new Error(`GitHub install redirect is not a valid URL: ${location}`);
}
if (resolved.protocol !== "https:") {
throw new Error(
`GitHub install redirect must use https, got: ${resolved.protocol}`,
);
}
if (resolved.hostname !== GITHUB_INSTALL_HOST) {
throw new Error(
`GitHub install redirect pointed at an unexpected host: ${resolved.hostname}`,
);
}
return resolved.toString();
}
export interface ClineIntegrationsRequestOptions {
apiBaseUrl: string;
/** Frontend origin the browser install flow returns to when it finishes. */
appBaseUrl: string;
authToken: string;
requestTimeoutMs?: number;
fetchImpl?: typeof fetch;
}
export async function listClineIntegrations(
options: ClineIntegrationsRequestOptions,
): Promise<ClineIntegration[]> {
const data = await requestClineApiJson("/api/v1/integrations", options);
return Array.isArray(data) ? (data as ClineIntegration[]) : [];
}
export async function listClineGitHubRepositories(
options: ClineIntegrationsRequestOptions,
): Promise<ClineGitHubRepository[]> {
const data = await requestClineApiJson(
"/api/v1/integrations/github/repositories",
options,
);
return Array.isArray(data) ? (data as ClineGitHubRepository[]) : [];
}
export async function resolveGitHubInstallUrl(
options: ClineIntegrationsRequestOptions,
): Promise<{ url: string }> {
const fetchImpl = options.fetchImpl ?? fetch;
const installUrl = new URL(
"/api/v1/integrations/github/install",
options.apiBaseUrl,
);
installUrl.searchParams.set(
"redirect",
new URL("/dashboard/integrations", options.appBaseUrl).toString(),
);
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
options.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS,
);
try {
const response = await fetchImpl(installUrl, {
method: "GET",
headers: { Authorization: `Bearer ${options.authToken}` },
redirect: "manual",
signal: controller.signal,
});
const location = response.headers.get("location");
if (response.status >= 300 && response.status < 400 && location?.trim()) {
return { url: resolveInstallRedirect(location.trim(), installUrl) };
}
const text = await response.text().catch(() => "");
let parsed: unknown;
try {
parsed = text.trim() ? JSON.parse(text) : undefined;
} catch {
parsed = undefined;
}
throw new Error(formatRequestFailure(response.status, text, parsed));
} finally {
clearTimeout(timeout);
}
}
function getEnvelopeError(parsed: unknown): string | undefined {
if (typeof parsed !== "object" || parsed === null || !("error" in parsed)) {
return undefined;
}
const error = (parsed as { error?: unknown }).error;
return typeof error === "string" && error.trim() ? error : undefined;
}
function formatRequestFailure(
status: number,
bodyText: string,
parsed: unknown,
): string {
const envelopeError = getEnvelopeError(parsed);
if (envelopeError) {
return envelopeError;
}
const body = bodyText.trim();
if (body) {
const preview = body.length > 200 ? `${body.slice(0, 200)}...` : body;
return `Cline integrations request failed with status ${status}: ${preview}`;
}
return `Cline integrations request failed with status ${status}`;
}
async function requestClineApiJson(
endpoint: string,
options: ClineIntegrationsRequestOptions,
): Promise<unknown> {
const fetchImpl = options.fetchImpl ?? fetch;
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
options.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS,
);
try {
const response = await fetchImpl(new URL(endpoint, options.apiBaseUrl), {
method: "GET",
headers: {
Authorization: `Bearer ${options.authToken}`,
"Content-Type": "application/json",
},
signal: controller.signal,
});
const text = await response.text();
let parsed: unknown;
if (text.trim()) {
try {
parsed = JSON.parse(text);
} catch {
if (!response.ok) {
throw new Error(
formatRequestFailure(response.status, text, undefined),
);
}
throw new Error("Cline integrations response was not valid JSON");
}
}
if (!response.ok) {
throw new Error(formatRequestFailure(response.status, text, parsed));
}
if (typeof parsed === "object" && parsed !== null && "success" in parsed) {
const envelope = parsed as {
success?: unknown;
error?: unknown;
data?: unknown;
};
if (typeof envelope.success === "boolean") {
if (!envelope.success) {
throw new Error(
getEnvelopeError(parsed) || "Cline integrations request failed",
);
}
return envelope.data ?? null;
}
}
return parsed ?? null;
} finally {
clearTimeout(timeout);
}
}
@@ -0,0 +1,113 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { handleCommand } from "./commands";
import type { SidecarContext } from "./types";
function createContext(): {
ctx: SidecarContext;
events: Array<{ name: string; payload: Record<string, unknown> }>;
} {
const events: Array<{ name: string; payload: Record<string, unknown> }> = [];
const ctx = {
liveSessions: new Map(),
restoringWorkspacePaths: new Set(),
streamIndices: new Map(),
wsClients: new Set([
{
send(message: string) {
const parsed = JSON.parse(message) as {
event: { name: string; payload: Record<string, unknown> };
};
events.push(parsed.event);
},
},
]),
pendingApprovals: new Map(),
pendingQuestions: new Map(),
sessionManager: null,
hubClient: null,
workspaceRoot: "/local/workspace",
unsubscribeSessionEvents: null,
cloudSessionManager: null,
} as unknown as SidecarContext;
return { ctx, events };
}
let dataDir: string;
beforeEach(() => {
dataDir = mkdtempSync(join(tmpdir(), "cline-commands-settings-"));
process.env.CLINE_DATA_DIR = dataDir;
});
afterEach(() => {
delete process.env.CLINE_CODE_CLOUD_AGENTS;
delete process.env.CLINE_DATA_DIR;
rmSync(dataDir, { recursive: true, force: true });
});
describe("desktop settings commands", () => {
it("reads default desktop settings and an off feature gate", async () => {
const { ctx } = createContext();
await expect(
handleCommand(ctx, "get_desktop_settings", {}),
).resolves.toEqual({ cloudSessionsEnabled: false });
await expect(handleCommand(ctx, "get_feature_flags", {})).resolves.toEqual({
cloudAgents: false,
});
});
it("rejects a non-boolean cloud sessions toggle value", async () => {
const { ctx, events } = createContext();
await expect(
handleCommand(ctx, "set_cloud_sessions_enabled", {
cloud_sessions_enabled: "yes",
}),
).rejects.toThrow("cloud_sessions_enabled must be a boolean");
expect(events).toEqual([]);
});
it("persists the toggle and broadcasts the new gate immediately", async () => {
const { ctx, events } = createContext();
await expect(
handleCommand(ctx, "set_cloud_sessions_enabled", {
cloud_sessions_enabled: true,
}),
).resolves.toEqual({ cloudSessionsEnabled: true });
// Open webviews re-evaluate without waiting for a restart or account
// change.
expect(events).toEqual([
{ name: "feature_flags_changed", payload: { cloudAgents: true } },
]);
await expect(handleCommand(ctx, "get_feature_flags", {})).resolves.toEqual({
cloudAgents: true,
});
await handleCommand(ctx, "set_cloud_sessions_enabled", {
cloud_sessions_enabled: false,
});
expect(events.at(-1)).toEqual({
name: "feature_flags_changed",
payload: { cloudAgents: false },
});
});
it("reports the env override through the feature gate", async () => {
const { ctx } = createContext();
process.env.CLINE_CODE_CLOUD_AGENTS = "1";
await expect(handleCommand(ctx, "get_feature_flags", {})).resolves.toEqual({
cloudAgents: true,
});
// The toggle's stored value is reported as-is; the override only
// affects the effective gate.
await expect(
handleCommand(ctx, "get_desktop_settings", {}),
).resolves.toEqual({ cloudSessionsEnabled: false });
});
});
File diff suppressed because it is too large Load Diff
@@ -21,6 +21,11 @@ vi.mock("@cline/core", async () => {
await vi.importActual<typeof import("@cline/core")>("@cline/core");
return {
...actual,
ClientSettingsManager: class {
initializeModesIfMissing = vi.fn();
read = vi.fn(() => ({ modes: {} }));
getModeSettings = vi.fn(() => undefined);
},
ClineCore: {
create: createCoreMock,
},
@@ -186,7 +191,7 @@ describe("Code sidecar runtime capabilities", () => {
const ctx = createSidecarContext("/workspace/project");
const hubClient = await ensureSharedHubClient(ctx);
expect(hubClient).toBe(ctx.hubClient);
expect(hubClient).toBeDefined();
expect(ensureCompatibleLocalHubUrlMock).toHaveBeenCalledWith({
strategy: "require-hub",
@@ -222,6 +227,43 @@ describe("Code sidecar runtime capabilities", () => {
});
});
it("leaves attached-session content projection to the Core event stream", async () => {
const { createSidecarContext, handleHubLiveEvent } = await import(
"./context"
);
const ctx = createSidecarContext("/workspace/project");
ctx.wsClients.add({ send: vi.fn() });
ctx.liveSessions.set("session-image", {
config: {},
messages: [],
promptsInQueue: [],
busy: true,
startedAt: Date.now(),
status: "running",
attachedViaHub: true,
});
for (const event of [
"assistant.delta",
"assistant.image",
"reasoning.delta",
"tool.started",
"tool.finished",
]) {
handleHubLiveEvent(ctx, {
event,
sessionId: "session-image",
payload: {
text: "one canonical copy",
toolCallId: "tool-1",
toolName: "run_commands",
},
});
}
expect(readEvents(ctx)).toEqual([]);
});
it("announces a queued prompt start once when drain emits both queue events", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
@@ -515,6 +557,37 @@ describe("Code sidecar runtime capabilities", () => {
).toEqual([]);
});
it("keeps an approval visible when its remote acknowledgement fails", async () => {
const { createSidecarContext } = await import("./context");
const { handleCommand } = await import("./commands");
const ctx = createSidecarContext("/workspace/project");
ctx.pendingApprovals.set("cloud-approval", {
item: {
requestId: "cloud-approval",
sessionId: "ses-cloud",
createdAt: new Date().toISOString(),
toolCallId: "tool-1",
toolName: "run_commands",
},
resolve: async () => {
throw new Error("hub disconnected");
},
});
await expect(
handleCommand(ctx, "respond_tool_approval", {
sessionId: "ses-cloud",
requestId: "cloud-approval",
approved: true,
}),
).rejects.toThrow("hub disconnected");
expect(
await handleCommand(ctx, "poll_tool_approvals", {
sessionId: "ses-cloud",
}),
).toEqual([expect.objectContaining({ requestId: "cloud-approval" })]);
});
it("routes routine commands through the connected shared Hub client", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
@@ -589,4 +662,37 @@ describe("disposeSidecarContext attachment cleanup", () => {
expect(existsSync(queuedFile)).toBe(false);
expect(ctx.liveSessions.size).toBe(0);
});
it("waits for pending approval callbacks before shutdown completes", async () => {
const { createSidecarContext, disposeSidecarContext } = await import(
"./context"
);
const ctx = createSidecarContext("/workspace/project");
let release: (() => void) | undefined;
ctx.pendingApprovals.set("approval-1", {
item: {
requestId: "approval-1",
sessionId: "session-1",
createdAt: new Date().toISOString(),
toolCallId: "tool-1",
toolName: "run_commands",
input: {},
},
resolve: async () =>
await new Promise<void>((resolve) => {
release = resolve;
}),
});
let disposed = false;
const disposing = disposeSidecarContext(ctx, "test_shutdown").then(() => {
disposed = true;
});
await vi.waitFor(() => expect(release).toBeTypeOf("function"));
expect(disposed).toBe(false);
release?.();
await disposing;
expect(disposed).toBe(true);
});
});
+331 -88
View File
@@ -28,8 +28,10 @@ import type {
PendingAskQuestion,
PendingToolApproval,
PromptInQueue,
SessionRuntimeBinding,
SidecarContext,
} from "./types";
import { LOCAL_ENVIRONMENT_ID } from "./types";
const ASK_QUESTION_TIMEOUT_MS = 5 * 60_000;
const hubClientInitialization = new WeakMap<
@@ -165,7 +167,7 @@ export function serializeQueuedPromptStart(input: {
});
}
function sendPromptsInQueueSnapshot(
export function sendPromptsInQueueSnapshot(
ctx: SidecarContext,
sessionId: string,
): void {
@@ -501,12 +503,15 @@ export function createSidecarContext(
wsClients: new Set(),
pendingApprovals: new Map(),
pendingQuestions: new Map(),
sessionManager: null,
hubClient: null,
workspaceRoot,
runtimeBindings: new Map(),
sessionEnvironmentIds: new Map(),
activeEnvironmentId: LOCAL_ENVIRONMENT_ID,
remoteEnvironments: null,
localWorkspaceRoot: workspaceRoot,
logger: observability.logger,
telemetry: observability.telemetry,
unsubscribeSessionEvents: null,
cloudSessionManager: null,
hubBuildMismatch: null,
};
}
@@ -516,9 +521,7 @@ export async function disposeSidecarContext(
reason = "code_sidecar_shutdown",
): Promise<void> {
const cleanup: Array<Promise<unknown>> = [];
ctx.unsubscribeSessionEvents?.();
ctx.unsubscribeSessionEvents = null;
const approvalCleanup: Array<Promise<unknown>> = [];
for (const [sessionId, session] of ctx.liveSessions) {
discardAllTrackedAttachments(sessionId, session);
@@ -534,7 +537,21 @@ export async function disposeSidecarContext(
}
ctx.wsClients.clear();
for (const pending of ctx.pendingApprovals.values()) {
pending.resolve({ approved: false, reason });
// Cloud sessions outlive this app: denying their approvals on local
// shutdown would fail a tool call on a pod that keeps running and
// could otherwise be answered later (from here or another surface).
// Drop those entries locally and leave the remote approval pending.
if (ctx.cloudSessionManager?.isCloudSession(pending.item.sessionId)) {
continue;
}
try {
approvalCleanup.push(
Promise.resolve(pending.resolve({ approved: false, reason })),
);
} catch (error) {
// Keep disposing the remaining resources, then preserve the failure.
approvalCleanup.push(Promise.reject(error));
}
}
ctx.pendingApprovals.clear();
for (const pending of ctx.pendingQuestions.values()) {
@@ -542,20 +559,28 @@ export async function disposeSidecarContext(
pending.reject(new Error(reason));
}
ctx.pendingQuestions.clear();
// Approval callbacks may need the Hub/cloud clients that are disposed below.
const approvalResults = await Promise.allSettled(approvalCleanup);
const hubClient = ctx.hubClient;
ctx.hubClient = null;
if (hubClient) {
cleanup.push(hubClient.dispose());
const cloudSessionManager = ctx.cloudSessionManager;
ctx.cloudSessionManager = null;
if (cloudSessionManager) {
cleanup.push(cloudSessionManager.dispose());
}
const sessionManager = ctx.sessionManager;
ctx.sessionManager = null;
if (sessionManager) {
cleanup.push(sessionManager.dispose(reason));
for (const binding of ctx.runtimeBindings?.values() ?? []) {
binding.unsubscribeSessionEvents();
cleanup.push(binding.hubClient.dispose());
cleanup.push(binding.sessionManager.dispose(reason));
}
ctx.runtimeBindings?.clear();
ctx.sessionEnvironmentIds?.clear();
if (ctx.remoteEnvironments) {
cleanup.push(ctx.remoteEnvironments.dispose());
ctx.remoteEnvironments = null;
}
const results = await Promise.allSettled(cleanup);
const results = [...approvalResults, ...(await Promise.allSettled(cleanup))];
const firstFailure = results.find(
(result): result is PromiseRejectedResult => result.status === "rejected",
);
@@ -685,6 +710,7 @@ export function handleHubLiveEvent(
sessionId?: string;
payload?: Record<string, unknown>;
},
options: { relayRawAssistantText?: boolean } = {},
): void {
const sessionId = typeof event.sessionId === "string" ? event.sessionId : "";
if (!sessionId) {
@@ -697,13 +723,23 @@ export function handleHubLiveEvent(
switch (event.event) {
case "assistant.delta": {
const text =
typeof event.payload?.text === "string" ? event.payload.text : "";
if (text) {
emitChunk(ctx, sessionId, "chat_text", text);
if (options.relayRawAssistantText) {
const text =
typeof event.payload?.text === "string" ? event.payload.text : "";
if (text) emitChunk(ctx, sessionId, "chat_text", text);
}
return;
}
case "assistant.image":
case "assistant.video":
case "assistant.audio":
case "reasoning.delta":
case "tool.started":
case "tool.finished":
// HubRuntimeHost already projects these into the canonical Core event
// stream consumed by handleCoreSessionEvent. Relaying the raw Hub copy
// too duplicates assistant output and tool activity.
return;
case "assistant.media": {
const media = event.payload?.media;
if (isGeneratedMedia(media)) {
@@ -711,61 +747,84 @@ export function handleHubLiveEvent(
}
return;
}
case "reasoning.delta": {
const text =
typeof event.payload?.text === "string" ? event.payload.text : "";
const redacted = event.payload?.redacted === true;
if (!text && !redacted) {
case "usage.updated": {
const delta =
event.payload?.delta &&
typeof event.payload.delta === "object" &&
!Array.isArray(event.payload.delta)
? (event.payload.delta as Record<string, unknown>)
: {};
const totals =
event.payload?.totals &&
typeof event.payload.totals === "object" &&
!Array.isArray(event.payload.totals)
? (event.payload.totals as Record<string, unknown>)
: {};
emitChunk(
ctx,
sessionId,
"chat_usage",
JSON.stringify({
inputTokens: delta.inputTokens,
outputTokens: delta.outputTokens,
cacheReadTokens: delta.cacheReadTokens,
cacheWriteTokens: delta.cacheWriteTokens,
cost: delta.totalCost,
totalInputTokens: totals.inputTokens,
totalOutputTokens: totals.outputTokens,
totalCost: totals.totalCost,
}),
);
return;
}
case "session.pending_prompts": {
const items = Array.isArray(event.payload?.prompts)
? (event.payload.prompts as Array<Record<string, unknown>>)
: [];
const mapped: PromptInQueue[] = items
.map((item) => ({
id: typeof item.id === "string" ? item.id : "",
prompt: typeof item.prompt === "string" ? item.prompt : "",
steer: item.delivery === "steer",
attachmentCount:
typeof item.attachmentCount === "number" ? item.attachmentCount : 0,
userImages: Array.isArray(item.userImages)
? (item.userImages as string[])
: undefined,
}))
.filter((item) => item.id && (item.prompt || item.attachmentCount > 0));
reconcileQueuedAttachments(
session,
mapped.map((item) => item.id),
);
// No "head submitted" inference here, unlike the local queue-drain
// handler: the hub emits an explicit session.pending_prompt_submitted
// for real submissions, and a snapshot can also shrink because a
// prompt was REMOVED — inferring a start would render the deleted
// prompt in the transcript as if it had been sent.
session.promptsInQueue = mapped;
sendPromptsInQueueSnapshot(ctx, sessionId);
return;
}
case "session.pending_prompt_submitted": {
const item =
event.payload?.prompt && typeof event.payload.prompt === "object"
? (event.payload.prompt as Record<string, unknown>)
: undefined;
const promptId = typeof item?.id === "string" ? item.id : "";
if (!promptId) {
return;
}
emitChunk(
ctx,
sessionId,
"chat_reasoning",
JSON.stringify({ text, redacted }),
);
return;
}
case "tool.started": {
emitChunk(
ctx,
sessionId,
"chat_tool_call_start",
JSON.stringify({
toolCallId:
typeof event.payload?.toolCallId === "string"
? event.payload.toolCallId
: undefined,
toolName:
typeof event.payload?.toolName === "string"
? event.payload.toolName
: "tool",
input: event.payload?.input,
}),
);
return;
}
case "tool.finished": {
emitChunk(
ctx,
sessionId,
"chat_tool_call_end",
JSON.stringify({
toolCallId:
typeof event.payload?.toolCallId === "string"
? event.payload.toolCallId
: undefined,
toolName:
typeof event.payload?.toolName === "string"
? event.payload.toolName
: "tool",
output: event.payload?.output,
error:
typeof event.payload?.error === "string"
? event.payload.error
: undefined,
}),
);
markQueuedAttachmentsSubmitted(session, promptId);
emitQueuedPromptStart(ctx, sessionId, session, {
promptId,
prompt: typeof item?.prompt === "string" ? item.prompt : "",
attachmentCount:
typeof item?.attachmentCount === "number" ? item.attachmentCount : 0,
userImages: Array.isArray(item?.userImages)
? (item.userImages as string[])
: undefined,
});
return;
}
case "run.started":
@@ -777,20 +836,43 @@ export function handleHubLiveEvent(
!Array.isArray(event.payload.session)
? (event.payload.session as Record<string, unknown>)
: undefined;
const status =
const runtimeStatus =
typeof payloadSession?.status === "string"
? payloadSession.status
: event.event === "run.started"
? "running"
: session.status;
// Hub "pending" means the run is blocked on approval or otherwise
// still active. Desktop has no pending status, so expose it as running
// and keep later prompts on the queue path.
const status = runtimeStatus === "pending" ? "running" : runtimeStatus;
// Pods emit periodic session.updated snapshots; re-broadcasting an
// unchanged status marks the session unread in the sidebar every time.
const statusChanged = session.status !== status;
session.status = status;
session.busy = status === "running";
sendEvent(ctx, "chat_session_status", { sessionId, status });
if (statusChanged) {
sendEvent(ctx, "chat_session_status", { sessionId, status });
}
return;
}
case "run.completed":
case "run.failed":
case "run.aborted": {
// A failed run carries its reason in payload.error — surface it, or
// the user sees a silent no-op (e.g. "Insufficient balance").
const errorMessage =
event.event === "run.failed" && typeof event.payload?.error === "string"
? event.payload.error.trim()
: "";
if (errorMessage) {
emitChunk(
ctx,
sessionId,
"chat_core_log",
JSON.stringify({ level: "error", message: errorMessage }),
);
}
const reason =
typeof event.payload?.reason === "string"
? event.payload.reason
@@ -822,8 +904,8 @@ export async function initializeSessionManager(
telemetry: ctx.telemetry,
hub: {
strategy: "require-hub",
workspaceRoot: ctx.workspaceRoot,
cwd: ctx.workspaceRoot,
workspaceRoot: ctx.localWorkspaceRoot,
cwd: ctx.localWorkspaceRoot,
clientType: "code-sidecar",
displayName: "Code App sidecar",
},
@@ -834,24 +916,186 @@ export async function initializeSessionManager(
handleCoreSessionEvent(ctx, event);
});
let hubClient: NodeHubClient;
try {
await ensureSharedHubClient(ctx, sessionManager.runtimeAddress);
hubClient = await ensureSharedHubClient(ctx, sessionManager.runtimeAddress);
} catch (error) {
unsubscribe();
await sessionManager.dispose("code_sidecar_hub_initialization_failed");
throw error;
}
ctx.sessionManager = sessionManager;
ctx.unsubscribeSessionEvents = unsubscribe;
ctx.runtimeBindings.set(LOCAL_ENVIRONMENT_ID, {
environmentId: LOCAL_ENVIRONMENT_ID,
kind: "local",
workspaceRoot: ctx.localWorkspaceRoot,
sessionManager,
hubClient,
unsubscribeSessionEvents: unsubscribe,
});
}
export function getRuntimeBinding(
ctx: SidecarContext,
environmentId = ctx.activeEnvironmentId,
): SessionRuntimeBinding {
const binding = ctx.runtimeBindings.get(environmentId);
if (!binding) {
throw new Error(`Environment ${environmentId} is not connected.`);
}
return binding;
}
export function getSessionRuntimeBinding(
ctx: SidecarContext,
sessionId?: string,
requestedEnvironmentId?: string,
): SessionRuntimeBinding {
const environmentId =
requestedEnvironmentId?.trim() ||
(sessionId ? ctx.liveSessions.get(sessionId)?.environmentId : undefined) ||
(sessionId ? ctx.sessionEnvironmentIds.get(sessionId) : undefined) ||
ctx.activeEnvironmentId;
return getRuntimeBinding(ctx, environmentId);
}
export async function findSessionRuntimeBinding(
ctx: SidecarContext,
sessionId: string,
preferredEnvironmentId?: string,
): Promise<SessionRuntimeBinding | undefined> {
const knownEnvironmentId =
preferredEnvironmentId?.trim() ||
ctx.liveSessions.get(sessionId)?.environmentId ||
ctx.sessionEnvironmentIds.get(sessionId);
const candidates = [
...(knownEnvironmentId
? [ctx.runtimeBindings.get(knownEnvironmentId)]
: []),
...ctx.runtimeBindings.values(),
].filter(
(binding, index, all): binding is SessionRuntimeBinding =>
Boolean(binding) && all.indexOf(binding) === index,
);
for (const binding of candidates) {
try {
if (await binding.sessionManager.get(sessionId)) {
ctx.sessionEnvironmentIds.set(sessionId, binding.environmentId);
return binding;
}
} catch {
// A disconnected environment must not prevent another runtime from
// resolving the session.
}
}
return undefined;
}
async function disposeRuntimeBinding(
binding: SessionRuntimeBinding,
reason: string,
): Promise<void> {
try {
binding.unsubscribeSessionEvents();
} catch {
// Continue disposing the Hub clients even if an event source has already
// torn down its subscription.
}
await Promise.allSettled([
binding.hubClient.dispose(),
binding.sessionManager.dispose(reason),
]);
}
export async function connectRemoteSessionRuntime(
ctx: SidecarContext,
connection: NonNullable<SessionRuntimeBinding["remote"]>,
): Promise<SessionRuntimeBinding> {
const environmentId = connection.profile.id;
const existing = ctx.runtimeBindings.get(environmentId);
const sessionManager = await ClineCore.create({
clientName: "cline-code",
backendMode: "remote",
capabilities: createSidecarRuntimeCapabilities(ctx),
logger: ctx.logger,
telemetry: ctx.telemetry,
remote: {
endpoint: connection.endpoint,
authToken: connection.authToken,
workspaceRoot: connection.workspaceRoot,
cwd: connection.workspaceRoot,
clientType: "code-sidecar-ssh",
displayName: `Code App (${connection.profile.name})`,
},
});
let unsubscribe: (() => void) | undefined;
let hubClient: NodeHubClient | undefined;
try {
unsubscribe = sessionManager.subscribe((event: CoreSessionEvent) => {
handleCoreSessionEvent(ctx, event);
});
hubClient = new NodeHubClient({
url: connection.endpoint,
authToken: connection.authToken,
clientType: "code-sidecar-ssh-observer",
displayName: `Code App observer (${connection.profile.name})`,
workspaceRoot: connection.workspaceRoot,
cwd: connection.workspaceRoot,
});
await hubClient.connect();
hubClient.subscribe((event) => handleHubLiveEvent(ctx, event));
} catch (error) {
try {
unsubscribe?.();
} catch {
// Best effort; the failed runtime still needs to be disposed below.
}
const disposals: Promise<unknown>[] = [
sessionManager.dispose("code_sidecar_remote_initialization_failed"),
];
if (hubClient) disposals.push(hubClient.dispose());
await Promise.allSettled(disposals);
throw error;
}
const binding: SessionRuntimeBinding = {
environmentId,
kind: "ssh",
workspaceRoot: connection.workspaceRoot,
sessionManager,
hubClient,
unsubscribeSessionEvents: unsubscribe,
remote: connection,
};
ctx.runtimeBindings.set(environmentId, binding);
ctx.activeEnvironmentId = environmentId;
if (existing) {
await disposeRuntimeBinding(existing, "code_sidecar_remote_reconnect");
}
return binding;
}
export async function disconnectRemoteSessionRuntime(
ctx: SidecarContext,
environmentId: string,
): Promise<void> {
const binding = ctx.runtimeBindings.get(environmentId);
if (binding?.kind === "ssh") {
ctx.runtimeBindings.delete(environmentId);
await disposeRuntimeBinding(binding, "code_sidecar_remote_disconnect");
}
if (ctx.activeEnvironmentId === environmentId) {
ctx.activeEnvironmentId = LOCAL_ENVIRONMENT_ID;
}
}
export async function ensureSharedHubClient(
ctx: SidecarContext,
preferredUrl?: string,
): Promise<NodeHubClient> {
if (ctx.hubClient) {
return ctx.hubClient;
const existing = ctx.runtimeBindings.get(LOCAL_ENVIRONMENT_ID)?.hubClient;
if (existing) {
return existing;
}
const pending = hubClientInitialization.get(ctx);
if (pending) {
@@ -863,8 +1107,8 @@ export async function ensureSharedHubClient(
preferredUrl?.trim() ||
(await ensureCompatibleLocalHubUrl({
strategy: "require-hub",
workspaceRoot: ctx.workspaceRoot,
cwd: ctx.workspaceRoot,
workspaceRoot: ctx.localWorkspaceRoot,
cwd: ctx.localWorkspaceRoot,
}));
if (!url) {
throw new Error("Unable to start or connect to the shared Cline Hub.");
@@ -874,15 +1118,14 @@ export async function ensureSharedHubClient(
url,
clientType: "code-sidecar-observer",
displayName: "Code App observer",
workspaceRoot: ctx.workspaceRoot,
cwd: ctx.workspaceRoot,
workspaceRoot: ctx.localWorkspaceRoot,
cwd: ctx.localWorkspaceRoot,
});
try {
await client.connect();
client.subscribe((event) => {
handleHubLiveEvent(ctx, event);
});
ctx.hubClient = client;
return client;
} catch (error) {
await client.dispose().catch(() => undefined);
@@ -0,0 +1,67 @@
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
readDesktopSettings,
resolveDesktopSettingsPath,
setCloudSessionsEnabled,
} from "./desktop-settings";
let dataDir: string;
beforeEach(() => {
dataDir = mkdtempSync(join(tmpdir(), "cline-desktop-settings-"));
process.env.CLINE_DATA_DIR = dataDir;
});
afterEach(() => {
delete process.env.CLINE_DATA_DIR;
rmSync(dataDir, { recursive: true, force: true });
});
describe("desktop settings", () => {
it("defaults cloud sessions to off when no settings file exists", () => {
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
});
it("persists the cloud sessions opt-in and reads it back", () => {
expect(setCloudSessionsEnabled(true)).toEqual({
cloudSessionsEnabled: true,
});
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: true });
expect(setCloudSessionsEnabled(false)).toEqual({
cloudSessionsEnabled: false,
});
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
});
it("writes into the desktop-owned settings file, not global-settings", () => {
setCloudSessionsEnabled(true);
const raw = JSON.parse(
readFileSync(resolveDesktopSettingsPath(), "utf8"),
) as Record<string, unknown>;
expect(resolveDesktopSettingsPath().endsWith("code-settings.json")).toBe(
true,
);
expect(raw.cloudSessionsEnabled).toBe(true);
});
it("treats malformed files and non-boolean values as off", () => {
mkdirSync(dirname(resolveDesktopSettingsPath()), { recursive: true });
writeFileSync(resolveDesktopSettingsPath(), "{not json", "utf8");
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
writeFileSync(
resolveDesktopSettingsPath(),
JSON.stringify({ cloudSessionsEnabled: "yes" }),
"utf8",
);
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
});
});
@@ -0,0 +1,56 @@
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { resolveClineDataDir } from "@cline/shared/storage";
/**
* Desktop-app-only preferences.
*
* These are kept out of the shared `global-settings.json` on purpose: that
* file is parsed with a strict schema by every Cline app, and an older CLI
* writing settings would silently strip fields it does not know about.
*/
export type DesktopSettings = {
/** Opt-in gate for cloud sessions while the feature is in preview. */
cloudSessionsEnabled: boolean;
};
const DEFAULT_SETTINGS: DesktopSettings = {
cloudSessionsEnabled: false,
};
export function resolveDesktopSettingsPath(): string {
return join(resolveClineDataDir(), "settings", "code-settings.json");
}
export function readDesktopSettings(): DesktopSettings {
let raw: string;
try {
raw = readFileSync(resolveDesktopSettingsPath(), "utf8");
} catch {
return { ...DEFAULT_SETTINGS };
}
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
return {
cloudSessionsEnabled: parsed.cloudSessionsEnabled === true,
};
} catch {
return { ...DEFAULT_SETTINGS };
}
}
export function writeDesktopSettings(settings: DesktopSettings): void {
const filePath = resolveDesktopSettingsPath();
mkdirSync(dirname(filePath), { recursive: true });
// Write-then-rename keeps the file whole if two app instances race or the
// process dies mid-write; a torn JSON file would silently reset settings.
const tempPath = `${filePath}.${process.pid}.tmp`;
writeFileSync(tempPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
renameSync(tempPath, filePath);
}
export function setCloudSessionsEnabled(enabled: boolean): DesktopSettings {
const next = { ...readDesktopSettings(), cloudSessionsEnabled: enabled };
writeDesktopSettings(next);
return next;
}
@@ -0,0 +1,45 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { setCloudSessionsEnabled } from "./desktop-settings";
import { isCloudAgentsEnabled } from "./feature-flags";
let dataDir: string;
beforeEach(() => {
dataDir = mkdtempSync(join(tmpdir(), "cline-feature-flags-"));
process.env.CLINE_DATA_DIR = dataDir;
});
afterEach(() => {
delete process.env.CLINE_CODE_CLOUD_AGENTS;
delete process.env.CLINE_DATA_DIR;
rmSync(dataDir, { recursive: true, force: true });
});
describe("isCloudAgentsEnabled", () => {
it("defaults to off with no setting and no override", () => {
expect(isCloudAgentsEnabled()).toBe(false);
});
it("follows the user's settings opt-in toggle", () => {
setCloudSessionsEnabled(true);
expect(isCloudAgentsEnabled()).toBe(true);
setCloudSessionsEnabled(false);
expect(isCloudAgentsEnabled()).toBe(false);
});
it("honors the env override in both directions", () => {
process.env.CLINE_CODE_CLOUD_AGENTS = "1";
expect(isCloudAgentsEnabled()).toBe(true);
process.env.CLINE_CODE_CLOUD_AGENTS = "true";
expect(isCloudAgentsEnabled()).toBe(true);
// The env override wins over the settings toggle in both directions.
setCloudSessionsEnabled(true);
process.env.CLINE_CODE_CLOUD_AGENTS = "0";
expect(isCloudAgentsEnabled()).toBe(false);
process.env.CLINE_CODE_CLOUD_AGENTS = "false";
expect(isCloudAgentsEnabled()).toBe(false);
});
});
@@ -0,0 +1,21 @@
import { readDesktopSettings } from "./desktop-settings";
/**
* Env override first; otherwise the user's explicit opt-in from Settings.
*
* Cloud sessions are in preview, so the gate is a toggle the user flips in
* Settings → General (default off) rather than a remote rollout flag. If a
* PostHog flag is later added to control the toggle's visibility, wire it
* through the sidecar's get_feature_flags command (see the commented gate in
* settings-view.tsx).
*/
export function isCloudAgentsEnabled(): boolean {
const override = process.env.CLINE_CODE_CLOUD_AGENTS?.trim().toLowerCase();
if (override === "1" || override === "true") {
return true;
}
if (override === "0" || override === "false") {
return false;
}
return readDesktopSettings().cloudSessionsEnabled;
}
+3 -5
View File
@@ -4,7 +4,7 @@ import {
setHomeDirIfUnset,
watchManagedHubBuildMismatch,
} from "@cline/core";
import { captureSdkError, claimHubDaemonProcess } from "@cline/shared";
import { captureSdkError } from "@cline/shared";
import { prewarmWorkspaceMetadata } from "./chat-session";
import { configureConnectorCliLaunch } from "./connectors";
import {
@@ -15,6 +15,7 @@ import {
} from "./context";
import { createDesktopObservability } from "./observability";
import { resolveWorkspaceRoot } from "./paths";
import { runRemoteHelperEntrypoint } from "./remote-helper";
import { startServer } from "./server";
import { ensureLoginShellPath } from "./shell-path";
import { buildTelemetrySelfcheckReport } from "./telemetry-selfcheck";
@@ -186,10 +187,7 @@ async function runEntrypoint(): Promise<void> {
runTelemetrySelfcheck();
return;
}
// Claim rather than read: consuming the sentinel keeps daemon-hosted sessions
// from handing it to every process they spawn.
if (claimHubDaemonProcess()) {
await import("@cline/core/hub/daemon-entry");
if (await runRemoteHelperEntrypoint()) {
return;
}
await main();
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { handleCommand } from "./commands";
import { createSidecarContext } from "./context";
import {
buildMcpServersResponse,
shouldProbeMcpServerAfterUpsert,
@@ -21,8 +22,9 @@ function createContext(workspaceRoot: string): SidecarContext {
hubClient: null,
workspaceRoot,
unsubscribeSessionEvents: null,
cloudSessionManager: null,
hubBuildMismatch: null,
};
} as unknown as SidecarContext;
}
describe("desktop MCP settings", () => {
@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SidecarContext } from "./types";
const mocks = vi.hoisted(() => ({
handleCommand: vi.fn(),
}));
vi.mock("./commands", () => ({
handleCommand: mocks.handleCommand,
}));
import { createFetchHandler } from "./server";
const server = {
port: 3126,
upgrade: vi.fn(() => true),
};
describe("realtime session endpoint", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns the short-lived realtime setup expected by the AI SDK hook", async () => {
mocks.handleCommand.mockResolvedValue({
kind: "realtime",
providerId: "vercel-ai-gateway",
modelId: "openai/gpt-realtime",
supportsTools: true,
token: "ephemeral-token",
url: "wss://realtime.example.test/session",
expiresAt: 1_785_280_000,
transport: "vercel-ai-gateway",
sessionConfig: { outputModalities: ["audio"] },
});
const ctx = {} as SidecarContext;
const response = await createFetchHandler(ctx, vi.fn())(
new Request("http://127.0.0.1:3126/api/modes/realtime/session", {
method: "POST",
headers: { origin: "tauri://localhost" },
}),
server,
);
expect(mocks.handleCommand).toHaveBeenCalledWith(
ctx,
"create_mode_session",
{ mode: "realtimeVoice" },
);
expect(response?.status).toBe(200);
if (!response) throw new Error("Missing realtime endpoint response");
await expect(response.json()).resolves.toEqual({
token: "ephemeral-token",
url: "wss://realtime.example.test/session",
expiresAt: 1_785_280_000,
tools: [
{
type: "function",
name: "run_cline",
description:
"Send the user's complete request to the active Cline agent. You must call this exactly once for every user utterance. Cline owns conversation history, workspace context, tools, MCP, approvals, and persistence. After the tool returns, speak its response faithfully.",
parameters: {
type: "object",
properties: {
request: {
type: "string",
description:
"The user's complete request, preserving all relevant detail.",
},
},
required: ["request"],
additionalProperties: false,
},
},
],
});
});
it("does not invoke the sidecar command for an untrusted origin", async () => {
const response = await createFetchHandler({} as SidecarContext, vi.fn())(
new Request("http://127.0.0.1:3126/api/modes/realtime/session", {
method: "POST",
headers: { origin: "https://attacker.example" },
}),
server,
);
expect(response?.status).toBe(403);
expect(mocks.handleCommand).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,804 @@
import {
mkdirSync,
mkdtempSync,
realpathSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type {
RemoteEnvironmentConnection,
RemoteEnvironmentProfile,
RemoteEnvironmentService,
RemoteEnvironmentStatus,
} from "./remote-environments";
import type { SessionRuntimeBinding, SidecarContext } from "./types";
const coreCreateMock = vi.hoisted(() => vi.fn());
const hubClientConstructorMock = vi.hoisted(() => vi.fn());
const hubConnectMock = vi.hoisted(() => vi.fn());
const hubSubscribeMock = vi.hoisted(() => vi.fn());
const hubDisposeMock = vi.hoisted(() => vi.fn());
const sessionStoreGetMock = vi.hoisted(() => vi.fn());
const sessionStoreDeleteMock = vi.hoisted(() => vi.fn());
const sessionStoreRunMock = vi.hoisted(() => vi.fn());
vi.mock("@cline/core", async () => {
const actual =
await vi.importActual<typeof import("@cline/core")>("@cline/core");
return {
...actual,
ClineCore: {
create: coreCreateMock,
},
SqliteSessionStore: class {
public get(sessionId: string): unknown {
return sessionStoreGetMock(sessionId);
}
public delete(sessionId: string, cascade?: boolean): boolean {
return sessionStoreDeleteMock(sessionId, cascade);
}
public run(sql: string, params?: unknown[]): void {
sessionStoreRunMock(sql, params);
}
},
NodeHubClient: class {
public constructor(options: unknown) {
hubClientConstructorMock(options);
}
public connect(): Promise<void> {
return hubConnectMock();
}
public subscribe(listener: unknown): () => void {
return hubSubscribeMock(listener);
}
public dispose(): Promise<void> {
return hubDisposeMock();
}
},
};
});
const profile: RemoteEnvironmentProfile = {
id: "remote-1",
name: "Build box",
host: "build.example.com",
user: "alice",
port: 2222,
createdAt: "2026-08-06T12:00:00.000Z",
updatedAt: "2026-08-06T12:00:00.000Z",
};
const connection: RemoteEnvironmentConnection = {
profile,
profileId: profile.id,
state: "connected",
endpoint: "ws://127.0.0.1:40123/hub",
authToken: "remote-hub-token",
workspaceRoot: "/home/alice",
homeDir: "/home/alice",
platform: "linux",
arch: "arm64",
remoteHubUrl: "ws://127.0.0.1:25463/hub",
localPort: 40123,
connectedAt: "2026-08-06T12:01:00.000Z",
};
const secondProfile: RemoteEnvironmentProfile = {
...profile,
id: "remote-2",
name: "Test box",
host: "test.example.com",
updatedAt: "2026-08-06T12:02:00.000Z",
};
const secondConnection: RemoteEnvironmentConnection = {
...connection,
profile: secondProfile,
profileId: secondProfile.id,
endpoint: "ws://127.0.0.1:40124/hub",
authToken: "second-remote-hub-token",
workspaceRoot: "/home/tester",
homeDir: "/home/tester",
remoteHubUrl: "ws://127.0.0.1:25464/hub",
localPort: 40124,
connectedAt: "2026-08-06T12:03:00.000Z",
};
type FakeService = {
service: RemoteEnvironmentService;
list: ReturnType<typeof vi.fn>;
upsert: ReturnType<typeof vi.fn>;
test: ReturnType<typeof vi.fn>;
connect: ReturnType<typeof vi.fn>;
disconnect: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
run: ReturnType<typeof vi.fn>;
};
function createFakeService(
availableConnections: RemoteEnvironmentConnection[] = [connection],
): FakeService {
const profiles = availableConnections.map((item) => item.profile);
const availableById = new Map(
availableConnections.map((item) => [item.profileId, item]),
);
const connectedById = new Map<string, RemoteEnvironmentConnection>();
let activeProfileId: string | undefined;
const list = vi.fn(async () => profiles);
const upsert = vi.fn(async () => profile);
const test = vi.fn(
async (): Promise<RemoteEnvironmentStatus> => ({
profileId: profile.id,
state: "available",
updatedAt: "2026-08-06T12:00:30.000Z",
message: "SSH connection succeeded",
remotePlatform: "linux",
remoteArch: "arm64",
}),
);
const connect = vi.fn(async (id: string) => {
const next = availableById.get(id);
if (!next) throw new Error(`Unknown fake remote environment: ${id}`);
connectedById.set(id, next);
activeProfileId = id;
return next;
});
const disconnect = vi.fn(async (id?: string) => {
const targetId = id ?? activeProfileId;
if (!targetId) return false;
const deleted = connectedById.delete(targetId);
if (activeProfileId === targetId) activeProfileId = undefined;
return deleted;
});
const deleteProfile = vi.fn(async () => true);
const run = vi.fn(async () => ({ stdout: "", stderr: "", exitCode: 0 }));
const service = {
list,
upsert,
test,
connect,
disconnect,
delete: deleteProfile,
run,
getActive: vi.fn(() =>
activeProfileId ? connectedById.get(activeProfileId) : undefined,
),
getConnection: vi.fn((id: string) => connectedById.get(id)),
activateConnection: vi.fn((id: string) => {
if (!connectedById.has(id)) return false;
activeProfileId = id;
return true;
}),
getStatuses: vi.fn(() => []),
} as unknown as RemoteEnvironmentService;
return {
service,
list,
upsert,
test,
connect,
disconnect,
delete: deleteProfile,
run,
};
}
function createManager() {
const unsubscribe = vi.fn();
const manager = {
subscribe: vi.fn(() => unsubscribe),
dispose: vi.fn(async () => undefined),
};
return { manager, unsubscribe };
}
function attachEventRecorder(ctx: SidecarContext): ReturnType<typeof vi.fn> {
const send = vi.fn();
ctx.wsClients.add({ send });
return send;
}
function readEvent(send: ReturnType<typeof vi.fn>, index: number) {
return JSON.parse(String(send.mock.calls[index]?.[0]));
}
function createExistingRemoteBinding(
environmentId: string,
): SessionRuntimeBinding {
const sessionManager = {
dispose: vi.fn(async () => undefined),
} as unknown as SessionRuntimeBinding["sessionManager"] & {
dispose: ReturnType<typeof vi.fn>;
};
const hubClient = {
dispose: vi.fn(async () => undefined),
} as unknown as SessionRuntimeBinding["hubClient"] & {
dispose: ReturnType<typeof vi.fn>;
};
return {
environmentId,
kind: "ssh",
workspaceRoot: "/old/workspace",
sessionManager,
hubClient,
unsubscribeSessionEvents: vi.fn(),
};
}
describe("remote environment command routing", () => {
beforeEach(() => {
coreCreateMock.mockReset();
hubClientConstructorMock.mockReset();
hubConnectMock.mockReset();
hubSubscribeMock.mockReset();
hubDisposeMock.mockReset();
sessionStoreGetMock.mockReset();
sessionStoreDeleteMock.mockReset();
sessionStoreRunMock.mockReset();
hubConnectMock.mockResolvedValue(undefined);
hubSubscribeMock.mockReturnValue(() => undefined);
hubDisposeMock.mockResolvedValue(undefined);
sessionStoreGetMock.mockReturnValue(undefined);
sessionStoreDeleteMock.mockReturnValue(false);
});
it("routes list, upsert, and SSH test commands through the configured service", async () => {
const { handleCommand } = await import("./commands");
const { createSidecarContext } = await import("./context");
const fake = createFakeService();
const ctx = createSidecarContext("/local/project");
ctx.remoteEnvironments = fake.service;
await expect(
handleCommand(ctx, "list_remote_environments"),
).resolves.toEqual({
profiles: [profile],
activeEnvironmentId: "local",
activeProfileId: null,
statuses: [],
});
const input = {
id: profile.id,
name: "Build box renamed",
host: profile.host,
};
await expect(
handleCommand(ctx, "upsert_remote_environment", { profile: input }),
).resolves.toEqual({ profile });
expect(fake.upsert).toHaveBeenCalledWith(input);
await expect(
handleCommand(ctx, "test_remote_environment", { id: ` ${profile.id} ` }),
).resolves.toEqual({
profile,
status: "passed",
message: "SSH connection succeeded",
remotePlatform: "linux",
remoteArch: "arm64",
});
expect(fake.test).toHaveBeenCalledWith(profile.id);
});
it("connects an authenticated remote runtime, records its binding, and disconnects it cleanly", async () => {
const { handleCommand } = await import("./commands");
const { createSidecarContext } = await import("./context");
const fake = createFakeService();
const { manager, unsubscribe } = createManager();
coreCreateMock.mockResolvedValue(manager);
const ctx = createSidecarContext("/local/project");
ctx.remoteEnvironments = fake.service;
const send = attachEventRecorder(ctx);
await expect(
handleCommand(ctx, "connect_remote_environment", {
id: profile.id,
}),
).resolves.toEqual({
profile,
status: "connected",
environmentId: profile.id,
activeEnvironmentId: profile.id,
activeProfileId: profile.id,
workspaceRoot: "/home/alice",
homeDir: "/home/alice",
remotePlatform: "linux",
remoteArch: "arm64",
});
expect(fake.connect).toHaveBeenCalledWith(profile.id);
expect(coreCreateMock).toHaveBeenCalledWith(
expect.objectContaining({
clientName: "cline-code",
backendMode: "remote",
remote: {
endpoint: connection.endpoint,
authToken: connection.authToken,
workspaceRoot: connection.workspaceRoot,
cwd: connection.workspaceRoot,
clientType: "code-sidecar-ssh",
displayName: "Code App (Build box)",
},
}),
);
expect(hubClientConstructorMock).toHaveBeenCalledWith({
url: connection.endpoint,
authToken: connection.authToken,
clientType: "code-sidecar-ssh-observer",
displayName: "Code App observer (Build box)",
workspaceRoot: connection.workspaceRoot,
cwd: connection.workspaceRoot,
});
expect(ctx.activeEnvironmentId).toBe(profile.id);
expect(ctx.runtimeBindings.get(profile.id)).toMatchObject({
environmentId: profile.id,
kind: "ssh",
workspaceRoot: connection.workspaceRoot,
remote: connection,
});
expect(readEvent(send, 0)).toEqual({
type: "event",
event: {
name: "remote_environment_changed",
payload: {
profile,
status: "connected",
environmentId: profile.id,
activeEnvironmentId: profile.id,
activeProfileId: profile.id,
workspaceRoot: "/home/alice",
homeDir: "/home/alice",
remotePlatform: "linux",
remoteArch: "arm64",
},
},
});
await expect(
handleCommand(ctx, "disconnect_remote_environment"),
).resolves.toEqual({
status: "disconnected",
disconnectedProfileId: profile.id,
activeEnvironmentId: "local",
activeProfileId: null,
});
expect(fake.disconnect).toHaveBeenCalledWith(profile.id);
expect(unsubscribe).toHaveBeenCalledOnce();
expect(manager.dispose).toHaveBeenCalledWith(
"code_sidecar_remote_disconnect",
);
expect(hubDisposeMock).toHaveBeenCalledOnce();
expect(ctx.runtimeBindings.has(profile.id)).toBe(false);
expect(ctx.activeEnvironmentId).toBe("local");
expect(readEvent(send, 1)).toEqual({
type: "event",
event: {
name: "remote_environment_changed",
payload: {
status: "disconnected",
activeProfileId: null,
activeEnvironmentId: "local",
environmentId: "local",
workspaceRoot: "/local/project",
},
},
});
});
it("rolls back the SSH tunnel and partial runtime when observer authentication fails", async () => {
const { handleCommand } = await import("./commands");
const { createSidecarContext } = await import("./context");
const fake = createFakeService();
const { manager, unsubscribe } = createManager();
coreCreateMock.mockResolvedValue(manager);
hubConnectMock.mockRejectedValue(new Error("remote auth rejected"));
const ctx = createSidecarContext("/local/project");
ctx.remoteEnvironments = fake.service;
const send = attachEventRecorder(ctx);
await expect(
handleCommand(ctx, "connect_remote_environment", { id: profile.id }),
).rejects.toThrow("remote auth rejected");
expect(fake.disconnect).toHaveBeenCalledWith(profile.id);
expect(unsubscribe).toHaveBeenCalledOnce();
expect(manager.dispose).toHaveBeenCalledWith(
"code_sidecar_remote_initialization_failed",
);
expect(hubDisposeMock).toHaveBeenCalledOnce();
expect(ctx.runtimeBindings.has(profile.id)).toBe(false);
expect(ctx.activeEnvironmentId).toBe("local");
expect(send).not.toHaveBeenCalled();
});
it("preserves the previous environment when switching hosts fails", async () => {
const { handleCommand } = await import("./commands");
const { createSidecarContext } = await import("./context");
const fake = createFakeService([connection, secondConnection]);
const firstRuntime = createManager();
const failedRuntime = createManager();
coreCreateMock
.mockResolvedValueOnce(firstRuntime.manager)
.mockResolvedValueOnce(failedRuntime.manager);
const ctx = createSidecarContext("/local/project");
ctx.remoteEnvironments = fake.service;
const send = attachEventRecorder(ctx);
await handleCommand(ctx, "connect_remote_environment", { id: profile.id });
const firstBinding = ctx.runtimeBindings.get(profile.id);
send.mockClear();
hubConnectMock.mockRejectedValueOnce(
new Error("second host auth rejected"),
);
await expect(
handleCommand(ctx, "connect_remote_environment", {
id: secondProfile.id,
}),
).rejects.toThrow("second host auth rejected");
expect(ctx.activeEnvironmentId).toBe(profile.id);
expect(ctx.runtimeBindings.get(profile.id)).toBe(firstBinding);
expect(ctx.runtimeBindings.has(secondProfile.id)).toBe(false);
expect(fake.service.getActive()?.profileId).toBe(profile.id);
expect(fake.disconnect).toHaveBeenCalledWith(secondProfile.id);
expect(fake.disconnect).not.toHaveBeenCalledWith(profile.id);
expect(firstRuntime.unsubscribe).not.toHaveBeenCalled();
expect(firstRuntime.manager.dispose).not.toHaveBeenCalled();
expect(failedRuntime.unsubscribe).toHaveBeenCalledOnce();
expect(failedRuntime.manager.dispose).toHaveBeenCalledWith(
"code_sidecar_remote_initialization_failed",
);
expect(send).not.toHaveBeenCalled();
await expect(
handleCommand(ctx, "list_remote_environments"),
).resolves.toMatchObject({
activeEnvironmentId: profile.id,
activeProfileId: profile.id,
});
});
it("retires the previous runtime only after a host switch commits", async () => {
const { handleCommand } = await import("./commands");
const { createSidecarContext } = await import("./context");
const fake = createFakeService([connection, secondConnection]);
const firstRuntime = createManager();
const secondRuntime = createManager();
coreCreateMock
.mockResolvedValueOnce(firstRuntime.manager)
.mockResolvedValueOnce(secondRuntime.manager);
const ctx = createSidecarContext("/local/project");
ctx.remoteEnvironments = fake.service;
await handleCommand(ctx, "connect_remote_environment", { id: profile.id });
await expect(
handleCommand(ctx, "connect_remote_environment", {
id: secondProfile.id,
}),
).resolves.toMatchObject({
environmentId: secondProfile.id,
activeEnvironmentId: secondProfile.id,
activeProfileId: secondProfile.id,
});
expect(ctx.activeEnvironmentId).toBe(secondProfile.id);
expect(ctx.runtimeBindings.has(profile.id)).toBe(false);
expect(ctx.runtimeBindings.has(secondProfile.id)).toBe(true);
expect(firstRuntime.unsubscribe).toHaveBeenCalledOnce();
expect(firstRuntime.manager.dispose).toHaveBeenCalledWith(
"code_sidecar_remote_disconnect",
);
expect(secondRuntime.manager.dispose).not.toHaveBeenCalled();
expect(fake.disconnect).toHaveBeenCalledWith(profile.id);
expect(fake.service.getActive()?.profileId).toBe(secondProfile.id);
});
it("disconnecting an inactive profile does not switch the active environment", async () => {
const { handleCommand } = await import("./commands");
const { createSidecarContext } = await import("./context");
const fake = createFakeService([connection, secondConnection]);
await fake.service.connect(profile.id);
await fake.service.connect(secondProfile.id);
const ctx = createSidecarContext("/local/project");
ctx.remoteEnvironments = fake.service;
ctx.runtimeBindings.set(
profile.id,
createExistingRemoteBinding(profile.id),
);
ctx.runtimeBindings.set(
secondProfile.id,
createExistingRemoteBinding(secondProfile.id),
);
ctx.activeEnvironmentId = secondProfile.id;
const send = attachEventRecorder(ctx);
await expect(
handleCommand(ctx, "disconnect_remote_environment", { id: profile.id }),
).resolves.toEqual({
status: "disconnected",
disconnectedProfileId: profile.id,
activeEnvironmentId: secondProfile.id,
activeProfileId: secondProfile.id,
});
expect(ctx.activeEnvironmentId).toBe(secondProfile.id);
expect(ctx.runtimeBindings.has(secondProfile.id)).toBe(true);
expect(fake.service.getActive()?.profileId).toBe(secondProfile.id);
expect(send).not.toHaveBeenCalled();
});
it("deletes a profile only after removing its runtime binding", async () => {
const { handleCommand } = await import("./commands");
const { createSidecarContext } = await import("./context");
const fake = createFakeService();
const ctx = createSidecarContext("/local/project");
ctx.remoteEnvironments = fake.service;
const send = attachEventRecorder(ctx);
const binding = createExistingRemoteBinding(profile.id);
ctx.runtimeBindings.set(profile.id, binding);
ctx.activeEnvironmentId = profile.id;
await expect(
handleCommand(ctx, "delete_remote_environment", { id: profile.id }),
).resolves.toEqual({
deleted: true,
activeEnvironmentId: "local",
activeProfileId: null,
});
expect(binding.unsubscribeSessionEvents).toHaveBeenCalledOnce();
expect(binding.sessionManager.dispose).toHaveBeenCalledWith(
"code_sidecar_remote_disconnect",
);
expect(binding.hubClient.dispose).toHaveBeenCalledOnce();
expect(fake.delete).toHaveBeenCalledWith(profile.id);
expect(ctx.runtimeBindings.has(profile.id)).toBe(false);
expect(ctx.activeEnvironmentId).toBe("local");
expect(readEvent(send, 0)).toEqual({
type: "event",
event: {
name: "remote_environment_changed",
payload: {
status: "disconnected",
activeProfileId: null,
activeEnvironmentId: "local",
environmentId: "local",
workspaceRoot: "/local/project",
reason: "profile_deleted",
},
},
});
});
it("routes remote workspace browsing and operations to the explicitly selected directory", async () => {
const { handleCommand } = await import("./commands");
const { createSidecarContext } = await import("./context");
const fake = createFakeService();
const ctx = createSidecarContext("/local/project");
ctx.remoteEnvironments = fake.service;
ctx.runtimeBindings.set(
profile.id,
createExistingRemoteBinding(profile.id),
);
expect(ctx.activeEnvironmentId).toBe("local");
fake.run.mockImplementation(async (_id, input) => {
if (input.command === "pwd") {
return { stdout: "/srv/code\n", stderr: "", exitCode: 0 };
}
if (input.command === "sh") {
return {
stdout: "/srv/code/zeta\0/srv/code/project\0",
stderr: "",
exitCode: 0,
};
}
if (input.command === "git" && input.args[0] === "ls-files") {
return {
stdout: "src/remote.ts\nREADME.md\n",
stderr: "",
exitCode: 0,
};
}
if (input.command === "git" && input.args[0] === "branch") {
return { stdout: "feature/ssh\n", stderr: "", exitCode: 0 };
}
return { stdout: "main\nfeature/ssh\n", stderr: "", exitCode: 0 };
});
await expect(
handleCommand(ctx, "list_workspace_directories", {
environmentId: profile.id,
path: "/srv/code",
}),
).resolves.toEqual({
environmentId: profile.id,
currentPath: "/srv/code",
parentPath: "/srv",
entries: [
{ name: "project", path: "/srv/code/project" },
{ name: "zeta", path: "/srv/code/zeta" },
],
truncated: false,
});
expect(fake.run).toHaveBeenCalledWith(profile.id, {
command: "pwd",
args: ["-P"],
cwd: "/srv/code",
});
const listInvocation = fake.run.mock.calls.find(
([, input]) => input.command === "sh",
)?.[1];
expect(listInvocation).toMatchObject({
command: "sh",
args: [
"-c",
expect.stringContaining("find -L"),
"cline-list-workspace-directories",
"/srv/code",
],
});
expect(String(listInvocation?.args[1])).not.toContain("/srv/code");
await expect(
handleCommand(ctx, "validate_workspace_directory", {
environmentId: profile.id,
path: "/srv/code/project",
}),
).resolves.toEqual({ environmentId: profile.id, valid: true });
expect(fake.run).toHaveBeenCalledWith(profile.id, {
command: "test",
args: ["-d", "/srv/code/project"],
});
await expect(
handleCommand(ctx, "search_workspace_files", {
environmentId: profile.id,
workspaceRoot: "/srv/code/project",
query: "remote",
}),
).resolves.toEqual(["src/remote.ts"]);
expect(fake.run).toHaveBeenCalledWith(profile.id, {
command: "git",
args: ["ls-files", "--cached", "--others", "--exclude-standard"],
cwd: "/srv/code/project",
});
await expect(
handleCommand(ctx, "get_git_branch", {
environmentId: profile.id,
cwd: "/srv/code/project",
}),
).resolves.toEqual({
environmentId: profile.id,
branch: "feature/ssh",
});
expect(fake.run).toHaveBeenCalledWith(profile.id, {
command: "git",
args: ["branch", "--show-current"],
cwd: "/srv/code/project",
});
});
it("lists and bounds local workspace directories through the local binding", async () => {
const { handleCommand } = await import("./commands");
const { createSidecarContext } = await import("./context");
const temporaryRoot = mkdtempSync(join(tmpdir(), "cline-workspaces-"));
try {
for (let index = 0; index < 201; index += 1) {
mkdirSync(
join(temporaryRoot, `project-${String(index).padStart(3, "0")}`),
);
}
writeFileSync(join(temporaryRoot, "not-a-directory.txt"), "ignored");
const ctx = createSidecarContext("/local/project");
ctx.runtimeBindings.set("local", {
...createExistingRemoteBinding("local"),
kind: "local",
workspaceRoot: "/local/project",
});
const currentPath = realpathSync(temporaryRoot);
await expect(
handleCommand(ctx, "list_workspace_directories", {
environmentId: "local",
path: temporaryRoot,
}),
).resolves.toEqual({
environmentId: "local",
currentPath,
parentPath: realpathSync(tmpdir()),
entries: expect.arrayContaining([
{
name: "project-000",
path: join(currentPath, "project-000"),
},
]),
truncated: true,
});
const result = (await handleCommand(ctx, "list_workspace_directories", {
environmentId: "local",
path: temporaryRoot,
})) as { entries: unknown[] };
expect(result.entries).toHaveLength(200);
} finally {
rmSync(temporaryRoot, { recursive: true, force: true });
}
});
it("routes session reads, title updates, and deletes to the requested environment", async () => {
const { handleCommand } = await import("./commands");
const { createSidecarContext } = await import("./context");
const ctx = createSidecarContext("/local/project");
const readMessages = vi.fn(async () => [
{ role: "user", content: "remote session message" },
]);
const update = vi.fn(async () => ({ updated: true }));
const deleteSession = vi.fn(async () => true);
const sessionManager = {
readMessages,
update,
delete: deleteSession,
dispose: vi.fn(async () => undefined),
} as unknown as SessionRuntimeBinding["sessionManager"];
ctx.runtimeBindings.set(profile.id, {
...createExistingRemoteBinding(profile.id),
sessionManager,
});
expect(ctx.activeEnvironmentId).toBe("local");
await expect(
handleCommand(ctx, "read_session_messages", {
environmentId: profile.id,
sessionId: "remote-session",
}),
).resolves.toHaveLength(1);
expect(readMessages).toHaveBeenCalledWith("remote-session");
ctx.liveSessions.set("same-id", {
environmentId: "local",
config: {},
messages: [{ role: "user", content: "local-only message" }],
promptsInQueue: [],
busy: false,
startedAt: Date.now(),
status: "idle",
});
readMessages.mockResolvedValueOnce([]);
await expect(
handleCommand(ctx, "read_session_messages", {
environmentId: profile.id,
sessionId: "same-id",
}),
).resolves.toEqual([]);
await expect(
handleCommand(ctx, "update_chat_session_title", {
environmentId: profile.id,
sessionId: "remote-session",
title: "Remote title",
}),
).resolves.toBe(true);
expect(update).toHaveBeenCalledWith("remote-session", {
title: "Remote title",
});
await expect(
handleCommand(ctx, "delete_chat_session", {
environmentId: profile.id,
sessionId: "remote-session",
}),
).resolves.toBe(true);
expect(deleteSession).toHaveBeenCalledWith("remote-session");
expect(sessionStoreDeleteMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,732 @@
import { EventEmitter } from "node:events";
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
type RemoteCommandResult,
type RemoteEnvironmentDependencies,
RemoteEnvironmentService,
type RemoteEnvironmentServiceOptions,
type RemoteTunnelProcess,
runRemoteProcess,
} from "./remote-environments";
class FakeTunnel extends EventEmitter implements RemoteTunnelProcess {
public readonly pid = 4242;
public exitCode: number | null = null;
public killed = false;
public kill(): boolean {
this.killed = true;
return true;
}
}
interface Invocation {
executable: string;
args: string[];
options: { timeoutMs: number; inputFile?: string };
}
function success(stdout = "", stderr = ""): RemoteCommandResult {
return { stdout, stderr, exitCode: 0 };
}
function inspection(
platform: string,
arch: string,
home: string,
prefix = "",
): RemoteCommandResult {
return success(
`${prefix}\0CLINE_REMOTE_INSPECT_V1\0${platform}\0${arch}\0${home}\0`,
);
}
describe("RemoteEnvironmentService", () => {
let testDirectory: string;
let profilesPath: string;
beforeEach(async () => {
testDirectory = await mkdtemp(join(tmpdir(), "cline-remote-environments-"));
profilesPath = join(
testDirectory,
"data",
"settings",
"remote-environments.json",
);
});
afterEach(async () => {
await rm(testDirectory, { recursive: true, force: true });
});
function createService(
overrides: Partial<RemoteEnvironmentDependencies> = {},
options: Omit<
RemoteEnvironmentServiceOptions,
"profilesPath" | "dependencies"
> = {},
): RemoteEnvironmentService {
let id = 0;
return new RemoteEnvironmentService({
...options,
profilesPath,
dependencies: {
now: () => new Date("2026-08-06T12:00:00.000Z"),
randomId: () => `test-id-${++id}`,
requestHubShutdown: async () => true,
...overrides,
},
});
}
it("drains ProxyCommand output after SSH exits", async () => {
const lateDiagnosticProgram =
'const { spawn } = require("node:child_process");' +
'spawn(process.execPath, ["-e", "setTimeout(() => process.stderr.write(\\"remote helper failed\\\\n\\"), 40)"], { stdio: ["ignore", "ignore", 2] });' +
'process.stderr.write("gcloud NumPy warning\\n");' +
"process.exit(23);";
const result = await runRemoteProcess(
process.execPath,
["-e", lateDiagnosticProgram],
{ timeoutMs: 2_000 },
);
expect(result).toMatchObject({ exitCode: 23, stdout: "" });
expect(result.stderr).toContain("gcloud NumPy warning");
expect(result.stderr).toContain("remote helper failed");
});
it("persists profiles atomically with private permissions and updates in place", async () => {
const service = createService();
const created = await service.upsert({
name: " Build box ",
host: "build.example.com",
user: "alice",
port: 2222,
identityFile: "~/.ssh/build_ed25519",
});
expect(created).toMatchObject({
id: "test-id-1",
name: "Build box",
host: "build.example.com",
createdAt: "2026-08-06T12:00:00.000Z",
updatedAt: "2026-08-06T12:00:00.000Z",
});
expect((await stat(profilesPath)).mode & 0o777).toBe(0o600);
const stored = JSON.parse(await readFile(profilesPath, "utf8"));
expect(stored).toEqual({ version: 1, profiles: [created] });
const updated = await service.upsert({
...created,
name: "Build box renamed",
});
expect(updated.id).toBe(created.id);
expect(updated.createdAt).toBe(created.createdAt);
expect(await service.list()).toEqual([updated]);
await expect(
service.upsert({ ...updated, host: "other.example.com" }),
).rejects.toThrow("Create a new remote environment instead");
expect(await service.list()).toEqual([updated]);
const reloaded = createService();
expect(await reloaded.list()).toEqual([updated]);
});
it("validates profile fields before persisting them", async () => {
const service = createService();
await expect(
service.upsert({ name: "bad", host: "-oProxyCommand=bad" }),
).rejects.toThrow("SSH host");
await expect(
service.upsert({ name: "bad", host: "host", port: 70_000 }),
).rejects.toThrow("between 1 and 65535");
await expect(service.list()).resolves.toEqual([]);
});
it("tests SSH connectivity with safe non-interactive OpenSSH options", async () => {
const invocations: Invocation[] = [];
const service = createService({
runProcess: async (executable, args, options) => {
invocations.push({ executable, args, options });
return inspection("Linux", "x86_64", "/home/alice");
},
});
const profile = await service.upsert({
name: "Remote",
host: "ssh-alias",
user: "alice",
port: 2202,
identityFile: "/keys/remote key",
});
await expect(service.test(profile.id)).resolves.toMatchObject({
profileId: profile.id,
state: "available",
remotePlatform: "linux",
remoteArch: "x64",
remoteHome: "/home/alice",
});
expect(invocations).toHaveLength(1);
expect(invocations[0]?.executable).toBe("ssh");
expect(invocations[0]?.args).toEqual(
expect.arrayContaining([
"-o",
"BatchMode=yes",
"ConnectTimeout=10",
"StrictHostKeyChecking=accept-new",
"-p",
"2202",
"-i",
"/keys/remote key",
"alice@ssh-alias",
]),
);
expect(invocations[0]?.args.at(-1)).toContain("uname -s");
});
it("leaves the SSH port unset so an OpenSSH config alias can choose it", async () => {
const invocations: Invocation[] = [];
const service = createService({
runProcess: async (executable, args, options) => {
invocations.push({ executable, args, options });
return inspection("Linux", "x86_64", "/home/alice");
},
});
const profile = await service.upsert({
name: "Configured host",
host: "pi-from-ssh-config",
});
await service.test(profile.id);
expect(invocations[0]?.args).not.toContain("-p");
expect(invocations[0]?.args.at(-2)).toBe("pi-from-ssh-config");
});
it("parses framed inspection data after noisy SSH startup output", async () => {
const service = createService({
runProcess: async () => ({
...inspection(
"Linux",
"aarch64",
"/home/pi",
"Welcome to the Pi server\n.bashrc says hello\n",
),
stderr: "gcloud: NumPy is not installed; tunnel may be slower.\n",
}),
});
const profile = await service.upsert({ name: "Pi", host: "pi" });
await expect(service.test(profile.id)).resolves.toMatchObject({
state: "available",
remotePlatform: "linux",
remoteArch: "arm64",
remoteHome: "/home/pi",
});
});
it("bootstraps the exact helper and creates a loopback-only SSH tunnel", async () => {
const invocations: Invocation[] = [];
const tunnel = new FakeTunnel();
const spawnTunnel = vi.fn(() => tunnel);
const waitForTunnel = vi.fn(async () => undefined);
const requestHubShutdown = vi.fn(async () => {
expect(tunnel.killed).toBe(false);
return true;
});
const service = createService({
runProcess: async (executable, args, options) => {
invocations.push({ executable, args, options });
const command = args.at(-1) ?? "";
if (command.includes("uname -s")) {
return inspection("Linux", "aarch64", "/home/dev");
}
if (command.includes("'test' '-x'")) {
return { stdout: "", stderr: "", exitCode: 1 };
}
if (command.includes("--remote-hub-ensure")) {
return success(
'{"url":"ws://127.0.0.1:25463/hub","authToken":"remote-secret"}\n',
);
}
return success();
},
resolveHelperBinary: async ({ platform, arch }) => {
expect({ platform, arch }).toEqual({
platform: "linux",
arch: "arm64",
});
return "/opt/cline/code-sidecar-linux-arm64";
},
fileReadable: async () => true,
hashFile: async () => "abcdef0123456789fedcba9876543210",
reservePort: async () => 43117,
spawnTunnel,
waitForTunnel,
requestHubShutdown,
});
const profile = await service.upsert({
name: "ARM builder",
host: "arm-builder",
});
const connection = await service.connect(profile.id);
expect(connection).toMatchObject({
profile,
profileId: profile.id,
state: "connected",
endpoint: "ws://127.0.0.1:43117/hub",
authToken: "remote-secret",
workspaceRoot: "/home/dev",
homeDir: "/home/dev",
platform: "linux",
arch: "arm64",
remoteHubUrl: "ws://127.0.0.1:25463/hub",
localPort: 43117,
});
expect(service.getActive()).toEqual(connection);
expect(service.getConnection(profile.id)).toEqual(connection);
const upload = invocations.find(
(invocation) => invocation.options.inputFile,
);
expect(upload).toMatchObject({
executable: "ssh",
options: { inputFile: "/opt/cline/code-sidecar-linux-arm64" },
});
expect(upload?.args.at(-1)).toContain("umask 077; cat >");
const ensure = invocations.find((invocation) =>
invocation.args.at(-1)?.includes("--remote-hub-ensure"),
);
expect(ensure?.args.at(-1)).toContain("'/home/dev'");
expect(ensure?.args.at(-1)).toContain(
"'/home/dev/.cline/data/remote/desktop-hub.json'",
);
expect(spawnTunnel).toHaveBeenCalledWith(
"ssh",
expect.arrayContaining([
"-N",
"ExitOnForwardFailure=yes",
"-L",
"127.0.0.1:43117:127.0.0.1:25463",
"arm-builder",
]),
);
expect(waitForTunnel).toHaveBeenCalledWith(43117, tunnel, 10_000);
await expect(service.disconnect()).resolves.toBe(true);
expect(requestHubShutdown).toHaveBeenCalledWith(
"ws://127.0.0.1:43117/hub",
"remote-secret",
);
expect(tunnel.killed).toBe(true);
expect(service.getActive()).toBeUndefined();
});
it("bounds Hub shutdown before closing the SSH tunnel", async () => {
const tunnel = new FakeTunnel();
const requestHubShutdown = vi.fn(
async () => await new Promise<boolean>(() => undefined),
);
const service = createService(
{
runProcess: async (_executable, args) => {
const command = args.at(-1) ?? "";
if (command.includes("uname -s")) {
return inspection("Linux", "aarch64", "/home/pi");
}
if (command.includes("--remote-hub-ensure")) {
return success(
'{"url":"ws://127.0.0.1:25463/hub","authToken":"token"}\n',
);
}
return success();
},
resolveHelperBinary: async () => "/opt/cline/helper",
fileReadable: async () => true,
hashFile: async () => "0123456789abcdef",
reservePort: async () => 43_000,
spawnTunnel: () => tunnel,
waitForTunnel: async () => undefined,
requestHubShutdown,
},
{ hubShutdownTimeoutMs: 5 },
);
const profile = await service.upsert({ name: "Pi", host: "pi" });
await service.connect(profile.id);
await expect(service.disconnect(profile.id)).resolves.toBe(true);
expect(requestHubShutdown).toHaveBeenCalledOnce();
expect(tunnel.killed).toBe(true);
});
it("reuses an already-installed content-addressed helper", async () => {
const invocations: Invocation[] = [];
const service = createService({
runProcess: async (executable, args, options) => {
invocations.push({ executable, args, options });
const command = args.at(-1) ?? "";
if (command.includes("uname -s")) {
return inspection("Darwin", "arm64", "/Users/dev");
}
if (command.includes("--remote-hub-ensure")) {
return success(
'{"url":"ws://localhost:29000/hub","authToken":"token"}\n',
);
}
return success();
},
resolveHelperBinary: async () => "/Applications/Cline.app/sidecar",
fileReadable: async () => true,
hashFile: async () => "0123456789abcdef",
reservePort: async () => 40000,
spawnTunnel: () => new FakeTunnel(),
waitForTunnel: async () => undefined,
});
const profile = await service.upsert({
name: "Mac",
host: "mac",
});
await service.connect(profile.id);
expect(invocations.some((invocation) => invocation.options.inputFile)).toBe(
false,
);
});
it("allows renaming but keeps a profile's SSH destination immutable", async () => {
const tunnel = new FakeTunnel();
const spawnTunnel = vi.fn(() => tunnel);
const service = createService({
runProcess: async (_executable, args) => {
const command = args.at(-1) ?? "";
if (command.includes("uname -s")) {
return inspection("Linux", "aarch64", "/home/pi");
}
if (command.includes("--remote-hub-ensure")) {
return success(
'{"url":"ws://127.0.0.1:25463/hub","authToken":"token"}\n',
);
}
return success();
},
resolveHelperBinary: async () => "/opt/cline/helper",
fileReadable: async () => true,
hashFile: async () => "0123456789abcdef",
reservePort: async () => 42_000,
spawnTunnel,
waitForTunnel: async () => undefined,
});
const created = await service.upsert({
name: "Pi",
host: "pi-from-ssh-config",
user: "pi",
identityFile: "/keys/pi_ed25519",
});
const first = await service.connect(created.id);
const renamed = await service.upsert({
...created,
name: "Pi renamed",
});
expect(service.getConnection(created.id)?.profile).toEqual(renamed);
await expect(service.connect(created.id)).resolves.toMatchObject({
localPort: first.localPort,
profile: renamed,
});
expect(spawnTunnel).toHaveBeenCalledTimes(1);
for (const destinationUpdate of [
{ host: "different-host" },
{ user: "different-user" },
{ port: 22 },
]) {
await expect(
service.upsert({ ...renamed, ...destinationUpdate }),
).rejects.toThrow("Create a new remote environment instead");
}
await expect(
service.upsert({
...renamed,
identityFile: "/keys/replacement_ed25519",
}),
).rejects.toThrow("Disconnect the remote environment");
expect(service.getConnection(created.id)).toMatchObject({
localPort: first.localPort,
profile: renamed,
});
expect(await service.list()).toEqual([renamed]);
expect(spawnTunnel).toHaveBeenCalledTimes(1);
expect(tunnel.killed).toBe(false);
await service.disconnect(created.id);
const updatedIdentity = await service.upsert({
...renamed,
identityFile: "/keys/replacement_ed25519",
});
expect(await service.list()).toEqual([updatedIdentity]);
});
it("fails clearly when no helper exists for the remote target and never downloads one", async () => {
const invocations: Invocation[] = [];
const service = createService({
runProcess: async (executable, args, options) => {
invocations.push({ executable, args, options });
return args.at(-1)?.includes("uname -s")
? inspection("Linux", "aarch64", "/home/dev")
: success();
},
resolveHelperBinary: async () => undefined,
});
const profile = await service.upsert({
name: "Remote",
host: "remote",
});
await expect(service.connect(profile.id)).rejects.toThrow(
"unsupported in SSH v0: no compatible desktop helper binary",
);
expect(invocations.some((invocation) => invocation.options.inputFile)).toBe(
false,
);
});
it("keeps the active tunnel alive when a replacement connection fails", async () => {
const tunnels: FakeTunnel[] = [];
let nextPort = 41_000;
const service = createService({
runProcess: async (_executable, args) => {
const destination = args.at(-2);
const command = args.at(-1) ?? "";
if (command.includes("uname -s")) {
return inspection("Linux", "aarch64", "/home/dev");
}
if (command.includes("--remote-hub-ensure")) {
return destination === "host-b"
? { stdout: "", stderr: "bootstrap failed", exitCode: 1 }
: success(
'{"url":"ws://127.0.0.1:25463/hub","authToken":"token"}\n',
);
}
return success();
},
resolveHelperBinary: async () => "/opt/cline/helper",
fileReadable: async () => true,
hashFile: async () => "0123456789abcdef",
reservePort: async () => nextPort++,
spawnTunnel: () => {
const tunnel = new FakeTunnel();
tunnels.push(tunnel);
return tunnel;
},
waitForTunnel: async () => undefined,
});
const hostA = await service.upsert({
name: "Host A",
host: "host-a",
});
const hostB = await service.upsert({
name: "Host B",
host: "host-b",
});
const firstConnection = await service.connect(hostA.id);
await expect(service.connect(hostB.id)).rejects.toThrow("bootstrap failed");
expect(service.getActive()).toEqual(firstConnection);
expect(service.getConnection(hostA.id)).toEqual(firstConnection);
expect(tunnels[0]?.killed).toBe(false);
});
it("keeps the previous tunnel until the runtime switch commits", async () => {
const tunnels: FakeTunnel[] = [];
let nextPort = 42_000;
const requestHubShutdown = vi.fn(async () => true);
const service = createService({
runProcess: async (_executable, args) => {
const destination = args.at(-2) ?? "host-a";
const command = args.at(-1) ?? "";
if (command.includes("uname -s")) {
return inspection("Linux", "aarch64", `/home/${destination}`);
}
if (command.includes("--remote-hub-ensure")) {
return success(
`{"url":"ws://127.0.0.1:25463/hub","authToken":"${destination}-token"}\n`,
);
}
return success();
},
resolveHelperBinary: async () => "/opt/cline/helper",
fileReadable: async () => true,
hashFile: async () => "0123456789abcdef",
reservePort: async () => nextPort++,
spawnTunnel: () => {
const tunnel = new FakeTunnel();
tunnels.push(tunnel);
return tunnel;
},
waitForTunnel: async () => undefined,
requestHubShutdown,
});
const hostA = await service.upsert({ name: "Host A", host: "host-a" });
const hostB = await service.upsert({ name: "Host B", host: "host-b" });
const connectionA = await service.connect(hostA.id);
const connectionB = await service.connect(hostB.id);
expect(service.getActive()).toEqual(connectionB);
expect(service.getConnection(hostA.id)).toEqual(connectionA);
expect(service.getConnection(hostB.id)).toEqual(connectionB);
expect(tunnels[0]?.killed).toBe(false);
expect(requestHubShutdown).not.toHaveBeenCalled();
expect(service.activateConnection(hostA.id)).toBe(true);
expect(service.getActive()).toEqual(connectionA);
expect(service.activateConnection("missing-host")).toBe(false);
expect(service.getActive()).toEqual(connectionA);
expect(service.activateConnection(hostB.id)).toBe(true);
expect(await service.disconnect(hostA.id)).toBe(true);
expect(tunnels[0]?.killed).toBe(true);
expect(tunnels[1]?.killed).toBe(false);
expect(service.getConnection(hostB.id)).toEqual(connectionB);
expect(service.getActive()).toEqual(connectionB);
});
it("reports tunnel loss separately from ordinary connection errors", async () => {
const tunnel = new FakeTunnel();
const onConnectionLost = vi.fn();
const requestHubShutdown = vi.fn(async () => true);
const service = new RemoteEnvironmentService({
profilesPath,
onConnectionLost,
dependencies: {
now: () => new Date("2026-08-06T12:00:00.000Z"),
randomId: () => "lost-profile",
runProcess: async (_executable, args) => {
const command = args.at(-1) ?? "";
if (command.includes("uname -s")) {
return inspection("Linux", "x86_64", "/home/dev");
}
if (command.includes("--remote-hub-ensure")) {
return success(
'{"url":"ws://127.0.0.1:25463/hub","authToken":"token"}\n',
);
}
return success();
},
resolveHelperBinary: async () => "/opt/cline/helper",
fileReadable: async () => true,
hashFile: async () => "0123456789abcdef",
reservePort: async () => 41_000,
spawnTunnel: () => tunnel,
waitForTunnel: async () => undefined,
requestHubShutdown,
},
});
const profile = await service.upsert({
name: "Remote",
host: "remote",
});
await service.connect(profile.id);
tunnel.emit("exit", 255, null);
expect(service.getActive()).toBeUndefined();
expect(onConnectionLost).toHaveBeenCalledWith(
expect.objectContaining({
profileId: profile.id,
state: "error",
}),
);
// The tunnel is already gone, so leave the dedicated owner record for a
// later reconnect instead of risking shutdown of an unrelated local Hub.
expect(requestHubShutdown).not.toHaveBeenCalled();
});
it("quotes command arguments and rejects non-zero remote commands", async () => {
const invocations: Invocation[] = [];
let failCommand = false;
const service = createService({
runProcess: async (executable, args, options) => {
invocations.push({ executable, args, options });
if (args.at(-1)?.includes("uname -s")) {
return inspection("Linux", "x86_64", "/home/dev");
}
return failCommand
? { stdout: "", stderr: "permission denied", exitCode: 13 }
: success("ok");
},
});
const profile = await service.upsert({ name: "Remote", host: "remote" });
await expect(
service.run(profile.id, {
command: "printf",
args: ["%s", "a'b; touch /tmp/not-created"],
cwd: "/tmp/a b",
}),
).resolves.toEqual({ stdout: "ok", stderr: "", exitCode: 0 });
expect(invocations.at(-1)?.args.at(-1)).toBe(
`cd '/tmp/a b' && exec 'printf' '%s' 'a'"'"'b; touch /tmp/not-created'`,
);
failCommand = true;
await expect(
service.run(profile.id, { command: "false", args: [] }),
).rejects.toThrow("permission denied");
});
it("does not let a ProxyCommand warning mask the remote helper diagnostic", async () => {
const service = createService({
runProcess: async () => ({
stdout: "Timed out waiting for detached hub startup.\n",
stderr: "gcloud: NumPy is not installed; tunnel may be slower.\n",
exitCode: 255,
}),
});
const profile = await service.upsert({ name: "GCP", host: "gcp-iap" });
const error = await service
.run(profile.id, { command: "remote-helper", args: [] })
.catch((failure: unknown) => failure);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain("exit 255");
expect((error as Error).message).toContain(
"gcloud: NumPy is not installed",
);
expect((error as Error).message).toContain(
"Timed out waiting for detached hub startup",
);
});
it("deletes profiles", async () => {
const service = createService();
const profile = await service.upsert({ name: "Remote", host: "remote" });
await expect(service.delete(profile.id)).resolves.toBe(true);
await expect(service.delete(profile.id)).resolves.toBe(false);
await expect(service.list()).resolves.toEqual([]);
});
it("turns SSH failures into an error status during connection tests", async () => {
const service = createService({
runProcess: async () => ({
stdout: "",
stderr: "Host key verification failed",
exitCode: 255,
}),
});
const profile = await service.upsert({ name: "Remote", host: "remote" });
await expect(service.test(profile.id)).resolves.toMatchObject({
state: "error",
message: expect.stringContaining("Host key verification failed"),
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,95 @@
import { describe, expect, it, vi } from "vitest";
import {
type RemoteHelperDependencies,
runRemoteHelperEntrypoint,
} from "./remote-helper";
function createDependencies(
overrides: Partial<RemoteHelperDependencies> = {},
): {
dependencies: RemoteHelperDependencies;
output: string[];
} {
const output: string[] = [];
return {
output,
dependencies: {
ensureDetachedHubServer: vi.fn(async () => ({
url: "ws://127.0.0.1:25463/hub",
authToken: "desktop-owner-token",
})),
claimHubDaemonProcess: vi.fn(() => false),
loadHubDaemon: vi.fn(async () => undefined),
ensureLoginShellPath: vi.fn(async () => ({
status: "skipped" as const,
reason: "test",
})),
setHomeDirIfUnset: vi.fn(),
homeDir: () => "/home/pi",
cwd: () => "/home/pi",
env: {},
writeOutput: (value) => output.push(value),
...overrides,
},
};
}
describe("remote helper entrypoint", () => {
it("starts only the explicitly owned desktop Hub discovery record", async () => {
const { dependencies, output } = createDependencies();
const discoveryPath = "/home/pi/.cline/data/remote/desktop-hub.json";
await expect(
runRemoteHelperEntrypoint(
[
"code-sidecar",
"--remote-hub-ensure",
"--cwd",
"/home/pi",
"--discovery-path",
discoveryPath,
],
dependencies,
),
).resolves.toBe(true);
expect(dependencies.env.CLINE_HUB_DISCOVERY_PATH).toBe(discoveryPath);
expect(dependencies.ensureDetachedHubServer).toHaveBeenCalledWith(
"/home/pi",
{
host: "127.0.0.1",
port: 0,
pathname: "/hub",
allowPortFallback: true,
},
);
expect(JSON.parse(output.join(""))).toMatchObject({
url: "ws://127.0.0.1:25463/hub",
authToken: "desktop-owner-token",
cwd: "/home/pi",
});
});
it("refuses bootstrap without an explicit discovery owner", async () => {
const { dependencies } = createDependencies();
await expect(
runRemoteHelperEntrypoint(
["code-sidecar", "--remote-hub-ensure"],
dependencies,
),
).rejects.toThrow("--discovery-path is required");
});
it("hosts the detached daemon when the one-shot sentinel is claimed", async () => {
const loadHubDaemon = vi.fn(async () => undefined);
const { dependencies } = createDependencies({
claimHubDaemonProcess: () => true,
loadHubDaemon,
});
await expect(
runRemoteHelperEntrypoint(["code-sidecar"], dependencies),
).resolves.toBe(true);
expect(loadHubDaemon).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,107 @@
import { homedir } from "node:os";
import { ensureDetachedHubServer, setHomeDirIfUnset } from "@cline/core";
import { claimHubDaemonProcess } from "@cline/shared";
import { ensureLoginShellPath } from "./shell-path";
export type RemoteHelperDependencies = {
ensureDetachedHubServer: typeof ensureDetachedHubServer;
claimHubDaemonProcess: typeof claimHubDaemonProcess;
loadHubDaemon: () => Promise<unknown>;
ensureLoginShellPath: typeof ensureLoginShellPath;
setHomeDirIfUnset: typeof setHomeDirIfUnset;
homeDir: () => string;
cwd: () => string;
env: NodeJS.ProcessEnv;
writeOutput: (output: string) => void;
};
const defaultDependencies: RemoteHelperDependencies = {
ensureDetachedHubServer,
claimHubDaemonProcess,
loadHubDaemon: () => import("@cline/core/hub/daemon-entry"),
ensureLoginShellPath,
setHomeDirIfUnset,
homeDir: homedir,
cwd: () => process.cwd(),
env: process.env,
writeOutput: (output) => process.stdout.write(output),
};
function readArgument(argv: string[], name: string): string | undefined {
const index = argv.indexOf(name);
const value = index >= 0 ? argv[index + 1] : undefined;
return value?.trim() || undefined;
}
function configureDedicatedDiscovery(
argv: string[],
dependencies: RemoteHelperDependencies,
): string {
const discoveryPath = readArgument(argv, "--discovery-path");
if (!discoveryPath) {
throw new Error("--discovery-path is required for remote Hub management");
}
// This explicit owner record is the safety boundary: the remote helper never
// reads or shuts down the user's default CLI-owned Hub discovery record.
dependencies.env.CLINE_HUB_DISCOVERY_PATH = discoveryPath;
return discoveryPath;
}
export async function runRemoteHubEnsure(
argv = process.argv,
dependencies: RemoteHelperDependencies = defaultDependencies,
): Promise<void> {
dependencies.setHomeDirIfUnset(dependencies.homeDir());
await dependencies.ensureLoginShellPath();
const cwd = readArgument(argv, "--cwd") ?? dependencies.cwd();
configureDedicatedDiscovery(argv, dependencies);
const result = await dependencies.ensureDetachedHubServer(cwd, {
host: "127.0.0.1",
port: 0,
pathname: "/hub",
allowPortFallback: true,
});
dependencies.writeOutput(
`${JSON.stringify({
...result,
cwd,
platform: process.platform,
arch: process.arch,
})}\n`,
);
}
/**
* Handles the SSH bootstrap command and the detached-daemon sentinel. The
* full desktop sidecar imports this function for same-platform remote hosts;
* packaged Linux SSH helpers compile this file directly and contain no desktop
* HTTP/WebSocket server or UI command router.
*/
export async function runRemoteHelperEntrypoint(
argv = process.argv,
dependencies: RemoteHelperDependencies = defaultDependencies,
): Promise<boolean> {
if (argv.includes("--remote-hub-ensure")) {
await runRemoteHubEnsure(argv, dependencies);
return true;
}
// Claim rather than read: consuming the sentinel keeps daemon-hosted
// sessions from handing it to every process they spawn.
if (dependencies.claimHubDaemonProcess()) {
await dependencies.loadHubDaemon();
return true;
}
return false;
}
if (import.meta.main) {
void (async () => {
if (!(await runRemoteHelperEntrypoint())) {
throw new Error("A remote helper command is required");
}
})().catch((error) => {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exitCode = 1;
});
}
@@ -1,4 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
MAX_RECORDED_AUDIO_BASE64_BYTES,
MAX_RECORDED_AUDIO_BYTES,
@@ -24,6 +27,20 @@ function createTelemetryHandler(capture = vi.fn()) {
};
}
const originalSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
const temporaryDirectories: string[] = [];
afterEach(() => {
if (originalSessionDataDir === undefined) {
delete process.env.CLINE_SESSION_DATA_DIR;
} else {
process.env.CLINE_SESSION_DATA_DIR = originalSessionDataDir;
}
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
describe("sidecar WebSocket payload limit", () => {
it("accepts every recording allowed by the voice input size limit", () => {
const handler = createWebSocketHandler({} as SidecarContext);
@@ -105,6 +122,41 @@ describe("sidecar HTTP origin checks", () => {
});
});
describe("session video artifacts", () => {
it("serves a generated video only from the session artifact directory", async () => {
const sessionsDir = mkdtempSync(join(tmpdir(), "desktop-video-artifact-"));
temporaryDirectories.push(sessionsDir);
process.env.CLINE_SESSION_DATA_DIR = sessionsDir;
const artifactsDir = join(sessionsDir, "session-1", "artifacts");
mkdirSync(artifactsDir, { recursive: true });
writeFileSync(join(artifactsDir, "video-result.mp4"), "video-bytes");
const response = await createHandler()(
new Request(
"http://127.0.0.1:3126/api/session-artifacts/session-1/video-result.mp4",
{ headers: { origin: "tauri://localhost" } },
),
createTestServer(),
);
expect(response?.status).toBe(200);
expect(response?.headers.get("content-type")).toBe("video/mp4");
await expect(response?.text()).resolves.toBe("video-bytes");
});
it("rejects untrusted origins for session artifacts", async () => {
const response = await createHandler()(
new Request(
"http://127.0.0.1:3126/api/session-artifacts/session-1/video.mp4",
{ headers: { origin: "https://attacker.example" } },
),
createTestServer(),
);
expect(response?.status).toBe(403);
});
});
describe("desktop error telemetry", () => {
it("captures sanitized webview error reports with structured context", async () => {
const server = createTestServer();
+132 -1
View File
@@ -1,4 +1,12 @@
import { captureSdkError } from "@cline/shared";
import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import { basename, join } from "node:path";
import { Readable } from "node:stream";
import {
captureSdkError,
REALTIME_CLINE_TOOLS,
type RealtimeVoiceModeSession,
} from "@cline/shared";
import type { DesktopTransportRequest } from "../webview/lib/desktop-transport";
import { MAX_DESKTOP_TRANSPORT_PAYLOAD_BYTES } from "../webview/lib/voice-input-limits";
import { handleCommand } from "./commands";
@@ -6,6 +14,7 @@ import { encodeSidecarEvent, sendEvent } from "./context";
import { fetchMarketplaceCatalog } from "./marketplace";
import { cancelMcpOAuthAuthorizationsForOwner } from "./mcp-oauth";
import { cancelProviderOAuthLoginsForOwner } from "./oauth-login";
import { sharedSessionDataDir } from "./paths";
import {
BunRuntime,
SIDECAR_HOST,
@@ -40,6 +49,21 @@ const JSON_HEADERS = {
"content-type": "application/json",
};
function artifactContentType(filename: string): string {
const lower = filename.toLowerCase();
if (lower.endsWith(".mp3")) return "audio/mpeg";
if (lower.endsWith(".wav")) return "audio/wav";
if (lower.endsWith(".aac")) return "audio/aac";
if (lower.endsWith(".m4a")) return "audio/mp4";
if (lower.endsWith(".weba")) return "audio/webm";
if (lower.endsWith(".flac")) return "audio/flac";
if (lower.endsWith(".ogg")) return "audio/ogg";
if (filename.toLowerCase().endsWith(".webm")) return "video/webm";
if (filename.toLowerCase().endsWith(".mov")) return "video/quicktime";
if (filename.toLowerCase().endsWith(".mpeg")) return "video/mpeg";
return "video/mp4";
}
function readOrigin(req: Request): string | undefined {
const origin = req.headers.get("origin")?.trim();
return origin ? origin : undefined;
@@ -205,6 +229,73 @@ export function createFetchHandler(
);
}
if (
req.method === "GET" &&
url.pathname.startsWith("/api/session-artifacts/")
) {
if (!isTrustedRequestOrigin(req)) {
return new Response("Forbidden", { status: 403 });
}
const segments = url.pathname
.slice("/api/session-artifacts/".length)
.split("/")
.map((segment) => decodeURIComponent(segment));
const [sessionId, artifactName, ...extra] = segments;
if (
!sessionId ||
!artifactName ||
extra.length > 0 ||
sessionId === "." ||
sessionId === ".." ||
artifactName === "." ||
artifactName === ".." ||
basename(sessionId) !== sessionId ||
basename(artifactName) !== artifactName ||
!/^[a-zA-Z0-9][a-zA-Z0-9._:-]*$/.test(sessionId) ||
!/^[a-zA-Z0-9][a-zA-Z0-9._ -]*$/.test(artifactName)
) {
return new Response("Invalid artifact path", { status: 400 });
}
const artifactPath = join(
sharedSessionDataDir(),
sessionId,
"artifacts",
artifactName,
);
const artifactStat = await stat(artifactPath).catch(() => null);
if (!artifactStat?.isFile()) {
return new Response("Artifact not found", { status: 404 });
}
const range = req.headers.get("range")?.match(/^bytes=(\d+)-(\d*)$/);
const start = range ? Number(range[1]) : 0;
const requestedEnd = range?.[2]
? Number(range[2])
: artifactStat.size - 1;
const end = Math.min(requestedEnd, artifactStat.size - 1);
if (start < 0 || start > end || start >= artifactStat.size) {
return new Response(null, {
status: 416,
headers: { "content-range": `bytes */${artifactStat.size}` },
});
}
const body = Readable.toWeb(
createReadStream(artifactPath, { start, end }),
);
return new Response(body as unknown as BodyInit, {
status: range ? 206 : 200,
headers: {
...corsHeaders(req),
"accept-ranges": "bytes",
"cache-control": "private, max-age=31536000, immutable",
"content-length": String(end - start + 1),
"content-type": artifactContentType(artifactName),
...(range
? { "content-range": `bytes ${start}-${end}/${artifactStat.size}` }
: {}),
},
});
}
if (
url.pathname === "/transport" &&
isTrustedRequestOrigin(req) &&
@@ -213,6 +304,46 @@ export function createFetchHandler(
return undefined;
}
if (
url.pathname === "/api/modes/realtime/session" &&
req.method === "POST"
) {
if (!isTrustedRequestOrigin(req)) {
return createJsonResponse(
req,
{ error: "Untrusted request origin" },
403,
);
}
try {
const session = (await handleCommand(ctx, "create_mode_session", {
mode: "realtimeVoice",
})) as RealtimeVoiceModeSession;
if (session.kind !== "realtime") {
throw new Error("Realtime mode returned an unexpected session");
}
return createJsonResponse(req, {
token: session.token,
url: session.url,
...(session.expiresAt === undefined
? {}
: { expiresAt: session.expiresAt }),
tools: session.supportsTools ? REALTIME_CLINE_TOOLS : [],
});
} catch (error) {
return createJsonResponse(
req,
{
error:
error instanceof Error
? error.message
: "Failed to create realtime session",
},
400,
);
}
}
if (url.pathname === "/api/marketplace/catalog") {
try {
return createJsonResponse(req, await fetchMarketplaceCatalog());
@@ -48,6 +48,9 @@ export function discoverChatSessions(
const out: JsonRecord[] = [];
const store = new SqliteSessionStore();
for (const [sessionId, session] of ctx.liveSessions.entries()) {
if (session.config.executionTarget === "cloud") {
continue;
}
if (!session.busy && !session.prompt && session.messages.length === 0) {
continue;
}
@@ -80,6 +80,7 @@ describe("readSessionMessages", () => {
expect.objectContaining({
id: "assistant-message_tool_use_2",
createdAt: assistantTimestamp + 1,
meta: expect.objectContaining({ toolCallId: "tool-use" }),
}),
]);
});
@@ -128,6 +129,92 @@ describe("readSessionMessages", () => {
]);
});
it("projects generated video artifact blocks", async () => {
const sessionId = `video-projection-${Date.now()}`;
const liveSessions = new Map([
[
sessionId,
{
messages: [
{
id: "assistant-video",
role: "assistant",
content: [
{
type: "video",
mediaType: "video/mp4",
path: `/tmp/session/artifacts/video-result.mp4`,
},
],
},
],
},
],
]);
await expect(
readSessionMessages(
{ liveSessions } as Parameters<typeof readSessionMessages>[0],
sessionId,
),
).resolves.toEqual([
expect.objectContaining({
role: "assistant",
content: "",
videos: [
{
id: "assistant-video_video_0",
mediaType: "video/mp4",
artifactName: "video-result.mp4",
},
],
}),
]);
});
it("projects generated audio artifact blocks", async () => {
const sessionId = `audio-projection-${Date.now()}`;
const liveSessions = new Map([
[
sessionId,
{
messages: [
{
id: "assistant-audio",
role: "assistant",
content: [
{
type: "audio",
mediaType: "audio/mpeg",
path: "/tmp/session/artifacts/audio-result.mp3",
},
],
},
],
},
],
]);
await expect(
readSessionMessages(
{ liveSessions } as Parameters<typeof readSessionMessages>[0],
sessionId,
),
).resolves.toEqual([
expect.objectContaining({
role: "assistant",
content: "",
audios: [
{
id: "assistant-audio_audio_0",
mediaType: "audio/mpeg",
artifactName: "audio-result.mp3",
},
],
}),
]);
});
it("preserves absolute user run counts when older messages are omitted", async () => {
const sessionId = `run-count-projection-${Date.now()}`;
const liveSessions = new Map([
@@ -1,5 +1,5 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { basename, dirname } from "node:path";
import {
getUserRunSpan,
projectSessionMessagesForDisplay,
@@ -333,28 +333,37 @@ export async function readSessionMessages(
ctx: Pick<SidecarContext, "liveSessions">,
sessionId: string,
maxMessages = 800,
/** Explicit authoritative source for remote sessions; bypasses local disk. */
sourceMessages?: unknown[],
): Promise<unknown[]> {
const persisted =
readPersistedChatMessages(sessionId) ??
// A child agent's transcript is not stored under its own session
// directory — it lives beside the root session's artifacts — so opening a
// subagent session has to resolve the path recorded on its row.
readChildSessionMessages(sessionId);
const messages =
persisted && persisted.length > 0
const isRemoteRead = sourceMessages !== undefined;
const persisted = sourceMessages
? undefined
: (readPersistedChatMessages(sessionId) ??
// A child agent's transcript is not stored under its own session
// directory — it lives beside the root session's artifacts — so opening a
// subagent session has to resolve the path recorded on its row.
readChildSessionMessages(sessionId));
const messages = (sourceMessages ??
(persisted && persisted.length > 0
? persisted
: (ctx.liveSessions.get(sessionId)?.messages ?? []);
: (ctx.liveSessions.get(sessionId)?.messages ??
[]))) as MessageWithMetadata[];
const max = Math.max(1, maxMessages);
const start = Math.max(0, messages.length - max);
const displayMessages = projectSessionMessagesForDisplay(
messages.slice(start),
messages.slice(start) as MessageWithMetadata[],
).map((entry) => ({
message: entry.message,
sourceIndex: start + entry.sourceIndex,
}));
const baseTs = nowMs() - messages.length;
const out: JsonRecord[] = [];
const checkpointsByRunCount = readCheckpointEntriesByRunCount(sessionId);
// Remote artifacts belong to the SSH host. Never decorate them with a
// same-id local session's live transcript or checkpoint metadata.
const checkpointsByRunCount = isRemoteRead
? new Map<number, StoredCheckpointEntry>()
: readCheckpointEntriesByRunCount(sessionId);
const pendingToolMessages = new Map<string, [number, string, unknown]>();
let userRunCount = 0;
for (let idx = 0; idx < start; idx += 1) {
@@ -462,6 +471,16 @@ export async function readSessionMessages(
const textParts: string[] = [];
const images: Array<{ id: string; mediaType: string; data: string }> = [];
const videos: Array<{
id: string;
mediaType: string;
artifactName: string;
}> = [];
const audios: Array<{
id: string;
mediaType: string;
artifactName: string;
}> = [];
const reasoningParts: string[] = [];
let reasoningRedacted = false;
let textSegmentIndex = 0;
@@ -517,6 +536,7 @@ export async function readSessionMessages(
createdAt: nextPartCreatedAt(),
meta: {
toolName,
...(toolUseId ? { toolCallId: toolUseId } : {}),
hookEventName: "history_tool_use",
},
});
@@ -547,6 +567,7 @@ export async function readSessionMessages(
? (target.meta as JsonRecord)
: {}),
toolName,
...(toolUseId ? { toolCallId: toolUseId } : {}),
hookEventName: "history_tool_result",
};
}
@@ -560,6 +581,7 @@ export async function readSessionMessages(
createdAt: nextPartCreatedAt(),
meta: {
toolName: "tool_result",
...(toolUseId ? { toolCallId: toolUseId } : {}),
hookEventName: "history_tool_result",
},
});
@@ -588,6 +610,30 @@ export async function readSessionMessages(
}
continue;
}
if (blockType === "video") {
const mediaType = trimNonEmptyString(record.mediaType);
const path = trimNonEmptyString(record.path);
if (mediaType && path) {
videos.push({
id: `${messageIdBase}_video_${blockIdx}`,
mediaType,
artifactName: basename(path),
});
}
continue;
}
if (blockType === "audio") {
const mediaType = trimNonEmptyString(record.mediaType);
const path = trimNonEmptyString(record.path);
if (mediaType && path) {
audios.push({
id: `${messageIdBase}_audio_${blockIdx}`,
mediaType,
artifactName: basename(path),
});
}
continue;
}
if (blockType === "media" && isGeneratedMedia(record.media)) {
flushTextParts();
out.push({
@@ -628,6 +674,44 @@ export async function readSessionMessages(
textMeta = undefined;
}
}
if (videos.length > 0) {
const target = out
.slice(outStartIndex)
.find((item) => item.role === role);
if (target) {
target.videos = videos;
} else {
out.push({
id: `${messageIdBase}_videos`,
sessionId,
role,
content: "",
videos,
createdAt: nextPartCreatedAt(),
meta: textMeta,
});
textMeta = undefined;
}
}
if (audios.length > 0) {
const target = out
.slice(outStartIndex)
.find((item) => item.role === role);
if (target) {
target.audios = audios;
} else {
out.push({
id: `${messageIdBase}_audios`,
sessionId,
role,
content: "",
audios,
createdAt: nextPartCreatedAt(),
meta: textMeta,
});
textMeta = undefined;
}
}
if (reasoningParts.length > 0 || reasoningRedacted) {
const reasoning = reasoningParts.join("\n").trim();
const target = out
@@ -2,13 +2,13 @@ import { getFileIndex } from "@cline/core";
import type { SidecarContext } from "../types";
export function searchWorkspaceFiles(
ctx: Pick<SidecarContext, "workspaceRoot">,
ctx: Pick<SidecarContext, "localWorkspaceRoot">,
args?: Record<string, unknown>,
): Promise<string[]> {
const root =
typeof args?.workspaceRoot === "string" && args.workspaceRoot.trim()
? args.workspaceRoot.trim()
: ctx.workspaceRoot;
: ctx.localWorkspaceRoot;
const query =
typeof args?.query === "string" ? args.query.trim().toLowerCase() : "";
const limit =
+28 -4
View File
@@ -8,6 +8,12 @@ import type {
ToolApprovalResult,
} from "@cline/core";
import type { MessageWithMetadata } from "@cline/llms";
import type {
RemoteEnvironmentConnection,
RemoteEnvironmentService,
} from "./remote-environments";
export const LOCAL_ENVIRONMENT_ID = "local";
export type JsonRecord = Record<string, unknown>;
@@ -36,6 +42,7 @@ export type ChatSessionCommandRequest = {
checkpointRunCount?: number;
forkBeforeRunCount?: number;
delivery?: "queue" | "steer";
source?: "desktop" | "realtime";
config?: JsonRecord;
attachments?: ChatTurnAttachments;
};
@@ -49,6 +56,7 @@ export type PromptInQueue = {
};
export type LiveSession = {
environmentId?: string;
config: JsonRecord;
messages: MessageWithMetadata[];
promptsInQueue: PromptInQueue[];
@@ -68,6 +76,16 @@ export type LiveSession = {
consumedAttachmentFiles?: Map<string, string[]>;
};
export type SessionRuntimeBinding = {
environmentId: string;
kind: "local" | "ssh";
workspaceRoot: string;
sessionManager: ClineCore;
hubClient: NodeHubClient;
unsubscribeSessionEvents: () => void;
remote?: RemoteEnvironmentConnection;
};
export type ToolApprovalRequestItem = {
requestId: string;
sessionId: string;
@@ -82,7 +100,7 @@ export type ToolApprovalRequestItem = {
export type PendingToolApproval = {
item: ToolApprovalRequestItem;
resolve: (result: ToolApprovalResult) => void;
resolve: (result: ToolApprovalResult) => void | Promise<void>;
};
export type AskQuestionRequestItem = {
@@ -115,12 +133,18 @@ export type SidecarContext = {
wsClients: Set<SidecarWebSocketClient>;
pendingApprovals: Map<string, PendingToolApproval>;
pendingQuestions: Map<string, PendingAskQuestion>;
sessionManager: ClineCore | null;
hubClient: NodeHubClient | null;
workspaceRoot: string;
runtimeBindings: Map<string, SessionRuntimeBinding>;
sessionEnvironmentIds: Map<string, string>;
activeEnvironmentId: string;
remoteEnvironments: RemoteEnvironmentService | null;
localWorkspaceRoot: string;
logger?: BasicLogger;
telemetry?: ITelemetryService;
unsubscribeSessionEvents: (() => void) | null;
cloudSessionManager: {
dispose(): Promise<void>;
isCloudSession(sessionId: string): boolean;
} | null;
/**
* Latest managed Hub build mismatch, broadcast as `hub_build_mismatch` and
* replayed to webviews that connect after the event fired.
+1
View File
@@ -519,6 +519,7 @@ dependencies = [
name = "cline-app"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"objc2",
"objc2-app-kit",
"objc2-foundation",
@@ -7,9 +7,10 @@ edition = "2021"
tauri-build = { version = "2.0.0", features = [] }
[dependencies]
base64 = "0.22"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "2.11.1", features = ["image-png", "tray-icon"] }
tauri = { version = "2.11.1", features = ["image-png", "macos-private-api", "tray-icon"] }
tauri-plugin-updater = "2"
tokio = { version = "1", features = ["sync", "time"] }
rfd = "0.15"
@@ -2,7 +2,7 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main-window",
"description": "Permissions required by the main desktop window.",
"windows": ["main"],
"windows": ["main", "avatar-overlay"],
"permissions": [
"core:default",
"core:window:allow-set-title",
+388 -5
View File
@@ -1,5 +1,6 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use base64::Engine;
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::fs;
@@ -14,17 +15,24 @@ use tauri::menu::{Menu, MenuItemKind, PredefinedMenuItem, Submenu};
use tauri::{
menu::{MenuBuilder, MenuItem},
tray::TrayIconBuilder,
Emitter, Manager, RunEvent, State, WindowEvent,
Emitter, Manager, RunEvent, State, WebviewUrl, WebviewWindowBuilder, WindowEvent,
};
use tauri_plugin_updater::UpdaterExt;
const UPDATE_INITIAL_DELAY: Duration = Duration::from_secs(10);
const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(2 * 60 * 60);
const MAIN_WINDOW_LABEL: &str = "main";
const AVATAR_OVERLAY_WINDOW_LABEL: &str = "avatar-overlay";
const DEFAULT_AVATAR_ID: &str = "cline-bot";
const AVATAR_CHANGED_EVENT: &str = "avatar-changed";
const AVATAR_SHOWN_EVENT: &str = "avatar-shown";
const AVATAR_COLLAPSED_WIDTH: f64 = 144.0;
const AVATAR_COLLAPSED_HEIGHT: f64 = 156.0;
const TRAY_ICON_ID: &str = "cline-code";
const TRAY_OPEN_MENU_ID: &str = "tray-open";
const TRAY_NEW_SESSION_MENU_ID: &str = "tray-new-session";
const TRAY_SETTINGS_MENU_ID: &str = "tray-settings";
const TRAY_TOGGLE_AVATAR_MENU_ID: &str = "tray-toggle-avatar";
const TRAY_QUIT_MENU_ID: &str = "tray-quit";
#[cfg(any(target_os = "macos", test))]
const VIEW_ZOOM_IN_MENU_ID: &str = "view-zoom-in";
@@ -68,6 +76,45 @@ struct AppContext {
workspace_root: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AvatarManifest {
id: String,
display_name: String,
description: String,
sprite_version_number: u8,
spritesheet_path: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct AvatarOption {
id: String,
display_name: String,
description: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct SelectedAvatar {
id: String,
display_name: String,
description: String,
sprite_url: String,
enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct SelectedAvatarPreference {
id: String,
#[serde(default = "default_avatar_enabled")]
enabled: bool,
}
fn default_avatar_enabled() -> bool {
true
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct UpdateStatus {
@@ -460,6 +507,14 @@ fn spawn_desktop_backend_process(context: &AppContext) -> Result<Child, String>
));
};
// The debug shell launches a compiled Bun sidecar, so it does not inherit
// Bun's `--conditions=development` signal. Mark it explicitly; otherwise
// the sidecar discovers the production hub and can execute sessions with
// an installed SDK instead of the sources in this checkout.
if cfg!(debug_assertions) && std::env::var_os("CLINE_BUILD_ENV").is_none() {
command.env("CLINE_BUILD_ENV", "development");
}
command
.stdin(Stdio::null())
.stdout(Stdio::piped())
@@ -806,6 +861,284 @@ fn show_main_window(app: &tauri::AppHandle) {
let _ = window.set_focus();
}
fn avatars_root() -> Result<PathBuf, String> {
let home = std::env::var("HOME").map_err(|_| "HOME is not set".to_string())?;
Ok(PathBuf::from(home).join(".cline").join("avatars"))
}
fn avatar_manifest_path(avatar_dir: &Path) -> Option<PathBuf> {
["avatar.json", "pet.json"]
.into_iter()
.map(|file_name| avatar_dir.join(file_name))
.find(|path| path.is_file())
}
fn read_custom_avatars() -> Vec<(AvatarManifest, PathBuf)> {
let Ok(root) = avatars_root() else {
return Vec::new();
};
let Ok(entries) = fs::read_dir(root) else {
return Vec::new();
};
let mut avatars = Vec::new();
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
if !file_type.is_dir() {
continue;
}
let Some(manifest_path) = avatar_manifest_path(&entry.path()) else {
continue;
};
let Ok(body) = fs::read(manifest_path) else {
continue;
};
let Ok(manifest) = serde_json::from_slice::<AvatarManifest>(&body) else {
continue;
};
if manifest.sprite_version_number != 2
|| manifest.id.trim().is_empty()
|| manifest.display_name.trim().is_empty()
{
continue;
}
let Ok(avatar_dir) = entry.path().canonicalize() else {
continue;
};
let Ok(sprite_path) = avatar_dir.join(&manifest.spritesheet_path).canonicalize() else {
continue;
};
if sprite_path.is_file() && sprite_path.starts_with(&avatar_dir) {
avatars.push((manifest, sprite_path));
}
}
avatars.sort_by(|(left, _), (right, _)| left.display_name.cmp(&right.display_name));
avatars
}
fn avatar_preference() -> SelectedAvatarPreference {
let Ok(root) = avatars_root() else {
return SelectedAvatarPreference {
id: DEFAULT_AVATAR_ID.to_string(),
enabled: true,
};
};
fs::read(root.join("selected.json"))
.ok()
.and_then(|body| serde_json::from_slice::<SelectedAvatarPreference>(&body).ok())
.unwrap_or_else(|| SelectedAvatarPreference {
id: DEFAULT_AVATAR_ID.to_string(),
enabled: true,
})
}
fn write_avatar_preference(preference: &SelectedAvatarPreference) -> Result<(), String> {
let root = avatars_root()?;
fs::create_dir_all(&root)
.map_err(|error| format!("failed creating avatar directory: {error}"))?;
let mut body = serde_json::to_vec_pretty(preference)
.map_err(|error| format!("failed encoding avatar preference: {error}"))?;
body.push(b'\n');
fs::write(root.join("selected.json"), body)
.map_err(|error| format!("failed saving avatar preference: {error}"))
}
#[tauri::command]
fn list_avatars() -> Vec<AvatarOption> {
let mut avatars = vec![
AvatarOption {
id: DEFAULT_AVATAR_ID.to_string(),
display_name: "Cline Bot".to_string(),
description: "A cheerful compact Cline avatar with expressive black oval eyes."
.to_string(),
},
AvatarOption {
id: "mom".to_string(),
display_name: "Mom".to_string(),
description: "A serene celestial oracle desktop companion.".to_string(),
},
];
for (manifest, _) in read_custom_avatars() {
let option = AvatarOption {
id: manifest.id.clone(),
display_name: manifest.display_name,
description: manifest.description,
};
if let Some(existing) = avatars.iter_mut().find(|avatar| avatar.id == option.id) {
*existing = option;
} else {
avatars.push(option);
}
}
avatars
}
#[tauri::command]
fn get_selected_avatar() -> SelectedAvatar {
let preference = avatar_preference();
let selected_id = preference.id;
if let Some((manifest, sprite_path)) = read_custom_avatars()
.into_iter()
.find(|(manifest, _)| manifest.id == selected_id)
{
if let Ok(bytes) = fs::read(&sprite_path) {
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
let mime_type = match sprite_path.extension().and_then(|value| value.to_str()) {
Some("png") => "image/png",
_ => "image/webp",
};
return SelectedAvatar {
id: manifest.id,
display_name: manifest.display_name,
description: manifest.description,
sprite_url: format!("data:{mime_type};base64,{encoded}"),
enabled: preference.enabled,
};
}
}
match selected_id.as_str() {
"mom" => SelectedAvatar {
id: "mom".to_string(),
display_name: "Mom".to_string(),
description: "A serene celestial oracle desktop companion.".to_string(),
sprite_url: "/avatars/mom/spritesheet.webp".to_string(),
enabled: preference.enabled,
},
_ => SelectedAvatar {
id: DEFAULT_AVATAR_ID.to_string(),
display_name: "Cline Bot".to_string(),
description: "A cheerful compact Cline avatar with expressive black oval eyes."
.to_string(),
sprite_url: "/avatars/cline-bot/spritesheet.webp".to_string(),
enabled: preference.enabled,
},
}
}
#[tauri::command]
fn set_selected_avatar(app: tauri::AppHandle, id: String) -> Result<(), String> {
if !list_avatars().iter().any(|avatar| avatar.id == id) {
return Err(format!("unknown avatar: {id}"));
}
let preference = SelectedAvatarPreference {
id,
enabled: avatar_preference().enabled,
};
write_avatar_preference(&preference)?;
let _ = app.emit_to(AVATAR_OVERLAY_WINDOW_LABEL, AVATAR_CHANGED_EVENT, ());
Ok(())
}
#[tauri::command]
fn set_avatar_enabled(app: tauri::AppHandle, enabled: bool) -> Result<(), String> {
let preference = SelectedAvatarPreference {
id: avatar_preference().id,
enabled,
};
write_avatar_preference(&preference)?;
if let Some(window) = app.get_webview_window(AVATAR_OVERLAY_WINDOW_LABEL) {
if preference.enabled {
window
.show()
.map_err(|error| format!("failed showing avatar overlay: {error}"))?;
let _ = app.emit_to(AVATAR_OVERLAY_WINDOW_LABEL, AVATAR_SHOWN_EVENT, ());
} else {
window
.hide()
.map_err(|error| format!("failed hiding avatar overlay: {error}"))?;
}
}
Ok(())
}
#[tauri::command]
fn handle_avatar_overlay_action(app: tauri::AppHandle, action: String) -> Result<(), String> {
match action.as_str() {
"open-cline" => show_main_window(&app),
"hide-avatar" => {
let preference = SelectedAvatarPreference {
id: avatar_preference().id,
enabled: false,
};
write_avatar_preference(&preference)?;
if let Some(window) = app.get_webview_window(AVATAR_OVERLAY_WINDOW_LABEL) {
window
.hide()
.map_err(|error| format!("failed hiding avatar overlay: {error}"))?;
}
}
_ => return Err(format!("unknown avatar overlay action: {action}")),
}
Ok(())
}
fn setup_avatar_overlay(app: &tauri::App) -> tauri::Result<()> {
let url = if cfg!(debug_assertions) {
WebviewUrl::External(
"http://localhost:3125/avatar-overlay"
.parse()
.expect("avatar overlay development URL must be valid"),
)
} else {
WebviewUrl::App("avatar-overlay.html".into())
};
let overlay = WebviewWindowBuilder::new(app, AVATAR_OVERLAY_WINDOW_LABEL, url)
.title("Cline Bot")
.inner_size(AVATAR_COLLAPSED_WIDTH, AVATAR_COLLAPSED_HEIGHT)
.resizable(false)
.decorations(false)
.transparent(true)
.shadow(false)
.always_on_top(true)
.visible_on_all_workspaces(true)
.skip_taskbar(true)
.focused(false)
.visible(avatar_preference().enabled)
.build()?;
if let Some(main) = app.get_webview_window(MAIN_WINDOW_LABEL) {
if let (Ok(main_position), Ok(main_size), Ok(overlay_size)) = (
main.outer_position(),
main.inner_size(),
overlay.outer_size(),
) {
let scale = main.scale_factor().unwrap_or(1.0);
let margin = (16.0 * scale).round() as i32;
let x = main_position.x.saturating_add(margin);
let y = main_position
.y
.saturating_add(main_size.height as i32)
.saturating_sub(overlay_size.height as i32)
.saturating_sub(margin);
let _ = overlay.set_position(tauri::PhysicalPosition::new(x, y));
}
}
Ok(())
}
fn toggle_avatar_overlay(app: &tauri::AppHandle) {
let Some(window) = app.get_webview_window(AVATAR_OVERLAY_WINDOW_LABEL) else {
return;
};
if window.is_visible().unwrap_or(false) {
let _ = window.hide();
let _ = write_avatar_preference(&SelectedAvatarPreference {
id: avatar_preference().id,
enabled: false,
});
} else {
let _ = window.show();
let _ = app.emit_to(AVATAR_OVERLAY_WINDOW_LABEL, AVATAR_SHOWN_EVENT, ());
let _ = write_avatar_preference(&SelectedAvatarPreference {
id: avatar_preference().id,
enabled: true,
});
}
}
fn queue_desktop_menu_action(app: &tauri::AppHandle, action: &str) {
show_main_window(app);
app.state::<DesktopMenuActionState>().enqueue(action);
@@ -931,6 +1264,7 @@ fn setup_tray_icon(app: &tauri::App) -> tauri::Result<()> {
.item(&running_sessions)
.separator()
.text(TRAY_SETTINGS_MENU_ID, "Settings")
.text(TRAY_TOGGLE_AVATAR_MENU_ID, "Show/Hide Avatar")
.separator()
.text(TRAY_QUIT_MENU_ID, "Quit")
.build()?;
@@ -957,6 +1291,7 @@ fn setup_tray_icon(app: &tauri::App) -> tauri::Result<()> {
TRAY_OPEN_MENU_ID => show_main_window(app),
TRAY_NEW_SESSION_MENU_ID => queue_desktop_menu_action(app, "new-session"),
TRAY_SETTINGS_MENU_ID => queue_desktop_menu_action(app, "open-settings"),
TRAY_TOGGLE_AVATAR_MENU_ID => toggle_avatar_overlay(app),
TRAY_QUIT_MENU_ID => app.exit(0),
_ => {}
})
@@ -1018,6 +1353,7 @@ fn main() {
#[cfg(target_os = "macos")]
setup_application_menu(app)?;
setup_tray_icon(app)?;
setup_avatar_overlay(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) {
@@ -1049,6 +1385,11 @@ fn main() {
api.prevent_close();
let _ = window.hide();
}
} else if window.label() == AVATAR_OVERLAY_WINDOW_LABEL {
if let WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
let _ = window.hide();
}
}
})
.invoke_handler(tauri::generate_handler![
@@ -1059,6 +1400,11 @@ fn main() {
restart_to_apply_update,
check_for_update_now,
set_app_icon,
list_avatars,
get_selected_avatar,
set_selected_avatar,
set_avatar_enabled,
handle_avatar_overlay_action,
drain_desktop_menu_actions,
set_tray_status
])
@@ -1066,10 +1412,7 @@ fn main() {
.expect("error while building tauri app")
.run(|app_handle, event| match event {
#[cfg(target_os = "macos")]
RunEvent::Reopen {
has_visible_windows: false,
..
} => show_main_window(app_handle),
RunEvent::Reopen { .. } => show_main_window(app_handle),
RunEvent::ExitRequested { .. } | RunEvent::Exit => {
app_handle
.state::<Arc<DesktopBackendState>>()
@@ -1085,6 +1428,46 @@ mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn avatar_preference_defaults_to_enabled_and_supports_explicit_state() {
let default_preference: SelectedAvatarPreference =
serde_json::from_str(r#"{"id":"mom"}"#).expect("preference should decode");
assert_eq!(default_preference.id, "mom");
assert!(default_preference.enabled);
let disabled_preference: SelectedAvatarPreference =
serde_json::from_str(r#"{"id":"mom","enabled":false}"#)
.expect("disabled preference should decode");
assert!(!disabled_preference.enabled);
}
#[test]
fn avatar_manifest_prefers_avatar_json_and_accepts_pet_json() {
let test_dir = std::env::temp_dir().join(format!(
"cline-avatar-manifest-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
fs::create_dir_all(&test_dir).expect("test directory should be created");
fs::write(test_dir.join("pet.json"), b"{}").expect("pet manifest should be written");
assert_eq!(
avatar_manifest_path(&test_dir)
.and_then(|path| path.file_name().map(|name| name.to_owned())),
Some(std::ffi::OsString::from("pet.json"))
);
fs::write(test_dir.join("avatar.json"), b"{}")
.expect("avatar manifest should be written");
assert_eq!(
avatar_manifest_path(&test_dir)
.and_then(|path| path.file_name().map(|name| name.to_owned())),
Some(std::ffi::OsString::from("avatar.json"))
);
fs::remove_dir_all(&test_dir).expect("test directory should be removed");
}
#[test]
fn desktop_menu_actions_are_buffered_in_order_until_drained() {
let state = DesktopMenuActionState::default();
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cline Code",
"version": "0.0.13",
"version": "0.0.14-beta.1",
"identifier": "bot.cline.app",
"build": {
"beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web",
@@ -18,6 +18,7 @@
}
},
"app": {
"macOSPrivateApi": true,
"windows": [
{
"label": "main",
@@ -38,7 +39,7 @@
"active": true,
"targets": "all",
"externalBin": ["bin/code-sidecar"],
"resources": ["icons/dock/*.png"],
"resources": ["icons/dock/*.png", "bin/remote-helpers/*"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
@@ -9,5 +9,6 @@ export default defineConfig({
},
test: {
environment: "node",
setupFiles: [fileURLToPath(new URL("./vitest.setup.ts", import.meta.url))],
},
});
+34
View File
@@ -0,0 +1,34 @@
// Node 25 exposes an empty localStorage object when no backing file is set.
// jsdom can inherit that object instead of installing its Storage instance,
// so provide the browser contract tests expect when the native methods are
// unavailable.
if (
typeof window !== "undefined" &&
typeof window.localStorage?.getItem !== "function"
) {
const values = new Map<string, string>();
const storage: Storage = {
get length() {
return values.size;
},
clear() {
values.clear();
},
getItem(key) {
return values.get(String(key)) ?? null;
},
key(index) {
return [...values.keys()][index] ?? null;
},
removeItem(key) {
values.delete(String(key));
},
setItem(key, value) {
values.set(String(key), String(value));
},
};
Object.defineProperty(window, "localStorage", {
configurable: true,
value: storage,
});
}
@@ -0,0 +1,10 @@
export default function AvatarOverlayLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<>
<style>{"html, body { background: transparent !important; }"}</style>
{children}
</>
);
}
@@ -0,0 +1,231 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
AVATAR_CHANGED_EVENT,
AVATAR_SHOWN_EVENT,
getSelectedAvatar,
performAvatarOverlayAction,
type SelectedAvatar,
} from "@/lib/avatar";
import {
AVATAR_ATLAS_COLUMNS,
AVATAR_ATLAS_ROWS,
AVATAR_CELL_HEIGHT,
AVATAR_CELL_WIDTH,
AVATAR_DISPLAY_SCALE,
AVATAR_IDLE_DURATIONS_MS,
AVATAR_JUMP_DURATIONS_MS,
AVATAR_WAVE_DURATIONS_MS,
avatarFrameBackgroundPosition,
} from "@/lib/avatar-sprite";
type Animation = "idle" | "jumping" | "waving";
type ContextMenuPosition = { x: number; y: number };
const DISPLAY_WIDTH = AVATAR_CELL_WIDTH * AVATAR_DISPLAY_SCALE;
const DISPLAY_HEIGHT = AVATAR_CELL_HEIGHT * AVATAR_DISPLAY_SCALE;
const CONTEXT_MENU_WIDTH = 132;
const CONTEXT_MENU_HEIGHT = 70;
export default function AvatarOverlayPage() {
const [avatar, setAvatar] = useState<SelectedAvatar | null>(null);
const [animation, setAnimation] = useState<Animation>("waving");
const [frame, setFrame] = useState(0);
const [contextMenu, setContextMenu] = useState<ContextMenuPosition | null>(
null,
);
const contextMenuRef = useRef<HTMLDivElement | null>(null);
const dragTimerRef = useRef<number | null>(null);
const draggingRef = useRef(false);
const suppressActivationRef = useRef(false);
const loadAvatar = useCallback(async () => {
const selected = await getSelectedAvatar();
setAvatar(selected);
void import("@tauri-apps/api/window").then(({ getCurrentWindow }) =>
getCurrentWindow().setTitle(selected.displayName),
);
setAnimation("waving");
setFrame(0);
}, []);
useEffect(() => {
void loadAvatar();
let disposed = false;
let unlisten: (() => void) | undefined;
void import("@tauri-apps/api/event").then(async ({ listen }) => {
const stopChanged = await listen(AVATAR_CHANGED_EVENT, () => {
if (!disposed) void loadAvatar();
});
const stopShown = await listen(AVATAR_SHOWN_EVENT, () => {
if (!disposed) {
setAnimation("waving");
setFrame(0);
}
});
const stop = () => {
stopChanged();
stopShown();
};
if (disposed) stop();
else unlisten = stop;
});
return () => {
disposed = true;
unlisten?.();
};
}, [loadAvatar]);
useEffect(() => {
if (!contextMenu) return;
const dismissOutside = (event: PointerEvent) => {
if (contextMenuRef.current?.contains(event.target as Node)) return;
suppressActivationRef.current = true;
setContextMenu(null);
};
const dismissOnBlur = () => setContextMenu(null);
window.addEventListener("pointerdown", dismissOutside, true);
window.addEventListener("blur", dismissOnBlur);
return () => {
window.removeEventListener("pointerdown", dismissOutside, true);
window.removeEventListener("blur", dismissOnBlur);
};
}, [contextMenu]);
useEffect(() => {
const durations =
animation === "jumping"
? AVATAR_JUMP_DURATIONS_MS
: animation === "waving"
? AVATAR_WAVE_DURATIONS_MS
: AVATAR_IDLE_DURATIONS_MS;
const timeout = window.setTimeout(() => {
if (animation !== "idle" && frame === durations.length - 1) {
setAnimation("idle");
setFrame(0);
return;
}
setFrame((current) => (current + 1) % durations.length);
}, durations[frame]);
return () => window.clearTimeout(timeout);
}, [animation, frame]);
const clearDragTimer = () => {
if (dragTimerRef.current !== null) {
window.clearTimeout(dragTimerRef.current);
dragTimerRef.current = null;
}
};
const handlePointerDown = (event: React.PointerEvent) => {
if (event.button !== 0 || contextMenu) return;
draggingRef.current = false;
clearDragTimer();
dragTimerRef.current = window.setTimeout(() => {
draggingRef.current = true;
void import("@tauri-apps/api/window").then(({ getCurrentWindow }) =>
getCurrentWindow().startDragging(),
);
}, 180);
};
const handlePointerUp = () => {
const wasDragging = draggingRef.current;
clearDragTimer();
draggingRef.current = false;
if (suppressActivationRef.current) {
suppressActivationRef.current = false;
return;
}
if (!wasDragging && !contextMenu) {
void performAvatarOverlayAction("open-cline");
}
};
const closeAvatar = async () => {
setContextMenu(null);
await performAvatarOverlayAction("hide-avatar");
};
const animationRow = animation === "waving" ? 3 : animation === "jumping" ? 4 : 0;
return (
<main
aria-label={`${avatar?.displayName ?? "Avatar"} desktop avatar`}
className="relative h-screen w-screen select-none overflow-hidden bg-transparent"
>
<div
aria-label="Open Cline"
className="absolute bottom-0 right-0 cursor-pointer"
onContextMenu={(event) => {
event.preventDefault();
clearDragTimer();
setContextMenu({
x: Math.min(event.clientX, window.innerWidth - CONTEXT_MENU_WIDTH),
y: Math.min(
event.clientY,
window.innerHeight - CONTEXT_MENU_HEIGHT,
),
});
}}
onPointerDown={handlePointerDown}
onPointerEnter={() => {
if (animation === "idle") {
setAnimation("jumping");
setFrame(0);
}
}}
onPointerLeave={clearDragTimer}
onPointerUp={handlePointerUp}
role="button"
style={{ height: DISPLAY_HEIGHT, width: DISPLAY_WIDTH }}
tabIndex={0}
>
{avatar ? (
<div
aria-hidden="true"
style={{
backgroundImage: `url(${JSON.stringify(avatar.spriteUrl)})`,
backgroundPosition: avatarFrameBackgroundPosition(
animationRow,
frame,
AVATAR_DISPLAY_SCALE,
),
backgroundRepeat: "no-repeat",
backgroundSize: `${AVATAR_ATLAS_COLUMNS * AVATAR_CELL_WIDTH * AVATAR_DISPLAY_SCALE}px ${AVATAR_ATLAS_ROWS * AVATAR_CELL_HEIGHT * AVATAR_DISPLAY_SCALE}px`,
height: DISPLAY_HEIGHT,
imageRendering: "pixelated",
width: DISPLAY_WIDTH,
}}
/>
) : null}
</div>
{contextMenu ? (
<div
className="absolute z-30 w-[132px] overflow-hidden rounded-md border border-white/15 bg-neutral-950/95 p-1 text-[11px] text-white shadow-xl backdrop-blur"
ref={contextMenuRef}
style={{ left: contextMenu.x, top: contextMenu.y }}
>
<button
className="block w-full rounded px-2 py-1.5 text-left hover:bg-white/10"
onClick={() => {
setContextMenu(null);
void performAvatarOverlayAction("open-cline");
}}
type="button"
>
Open Cline
</button>
<button
className="block w-full rounded px-2 py-1.5 text-left text-red-300 hover:bg-white/10"
onClick={() => void closeAvatar()}
type="button"
>
Close avatar
</button>
</div>
) : null}
</main>
);
}
@@ -134,6 +134,26 @@
}
}
.cline-thinking-slider-shimmer {
background-image: linear-gradient(
105deg,
transparent 20%,
color-mix(in oklab, var(--primary-foreground) 45%, transparent) 42%,
color-mix(in oklab, var(--primary-foreground) 70%, transparent) 50%,
color-mix(in oklab, var(--primary-foreground) 45%, transparent) 58%,
transparent 80%
);
background-position: 180% 0;
background-size: 220% 100%;
animation: cline-thinking-slider-shimmer 1.8s ease-in-out infinite;
}
@keyframes cline-thinking-slider-shimmer {
to {
background-position: -180% 0;
}
}
/* Softens the welcome <-> conversation swap: the hero and the message grid
* replace each other in a single commit, which otherwise reads as a hard
* white flash. Plays whenever the element (re)becomes visible display:none
@@ -160,6 +180,10 @@
animation: none;
}
.cline-thinking-slider-shimmer {
display: none;
}
.cline-view-enter {
animation: none;
}
File diff suppressed because it is too large Load Diff
@@ -47,6 +47,44 @@ describe("AgentHeader title bar", () => {
});
describe("AgentHeader title editor", () => {
it("shows the workspace selector below the session title", async () => {
await act(async () => {
root.render(
<AgentHeader
onNewThread={vi.fn()}
status="idle"
title="Session"
workspace={{
currentBranch: "main",
onListGitBranches: vi.fn(async () => ({
branches: ["main"],
current: "main",
})),
onRefreshWorkspaces: vi.fn(async () => undefined),
onSwitchGitBranch: vi.fn(async () => true),
onSwitchWorkspace: vi.fn(async () => true),
workspaces: ["/workspace/cline"],
workspaceRoot: "/workspace/cline",
}}
/>,
);
});
const workspace = container.querySelector("#git-branch-btn");
const newSession = container.querySelector('[aria-label="New session"]');
const title = container.querySelector('[title="Session"]');
expect(workspace).not.toBeNull();
expect((workspace as HTMLButtonElement | null)?.disabled).toBe(true);
expect(workspace?.className).toContain("text-xs");
expect(workspace?.className).toContain("text-muted-foreground");
expect(workspace?.parentElement?.parentElement?.parentElement).toBe(
title?.parentElement?.parentElement,
);
expect(workspace?.parentElement?.parentElement?.parentElement).not.toBe(
newSession?.parentElement,
);
});
it("preserves the displayed title width when editing starts", async () => {
await act(async () => {
root.render(
@@ -66,7 +104,7 @@ describe("AgentHeader title editor", () => {
"max-md:pl-28",
);
expect(container.querySelector("header")?.className).toContain(
"max-md:h-7",
"max-md:h-12",
);
expect(container.querySelector("header")?.className).toContain(
"md:group-data-[state=collapsed]/sidebar-wrapper:pl-7",
@@ -34,6 +34,7 @@ import {
import { Input } from "./ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { normalizeTitle } from "./utils";
import { WorkspaceSelector } from "./views/chat/workspace-selector";
type AgentHeaderProps = {
title?: string;
@@ -60,6 +61,16 @@ type AgentHeaderProps = {
/** Set when the open session is itself a child agent run. */
parentSession?: { sessionId: string; title?: string };
onOpenParentSession?: (parentSessionId: string) => void | Promise<void>;
workspace?: {
currentBranch: string | null;
workspaceRoot: string;
workspaces: string[];
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
onRefreshWorkspaces: () => Promise<void>;
onSwitchGitBranch: (branch: string) => Promise<boolean>;
onSwitchWorkspace: (workspacePath: string) => Promise<boolean>;
onPickWorkspaceDirectory?: (initialPath?: string) => Promise<string | null>;
};
};
function AgentHeaderImpl({
@@ -83,6 +94,7 @@ function AgentHeaderImpl({
onOpenAgentSession,
parentSession,
onOpenParentSession,
workspace,
}: AgentHeaderProps) {
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [titleInput, setTitleInput] = useState("");
@@ -124,7 +136,7 @@ function AgentHeaderImpl({
return (
<header
className="flex h-12 items-center justify-between gap-2 px-4 max-md:h-7 max-md:pl-28 md:group-data-[state=collapsed]/sidebar-wrapper:pl-7"
className="flex h-12 items-center justify-between gap-2 px-4 max-md:h-12 max-md:pl-28 md:group-data-[state=collapsed]/sidebar-wrapper:pl-7"
data-tauri-drag-region="deep"
>
{/* Left: thread title */}
@@ -140,88 +152,111 @@ function AgentHeaderImpl({
}
tone={statusTone}
/>
{!canEditTitle ? (
<span
className="min-w-0 truncate text-sm font-medium text-foreground"
title={threadTitle}
>
{threadTitle}
</span>
) : isEditingTitle ? (
<form
className="m-0 min-w-0 max-w-full shrink-0"
onSubmit={(event) => {
event.preventDefault();
void submitTitle();
}}
style={{ width: titleEditorWidth }}
>
<Input
autoFocus
className="h-7 w-full text-sm"
disabled={renamingTitle}
onBlur={() => {
void submitTitle();
}}
onChange={(event) => setTitleInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
<div className="flex min-w-0 flex-col justify-center">
<div className="flex min-w-0 items-center gap-1">
{!canEditTitle ? (
<span
className="min-w-0 truncate text-sm font-medium text-foreground"
title={threadTitle}
>
{threadTitle}
</span>
) : isEditingTitle ? (
<form
className="m-0 min-w-0 max-w-full shrink-0"
onSubmit={(event) => {
event.preventDefault();
void submitTitle();
}}
style={{ width: titleEditorWidth }}
>
<Input
autoFocus
className="h-7 w-full text-sm"
disabled={renamingTitle}
onBlur={() => {
void submitTitle();
}}
onChange={(event) => setTitleInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
setTitleInput(threadTitle);
setIsEditingTitle(false);
}
}}
value={titleInput}
/>
</form>
) : (
<button
className={cn(
"min-w-0 truncate text-sm font-medium text-foreground",
canEditTitle &&
"rounded px-1 py-0.5 transition-colors hover:bg-accent",
)}
disabled={renamingTitle}
onClick={(event) => {
if (!canEditTitle || renamingTitle) {
return;
}
setTitleEditorWidth(
event.currentTarget.getBoundingClientRect().width,
);
setTitleInput(threadTitle);
setIsEditingTitle(false);
}
}}
value={titleInput}
/>
</form>
) : (
<button
className={cn(
"min-w-0 truncate text-sm font-medium text-foreground",
canEditTitle &&
"rounded px-1 py-0.5 transition-colors hover:bg-surface-hover",
setIsEditingTitle(true);
}}
type="button"
title={threadTitle}
>
{threadTitle}
</button>
)}
disabled={renamingTitle}
onClick={(event) => {
if (!canEditTitle || renamingTitle) {
return;
}
setTitleEditorWidth(
event.currentTarget.getBoundingClientRect().width,
);
setTitleInput(threadTitle);
setIsEditingTitle(true);
}}
type="button"
title={threadTitle}
>
{threadTitle}
</button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="Session actions"
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
id="show-more-btn"
variant="ghost"
size="icon-sm"
type="button"
>
<MoreHorizontal className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-44">
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={!canDeleteSession || deletingSession}
onClick={triggerDeleteSession}
>
<Trash2 className="size-4" />
<span>{deletingSession ? "Deleting..." : "Delete session"}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{showSessionActions ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="Session actions"
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
id="show-more-btn"
size="icon-sm"
type="button"
variant="ghost"
>
<MoreHorizontal className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-44">
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={!canDeleteSession || deletingSession}
onClick={triggerDeleteSession}
>
<Trash2 className="size-4" />
<span>
{deletingSession ? "Deleting..." : "Delete session"}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
{workspace ? (
<WorkspaceSelector
className="text-xs text-muted-foreground disabled:opacity-100"
currentBranch={workspace.currentBranch}
disabled
onListGitBranches={workspace.onListGitBranches}
onPickWorkspaceDirectory={workspace.onPickWorkspaceDirectory}
onRefreshWorkspaces={workspace.onRefreshWorkspaces}
onSwitchGitBranch={workspace.onSwitchGitBranch}
onSwitchWorkspace={workspace.onSwitchWorkspace}
placement="bottom"
workspaces={workspace.workspaces}
workspaceRoot={workspace.workspaceRoot}
/>
) : null}
</div>
</div>
{showSessionActions ? (
@@ -265,7 +300,7 @@ function AgentHeaderImpl({
) : (
<Button
aria-label="New session"
className="flex items-center gap-1 rounded-md text-sm text-muted-foreground hover:bg-surface-hover hover:text-foreground transition-colors"
className="hidden items-center gap-1 rounded-md text-sm text-muted-foreground transition-colors hover:bg-surface-hover hover:text-foreground"
onClick={() => onNewThread?.()}
size="icon-sm"
variant="ghost"
@@ -250,6 +250,21 @@ describe("AgentSidebar session organization", () => {
).toBe(false);
});
it("labels a cloud session by repository", () => {
expect(
getSessionOverviewItems({
...makeThread("cloud", 1),
origin: "cloud",
repoUrl: "https://github.com/cline/cline",
workspacePath: "https://github.com/cline/cline",
}),
).toContainEqual([
"Repository",
"https://github.com/cline/cline",
"https://github.com/cline/cline",
]);
});
it("shows the full first line of the session title", () => {
const firstLine =
"This is a complete session title that is intentionally longer than seventy characters for the hover overview";
@@ -557,6 +572,9 @@ describe("AgentSidebar session organization", () => {
onHome={vi.fn()}
onNewThread={onNewThread}
onSettingsSectionChange={vi.fn()}
realtimeVoiceControl={
<span data-testid="realtime-voice-control" />
}
sessionHistory={makeSessionHistory([], vi.fn())}
setView={vi.fn()}
settingsSection="General"
@@ -569,8 +587,12 @@ describe("AgentSidebar session organization", () => {
const logo = container.querySelector('[aria-label="Cline home"]');
const newSession = container.querySelector('[aria-label="New Session"]');
const realtimeVoice = container.querySelector(
'[data-testid="realtime-voice-control"]',
);
expect(logo).not.toBeNull();
expect(newSession).not.toBeNull();
expect(realtimeVoice?.parentElement).toBe(newSession?.parentElement);
expect(newSession?.textContent).toBe("");
await click(newSession as Element);
expect(onNewThread).toHaveBeenCalledOnce();
@@ -586,6 +608,9 @@ describe("AgentSidebar session organization", () => {
onHome={vi.fn()}
onNewThread={vi.fn()}
onSettingsSectionChange={vi.fn()}
realtimeVoiceControl={
<span data-testid="realtime-voice-control" />
}
sessionHistory={makeSessionHistory([], vi.fn())}
setView={vi.fn()}
settingsSection="General"
@@ -598,6 +623,14 @@ describe("AgentSidebar session organization", () => {
expect(container.querySelector('[aria-label="Cline home"]')).not.toBeNull();
expect(container.querySelector('[aria-label="New Session"]')).toBeNull();
const collapsedLogo = container.querySelector('[aria-label="Cline home"]');
const collapsedRealtimeVoice = container.querySelector(
'[data-testid="realtime-voice-control"]',
);
expect(
collapsedLogo?.compareDocumentPosition(collapsedRealtimeVoice as Node) ??
0,
).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(
container.querySelector('[aria-label="Expand sidebar"]')?.className,
).toContain("mt-auto");
@@ -9,12 +9,14 @@ import {
ChevronRight,
CircleUserRound,
Clock3,
Cloud,
Code,
FileText,
Filter,
FolderTree,
GitFork,
Loader2,
Network,
PanelLeftOpen,
Pencil,
Plug,
@@ -90,6 +92,7 @@ import {
isBetaVersion,
productNameForVersion,
} from "@/lib/app-channel";
import { isCloudProvisioningSessionId } from "@/lib/cloud-repositories";
import { desktopClient } from "@/lib/desktop-client";
import {
ALL_SESSION_SOURCES,
@@ -140,6 +143,7 @@ const SETTINGS_SECTION_ICONS = {
Models: Bot,
Channels: Radio,
Schedules: Clock3,
Remote: Network,
Account: CircleUserRound,
Plugins: Plug,
Skills: Activity,
@@ -222,6 +226,7 @@ export function AgentSidebar({
view,
activeSessionId,
sessionHistory,
realtimeVoiceControl,
}: {
canNavigateBack?: boolean;
canNavigateForward?: boolean;
@@ -235,6 +240,7 @@ export function AgentSidebar({
view: AppView;
activeSessionId?: string | null;
sessionHistory: UseSessionHistoryResult;
realtimeVoiceControl?: ReactNode;
}) {
const { isMobile, setOpen, setOpenMobile, state } = useSidebar();
const isCollapsed = !isMobile && state === "collapsed";
@@ -631,8 +637,8 @@ export function AgentSidebar({
<div
className={cn(
"flex h-10 shrink-0 items-center justify-between px-2",
isCollapsed && "px-1.5",
"flex min-h-10 shrink-0 items-center justify-between px-3",
isCollapsed && "flex-col items-center justify-start gap-1 px-1.5",
)}
>
<div className="flex min-w-0 items-center gap-0.5">
@@ -704,18 +710,26 @@ export function AgentSidebar({
) : null}
{!isCollapsed ? <AppUpdateIndicator /> : null}
</div>
{!isCollapsed ? (
<Button
aria-label="New Session"
className="size-8 shrink-0 justify-center px-0"
onClick={openNewThread}
title="New Session"
type="button"
variant="sidebarItem"
>
<Plus className="size-4" />
</Button>
) : null}
<div
className={cn(
"flex shrink-0 items-center gap-0.5",
isCollapsed && "flex-col",
)}
>
{realtimeVoiceControl}
{!isCollapsed ? (
<Button
aria-label="New Session"
className="size-8 shrink-0 justify-center px-0"
onClick={openNewThread}
title="New Session"
type="button"
variant="sidebarItem"
>
<Plus className="size-4" />
</Button>
) : null}
</div>
</div>
{isCollapsed ? (
@@ -986,9 +1000,9 @@ export function AgentSidebar({
<AlertDialogHeader>
<AlertDialogTitle>Delete session?</AlertDialogTitle>
<AlertDialogDescription>
This removes "
{normalizeTitle(deleteConfirmThread?.title ?? "this session")}"
from local history.
{deleteConfirmThread?.origin === "cloud"
? `This deletes "${normalizeTitle(deleteConfirmThread?.title ?? "this session")}" and its cloud workspace.`
: `This removes "${normalizeTitle(deleteConfirmThread?.title ?? "this session")}" from local history.`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@@ -1092,11 +1106,13 @@ function ThreadItem({
const pending = pendingAction !== null;
const statusDotClass = pending
? "bg-yellow-400"
: thread.status === "running"
? "bg-green-500"
: unread
? "bg-blue-500"
: "";
: thread.status === "provisioning"
? "animate-pulse bg-yellow-400"
: thread.status === "running"
? "bg-green-500"
: unread
? "bg-blue-500"
: "";
const infoItems = getSessionOverviewItems(thread);
if (editing) {
@@ -1139,8 +1155,14 @@ function ThreadItem({
onClick={onClick}
type="button"
>
<span className="block max-w-full min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm font-normal leading-tight">
{title}
<span className="flex max-w-full min-w-0 items-center gap-1.5 overflow-hidden text-sm font-normal leading-tight">
{thread.origin === "cloud" ? (
<Cloud
aria-label="Cloud session"
className="size-3 shrink-0 text-muted-foreground"
/>
) : null}
<span className="truncate">{title}</span>
</span>
<span className="flex shrink-0 items-center gap-1.5 text-[11px] text-muted-foreground">
{thread.pinned ? (
@@ -1187,6 +1209,12 @@ function ThreadItem({
</HoverCardContent>
</HoverCard>
<SessionContextMenuContent
allowFavorite={thread.origin !== "cloud"}
allowFork={thread.origin !== "cloud"}
// Provisioning placeholders have no server session to rename or
// delete yet (the sidecar rejects both until the create settles).
allowRename={!isCloudProvisioningSessionId(thread.id)}
allowDelete={!isCloudProvisioningSessionId(thread.id)}
favorited={Boolean(thread.pinned)}
onDelete={onDelete}
onFork={onFork}
@@ -1210,8 +1238,10 @@ export function getSessionOverviewItems(
const workspacePath = thread.workspacePath || thread.codebase;
const items: Array<[string, string | null | undefined, string?]> = [
[
"Workspace",
workspaceDisplayName(workspacePath),
thread.origin === "cloud" ? "Repository" : "Workspace",
thread.origin === "cloud"
? thread.repoUrl
: workspaceDisplayName(workspacePath),
workspacePath || undefined,
],
["Branch", thread.gitBranch],
@@ -1279,6 +1309,10 @@ function EditableSessionTitle({
}
function SessionContextMenuContent({
allowFavorite,
allowFork,
allowRename,
allowDelete = true,
favorited,
onRename,
onToggleFavorite,
@@ -1286,6 +1320,10 @@ function SessionContextMenuContent({
onDelete,
pendingAction,
}: {
allowFavorite: boolean;
allowFork: boolean;
allowRename: boolean;
allowDelete?: boolean;
favorited: boolean;
onRename: () => void;
onToggleFavorite: () => void;
@@ -1296,28 +1334,34 @@ function SessionContextMenuContent({
const pending = pendingAction !== null;
return (
<ContextMenuContent className="w-40">
<ContextMenuItem disabled={pending} onSelect={onToggleFavorite}>
<Star className={cn("size-4", favorited && "fill-current")} />
{favorited ? "Unfavorite" : "Favorite"}
</ContextMenuItem>
<ContextMenuItem disabled={pending} onSelect={onRename}>
{pendingAction === "rename" ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Pencil className="size-4" />
)}
{pendingAction === "rename" ? "Renaming..." : "Rename"}
</ContextMenuItem>
<ContextMenuItem disabled={pending} onSelect={onFork}>
{pendingAction === "fork" ? (
<Loader2 className="size-4 animate-spin" />
) : (
<GitFork className="size-4" />
)}
{pendingAction === "fork" ? "Forking..." : "Fork"}
</ContextMenuItem>
{allowFavorite ? (
<ContextMenuItem disabled={pending} onSelect={onToggleFavorite}>
<Star className={cn("size-4", favorited && "fill-current")} />
{favorited ? "Unfavorite" : "Favorite"}
</ContextMenuItem>
) : null}
{allowRename ? (
<ContextMenuItem disabled={pending} onSelect={onRename}>
{pendingAction === "rename" ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Pencil className="size-4" />
)}
{pendingAction === "rename" ? "Renaming..." : "Rename"}
</ContextMenuItem>
) : null}
{allowFork ? (
<ContextMenuItem disabled={pending} onSelect={onFork}>
{pendingAction === "fork" ? (
<Loader2 className="size-4 animate-spin" />
) : (
<GitFork className="size-4" />
)}
{pendingAction === "fork" ? "Forking..." : "Fork"}
</ContextMenuItem>
) : null}
<ContextMenuItem
disabled={pending}
disabled={pending || !allowDelete}
onSelect={onDelete}
variant="destructive"
>
@@ -0,0 +1,43 @@
"use client";
import type { CSSProperties } from "react";
import { cn } from "@/lib/utils";
export function AnimatedOrb({
className,
intensity = 0,
}: {
className?: string;
intensity?: number;
}) {
const normalizedIntensity = Math.min(1, Math.max(0, intensity));
const animationStyle = {
"--orb-fast-duration": `${Math.max(1.8, 6 - normalizedIntensity * 4.2)}s`,
"--orb-slow-duration": `${Math.max(1.2, 3.5 - normalizedIntensity * 2.3)}s`,
} as CSSProperties;
return (
<span
aria-hidden="true"
className={cn(
"relative block size-5 shrink-0 overflow-hidden rounded-full bg-[#ebf4ff]",
className,
)}
style={{
...animationStyle,
filter: `saturate(${1 + normalizedIntensity * 0.5}) drop-shadow(0 0 ${normalizedIntensity * 8}px rgba(118, 11, 226, 0.55))`,
transform: `scale(${1 + normalizedIntensity * 0.22})`,
transition: "transform 75ms ease-out, filter 100ms ease-out",
}}
>
<span className="absolute inset-0 animate-[spin_var(--orb-fast-duration)_linear_infinite] blur-[2.5px]">
<span className="absolute left-[8%] top-[12%] size-[55%] rounded-full bg-[#760be2]" />
<span className="absolute right-[3%] top-[18%] size-[45%] rounded-full bg-[#f5adff]" />
<span className="absolute bottom-[2%] left-[18%] size-[58%] rounded-full bg-[#ebf4ff]" />
<span className="absolute bottom-[18%] right-[4%] size-[38%] rounded-full bg-[#760be2]" />
</span>
<span className="absolute inset-[12%] animate-[spin_var(--orb-slow-duration)_linear_infinite_reverse] rounded-full bg-[linear-gradient(176deg,#f5adff_0%,#ebf4ff_35%,#760be2_100%)] blur-[2px]" />
<span className="pointer-events-none absolute inset-0 rounded-full bg-linear-to-b from-white/55 via-transparent to-[#760be2]/15" />
</span>
);
}
@@ -0,0 +1,17 @@
import type {
ChatPromptCompletion,
ToolApprovalRequestItem,
} from "@/hooks/chat-session/types";
import type { ChatSessionStatus } from "@/lib/chat-schema";
export type RealtimeChatBridge = {
threadId: string;
sessionId: string | null;
providerId: string;
modelId: string;
status: ChatSessionStatus;
hasChatHistory: boolean;
pendingToolApprovals: ToolApprovalRequestItem[];
pendingQuestionCount: number;
sendPrompt: (prompt: string) => Promise<ChatPromptCompletion | undefined>;
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -21,13 +21,15 @@ function PopoverContent({
className,
align = "center",
sideOffset = 4,
forceMount,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Portal forceMount={forceMount}>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
forceMount={forceMount}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
@@ -11,6 +11,7 @@ import {
} from "@/lib/model-selection";
import {
buildUserInstructionSlashCommands,
buildWorkspaceFileSearchKey,
ChatInputBar,
} from "./chat-input-bar";
@@ -72,6 +73,7 @@ vi.mock("@/components/ai-elements/speech-input", async () => {
vi.mock("@/lib/provider-model-catalog", () => ({
loadProviderModelCatalog: loadProviderModelCatalogMock,
loadProviderModels: loadProviderModelsMock,
MODE_SETTINGS_CHANGED_EVENT: "cline:mode-settings-changed",
subscribeToProviderModels: subscribeToProviderModelsMock,
VOICE_INPUT_SETTINGS_CHANGED_EVENT: "cline:test-voice-input-settings-changed",
}));
@@ -202,6 +204,24 @@ async function renderVoiceComposer({
}
describe("ChatInputBar", () => {
it("isolates workspace file search caches by environment", () => {
const localKey = buildWorkspaceFileSearchKey(
"local",
"/workspace/shared",
"src",
);
const remoteKey = buildWorkspaceFileSearchKey(
"pi-server",
"/workspace/shared",
"src",
);
expect(remoteKey).not.toBe(localKey);
expect(
buildWorkspaceFileSearchKey("pi-server", "/workspace/shared", "src"),
).toBe(remoteKey);
});
it("builds slash commands from both workflows and skills", () => {
expect(
buildUserInstructionSlashCommands({
@@ -226,6 +246,203 @@ describe("ChatInputBar", () => {
]);
});
it("allows cloud image and model selection without replacing local defaults", async () => {
loadProviderModelCatalogMock.mockResolvedValue({
providers: [],
enabledProviderIds: ["anthropic", "cline"],
providerModels: {
anthropic: ["claude-test"],
cline: ["cline-test", "cline-alt"],
},
providerReasoningModels: { anthropic: [], cline: [] },
});
const localSelection = {
lastProvider: "anthropic",
lastModelByProvider: { anthropic: "claude-test" },
};
window.localStorage.setItem(
MODEL_SELECTION_STORAGE_KEY,
JSON.stringify(localSelection),
);
const onProviderChange = vi.fn();
const onModelChange = vi.fn();
await act(async () => {
root.render(
<WorkspaceProvider
value={{
workspaceRoot: "",
workspaces: [],
listWorkspaces: vi.fn(async () => []),
refreshWorkspaces: vi.fn(async () => undefined),
switchWorkspace: vi.fn(async () => true),
pickWorkspaceDirectory: vi.fn(async () => null),
selectChat: vi.fn(async () => true),
}}
>
<ChatInputBar
attachments={[]}
cloudBranch="feature/cloud"
executionTarget="cloud"
gitBranch="no-git"
hasActiveSession
mode="act"
model="claude-test"
onAbort={vi.fn()}
onAttachFiles={vi.fn()}
onEditPromptInQueue={vi.fn()}
onListGitBranches={vi.fn(async () => ({
current: "no-git",
branches: [],
}))}
onModeToggle={vi.fn()}
onModelChange={onModelChange}
onPromptInputChange={vi.fn()}
onProviderChange={onProviderChange}
onReasoningChange={vi.fn()}
onRemoveAttachment={vi.fn()}
onRemovePromptInQueue={vi.fn()}
onSend={vi.fn()}
onSteerPromptInQueue={vi.fn()}
onSwitchGitBranch={vi.fn(async () => false)}
promptDraft={{ version: 0, value: "" }}
promptsInQueue={[]}
provider="anthropic"
reasoningEffort="low"
repoUrl="https://github.com/cline/cline"
status="idle"
summary={{ toolCalls: 0, tokensIn: 0, tokensOut: 0 }}
thinking
variant="conversation"
/>
</WorkspaceProvider>,
);
await Promise.resolve();
});
await vi.waitFor(() => {
expect(onProviderChange).toHaveBeenCalledWith("cline");
});
expect(onModelChange).not.toHaveBeenCalled();
expect(
container.querySelector('[aria-label="Attach images"]'),
).not.toBeNull();
expect(
container.querySelector<HTMLInputElement>('input[type="file"]')?.accept,
).toBe("image/*");
expect(container.querySelector("#git-branch-btn")).toBeNull();
expect(container.textContent).toContain("cline/cline / feature/cloud");
expect(
container.querySelector<HTMLButtonElement>(
'[aria-label="Model and provider"]',
)?.disabled,
).toBe(false);
expect(
container.querySelector<HTMLButtonElement>(
'[aria-label="Thinking level"]',
)?.disabled,
).toBe(true);
const modelTrigger = container.querySelector<HTMLButtonElement>(
'[aria-label="Model and provider"]',
);
await act(async () => modelTrigger?.click());
const cloudModel = container.querySelector<HTMLButtonElement>(
'[aria-label="Model: cline-test"]',
);
await act(async () => cloudModel?.click());
const alternateModel = Array.from(
container.querySelectorAll<HTMLButtonElement>(
".cline-ui-search-combobox__option",
),
).find((button) => button.textContent?.includes("cline-alt"));
expect(alternateModel).not.toBeUndefined();
await act(async () => alternateModel?.click());
expect(onModelChange).toHaveBeenCalledWith("cline-alt");
expect(
JSON.parse(
window.localStorage.getItem(MODEL_SELECTION_STORAGE_KEY) ?? "null",
),
).toEqual(localSelection);
});
it("blocks a new cloud message until a GitHub repository is selected", async () => {
const onSend = vi.fn();
const render = async (repoUrl?: string) => {
await act(async () => {
root.render(
<WorkspaceProvider
value={{
workspaceRoot: "",
workspaces: [],
listWorkspaces: vi.fn(async () => []),
refreshWorkspaces: vi.fn(async () => undefined),
switchWorkspace: vi.fn(async () => true),
pickWorkspaceDirectory: vi.fn(async () => null),
selectChat: vi.fn(async () => true),
}}
>
<ChatInputBar
attachments={[]}
executionTarget="cloud"
gitBranch="no-git"
hasActiveSession={false}
mode="act"
model="test-model"
onAbort={vi.fn()}
onAttachFiles={vi.fn()}
onEditPromptInQueue={vi.fn()}
onListGitBranches={vi.fn(async () => ({
current: "no-git",
branches: [],
}))}
onModeToggle={vi.fn()}
onModelChange={vi.fn()}
onPromptInputChange={vi.fn()}
onProviderChange={vi.fn()}
onReasoningChange={vi.fn()}
onRemoveAttachment={vi.fn()}
onRemovePromptInQueue={vi.fn()}
onSend={onSend}
onSteerPromptInQueue={vi.fn()}
onSwitchGitBranch={vi.fn(async () => false)}
promptDraft={{ version: 0, value: "Continue in cloud" }}
promptsInQueue={[]}
provider="cline"
reasoningEffort="low"
repoUrl={repoUrl}
status="idle"
summary={{ toolCalls: 0, tokensIn: 0, tokensOut: 0 }}
thinking
/>
</WorkspaceProvider>,
);
await Promise.resolve();
});
};
await render();
const sendButton = container.querySelector<HTMLButtonElement>(
'[aria-label="Send message"]',
);
const promptInput = container.querySelector<HTMLTextAreaElement>(
'textarea[role="combobox"]',
);
expect(sendButton?.disabled).toBe(true);
expect(sendButton?.title).toBe("Choose a repository");
expect(container.textContent).toContain("Repository required");
expect(promptInput?.placeholder).toBe("Choose a repository");
await act(async () => {
promptInput?.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
);
});
expect(onSend).not.toHaveBeenCalled();
await render("https://github.com/cline/cline");
expect(sendButton?.disabled).toBe(false);
await act(async () => sendButton?.click());
expect(onSend).toHaveBeenCalledWith("Continue in cloud");
});
it("top-aligns the textarea in the taller welcome composer", async () => {
await act(async () => {
root.render(
@@ -242,6 +459,7 @@ describe("ChatInputBar", () => {
>
<ChatInputBar
attachments={[]}
environmentId="local"
gitBranch="main"
mode="act"
model="test-model"
@@ -756,12 +974,12 @@ describe("ChatInputBar", () => {
onAbort={vi.fn()}
onAttachFiles={vi.fn()}
onEditPromptInQueue={vi.fn()}
onModeToggle={vi.fn()}
onModelChange={vi.fn()}
onListGitBranches={vi.fn(async () => ({
current: "main",
branches: ["main"],
}))}
onModeToggle={vi.fn()}
onModelChange={vi.fn()}
onOpenVoiceInputSettings={onOpenVoiceInputSettings}
onPromptInputChange={vi.fn()}
onProviderChange={vi.fn()}
@@ -769,7 +987,7 @@ describe("ChatInputBar", () => {
onRemoveAttachment={vi.fn()}
onSend={vi.fn()}
onSteerPromptInQueue={vi.fn()}
onSwitchGitBranch={vi.fn(async () => true)}
onSwitchGitBranch={vi.fn(async () => false)}
onRemovePromptInQueue={vi.fn()}
promptDraft={{ version: 0, value: "" }}
promptsInQueue={[]}
@@ -786,8 +1004,25 @@ describe("ChatInputBar", () => {
};
await render("idle");
const compactModelTrigger = container.querySelector<HTMLButtonElement>(
'[aria-label="Model settings"]',
);
expect(compactModelTrigger).not.toBeNull();
expect(
compactModelTrigger?.querySelector(".lucide-chevron-down"),
).toBeNull();
const levelIcon = compactModelTrigger?.querySelector(
"[aria-hidden='true']",
);
expect(levelIcon?.className).toContain("-scale-x-100");
expect(levelIcon?.querySelector(".lucide-signal")?.classList).toContain(
"text-primary",
);
expect(compactModelTrigger?.lastElementChild).toBe(levelIcon);
await act(async () => compactModelTrigger?.click());
expect(compactModelTrigger?.getAttribute("aria-expanded")).toBe("true");
await vi.waitFor(() => {
expect(loadProviderModelsMock).toHaveBeenCalledWith("cline");
expect(subscribeToProviderModelsMock).toHaveBeenCalled();
});
const providerModelsListener =
subscribeToProviderModelsMock.mock.calls[0]?.[0];
@@ -797,47 +1032,108 @@ describe("ChatInputBar", () => {
]);
});
await vi.waitFor(() => {
const trigger = container.querySelector<HTMLButtonElement>(
'[aria-label="Thinking level"]',
const trigger = document.querySelector<HTMLInputElement>(
'[aria-label="Effort"]',
);
expect(trigger?.textContent).toContain("High");
expect(trigger?.value).toBe("3");
expect(trigger?.getAttribute("aria-valuetext")).toBe("High");
expect(trigger?.disabled).toBe(true);
expect(trigger?.className).toContain("h-4");
expect(document.body.textContent).toContain("EffortHigh");
expect(document.body.textContent).not.toContain("ThinkingHigh");
expect(
trigger?.querySelector('[data-slot="select-value"]')?.parentElement
document.querySelector('[data-slot="thinking-level-markers"]')
?.children,
).toHaveLength(5);
const markers = document.querySelector(
'[data-slot="thinking-level-markers"]',
)?.children;
expect(markers?.[0]?.className).toContain("bg-zinc-500");
expect(markers?.[4]?.className).toContain("bg-zinc-500");
expect(markers?.[3]?.className).toContain("opacity-50");
expect(markers?.[3]?.className).not.toContain("opacity-0");
expect(markers?.[0]?.className).not.toContain("bg-primary-foreground");
expect(
document.querySelector('[data-slot="thinking-level-markers"]')
?.className,
).toContain("max-[560px]:sr-only");
).toContain("top-0");
expect(
[...document.querySelectorAll("span")]
.find((element) => element.textContent?.includes("High"))
?.querySelector(".lucide-signal-high"),
).not.toBeNull();
});
const compactModelTrigger = container.querySelector<HTMLButtonElement>(
'[aria-label="Model and provider"]',
);
expect(compactModelTrigger?.disabled).toBe(false);
await act(async () => compactModelTrigger?.click());
expect(compactModelTrigger?.getAttribute("aria-expanded")).toBe("true");
expect(
container.querySelectorAll<HTMLButtonElement>(
'[aria-label^="Provider:"]',
),
).toHaveLength(2);
document.querySelectorAll<HTMLButtonElement>('[aria-label^="Provider:"]'),
).toHaveLength(1);
expect(
container.querySelectorAll<HTMLButtonElement>('[aria-label^="Model:"]'),
).toHaveLength(2);
expect(container.textContent).toContain("refreshed-model");
await act(async () =>
container
.querySelector<HTMLButtonElement>('[aria-label="Close model selector"]')
?.click(),
document.querySelectorAll<HTMLButtonElement>('[aria-label^="Model:"]'),
).toHaveLength(1);
expect(document.body.textContent).toContain("refreshed-model");
const modelMenuTrigger = document.querySelector<HTMLButtonElement>(
'[aria-label^="Model:"]',
);
expect(modelMenuTrigger?.className).toContain("w-full");
expect(modelMenuTrigger?.className).toContain("max-w-none");
const providerMenuTrigger = document.querySelector<HTMLButtonElement>(
'[aria-label^="Provider:"]',
);
await act(async () => {
providerMenuTrigger?.click();
});
expect(modelMenuTrigger?.getAttribute("aria-expanded")).toBe("false");
expect(
document
.querySelector<HTMLButtonElement>('[aria-label^="Provider:"]')
?.getAttribute("aria-expanded"),
).toBe("true");
expect(
document.querySelector<HTMLElement>('[aria-label="Search provider"]')
?.className,
).toContain("right-full");
await act(async () => {
providerMenuTrigger?.click();
modelMenuTrigger?.click();
});
const modelMenu = document.querySelector<HTMLElement>(
'[aria-label="Search model"]',
);
expect(modelMenu?.className).toContain("right-full");
expect(modelMenu?.className).toContain("top-0");
expect(modelMenu?.className).not.toContain("bottom-0");
await act(async () => {
document.dispatchEvent(
new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }),
);
});
await act(async () => {
document.dispatchEvent(
new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }),
);
});
await vi.waitFor(() =>
expect(compactModelTrigger?.getAttribute("aria-expanded")).toBe("false"),
);
expect(compactModelTrigger?.getAttribute("aria-expanded")).toBe("false");
const promptInput = container.querySelector<HTMLTextAreaElement>(
'textarea[role="combobox"]',
);
await act(async () => {
promptInput?.blur();
promptInput?.dispatchEvent(new Event("focusin", { bubbles: true }));
});
expect(promptInput?.rows).toBe(2);
expect(promptInput?.className).toContain("field-sizing-content");
expect(promptInput?.className).toContain("overflow-y-auto");
expect(promptInput?.className).not.toContain("self-start");
expect(promptInput?.style.minHeight).toBe("2.5rem");
expect(promptInput?.style.maxHeight).toBe("6.25rem");
const emptySpeechTrigger = container.querySelector<HTMLButtonElement>(
'[aria-label="Record speech"]',
);
expect(emptySpeechTrigger).not.toBeNull();
expect(promptInput?.parentElement?.contains(emptySpeechTrigger)).toBe(true);
expect(onOpenVoiceInputSettings).not.toHaveBeenCalled();
await act(async () => {
if (!promptInput) return;
@@ -849,6 +1145,15 @@ describe("ChatInputBar", () => {
promptInput.dispatchEvent(new Event("input", { bubbles: true }));
});
expect(promptInput?.rows).toBe(2);
expect(
container.querySelector('[aria-label="Record speech"]'),
).not.toBeNull();
expect(
container.querySelector('[aria-label="Send message"]'),
).not.toBeNull();
expect(
container.querySelector('[aria-label="Configure realtime voice"]'),
).toBeNull();
await render("starting");
expect(container.querySelector('[aria-label="Stop agent"]')).toBeNull();
@@ -856,16 +1161,9 @@ describe("ChatInputBar", () => {
expect(container.querySelector('[aria-label="Stop agent"]')).not.toBeNull();
expect(onReasoningChange).not.toHaveBeenCalled();
const providerTrigger = container.querySelector<HTMLButtonElement>(
'[aria-label^="Provider:"]',
);
expect(providerTrigger?.parentElement?.parentElement?.className).toContain(
"max-[560px]:hidden",
);
expect(compactModelTrigger?.className).toContain("max-[560px]:inline-flex");
expect(compactModelTrigger?.querySelector(".lucide-cpu")).not.toBeNull();
const workspaceTrigger =
container.querySelector<HTMLButtonElement>("#git-branch-btn");
expect(
compactModelTrigger?.querySelector(".lucide-signal-high"),
).not.toBeNull();
const attachTrigger = container.querySelector<HTMLButtonElement>(
'[aria-label="Attach files"]',
);
@@ -876,11 +1174,16 @@ describe("ChatInputBar", () => {
'[aria-label="Thinking level"]',
);
const leftControls = attachTrigger?.parentElement;
expect(
attachTrigger?.querySelector(".lucide-paperclip")?.classList,
).toContain("size-3");
expect(leftControls?.className).toContain("max-[560px]:flex-nowrap");
expect(leftControls?.contains(compactModelTrigger ?? null)).toBe(true);
expect(leftControls?.contains(thinkingTrigger ?? null)).toBe(true);
expect(leftControls?.contains(speechTrigger ?? null)).toBe(false);
const workspaceTrigger =
container.querySelector<HTMLButtonElement>("#git-branch-btn");
expect(workspaceTrigger?.disabled).toBe(true);
expect(workspaceTrigger?.className).toContain("max-[560px]:size-7");
expect(workspaceTrigger?.textContent).toContain("cline");
@@ -912,7 +1215,7 @@ describe("ChatInputBar", () => {
expect(onOpenVoiceInputSettings).toHaveBeenCalledOnce();
});
it("selects High from the supported model thinking menu", async () => {
it("selects High with the supported model thinking slider", async () => {
loadProviderModelCatalogMock.mockResolvedValue({
providers: [],
enabledProviderIds: ["cline"],
@@ -935,16 +1238,11 @@ describe("ChatInputBar", () => {
>
<ChatInputBar
attachments={[]}
gitBranch="main"
mode="act"
model="test-model"
onAbort={vi.fn()}
onAttachFiles={vi.fn()}
onEditPromptInQueue={vi.fn()}
onListGitBranches={vi.fn(async () => ({
current: "main",
branches: ["main"],
}))}
onModeToggle={vi.fn()}
onModelChange={vi.fn()}
onPromptInputChange={vi.fn()}
@@ -953,7 +1251,6 @@ describe("ChatInputBar", () => {
onRemoveAttachment={vi.fn()}
onSend={vi.fn()}
onSteerPromptInQueue={vi.fn()}
onSwitchGitBranch={vi.fn(async () => true)}
onRemovePromptInQueue={vi.fn()}
promptDraft={{ version: 0, value: "" }}
promptsInQueue={[]}
@@ -966,31 +1263,36 @@ describe("ChatInputBar", () => {
</WorkspaceProvider>,
);
});
const modelSettings = container.querySelector<HTMLButtonElement>(
'[aria-label="Model settings"]',
);
await act(async () => modelSettings?.click());
const trigger = await vi.waitFor(() => {
const element = container.querySelector<HTMLButtonElement>(
'[aria-label="Thinking level"]',
const element = document.querySelector<HTMLInputElement>(
'[aria-label="Effort"]',
);
expect(element?.disabled).toBe(false);
return element as HTMLButtonElement;
return element as HTMLInputElement;
});
await vi.waitFor(() => expect(document.activeElement).toBe(trigger));
await act(async () => {
trigger.dispatchEvent(
new MouseEvent("pointerdown", { bubbles: true, cancelable: true }),
new KeyboardEvent("keydown", { bubbles: true, key: "ArrowRight" }),
);
trigger.click();
});
const highOption = await vi.waitFor(() => {
const element = [
...document.querySelectorAll<HTMLElement>('[role="option"]'),
].find((option) => option.textContent?.includes("High"));
expect(element).toBeDefined();
return element as HTMLElement;
expect(onReasoningChange).toHaveBeenLastCalledWith({
reasoningEffort: "medium",
thinking: true,
});
onReasoningChange.mockClear();
await act(async () => {
highOption.dispatchEvent(
new MouseEvent("pointerup", { bubbles: true, cancelable: true }),
);
highOption.click();
const setValue = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)?.set;
setValue?.call(trigger, "3");
trigger.dispatchEvent(new Event("input", { bubbles: true }));
trigger.dispatchEvent(new Event("change", { bubbles: true }));
});
expect(onReasoningChange).toHaveBeenCalledWith({
@@ -1022,16 +1324,11 @@ describe("ChatInputBar", () => {
>
<ChatInputBar
attachments={[]}
gitBranch="main"
mode="act"
model="test-model"
onAbort={vi.fn()}
onAttachFiles={vi.fn()}
onEditPromptInQueue={onEditPromptInQueue}
onListGitBranches={vi.fn(async () => ({
current: "main",
branches: ["main"],
}))}
onModeToggle={vi.fn()}
onModelChange={vi.fn()}
onPromptInputChange={vi.fn()}
@@ -1040,7 +1337,6 @@ describe("ChatInputBar", () => {
onRemoveAttachment={vi.fn()}
onSend={vi.fn()}
onSteerPromptInQueue={onSteerPromptInQueue}
onSwitchGitBranch={vi.fn(async () => true)}
onRemovePromptInQueue={onRemovePromptInQueue}
promptDraft={{ version: 0, value: "" }}
promptsInQueue={[
@@ -1301,7 +1597,12 @@ describe("ChatInputBar", () => {
expect(loadProviderModelsMock).toHaveBeenCalledWith("openrouter");
});
// The composer displays the session's model...
const modelTrigger = container.querySelector<HTMLButtonElement>(
const modelSettings = container.querySelector<HTMLButtonElement>(
'[aria-label="Model settings"]',
);
expect(modelSettings?.textContent).toContain("old-session-model");
await act(async () => modelSettings?.click());
const modelTrigger = document.querySelector<HTMLButtonElement>(
'[aria-label^="Model:"]',
);
expect(modelTrigger?.textContent).toContain("old-session-model");
@@ -1318,9 +1619,8 @@ describe("ChatInputBar", () => {
// An explicit pick in the model dropdown DOES update the remembered
// selection.
await act(async () => modelTrigger?.click());
const panel = document.querySelector('[role="dialog"]');
const option = [
...(panel?.querySelectorAll<HTMLButtonElement>("button") ?? []),
...document.querySelectorAll<HTMLButtonElement>('[role="dialog"] button'),
].find((entry) => entry.textContent?.includes("user-picked-model"));
expect(option).toBeTruthy();
await act(async () => option?.click());
@@ -1446,17 +1746,12 @@ describe("ChatInputBar token ring", () => {
>
<ChatInputBar
attachments={[]}
gitBranch="main"
mode="act"
model="test-model"
modelContextWindow={modelContextWindow}
onAbort={vi.fn()}
onAttachFiles={vi.fn()}
onEditPromptInQueue={vi.fn()}
onListGitBranches={vi.fn(async () => ({
current: "main",
branches: ["main"],
}))}
onModeToggle={vi.fn()}
onModelChange={vi.fn()}
onPromptInputChange={vi.fn()}
@@ -1466,7 +1761,6 @@ describe("ChatInputBar token ring", () => {
onRemovePromptInQueue={vi.fn()}
onSend={vi.fn()}
onSteerPromptInQueue={vi.fn()}
onSwitchGitBranch={vi.fn(async () => true)}
promptDraft={{ version: 0, value: "" }}
promptsInQueue={[]}
provider="cline"
@@ -1517,6 +1811,10 @@ describe("ChatInputBar token ring", () => {
);
expect(trigger?.textContent).toBe("");
const ring = trigger?.querySelector("svg");
const inputBar = trigger?.closest(".max-w-full");
expect(inputBar?.classList.contains("w-full")).toBe(true);
expect(inputBar?.classList.contains("min-w-0")).toBe(true);
expect(inputBar?.classList.contains("max-w-full")).toBe(true);
expect(ring?.classList.contains("size-3.5")).toBe(true);
expect(ring?.getAttribute("height")).toBe("22");
expect(ring?.getAttribute("width")).toBe("22");
@@ -1526,21 +1824,15 @@ describe("ChatInputBar token ring", () => {
expect(
Number(progressCircle?.getAttribute("stroke-dashoffset")),
).toBeCloseTo(circumference * 0.25);
if (!trigger?.parentElement) {
throw new Error("Expected token usage trigger group");
}
const usageGroup = trigger.parentElement;
const workspaceSelector = usageGroup.querySelector("#git-branch-btn");
if (!workspaceSelector) {
throw new Error("Expected workspace selector in token usage group");
}
expect(usageGroup.classList.contains("gap-0")).toBe(true);
expect(usageGroup.contains(workspaceSelector)).toBe(true);
expect(container.querySelector("#git-branch-btn")).toBeNull();
expect(trigger?.parentElement?.classList.contains("gap-0")).toBe(true);
const modelSettings = container.querySelector(
'[aria-label="Model settings"]',
);
expect(
Boolean(
trigger.compareDocumentPosition(workspaceSelector) &
Node.DOCUMENT_POSITION_PRECEDING,
modelSettings?.compareDocumentPosition(trigger as Node) &
Node.DOCUMENT_POSITION_FOLLOWING,
),
).toBe(true);
});
@@ -5,7 +5,21 @@ import {
formatDisplayUserInput,
} from "@cline/shared/browser";
import { AgentPromptQueue, SearchCombobox } from "@cline/ui";
import { ArrowUp, Brain, CircleStop, Cpu, Paperclip, X } from "lucide-react";
import {
ArrowRight,
ArrowUp,
Brain,
CircleStop,
Cloud,
Cpu,
Paperclip,
Signal,
SignalHigh,
SignalLow,
SignalMedium,
SignalZero,
X,
} from "lucide-react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
SpeechInput,
@@ -31,6 +45,7 @@ import { formatCostUsd } from "@/hooks/use-session-history";
import { toast } from "@/hooks/use-toast";
import type { ChatSessionConfig, ChatSessionStatus } from "@/lib/chat-schema";
import { imageFilesFromClipboard } from "@/lib/clipboard-images";
import { cloudRepositoryLabel } from "@/lib/cloud-repositories";
import { desktopClient, writeDesktopDebugLog } from "@/lib/desktop-client";
import {
readModelSelectionStorageFromWindow,
@@ -40,6 +55,7 @@ import { normalizeProviderId } from "@/lib/provider-id";
import {
loadProviderModelCatalog,
loadProviderModels,
MODE_SETTINGS_CHANGED_EVENT,
subscribeToProviderModels,
type TranscriptionModelTarget,
VOICE_INPUT_SETTINGS_CHANGED_EVENT,
@@ -132,6 +148,7 @@ const FALLBACK_PROVIDER_REASONING_MODELS: Record<string, string[]> = {
openrouter: ["anthropic/claude-sonnet-4.6"],
gemini: ["gemini-3-pro-latest"],
};
const CLINE_ONLY_PROVIDER_IDS = ["cline"];
type ReasoningEffort = NonNullable<ChatSessionConfig["reasoningEffort"]>;
type ReasoningEffortOption = {
@@ -151,7 +168,36 @@ const EFFORT_LEVELS: ReasoningEffortOption[] = [
{ label: "High", value: "high" },
{ label: "Extra", value: "xhigh" },
];
function ReasoningEffortIcon({
value,
className = "size-3",
}: {
value: ReasoningEffortOption["value"];
className?: string;
}) {
const LevelIcon =
value === "none"
? SignalZero
: value === "low"
? SignalLow
: value === "medium"
? SignalMedium
: value === "high"
? SignalHigh
: Signal;
return (
<span
aria-hidden="true"
className={cn("relative inline-flex -scale-x-100", className)}
>
<Signal className="absolute inset-0 size-full text-primary opacity-50" />
<LevelIcon className="relative size-full text-primary" />
</span>
);
}
const PROMPT_INPUT_COLLAPSED_ROWS = 1;
const PROMPT_INPUT_WELCOME_ROWS = 3;
const PROMPT_INPUT_EXPANDED_ROWS = 2;
const PROMPT_INPUT_MAX_ROWS = 5;
const PROMPT_INPUT_LINE_HEIGHT_REM = 1.25;
@@ -269,7 +315,16 @@ export type PromptDraft = {
value: string;
};
export function buildWorkspaceFileSearchKey(
environmentId: string,
workspaceRoot: string,
query: string,
): string {
return JSON.stringify([environmentId, workspaceRoot, query]);
}
type ChatInputBarProps = {
environmentId: string;
variant?: "conversation" | "welcome";
status: ChatSessionStatus;
provider: string;
@@ -280,6 +335,10 @@ type ChatInputBarProps = {
reasoningEffort: ChatSessionConfig["reasoningEffort"];
/** Branch name, "no-git" for a non-repo folder, null while discovery is pending. */
gitBranch: string | null;
executionTarget?: "local" | "cloud";
repoUrl?: string;
cloudBranch?: string;
hasActiveSession?: boolean;
promptDraft: PromptDraft;
onPromptInputChange: (value: string) => void;
onProviderChange: (provider: string) => void;
@@ -313,6 +372,7 @@ type ChatInputBarProps = {
};
function ChatInputBarImpl({
environmentId,
variant = "conversation",
status,
provider,
@@ -322,6 +382,10 @@ function ChatInputBarImpl({
thinking,
reasoningEffort,
gitBranch,
executionTarget = "local",
repoUrl,
cloudBranch,
hasActiveSession = false,
promptDraft,
onPromptInputChange,
onProviderChange,
@@ -432,13 +496,27 @@ function ChatInputBarImpl({
},
[model, provider],
);
const canSend = hasDraft && !speechInputActive;
const needsCloudRepository =
executionTarget === "cloud" && !hasActiveSession && !repoUrl?.trim();
const cloudSettingsLocked = executionTarget === "cloud" && hasActiveSession;
const canSend =
hasDraft &&
!speechInputActive &&
!needsCloudRepository &&
(executionTarget !== "cloud" || promptInput.trim().length > 0);
const cloudContextLabel = useMemo(
() =>
[cloudRepositoryLabel(repoUrl ?? "", "Cloud"), cloudBranch?.trim()]
.filter(Boolean)
.join(" / "),
[cloudBranch, repoUrl],
);
const handleSend = useCallback(() => {
if (speechInputActive) return;
if (!canSend) return;
const prompt = promptInput.trim();
setPromptInput("");
onSend(prompt);
}, [onSend, promptInput, setPromptInput, speechInputActive]);
}, [canSend, onSend, promptInput, setPromptInput]);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [transcriptionTarget, setTranscriptionTarget] =
useState<TranscriptionModelTarget | null>(null);
@@ -475,7 +553,10 @@ function ChatInputBarImpl({
const mentionKey = activeMention
? `${activeMention.start}:${activeMention.query}`
: null;
const mentionOpen = mentionKey !== null && dismissedMentionKey !== mentionKey;
const mentionOpen =
executionTarget === "local" &&
mentionKey !== null &&
dismissedMentionKey !== mentionKey;
const [mentionFiles, setMentionFiles] = useState<string[]>([]);
const [mentionLoading, setMentionLoading] = useState(false);
const [mentionSelectedIndex, setMentionSelectedIndex] = useState(0);
@@ -493,22 +574,28 @@ function ChatInputBarImpl({
const slashKey = activeSlash
? `${activeSlash.slashIndex}:${activeSlash.query}`
: null;
const slashOpen = slashKey !== null && dismissedSlashKey !== slashKey;
// Slash commands resolve against locally installed skills/workflows, which
// the cloud sandbox cannot run — gate them like @-mentions.
const slashOpen =
executionTarget === "local" &&
slashKey !== null &&
dismissedSlashKey !== slashKey;
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>(
() => cachedSlashCommands ?? BUILTIN_SLASH_COMMANDS,
);
const [slashLoading, setSlashLoading] = useState(false);
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0);
useEffect(() => {
let cancelled = false;
let loadId = 0;
const loadVoiceInput = () => {
const loadModeSettings = () => {
const currentLoadId = ++loadId;
loadProviderModelCatalog()
.then((catalog) => {
if (!cancelled && currentLoadId === loadId) {
updateTranscriptionTarget(catalog.voiceInput);
updateTranscriptionTarget(
catalog.modes?.voiceInput ?? catalog.voiceInput,
);
}
})
.catch(() => {
@@ -517,14 +604,31 @@ function ChatInputBarImpl({
}
});
};
loadVoiceInput();
window.addEventListener(VOICE_INPUT_SETTINGS_CHANGED_EVENT, loadVoiceInput);
const handleModeSettingsChanged = (event: Event) => {
const mode = (event as CustomEvent<{ mode?: string }>).detail?.mode;
if (!mode || mode === "voiceInput") {
loadModeSettings();
}
};
loadModeSettings();
window.addEventListener(
MODE_SETTINGS_CHANGED_EVENT,
handleModeSettingsChanged,
);
window.addEventListener(
VOICE_INPUT_SETTINGS_CHANGED_EVENT,
loadModeSettings,
);
return () => {
cancelled = true;
loadId += 1;
window.removeEventListener(
MODE_SETTINGS_CHANGED_EVENT,
handleModeSettingsChanged,
);
window.removeEventListener(
VOICE_INPUT_SETTINGS_CHANGED_EVENT,
loadVoiceInput,
loadModeSettings,
);
};
}, [updateTranscriptionTarget]);
@@ -757,6 +861,16 @@ function ChatInputBarImpl({
() => resolveEffortIndex(thinking, reasoningEffort),
[reasoningEffort, thinking],
);
const [modelSettingsOpen, setModelSettingsOpen] = useState(false);
const thinkingSliderRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (
executionTarget === "cloud" &&
normalizeProviderId(provider) !== "cline"
) {
onProviderChange("cline");
}
}, [executionTarget, onProviderChange, provider]);
const hasExplicitReasoningSelection =
thinking !== undefined || reasoningEffort !== undefined;
const effortLabel =
@@ -766,9 +880,11 @@ function ChatInputBarImpl({
? "None"
: (EFFORT_LEVELS[effortIndex]?.label ?? "Reasoning");
const promptInputRows =
variant === "welcome" || promptInputFocused
? PROMPT_INPUT_EXPANDED_ROWS
: PROMPT_INPUT_COLLAPSED_ROWS;
variant === "welcome"
? PROMPT_INPUT_WELCOME_ROWS
: promptInputFocused
? PROMPT_INPUT_EXPANDED_ROWS
: PROMPT_INPUT_COLLAPSED_ROWS;
const handleEffortChange = useCallback(
(value: string) => {
if (modelSupportsReasoning !== true) {
@@ -821,7 +937,11 @@ function ChatInputBarImpl({
return;
}
const requestKey = `${workspaceRoot}::${activeMention.query}`;
const requestKey = buildWorkspaceFileSearchKey(
environmentId,
workspaceRoot,
activeMention.query,
);
if (mentionLastRequestKeyRef.current === requestKey) {
return;
}
@@ -843,6 +963,7 @@ function ChatInputBarImpl({
const results = await desktopClient.invoke<string[]>(
"search_workspace_files",
{
environmentId,
workspaceRoot,
query: activeMention.query,
limit: 10,
@@ -873,7 +994,13 @@ function ChatInputBarImpl({
cancelled = true;
window.clearTimeout(timeoutId);
};
}, [activeMention, mentionOpen, workspaceRoot, mentionFiles.length]);
}, [
activeMention,
environmentId,
mentionOpen,
workspaceRoot,
mentionFiles.length,
]);
const insertMentionFile = useCallback(
(filePath: string) => {
@@ -993,7 +1120,7 @@ function ChatInputBarImpl({
return (
<div
className={cn(
"bg-card",
"w-full min-w-0 max-w-full bg-card",
variant === "welcome"
? "overflow-visible rounded-xl border border-border/90 bg-surface-1/40 shadow-[0_24px_80px_-56px_color-mix(in_oklab,var(--primary)_72%,transparent)] backdrop-blur-md"
: "overflow-visible rounded-xl border border-border bg-surface-2 backdrop-blur-sm focus-within:border-primary/50 focus-within:ring-1 focus-within:ring-primary/20",
@@ -1101,9 +1228,9 @@ function ChatInputBarImpl({
{/* biome-ignore lint/a11y/noStaticElementInteractions: Empty composer space forwards pointer focus to the nested textarea; keyboard users focus the textarea directly. */}
<div
className={cn(
"flex items-end gap-2 rounded-lg border border-border bg-background px-3 py-2.5 focus-within:border-primary/50 focus-within:ring-1 focus-within:ring-primary/20",
"flex gap-2 rounded-lg border border-border bg-background px-3 py-2.5 transition-all focus-within:border-primary/50 focus-within:ring-1 focus-within:ring-primary/20",
variant === "welcome"
? "min-h-16 rounded-none border-0 bg-transparent px-0 py-0 focus-within:ring-0"
? "min-h-16 items-end rounded-none border-0 bg-transparent px-0 py-0 focus-within:ring-0"
: "min-h-24 items-start rounded-none border-0 bg-transparent px-0 py-0 focus-within:border-transparent focus-within:ring-0",
)}
onMouseDown={(event) => {
@@ -1252,11 +1379,17 @@ function ChatInputBarImpl({
placeholder={
speechInputProcessing
? "Transcribing voice input…"
: variant === "welcome"
? "Ask to make changes, @mention files, reference #PRs, or run /commands."
: isBusy
: needsCloudRepository
? "Choose a repository"
: isBusy && variant !== "welcome"
? "Agent is working... submit to queue another message"
: "Enter your question or type / for commands or @ for context"
: executionTarget === "cloud"
? // Mentions and slash commands are local-only; do not
// advertise them in cloud sessions.
"Describe what Cline should do in this repository."
: variant === "welcome"
? "Ask to make changes, @mention files, reference #PRs, or run /commands."
: "Enter your question or type / for commands or @ for context"
}
readOnly={speechInputActive}
ref={promptInputRef}
@@ -1270,22 +1403,30 @@ function ChatInputBarImpl({
/>
<div
className={cn(
"flex shrink-0 items-center gap-2",
"flex shrink-0 items-center gap-1",
variant === "conversation" && "self-end",
)}
>
{needsCloudRepository ? (
<span
aria-live="polite"
className="max-w-40 text-right text-[11px] leading-4 text-muted-foreground"
>
Repository required
</span>
) : null}
{canAbort && (
<button
aria-label="Stop agent"
className={cn(
"bg-foreground p-1.5 text-background hover:bg-destructive",
"inline-grid size-7 shrink-0 place-items-center bg-foreground p-0 text-background transition-colors hover:bg-primary/80",
variant === "welcome" ? "rounded-md" : "rounded-full",
)}
onClick={onAbort}
title="Stop the agent (Esc)"
type="button"
>
<CircleStop className="size-3" />
<CircleStop className="size-2" />
</button>
)}
<SpeechInput
@@ -1326,23 +1467,27 @@ function ChatInputBarImpl({
: "Configure voice input in Settings → Models"
}
/>
{(!isBusy || canSend) && (
{!isBusy || canSend ? (
<button
aria-label="Send message"
className={cn(
"p-1.5 disabled:cursor-not-allowed disabled:opacity-50",
"inline-grid size-7 shrink-0 place-items-center p-0 transition-colors disabled:cursor-not-allowed disabled:opacity-50",
variant === "welcome"
? "rounded-md bg-[linear-gradient(145deg,var(--primary-emphasis),var(--primary))] text-white shadow-sm hover:brightness-110"
: "rounded-full bg-primary text-background hover:bg-primary/80",
? "rounded-md bg-transparent text-muted-foreground hover:bg-muted hover:text-foreground"
: "rounded-full bg-transparent text-muted-foreground hover:bg-muted hover:text-foreground",
)}
disabled={!canSend}
onClick={handleSend}
title="Send (Enter)"
title={
needsCloudRepository
? "Choose a repository"
: "Send (Enter)"
}
type="button"
>
<ArrowUp className="size-3" />
<ArrowRight className="size-4" />
</button>
)}
) : null}
</div>
</div>
</div>
@@ -1372,7 +1517,9 @@ function ChatInputBarImpl({
<div className="flex min-w-0 items-center justify-between gap-x-3 gap-y-2 rounded-b-xl border-t border-border bg-muted/20 px-2 py-2 text-sm text-muted-foreground">
<div className="flex min-w-0 flex-auto flex-wrap items-center gap-2 max-[560px]:flex-nowrap">
<button
aria-label="Attach files"
aria-label={
executionTarget === "cloud" ? "Attach images" : "Attach files"
}
className="rounded-md p-2 text-muted-foreground hover:bg-surface-hover"
onClick={() => fileInputRef.current?.click()}
type="button"
@@ -1380,7 +1527,7 @@ function ChatInputBarImpl({
<Paperclip className="size-3" />
</button>
<input
accept="*/*"
accept={executionTarget === "cloud" ? "image/*" : "*/*"}
className="hidden"
multiple
onChange={(event) => {
@@ -1391,96 +1538,221 @@ function ChatInputBarImpl({
ref={fileInputRef}
type="file"
/>
<div className="hidden shrink-0 items-center rounded-md bg-muted p-0.5">
<button
aria-pressed={mode === "plan"}
className={cn(
"rounded px-2 py-1 ",
mode === "plan"
? "bg-background text-foreground shadow-xs"
: "hover:text-foreground",
)}
onClick={() => {
if (mode !== "plan") onModeToggle();
}}
type="button"
>
Plan
</button>
<button
aria-pressed={mode === "act"}
className={cn(
"rounded px-2 py-1 ",
mode === "act"
? "bg-background text-foreground shadow-xs"
: "hover:text-foreground",
)}
onClick={() => {
if (mode !== "act") onModeToggle();
}}
type="button"
>
Act
</button>
{executionTarget === "cloud" ? (
<span className="truncate text-[11px]" title={cloudContextLabel}>
{cloudContextLabel}
</span>
) : null}
<div className="ml-auto flex min-w-0 shrink-0 items-center gap-0">
{executionTarget === "cloud" ? (
<>
<ModelSelector
allowedProviderIds={CLINE_ONLY_PROVIDER_IDS}
autoCorrectModel={!cloudSettingsLocked}
isBusy={isBusy}
model={model}
onModelChange={onModelChange}
onModelSupportsReasoningChange={
handleModelSupportsReasoningChange
}
onProviderChange={onProviderChange}
persistSelection={false}
provider={provider}
/>
<Select
disabled
value={EFFORT_LEVELS[effortIndex]?.value ?? "low"}
>
<SelectTrigger
aria-label="Thinking level"
className="h-7 border-0"
size="sm"
>
<span className="flex items-center gap-1">
{effortLabel}
<ReasoningEffortIcon
className="size-3"
value={EFFORT_LEVELS[effortIndex]?.value ?? "low"}
/>
</span>
</SelectTrigger>
<SelectContent>
{EFFORT_LEVELS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</>
) : (
<>
<Popover
onOpenChange={setModelSettingsOpen}
open={modelSettingsOpen}
>
<PopoverTrigger asChild>
<button
aria-label="Model settings"
className="flex h-7 max-w-md items-center gap-1 rounded-md px-2 text-[11px] transition-colors hover:bg-accent"
type="button"
>
<span className="truncate text-muted-foreground">
{provider}
</span>
<span className="truncate text-foreground">{model}</span>
<ReasoningEffortIcon
className="size-3 shrink-0"
value={EFFORT_LEVELS[effortIndex]?.value ?? "low"}
/>
</button>
</PopoverTrigger>
<PopoverContent
align="end"
className="w-72 space-y-1 p-2"
forceMount
onOpenAutoFocus={(event) => {
event.preventDefault();
requestAnimationFrame(() =>
thinkingSliderRef.current?.focus(),
);
}}
side="top"
>
<ModelSelector
allowedProviderIds={
executionTarget === "cloud"
? CLINE_ONLY_PROVIDER_IDS
: undefined
}
autoCorrectModel={!cloudSettingsLocked}
isBusy={isBusy}
model={model}
onModelChange={onModelChange}
onModelSupportsReasoningChange={
handleModelSupportsReasoningChange
}
onProviderChange={onProviderChange}
persistSelection={executionTarget !== "cloud"}
provider={provider}
variant="menu"
/>
<div className="rounded-md px-2 py-1">
<div className="flex items-center justify-between text-xs">
<span className="text-[10px] text-muted-foreground">
Effort
</span>
<span className="flex items-center gap-1.5 text-[10px] text-foreground">
{effortLabel}
<ReasoningEffortIcon
className="size-3.5"
value={EFFORT_LEVELS[effortIndex]?.value ?? "low"}
/>
</span>
</div>
<div className="relative mt-3 h-4">
<input
aria-label="Effort"
aria-valuetext={effortLabel}
className="relative z-10 block h-4 w-full cursor-pointer appearance-none rounded-full disabled:cursor-not-allowed disabled:opacity-50"
disabled={
cloudSettingsLocked ||
modelSupportsReasoning !== true
}
max={EFFORT_LEVELS.length - 1}
min={0}
onChange={(event) => {
const option =
EFFORT_LEVELS[Number(event.currentTarget.value)];
if (option) handleEffortChange(option.value);
}}
onKeyDown={(event) => {
if (
event.key !== "ArrowLeft" &&
event.key !== "ArrowRight"
)
return;
event.preventDefault();
const direction =
event.key === "ArrowRight" ? 1 : -1;
const nextIndex = Math.min(
EFFORT_LEVELS.length - 1,
Math.max(0, effortIndex + direction),
);
const option = EFFORT_LEVELS[nextIndex];
if (option) handleEffortChange(option.value);
}}
ref={thinkingSliderRef}
step={1}
type="range"
value={effortIndex}
/>
<div
aria-hidden="true"
className="pointer-events-none absolute left-2.5 right-2.5 top-0 z-20 flex h-4 items-center justify-between"
data-slot="thinking-level-markers"
>
{EFFORT_LEVELS.map((option) => (
<span
className="size-1 rounded-full bg-zinc-500 opacity-50"
key={option.value}
/>
))}
</div>
</div>
</div>
</PopoverContent>
</Popover>
<Select
disabled={modelSupportsReasoning !== true}
onValueChange={handleEffortChange}
value={EFFORT_LEVELS[effortIndex]?.value ?? "low"}
>
<SelectTrigger
aria-label="Thinking level"
className="h-7 gap-1.5 border-0 px-2 text-sm shadow-none"
size="sm"
>
<span className="flex items-center gap-1">
{effortLabel}
<ReasoningEffortIcon
className="size-3"
value={EFFORT_LEVELS[effortIndex]?.value ?? "low"}
/>
</span>
</SelectTrigger>
<SelectContent>
{EFFORT_LEVELS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
</div>
<div className="min-w-0 shrink-0">
<ModelSelector
isBusy={isBusy}
model={model}
onModelChange={onModelChange}
onModelSupportsReasoningChange={
handleModelSupportsReasoningChange
}
onProviderChange={onProviderChange}
provider={provider}
/>
</div>
<Select
disabled={modelSupportsReasoning !== true}
onValueChange={handleEffortChange}
value={EFFORT_LEVELS[effortIndex]?.value ?? "low"}
>
<SelectTrigger
aria-label="Thinking level"
className="gap-1.5 border-0 px-2 text-sm shadow-none data-[size=sm]:h-7 [&>svg:last-child]:hidden max-[560px]:size-7 max-[560px]:justify-center max-[560px]:p-0 bg-transparent! hover:bg-surface-hover!"
size="sm"
title={
modelSupportsReasoning === false
? "The selected model does not report reasoning support"
: undefined
}
>
<Brain className="size-3" />
<span className="max-[560px]:sr-only">
<SelectValue>{effortLabel}</SelectValue>
</span>
</SelectTrigger>
<SelectContent align="start">
{EFFORT_LEVELS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="ml-auto flex min-w-0 items-center gap-2 max-[560px]:shrink-0">
<div className="ml-auto flex min-w-0 shrink-0 items-center gap-0">
{variant === "conversation" ? (
<div className="flex min-w-0 items-center gap-0">
<div className="min-w-0 overflow-visible">
<WorkspaceSelector
currentBranch={gitBranch}
disabled
onListGitBranches={onListGitBranches}
onRefreshWorkspaces={onRefreshWorkspaces}
onPickWorkspaceDirectory={onPickWorkspaceDirectory}
onSwitchGitBranch={onSwitchGitBranch}
onSwitchWorkspace={onSwitchWorkspace}
workspaces={workspaces}
workspaceRoot={workspaceRoot}
/>
</div>
{executionTarget !== "cloud" && gitBranch !== undefined ? (
<div className="min-w-0 overflow-visible">
<WorkspaceSelector
currentBranch={gitBranch}
disabled
onListGitBranches={onListGitBranches}
onRefreshWorkspaces={onRefreshWorkspaces}
onPickWorkspaceDirectory={onPickWorkspaceDirectory}
onSwitchGitBranch={onSwitchGitBranch}
onSwitchWorkspace={onSwitchWorkspace}
workspaces={workspaces}
workspaceRoot={workspaceRoot}
/>
</div>
) : null}
<TokenUsageRing
usage={{
contextWindow: modelContextWindow,
@@ -1506,19 +1778,27 @@ export const ChatInputBar = memo(ChatInputBarImpl);
// Memoized: the selectors load/hold the full provider-model catalog, so they
// should not re-render for every keystroke in the composer textarea.
const ModelSelector = memo(function ModelSelector({
allowedProviderIds,
autoCorrectModel = true,
persistSelection = true,
provider,
model,
isBusy,
onProviderChange,
onModelChange,
onModelSupportsReasoningChange,
variant = "toolbar",
}: {
allowedProviderIds?: string[];
autoCorrectModel?: boolean;
persistSelection?: boolean;
provider: string;
model: string;
isBusy: boolean;
onProviderChange: (provider: string) => void;
onModelChange: (model: string) => void;
onModelSupportsReasoningChange: (supportsReasoning: boolean | null) => void;
variant?: "toolbar" | "menu";
}) {
const normalizedProvider = normalizeProviderId(provider);
const [providerModels, setProviderModels] = useState<
@@ -1538,10 +1818,13 @@ const ModelSelector = memo(function ModelSelector({
const visibleProviderModels = useMemo(() => {
const next: Record<string, string[]> = {};
for (const providerId of enabledProviderIds) {
if (allowedProviderIds && !allowedProviderIds.includes(providerId)) {
continue;
}
next[providerId] = providerModels[providerId] ?? [];
}
return next;
}, [enabledProviderIds, providerModels]);
}, [allowedProviderIds, enabledProviderIds, providerModels]);
const providers = useMemo(
() => Object.keys(visibleProviderModels),
[visibleProviderModels],
@@ -1679,6 +1962,9 @@ const ModelSelector = memo(function ModelSelector({
// happened to use.
const rememberSelection = useCallback(
(providerId: string, modelId: string | undefined) => {
if (!persistSelection) {
return;
}
const normalizedId = normalizeProviderId(providerId);
if (!normalizedId) {
return;
@@ -1701,7 +1987,7 @@ const ModelSelector = memo(function ModelSelector({
};
});
},
[],
[persistSelection],
);
useEffect(() => {
@@ -1716,13 +2002,22 @@ const ModelSelector = memo(function ModelSelector({
if (providers.length === 0) {
return;
}
// isBusy also covers a locked cloud composer: silently "correcting" an
// attached cloud session's model (e.g. an org-catalog id missing from
// the local list) would push a real model change to the remote session
// on the next send, contradicting the locked-settings tooltip.
if (isBusy) {
return;
}
if (resolvedProvider && resolvedProvider !== normalizedProvider) {
onProviderChange(resolvedProvider);
}
if (resolvedModel && resolvedModel !== model) {
if (autoCorrectModel && resolvedModel && resolvedModel !== model) {
onModelChange(resolvedModel);
}
}, [
autoCorrectModel,
isBusy,
model,
onModelChange,
onProviderChange,
@@ -1788,7 +2083,10 @@ const ModelSelector = memo(function ModelSelector({
},
[onModelChange, rememberSelection, resolvedProvider],
);
const renderProviderSelect = (triggerClassName: string) => (
const renderProviderSelect = (
triggerClassName: string,
placement: "top" | "right" | "bottom" | "left" = "top",
) => (
<SearchCombobox
ariaLabel="Provider"
className={triggerClassName}
@@ -1797,7 +2095,7 @@ const ModelSelector = memo(function ModelSelector({
onValueChange={handleProviderSelect}
options={providers.map((value) => ({ label: value, value }))}
placeholder="Provider"
placement="top"
placement={placement}
searchPlaceholder="Search providers"
value={resolvedProvider}
/>
@@ -1805,8 +2103,11 @@ const ModelSelector = memo(function ModelSelector({
const renderModelSelect = (
triggerClassName: string,
closeMobileMenu = false,
placement: "top" | "right" | "bottom" | "left" = "top",
align: "start" | "end" = "start",
) => (
<SearchCombobox
align={align}
ariaLabel="Model"
className={triggerClassName}
disabled={isBusy || modelsForProvider.length === 0}
@@ -1817,11 +2118,41 @@ const ModelSelector = memo(function ModelSelector({
}}
options={modelsForProvider.map((value) => ({ label: value, value }))}
placeholder="Model"
placement="top"
placement={placement}
searchPlaceholder="Search models"
value={resolvedModel}
/>
);
if (variant === "menu") {
return (
<div className="grid gap-1 text-xs">
<div
className="rounded-md px-2 py-1.5 transition-colors hover:bg-accent"
data-slot="provider-selector-row"
>
<div className="mb-0.5 text-[10px] text-muted-foreground">
Provider
</div>
{renderProviderSelect(
"h-6 w-full max-w-none justify-start border-0 bg-transparent !p-0 text-left text-xs text-foreground shadow-none hover:bg-transparent",
"left",
)}
</div>
<div
className="rounded-md px-2 py-1.5 transition-colors hover:bg-accent"
data-slot="model-selector-row"
>
<div className="mb-0.5 text-[10px] text-muted-foreground">Model</div>
{renderModelSelect(
"h-6 w-full max-w-none justify-start border-0 bg-transparent !p-0 text-left text-xs text-foreground shadow-none hover:bg-transparent",
false,
"left",
"start",
)}
</div>
</div>
);
}
return (
<div className="relative min-w-0 shrink-0 text-sm">
@@ -1868,10 +2199,9 @@ const ModelSelector = memo(function ModelSelector({
</>
) : null}
<div className="flex min-w-0 items-center gap-0.5 max-[560px]:hidden">
{renderProviderSelect("max-w-28")}
<div className="bg-border-2 h-4 w-[0.1rem]" />
{renderModelSelect("max-w-52")}
<div className="flex min-w-0 items-center gap-0 max-[560px]:hidden">
{renderProviderSelect("max-w-28 text-[11px] text-muted-foreground")}
{renderModelSelect("max-w-52 text-[11px] text-foreground")}
</div>
</div>
);
@@ -693,7 +693,7 @@ describe("ChatMessages tool disclosures", () => {
...(assistantActions?.querySelectorAll(".cline-chat-message-action") ??
[]),
];
expect(assistantActionButtons).toHaveLength(2);
expect(assistantActionButtons).toHaveLength(3);
expect(
assistantActionButtons.every(
(action) => action.getAttribute("data-slot") === "icon-button",
@@ -19,12 +19,15 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { toast } from "@/hooks/use-toast";
import type {
ChatMessage,
ChatMessageImage,
ChatMessageVideo,
ChatSessionStatus,
} from "@/lib/chat-schema";
import { openExternalUrl } from "@/lib/desktop-client";
import { cn } from "@/lib/utils";
import { STREAMING_TITLE_CLASS } from "./messages/constants";
import {
@@ -35,12 +38,14 @@ import {
} from "./messages/group-messages";
import { ChatImageLightbox } from "./messages/image-lightbox";
import { MessageBubble } from "./messages/message-bubble";
import { ChatVideoLightbox } from "./messages/message-media";
import {
ToolApprovalPanel,
type ToolApprovalRequestItem,
} from "./messages/tool-approval-panel";
import { ToolMessageBlock } from "./messages/tool-message-block";
import { buildToolPresentation } from "./messages/tool-summaries";
import { useAssistantSpeech } from "./messages/use-assistant-speech";
import { SessionContent } from "./session-content";
type ChatMessagesProps = {
@@ -70,6 +75,9 @@ type ChatMessagesProps = {
runCount: number,
) => void | Promise<void>;
onForkSession?: () => void | Promise<void>;
onOpenVoiceOutputSettings?: () => void;
startingLabel?: string;
errorAction?: { label: string; url: string };
};
type AskQuestionRequestItem = {
@@ -100,6 +108,9 @@ function ChatMessagesImpl({
onRestoreCheckpoint,
onEditMessage,
onForkSession,
onOpenVoiceOutputSettings,
startingLabel = "Thinking...",
errorAction,
}: ChatMessagesProps) {
const hasMessages = messages.length > 0;
// Scanned from the tail without copying: this component re-renders on
@@ -176,12 +187,22 @@ function ChatMessagesImpl({
sessionId: string | null;
image: ChatMessageImage;
} | null>(null);
const [expandedVideo, setExpandedVideo] = useState<{
sessionId: string;
video: ChatMessageVideo;
} | null>(null);
const assistantSpeech = useAssistantSpeech({
sessionId,
onOpenVoiceOutputSettings,
});
const sessionVersioningPending =
editingMessageId !== null ||
forkingMessageId !== null ||
Object.values(checkpointActions).includes("undoing");
const visibleExpandedImage =
expandedImage?.sessionId === sessionId ? expandedImage.image : null;
const visibleExpandedVideo =
expandedVideo?.sessionId === sessionId ? expandedVideo : null;
const showIdleDetails =
!hasMessages && !isSessionSwitching && !showSwitchTransition;
const renderItems = useMemo(() => groupChatMessages(messages), [messages]);
@@ -212,17 +233,18 @@ function ChatMessagesImpl({
}, [sessionId]);
useEffect(() => {
if (!visibleExpandedImage) {
if (!visibleExpandedImage && !visibleExpandedVideo) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setExpandedImage(null);
setExpandedVideo(null);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [visibleExpandedImage]);
}, [visibleExpandedImage, visibleExpandedVideo]);
useEffect(() => {
if (!isSessionSwitching) {
@@ -421,6 +443,12 @@ function ChatMessagesImpl({
},
[sessionId],
);
const handleExpandVideo = useCallback(
(video: ChatMessageVideo) => {
if (sessionId) setExpandedVideo({ sessionId, video });
},
[sessionId],
);
const handleForkSession = useCallback(
async (messageId: string) => {
@@ -504,6 +532,7 @@ function ChatMessagesImpl({
message={message}
runCount={userRunCountByMessage.get(message)}
onExpandImage={handleExpandImage}
onExpandVideo={handleExpandVideo}
onCopyMessage={handleCopyMessage}
onEditMessage={
onEditMessage ? requestEditMessage : undefined
@@ -548,6 +577,19 @@ function ChatMessagesImpl({
}
forkPending={forkingMessageId === message.id}
forkError={forkErrors[message.id]}
onSpeakMessage={assistantSpeech.speak}
speechAvailable={Boolean(assistantSpeech.target)}
speechSettingsLoaded={assistantSpeech.settingsLoaded}
speechState={
assistantSpeech.state?.messageId === message.id
? assistantSpeech.state.phase
: undefined
}
speechTargetLabel={
assistantSpeech.target
? `${assistantSpeech.target.providerName} / ${assistantSpeech.target.modelName}`
: undefined
}
reasoningContent={reasoningContent}
reasoningRedacted={reasoningMessages.some(
(reasoningMessage) =>
@@ -623,7 +665,7 @@ function ChatMessagesImpl({
!isSessionSwitching ? (
<div className="flex min-h-7 items-center gap-2 py-1 text-sm font-medium text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span className={STREAMING_TITLE_CLASS}>Thinking...</span>
<span className={STREAMING_TITLE_CLASS}>{startingLabel}</span>
</div>
) : null}
{chatTransportState !== "connected" && !shouldShowErrorBanner ? (
@@ -638,7 +680,17 @@ function ChatMessagesImpl({
) : null}
{shouldShowErrorBanner ? (
<div className="cline-chat-selectable mt-4 rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
{error}
<p>{error}</p>
{errorAction ? (
<Button
className="mt-2"
onClick={() => void openExternalUrl(errorAction.url)}
size="sm"
variant="outline"
>
{errorAction.label}
</Button>
) : null}
</div>
) : null}
</SessionContent>
@@ -651,6 +703,13 @@ function ChatMessagesImpl({
onClose={() => setExpandedImage(null)}
/>
) : null}
{visibleExpandedVideo ? (
<ChatVideoLightbox
onClose={() => setExpandedVideo(null)}
sessionId={visibleExpandedVideo.sessionId}
video={visibleExpandedVideo.video}
/>
) : null}
<AlertDialog
open={checkpointConfirmation !== null}
onOpenChange={(open) => {
@@ -0,0 +1,279 @@
"use client";
import {
ArrowRight,
Check,
Cloud,
ExternalLink,
GitBranch,
Github,
LoaderCircle,
LogIn,
RefreshCcw,
ShieldCheck,
Sparkles,
} from "lucide-react";
import type { ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export type CloudOnboardingVariant =
| "signed_out"
| "not_connected"
| "no_repositories"
| "error";
/**
* Full-width onboarding panel shown in place of the composer when a cloud
* session cannot be started yet (signed out, GitHub not connected, or the
* GitHub App has no repository access). Teaches what cloud sessions are and
* walks the user through the dashboard hand-off, then auto-detects completion.
*/
export function CloudOnboardingCard({
variant,
onConnect,
onRefresh,
onSignIn,
signingIn = false,
checking = false,
}: {
variant: CloudOnboardingVariant;
/** Opens the Cline dashboard integrations page in the browser. */
onConnect: () => void;
onRefresh: () => void;
onSignIn?: () => void;
signingIn?: boolean;
/** True while a background repository re-check is in flight. */
checking?: boolean;
}) {
if (variant === "error") {
return (
<div className="rounded-xl border border-border bg-card/80 p-6 text-center shadow-sm backdrop-blur-sm">
<p className="text-sm font-medium text-foreground">
Could not reach Cline Cloud
</p>
<p className="mt-1 text-sm text-muted-foreground">
Check your connection and try again.
</p>
<Button
className="mt-4"
disabled={checking}
onClick={onRefresh}
size="sm"
variant="outline"
>
<RefreshCcw
aria-hidden="true"
className={cn("size-3.5", checking && "animate-spin")}
/>
Retry
</Button>
</div>
);
}
const isSignedOut = variant === "signed_out";
const isNoRepositories = variant === "no_repositories";
return (
<div className="overflow-hidden rounded-xl border border-border bg-card/80 shadow-sm backdrop-blur-sm">
<div className="flex flex-col gap-6 p-6 max-[720px]:p-5">
<div className="flex items-start justify-between gap-6 max-[720px]:flex-col">
<div className="min-w-0">
<p className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-2.5 py-1 text-[11px] font-medium uppercase tracking-wide text-primary">
<Sparkles aria-hidden="true" className="size-3" />
Cloud sessions
</p>
<h2 className="mt-3 text-lg font-semibold text-foreground">
{isSignedOut
? "Run Cline in the cloud"
: isNoRepositories
? "Give Cline access to a repository"
: "Connect GitHub to get started"}
</h2>
<p className="mt-1.5 max-w-xl text-sm leading-relaxed text-muted-foreground">
Cloud sessions run on secure, isolated sandboxes on Cline's
infrastructure. Cline clones your repository, works on a branch,
and keeps going even when you close the app check back in from
any device.
</p>
</div>
<CloudFlowIllustration className="shrink-0 max-[720px]:self-center" />
</div>
<ol className="grid grid-cols-3 gap-3 max-[720px]:grid-cols-1">
{isSignedOut ? (
<OnboardingStep
icon={<LogIn aria-hidden="true" className="size-4" />}
index={1}
title="Sign in with Cline"
>
Cloud sessions are part of your Cline account.
</OnboardingStep>
) : (
<OnboardingStep
done={isNoRepositories}
icon={<Github aria-hidden="true" className="size-4" />}
index={1}
title="Connect GitHub"
>
Link your GitHub account from the Cline dashboard.
</OnboardingStep>
)}
<OnboardingStep
active={isNoRepositories}
icon={<ShieldCheck aria-hidden="true" className="size-4" />}
index={2}
title="Pick your repositories"
>
Choose which repositories the Cline GitHub App can access.
</OnboardingStep>
<OnboardingStep
icon={<GitBranch aria-hidden="true" className="size-4" />}
index={3}
title="Start a session"
>
Pick a repo and branch here, describe the task, and Cline gets to
work in the cloud.
</OnboardingStep>
</ol>
<div className="flex flex-wrap items-center gap-3">
{isSignedOut ? (
<Button disabled={signingIn} onClick={onSignIn} size="sm">
<LogIn aria-hidden="true" className="size-3.5" />
{signingIn ? "Waiting for browser…" : "Sign in with Cline"}
</Button>
) : (
<Button onClick={onConnect} size="sm">
<Github aria-hidden="true" className="size-3.5" />
{isNoRepositories ? "Manage repository access" : "Connect GitHub"}
<ExternalLink aria-hidden="true" className="size-3" />
</Button>
)}
{isSignedOut ? null : (
<Button
disabled={checking}
onClick={onRefresh}
size="sm"
variant="ghost"
>
<RefreshCcw
aria-hidden="true"
className={cn("size-3.5", checking && "animate-spin")}
/>
{isNoRepositories ? "Check again" : "I've connected GitHub"}
</Button>
)}
{isSignedOut ? null : (
<span
aria-live="polite"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground"
>
<LoaderCircle
aria-hidden="true"
className="size-3 animate-spin motion-reduce:animate-none"
/>
Watching for changes this updates automatically.
</span>
)}
</div>
</div>
</div>
);
}
function OnboardingStep({
index,
title,
icon,
children,
done = false,
active = false,
}: {
index: number;
title: string;
icon: ReactNode;
children: ReactNode;
/** Step already completed (e.g. GitHub connected, repos pending). */
done?: boolean;
/** The step the user should do next. */
active?: boolean;
}) {
return (
<li
className={cn(
"rounded-lg border border-border/70 bg-background/60 p-3.5",
active && "border-primary/40 bg-primary/5",
)}
>
<div className="flex items-center gap-2">
<span
className={cn(
"inline-flex size-6 shrink-0 items-center justify-center rounded-full text-[11px] font-semibold",
done
? "bg-success/15 text-success"
: active
? "bg-primary text-white"
: "bg-muted text-muted-foreground",
)}
>
{done ? <Check aria-hidden="true" className="size-3.5" /> : index}
</span>
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-foreground">
{icon}
{title}
</span>
</div>
<p className="mt-2 text-xs leading-relaxed text-muted-foreground">
{children}
</p>
</li>
);
}
/**
* Decorative GitHub Cline Cloud branch flow diagram. Built from theme
* tokens so it adapts to light/dark mode and the accent color.
*/
function CloudFlowIllustration({ className }: { className?: string }) {
return (
<div
aria-hidden="true"
className={cn("flex items-center gap-2.5", className)}
>
<IllustrationNode>
<Github className="size-5 text-foreground" />
</IllustrationNode>
<ArrowRight className="size-3.5 text-muted-foreground/70" />
<IllustrationNode emphasized>
<Cloud className="size-6 text-primary" />
</IllustrationNode>
<ArrowRight className="size-3.5 text-muted-foreground/70" />
<IllustrationNode>
<GitBranch className="size-5 text-foreground" />
</IllustrationNode>
</div>
);
}
function IllustrationNode({
children,
emphasized = false,
}: {
children: ReactNode;
emphasized?: boolean;
}) {
return (
<span
className={cn(
"inline-flex items-center justify-center rounded-2xl border shadow-sm",
emphasized
? "size-14 border-primary/30 bg-primary/10"
: "size-11 border-border/70 bg-background/80",
)}
>
{children}
</span>
);
}
@@ -143,6 +143,7 @@ describe("DiffView file actions", () => {
root.render(
<DiffView
cwd="/Users/renee/cline"
environmentId="local"
fileDiffs={[FILE_DIFF]}
onClose={vi.fn()}
/>,
@@ -159,6 +160,7 @@ describe("DiffView file actions", () => {
root.render(
<DiffView
cwd="/Users/renee/cline"
environmentId="local"
fileDiffs={[FILE_DIFF]}
onClose={vi.fn()}
/>,
@@ -176,6 +178,7 @@ describe("DiffView file actions", () => {
await click(vscodeItem as Element);
expect(invokeMock).toHaveBeenCalledWith("open_file_in_editor", {
environmentId: "local",
path: "docs/a.mdx",
cwd: "/Users/renee/cline",
editor: "vscode",
@@ -191,7 +194,13 @@ describe("DiffView file actions", () => {
});
await act(async () => {
root.render(<DiffView fileDiffs={[FILE_DIFF]} onClose={vi.fn()} />);
root.render(
<DiffView
environmentId="local"
fileDiffs={[FILE_DIFF]}
onClose={vi.fn()}
/>,
);
});
await pointerDown(buttonWithLabel("Open docs/a.mdx in editor"));
@@ -202,6 +211,7 @@ describe("DiffView file actions", () => {
await click(menuItems()[0] as Element);
expect(invokeMock).toHaveBeenCalledWith("open_file_in_editor", {
environmentId: "local",
path: "docs/a.mdx",
editor: "default",
});
@@ -209,7 +219,13 @@ describe("DiffView file actions", () => {
it("copies the path as-is when no cwd is available", async () => {
await act(async () => {
root.render(<DiffView fileDiffs={[FILE_DIFF]} onClose={vi.fn()} />);
root.render(
<DiffView
environmentId="local"
fileDiffs={[FILE_DIFF]}
onClose={vi.fn()}
/>,
);
});
await click(buttonWithLabel("Copy file path for docs/a.mdx"));
@@ -28,6 +28,7 @@ import { resolveWorkspaceFilePath } from "@/lib/workspace-paths";
import { EditorIcon } from "./editor-icons";
type DiffViewProps = {
environmentId: string;
fileDiffs: SessionFileDiff[];
cwd?: string;
onClose: () => void;
@@ -38,7 +39,12 @@ type EditorOption = {
label: string;
};
export function DiffView({ fileDiffs, cwd, onClose }: DiffViewProps) {
export function DiffView({
environmentId,
fileDiffs,
cwd,
onClose,
}: DiffViewProps) {
const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(new Set());
const [editors, setEditors] = useState<EditorOption[]>([]);
@@ -120,6 +126,7 @@ export function DiffView({ fileDiffs, cwd, onClose }: DiffViewProps) {
collapsed={collapsedFiles.has(file.path)}
cwd={cwd}
editors={editors}
environmentId={environmentId}
file={file}
key={file.path}
onToggle={() => toggleFileCollapse(file.path)}
@@ -137,12 +144,14 @@ function DiffFileSection({
collapsed,
cwd,
editors,
environmentId,
onToggle,
}: {
file: SessionFileDiff;
collapsed: boolean;
cwd?: string;
editors: EditorOption[];
environmentId: string;
onToggle: () => void;
}) {
const [copied, setCopied] = useState(false);
@@ -175,6 +184,7 @@ function DiffFileSection({
setOpening(true);
try {
await desktopClient.invoke("open_file_in_editor", {
environmentId,
path: file.path,
...(cwd?.trim() ? { cwd } : {}),
...(editor ? { editor } : {}),
@@ -192,7 +202,7 @@ function DiffFileSection({
setOpening(false);
}
},
[file.path, cwd],
[cwd, environmentId, file.path],
);
return (
@@ -0,0 +1,202 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { RemoteEnvironmentProfile } from "@/lib/remote-environments";
import {
buildEnvironmentSelectorModel,
EnvironmentSelector,
} from "./environment-selector";
const profiles: RemoteEnvironmentProfile[] = [
{
id: "pi-server",
name: "Raspberry Pi",
host: "pi.example.com",
user: "pi",
},
{
id: "build-box",
name: "Build box",
host: "builder.example.com",
user: "ubuntu",
port: 2200,
},
];
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
if (!("ResizeObserver" in globalThis)) {
Object.assign(globalThis, {
ResizeObserver: class {
observe() {}
unobserve() {}
disconnect() {}
},
});
}
Element.prototype.scrollIntoView ??= () => {};
Element.prototype.hasPointerCapture ??= () => false;
Element.prototype.setPointerCapture ??= () => {};
Element.prototype.releasePointerCapture ??= () => {};
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.restoreAllMocks();
});
async function click(element: Element): Promise<void> {
await act(async () => {
element.dispatchEvent(
new MouseEvent("click", { bubbles: true, cancelable: true }),
);
await Promise.resolve();
});
}
async function pointerDown(element: Element): Promise<void> {
await act(async () => {
element.dispatchEvent(
new MouseEvent("pointerdown", {
bubbles: true,
cancelable: true,
button: 0,
}),
);
await Promise.resolve();
});
}
function trigger(): HTMLButtonElement {
const element = container.querySelector<HTMLButtonElement>(
"#environment-selector-btn",
);
expect(element).not.toBeNull();
return element as HTMLButtonElement;
}
function menuItemContaining(text: string): HTMLElement {
const item = Array.from(
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
).find((candidate) => candidate.textContent?.includes(text));
expect(item).toBeDefined();
return item as HTMLElement;
}
describe("buildEnvironmentSelectorModel", () => {
it("builds a sorted remote catalog and identifies the connected profile", () => {
const model = buildEnvironmentSelectorModel("pi-server", [
...profiles,
{ ...profiles[0], name: "Duplicate Pi" },
{ ...profiles[0], id: undefined, name: "Unsaved" },
]);
expect(model).toMatchObject({
activeKind: "remote",
activeLabel: "Raspberry Pi",
local: { id: "local", selected: false },
});
expect(model.remotes).toEqual([
expect.objectContaining({
id: "build-box",
label: "Build box",
destination: "ubuntu@builder.example.com:2200",
selected: false,
}),
expect.objectContaining({
id: "pi-server",
label: "Raspberry Pi",
status: "Connected",
selected: true,
}),
]);
});
it("does not mislabel an unloaded remote environment as Local", () => {
expect(buildEnvironmentSelectorModel("remote-loading", [])).toMatchObject({
activeKind: "remote",
activeLabel: "Remote",
local: { selected: false },
});
});
});
describe("EnvironmentSelector", () => {
it("renders every environment tier and routes selections and host setup", async () => {
const onSelectEnvironment = vi.fn(async () => undefined);
const onAddSshHost = vi.fn();
await act(async () => {
root.render(
<EnvironmentSelector
activeEnvironmentId="pi-server"
onAddSshHost={onAddSshHost}
onSelectEnvironment={onSelectEnvironment}
profiles={profiles}
/>,
);
});
expect(trigger().textContent?.trim()).toBe("");
expect(trigger().getAttribute("aria-label")).toBe(
"Environment: Raspberry Pi",
);
expect(trigger().title).toBe("Environment: Raspberry Pi");
expect(document.body.textContent).not.toContain("Raspberry Pi");
await pointerDown(trigger());
expect(document.body.textContent).toContain("Raspberry Pi");
expect(document.body.textContent).toContain("Local");
expect(document.body.textContent).toContain("Remote");
expect(document.body.textContent).toContain("Build box");
expect(document.body.textContent).toContain(
"ubuntu@builder.example.com:2200",
);
expect(document.body.textContent).toContain("Connected");
expect(document.body.textContent).toContain("Cloud");
expect(document.body.textContent).toContain("Coming soon");
expect(
menuItemContaining("Cloud").getAttribute("data-disabled"),
).not.toBeNull();
await click(menuItemContaining("Local"));
await vi.waitFor(() => {
expect(onSelectEnvironment).toHaveBeenCalledWith("local");
});
await pointerDown(trigger());
await click(menuItemContaining("Add SSH Host"));
expect(onAddSshHost).toHaveBeenCalledTimes(1);
});
it("reopens the menu after a rejected environment switch", async () => {
const onSelectEnvironment = vi
.fn()
.mockRejectedValue(new Error("SSH unavailable"));
await act(async () => {
root.render(
<EnvironmentSelector
activeEnvironmentId="local"
onAddSshHost={vi.fn()}
onSelectEnvironment={onSelectEnvironment}
profiles={profiles}
/>,
);
});
await pointerDown(trigger());
await click(menuItemContaining("Build box"));
await vi.waitFor(() => {
expect(onSelectEnvironment).toHaveBeenCalledWith("build-box");
expect(menuItemContaining("Build box")).toBeDefined();
});
expect(trigger().disabled).toBe(false);
});
});
@@ -0,0 +1,219 @@
"use client";
import { Check, Cloud, Laptop, Loader2, Plus, Server } from "lucide-react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
formatRemoteEnvironmentDestination,
type RemoteEnvironmentProfile,
} from "@/lib/remote-environments";
import { LOCAL_WORKSPACE_ENVIRONMENT_ID } from "@/lib/workspace-paths";
export type EnvironmentSelectorOption = {
id: string;
label: string;
destination?: string;
kind: "local" | "remote";
selected: boolean;
status?: "Active" | "Connected";
};
export type EnvironmentSelectorModel = {
activeKind: "local" | "remote";
activeLabel: string;
local: EnvironmentSelectorOption;
remotes: EnvironmentSelectorOption[];
};
export type EnvironmentSelectorProps = {
activeEnvironmentId: string;
profiles: RemoteEnvironmentProfile[];
loading?: boolean;
switchingEnvironmentId?: string | null;
onSelectEnvironment: (environmentId: string) => void | Promise<void>;
onAddSshHost: () => void;
};
export function buildEnvironmentSelectorModel(
activeEnvironmentId: string,
profiles: RemoteEnvironmentProfile[],
): EnvironmentSelectorModel {
const remoteById = new Map<string, EnvironmentSelectorOption>();
for (const profile of profiles) {
const id = profile.id?.trim();
if (!id || remoteById.has(id)) continue;
const selected = id === activeEnvironmentId;
remoteById.set(id, {
id,
label: profile.name.trim() || profile.host.trim() || "SSH host",
destination: formatRemoteEnvironmentDestination(profile),
kind: "remote",
selected,
status: selected ? "Connected" : undefined,
});
}
const remotes = [...remoteById.values()].sort(
(left, right) =>
left.label.localeCompare(right.label) || left.id.localeCompare(right.id),
);
const localSelected = activeEnvironmentId === LOCAL_WORKSPACE_ENVIRONMENT_ID;
const activeRemote = remotes.find((option) => option.selected);
return {
activeKind: localSelected ? "local" : "remote",
activeLabel: activeRemote?.label ?? (localSelected ? "Local" : "Remote"),
local: {
id: LOCAL_WORKSPACE_ENVIRONMENT_ID,
label: "Local",
kind: "local",
selected: localSelected,
status: localSelected ? "Active" : undefined,
},
remotes,
};
}
export function EnvironmentSelector({
activeEnvironmentId,
profiles,
loading = false,
switchingEnvironmentId,
onSelectEnvironment,
onAddSshHost,
}: EnvironmentSelectorProps) {
const model = useMemo(
() => buildEnvironmentSelectorModel(activeEnvironmentId, profiles),
[activeEnvironmentId, profiles],
);
const [internalSwitchingId, setInternalSwitchingId] = useState<string | null>(
null,
);
const [open, setOpen] = useState(false);
const pendingEnvironmentId = switchingEnvironmentId ?? internalSwitchingId;
const busy = loading || pendingEnvironmentId !== null;
const ActiveIcon = model.activeKind === "remote" ? Server : Laptop;
const selectEnvironment = async (environmentId: string) => {
if (busy || environmentId === activeEnvironmentId) return;
setInternalSwitchingId(environmentId);
try {
await onSelectEnvironment(environmentId);
} catch {
// The parent owns connection errors and their user-facing presentation;
// reopen so the failed choice does not strand the user at a closed menu.
setOpen(true);
} finally {
setInternalSwitchingId(null);
}
};
const optionStatus = (option: EnvironmentSelectorOption) => {
if (pendingEnvironmentId === option.id) {
return (
<span className="ml-auto flex items-center gap-1 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
Connecting
</span>
);
}
if (option.status) {
return (
<span className="ml-auto text-xs text-muted-foreground">
{option.status}
</span>
);
}
return null;
};
return (
<DropdownMenu onOpenChange={setOpen} open={open}>
<DropdownMenuTrigger asChild>
<Button
aria-label={`Environment: ${model.activeLabel}`}
className="size-9 shrink-0 rounded-md border border-border/70 bg-background/80 p-0 text-foreground shadow-none transition-colors hover:bg-accent hover:text-foreground"
disabled={busy}
id="environment-selector-btn"
title={`Environment: ${model.activeLabel}`}
variant="ghost"
>
{busy ? (
<Loader2 className="size-4 animate-spin" />
) : (
<ActiveIcon className="size-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-72" side="bottom">
<DropdownMenuItem
aria-current={model.local.selected ? "true" : undefined}
disabled={busy}
onSelect={() => void selectEnvironment(model.local.id)}
>
<Laptop />
<span>Local</span>
{optionStatus(model.local)}
{model.local.selected ? <Check className="ml-1" /> : null}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Remote
</DropdownMenuLabel>
{model.remotes.length > 0 ? (
model.remotes.map((option) => (
<DropdownMenuItem
aria-current={option.selected ? "true" : undefined}
disabled={busy}
key={option.id}
onSelect={() => void selectEnvironment(option.id)}
>
<Server />
<span className="min-w-0 flex-1">
<span className="block truncate">{option.label}</span>
{option.destination ? (
<span className="block truncate font-mono text-[10px] text-muted-foreground">
{option.destination}
</span>
) : null}
</span>
{optionStatus(option)}
{option.selected ? <Check className="ml-1" /> : null}
</DropdownMenuItem>
))
) : (
<DropdownMenuItem disabled>
<Server />
<span className="text-muted-foreground">No SSH hosts saved</span>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Cloud
</DropdownMenuLabel>
<DropdownMenuItem disabled>
<Cloud />
<span>Cloud</span>
<span className="ml-auto rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
Coming soon
</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={busy} onSelect={onAddSshHost}>
<Plus />
<span>Add SSH Host</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -6,7 +6,7 @@ describe("formatChatMessageContent", () => {
expect(
formatChatMessageContent(
"user",
" <user_input>\nPlease fix the tests\n</user_input> ",
' <user_input mode="act">\nPlease fix the tests\n</user_input> ',
),
).toBe("Please fix the tests");
});
@@ -16,16 +16,20 @@ import {
Loader2,
PencilIcon,
SplitIcon,
Square,
UndoIcon,
Volume2,
} from "lucide-react";
import { memo, useEffect, useState } from "react";
import type {
ChatMessage,
ChatMessageImage,
ChatMessageMedia,
ChatMessageVideo,
} from "@/lib/chat-schema";
import { MemoizedMarkdown } from "../../../ui/markdown";
import { formatChatMessageContent } from "../message-content";
import { MessageAudios, MessageVideos } from "./message-media";
import { ReasoningBlock } from "./reasoning-block";
function AssistantImageCarousel({
@@ -150,6 +154,8 @@ function MessageMedia({ media }: { media: ChatMessageMedia[] }) {
);
}
export type AssistantSpeechPhase = "generating" | "playing";
// Memoized with id-parameterized callbacks: during streaming only the message
// object that received a delta changes identity, so all other bubbles skip
// re-rendering (and re-running their Markdown pipeline) per flush.
@@ -160,6 +166,7 @@ export const MessageBubble = memo(function MessageBubble({
isStreaming = false,
onCopyMessage,
onExpandImage,
onExpandVideo,
onEditMessage,
editDisabled = false,
editPending = false,
@@ -173,6 +180,11 @@ export const MessageBubble = memo(function MessageBubble({
forkDisabled = false,
forkPending = false,
forkError,
onSpeakMessage,
speechAvailable = false,
speechSettingsLoaded = false,
speechState,
speechTargetLabel,
isLastAssistantMessage = false,
reasoningContent,
reasoningRedacted,
@@ -184,6 +196,7 @@ export const MessageBubble = memo(function MessageBubble({
isStreaming?: boolean;
onCopyMessage?: (messageId: string, content: string) => void | Promise<void>;
onExpandImage?: (image: ChatMessageImage) => void;
onExpandVideo?: (video: ChatMessageVideo) => void;
onEditMessage?: (
messageId: string,
content: string,
@@ -204,6 +217,11 @@ export const MessageBubble = memo(function MessageBubble({
forkDisabled?: boolean;
forkPending?: boolean;
forkError?: string;
onSpeakMessage?: (messageId: string, content: string) => void | Promise<void>;
speechAvailable?: boolean;
speechSettingsLoaded?: boolean;
speechState?: AssistantSpeechPhase;
speechTargetLabel?: string;
isLastAssistantMessage?: boolean;
reasoningContent: string;
reasoningRedacted: boolean;
@@ -221,7 +239,7 @@ export const MessageBubble = memo(function MessageBubble({
!isStreaming &&
!isError &&
Boolean(displayContent.trim()) &&
Boolean(onCopyMessage || onForkSession);
Boolean(onCopyMessage || onForkSession || onSpeakMessage);
const shouldRenderUserActions =
isUser &&
Boolean(
@@ -235,7 +253,10 @@ export const MessageBubble = memo(function MessageBubble({
Boolean(restoreError) ||
Boolean(editError);
const keepAssistantActionsVisible =
isLastAssistantMessage || forkPending || Boolean(forkError);
isLastAssistantMessage ||
forkPending ||
Boolean(forkError) ||
Boolean(speechState);
const messageDate = new Date(message.createdAt);
const hasValidMessageDate = !Number.isNaN(messageDate.getTime());
@@ -277,6 +298,21 @@ export const MessageBubble = memo(function MessageBubble({
/>
) : null}
{message.videos?.length && message.sessionId ? (
<MessageVideos
onExpandVideo={onExpandVideo}
sessionId={message.sessionId}
videos={message.videos}
/>
) : null}
{message.audios?.length && message.sessionId ? (
<MessageAudios
audios={message.audios}
sessionId={message.sessionId}
/>
) : null}
{message.media?.length ? <MessageMedia media={message.media} /> : null}
{displayContent ? (
@@ -371,6 +407,44 @@ export const MessageBubble = memo(function MessageBubble({
)}
</MessageAction>
) : null}
{onSpeakMessage ? (
<MessageAction
disabled={!speechSettingsLoaded}
label={
speechState === "generating"
? "Cancel speech generation"
: speechState === "playing"
? "Stop speaking assistant message"
: speechAvailable
? "Speak assistant message"
: "Configure voice output"
}
onClick={() => void onSpeakMessage(message.id, displayContent)}
title={
!speechSettingsLoaded
? "Loading voice output settings"
: speechState === "generating"
? "Cancel speech generation"
: speechState === "playing"
? "Stop speaking"
: speechTargetLabel
? `Speak with ${speechTargetLabel}`
: "Configure voice output in Settings → Models"
}
>
{speechState ? (
<Square
className={
speechState === "generating"
? "size-3 animate-pulse"
: "size-3"
}
/>
) : (
<Volume2 className="size-3" />
)}
</MessageAction>
) : null}
{onForkSession ? (
<MessageAction
disabled={forkDisabled || forkPending}
@@ -0,0 +1,337 @@
"use client";
import {
AudioPlayer,
AudioPlayerControlBar,
AudioPlayerElement,
AudioPlayerPlayButton,
AudioPlayerTimeDisplay,
AudioPlayerTimeRange,
} from "@cline/ui";
import { ChevronLeft, ChevronRight, Loader2, Maximize2, X } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import type {
ChatMessageAudio,
ChatMessageImage,
ChatMessageVideo,
} from "@/lib/chat-schema";
import { resolveDesktopBackendHttpEndpoint } from "@/lib/desktop-client";
export function MessageImages({
images,
isUser,
onExpandImage,
}: {
images: ChatMessageImage[];
isUser: boolean;
onExpandImage?: (image: ChatMessageImage) => void;
}) {
if (!isUser) {
return (
<AssistantImageCarousel images={images} onExpandImage={onExpandImage} />
);
}
return (
<div className="grid max-w-2xl gap-2">
{images.map((image, index) => (
<ImageButton
alt={`Attachment ${index + 1}`}
ariaLabel={`Expand attachment ${index + 1}`}
image={image}
key={image.id}
onExpandImage={onExpandImage}
/>
))}
</div>
);
}
function AssistantImageCarousel({
images,
onExpandImage,
}: {
images: ChatMessageImage[];
onExpandImage?: (image: ChatMessageImage) => void;
}) {
const [activeIndex, setActiveIndex] = useState(0);
const lastIndex = images.length - 1;
const safeIndex = Math.min(activeIndex, lastIndex);
const image = images[safeIndex];
useEffect(() => {
setActiveIndex((index) => Math.min(index, lastIndex));
}, [lastIndex]);
if (!image) return null;
return (
<div className="relative w-fit max-w-2xl">
<ImageButton
alt={`Generated result ${safeIndex + 1}`}
ariaLabel={`Expand generated image ${safeIndex + 1}`}
image={image}
onExpandImage={onExpandImage}
/>
{images.length > 1 ? (
<>
<button
aria-label="Previous generated image"
className="absolute left-1 top-1/2 flex size-7 -translate-y-1/2 items-center justify-center rounded-full border border-border bg-background/85 text-foreground shadow-sm backdrop-blur-sm transition-opacity hover:bg-background disabled:cursor-not-allowed disabled:opacity-35"
disabled={safeIndex === 0}
onClick={() => setActiveIndex((index) => Math.max(0, index - 1))}
type="button"
>
<ChevronLeft className="size-4" />
</button>
<button
aria-label="Next generated image"
className="absolute right-1 top-1/2 flex size-7 -translate-y-1/2 items-center justify-center rounded-full border border-border bg-background/85 text-foreground shadow-sm backdrop-blur-sm transition-opacity hover:bg-background disabled:cursor-not-allowed disabled:opacity-35"
disabled={safeIndex === lastIndex}
onClick={() =>
setActiveIndex((index) => Math.min(lastIndex, index + 1))
}
type="button"
>
<ChevronRight className="size-4" />
</button>
<div className="absolute bottom-1 left-1/2 -translate-x-1/2 rounded-full bg-background/85 px-2 py-0.5 text-[11px] text-foreground shadow-sm backdrop-blur-sm">
{safeIndex + 1} / {images.length}
</div>
</>
) : null}
</div>
);
}
function ImageButton({
alt,
ariaLabel,
image,
onExpandImage,
}: {
alt: string;
ariaLabel: string;
image: ChatMessageImage;
onExpandImage?: (image: ChatMessageImage) => void;
}) {
return (
<button
aria-label={ariaLabel}
className="cursor-zoom-in overflow-hidden rounded-lg border border-border bg-muted text-left transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onExpandImage?.(image)}
type="button"
>
{/* biome-ignore lint/performance/noImgElement: In-memory data URLs cannot use Next's optimizer. */}
<img
alt={alt}
className="max-h-56.25 max-w-56.25 object-contain"
src={`data:${image.mediaType};base64,${image.data}`}
/>
</button>
);
}
export function MessageVideos({
sessionId,
videos,
onExpandVideo,
}: {
sessionId: string;
videos: ChatMessageVideo[];
onExpandVideo?: (video: ChatMessageVideo) => void;
}) {
return (
<div className="grid max-w-2xl gap-2">
{videos.map((video) => (
<GeneratedVideo
key={video.id}
onExpandVideo={onExpandVideo}
sessionId={sessionId}
video={video}
/>
))}
</div>
);
}
export function ChatVideoLightbox({
sessionId,
video,
onClose,
}: {
sessionId: string;
video: ChatMessageVideo;
onClose: () => void;
}) {
const source = useSessionArtifactSource(sessionId, video.artifactName);
return (
<div
aria-label="Expanded generated video"
aria-modal="true"
className="absolute inset-0 z-50 flex items-center justify-center bg-background/95 p-4 backdrop-blur-sm"
role="dialog"
>
<button
aria-label="Close expanded video"
className="absolute inset-0 cursor-zoom-out"
onClick={onClose}
type="button"
/>
<div className="relative z-10 flex h-full w-full items-center justify-center">
{source ? (
// biome-ignore lint/a11y/useMediaCaption: Generated videos do not include a separate caption track.
<video
aria-label="Expanded generated video player"
autoPlay
className="max-h-full max-w-full rounded-lg bg-black shadow-2xl"
controls
playsInline
src={source}
/>
) : (
<LoadingArtifact label="Loading video…" />
)}
<Button
aria-label="Close video viewer"
className="absolute right-0 top-0 rounded-full"
onClick={onClose}
size="icon"
type="button"
variant="secondary"
>
<X className="size-4" />
</Button>
</div>
</div>
);
}
function GeneratedVideo({
sessionId,
video,
onExpandVideo,
}: {
sessionId: string;
video: ChatMessageVideo;
onExpandVideo?: (video: ChatMessageVideo) => void;
}) {
const source = useSessionArtifactSource(sessionId, video.artifactName);
return (
<div className="relative w-fit max-w-2xl overflow-hidden rounded-lg border border-border bg-black">
{source ? (
<>
{/* biome-ignore lint/a11y/useMediaCaption: Generated videos do not include a separate caption track. */}
<video
aria-label="Generated video"
className="max-h-96 max-w-full"
controls
playsInline
preload="metadata"
src={source}
/>
<Button
aria-label="Expand generated video"
className="absolute right-2 top-2 rounded-full bg-background/85 shadow-md backdrop-blur-sm"
onClick={() => onExpandVideo?.(video)}
size="icon"
type="button"
variant="secondary"
>
<Maximize2 className="size-4" />
</Button>
</>
) : (
<LoadingArtifact label="Loading video…" />
)}
</div>
);
}
export function MessageAudios({
sessionId,
audios,
}: {
sessionId: string;
audios: ChatMessageAudio[];
}) {
return (
<div className="grid max-w-2xl gap-2">
{audios.map((audio) => (
<GeneratedAudio audio={audio} key={audio.id} sessionId={sessionId} />
))}
</div>
);
}
function GeneratedAudio({
sessionId,
audio,
}: {
sessionId: string;
audio: ChatMessageAudio;
}) {
const source = useSessionArtifactSource(sessionId, audio.artifactName);
return source ? (
<AudioPlayer className="w-full max-w-md text-foreground">
<AudioPlayerElement
aria-label="Generated audio"
muted={false}
onPlay={(event) => {
event.currentTarget.muted = false;
if (event.currentTarget.volume === 0) {
event.currentTarget.volume = 1;
}
}}
preload="metadata"
src={source}
/>
<AudioPlayerControlBar className="w-full">
<AudioPlayerPlayButton aria-label="Play or pause generated audio" />
<AudioPlayerTimeRange />
<AudioPlayerTimeDisplay
aria-label="Generated audio time remaining"
noToggle
remaining
/>
</AudioPlayerControlBar>
</AudioPlayer>
) : (
<LoadingArtifact label="Loading audio…" />
);
}
function LoadingArtifact({ label }: { label: string }) {
return (
<div className="flex h-40 w-72 items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 size-4 animate-spin" />
{label}
</div>
);
}
function useSessionArtifactSource(
sessionId: string,
artifactName: string,
): string | null {
const [source, setSource] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
void resolveDesktopBackendHttpEndpoint().then((endpoint) => {
if (cancelled) return;
setSource(
`${endpoint}/api/session-artifacts/${encodeURIComponent(sessionId)}/${encodeURIComponent(artifactName)}`,
);
});
return () => {
cancelled = true;
};
}, [artifactName, sessionId]);
return source;
}
@@ -0,0 +1,196 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "@/hooks/use-toast";
import { desktopClient, writeDesktopDebugLog } from "@/lib/desktop-client";
import {
loadProviderModelCatalog,
MODE_SETTINGS_CHANGED_EVENT,
type SpeechGenerationModelTarget,
} from "@/lib/provider-model-catalog";
import type { AssistantSpeechPhase } from "./message-bubble";
type AssistantSpeechState = {
messageId: string;
phase: AssistantSpeechPhase;
};
export function useAssistantSpeech({
sessionId,
onOpenVoiceOutputSettings,
}: {
sessionId: string | null;
onOpenVoiceOutputSettings?: () => void;
}) {
const [target, setTarget] = useState<SpeechGenerationModelTarget | null>(
null,
);
const [settingsLoaded, setSettingsLoaded] = useState(false);
const [state, setState] = useState<AssistantSpeechState | null>(null);
const activeAudioRef = useRef<HTMLAudioElement | null>(null);
const activeAudioUrlRef = useRef<string | null>(null);
const requestIdRef = useRef(0);
const sessionIdRef = useRef(sessionId);
useEffect(() => {
let cancelled = false;
let loadId = 0;
const loadVoiceOutput = () => {
const currentLoadId = ++loadId;
setSettingsLoaded(false);
void loadProviderModelCatalog()
.then((catalog) => {
if (!cancelled && currentLoadId === loadId) {
setTarget(catalog.modes.voiceOutput);
setSettingsLoaded(true);
}
})
.catch(() => {
if (!cancelled && currentLoadId === loadId) {
setTarget(null);
setSettingsLoaded(true);
}
});
};
const handleModeSettingsChanged = (event: Event) => {
const mode = (event as CustomEvent<{ mode?: string }>).detail?.mode;
if (!mode || mode === "voiceOutput") loadVoiceOutput();
};
loadVoiceOutput();
window.addEventListener(
MODE_SETTINGS_CHANGED_EVENT,
handleModeSettingsChanged,
);
return () => {
cancelled = true;
loadId += 1;
window.removeEventListener(
MODE_SETTINGS_CHANGED_EVENT,
handleModeSettingsChanged,
);
};
}, []);
const stop = useCallback(() => {
requestIdRef.current += 1;
const audio = activeAudioRef.current;
if (audio) {
audio.pause();
audio.removeAttribute("src");
activeAudioRef.current = null;
}
const audioUrl = activeAudioUrlRef.current;
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
activeAudioUrlRef.current = null;
}
setState(null);
}, []);
useEffect(() => {
if (sessionIdRef.current !== sessionId) {
sessionIdRef.current = sessionId;
stop();
}
}, [sessionId, stop]);
useEffect(() => stop, [stop]);
const speak = useCallback(
async (messageId: string, content: string) => {
if (state?.messageId === messageId) {
stop();
return;
}
if (!target) {
onOpenVoiceOutputSettings?.();
return;
}
const text = content.trim();
if (!text) return;
stop();
const requestId = requestIdRef.current;
setState({ messageId, phase: "generating" });
writeDesktopDebugLog({
scope: "voice-output",
level: "debug",
message: "Assistant message requested generated speech",
timestamp: new Date().toISOString(),
metadata: {
messageId,
providerId: target.providerId,
modelId: target.modelId,
textCharacters: text.length,
},
});
try {
const result = await desktopClient.invoke<{
audioBase64?: string;
mediaType?: string;
}>("synthesize_speech", { text });
if (requestIdRef.current !== requestId) return;
if (!result.audioBase64) {
throw new Error("The speech provider returned no audio");
}
const mediaType = result.mediaType?.trim() || "audio/mpeg";
const audioUrl = URL.createObjectURL(
base64ToAudioBlob(result.audioBase64, mediaType),
);
const audio = new Audio(audioUrl);
activeAudioRef.current = audio;
activeAudioUrlRef.current = audioUrl;
let released = false;
const release = () => {
if (released) return;
released = true;
if (activeAudioRef.current === audio) activeAudioRef.current = null;
if (activeAudioUrlRef.current === audioUrl) {
URL.revokeObjectURL(audioUrl);
activeAudioUrlRef.current = null;
}
if (requestIdRef.current === requestId) {
setState((current) =>
current?.messageId === messageId ? null : current,
);
}
};
audio.addEventListener("ended", release, { once: true });
audio.addEventListener("error", release, { once: true });
setState({ messageId, phase: "playing" });
await audio.play();
} catch (error) {
if (requestIdRef.current !== requestId) return;
stop();
const message = error instanceof Error ? error.message : String(error);
writeDesktopDebugLog({
scope: "voice-output",
level: "error",
message: "Assistant message voice playback failed in the webview",
timestamp: new Date().toISOString(),
metadata: { failure: message, messageId },
});
toast({
variant: "destructive",
title: "Voice playback failed",
description: message,
});
}
},
[onOpenVoiceOutputSettings, state?.messageId, stop, target],
);
return { settingsLoaded, speak, state, target };
}
function base64ToAudioBlob(audioBase64: string, mediaType: string): Blob {
const binary = window.atob(audioBase64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new Blob([bytes.buffer], { type: mediaType });
}
@@ -0,0 +1,145 @@
// @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 { RemoteDirectoryPicker } from "./remote-directory-picker";
const { invokeMock } = vi.hoisted(() => ({
invokeMock: vi.fn(),
}));
vi.mock("@/lib/desktop-client", () => ({
desktopClient: { invoke: invokeMock },
}));
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
invokeMock.mockReset();
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.restoreAllMocks();
});
async function clickButton(text: string): Promise<void> {
const button = [
...document.querySelectorAll<HTMLButtonElement>("button"),
].find((candidate) => candidate.textContent?.includes(text));
expect(button).toBeDefined();
await act(async () => {
button?.click();
await Promise.resolve();
});
}
describe("RemoteDirectoryPicker", () => {
it("browses from remote home and returns the selected directory", async () => {
invokeMock.mockImplementation(
async (command: string, args?: Record<string, unknown>) => {
expect(command).toBe("list_workspace_directories");
if (args?.path === "/home/pi") {
return {
environmentId: "pi-host",
currentPath: "/home/pi",
parentPath: "/home",
entries: [{ name: "projects", path: "/home/pi/projects" }],
truncated: true,
};
}
if (args?.path === "/home/pi/projects") {
return {
environmentId: "pi-host",
currentPath: "/srv/projects",
parentPath: "/srv",
entries: [{ name: "cline", path: "/srv/projects/cline" }],
truncated: false,
};
}
return {
environmentId: "pi-host",
currentPath: String(args?.path),
parentPath: "/srv/projects",
entries: [],
truncated: false,
};
},
);
const onSelect = vi.fn();
await act(async () => {
root.render(
<RemoteDirectoryPicker
environmentId="pi-host"
homeDir="/home/pi"
onCancel={vi.fn()}
onSelect={onSelect}
open
/>,
);
});
await vi.waitFor(() => {
expect(document.body.textContent).toContain("projects");
expect(document.body.textContent).toContain(
"Only the first directories are shown",
);
});
expect(invokeMock).toHaveBeenCalledWith("list_workspace_directories", {
environmentId: "pi-host",
path: "/home/pi",
});
await clickButton("projects");
await vi.waitFor(() => {
expect(document.body.textContent).toContain("cline");
});
expect(invokeMock).toHaveBeenCalledWith("list_workspace_directories", {
environmentId: "pi-host",
path: "/home/pi/projects",
});
await clickButton("cline");
await vi.waitFor(() => {
expect(document.body.textContent).toContain("/srv/projects/cline");
});
await clickButton("Use this folder");
expect(onSelect).toHaveBeenCalledWith("/srv/projects/cline");
});
it("rejects a directory response from another environment", async () => {
invokeMock.mockResolvedValue({
environmentId: "other-host",
currentPath: "/home/other",
parentPath: "/home",
entries: [],
truncated: false,
});
await act(async () => {
root.render(
<RemoteDirectoryPicker
environmentId="pi-host"
homeDir="/home/pi"
onCancel={vi.fn()}
onSelect={vi.fn()}
open
/>,
);
});
await vi.waitFor(() => {
expect(document.body.textContent).toContain(
"Directory response belongs to other-host, not pi-host.",
);
});
});
});
@@ -0,0 +1,229 @@
"use client";
import {
ArrowUp,
CircleAlert,
Folder,
Home,
Loader2,
RefreshCw,
} from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { desktopClient } from "@/lib/desktop-client";
type WorkspaceDirectoryListResult = {
environmentId: string;
currentPath: string;
parentPath: string | null;
entries: Array<{ name: string; path: string }>;
truncated: boolean;
};
function normalizeRemotePath(path: string): string {
const trimmed = path.trim();
if (trimmed === "/") return trimmed;
return trimmed.replace(/\/+$/, "");
}
export function RemoteDirectoryPicker({
open,
environmentId,
homeDir,
onCancel,
onSelect,
}: {
open: boolean;
environmentId: string;
homeDir: string;
onCancel: () => void;
onSelect: (path: string) => void;
}) {
const normalizedHome = normalizeRemotePath(homeDir) || "/";
const [currentPath, setCurrentPath] = useState(normalizedHome);
const [requestedPath, setRequestedPath] = useState(normalizedHome);
const [parentPath, setParentPath] = useState<string | null>(null);
const [directories, setDirectories] = useState<
Array<{ name: string; path: string }>
>([]);
const [truncated, setTruncated] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [reloadVersion, setReloadVersion] = useState(0);
useEffect(() => {
if (!open) return;
setCurrentPath(normalizedHome);
setRequestedPath(normalizedHome);
setParentPath(null);
setDirectories([]);
setTruncated(false);
}, [normalizedHome, open]);
useEffect(() => {
if (!open) return;
const request = {
environmentId,
path: requestedPath,
reloadVersion,
};
let cancelled = false;
setLoading(true);
setError(null);
desktopClient
.invoke<WorkspaceDirectoryListResult>("list_workspace_directories", {
environmentId: request.environmentId,
path: request.path,
})
.then((result) => {
if (cancelled) return;
if (result.environmentId !== request.environmentId) {
throw new Error(
`Directory response belongs to ${result.environmentId}, not ${request.environmentId}.`,
);
}
const canonicalPath = normalizeRemotePath(result.currentPath);
if (!canonicalPath) {
throw new Error("Remote host returned an empty directory path.");
}
setCurrentPath(canonicalPath);
setParentPath(
result.parentPath ? normalizeRemotePath(result.parentPath) : null,
);
setDirectories(
(result.entries ?? []).filter(
(entry) => entry.name.trim() && normalizeRemotePath(entry.path),
),
);
setTruncated(result.truncated === true);
})
.catch((listError: unknown) => {
if (cancelled) return;
setDirectories([]);
setTruncated(false);
setError(
listError instanceof Error ? listError.message : String(listError),
);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [environmentId, open, reloadVersion, requestedPath]);
const canGoUp = Boolean(parentPath && parentPath !== currentPath);
return (
<Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onCancel()}>
<DialogContent className="gap-4 sm:max-w-xl">
<DialogHeader>
<DialogTitle>Choose remote workspace</DialogTitle>
<DialogDescription>
Browse directories on the connected SSH host. No local folders are
shown here.
</DialogDescription>
</DialogHeader>
<div className="flex min-w-0 items-center gap-2">
<Button
aria-label="Remote home directory"
disabled={loading || currentPath === normalizedHome}
onClick={() => setRequestedPath(normalizedHome)}
size="icon"
variant="outline"
>
<Home />
</Button>
<Button
aria-label="Parent remote directory"
disabled={loading || !canGoUp}
onClick={() => parentPath && setRequestedPath(parentPath)}
size="icon"
variant="outline"
>
<ArrowUp />
</Button>
<p
className="min-w-0 flex-1 truncate rounded-md border bg-muted/30 px-3 py-2 font-mono text-xs"
title={currentPath}
>
{currentPath}
</p>
<Button
aria-label="Refresh remote directories"
disabled={loading}
onClick={() => setReloadVersion((version) => version + 1)}
size="icon"
variant="outline"
>
<RefreshCw className={loading ? "animate-spin" : undefined} />
</Button>
</div>
<div className="min-h-56 rounded-md border p-1.5">
{loading ? (
<div className="flex h-52 items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading remote directories
</div>
) : error ? (
<div className="flex h-52 flex-col items-center justify-center gap-2 px-6 text-center text-sm text-destructive">
<CircleAlert className="size-5" />
{error}
</div>
) : directories.length === 0 ? (
<div className="flex h-52 items-center justify-center text-sm text-muted-foreground">
No subdirectories
</div>
) : (
<div className="flex max-h-56 flex-col gap-0.5 overflow-y-auto">
{directories.map((entry) => {
return (
<Button
className="h-auto w-full justify-start gap-2 px-2 py-2 text-left"
key={entry.path}
onClick={() => setRequestedPath(entry.path)}
variant="ghost"
>
<Folder className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate text-sm">
{entry.name}
</span>
</Button>
);
})}
</div>
)}
</div>
{truncated && !loading && !error ? (
<p className="text-xs text-muted-foreground">
Only the first directories are shown. Open a folder to continue
browsing.
</p>
) : null}
<DialogFooter>
<Button onClick={onCancel} variant="outline">
Cancel
</Button>
<Button
disabled={loading || Boolean(error)}
onClick={() => onSelect(currentPath)}
>
Use this folder
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,11 +1,38 @@
// @vitest-environment jsdom
import type { ComponentProps } from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WorkspaceProvider } from "@/contexts/workspace-context";
import { WelcomeScreen } from "./welcome-chat";
const { invokeMock, subscribeMock, accountRef } = vi.hoisted(() => ({
invokeMock: vi.fn(
async (_command: string, _args?: unknown) => ({}) as unknown,
),
subscribeMock: vi.fn(
(_eventName: string, _handler: (payload: unknown) => void) => () =>
undefined,
),
accountRef: { user: null as { id: string } | null },
}));
vi.mock("@/lib/desktop-client", () => ({
desktopClient: {
invoke: invokeMock,
subscribe: subscribeMock,
},
openExternalUrl: vi.fn(async () => undefined),
}));
vi.mock("@/contexts/account-context", () => ({
useAccount: () => ({
user: accountRef.user,
refreshAccount: vi.fn(async () => undefined),
}),
}));
let container: HTMLDivElement;
let root: Root;
@@ -36,6 +63,7 @@ async function renderWelcomeScreen({
current: "main",
branches: ["main"],
})),
...cloudProps
}: {
workspaceRoot: string;
workspaces: string[];
@@ -45,7 +73,7 @@ async function renderWelcomeScreen({
current: string;
branches: string[];
}>;
}): Promise<void> {
} & Partial<ComponentProps<typeof WelcomeScreen>>): Promise<void> {
await act(async () => {
root.render(
<WorkspaceProvider
@@ -66,6 +94,7 @@ async function renderWelcomeScreen({
gitBranch={gitBranch}
onListGitBranches={onListGitBranches}
onSwitchGitBranch={vi.fn(async () => true)}
{...cloudProps}
/>
</WorkspaceProvider>,
);
@@ -86,6 +115,20 @@ async function clickButton(text: string, last = false): Promise<void> {
}
describe("WelcomeScreen", () => {
it("keeps the welcome composer in the upper portion of the page", async () => {
await renderWelcomeScreen({
workspaceRoot: "/projects/project-1",
workspaces: ["/projects/project-1"],
});
const content = container.querySelector(".max-w-240");
expect(content?.className).toContain("pt-[clamp(4rem,14vh,9rem)]");
expect(content?.className).not.toContain("26vh");
const composerWrapper = content?.querySelector(".mt-4");
expect(composerWrapper?.classList.contains("min-w-0")).toBe(true);
expect(composerWrapper?.classList.contains("max-w-full")).toBe(true);
});
// Prompt suggestions (quick-action cards, including "Review changes") are
// temporarily disabled while we improve them; see welcome-chat.tsx.
// Re-enable these tests when the suggestions come back.
@@ -235,6 +278,117 @@ describe("WelcomeScreen", () => {
}
});
it("keeps a repository picked from a freshly scoped list after an org switch", async () => {
accountRef.user = { id: "user-1" };
const repository = (owner: string) => ({
id: 7,
name: "repo",
fullName: `${owner}/repo`,
url: `https://github.com/${owner}/repo`,
defaultBranch: "main",
});
// Mount-time check sees the old org; every later fetch (the picker's
// included) sees the new org.
let fetches = 0;
invokeMock.mockImplementation(async (command: string) => {
if (command === "list_cloud_repositories") {
fetches += 1;
return {
connected: true,
connectUrl: "https://app.example/dashboard/integrations",
repositories: [repository(fetches === 1 ? "oldorg" : "neworg")],
};
}
return {};
});
const onRepoUrlChange = vi.fn();
const onCloudBranchChange = vi.fn();
const cloudProps = {
cloudAgentsEnabled: true,
executionTarget: "cloud" as const,
onRepoUrlChange,
onCloudBranchChange,
};
await renderWelcomeScreen({
workspaceRoot: "/projects/project-1",
workspaces: ["/projects/project-1"],
...cloudProps,
});
// Pick the new-org repository from the picker (whose fetch is scoped
// to the new org).
await clickButton("Select repository");
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
await clickButton("neworg/repo");
expect(onRepoUrlChange).toHaveBeenLastCalledWith(
"https://github.com/neworg/repo",
);
// The parent applies the selection; the stale-selection guard must
// not wipe it against the old org's snapshot.
onRepoUrlChange.mockClear();
onCloudBranchChange.mockClear();
await renderWelcomeScreen({
workspaceRoot: "/projects/project-1",
workspaces: ["/projects/project-1"],
...cloudProps,
repoUrl: "https://github.com/neworg/repo",
});
await act(async () => {
await Promise.resolve();
});
expect(onRepoUrlChange).not.toHaveBeenCalledWith("");
expect(onCloudBranchChange).not.toHaveBeenCalledWith("");
accountRef.user = null;
});
it("re-checks cloud setup when the sidecar broadcasts a scope change", async () => {
accountRef.user = { id: "user-1" };
let fetches = 0;
invokeMock.mockImplementation(async (command: string) => {
if (command === "list_cloud_repositories") {
fetches += 1;
return {
connected: true,
connectUrl: "https://app.example/dashboard/integrations",
repositories: [
{
id: 7,
name: "repo",
fullName: "org/repo",
url: "https://github.com/org/repo",
defaultBranch: "main",
},
],
};
}
return {};
});
await renderWelcomeScreen({
workspaceRoot: "/projects/project-1",
workspaces: ["/projects/project-1"],
cloudAgentsEnabled: true,
executionTarget: "cloud",
});
const scopeHandler = subscribeMock.mock.calls.find(
([eventName]) => eventName === "cloud_sessions_changed",
)?.[1] as ((payload: unknown) => void) | undefined;
expect(scopeHandler).toBeDefined();
const fetchesBefore = fetches;
await act(async () => {
scopeHandler?.({});
await Promise.resolve();
});
expect(fetches).toBeGreaterThan(fetchesBefore);
accountRef.user = null;
});
it("selects Just chat from the pathless workspace menu", async () => {
const selectChat = vi.fn(async () => true);
const onListGitBranches = vi.fn(async () => ({
@@ -1,122 +1,47 @@
"use client";
import { getClineEnvironmentConfig } from "@cline/shared/browser";
import { AgentAurora, AgentHeroHeading } from "@cline/ui";
import { Cloud } from "lucide-react";
import type { ReactNode } from "react";
import { useEffect } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useAccount } from "@/contexts/account-context";
import { useWorkspace } from "@/contexts/workspace-context";
import {
type CloudBranchListOptions,
type CloudBranchListResult,
type CloudRepositoryListResult,
normalizeCloudRepositoryUrl,
} from "@/lib/cloud-repositories";
import { desktopClient } from "@/lib/desktop-client";
import { invalidateProviderCatalogCache } from "@/lib/provider-model-catalog";
import { cn } from "@/lib/utils";
import {
CloudOnboardingCard,
type CloudOnboardingVariant,
} from "./cloud-onboarding";
import { SessionContent } from "./session-content";
import { WelcomeWorkspaceControls } from "./welcome-workspace-controls";
// Prompt suggestions (including "Review changes") are temporarily disabled
// while we improve them. To re-enable, uncomment the block below plus the
// `AgentQuickActions` render in `WelcomeScreen`, restore the commented-out
// imports (`isChatWorkspacePath`, `AgentQuickAction`, `AgentQuickActions`,
// `useMemo`) and the `onStartChat`/`quickActions` props, and re-enable the
// suggestion tests in `welcome-chat.test.tsx`.
//
// import { isChatWorkspacePath } from "@cline/shared/browser";
// import { type AgentQuickAction, AgentQuickActions } from "@cline/ui";
// import { useMemo } from "react";
//
// /** Code-centric starters, shown only when the folder is a git repository. */
// const DEVELOPER_QUICK_ACTIONS: AgentQuickAction[] = [
// {
// id: "review-changes",
// label: "Review changes",
// description: "Review the current changes and call out anything risky.",
// value: "Review the current changes and call out anything risky.",
// },
// {
// id: "check-build",
// label: "Check for build errors",
// description: "Run the relevant checks and help me fix any failures.",
// value: "Check this project for build errors and help me fix any failures.",
// },
// ];
//
// /**
// * General-purpose starters for a plain (non-git) folder — phrased around the
// * files the agent can see, with no developer vocabulary.
// */
// const FOLDER_QUICK_ACTIONS: AgentQuickAction[] = [
// {
// id: "summarize-folder",
// label: "Summarize this folder",
// description: "Get a plain-language overview of the files here.",
// value:
// "Look through the files in this folder and give me a plain-language summary of what's here.",
// },
// {
// id: "organize-files",
// label: "Organize these files",
// description: "Tidy up names and structure, with your approval.",
// value:
// "Help me organize this folder: suggest a tidy structure and clearer file names, and check with me before moving anything.",
// },
// {
// id: "draft-document",
// label: "Draft a document",
// description: "Start a new doc with a first draft you can edit.",
// value:
// "Help me draft a new document in this folder. Ask me a few questions about what it should cover, then write a first draft.",
// },
// ];
//
// /** Starters for chat with no folder selected at all. */
// const CHAT_QUICK_ACTIONS: AgentQuickAction[] = [
// {
// id: "draft-document",
// label: "Draft a document",
// description: "Start a new doc with a first draft you can edit.",
// value:
// "Help me draft a document. Ask me a few questions about what it should cover, then write a first draft.",
// },
// {
// id: "research-topic",
// label: "Research a topic",
// description: "Gather the key facts and sum them up.",
// value:
// "Research a topic for me: ask me what I want to learn about, then summarize the key points in plain language.",
// },
// {
// id: "plan-something",
// label: "Plan something",
// description: "Break a goal into clear, doable steps.",
// value:
// "Help me plan something. Ask me what I'm trying to get done, then break it into clear steps.",
// },
// ];
//
// /**
// * Picks starter suggestions that match what the user actually opened: code
// * cards only make sense inside a git repo; a plain folder gets file-oriented
// * cards; no folder at all gets folderless general-purpose cards.
// *
// * `gitBranch` is `null` while branch discovery for the selected folder is
// * still pending; no cards are suggested until the folder is classified so a
// * git repo never flashes the plain-folder set (or vice versa).
// */
// export function defaultQuickActionsForContext({
// workspaceRoot,
// gitBranch,
// }: {
// workspaceRoot: string;
// gitBranch: string | null;
// }): AgentQuickAction[] {
// const isChatWorkspace =
// !workspaceRoot.trim() || isChatWorkspacePath(workspaceRoot);
// if (isChatWorkspace) {
// return CHAT_QUICK_ACTIONS;
// }
// if (gitBranch === null) {
// return [];
// }
// if (gitBranch !== "no-git") {
// return DEVELOPER_QUICK_ACTIONS;
// }
// return FOLDER_QUICK_ACTIONS;
// }
// Used only until the API's connectUrl arrives (or when it is blank), so a
// staging/local build still points at its own dashboard.
const FALLBACK_CONNECT_URL = `${getClineEnvironmentConfig().appBaseUrl}/dashboard/integrations`;
// The dashboard hand-off happens in the browser, so re-check often enough
// that the panel flips to ready shortly after the user finishes there.
const CLOUD_SETUP_POLL_INTERVAL_MS = 6_000;
type CloudSetupState = {
status:
| "unknown"
| "checking"
| "ready"
| "not_connected"
| "no_repositories"
| "error";
connectUrl: string;
/** Normalized URLs of repositories the account can currently access. */
repositoryUrls: string[];
};
export function WelcomeScreen({
active,
@@ -126,6 +51,14 @@ export function WelcomeScreen({
gitBranch,
onListGitBranches,
onSwitchGitBranch,
executionTarget = "local",
repoUrl = "",
cloudBranch = "",
onExecutionTargetChange = () => undefined,
onRepoUrlChange = () => undefined,
onCloudBranchChange = () => undefined,
cloudAgentsEnabled = false,
environmentSelector,
}: {
active: boolean;
body: ReactNode;
@@ -136,7 +69,25 @@ export function WelcomeScreen({
gitBranch: string | null;
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
onSwitchGitBranch: (branch: string) => Promise<boolean>;
executionTarget?: "local" | "cloud";
repoUrl?: string;
cloudBranch?: string;
onExecutionTargetChange?: (target: "local" | "cloud") => void;
onRepoUrlChange?: (repoUrl: string) => void;
onCloudBranchChange?: (branch: string) => void;
cloudAgentsEnabled?: boolean;
environmentSelector?: ReactNode;
}) {
const { user, refreshAccount } = useAccount();
const [signingIn, setSigningIn] = useState(false);
const [signInError, setSignInError] = useState<string | null>(null);
const [cloudSetup, setCloudSetup] = useState<CloudSetupState>({
status: "unknown",
connectUrl: FALLBACK_CONNECT_URL,
repositoryUrls: [],
});
const [cloudSetupChecking, setCloudSetupChecking] = useState(false);
const cloudSetupRequestRef = useRef(0);
const {
workspaceRoot,
workspaces,
@@ -145,16 +96,181 @@ export function WelcomeScreen({
pickWorkspaceDirectory,
selectChat,
} = useWorkspace();
// Suggestions are disabled for now; see the note above.
// const defaultActions = useMemo(
// () => defaultQuickActionsForContext({ workspaceRoot, gitBranch }),
// [workspaceRoot, gitBranch],
// );
// const actions = quickActions.length > 0 ? quickActions : defaultActions;
const applyCloudSetupResult = useCallback(
(result: CloudRepositoryListResult) => {
setCloudSetup({
status:
result.connected === false
? "not_connected"
: result.repositories.length === 0
? "no_repositories"
: "ready",
connectUrl: result.connectUrl?.trim() || FALLBACK_CONNECT_URL,
repositoryUrls: result.repositories.map((repository) =>
normalizeCloudRepositoryUrl(repository.url),
),
});
},
[],
);
const fetchCloudRepositories = useCallback(
() =>
desktopClient.invoke<CloudRepositoryListResult>(
"list_cloud_repositories",
{},
),
[],
);
const listCloudRepositories = useCallback(async () => {
// Every successful repository fetch — the picker's own load included —
// refreshes the snapshot the stale-selection guard below compares
// against. Without this, an org switch leaves the guard holding the
// old scope's list and it wipes a repository just picked from the new
// scope's correctly filtered picker.
const requestId = ++cloudSetupRequestRef.current;
const result = await fetchCloudRepositories();
if (cloudSetupRequestRef.current === requestId) {
applyCloudSetupResult(result);
}
return result;
}, [applyCloudSetupResult, fetchCloudRepositories]);
const listCloudBranches = useCallback(
async (repositoryId: number, options: CloudBranchListOptions = {}) => {
const result = await desktopClient.invoke<{
available?: boolean;
branches?: string[];
nextToken?: string;
}>("list_cloud_branches", { repositoryId, ...options });
return {
available: result.available !== false,
branches: Array.isArray(result.branches) ? result.branches : [],
nextToken:
typeof result.nextToken === "string" ? result.nextToken : undefined,
} satisfies CloudBranchListResult;
},
[],
);
const openExternalUrl = useCallback(async (url: string) => {
await desktopClient.invoke("open_external_url", { url });
}, []);
const cloudModeActive =
active && cloudAgentsEnabled && executionTarget === "cloud";
const signedIn = Boolean(user);
const accountUserId = user?.id ?? null;
// Read by the poll interval without making the state updater impure or
// re-subscribing the effect on every status change.
const cloudSetupStatusRef = useRef(cloudSetup.status);
cloudSetupStatusRef.current = cloudSetup.status;
const checkCloudSetup = useCallback(async () => {
const requestId = ++cloudSetupRequestRef.current;
setCloudSetupChecking(true);
try {
const result = await fetchCloudRepositories();
if (cloudSetupRequestRef.current !== requestId) return;
applyCloudSetupResult(result);
} catch {
if (cloudSetupRequestRef.current !== requestId) return;
setCloudSetup((prev) => ({ ...prev, status: "error" }));
} finally {
if (cloudSetupRequestRef.current === requestId) {
setCloudSetupChecking(false);
}
}
}, [applyCloudSetupResult, fetchCloudRepositories]);
// Check GitHub connectivity whenever the cloud composer becomes relevant
// or the signed-in account changes, and keep watching while onboarding is
// on screen: the connect flow finishes in the browser, so the panel must
// notice on its own.
useEffect(() => {
void accountUserId;
if (!cloudModeActive || !signedIn) return;
setCloudSetup((prev) =>
prev.status === "unknown" ? { ...prev, status: "checking" } : prev,
);
void checkCloudSetup();
const handleFocus = () => void checkCloudSetup();
window.addEventListener("focus", handleFocus);
const interval = window.setInterval(() => {
const status = cloudSetupStatusRef.current;
if (status === "not_connected" || status === "no_repositories") {
void checkCloudSetup();
}
}, CLOUD_SETUP_POLL_INTERVAL_MS);
return () => {
window.removeEventListener("focus", handleFocus);
window.clearInterval(interval);
};
}, [accountUserId, checkCloudSetup, cloudModeActive, signedIn]);
// Account/organization switches re-scope the repository list on the
// sidecar side; refresh the setup snapshot immediately instead of waiting
// for a focus event or the onboarding poll (which stops in "ready").
useEffect(() => {
if (!cloudModeActive || !signedIn) return;
return desktopClient.subscribe("cloud_sessions_changed", () => {
void checkCloudSetup();
});
}, [checkCloudSetup, cloudModeActive, signedIn]);
useEffect(() => {
if (active) void refreshWorkspaces();
}, [active, refreshWorkspaces]);
if (active && executionTarget === "local") void refreshWorkspaces();
}, [active, executionTarget, refreshWorkspaces]);
// A previously selected repository can disappear from the account's reach
// (GitHub App access revoked, account/org switched). Clear the stale
// selection so the "Repository required" gate re-engages instead of
// letting the send fail server-side after the fact.
useEffect(() => {
if (!cloudModeActive || cloudSetup.status === "unknown") return;
if (cloudSetup.status === "error" || cloudSetup.status === "checking") {
return;
}
const normalized = normalizeCloudRepositoryUrl(repoUrl);
if (!normalized) return;
if (!cloudSetup.repositoryUrls.includes(normalized)) {
onRepoUrlChange("");
onCloudBranchChange("");
}
}, [
cloudModeActive,
cloudSetup,
onCloudBranchChange,
onRepoUrlChange,
repoUrl,
]);
const signIn = async () => {
if (signingIn) return;
setSigningIn(true);
setSignInError(null);
try {
await desktopClient.invoke("run_provider_oauth_login", {
provider: "cline",
});
invalidateProviderCatalogCache();
await refreshAccount();
} catch (error) {
setSignInError(error instanceof Error ? error.message : String(error));
} finally {
setSigningIn(false);
}
};
const cloudOnboardingVariant: CloudOnboardingVariant | null = !cloudModeActive
? null
: !signedIn
? "signed_out"
: cloudSetup.status === "not_connected"
? "not_connected"
: cloudSetup.status === "no_repositories"
? "no_repositories"
: cloudSetup.status === "error"
? "error"
: null;
const showCloudOnboarding = cloudOnboardingVariant !== null;
return (
<div
@@ -175,7 +291,7 @@ export function WelcomeScreen({
<div
className={cn(
active
? "mx-auto flex min-h-full w-full max-w-240 flex-col justify-center px-6 py-16 max-[720px]:px-4 max-[720px]:py-10"
? "mx-auto flex w-full max-w-240 flex-col px-6 pb-32 pt-[clamp(4rem,14vh,9rem)] max-[720px]:px-4 max-[720px]:pb-20 max-[720px]:pt-16"
: "contents",
)}
>
@@ -183,18 +299,38 @@ export function WelcomeScreen({
<div className="cline-view-enter">
<AgentHeroHeading />
<div className="mt-11 flex min-w-0 items-center">
<div className="mt-11 flex min-w-0 items-center gap-2">
{environmentSelector}
<WelcomeWorkspaceControls
cloudBranch={cloudBranch}
cloudControlsHidden={showCloudOnboarding}
cloudEnabled={cloudAgentsEnabled}
currentBranch={gitBranch}
executionTarget={executionTarget}
onCloudBranchChange={onCloudBranchChange}
onListCloudBranches={listCloudBranches}
onListCloudRepositories={listCloudRepositories}
onListGitBranches={onListGitBranches}
onOpenExternalUrl={openExternalUrl}
onPickWorkspaceDirectory={pickWorkspaceDirectory}
onRefreshWorkspaces={refreshWorkspaces}
onExecutionTargetChange={onExecutionTargetChange}
onRepoUrlChange={onRepoUrlChange}
onSignIn={signIn}
onSelectChat={selectChat}
onSwitchGitBranch={onSwitchGitBranch}
onSwitchWorkspace={switchWorkspace}
repoUrl={repoUrl}
signedIn={signedIn}
signingIn={signingIn}
workspaceRoot={workspaceRoot}
workspaces={workspaces}
/>
{signInError ? (
<p className="mt-2 text-xs text-destructive">
Sign in failed: {signInError}
</p>
) : null}
</div>
</div>
) : null}
@@ -210,23 +346,40 @@ export function WelcomeScreen({
{body}
</div>
{active && notice ? notice : null}
{active && notice && !showCloudOnboarding ? notice : null}
{active && showCloudOnboarding ? (
<div className="mt-4 w-full">
<CloudOnboardingCard
checking={cloudSetupChecking}
onConnect={() => void openExternalUrl(cloudSetup.connectUrl)}
onRefresh={() => void checkCloudSetup()}
onSignIn={() => void signIn()}
signingIn={signingIn}
variant={cloudOnboardingVariant}
/>
</div>
) : null}
<div
className={active ? "mt-4 w-full" : "z-20 shrink-0 px-6 pb-6"}
className={cn(
active
? "mt-4 min-w-0 w-full max-w-full"
: "z-20 shrink-0 px-6 pb-6",
active && showCloudOnboarding && "hidden",
)}
key="persistent-composer"
>
{active ? composer : <SessionContent>{composer}</SessionContent>}
</div>
{/* Prompt suggestions are disabled for now; see the note above.
{active ? (
<AgentQuickActions
actions={actions}
className="cline-view-enter mt-11"
onSelect={(action) => onStartChat(action.value)}
/>
) : null} */}
{active && cloudModeActive && !showCloudOnboarding ? (
<p className="mt-3 flex items-center justify-center gap-1.5 text-center text-xs text-muted-foreground">
<Cloud aria-hidden="true" className="size-3 shrink-0" />
Cloud sessions run on a secure sandbox, work on a branch, and keep
going even when you close the app.
</p>
) : null}
</div>
</div>
</div>
@@ -1,5 +1,6 @@
// @vitest-environment jsdom
import type { ComponentProps } from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -10,6 +11,16 @@ let root: Root;
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const values = new Map<string, string>();
Object.defineProperty(window, "localStorage", {
configurable: true,
value: {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
clear: () => values.clear(),
},
});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
@@ -19,36 +30,509 @@ afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
async function renderControls({
onSwitchWorkspace = vi.fn(async () => true),
onPickWorkspaceDirectory = vi.fn(async (): Promise<string | null> => null),
}: {
onSwitchWorkspace?: (workspacePath: string) => Promise<boolean>;
onPickWorkspaceDirectory?: (initialPath?: string) => Promise<string | null>;
} = {}): Promise<void> {
function button(text: string): HTMLButtonElement {
const match = [
...container.querySelectorAll<HTMLButtonElement>("button"),
].find((candidate) => candidate.textContent?.includes(text));
expect(match).toBeDefined();
return match as HTMLButtonElement;
}
async function click(target: HTMLElement) {
await act(async () => {
root.render(
<WelcomeWorkspaceControls
currentBranch="main"
onListGitBranches={vi.fn(async () => ({
current: "main",
branches: ["main"],
}))}
onPickWorkspaceDirectory={onPickWorkspaceDirectory}
onRefreshWorkspaces={vi.fn(async () => undefined)}
onSelectChat={vi.fn(async () => true)}
onSwitchGitBranch={vi.fn(async () => true)}
onSwitchWorkspace={onSwitchWorkspace}
workspaceRoot="/projects/project-1"
workspaces={["/projects/project-1"]}
/>,
);
target.click();
await Promise.resolve();
});
}
function renderControls(
overrides: Partial<ComponentProps<typeof WelcomeWorkspaceControls>> = {},
) {
const props: ComponentProps<typeof WelcomeWorkspaceControls> = {
cloudEnabled: true,
executionTarget: "local",
repoUrl: "",
cloudBranch: "",
onCloudBranchChange: vi.fn(),
signedIn: true,
signingIn: false,
onExecutionTargetChange: vi.fn(),
onRepoUrlChange: vi.fn(),
onListCloudRepositories: vi.fn(async () => ({
connected: true,
connectUrl: "https://app.example/dashboard/integrations",
repositories: [
{
id: 42,
name: "cline",
fullName: "cline/cline",
url: "https://github.com/cline/cline",
defaultBranch: "main",
},
],
})),
onListCloudBranches: vi.fn(async () => ({
available: true,
branches: ["main", "feature/cloud"],
})),
onOpenExternalUrl: vi.fn(async () => undefined),
onSignIn: vi.fn(),
workspaceRoot: "/projects/cline",
workspaces: ["/projects/cline"],
onRefreshWorkspaces: vi.fn(async () => undefined),
onSwitchWorkspace: vi.fn(async () => true),
onPickWorkspaceDirectory: vi.fn(async () => null),
onSelectChat: vi.fn(async () => true),
currentBranch: "main",
onListGitBranches: vi.fn(async () => ({
current: "main",
branches: ["main"],
})),
onSwitchGitBranch: vi.fn(async () => true),
...overrides,
};
act(() => root.render(<WelcomeWorkspaceControls {...props} />));
return props;
}
describe("WelcomeWorkspaceControls cloud mode", () => {
it("selects Cloud from the same workspace control row", async () => {
const props = renderControls();
await click(button("Cloud"));
expect(props.onExecutionTargetChange).toHaveBeenCalledWith("cloud");
});
it("hides the Local/Cloud selector when the feature flag is off", () => {
renderControls({ cloudEnabled: false });
const buttons = [...container.querySelectorAll("button")].map(
(candidate) => candidate.textContent ?? "",
);
expect(buttons.some((text) => text.includes("Cloud"))).toBe(false);
// Local workspace controls still render.
expect(buttons.some((text) => text.includes("cline"))).toBe(true);
});
it("requires sign in before choosing a cloud repository", async () => {
const props = renderControls({
executionTarget: "cloud",
signedIn: false,
});
expect(container.textContent).toContain("Sign in to use Cloud");
expect(container.textContent).not.toContain("Select repository");
await click(button("Sign in to use Cloud"));
expect(props.onSignIn).toHaveBeenCalledOnce();
});
it("selects a connected GitHub repository and its default branch", async () => {
const onRepoUrlChange = vi.fn();
const onCloudBranchChange = vi.fn();
const props = renderControls({
executionTarget: "cloud",
onRepoUrlChange,
onCloudBranchChange,
});
await act(async () => {
button("Select repository").click();
await Promise.resolve();
await Promise.resolve();
});
expect(props.onListCloudRepositories).toHaveBeenCalledOnce();
await click(button("cline/cline"));
expect(onRepoUrlChange).toHaveBeenLastCalledWith(
"https://github.com/cline/cline",
);
expect(onCloudBranchChange).toHaveBeenLastCalledWith("main");
});
it("loads and selects a branch for the connected repository", async () => {
const onCloudBranchChange = vi.fn();
const props = renderControls({
executionTarget: "cloud",
onCloudBranchChange,
});
await act(async () => {
button("Select repository").click();
await Promise.resolve();
await Promise.resolve();
});
await click(button("cline/cline"));
renderControls({
executionTarget: "cloud",
repoUrl: "https://github.com/cline/cline",
cloudBranch: "main",
onCloudBranchChange,
onListCloudRepositories: props.onListCloudRepositories,
onListCloudBranches: props.onListCloudBranches,
});
await act(async () => {
button("main").click();
await Promise.resolve();
await Promise.resolve();
});
expect(props.onListCloudBranches).toHaveBeenCalledWith(42);
await click(button("feature/cloud"));
expect(onCloudBranchChange).toHaveBeenLastCalledWith("feature/cloud");
});
it("loads additional branch pages as the user scrolls", async () => {
let intersectionCallback:
| ((entries: IntersectionObserverEntry[]) => void)
| undefined;
vi.stubGlobal(
"IntersectionObserver",
class {
constructor(callback: (entries: IntersectionObserverEntry[]) => void) {
intersectionCallback = callback;
}
observe() {}
disconnect() {}
},
);
const onListCloudBranches = vi.fn(
async (_repositoryId: number, options?: { cursor?: string }) =>
options?.cursor
? { available: true, branches: ["feature/cloud"], nextToken: "" }
: { available: true, branches: ["main"], nextToken: "2" },
);
const props = renderControls({
executionTarget: "cloud",
onListCloudBranches,
});
await act(async () => {
button("Select repository").click();
await Promise.resolve();
await Promise.resolve();
});
await click(button("cline/cline"));
renderControls({
executionTarget: "cloud",
repoUrl: "https://github.com/cline/cline",
cloudBranch: "main",
onListCloudRepositories: props.onListCloudRepositories,
onListCloudBranches,
});
await vi.waitFor(() =>
expect(onListCloudBranches).toHaveBeenCalledWith(42),
);
await click(button("main"));
await vi.waitFor(() => expect(intersectionCallback).toBeDefined());
await act(async () => {
intersectionCallback?.([
{ isIntersecting: true } as IntersectionObserverEntry,
]);
});
await vi.waitFor(() =>
expect(onListCloudBranches).toHaveBeenCalledWith(42, { cursor: "2" }),
);
await vi.waitFor(() =>
expect(container.textContent).toContain("feature/cloud"),
);
});
it("recovers pagination when the search changes while a page fetch is in flight", async () => {
let intersectionCallback:
| ((entries: IntersectionObserverEntry[]) => void)
| undefined;
vi.stubGlobal(
"IntersectionObserver",
class {
constructor(callback: (entries: IntersectionObserverEntry[]) => void) {
intersectionCallback = callback;
}
observe() {}
disconnect() {}
},
);
let releaseHungPage:
| ((result: {
available: boolean;
branches: string[];
nextToken?: string;
}) => void)
| undefined;
let cursorFetches = 0;
const onListCloudBranches = vi.fn(
async (
_repositoryId: number,
options?: { cursor?: string; query?: string },
) => {
if (options?.cursor) {
cursorFetches += 1;
if (cursorFetches === 1) {
// First page fetch hangs until the test releases it.
return new Promise<{
available: boolean;
branches: string[];
nextToken?: string;
}>((resolve) => {
releaseHungPage = resolve;
});
}
return {
available: true,
branches: ["feature/cloud"],
nextToken: "",
};
}
if (options?.query) {
return {
available: true,
branches: ["feature/cloud"],
nextToken: "",
};
}
return { available: true, branches: ["main"], nextToken: "2" };
},
);
const props = renderControls({
executionTarget: "cloud",
onListCloudBranches,
});
await act(async () => {
button("Select repository").click();
await Promise.resolve();
await Promise.resolve();
});
await click(button("cline/cline"));
renderControls({
executionTarget: "cloud",
repoUrl: "https://github.com/cline/cline",
cloudBranch: "main",
onListCloudRepositories: props.onListCloudRepositories,
onListCloudBranches,
});
await vi.waitFor(() =>
expect(onListCloudBranches).toHaveBeenCalledWith(42),
);
await click(button("main"));
await vi.waitFor(() => expect(intersectionCallback).toBeDefined());
await act(async () => {
intersectionCallback?.([
{ isIntersecting: true } as IntersectionObserverEntry,
]);
});
await vi.waitFor(() => expect(releaseHungPage).toBeDefined());
// Type a search character while the page fetch is still in flight; the
// request key changes under it.
const search = container.querySelector<HTMLInputElement>(
'input[placeholder="Search branches…"]',
);
expect(search).not.toBeNull();
await act(async () => {
const valueSetter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)?.set;
valueSetter?.call(search, "feature");
search?.dispatchEvent(new Event("input", { bubbles: true }));
});
await vi.waitFor(() =>
expect(onListCloudBranches).toHaveBeenCalledWith(42, {
query: "feature",
}),
);
// The stale page fetch settles after the key changed; its results are
// discarded but the loading flag must be released.
await act(async () => {
releaseHungPage?.({
available: true,
branches: ["stale/page"],
nextToken: "3",
});
await Promise.resolve();
});
expect(container.textContent).not.toContain("stale/page");
// Clear the search and scroll again: pagination must still work. Drop
// the captured observer first: the effect only re-creates one after
// the post-clear base list applied (nextToken set again), so waiting
// for it guarantees the scroll uses fresh state instead of racing the
// base fetch with a stale closure.
intersectionCallback = undefined;
await act(async () => {
const valueSetter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)?.set;
valueSetter?.call(search, "");
search?.dispatchEvent(new Event("input", { bubbles: true }));
});
await vi.waitFor(() => expect(intersectionCallback).toBeDefined());
await act(async () => {
intersectionCallback?.([
{ isIntersecting: true } as IntersectionObserverEntry,
]);
});
await vi.waitFor(() => expect(cursorFetches).toBe(2));
await vi.waitFor(() =>
expect(container.textContent).toContain("feature/cloud"),
);
});
it("searches branches through the server", async () => {
const onListCloudBranches = vi.fn(
async (_repositoryId: number, options?: { query?: string }) =>
options?.query
? { available: true, branches: ["feature/cloud"] }
: { available: true, branches: ["main"] },
);
const props = renderControls({
executionTarget: "cloud",
onListCloudBranches,
});
await act(async () => {
button("Select repository").click();
await Promise.resolve();
await Promise.resolve();
});
await click(button("cline/cline"));
renderControls({
executionTarget: "cloud",
repoUrl: "https://github.com/cline/cline",
cloudBranch: "main",
onListCloudRepositories: props.onListCloudRepositories,
onListCloudBranches,
});
await vi.waitFor(() =>
expect(onListCloudBranches).toHaveBeenCalledWith(42),
);
await click(button("main"));
const search = container.querySelector<HTMLInputElement>(
'input[placeholder="Search branches…"]',
);
expect(search).not.toBeNull();
await act(async () => {
const valueSetter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)?.set;
valueSetter?.call(search, "feature");
search?.dispatchEvent(new Event("input", { bubbles: true }));
});
await vi.waitFor(() =>
expect(onListCloudBranches).toHaveBeenCalledWith(42, {
query: "feature",
}),
);
await vi.waitFor(() =>
expect(container.textContent).toContain("feature/cloud"),
);
});
it("uses and labels the repository default when branch selection is unavailable", async () => {
const onCloudBranchChange = vi.fn();
const onListCloudBranches = vi.fn(async () => ({
available: false,
branches: [],
}));
const props = renderControls({
executionTarget: "cloud",
onCloudBranchChange,
onListCloudBranches,
});
await act(async () => {
button("Select repository").click();
await Promise.resolve();
await Promise.resolve();
});
await click(button("cline/cline"));
renderControls({
executionTarget: "cloud",
repoUrl: "https://github.com/cline/cline",
cloudBranch: "main",
onCloudBranchChange,
onListCloudRepositories: props.onListCloudRepositories,
onListCloudBranches,
});
await act(async () => {
await vi.waitFor(() => {
expect(onListCloudBranches).toHaveBeenCalledWith(42);
expect(container.textContent).toContain("main (default)");
});
});
const branchButton = button("main (default)");
expect(branchButton.disabled).toBe(true);
expect(branchButton.title).toBe(
"Using the repository default branch: main",
);
expect(container.textContent).not.toContain("Could not load branches.");
});
it("uses a clear fallback label before default-branch metadata is deployed", async () => {
const onListCloudRepositories = vi.fn(async () => ({
connected: true,
connectUrl: "https://app.example/dashboard/integrations",
repositories: [
{
id: 42,
name: "cline",
fullName: "cline/cline",
url: "https://github.com/cline/cline",
defaultBranch: "",
},
],
}));
const onListCloudBranches = vi.fn(async () => ({
available: false,
branches: [],
}));
const props = renderControls({
executionTarget: "cloud",
onListCloudRepositories,
onListCloudBranches,
});
await act(async () => {
button("Select repository").click();
await Promise.resolve();
await Promise.resolve();
});
await click(button("cline/cline"));
renderControls({
executionTarget: "cloud",
repoUrl: "https://github.com/cline/cline",
cloudBranch: "",
onListCloudRepositories,
onListCloudBranches,
onCloudBranchChange: props.onCloudBranchChange,
});
await vi.waitFor(() => {
expect(button("Default branch").disabled).toBe(true);
});
expect(container.textContent).not.toContain("Select branch… (default)");
});
it("links to GitHub setup when no integration is connected", async () => {
const props = renderControls({
executionTarget: "cloud",
onListCloudRepositories: vi.fn(async () => ({
connected: false,
connectUrl: "https://app.example/dashboard/integrations",
repositories: [],
})),
});
await act(async () => {
button("Select repository").click();
await Promise.resolve();
await Promise.resolve();
});
expect(container.textContent).toContain(
"Connect GitHub to select a repository.",
);
await click(button("Connect GitHub"));
expect(props.onOpenExternalUrl).toHaveBeenCalledWith(
"https://app.example/dashboard/integrations",
);
});
});
async function clickButton(text: string): Promise<void> {
const button = [
...container.querySelectorAll<HTMLButtonElement>("button"),
@@ -61,7 +545,9 @@ async function clickButton(text: string): Promise<void> {
}
async function openWorkspaceMenu(): Promise<void> {
await clickButton("project-1");
await clickButton(
container.textContent?.includes("project-1") ? "project-1" : "cline",
);
}
async function typeInSearch(value: string): Promise<void> {
@@ -120,23 +606,13 @@ describe("WelcomeWorkspaceControls manual path entry", () => {
// wipe the menu's typed path or a visible error while it is open.
const onSwitchWorkspace = vi.fn(async () => false);
const render = async () => {
renderControls({
onRefreshWorkspaces: vi.fn(async () => undefined),
onSwitchWorkspace,
workspaceRoot: "/projects/project-1",
workspaces: ["/projects/project-1"],
});
await act(async () => {
root.render(
<WelcomeWorkspaceControls
currentBranch="main"
onListGitBranches={vi.fn(async () => ({
current: "main",
branches: ["main"],
}))}
onPickWorkspaceDirectory={vi.fn(async () => null)}
onRefreshWorkspaces={vi.fn(async () => undefined)}
onSelectChat={vi.fn(async () => true)}
onSwitchGitBranch={vi.fn(async () => true)}
onSwitchWorkspace={onSwitchWorkspace}
workspaceRoot="/projects/project-1"
workspaces={["/projects/project-1"]}
/>,
);
await Promise.resolve();
});
};
@@ -173,26 +649,18 @@ describe("WelcomeWorkspaceControls manual path entry", () => {
async function renderBranchChipControls(overrides: {
currentBranch: string;
}): Promise<void> {
renderControls({
cloudEnabled: false,
currentBranch: overrides.currentBranch,
onListGitBranches: vi.fn(async () => ({
current: overrides.currentBranch,
branches:
overrides.currentBranch === "no-git" ? [] : [overrides.currentBranch],
})),
workspaceRoot: "/home/beatrix/recipes",
workspaces: ["/home/beatrix/recipes"],
});
await act(async () => {
root.render(
<WelcomeWorkspaceControls
currentBranch={overrides.currentBranch}
onListGitBranches={vi.fn(async () => ({
current: overrides.currentBranch,
branches:
overrides.currentBranch === "no-git"
? []
: [overrides.currentBranch],
}))}
onPickWorkspaceDirectory={vi.fn(async () => null)}
onRefreshWorkspaces={vi.fn(async () => undefined)}
onSelectChat={vi.fn(async () => true)}
onSwitchGitBranch={vi.fn(async () => true)}
onSwitchWorkspace={vi.fn(async () => true)}
workspaceRoot="/home/beatrix/recipes"
workspaces={["/home/beatrix/recipes"]}
/>,
);
await Promise.resolve();
});
}
@@ -3,15 +3,37 @@
import { isChatWorkspacePath } from "@cline/shared/browser";
import {
Check,
Cloud,
FilePlus2,
Folder,
GitBranch,
Github,
HardDrive,
LoaderCircle,
LogIn,
Plus,
RefreshCcw,
Search,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
type CloudBranchListOptions,
type CloudBranchListResult,
type CloudRepositoryListResult,
type CloudRepositoryOption,
cloudRepositoryLabel,
normalizeCloudRepositoryUrl,
preferredCloudBranch,
} from "@/lib/cloud-repositories";
import { cn } from "@/lib/utils";
import {
looksLikeFolderPath,
@@ -43,6 +65,454 @@ const TRIGGER_CLASS =
const PANEL_CLASS =
"absolute left-0 top-full z-50 mt-2 w-72 rounded-lg border border-border bg-popover shadow-xl";
function ExecutionTargetPicker({
executionTarget,
onChange,
}: {
executionTarget: "local" | "cloud";
onChange: (target: "local" | "cloud") => void;
}) {
return (
<fieldset className="inline-flex shrink-0 items-center rounded-md border border-border/70 bg-background/80 p-0.5">
<legend className="sr-only">Execution location</legend>
{(["local", "cloud"] as const).map((target) => {
const active = executionTarget === target;
const Icon = target === "local" ? HardDrive : Cloud;
return (
<button
aria-pressed={active}
className={cn(
"inline-flex items-center gap-1.5 rounded px-2.5 py-1 text-xs font-medium transition-colors",
active
? "bg-accent text-foreground shadow-xs"
: "text-muted-foreground hover:text-foreground",
)}
key={target}
onClick={() => onChange(target)}
type="button"
>
<Icon className="size-3" />
{target === "local" ? "Local" : "Cloud"}
</button>
);
})}
</fieldset>
);
}
function CloudRepositoryPicker({
open,
onToggle,
onClose,
repoUrl,
onSelect,
onRepositoriesLoaded,
onListRepositories,
onOpenExternalUrl,
}: {
open: boolean;
onToggle: () => void;
onClose: () => void;
repoUrl: string;
onSelect: (repository: CloudRepositoryOption) => void;
onRepositoriesLoaded: (repositories: CloudRepositoryOption[]) => void;
onListRepositories: () => Promise<CloudRepositoryListResult>;
onOpenExternalUrl: (url: string) => Promise<void>;
}) {
const [query, setQuery] = useState("");
const [reloadKey, setReloadKey] = useState(0);
const [result, setResult] = useState<CloudRepositoryListResult>();
const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
useEffect(() => {
if (!open) return;
void reloadKey;
let cancelled = false;
setStatus("loading");
void onListRepositories()
.then((next) => {
if (cancelled) return;
setResult(next);
onRepositoriesLoaded(next.repositories);
setStatus("idle");
})
.catch(() => {
if (!cancelled) setStatus("error");
});
return () => {
cancelled = true;
};
}, [onListRepositories, onRepositoriesLoaded, open, reloadKey]);
const repositories = result?.repositories ?? [];
const normalizedQuery = query.trim().toLowerCase();
const filteredRepositories = repositories.filter((repository) =>
`${repository.fullName} ${repository.name}`
.toLowerCase()
.includes(normalizedQuery),
);
return (
<div className="relative min-w-0">
<button
aria-expanded={open}
aria-haspopup="dialog"
className={cn(TRIGGER_CLASS, "min-w-0 max-w-full")}
onClick={onToggle}
title={repoUrl || "Select a connected GitHub repository"}
type="button"
>
<Github
aria-hidden="true"
className="size-4 shrink-0 text-muted-foreground"
/>
<span className="max-w-56 truncate">
{repoUrl
? cloudRepositoryLabel(repoUrl, "Cloud repo")
: "Select repository…"}
</span>
</button>
{open ? (
<div
className={PANEL_CLASS}
role="dialog"
aria-label="Cloud repository"
>
{result?.connected !== false ? (
<SearchInput
onChange={setQuery}
placeholder="Search repositories…"
value={query}
/>
) : null}
<div className="max-h-72 overflow-y-auto p-1.5">
{status === "loading" ? (
<PickerStatus icon="loading" message="Loading repositories…" />
) : status === "error" ? (
<PickerStatus message="Could not load repositories.">
<Button
onClick={() => setReloadKey((current) => current + 1)}
size="sm"
variant="ghost"
>
<RefreshCcw aria-hidden="true" className="size-3" />
Retry
</Button>
</PickerStatus>
) : result?.connected === false ? (
<PickerStatus message="Connect GitHub to select a repository.">
<Button
onClick={() => void onOpenExternalUrl(result.connectUrl)}
size="sm"
variant="ghost"
>
Connect GitHub
</Button>
</PickerStatus>
) : filteredRepositories.length === 0 ? (
<PickerStatus
message={
repositories.length === 0
? "No connected repositories."
: "No repositories found."
}
/>
) : (
filteredRepositories.map((repository) => (
<Button
className="w-full justify-start text-xs"
key={repository.id}
onClick={() => {
onSelect(repository);
onClose();
}}
title={repository.fullName}
variant="ghost"
>
<Github aria-hidden="true" className="size-3" />
<span className="truncate">{repository.fullName}</span>
{normalizeCloudRepositoryUrl(repoUrl) ===
normalizeCloudRepositoryUrl(repository.url) ? (
<Check aria-hidden="true" className="ml-auto size-3" />
) : null}
</Button>
))
)}
</div>
</div>
) : null}
</div>
);
}
function CloudBranchPicker({
open,
onToggle,
onClose,
repositoryId,
defaultBranch,
branch,
onBranchChange,
onListBranches,
}: {
open: boolean;
onToggle: () => void;
onClose: () => void;
repositoryId?: number;
defaultBranch: string;
branch: string;
onBranchChange: (branch: string) => void;
onListBranches: (
repositoryId: number,
options?: CloudBranchListOptions,
) => Promise<CloudBranchListResult>;
}) {
const [query, setQuery] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
const [reloadKey, setReloadKey] = useState(0);
const [branches, setBranches] = useState<string[]>([]);
const [nextToken, setNextToken] = useState("");
const [loadingMore, setLoadingMore] = useState(false);
const [loadMoreError, setLoadMoreError] = useState(false);
const [status, setStatus] = useState<
"idle" | "loading" | "error" | "unavailable"
>("idle");
const branchRef = useRef(branch);
const listRef = useRef<HTMLDivElement>(null);
const loadMoreRef = useRef<HTMLDivElement>(null);
const requestKeyRef = useRef("");
branchRef.current = branch;
useEffect(() => {
const timeout = window.setTimeout(
() => setDebouncedQuery(query.trim()),
250,
);
return () => window.clearTimeout(timeout);
}, [query]);
const searchPending = query.trim() !== debouncedQuery;
useEffect(() => {
if (!repositoryId) return;
const requestKey = `${repositoryId}:${debouncedQuery}:${reloadKey}`;
requestKeyRef.current = requestKey;
let cancelled = false;
setStatus("loading");
setLoadMoreError(false);
const request = debouncedQuery
? onListBranches(repositoryId, { query: debouncedQuery })
: onListBranches(repositoryId);
void request
.then((result) => {
if (cancelled || requestKeyRef.current !== requestKey) return;
if (!result.available) {
setBranches([]);
onBranchChange(defaultBranch);
setStatus("unavailable");
return;
}
setBranches(result.branches);
setNextToken(result.nextToken ?? "");
if (!branchRef.current) {
onBranchChange(preferredCloudBranch(result.branches, defaultBranch));
}
setStatus("idle");
})
.catch(() => {
if (!cancelled && requestKeyRef.current === requestKey) {
setStatus("error");
}
});
return () => {
cancelled = true;
};
}, [
debouncedQuery,
defaultBranch,
onBranchChange,
onListBranches,
reloadKey,
repositoryId,
]);
const loadMore = useCallback(async () => {
if (!repositoryId || !nextToken || loadingMore) return;
const requestKey = requestKeyRef.current;
setLoadingMore(true);
setLoadMoreError(false);
try {
const result = await onListBranches(repositoryId, {
cursor: nextToken,
query: debouncedQuery || undefined,
});
if (requestKeyRef.current !== requestKey) return;
setBranches((current) => [...new Set([...current, ...result.branches])]);
setNextToken(result.nextToken ?? "");
} catch {
if (requestKeyRef.current === requestKey) setLoadMoreError(true);
} finally {
// Reset unconditionally: only one page fetch can be in flight (the
// loadingMore guard above), so this always refers to that fetch. A
// key-guarded reset would leave loadingMore stuck true forever when
// the search query changes mid-fetch, permanently killing
// pagination for this picker.
setLoadingMore(false);
}
}, [debouncedQuery, loadingMore, nextToken, onListBranches, repositoryId]);
useEffect(() => {
const root = listRef.current;
const target = loadMoreRef.current;
if (
!open ||
!root ||
!target ||
!nextToken ||
loadingMore ||
loadMoreError ||
searchPending
) {
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) void loadMore();
},
{ root, rootMargin: "0px 0px 96px 0px" },
);
observer.observe(target);
return () => observer.disconnect();
}, [loadMore, loadMoreError, loadingMore, nextToken, open, searchPending]);
return (
<div className="relative min-w-0">
<button
aria-expanded={open}
aria-haspopup="dialog"
className={cn(TRIGGER_CLASS, "min-w-0 max-w-full")}
disabled={!repositoryId || status === "unavailable"}
onClick={onToggle}
title={
status === "unavailable"
? `Using the repository default branch${branch ? `: ${branch}` : ""}`
: branch || "Select a branch"
}
type="button"
>
<GitBranch
aria-hidden="true"
className="size-4 shrink-0 text-muted-foreground"
/>
<span className="max-w-48 truncate">
{status === "unavailable"
? branch
? `${branch} (default)`
: "Default branch"
: branch || "Select branch…"}
</span>
</button>
{open && repositoryId && status !== "unavailable" ? (
<div className={PANEL_CLASS} role="dialog" aria-label="Cloud branch">
<SearchInput
onChange={setQuery}
placeholder="Search branches…"
value={query}
/>
<div
className="max-h-72 overflow-y-auto overscroll-contain p-1.5"
ref={listRef}
>
{status === "loading" || searchPending ? (
<PickerStatus
icon="loading"
message={query.trim() ? "Searching…" : "Loading branches…"}
/>
) : status === "error" ? (
<PickerStatus message="Could not load branches.">
<Button
onClick={() => setReloadKey((current) => current + 1)}
size="sm"
variant="ghost"
>
<RefreshCcw aria-hidden="true" className="size-3" />
Retry
</Button>
</PickerStatus>
) : branches.length === 0 ? (
<PickerStatus message="No branches found." />
) : (
branches.map((item) => (
<Button
className="w-full justify-start text-xs [content-visibility:auto]"
key={item}
onClick={() => {
onBranchChange(item);
onClose();
}}
variant="ghost"
>
<GitBranch aria-hidden="true" className="size-3" />
<span className="truncate">{item}</span>
{branch === item ? (
<Check aria-hidden="true" className="ml-auto size-3" />
) : null}
</Button>
))
)}
{status === "idle" && nextToken ? (
<div
aria-live="polite"
className="px-3 py-2 text-center text-xs text-muted-foreground"
ref={loadMoreRef}
>
{loadingMore ? "Loading more branches…" : null}
</div>
) : null}
{loadMoreError ? (
<Button
aria-live="polite"
className="w-full justify-start text-xs"
onClick={() => void loadMore()}
variant="ghost"
>
Could not load more branches Retry
</Button>
) : null}
</div>
</div>
) : null}
</div>
);
}
function PickerStatus({
children,
icon,
message,
}: {
children?: ReactNode;
icon?: "loading";
message: string;
}) {
return (
<div
aria-live="polite"
className="flex min-h-20 flex-col items-center justify-center gap-2 px-3 py-4 text-center text-xs text-muted-foreground"
>
{icon === "loading" ? (
<LoaderCircle
aria-hidden="true"
className="size-4 animate-spin motion-reduce:animate-none"
/>
) : null}
<span>{message}</span>
{children}
</div>
);
}
function SearchInput({
value,
onChange,
@@ -55,13 +525,20 @@ function SearchInput({
return (
<div className="border-b border-border p-2">
<div className="flex items-center gap-2 rounded-md bg-background px-2.5 py-1.5">
<Search className="size-3 shrink-0 text-muted-foreground" />
<Search
aria-hidden="true"
className="size-3 shrink-0 text-muted-foreground"
/>
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<Input
autoFocus
aria-label={placeholder}
autoComplete="off"
className="h-auto flex-1 border-0 bg-transparent px-0 py-0 text-xs shadow-none focus-visible:ring-0"
name={placeholder.toLowerCase().replaceAll(/[^a-z]+/g, "-")}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
spellCheck={false}
value={value}
/>
</div>
@@ -422,6 +899,20 @@ function BranchPicker({
}
export function WelcomeWorkspaceControls({
cloudEnabled,
cloudControlsHidden = false,
executionTarget,
repoUrl,
cloudBranch,
signedIn,
signingIn,
onExecutionTargetChange,
onCloudBranchChange,
onListCloudRepositories,
onListCloudBranches,
onOpenExternalUrl,
onRepoUrlChange,
onSignIn,
workspaceRoot,
workspaces,
onRefreshWorkspaces,
@@ -432,6 +923,27 @@ export function WelcomeWorkspaceControls({
onListGitBranches,
onSwitchGitBranch,
}: {
cloudEnabled: boolean;
/**
* Hides the repo/branch pickers and sign-in button while the cloud
* onboarding panel owns those calls-to-action.
*/
cloudControlsHidden?: boolean;
executionTarget: "local" | "cloud";
repoUrl: string;
cloudBranch: string;
onCloudBranchChange: (branch: string) => void;
onListCloudRepositories: () => Promise<CloudRepositoryListResult>;
onListCloudBranches: (
repositoryId: number,
options?: CloudBranchListOptions,
) => Promise<CloudBranchListResult>;
onOpenExternalUrl: (url: string) => Promise<void>;
signedIn: boolean;
signingIn: boolean;
onExecutionTargetChange: (target: "local" | "cloud") => void;
onRepoUrlChange: (repoUrl: string) => void;
onSignIn: () => void | Promise<void>;
workspaceRoot: string;
workspaces: string[];
onRefreshWorkspaces: () => Promise<void>;
@@ -443,10 +955,26 @@ export function WelcomeWorkspaceControls({
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
onSwitchGitBranch: (branch: string) => Promise<boolean>;
}) {
const [openMenu, setOpenMenu] = useState<"workspace" | "branch" | null>(null);
const [openMenu, setOpenMenu] = useState<
"workspace" | "branch" | "cloud-repository" | "cloud-branch" | null
>(null);
const [cloudRepositoryId, setCloudRepositoryId] = useState<number>();
const [cloudDefaultBranch, setCloudDefaultBranch] = useState("");
const isChatWorkspace =
!workspaceRoot.trim() || isChatWorkspacePath(workspaceRoot);
const containerRef = useRef<HTMLDivElement>(null);
const handleCloudRepositoriesLoaded = useCallback(
(repositories: CloudRepositoryOption[]) => {
const selected = repositories.find(
(repository) =>
normalizeCloudRepositoryUrl(repository.url) ===
normalizeCloudRepositoryUrl(repoUrl),
);
setCloudRepositoryId(selected?.id);
setCloudDefaultBranch(selected?.defaultBranch ?? "");
},
[repoUrl],
);
// Close whichever menu is open when clicking outside the control row.
useEffect(() => {
@@ -464,39 +992,103 @@ export function WelcomeWorkspaceControls({
}, [openMenu]);
return (
<div className="flex min-w-0 items-center gap-2" ref={containerRef}>
<WorkspacePicker
onClose={() => setOpenMenu(null)}
onPickWorkspaceDirectory={onPickWorkspaceDirectory}
onRefreshWorkspaces={onRefreshWorkspaces}
onSelectChat={onSelectChat}
onSwitchWorkspace={onSwitchWorkspace}
onToggle={() =>
setOpenMenu((current) =>
current === "workspace" ? null : "workspace",
)
}
open={openMenu === "workspace"}
workspaceRoot={workspaceRoot}
workspaces={workspaces}
/>
{/* Git is a developer affordance: a plain (non-git) folder gets no
branch chrome at all instead of a confusing "No branch" chip.
Pending discovery (null) is treated the same until it resolves. */}
{!isChatWorkspace &&
currentBranch !== null &&
currentBranch !== "no-git" ? (
<BranchPicker
currentBranch={currentBranch}
onClose={() => setOpenMenu(null)}
onListGitBranches={onListGitBranches}
onSwitchGitBranch={onSwitchGitBranch}
onToggle={() =>
setOpenMenu((current) => (current === "branch" ? null : "branch"))
}
open={openMenu === "branch"}
<div
className="flex min-w-0 flex-wrap items-center gap-2"
ref={containerRef}
>
{cloudEnabled ? (
<ExecutionTargetPicker
executionTarget={executionTarget}
onChange={(target) => {
setOpenMenu(null);
onExecutionTargetChange(target);
}}
/>
) : null}
{cloudEnabled && executionTarget === "cloud" ? (
cloudControlsHidden ? null : signedIn ? (
<>
<CloudRepositoryPicker
onClose={() => setOpenMenu(null)}
onListRepositories={onListCloudRepositories}
onOpenExternalUrl={onOpenExternalUrl}
onRepositoriesLoaded={handleCloudRepositoriesLoaded}
onSelect={(repository) => {
setCloudRepositoryId(repository.id);
setCloudDefaultBranch(repository.defaultBranch);
onRepoUrlChange(normalizeCloudRepositoryUrl(repository.url));
onCloudBranchChange(repository.defaultBranch);
}}
onToggle={() =>
setOpenMenu((current) =>
current === "cloud-repository" ? null : "cloud-repository",
)
}
open={openMenu === "cloud-repository"}
repoUrl={repoUrl}
/>
<CloudBranchPicker
branch={cloudBranch}
defaultBranch={cloudDefaultBranch}
key={cloudRepositoryId ?? "no-repository"}
onBranchChange={onCloudBranchChange}
onClose={() => setOpenMenu(null)}
onListBranches={onListCloudBranches}
onToggle={() =>
setOpenMenu((current) =>
current === "cloud-branch" ? null : "cloud-branch",
)
}
open={openMenu === "cloud-branch"}
repositoryId={cloudRepositoryId}
/>
</>
) : (
<Button
disabled={signingIn}
onClick={() => void onSignIn()}
size="sm"
variant="outline"
>
<LogIn className="size-3.5" />
{signingIn ? "Waiting for browser..." : "Sign in to use Cloud"}
</Button>
)
) : (
<>
<WorkspacePicker
onClose={() => setOpenMenu(null)}
onPickWorkspaceDirectory={onPickWorkspaceDirectory}
onRefreshWorkspaces={onRefreshWorkspaces}
onSelectChat={onSelectChat}
onSwitchWorkspace={onSwitchWorkspace}
onToggle={() =>
setOpenMenu((current) =>
current === "workspace" ? null : "workspace",
)
}
open={openMenu === "workspace"}
workspaceRoot={workspaceRoot}
workspaces={workspaces}
/>
{!isChatWorkspace &&
currentBranch !== null &&
currentBranch !== "no-git" ? (
<BranchPicker
currentBranch={currentBranch}
onClose={() => setOpenMenu(null)}
onListGitBranches={onListGitBranches}
onSwitchGitBranch={onSwitchGitBranch}
onToggle={() =>
setOpenMenu((current) =>
current === "branch" ? null : "branch",
)
}
open={openMenu === "branch"}
/>
) : null}
</>
)}
</div>
);
}
@@ -47,6 +47,8 @@ export function WorkspaceSelector({
onPickWorkspaceDirectory,
onCreateGitBranch,
disabled = false,
placement = "top",
className,
}: {
/** Branch name, "no-git" for a non-repo folder, null while discovery is pending. */
currentBranch: string | null;
@@ -59,6 +61,8 @@ export function WorkspaceSelector({
onPickWorkspaceDirectory?: (initialPath?: string) => Promise<string | null>;
onCreateGitBranch?: (branchName: string) => Promise<boolean>;
disabled?: boolean;
placement?: "top" | "bottom";
className?: string;
}) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
@@ -250,7 +254,10 @@ export function WorkspaceSelector({
? `Workspace ${workspaceName}, branch ${currentBranch}`
: `Folder ${workspaceName}`
}
className="flex max-w-full min-w-0 items-center gap-1 h-auto px-1 py-0.5 hover:text-foreground transition-colors max-[560px]:size-7 max-[560px]:justify-center max-[560px]:p-0 text-sm"
className={cn(
"flex h-auto max-w-full min-w-0 items-center gap-1 px-1 py-0.5 text-sm transition-colors hover:text-foreground max-[560px]:size-7 max-[560px]:justify-center max-[560px]:p-0",
className,
)}
disabled={disabled || switching}
id="git-branch-btn"
onClick={() => {
@@ -285,7 +292,7 @@ export function WorkspaceSelector({
</Button>
</span>
</TooltipTrigger>
<TooltipContent align="end" side="top" sideOffset={6}>
<TooltipContent align="end" side={placement} sideOffset={6}>
{workspaceRoot || workspaceName}
{hasGit ? ` / ${currentBranch}` : ""}
</TooltipContent>
@@ -307,7 +314,12 @@ export function WorkspaceSelector({
setSearch("");
}}
/>
<div className="absolute bottom-full right-0 z-50 mb-2 w-72 rounded-lg border border-border bg-popover shadow-xl">
<div
className={cn(
"absolute right-0 z-50 w-72 rounded-lg border border-border bg-popover shadow-xl",
placement === "bottom" ? "top-full mt-2" : "bottom-full mb-2",
)}
>
{/* Search */}
<div className="p-2 border-b border-border">
<div className="flex items-center gap-2 rounded-md bg-background px-2.5 py-1.5">
@@ -0,0 +1,271 @@
// @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 {
GITHUB_INSTALL_POLL_INTERVAL_MS,
GitHubConnectStep,
} from "./onboarding-github-step";
const { invoke, openExternalUrl } = vi.hoisted(() => ({
invoke: vi.fn(),
openExternalUrl: vi.fn(),
}));
vi.mock("@/lib/desktop-client", () => ({
desktopClient: { invoke },
openExternalUrl,
}));
type IntegrationsMock = {
list?: () => unknown;
githubInstallUrl?: () => unknown;
listGitHubRepositories?: () => unknown;
};
function mockIntegrationsCommand(handlers: IntegrationsMock) {
invoke.mockImplementation(
async (command: string, args?: Record<string, unknown>) => {
if (command !== "cline_integrations") {
throw new Error(`unexpected command: ${command}`);
}
const operation = String(args?.operation);
const handler = handlers[operation as keyof IntegrationsMock];
if (!handler) {
throw new Error(`unexpected operation: ${operation}`);
}
return handler();
},
);
}
describe("GitHubConnectStep", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
invoke.mockReset();
openExternalUrl.mockReset();
openExternalUrl.mockResolvedValue(undefined);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.useRealTimers();
});
function buttonByText(text: string): HTMLButtonElement {
const button = Array.from(container.querySelectorAll("button")).find(
(candidate) => candidate.textContent?.trim() === text,
);
if (!button) {
throw new Error(`button not found: ${text}`);
}
return button;
}
async function render(onContinue = vi.fn()) {
await act(async () => {
root.render(<GitHubConnectStep onContinue={onContinue} />);
});
return onContinue;
}
it("continues silently when GitHub is already connected", async () => {
mockIntegrationsCommand({ list: () => [{ provider: "github" }] });
const onContinue = await render();
expect(onContinue).toHaveBeenCalledTimes(1);
});
it("continues silently when the account is signed out", async () => {
mockIntegrationsCommand({
list: () => ({ signedIn: false, code: "ACCOUNT_NOT_AUTHENTICATED" }),
});
const onContinue = await render();
expect(onContinue).toHaveBeenCalledTimes(1);
});
it("shows the connect card when GitHub is not connected", async () => {
mockIntegrationsCommand({ list: () => [] });
const onContinue = await render();
expect(container.textContent).toContain("Connect GitHub");
expect(container.textContent).toContain("Not connected");
expect(onContinue).not.toHaveBeenCalled();
});
it("skips without connecting", async () => {
mockIntegrationsCommand({ list: () => [] });
const onContinue = await render();
await act(async () => {
buttonByText("Skip for now").click();
});
expect(onContinue).toHaveBeenCalledTimes(1);
});
it("opens the install URL, polls until connected, and lists repositories", async () => {
let connected = false;
mockIntegrationsCommand({
list: () => (connected ? [{ provider: "github" }] : []),
githubInstallUrl: () => ({
url: "https://github.com/apps/cline/installations/new?state=abc",
}),
listGitHubRepositories: () => [
{ id: 1, full_name: "cline/cline", private: false },
{ id: 2, full_name: "cline/core-platform", private: true },
],
});
const onContinue = await render();
vi.useFakeTimers();
await act(async () => {
buttonByText("Connect GitHub").click();
});
expect(openExternalUrl).toHaveBeenCalledWith(
"https://github.com/apps/cline/installations/new?state=abc",
);
expect(container.textContent).toContain(
"Finish installing the Cline GitHub App in your browser",
);
// First poll: still not installed.
await act(async () => {
await vi.advanceTimersByTimeAsync(GITHUB_INSTALL_POLL_INTERVAL_MS);
});
expect(container.textContent).not.toContain("Connected");
connected = true;
await act(async () => {
await vi.advanceTimersByTimeAsync(GITHUB_INSTALL_POLL_INTERVAL_MS);
});
expect(container.textContent).toContain("Connected");
expect(container.textContent).toContain("Accessible repositories");
expect(container.textContent).toContain("cline/cline");
expect(container.textContent).toContain("cline/core-platform");
expect(onContinue).not.toHaveBeenCalled();
await act(async () => {
buttonByText("Continue").click();
});
expect(onContinue).toHaveBeenCalledTimes(1);
});
it("returns to the connect state with an error when the install URL fails", async () => {
mockIntegrationsCommand({
list: () => [],
githubInstallUrl: () => {
throw new Error("authentication required");
},
});
await render();
await act(async () => {
buttonByText("Connect GitHub").click();
});
expect(container.textContent).toContain(
"Failed to start the GitHub connection",
);
expect(container.textContent).toContain("authentication required");
expect(openExternalUrl).not.toHaveBeenCalled();
expect(buttonByText("Connect GitHub")).toBeDefined();
});
it("stops waiting when the browser round-trip is cancelled", async () => {
mockIntegrationsCommand({
list: () => [],
githubInstallUrl: () => ({ url: "https://github.com/install" }),
});
await render();
await act(async () => {
buttonByText("Connect GitHub").click();
});
expect(container.textContent).toContain("Finish installing");
await act(async () => {
buttonByText("Cancel").click();
});
expect(container.textContent).not.toContain("Finish installing");
expect(buttonByText("Connect GitHub")).toBeDefined();
});
it("stops polling once cancelled instead of leaving the interval running", async () => {
mockIntegrationsCommand({
list: () => [],
githubInstallUrl: () => ({ url: "https://github.com/install" }),
});
await render();
vi.useFakeTimers();
await act(async () => {
buttonByText("Connect GitHub").click();
});
await act(async () => {
await vi.advanceTimersByTimeAsync(GITHUB_INSTALL_POLL_INTERVAL_MS);
});
await act(async () => {
buttonByText("Cancel").click();
});
const callsAfterCancel = invoke.mock.calls.length;
// No further polls may fire after cancelling.
await act(async () => {
await vi.advanceTimersByTimeAsync(GITHUB_INSTALL_POLL_INTERVAL_MS * 5);
});
expect(invoke.mock.calls.length).toBe(callsAfterCancel);
});
it("stops polling and reports the signed-out session when the account expires", async () => {
let signedOut = false;
mockIntegrationsCommand({
list: () =>
signedOut ? { signedIn: false, code: "ACCOUNT_NOT_AUTHENTICATED" } : [],
githubInstallUrl: () => ({ url: "https://github.com/install" }),
});
await render();
vi.useFakeTimers();
await act(async () => {
buttonByText("Connect GitHub").click();
});
expect(container.textContent).toContain("Finish installing");
signedOut = true;
await act(async () => {
await vi.advanceTimersByTimeAsync(GITHUB_INSTALL_POLL_INTERVAL_MS);
});
// Back to the actionable connect state, not a permanent spinner.
expect(container.textContent).not.toContain("Finish installing");
expect(container.textContent).toContain("Your Cline account session ended");
expect(buttonByText("Connect GitHub")).toBeDefined();
const callsAfterSignOut = invoke.mock.calls.length;
await act(async () => {
await vi.advanceTimersByTimeAsync(GITHUB_INSTALL_POLL_INTERVAL_MS * 5);
});
expect(invoke.mock.calls.length).toBe(callsAfterSignOut);
});
it("checks integrations once even when the parent re-renders", async () => {
mockIntegrationsCommand({ list: () => [] });
const onContinue = vi.fn();
await act(async () => {
root.render(<GitHubConnectStep onContinue={onContinue} />);
});
const callsAfterMount = invoke.mock.calls.length;
expect(callsAfterMount).toBe(1);
// A parent re-render passing a brand-new inline callback must not
// re-trigger the initial check.
await act(async () => {
root.render(<GitHubConnectStep onContinue={() => onContinue()} />);
});
expect(invoke.mock.calls.length).toBe(callsAfterMount);
});
});
@@ -0,0 +1,288 @@
"use client";
import { GitHubIcon } from "@cline/ui";
import { Loader2, Lock } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
type ClineGitHubRepository,
fetchGitHubInstallUrl,
findGitHubIntegration,
listClineGitHubRepositories,
listClineIntegrations,
} from "@/lib/cline-integrations";
import { openExternalUrl } from "@/lib/desktop-client";
export const GITHUB_INSTALL_POLL_INTERVAL_MS = 3_000;
type GitHubStepPhase = "checking" | "connect" | "waiting" | "connected";
export function GitHubConnectStep({ onContinue }: { onContinue: () => void }) {
const [phase, setPhase] = useState<GitHubStepPhase>("checking");
const [connectError, setConnectError] = useState<string | null>(null);
const [repos, setRepos] = useState<ClineGitHubRepository[] | null>(null);
const onContinueRef = useRef(onContinue);
useEffect(() => {
onContinueRef.current = onContinue;
}, [onContinue]);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const result = await listClineIntegrations();
if (cancelled) {
return;
}
if (
result.status === "not-authenticated" ||
findGitHubIntegration(result.integrations)
) {
onContinueRef.current();
return;
}
setPhase("connect");
} catch {
if (!cancelled) {
setPhase("connect");
}
}
})();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
// The install finishes in the external browser, which cannot navigate the
// app back, so poll the integrations list until the installation lands.
if (phase !== "waiting") {
return;
}
let cancelled = false;
let inFlight = false;
let interval: ReturnType<typeof setInterval> | undefined;
const stop = () => {
cancelled = true;
if (interval !== undefined) {
clearInterval(interval);
interval = undefined;
}
};
async function poll() {
try {
const result = await listClineIntegrations();
if (cancelled) {
return;
}
if (result.status === "not-authenticated") {
// The account session ended mid-install. Nothing will ever
// arrive, so stop polling instead of spinning forever.
stop();
setConnectError(
"Your Cline account session ended. Sign in again to connect GitHub.",
);
setPhase("connect");
return;
}
if (findGitHubIntegration(result.integrations)) {
setPhase("connected");
}
} catch {
// Transient failures keep polling; the user can cancel anytime.
} finally {
inFlight = false;
}
}
interval = setInterval(() => {
if (inFlight) {
return;
}
inFlight = true;
void poll();
}, GITHUB_INSTALL_POLL_INTERVAL_MS);
// Covers unmount and every phase change, including the Cancel button.
return stop;
}, [phase]);
useEffect(() => {
if (phase !== "connected") {
return;
}
let cancelled = false;
listClineGitHubRepositories()
.then((result) => {
if (!cancelled) {
setRepos(result);
}
})
.catch(() => {
if (!cancelled) {
setRepos([]);
}
});
return () => {
cancelled = true;
};
}, [phase]);
const connect = useCallback(async () => {
setConnectError(null);
setPhase("waiting");
try {
const url = await fetchGitHubInstallUrl();
await openExternalUrl(url);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
setConnectError(`Failed to start the GitHub connection: ${reason}`);
setPhase("connect");
}
}, []);
if (phase === "checking") {
return (
<output
aria-label="Checking GitHub connection"
className="flex items-center justify-center py-16"
>
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</output>
);
}
return (
<>
<h1 className="text-2xl font-semibold tracking-tight text-foreground">
Connect GitHub
</h1>
<p className="mt-2 text-sm text-muted-foreground">
Grant Cline access to your GitHub repositories to supercharge it with
real-world context. You can always do this later from your dashboard.
</p>
<div className="mt-6 rounded-2xl border border-border/70 bg-background/60 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-secondary text-foreground">
<GitHubIcon className="size-4" />
</span>
<div className="flex items-center gap-2">
<p className="text-base font-semibold text-foreground">GitHub</p>
{phase === "connected" ? (
<Badge
className="bg-primary/15 text-primary"
variant="secondary"
>
Connected
</Badge>
) : (
<Badge variant="secondary">Not connected</Badge>
)}
</div>
</div>
{phase === "connect" ? (
<Button
className="rounded-full"
onClick={() => void connect()}
type="button"
>
Connect GitHub
</Button>
) : null}
</div>
{phase === "waiting" ? (
<div className="mt-3 flex flex-wrap items-center gap-3">
<p className="inline-flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Finish installing the Cline GitHub App in your browser...
</p>
<button
className="text-sm text-muted-foreground transition-colors hover:text-foreground"
onClick={() => setPhase("connect")}
type="button"
>
Cancel
</button>
</div>
) : null}
{connectError ? (
<p className="mt-2 text-xs text-destructive" role="alert">
{connectError}
</p>
) : null}
{phase === "connected" ? (
<div className="mt-4 border-t border-border/70 pt-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Accessible repositories
{repos ? (
<span className="ml-2 font-normal normal-case">
({repos.length})
</span>
) : null}
</p>
{repos === null ? (
<p className="mt-2 inline-flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading repositories...
</p>
) : repos.length > 0 ? (
<ul className="mt-2 flex max-h-40 flex-col gap-1 overflow-y-auto pr-1">
{repos.map((repo) => (
<li
className="flex items-center gap-2 text-sm text-foreground"
key={repo.id ?? repo.full_name}
>
<Lock
aria-hidden="true"
className={
repo.private
? "size-3 shrink-0 text-muted-foreground"
: "size-3 shrink-0 text-transparent"
}
/>
<span className="truncate">
{repo.full_name ?? repo.name}
</span>
</li>
))}
</ul>
) : (
<p className="mt-2 text-sm text-muted-foreground">
No repositories found. You may need to grant access in your
GitHub App settings.
</p>
)}
</div>
) : null}
</div>
<div className="mt-5 flex justify-center">
{phase === "connected" ? (
<Button
className="h-11 w-full rounded-full text-base"
onClick={onContinue}
type="button"
>
Continue
</Button>
) : (
<button
className="text-sm text-muted-foreground transition-colors hover:text-foreground"
onClick={onContinue}
type="button"
>
Skip for now
</button>
)}
</div>
</>
);
}
@@ -195,6 +195,11 @@ describe("OnboardingView", () => {
await act(async () => {
buttonByText("Continue").click();
});
// Connecting a Cline account routes through the GitHub integration step.
expect(container.textContent).toContain("Connect GitHub");
await act(async () => {
buttonByText("Skip for now").click();
});
expect(container.textContent).toContain("You're all set");
expect(
parseModelSelectionStorage(
@@ -203,6 +208,30 @@ describe("OnboardingView", () => {
).toBe("cline");
});
it("skips the GitHub step silently when the integration is already connected", async () => {
invoke.mockImplementation(async (command: string) => {
if (command === "cline_account") {
return { email: "dev@example.com", displayName: "Dev" };
}
if (command === "cline_integrations") {
return [{ provider: "github" }];
}
if (command === "list_provider_catalog") {
return { providers: [makeProvider()], settingsPath: "/tmp/p.json" };
}
return {};
});
await render();
await act(async () => {
buttonByText("Get started").click();
});
await act(async () => {
buttonByText("Continue").click();
});
expect(container.textContent).not.toContain("Connect GitHub");
expect(container.textContent).toContain("You're all set");
});
it("lets the user cancel a pending browser sign-in", async () => {
await render();
await act(async () => {
@@ -281,6 +310,10 @@ describe("OnboardingView", () => {
enabled: true,
api_key: "cline_key_123",
});
expect(container.textContent).toContain("Connect GitHub");
await act(async () => {
buttonByText("Skip for now").click();
});
expect(container.textContent).toContain("You're all set");
expect(container.textContent).toContain("Your Cline account is connected");
expect(
@@ -374,6 +407,10 @@ describe("OnboardingView", () => {
expect(invoke).toHaveBeenCalledWith("run_provider_oauth_login", {
provider: "cline",
});
expect(container.textContent).toContain("Connect GitHub");
await act(async () => {
buttonByText("Skip for now").click();
});
expect(container.textContent).toContain("You're all set");
expect(
parseModelSelectionStorage(
@@ -20,6 +20,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { GitHubConnectStep } from "@/components/views/onboarding/onboarding-github-step";
import { useAccount } from "@/contexts/account-context";
import { OAUTH_MANAGED_PROVIDERS } from "@/hooks/chat-session/constants";
import { isClineAccountNotAuthenticatedResult } from "@/lib/cline-account-state";
@@ -40,7 +41,7 @@ import type { Provider } from "@/lib/provider-schema";
const CREATE_ACCOUNT_URL = "https://app.cline.bot";
export type OnboardingStep = "welcome" | "connect" | "done";
export type OnboardingStep = "welcome" | "connect" | "github" | "done";
type OnboardingConnection =
| { kind: "cline" }
@@ -700,7 +701,8 @@ function DoneStep({
/**
* Full-screen first-run experience: welcome, connect a model provider (Cline
* account or bring-your-own API key), done. Rendered by the app shell while
* account or bring-your-own API key), connect GitHub (Cline accounts only,
* skipped when already connected), done. Rendered by the app shell while
* onboarding has not been completed (see lib/onboarding.ts); `onComplete`
* marks it completed and returns to the chat.
*/
@@ -726,10 +728,16 @@ export function OnboardingView({
onBack={() => setStep("welcome")}
onConnected={(nextConnection) => {
setConnection(nextConnection);
setStep("done");
// The GitHub integration lives on the Cline account, so the
// step only applies when one is connected.
setStep(nextConnection.kind === "cline" ? "github" : "done");
}}
onSkip={onComplete}
/>
) : step === "github" ? (
<OnboardingCard wide>
<GitHubConnectStep onContinue={() => setStep("done")} />
</OnboardingCard>
) : (
<DoneStep connection={connection} onFinish={onComplete} />
)}
@@ -30,6 +30,7 @@ const thread: SessionThread = {
const session: SessionHistoryItem = {
sessionId: thread.id,
environmentId: "local",
status: "completed",
provider: thread.provider,
model: thread.model,
@@ -165,6 +166,21 @@ describe("SessionsView table", () => {
expect(container.querySelector('[aria-label="Favorited"]')).not.toBeNull();
});
it("marks cloud sessions and shows their repository", async () => {
const cloudThread: SessionThread = {
...thread,
origin: "cloud",
repoUrl: "https://github.com/cline/cline",
};
const view = renderView({ threads: [cloudThread] });
await view.render();
expect(
container.querySelector('[aria-label="Cloud session"]'),
).not.toBeNull();
expect(container.textContent).toContain("https://github.com/cline/cline");
});
it("opens a session on click but not while text is selected", async () => {
const view = renderView({});
await view.render();
@@ -252,6 +268,43 @@ describe("SessionsView table", () => {
expect(view.loadAllSessions).toHaveBeenCalledOnce();
});
it("groups filter choices without prefixes and searches them", async () => {
const view = renderView();
await view.render();
const filterButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Filter sessions"]',
);
await act(async () => {
filterButton?.dispatchEvent(
new MouseEvent("pointerdown", {
bubbles: true,
cancelable: true,
button: 0,
}),
);
});
expect(document.body.textContent).toContain("Workspaces");
expect(document.body.textContent).toContain("Providers");
expect(document.body.textContent).not.toContain("provider:cline-pass");
const search = document.body.querySelector<HTMLInputElement>(
'input[aria-label="Search session filters"]',
);
await act(async () => {
if (!search) return;
const setValue = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)?.set;
setValue?.call(search, "cline-pass");
search.dispatchEvent(new Event("input", { bubbles: true }));
});
expect(document.body.textContent).toContain("cline-pass");
expect(document.body.textContent).not.toContain("Workspaces");
});
});
describe("SessionsView pagination", () => {
@@ -7,6 +7,7 @@ import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
Cloud,
Filter,
Folder,
GitFork,
@@ -49,6 +50,7 @@ import {
sessionActivityTimestamp,
type UseSessionHistoryResult,
} from "@/hooks/use-session-history";
import { isCloudProvisioningSessionId } from "@/lib/cloud-repositories";
import type { SessionHistoryItem } from "@/lib/session-history";
import { sessionStatusColor, sessionStatusTone } from "@/lib/session-status";
import { cn } from "@/lib/utils";
@@ -59,6 +61,19 @@ type SessionsViewProps = {
};
const PAGE_SIZE = 10;
const FILTER_CATEGORIES = [
"Favorites",
"Workspaces",
"Statuses",
"Providers",
"Models",
] as const;
type FilterCategory = (typeof FILTER_CATEGORIES)[number];
type SessionFilterDetail = {
key: string;
category: FilterCategory;
label: string;
};
function modelLabel(thread: SessionThread): string {
if (thread.provider && thread.model) {
@@ -127,16 +142,42 @@ function tokensLabel(thread: SessionThread): string {
function sessionFilterDetails(
thread: SessionThread,
session?: SessionHistoryItem,
): string[] {
): SessionFilterDetail[] {
const workspacePath = session?.workspaceRoot || session?.cwd || "";
const workspace = workspacePath ? basenamePath(workspacePath) : "";
return [
thread.pinned ? "favorite:yes" : undefined,
workspace ? `workspace:${workspace}` : undefined,
thread.status ? `status:${thread.status}` : undefined,
thread.provider ? `provider:${thread.provider}` : undefined,
thread.model ? `model:${thread.model}` : undefined,
].filter((detail): detail is string => Boolean(detail));
thread.pinned
? { key: "favorite:yes", category: "Favorites", label: "Favorites" }
: undefined,
workspace
? {
key: `workspace:${workspace}`,
category: "Workspaces",
label: workspace,
}
: undefined,
thread.status
? {
key: `status:${thread.status}`,
category: "Statuses",
label: thread.status,
}
: undefined,
thread.provider
? {
key: `provider:${thread.provider}`,
category: "Providers",
label: thread.provider,
}
: undefined,
thread.model
? {
key: `model:${thread.model}`,
category: "Models",
label: thread.model,
}
: undefined,
].filter((detail): detail is SessionFilterDetail => Boolean(detail));
}
function sortTimestamp(session?: SessionHistoryItem) {
@@ -149,6 +190,7 @@ function sortTimestamp(session?: SessionHistoryItem) {
export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
const [query, setQuery] = useState("");
const [filterQuery, setFilterQuery] = useState("");
const [sessionFilters, setSessionFilters] = useState<string[]>([]);
const [sortDirection, setSortDirection] = useState<"newest" | "oldest">(
"newest",
@@ -175,17 +217,31 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
requiresCompleteHistory,
]);
const filterOptions = useMemo(
() =>
Array.from(
new Set(
history.threads.flatMap((thread) =>
sessionFilterDetails(thread, history.sessionById.get(thread.id)),
),
),
).sort((a, b) => a.localeCompare(b)),
[history.sessionById, history.threads],
);
const filterOptions = useMemo(() => {
const options = new Map<string, SessionFilterDetail>();
for (const thread of history.threads) {
for (const detail of sessionFilterDetails(
thread,
history.sessionById.get(thread.id),
)) {
options.set(detail.key, detail);
}
}
return [...options.values()].sort((a, b) => a.label.localeCompare(b.label));
}, [history.sessionById, history.threads]);
const groupedFilterOptions = useMemo(() => {
const normalizedQuery = filterQuery.trim().toLowerCase();
return FILTER_CATEGORIES.map((category) => ({
category,
options: filterOptions.filter(
(option) =>
option.category === category &&
(!normalizedQuery ||
option.label.toLowerCase().includes(normalizedQuery) ||
category.toLowerCase().includes(normalizedQuery)),
),
})).filter((group) => group.options.length > 0);
}, [filterOptions, filterQuery]);
const filteredThreads = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
@@ -194,7 +250,8 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
const session = history.sessionById.get(thread.id);
const details = sessionFilterDetails(thread, session);
const matchesFilters =
selected.size === 0 || details.some((detail) => selected.has(detail));
selected.size === 0 ||
details.some((detail) => selected.has(detail.key));
if (!matchesFilters) {
return false;
}
@@ -206,6 +263,7 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
thread.codebase,
thread.provider,
thread.model,
thread.repoUrl,
session?.workspaceRoot,
session?.cwd,
]
@@ -357,6 +415,7 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
</DropdownMenu>
<DropdownMenu
onOpenChange={(open) => {
if (!open) setFilterQuery("");
// Filter choices are derived from the loaded rows, so
// complete the history as soon as the user opens this
// menu. This keeps both the options and their results
@@ -378,9 +437,20 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
<Filter className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-72 w-72">
<DropdownMenuContent align="end" className="max-h-96 w-72">
<DropdownMenuGroup>
<DropdownMenuLabel>Filter sessions</DropdownMenuLabel>
<div className="px-2 pb-2">
<Input
aria-label="Search session filters"
autoFocus
className="h-8"
onChange={(event) => setFilterQuery(event.target.value)}
onKeyDown={(event) => event.stopPropagation()}
placeholder="Search filters…"
value={filterQuery}
/>
</div>
{sessionFilters.length > 0 ? (
<>
<DropdownMenuItem onClick={() => setSessionFilters([])}>
@@ -389,23 +459,31 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
<DropdownMenuSeparator />
</>
) : null}
{filterOptions.length === 0 ? (
{groupedFilterOptions.length === 0 ? (
<DropdownMenuItem disabled>
No filters available
{filterQuery
? "No matching filters"
: "No filters available"}
</DropdownMenuItem>
) : (
filterOptions.map((detail) => (
<DropdownMenuCheckboxItem
checked={sessionFilters.includes(detail)}
key={detail}
onCheckedChange={(checked) =>
toggleFilter(detail, checked === true)
}
>
<span className="truncate" title={detail}>
{detail}
</span>
</DropdownMenuCheckboxItem>
groupedFilterOptions.map((group, groupIndex) => (
<div key={group.category}>
{groupIndex > 0 ? <DropdownMenuSeparator /> : null}
<DropdownMenuLabel>{group.category}</DropdownMenuLabel>
{group.options.map((detail) => (
<DropdownMenuCheckboxItem
checked={sessionFilters.includes(detail.key)}
key={detail.key}
onCheckedChange={(checked) =>
toggleFilter(detail.key, checked === true)
}
>
<span className="truncate" title={detail.label}>
{detail.label}
</span>
</DropdownMenuCheckboxItem>
))}
</div>
))
)}
</DropdownMenuGroup>
@@ -568,6 +646,12 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
tone={sessionStatusTone(thread.status)}
/>
<span className="truncate">{thread.title}</span>
{thread.origin === "cloud" ? (
<Cloud
aria-label="Cloud session"
className="size-3.5 shrink-0 text-muted-foreground"
/>
) : null}
{thread.pinned ? (
<Star
aria-label="Favorited"
@@ -576,9 +660,20 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
) : null}
</span>
<span className="flex min-w-0 items-center gap-2 text-muted-foreground">
<Folder className="size-3.5 shrink-0" />
<span className="truncate" title={workspace}>
{workspace ? basenamePath(workspace) : "No workspace"}
{thread.origin === "cloud" ? (
<Cloud className="size-3.5 shrink-0" />
) : (
<Folder className="size-3.5 shrink-0" />
)}
<span
className="truncate"
title={thread.repoUrl || workspace}
>
{thread.origin === "cloud"
? thread.repoUrl || "Cloud repository"
: workspace
? basenamePath(workspace)
: "No workspace"}
</span>
</span>
<span
@@ -615,34 +710,46 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={6}>
<DropdownMenuItem
onClick={() =>
void history.setThreadPinned(
thread.id,
!thread.pinned,
)
}
>
<Star
className={cn(
"size-4",
thread.pinned && "fill-current",
)}
/>
{thread.pinned ? "Unfavorite" : "Favorite"}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => startRename(thread)}>
<Pencil className="size-4" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void history.forkThread(thread.id)}
>
<GitFork className="size-4" />
Fork
</DropdownMenuItem>
{thread.origin !== "cloud" ? (
<DropdownMenuItem
onClick={() =>
void history.setThreadPinned(
thread.id,
!thread.pinned,
)
}
>
<Star
className={cn(
"size-4",
thread.pinned && "fill-current",
)}
/>
{thread.pinned ? "Unfavorite" : "Favorite"}
</DropdownMenuItem>
) : null}
{/* Cloud sessions support rename (PATCH title), matching
the sidebar and chat header affordances. Provisioning
placeholders have no server session to rename yet. */}
{!isCloudProvisioningSessionId(thread.id) ? (
<DropdownMenuItem onClick={() => startRename(thread)}>
<Pencil className="size-4" />
Rename
</DropdownMenuItem>
) : null}
{thread.origin !== "cloud" ? (
<DropdownMenuItem
onClick={() => void history.forkThread(thread.id)}
>
<GitFork className="size-4" />
Fork
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
// Provisioning placeholders have no server session
// to delete yet; the sidecar rejects the request.
disabled={isCloudProvisioningSessionId(thread.id)}
onClick={() => setDeleteCandidate(thread)}
variant="destructive"
>
@@ -748,8 +855,9 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
<AlertDialogHeader>
<AlertDialogTitle>Delete session?</AlertDialogTitle>
<AlertDialogDescription>
This removes "{deleteCandidate?.title ?? "this session"}" from
local history.
{deleteCandidate?.origin === "cloud"
? `This deletes "${deleteCandidate?.title ?? "this session"}" and its cloud workspace.`
: `This removes "${deleteCandidate?.title ?? "this session"}" from local history.`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@@ -9,38 +9,464 @@ import {
ProviderListContent,
} from "./provider-list-view";
const provider: Provider = {
id: "ollama",
name: "Ollama",
models: 2,
color: "#000",
letter: "OL",
enabled: true,
modelList: [
{ id: "alpha", name: "Alpha" },
{ id: "beta", name: "Beta" },
],
};
const providers: Provider[] = [
{
id: "elevenlabs",
name: "ElevenLabs",
models: 1,
color: "#000000",
letter: "EL",
enabled: true,
modelList: [
{
id: "scribe_v2",
name: "Scribe v2",
inputModalities: ["audio"],
outputModalities: ["text"],
},
{
id: "eleven_turbo_v2_5",
name: "Eleven Turbo v2.5",
inputModalities: ["text"],
outputModalities: ["audio"],
},
],
},
{
id: "groq",
name: "Groq",
models: 3,
color: "#000000",
letter: "GR",
enabled: true,
modelList: [
{
id: "whisper-large-v3",
name: "Whisper Large v3",
inputModalities: ["audio"],
outputModalities: ["text"],
},
{
id: "whisper-large-v3-turbo",
name: "Whisper Large v3 Turbo",
inputModalities: ["audio"],
outputModalities: ["text"],
},
{
id: "llama-chat",
name: "Llama Chat",
inputModalities: ["text"],
outputModalities: ["text"],
},
],
},
{
id: "gemini",
name: "Google Gemini",
models: 1,
color: "#000000",
letter: "GG",
enabled: true,
modelList: [
{
id: "gemini-3.5-live-translate-preview",
name: "Gemini 3.5 Live Translate Preview",
inputModalities: ["audio"],
outputModalities: ["audio", "text"],
},
],
},
];
describe("ProviderDetailContent models", () => {
let container: HTMLDivElement;
let root: Root;
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
window.localStorage.clear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.restoreAllMocks();
});
describe("ProviderListContent voice input settings", () => {
it("shows model and mode provider sections with a scrollable model list", async () => {
await act(async () => {
root.render(
<ProviderListContent
onAddProvider={vi.fn()}
onConfigure={vi.fn()}
onRealtimeVoiceChange={vi.fn()}
onToggle={vi.fn()}
onVoiceInputChange={vi.fn()}
onVoiceOutputChange={vi.fn()}
providers={providers}
/>,
);
});
expect(container.textContent).toContain("Model Providers");
expect(container.textContent).toContain("Mode Providers");
expect(container.textContent).toContain("Scroll to view more providers");
expect(
container.querySelector('[aria-label="Model provider list"]')?.className,
).toContain("h-[80vh]");
expect(
container.querySelector('[data-slot="mode-providers"]')?.className,
).toContain("h-[80vh]");
expect(
container.querySelector('[aria-label="Voice input provider"]'),
).not.toBeNull();
expect(
container.querySelector('[aria-label="Search model providers"]'),
).not.toBeNull();
expect(
container.querySelector('[aria-label="Search providers"]'),
).toBeNull();
expect(
container
.querySelector('[aria-label="Voice input provider"]')
?.closest("[class*='col-start-2']"),
).not.toBeNull();
expect(
container.querySelector('[aria-label="Toggle model providers"]'),
).toBeNull();
expect(
container.querySelector('[aria-label="Toggle mode providers"]'),
).toBeNull();
expect(
[...container.querySelectorAll("button")].find(
(button) => button.textContent === "Modes",
),
).toBeUndefined();
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
it("hides mode providers while a model provider is selected", async () => {
await act(async () => {
root.render(
<ProviderListContent
onAddProvider={vi.fn()}
onConfigure={vi.fn()}
onRealtimeVoiceChange={vi.fn()}
onToggle={vi.fn()}
onVoiceInputChange={vi.fn()}
onVoiceOutputChange={vi.fn()}
providers={providers}
selectedProviderId="openai"
/>,
);
});
expect(container.textContent).not.toContain("Mode Providers");
expect(
[...container.querySelectorAll("button")].find(
(button) => button.textContent === "Modes",
),
).toBeUndefined();
});
it("focuses the always-visible provider search with Cmd+F or Ctrl+F", async () => {
await act(async () => {
root.render(
<ProviderListContent
onAddProvider={vi.fn()}
onConfigure={vi.fn()}
onRealtimeVoiceChange={vi.fn()}
onToggle={vi.fn()}
onVoiceInputChange={vi.fn()}
onVoiceOutputChange={vi.fn()}
providers={providers}
/>,
);
});
const cmdFind = new KeyboardEvent("keydown", {
key: "f",
metaKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => window.dispatchEvent(cmdFind));
const searchInput = container.querySelector<HTMLInputElement>(
'[aria-label="Search model providers"]',
);
expect(cmdFind.defaultPrevented).toBe(true);
expect(document.activeElement).toBe(searchInput);
searchInput?.blur();
const ctrlFind = new KeyboardEvent("keydown", {
key: "F",
ctrlKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => window.dispatchEvent(ctrlFind));
expect(ctrlFind.defaultPrevented).toBe(true);
expect(document.activeElement).toBe(searchInput);
});
it("lets the user choose and clear the voice provider and model", async () => {
const onVoiceInputChange = vi.fn();
let selection: VoiceInputModeSettings | undefined = {
providerId: "elevenlabs",
modelId: "scribe_v2",
};
const render = async () => {
await act(async () => {
root.render(
<ProviderListContent
onAddProvider={vi.fn()}
onConfigure={vi.fn()}
onRealtimeVoiceChange={vi.fn()}
onToggle={vi.fn()}
onVoiceInputChange={onVoiceInputChange}
onVoiceOutputChange={vi.fn()}
providers={providers}
voiceInput={selection}
/>,
);
});
};
await render();
const providerSelect = container.querySelector<HTMLSelectElement>(
'[aria-label="Voice input provider"]',
);
const modelSelect = container.querySelector<HTMLSelectElement>(
'[aria-label="Voice input model"]',
);
expect(providerSelect?.value).toBe("elevenlabs");
expect(modelSelect?.value).toBe("scribe_v2");
await act(async () => {
if (!providerSelect) return;
providerSelect.value = "groq";
providerSelect.dispatchEvent(new Event("change", { bubbles: true }));
});
expect(onVoiceInputChange).toHaveBeenLastCalledWith({
providerId: "groq",
modelId: "whisper-large-v3",
});
selection = {
providerId: "groq",
modelId: "whisper-large-v3",
};
await render();
const groqModelSelect = container.querySelector<HTMLSelectElement>(
'[aria-label="Voice input model"]',
);
await act(async () => {
if (!groqModelSelect) return;
groqModelSelect.value = "whisper-large-v3-turbo";
groqModelSelect.dispatchEvent(new Event("change", { bubbles: true }));
});
expect(onVoiceInputChange).toHaveBeenLastCalledWith({
providerId: "groq",
modelId: "whisper-large-v3-turbo",
});
const groqProviderSelect = container.querySelector<HTMLSelectElement>(
'[aria-label="Voice input provider"]',
);
await act(async () => {
if (!groqProviderSelect) return;
groqProviderSelect.value = "";
groqProviderSelect.dispatchEvent(new Event("change", { bubbles: true }));
});
expect(onVoiceInputChange).toHaveBeenLastCalledWith(undefined);
});
it("lets the user configure a text-to-audio model and provider voice", async () => {
const onVoiceOutputChange = vi.fn();
let selection: VoiceOutputModeSettings | undefined;
const render = async () => {
await act(async () => {
root.render(
<ProviderListContent
onAddProvider={vi.fn()}
onConfigure={vi.fn()}
onRealtimeVoiceChange={vi.fn()}
onToggle={vi.fn()}
onVoiceInputChange={vi.fn()}
onVoiceOutputChange={onVoiceOutputChange}
providers={providers}
voiceOutput={selection}
/>,
);
});
};
await render();
const providerSelect = container.querySelector<HTMLSelectElement>(
'[aria-label="Voice output provider"]',
);
await act(async () => {
if (!providerSelect) return;
providerSelect.value = "elevenlabs";
providerSelect.dispatchEvent(new Event("change", { bubbles: true }));
});
expect(onVoiceOutputChange).toHaveBeenLastCalledWith({
providerId: "elevenlabs",
modelId: "eleven_turbo_v2_5",
});
selection = {
providerId: "elevenlabs",
modelId: "eleven_turbo_v2_5",
};
await render();
const voiceInput = container.querySelector<HTMLInputElement>(
'[aria-label="Voice output voice"]',
);
await act(async () => {
if (!voiceInput) return;
const setValue = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)?.set;
setValue?.call(voiceInput, "voice-123");
voiceInput.dispatchEvent(new Event("input", { bubbles: true }));
voiceInput.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
});
expect(onVoiceOutputChange).toHaveBeenLastCalledWith({
providerId: "elevenlabs",
modelId: "eleven_turbo_v2_5",
voice: "voice-123",
});
});
it("lets the user configure a realtime audio model without hard-coding a voice", async () => {
const onRealtimeVoiceChange = vi.fn();
let selection: RealtimeVoiceModeSettings | undefined;
const render = async () => {
await act(async () => {
root.render(
<ProviderListContent
onAddProvider={vi.fn()}
onConfigure={vi.fn()}
onRealtimeVoiceChange={onRealtimeVoiceChange}
onToggle={vi.fn()}
onVoiceInputChange={vi.fn()}
onVoiceOutputChange={vi.fn()}
providers={providers}
realtimeVoice={selection}
/>,
);
});
};
await render();
const providerSelect = container.querySelector<HTMLSelectElement>(
'[aria-label="Realtime voice provider"]',
);
await act(async () => {
if (!providerSelect) return;
providerSelect.value = "gemini";
providerSelect.dispatchEvent(new Event("change", { bubbles: true }));
});
expect(onRealtimeVoiceChange).toHaveBeenLastCalledWith({
providerId: "gemini",
modelId: "gemini-3.5-live-translate-preview",
});
selection = {
providerId: "gemini",
modelId: "gemini-3.5-live-translate-preview",
};
await render();
expect(
container.querySelector<HTMLSelectElement>(
'[aria-label="Realtime voice model"]',
)?.value,
).toBe("gemini-3.5-live-translate-preview");
expect(
container.querySelector<HTMLInputElement>(
'[aria-label="Realtime voice name"]',
)?.placeholder,
).toBe("Provider default");
});
});
describe("ProviderDetailContent model capabilities", () => {
it("shows image, audio, and reasoning icons beside supported models", async () => {
const provider: Provider = {
id: "capability-provider",
name: "Capability Provider",
models: 3,
color: "#000000",
letter: "CP",
enabled: true,
modelList: [
{
id: "audio-input",
name: "Audio Input",
inputModalities: ["audio"],
outputModalities: ["text"],
},
{
id: "audio-output",
name: "Audio Output",
inputModalities: ["text"],
outputModalities: ["audio"],
},
{
id: "reasoning-vision",
name: "Reasoning Vision",
supportsReasoning: true,
supportsVision: true,
},
],
};
await act(async () => {
root.render(
<ProviderDetailContent
onBack={vi.fn()}
onUpdate={vi.fn()}
provider={provider}
/>,
);
});
expect(
container.querySelectorAll(
'[role="img"][aria-label="Audio support"] .lucide-mic',
),
).toHaveLength(2);
expect(
container.querySelector(
'[role="img"][aria-label="Image support"] .lucide-image',
),
).not.toBeNull();
expect(
container.querySelector(
'[role="img"][aria-label="Reasoning support"] .lucide-brain',
),
).not.toBeNull();
});
it("persists favorites, sorts them first, and adds models", async () => {
window.localStorage.clear();
const onUpdateModels = vi.fn();
const provider: Provider = {
id: "ollama",
name: "Ollama",
models: 2,
color: "#000",
letter: "OL",
enabled: true,
modelList: [
{ id: "alpha", name: "Alpha" },
{ id: "beta", name: "Beta" },
],
};
await act(async () => {
root.render(
<ProviderDetailContent
@@ -20,7 +20,7 @@ import {
Star,
X,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useEffect, useId, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -28,7 +28,10 @@ import { Switch } from "@/components/ui/switch";
import { openExternalUrl } from "@/lib/desktop-client";
import { getProviderApiKeyUrl } from "@/lib/provider-key-urls";
import {
hasRealtimeVoiceTransport,
isDedicatedTranscriptionModel,
isRealtimeVoiceModel,
isSpeechGenerationModel,
supportsAudio,
} from "@/lib/provider-model-catalog";
import type {
@@ -36,7 +39,9 @@ import type {
ProviderConfigField,
ProviderConfigFieldPrimitive,
ProviderSettingsUpdate,
RealtimeVoiceModeSettings,
VoiceInputSelection,
VoiceOutputModeSettings,
} from "@/lib/provider-schema";
import { cn } from "@/lib/utils";
@@ -132,23 +137,41 @@ export function ProviderListContent({
onConfigure,
onAddProvider,
onVoiceInputChange,
onVoiceOutputChange = () => undefined,
onRealtimeVoiceChange = () => undefined,
selectedProviderId,
variant = "page",
voiceInput,
voiceInputSaving = false,
voiceOutput,
voiceOutputSaving = false,
realtimeVoice,
realtimeVoiceSaving = false,
}: {
providers: Provider[];
onToggle: (id: string) => void;
onConfigure: (id: string) => void;
onAddProvider: () => void;
onVoiceInputChange: (selection: VoiceInputSelection | undefined) => void;
onVoiceOutputChange?: (
selection: VoiceOutputModeSettings | undefined,
) => void;
onRealtimeVoiceChange?: (
selection: RealtimeVoiceModeSettings | undefined,
) => void;
selectedProviderId?: string | null;
variant?: "page" | "panel";
voiceInput?: VoiceInputSelection;
voiceInputSaving?: boolean;
voiceOutput?: VoiceOutputModeSettings;
voiceOutputSaving?: boolean;
realtimeVoice?: RealtimeVoiceModeSettings;
realtimeVoiceSaving?: boolean;
}) {
const [providerSearchOpen, setProviderSearchOpen] = useState(false);
const [providerSearch, setProviderSearch] = useState("");
const providerSearchInputRef = useRef<HTMLInputElement>(null);
const voiceOutputInputId = useId();
const realtimeVoiceInputId = useId();
const enabledProviderCount = providers.filter(
(provider) => provider.enabled,
).length;
@@ -159,6 +182,7 @@ export function ProviderListContent({
)
: providers;
const isPanel = variant === "panel";
const showModeProviders = !selectedProviderId;
const voiceProviders = providers
.filter((provider) => provider.enabled)
.map((provider) => ({
@@ -170,19 +194,80 @@ export function ProviderListContent({
(entry) => entry.provider.id === voiceInput?.providerId,
);
const selectedVoiceModels = selectedVoiceProvider?.models ?? [];
const speechProviders = providers
.filter((provider) => provider.enabled)
.map((provider) => ({
provider,
models: (provider.modelList ?? []).filter(isSpeechGenerationModel),
}))
.filter((entry) => entry.models.length > 0);
const selectedSpeechProvider = speechProviders.find(
(entry) => entry.provider.id === voiceOutput?.providerId,
);
const selectedSpeechModels = selectedSpeechProvider?.models ?? [];
const realtimeProviders = providers
.filter(
(provider) => provider.enabled && hasRealtimeVoiceTransport(provider.id),
)
.map((provider) => ({
provider,
models: (provider.modelList ?? []).filter(isRealtimeVoiceModel),
}))
.filter((entry) => entry.models.length > 0);
const selectedRealtimeProvider = realtimeProviders.find(
(entry) => entry.provider.id === realtimeVoice?.providerId,
);
const selectedRealtimeModels = selectedRealtimeProvider?.models ?? [];
const [voiceDraft, setVoiceDraft] = useState(voiceOutput?.voice ?? "");
const [realtimeVoiceDraft, setRealtimeVoiceDraft] = useState(
realtimeVoice?.voice ?? "",
);
useEffect(
() => setVoiceDraft(voiceOutput?.voice ?? ""),
[voiceOutput?.voice],
);
useEffect(
() => setRealtimeVoiceDraft(realtimeVoice?.voice ?? ""),
[realtimeVoice?.voice],
);
useEffect(() => {
const handleFindShortcut = (event: KeyboardEvent) => {
if (
event.key.toLowerCase() !== "f" ||
(!event.metaKey && !event.ctrlKey) ||
event.altKey ||
event.shiftKey
) {
return;
}
event.preventDefault();
providerSearchInputRef.current?.focus();
providerSearchInputRef.current?.select();
};
window.addEventListener("keydown", handleFindShortcut);
return () => window.removeEventListener("keydown", handleFindShortcut);
}, []);
const defaultVoiceForProvider = (providerId: string): string | undefined => {
if (providerId === "gemini") return "Kore";
if (providerId === "elevenlabs") return undefined;
return "alloy";
};
return (
<ScrollArea className="h-full">
<div
className={cn(
"py-10 max-[720px]:px-4 max-[720px]:py-5",
"grid gap-x-8 py-10 max-[720px]:px-4 max-[720px]:py-5",
showModeProviders
? "grid-cols-2 max-[1000px]:grid-cols-1"
: "grid-cols-1",
isPanel ? "px-8" : "px-18 max-[1200px]:px-8",
)}
>
<div
className={cn(
"mb-8 flex items-start justify-between gap-6 max-[860px]:flex-col max-[860px]:items-stretch",
isPanel ? "max-w-none" : "max-w-2xl",
"order-1 mb-4 flex items-start justify-between gap-6 max-[860px]:flex-col max-[860px]:items-stretch",
isPanel ? "max-w-none" : "max-w-[42rem]",
)}
>
<div className="min-w-0">
@@ -200,16 +285,6 @@ export function ProviderListContent({
</p>
</div>
<div className="flex shrink-0 items-center gap-2 max-[860px]:justify-start">
<Button
aria-label="Search providers"
className="size-8 rounded-md"
onClick={() => setProviderSearchOpen((open) => !open)}
size="icon-sm"
type="button"
variant={providerSearchOpen ? "default" : "secondary"}
>
<Search className="size-4" />
</Button>
<Button
className="h-8 rounded-md bg-foreground px-3 text-sm text-background hover:bg-foreground/90"
onClick={onAddProvider}
@@ -221,153 +296,384 @@ export function ProviderListContent({
</div>
</div>
<div
className={cn(
"mb-7 border-y py-4",
isPanel ? "max-w-none" : "max-w-[42rem]",
)}
>
<div className="mb-3">
<h2 className="text-[17px] font-semibold text-foreground">
Voice input
</h2>
<p className="mt-1 text-sm leading-5 text-muted-foreground">
Choose the configured audio-to-text model used by the microphone
in chat. Streaming models show text live; other models transcribe
after recording stops.
</p>
</div>
<div className="grid grid-cols-2 gap-3 max-[720px]:grid-cols-1">
<label className="space-y-1.5 text-sm text-muted-foreground">
<span>Provider</span>
<select
aria-label="Voice input provider"
className="h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
disabled={voiceInputSaving}
onChange={(event) => {
const providerId = event.target.value;
if (!providerId) {
onVoiceInputChange(undefined);
return;
}
const entry = voiceProviders.find(
(candidate) => candidate.provider.id === providerId,
);
const modelId = entry?.models[0]?.id;
if (modelId) {
onVoiceInputChange({ providerId, modelId });
}
}}
value={selectedVoiceProvider?.provider.id ?? ""}
>
<option value="">Not configured</option>
{voiceProviders.map(({ provider }) => (
<option key={provider.id} value={provider.id}>
{provider.name}
</option>
))}
</select>
</label>
<label className="space-y-1.5 text-sm text-muted-foreground">
<span>Model</span>
<select
aria-label="Voice input model"
className="h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
disabled={!selectedVoiceProvider || voiceInputSaving}
onChange={(event) => {
if (!selectedVoiceProvider || !event.target.value) return;
onVoiceInputChange({
providerId: selectedVoiceProvider.provider.id,
modelId: event.target.value,
});
}}
value={voiceInput?.modelId ?? ""}
>
{selectedVoiceModels.length === 0 ? (
<option value="">Enable an audio provider first</option>
) : null}
{selectedVoiceModels.map((model) => (
<option key={model.id} value={model.id}>
{model.name}
{model.operationModes?.includes("streaming")
? " (Live)"
: ""}
</option>
))}
</select>
</label>
</div>
</div>
{showModeProviders ? (
<div
className={cn(
"order-3 col-start-2 row-start-1 row-span-3 h-[80vh] overflow-y-auto border-y py-4 max-[1000px]:col-start-1 max-[1000px]:row-start-4 max-[1000px]:row-span-1 max-[1000px]:mt-8",
isPanel ? "max-w-none" : "max-w-[42rem]",
)}
data-slot="mode-providers"
>
<div className="mb-6">
<h2 className="text-[24px] font-semibold leading-[1.15] text-foreground">
Mode Providers
</h2>
<p className="mt-1 text-sm text-muted-foreground">
Configure providers used by voice modes.
</p>
</div>
{providerSearchOpen ? (
<div className={cn("mb-4", isPanel ? "max-w-none" : "max-w-2xl")}>
<div className="flex h-9 items-center gap-2 rounded border bg-background px-3">
<Search className="size-4 shrink-0 text-muted-foreground" />
<Input
aria-label="Search model providers"
autoFocus
className="h-7 border-0 bg-transparent px-0 text-sm"
onChange={(event) => setProviderSearch(event.target.value)}
placeholder="Search providers"
value={providerSearch}
/>
<div className="mb-3">
<h2 className="text-[17px] font-semibold text-foreground">
Voice input
</h2>
<p className="mt-1 text-sm leading-5 text-muted-foreground">
Choose the audio-to-text model used by the microphone in chat.
</p>
</div>
<div className="grid grid-cols-2 gap-3 max-[720px]:grid-cols-1">
<label className="space-y-1.5 text-sm text-muted-foreground">
<span>Provider</span>
<select
aria-label="Voice input provider"
className="h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
disabled={voiceInputSaving}
onChange={(event) => {
const providerId = event.target.value;
if (!providerId) return onVoiceInputChange(undefined);
const modelId = voiceProviders.find(
(entry) => entry.provider.id === providerId,
)?.models[0]?.id;
if (modelId) onVoiceInputChange({ providerId, modelId });
}}
value={selectedVoiceProvider?.provider.id ?? ""}
>
<option value="">Not configured</option>
{voiceProviders.map(({ provider }) => (
<option key={provider.id} value={provider.id}>
{provider.name}
</option>
))}
</select>
</label>
<label className="space-y-1.5 text-sm text-muted-foreground">
<span>Model</span>
<select
aria-label="Voice input model"
className="h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground disabled:opacity-50"
disabled={!selectedVoiceProvider || voiceInputSaving}
onChange={(event) =>
selectedVoiceProvider &&
event.target.value &&
onVoiceInputChange({
providerId: selectedVoiceProvider.provider.id,
modelId: event.target.value,
})
}
value={voiceInput?.modelId ?? ""}
>
{selectedVoiceModels.length === 0 ? (
<option value="">Enable an audio provider first</option>
) : null}
{selectedVoiceModels.map((model) => (
<option key={model.id} value={model.id}>
{model.name}
{model.operationModes?.includes("streaming")
? " (Live)"
: ""}
</option>
))}
</select>
</label>
</div>
<div className="mt-6 border-t pt-5">
<div className="mb-3">
<h2 className="text-[17px] font-semibold text-foreground">
Voice playback
</h2>
<p className="mt-1 text-sm leading-5 text-muted-foreground">
Choose a text-to-audio model and provider voice.
</p>
</div>
<div className="grid grid-cols-3 gap-3 max-[720px]:grid-cols-1">
<label className="space-y-1.5 text-sm text-muted-foreground">
<span>Provider</span>
<select
aria-label="Voice output provider"
className="h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground"
disabled={voiceOutputSaving}
onChange={(event) => {
const providerId = event.target.value;
if (!providerId) return onVoiceOutputChange(undefined);
const modelId = speechProviders.find(
(entry) => entry.provider.id === providerId,
)?.models[0]?.id;
if (modelId) {
const voice = defaultVoiceForProvider(providerId);
onVoiceOutputChange({
providerId,
modelId,
...(voice ? { voice } : {}),
});
}
}}
value={selectedSpeechProvider?.provider.id ?? ""}
>
<option value="">Not configured</option>
{speechProviders.map(({ provider }) => (
<option key={provider.id} value={provider.id}>
{provider.name}
</option>
))}
</select>
</label>
<label className="space-y-1.5 text-sm text-muted-foreground">
<span>Model</span>
<select
aria-label="Voice output model"
className="h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground disabled:opacity-50"
disabled={!selectedSpeechProvider || voiceOutputSaving}
onChange={(event) =>
selectedSpeechProvider &&
event.target.value &&
onVoiceOutputChange({
providerId: selectedSpeechProvider.provider.id,
modelId: event.target.value,
...(voiceOutput?.voice
? { voice: voiceOutput.voice }
: {}),
})
}
value={voiceOutput?.modelId ?? ""}
>
{selectedSpeechModels.length === 0 ? (
<option value="">
Enable a text-to-audio provider first
</option>
) : null}
{selectedSpeechModels.map((model) => (
<option key={model.id} value={model.id}>
{model.name}
</option>
))}
</select>
</label>
<label
className="space-y-1.5 text-sm text-muted-foreground"
htmlFor={voiceOutputInputId}
>
<span>Voice</span>
<Input
aria-label="Voice output voice"
disabled={!selectedSpeechProvider || voiceOutputSaving}
id={voiceOutputInputId}
onBlur={() => {
if (
!selectedSpeechProvider ||
!voiceOutput ||
voiceDraft.trim() === (voiceOutput.voice ?? "")
)
return;
onVoiceOutputChange({
providerId: selectedSpeechProvider.provider.id,
modelId: voiceOutput.modelId,
...(voiceDraft.trim()
? { voice: voiceDraft.trim() }
: {}),
});
}}
onChange={(event) => setVoiceDraft(event.target.value)}
placeholder={
selectedSpeechProvider?.provider.id === "elevenlabs"
? "ElevenLabs voice ID"
: selectedSpeechProvider?.provider.id === "gemini"
? "Kore"
: "alloy"
}
value={voiceDraft}
/>
</label>
</div>
</div>
<div className="mt-6 border-t pt-5">
<div className="mb-3">
<h2 className="text-[17px] font-semibold text-foreground">
Realtime voice
</h2>
<p className="mt-1 text-sm leading-5 text-muted-foreground">
Choose an audio-in/audio-out model for low-latency
conversation.
</p>
</div>
<div className="grid grid-cols-3 gap-3 max-[720px]:grid-cols-1">
<label className="space-y-1.5 text-sm text-muted-foreground">
<span>Provider</span>
<select
aria-label="Realtime voice provider"
className="h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground"
disabled={realtimeVoiceSaving}
onChange={(event) => {
const providerId = event.target.value;
if (!providerId) return onRealtimeVoiceChange(undefined);
const modelId = realtimeProviders.find(
(entry) => entry.provider.id === providerId,
)?.models[0]?.id;
if (modelId)
onRealtimeVoiceChange({ providerId, modelId });
}}
value={selectedRealtimeProvider?.provider.id ?? ""}
>
<option value="">Not configured</option>
{realtimeProviders.map(({ provider }) => (
<option key={provider.id} value={provider.id}>
{provider.name}
</option>
))}
</select>
</label>
<label className="space-y-1.5 text-sm text-muted-foreground">
<span>Model</span>
<select
aria-label="Realtime voice model"
className="h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground disabled:opacity-50"
disabled={!selectedRealtimeProvider || realtimeVoiceSaving}
onChange={(event) =>
selectedRealtimeProvider &&
event.target.value &&
onRealtimeVoiceChange({
providerId: selectedRealtimeProvider.provider.id,
modelId: event.target.value,
...(realtimeVoice?.voice
? { voice: realtimeVoice.voice }
: {}),
})
}
value={realtimeVoice?.modelId ?? ""}
>
{selectedRealtimeModels.length === 0 ? (
<option value="">
Enable OpenAI, Gemini, or Vercel AI Gateway first
</option>
) : null}
{selectedRealtimeModels.map((model) => (
<option key={model.id} value={model.id}>
{model.name}
</option>
))}
</select>
</label>
<label
className="space-y-1.5 text-sm text-muted-foreground"
htmlFor={realtimeVoiceInputId}
>
<span>Voice</span>
<Input
aria-label="Realtime voice name"
disabled={!selectedRealtimeProvider || realtimeVoiceSaving}
id={realtimeVoiceInputId}
onBlur={() => {
if (
!selectedRealtimeProvider ||
!realtimeVoice ||
realtimeVoiceDraft.trim() ===
(realtimeVoice.voice ?? "")
)
return;
onRealtimeVoiceChange({
providerId: selectedRealtimeProvider.provider.id,
modelId: realtimeVoice.modelId,
...(realtimeVoiceDraft.trim()
? { voice: realtimeVoiceDraft.trim() }
: {}),
});
}}
onChange={(event) =>
setRealtimeVoiceDraft(event.target.value)
}
placeholder={
selectedRealtimeProvider
? "Provider default"
: "Select a provider first"
}
value={realtimeVoiceDraft}
/>
</label>
</div>
</div>
</div>
) : null}
<div
className={cn(
"overflow-hidden",
isPanel ? "max-w-none" : "max-w-2xl",
"order-2 col-start-1 row-start-2 mb-4",
isPanel ? "max-w-none" : "max-w-[42rem]",
)}
>
{filteredProviders.length === 0 ? (
<div className="border-b px-2 py-6 text-base text-muted-foreground">
No providers match "{providerSearch.trim()}".
</div>
) : null}
{filteredProviders.map((prov) => (
<div
className={cn(
"flex min-h-11 items-center gap-4 border-b px-2 py-2 hover:bg-surface-hover-lighter",
selectedProviderId === prov.id && "bg-surface-hover",
)}
key={prov.id}
>
<button
className="flex min-w-0 flex-1 items-center gap-3 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onConfigure(prov.id)}
type="button"
>
<div className="flex min-w-0 flex-1 items-baseline gap-2">
<p className="truncate text-lg font-semibold text-foreground">
{prov.name}
</p>
<p className="shrink-0 truncate font-mono text-xs text-muted-foreground">
{prov.id}
</p>
<div className="flex h-9 items-center gap-2 rounded border bg-background px-3">
<Search className="size-4 shrink-0 text-muted-foreground" />
<Input
aria-label="Search model providers"
className="h-7 border-0 bg-transparent px-0 text-sm"
onChange={(event) => setProviderSearch(event.target.value)}
placeholder="Search providers"
ref={providerSearchInputRef}
value={providerSearch}
/>
</div>
</div>
<div
className={cn(
"order-2 col-start-1 row-start-3 overflow-hidden rounded-md border shadow-inner",
isPanel ? "max-w-none" : "max-w-[42rem]",
)}
>
<ScrollArea aria-label="Model provider list" className="h-[80vh]">
<div>
{filteredProviders.length === 0 ? (
<div className="border-b px-2 py-6 text-[15px] text-muted-foreground">
No providers match "{providerSearch.trim()}".
</div>
<p className="shrink-0 text-[15px] text-muted-foreground">
{prov.models === null
? "Models load on demand"
: `${prov.models} model${prov.models !== 1 ? "s" : ""}`}
</p>
</button>
<Switch
aria-label={`Toggle ${prov.name}`}
checked={prov.enabled}
onCheckedChange={() => onToggle(prov.id)}
/>
<button
aria-label={`Configure ${prov.name}`}
className="grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-surface-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onConfigure(prov.id)}
type="button"
>
<ChevronRight className="size-4" />
</button>
) : null}
{filteredProviders.map((prov) => (
<div
className={cn(
"flex min-h-11 items-center gap-4 border-b px-2 py-2 transition-colors hover:bg-surface-hover-lighter",
selectedProviderId === prov.id && "bg-surface-hover",
)}
key={prov.id}
>
<button
className="flex min-w-0 flex-1 items-center gap-3 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onConfigure(prov.id)}
type="button"
>
<div className="flex min-w-0 flex-1 items-baseline gap-2">
<p className="truncate text-[17px] font-semibold text-foreground">
{prov.name}
</p>
<p className="shrink-0 truncate font-mono text-xs text-muted-foreground">
{prov.id}
</p>
</div>
<p className="shrink-0 text-[15px] text-muted-foreground">
{prov.models === null
? "Models load on demand"
: `${prov.models} model${prov.models !== 1 ? "s" : ""}`}
</p>
</button>
<Switch
aria-label={`Toggle ${prov.name}`}
checked={prov.enabled}
onCheckedChange={() => onToggle(prov.id)}
/>
<button
aria-label={`Configure ${prov.name}`}
className="grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-surface-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onConfigure(prov.id)}
type="button"
>
<ChevronRight className="size-4" />
</button>
</div>
))}
</div>
))}
</ScrollArea>
<div className="border-t bg-muted/60 px-3 py-1.5 text-center text-xs text-muted-foreground">
Scroll to view more providers
</div>
</div>
</div>
</ScrollArea>
@@ -0,0 +1,178 @@
// @vitest-environment jsdom
import { act, type HTMLAttributes } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { RemoteEnvironmentProfile } from "@/lib/remote-environments";
import { RemoteEnvironmentsContent } from "./remote-environments-view";
const { invokeMock } = vi.hoisted(() => ({
invokeMock: vi.fn(),
}));
vi.mock("@/lib/desktop-client", () => ({
desktopClient: { invoke: invokeMock },
}));
vi.mock("@/components/ui/scroll-area", () => ({
ScrollArea: ({ children, ...props }: HTMLAttributes<HTMLDivElement>) => (
<div {...props}>{children}</div>
),
}));
const profile: RemoteEnvironmentProfile = {
id: "build-box",
name: "Build box",
host: "builder.example.com",
user: "ubuntu",
port: 22,
identityFile: "~/.ssh/id_ed25519",
};
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
invokeMock.mockReset();
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.restoreAllMocks();
});
function buttonWithText(text: string): HTMLButtonElement {
const button = [
...container.querySelectorAll<HTMLButtonElement>("button"),
].find((candidate) => candidate.textContent?.includes(text));
expect(button).toBeDefined();
return button as HTMLButtonElement;
}
function inputById(id: string): HTMLInputElement {
const input = container.querySelector<HTMLInputElement>(`#${id}`);
expect(input).not.toBeNull();
return input as HTMLInputElement;
}
async function click(element: Element): Promise<void> {
await act(async () => {
element.dispatchEvent(
new MouseEvent("click", { bubbles: true, cancelable: true }),
);
await Promise.resolve();
});
}
describe("RemoteEnvironmentsContent", () => {
it("locks a saved profile destination while leaving editable metadata available", async () => {
invokeMock.mockImplementation(async (command: string) => {
if (command === "list_remote_environments") {
return { profiles: [profile], activeProfileId: null };
}
throw new Error(`Unexpected command: ${command}`);
});
await act(async () => {
root.render(<RemoteEnvironmentsContent />);
});
await vi.waitFor(() => {
expect(inputById("remote-name").value).toBe("Build box");
});
expect(inputById("remote-host").disabled).toBe(true);
expect(inputById("remote-user").disabled).toBe(true);
expect(inputById("remote-port").disabled).toBe(true);
expect(inputById("remote-name").disabled).toBe(false);
expect(inputById("remote-identity").disabled).toBe(false);
expect(container.textContent).toContain(
"Create a new host to change the SSH host, user, or port.",
);
await click(buttonWithText("New host"));
expect(inputById("remote-host").disabled).toBe(false);
expect(inputById("remote-user").disabled).toBe(false);
expect(inputById("remote-port").disabled).toBe(false);
expect(container.textContent).not.toContain(
"Create a new host to change the SSH host, user, or port.",
);
});
it("keeps settings limited to saving and testing SSH hosts", async () => {
invokeMock.mockImplementation(async (command: string) => {
switch (command) {
case "list_remote_environments":
return { profiles: [profile], activeProfileId: profile.id };
case "upsert_remote_environment":
return { profile };
default:
throw new Error(`Unexpected command: ${command}`);
}
});
await act(async () => {
root.render(<RemoteEnvironmentsContent />);
});
await vi.waitFor(() => {
expect(container.textContent).toContain("Build box");
expect(buttonWithText("Save").disabled).toBe(false);
});
expect(container.querySelector("#remote-workspace")).toBeNull();
expect(container.textContent).not.toContain("Connect & Open");
expect(container.textContent).not.toContain("Disconnect");
expect(container.textContent).toContain(
"Connect from the environment selector beside the workspace picker.",
);
expect(container.textContent).toContain(
"v0 requires key-based or agent authentication; it cannot show an interactive password prompt.",
);
await click(buttonWithText("Save"));
await vi.waitFor(() => {
expect(invokeMock).toHaveBeenCalledTimes(2);
});
expect(invokeMock).toHaveBeenNthCalledWith(2, "upsert_remote_environment", {
profile,
});
expect(container.textContent).toContain("Connected");
expect(container.textContent).toContain("Ready");
});
it("keeps a failed SSH test visible on its profile", async () => {
invokeMock.mockImplementation(async (command: string) => {
switch (command) {
case "list_remote_environments":
return { profiles: [profile], activeProfileId: null };
case "upsert_remote_environment":
return { profile };
case "test_remote_environment":
throw new Error("Permission denied (publickey)");
default:
throw new Error(`Unexpected command: ${command}`);
}
});
await act(async () => {
root.render(<RemoteEnvironmentsContent />);
});
await vi.waitFor(() => {
expect(buttonWithText("Test connection").disabled).toBe(false);
});
await click(buttonWithText("Test connection"));
await vi.waitFor(() => {
expect(container.textContent).toContain("Permission denied (publickey)");
});
expect(container.textContent).toContain("Failed");
expect(invokeMock).toHaveBeenNthCalledWith(3, "test_remote_environment", {
id: profile.id,
});
});
});
@@ -0,0 +1,687 @@
"use client";
import {
CheckCircle2,
CircleAlert,
CloudCog,
Loader2,
Plus,
RefreshCw,
Server,
Trash2,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button, buttonVariants } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { desktopClient } from "@/lib/desktop-client";
import {
createRemoteEnvironmentDraft,
DEFAULT_REMOTE_ENVIRONMENT_RUNTIME_STATE,
formatRemoteEnvironmentDestination,
normalizeRemoteEnvironmentProfile,
type RemoteEnvironmentDeleteResult,
type RemoteEnvironmentListResult,
type RemoteEnvironmentProfile,
type RemoteEnvironmentRuntimeState,
type RemoteEnvironmentTestResult,
type RemoteEnvironmentUpsertResult,
validateRemoteEnvironmentProfile,
} from "@/lib/remote-environments";
import { cn } from "@/lib/utils";
import { PageEmptyState, PageFrame, PageHeader } from "../page-layout";
type RemoteAction = "save" | "test" | "delete";
type BusyAction = {
action: RemoteAction;
profileId: string;
};
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function profileIdOrThrow(profile: RemoteEnvironmentProfile): string {
if (!profile.id) {
throw new Error("The desktop backend did not return an SSH profile ID.");
}
return profile.id;
}
function statusLabel(value: string): string {
return value
.split("-")
.map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`)
.join(" ");
}
function StatusBadge({ label, value }: { label: string; value: string }) {
const isPositive =
value === "connected" || value === "passed" || value === "ready";
const isPending =
value === "connecting" ||
value === "disconnecting" ||
value === "testing" ||
value === "installing";
const isError = value === "failed" || value === "error";
return (
<div className="flex min-w-0 items-center justify-between gap-3">
<span className="text-xs text-muted-foreground">{label}</span>
<Badge
className={cn(
isPositive &&
"border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
isPending &&
"border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-300",
isError && "border-destructive/30 bg-destructive/10 text-destructive",
)}
variant="outline"
>
{isPending ? <Loader2 className="animate-spin" /> : null}
{isPositive ? <CheckCircle2 /> : null}
{isError ? <CircleAlert /> : null}
{statusLabel(value)}
</Badge>
</div>
);
}
function runtimeStateFor(
states: Record<string, RemoteEnvironmentRuntimeState>,
profileId: string | undefined,
activeProfileId: string | null,
): RemoteEnvironmentRuntimeState {
if (profileId && states[profileId]) {
return states[profileId];
}
if (profileId && profileId === activeProfileId) {
return {
...DEFAULT_REMOTE_ENVIRONMENT_RUNTIME_STATE,
bootstrap: "ready",
connection: "connected",
};
}
return DEFAULT_REMOTE_ENVIRONMENT_RUNTIME_STATE;
}
export function RemoteEnvironmentsContent() {
const [profiles, setProfiles] = useState<RemoteEnvironmentProfile[]>([]);
const [activeProfileId, setActiveProfileId] = useState<string | null>(null);
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(
null,
);
const [draft, setDraft] = useState<RemoteEnvironmentProfile>(() =>
createRemoteEnvironmentDraft(),
);
const [runtimeStates, setRuntimeStates] = useState<
Record<string, RemoteEnvironmentRuntimeState>
>({});
const [isLoading, setIsLoading] = useState(true);
const [busyAction, setBusyAction] = useState<BusyAction | null>(null);
const [error, setError] = useState<string | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] =
useState<RemoteEnvironmentProfile | null>(null);
const selectedProfile = useMemo(
() => profiles.find((profile) => profile.id === selectedProfileId),
[profiles, selectedProfileId],
);
const selectedRuntime = runtimeStateFor(
runtimeStates,
selectedProfileId ?? draft.id,
activeProfileId,
);
const isBusy = isLoading || busyAction !== null;
const hasSavedDestination = Boolean(draft.id);
const setRuntimeState = useCallback(
(
profileId: string,
updates:
| Partial<RemoteEnvironmentRuntimeState>
| ((
current: RemoteEnvironmentRuntimeState,
) => Partial<RemoteEnvironmentRuntimeState>),
) => {
setRuntimeStates((current) => {
const previous = runtimeStateFor(current, profileId, activeProfileId);
const nextUpdates =
typeof updates === "function" ? updates(previous) : updates;
return {
...current,
[profileId]: { ...previous, ...nextUpdates },
};
});
},
[activeProfileId],
);
const selectProfile = useCallback((profile: RemoteEnvironmentProfile) => {
setSelectedProfileId(profile.id ?? null);
setDraft(createRemoteEnvironmentDraft(profile));
setFormError(null);
setError(null);
}, []);
const startNewProfile = useCallback(() => {
setSelectedProfileId(null);
setDraft(createRemoteEnvironmentDraft());
setFormError(null);
setError(null);
}, []);
const loadProfiles = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const result = await desktopClient.invoke<RemoteEnvironmentListResult>(
"list_remote_environments",
);
setProfiles(result.profiles);
setActiveProfileId(result.activeProfileId);
setRuntimeStates((current) => {
if (!result.activeProfileId) return current;
return {
...current,
[result.activeProfileId]: {
...DEFAULT_REMOTE_ENVIRONMENT_RUNTIME_STATE,
...current[result.activeProfileId],
bootstrap: "ready",
connection: "connected",
},
};
});
const nextProfile =
result.profiles.find(
(profile) => profile.id === result.activeProfileId,
) ?? result.profiles[0];
if (nextProfile) {
setSelectedProfileId(nextProfile.id ?? null);
setDraft(createRemoteEnvironmentDraft(nextProfile));
} else {
startNewProfile();
}
} catch (loadError) {
setError(errorMessage(loadError));
} finally {
setIsLoading(false);
}
}, [startNewProfile]);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
void loadProfiles();
}, 0);
return () => window.clearTimeout(timeoutId);
}, [loadProfiles]);
const updateDraft = <Key extends keyof RemoteEnvironmentProfile>(
key: Key,
value: RemoteEnvironmentProfile[Key],
) => {
setDraft((current) => ({ ...current, [key]: value }));
setFormError(null);
};
const upsertLocalProfile = useCallback(
(profile: RemoteEnvironmentProfile) => {
setProfiles((current) => {
const existingIndex = current.findIndex(
(candidate) => candidate.id === profile.id,
);
if (existingIndex < 0) return [...current, profile];
return current.map((candidate, index) =>
index === existingIndex ? profile : candidate,
);
});
setSelectedProfileId(profile.id ?? null);
setDraft(createRemoteEnvironmentDraft(profile));
},
[],
);
const persistDraft = useCallback(
async (action: RemoteAction): Promise<RemoteEnvironmentProfile> => {
const validationError = validateRemoteEnvironmentProfile(draft);
if (validationError) {
setFormError(validationError);
throw new Error(validationError);
}
const normalized = normalizeRemoteEnvironmentProfile(draft);
setFormError(null);
setError(null);
setBusyAction({ action, profileId: normalized.id ?? "new" });
const result = await desktopClient.invoke<RemoteEnvironmentUpsertResult>(
"upsert_remote_environment",
{ profile: normalized },
);
profileIdOrThrow(result.profile);
upsertLocalProfile(result.profile);
return result.profile;
},
[draft, upsertLocalProfile],
);
const saveProfile = async () => {
try {
await persistDraft("save");
} catch (saveError) {
if (!validateRemoteEnvironmentProfile(draft)) {
setError(errorMessage(saveError));
}
} finally {
setBusyAction(null);
}
};
const testProfile = async () => {
let profile: RemoteEnvironmentProfile;
try {
profile = await persistDraft("test");
} catch (saveError) {
if (!validateRemoteEnvironmentProfile(draft)) {
setError(errorMessage(saveError));
}
setBusyAction(null);
return;
}
const profileId = profileIdOrThrow(profile);
setRuntimeState(profileId, { test: "testing", message: undefined });
setBusyAction({ action: "test", profileId });
try {
const result = await desktopClient.invoke<RemoteEnvironmentTestResult>(
"test_remote_environment",
{ id: profileId },
);
setRuntimeState(profileId, {
test: result.status === "failed" ? "failed" : "passed",
message: result.message,
remotePlatform: result.remotePlatform,
remoteArch: result.remoteArch,
});
} catch (testError) {
setRuntimeState(profileId, {
test: "failed",
message: errorMessage(testError),
});
} finally {
setBusyAction(null);
}
};
const deleteProfile = async (profile: RemoteEnvironmentProfile) => {
const profileId = profileIdOrThrow(profile);
setBusyAction({ action: "delete", profileId });
setError(null);
try {
await desktopClient.invoke<RemoteEnvironmentDeleteResult>(
"delete_remote_environment",
{ id: profileId },
);
const remaining = profiles.filter(
(candidate) => candidate.id !== profileId,
);
setProfiles(remaining);
setRuntimeStates((current) => {
const next = { ...current };
delete next[profileId];
return next;
});
if (activeProfileId === profileId) setActiveProfileId(null);
const nextProfile = remaining[0];
if (nextProfile) selectProfile(nextProfile);
else startNewProfile();
} catch (deleteError) {
setError(errorMessage(deleteError));
} finally {
setBusyAction(null);
setDeleteTarget(null);
}
};
return (
<PageFrame>
<PageHeader
actions={
<>
<Button
disabled={isBusy}
onClick={startNewProfile}
variant="outline"
>
<Plus />
New host
</Button>
<Button
aria-label="Refresh remote environments"
disabled={isLoading || isBusy}
onClick={() => void loadProfiles()}
variant="outline"
>
<RefreshCw className={cn(isLoading && "animate-spin")} />
Refresh
</Button>
</>
}
description="Save SSH hosts and verify access. Connect from the environment selector beside the workspace picker."
icon={CloudCog}
title="Remote environments"
/>
{error ? (
<Alert className="mb-5" variant="destructive">
<CircleAlert />
<AlertTitle>Remote environment error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<div className="grid grid-cols-[minmax(16rem,0.75fr)_minmax(24rem,1.25fr)] gap-5 max-[960px]:grid-cols-1">
<Card className="h-fit gap-4 py-5">
<CardHeader className="px-5">
<CardTitle>SSH hosts</CardTitle>
<CardDescription>
{profiles.length === 1
? "1 configured environment"
: `${profiles.length} configured environments`}
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 px-3">
{isLoading ? (
<div className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading SSH hosts
</div>
) : profiles.length === 0 ? (
<PageEmptyState>
No SSH hosts yet. Add the address for your first remote
environment.
</PageEmptyState>
) : (
profiles.map((profile) => {
const runtime = runtimeStateFor(
runtimeStates,
profile.id,
activeProfileId,
);
const isSelected = profile.id === selectedProfileId;
const isActive = profile.id === activeProfileId;
return (
<button
aria-current={isSelected ? "true" : undefined}
className={cn(
"w-full rounded-lg border border-transparent px-3 py-3 text-left transition-colors hover:bg-accent/60",
isSelected && "border-border bg-accent",
)}
key={profile.id ?? `${profile.host}:${profile.port}`}
onClick={() => selectProfile(profile)}
type="button"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{profile.name}
</p>
<p className="mt-1 truncate font-mono text-xs text-muted-foreground">
{formatRemoteEnvironmentDestination(profile)}
</p>
</div>
{isActive ? (
<Badge
className="border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
variant="outline"
>
Active
</Badge>
) : runtime.connection === "connecting" ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : null}
</div>
</button>
);
})
)}
</CardContent>
</Card>
<Card className="gap-5 py-5">
<CardHeader className="px-5">
<CardTitle>
{selectedProfile
? `Edit ${selectedProfile.name}`
: "Add SSH host"}
</CardTitle>
<CardDescription>
OpenSSH config aliases work in the host field. Explicit values
here override matching SSH config values. v0 requires key-based or
agent authentication; it cannot show an interactive password
prompt.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5 px-5">
<div className="grid grid-cols-2 gap-4 max-[620px]:grid-cols-1">
<div className="space-y-2">
<Label htmlFor="remote-name">Name</Label>
<Input
disabled={isBusy}
id="remote-name"
onChange={(event) => updateDraft("name", event.target.value)}
placeholder="Build server"
value={draft.name}
/>
</div>
<div className="space-y-2">
<Label htmlFor="remote-host">SSH host</Label>
<Input
autoCapitalize="none"
disabled={isBusy || hasSavedDestination}
id="remote-host"
onChange={(event) => updateDraft("host", event.target.value)}
placeholder="dev.example.com or ssh-config-alias"
spellCheck={false}
value={draft.host}
/>
</div>
<div className="space-y-2">
<Label htmlFor="remote-user">User (optional)</Label>
<Input
autoCapitalize="none"
disabled={isBusy || hasSavedDestination}
id="remote-user"
onChange={(event) => updateDraft("user", event.target.value)}
placeholder="ubuntu"
spellCheck={false}
value={draft.user ?? ""}
/>
</div>
<div className="space-y-2">
<Label htmlFor="remote-port">Port</Label>
<Input
disabled={isBusy || hasSavedDestination}
id="remote-port"
max={65_535}
min={1}
onChange={(event) =>
updateDraft(
"port",
event.target.value === ""
? undefined
: Number(event.target.value),
)
}
placeholder="22 (from SSH config by default)"
type="number"
value={
draft.port === undefined || Number.isNaN(draft.port)
? ""
: draft.port
}
/>
</div>
</div>
{hasSavedDestination ? (
<p className="text-xs text-muted-foreground">
Create a new host to change the SSH host, user, or port.
</p>
) : null}
<div className="space-y-2">
<Label htmlFor="remote-identity">Identity file (optional)</Label>
<Input
disabled={isBusy}
id="remote-identity"
onChange={(event) =>
updateDraft("identityFile", event.target.value)
}
placeholder="~/.ssh/id_ed25519"
spellCheck={false}
value={draft.identityFile ?? ""}
/>
</div>
{formError ? (
<p className="text-sm text-destructive">{formError}</p>
) : null}
<div className="rounded-lg border bg-muted/30 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Server className="size-4 text-muted-foreground" />
<p className="text-sm font-medium">Environment status</p>
</div>
{draft.id === activeProfileId ? (
<Badge>Active</Badge>
) : (
<Badge variant="secondary">Inactive</Badge>
)}
</div>
<div className="grid grid-cols-3 gap-x-5 gap-y-2 max-[620px]:grid-cols-1">
<StatusBadge
label="Connection"
value={selectedRuntime.connection}
/>
<StatusBadge label="SSH test" value={selectedRuntime.test} />
<StatusBadge
label="Cline bootstrap"
value={selectedRuntime.bootstrap}
/>
</div>
{selectedRuntime.remotePlatform || selectedRuntime.remoteArch ? (
<p className="mt-3 text-xs text-muted-foreground">
Remote:{" "}
{[selectedRuntime.remotePlatform, selectedRuntime.remoteArch]
.filter(Boolean)
.join(" · ")}
</p>
) : null}
{selectedRuntime.message ? (
<p
className={cn(
"mt-3 text-xs leading-5 text-muted-foreground",
(selectedRuntime.connection === "error" ||
selectedRuntime.test === "failed" ||
selectedRuntime.bootstrap === "failed") &&
"text-destructive",
)}
>
{selectedRuntime.message}
</p>
) : null}
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-5">
<div>
{draft.id ? (
<Button
disabled={isBusy}
onClick={() => setDeleteTarget(draft)}
variant="ghost"
>
<Trash2 />
Delete
</Button>
) : null}
</div>
<div className="flex flex-wrap justify-end gap-2">
<Button
disabled={isBusy}
onClick={() => void saveProfile()}
variant="outline"
>
{busyAction?.action === "save" ? (
<Loader2 className="animate-spin" />
) : null}
Save
</Button>
<Button
disabled={isBusy}
onClick={() => void testProfile()}
variant="outline"
>
{busyAction?.action === "test" ? (
<Loader2 className="animate-spin" />
) : null}
Test connection
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
<AlertDialog
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
open={deleteTarget !== null}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete SSH host?</AlertDialogTitle>
<AlertDialogDescription>
{deleteTarget
? `Delete “${deleteTarget.name}” from remote environments? Projects and Cline session data remain on the remote host.`
: "Delete this SSH host from remote environments?"}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isBusy}>Cancel</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
disabled={isBusy || !deleteTarget}
onClick={() => {
if (deleteTarget) void deleteProfile(deleteTarget);
}}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PageFrame>
);
}
@@ -22,19 +22,28 @@ import {
readStoredAppIcon,
setStoredAppIcon,
} from "@/lib/app-icon";
import {
type AvatarOption,
getSelectedAvatar,
listAvatars,
selectAvatar,
setAvatarEnabled,
} from "@/lib/avatar";
import { desktopClient } from "@/lib/desktop-client";
import { resetOnboarding } from "@/lib/onboarding";
import {
invalidateProviderCatalogCache,
notifyVoiceInputSettingsChanged,
notifyModeSettingsChanged,
publishProviderModels,
} from "@/lib/provider-model-catalog";
import type {
Provider,
ProviderCatalogResponse,
ProviderMode,
ProviderModelsResponse,
ProviderModeSettingsMap,
ProviderModesSettings,
ProviderSettingsUpdate,
VoiceInputSelection,
} from "@/lib/provider-schema";
import {
type HubAccent,
@@ -56,6 +65,7 @@ import {
ProviderDetailContent,
ProviderListContent,
} from "./provider-list-view";
import { RemoteEnvironmentsContent } from "./remote-environments-view";
import { RoutineSchedulesContent } from "./routine-view";
import type { SettingsSection } from "./sections";
import { toSettingsPatch } from "./settings-patch";
@@ -80,7 +90,22 @@ let providerCatalogCache: {
providers: Provider[];
fetchedAt: number;
} | null = null;
let voiceInputCache: VoiceInputSelection | undefined;
let modeSettingsCache: ProviderModesSettings = {};
function removeProviderModes(
modes: ProviderModesSettings,
providerId: string,
): { modes: ProviderModesSettings; removed: ProviderMode[] } {
const next = { ...modes };
const removed: ProviderMode[] = [];
for (const mode of Object.keys(next) as ProviderMode[]) {
if (next[mode]?.providerId === providerId) {
delete next[mode];
removed.push(mode);
}
}
return { modes: next, removed };
}
// -----------------------------------------------------------
// Component
@@ -118,10 +143,11 @@ export function SettingsView({
null,
);
const [addingProvider, setAddingProvider] = useState(false);
const [voiceInput, setVoiceInput] = useState<VoiceInputSelection | undefined>(
() => voiceInputCache,
);
const [voiceInputSaving, setVoiceInputSaving] = useState(false);
const [modeSettings, setModeSettings] =
useState<ProviderModesSettings>(modeSettingsCache);
const [savingModes, setSavingModes] = useState<
Partial<Record<ProviderMode, boolean>>
>({});
useEffect(() => {
if (section !== "Models") {
@@ -154,7 +180,7 @@ export function SettingsView({
now - providerCatalogCache.fetchedAt < PROVIDER_CATALOG_CACHE_TTL_MS
) {
setProviders(providerCatalogCache.providers);
setVoiceInput(voiceInputCache);
setModeSettings(modeSettingsCache);
setProvidersLoading(false);
setProviderCatalogError(null);
return;
@@ -167,8 +193,11 @@ export function SettingsView({
"list_provider_catalog",
);
setProvidersWithCache(payload.providers);
voiceInputCache = payload.voiceInput;
setVoiceInput(payload.voiceInput);
const modes =
payload.modes ??
(payload.voiceInput ? { voiceInput: payload.voiceInput } : {});
modeSettingsCache = modes;
setModeSettings(modes);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setProviderCatalogError(message);
@@ -230,14 +259,17 @@ export function SettingsView({
return p;
}
const nextEnabled = !p.enabled;
const clearsVoiceInput =
!nextEnabled && voiceInput?.providerId === id;
const updatedModes = nextEnabled
? { modes: modeSettings, removed: [] }
: removeProviderModes(modeSettings, id);
void persistProviderSettings(id, { enabled: nextEnabled }).then(
(saved) => {
if (saved && clearsVoiceInput) {
voiceInputCache = undefined;
setVoiceInput(undefined);
notifyVoiceInputSettingsChanged();
if (saved && updatedModes.removed.length > 0) {
modeSettingsCache = updatedModes.modes;
setModeSettings(updatedModes.modes);
for (const mode of updatedModes.removed) {
notifyModeSettingsChanged(mode);
}
}
},
);
@@ -245,27 +277,31 @@ export function SettingsView({
}),
);
},
[persistProviderSettings, setProvidersWithCache, voiceInput],
[persistProviderSettings, setProvidersWithCache, modeSettings],
);
const updateVoiceInput = useCallback(
async (selection: VoiceInputSelection | undefined) => {
setVoiceInputSaving(true);
const updateModeSettings = useCallback(
async <Mode extends ProviderMode>(
mode: Mode,
settings: ProviderModeSettingsMap[Mode] | undefined,
) => {
setSavingModes((current) => ({ ...current, [mode]: true }));
try {
const result = await desktopClient.invoke<{
voiceInput?: VoiceInputSelection;
}>("save_voice_input_settings", {
provider: selection?.providerId,
model: selection?.modelId,
modes: ProviderModesSettings;
}>("save_mode_settings", {
mode,
settings,
});
voiceInputCache = result.voiceInput;
setVoiceInput(result.voiceInput);
notifyVoiceInputSettingsChanged();
const modes = result.modes ?? {};
modeSettingsCache = modes;
setModeSettings(modes);
notifyModeSettingsChanged(mode);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
window.alert(`Failed to save voice input settings: ${message}`);
window.alert(`Failed to save ${mode} settings: ${message}`);
} finally {
setVoiceInputSaving(false);
setSavingModes((current) => ({ ...current, [mode]: false }));
}
},
[],
@@ -462,12 +498,24 @@ export function SettingsView({
onAddProvider={openAddProvider}
onConfigure={openProviderDetail}
onToggle={toggleProvider}
onVoiceInputChange={(selection) => void updateVoiceInput(selection)}
onRealtimeVoiceChange={(settings) =>
void updateModeSettings("realtimeVoice", settings)
}
onVoiceInputChange={(settings) =>
void updateModeSettings("voiceInput", settings)
}
onVoiceOutputChange={(settings) =>
void updateModeSettings("voiceOutput", settings)
}
providers={providers}
realtimeVoice={modeSettings.realtimeVoice}
realtimeVoiceSaving={savingModes.realtimeVoice}
selectedProviderId={selectedProvider.id}
variant="panel"
voiceInput={voiceInput}
voiceInputSaving={voiceInputSaving}
voiceInput={modeSettings.voiceInput}
voiceInputSaving={savingModes.voiceInput}
voiceOutput={modeSettings.voiceOutput}
voiceOutputSaving={savingModes.voiceOutput}
/>
<aside className="min-h-0 overflow-hidden border-l bg-background max-[1100px]:border-l-0 max-[1100px]:border-t">
<ProviderDetailContent
@@ -495,10 +543,22 @@ export function SettingsView({
onAddProvider={openAddProvider}
onConfigure={openProviderDetail}
onToggle={toggleProvider}
onVoiceInputChange={(selection) => void updateVoiceInput(selection)}
onRealtimeVoiceChange={(settings) =>
void updateModeSettings("realtimeVoice", settings)
}
onVoiceInputChange={(settings) =>
void updateModeSettings("voiceInput", settings)
}
onVoiceOutputChange={(settings) =>
void updateModeSettings("voiceOutput", settings)
}
providers={providers}
voiceInput={voiceInput}
voiceInputSaving={voiceInputSaving}
realtimeVoice={modeSettings.realtimeVoice}
realtimeVoiceSaving={savingModes.realtimeVoice}
voiceInput={modeSettings.voiceInput}
voiceInputSaving={savingModes.voiceInput}
voiceOutput={modeSettings.voiceOutput}
voiceOutputSaving={savingModes.voiceOutput}
/>
);
@@ -523,6 +583,8 @@ export function SettingsView({
<ChannelsContent />
) : activeNav === "Schedules" ? (
<RoutineSchedulesContent onOpenSession={onOpenSession} />
) : activeNav === "Remote" ? (
<RemoteEnvironmentsContent />
) : activeNav === "Account" ? (
<AccountView />
) : activeNav === "General" ? (
@@ -576,6 +638,12 @@ function GeneralSettingsContent() {
});
const [appIconError, setAppIconError] = useState<string | null>(null);
const appIconRequestRef = useRef(0);
const [avatars, setAvatars] = useState<AvatarOption[]>([]);
const [selectedAvatarId, setSelectedAvatarId] = useState("cline-bot");
const [avatarEnabled, setAvatarEnabledState] = useState(true);
const [avatarLoading, setAvatarLoading] = useState(true);
const [avatarSaving, setAvatarSaving] = useState(false);
const [avatarError, setAvatarError] = useState<string | null>(null);
const [telemetryOptOut, setTelemetryOptOut] = useState(false);
const [telemetryLoading, setTelemetryLoading] = useState(true);
const [telemetrySaving, setTelemetrySaving] = useState(false);
@@ -588,8 +656,37 @@ function GeneralSettingsContent() {
const [webSearchLoading, setWebSearchLoading] = useState(true);
const [webSearchSaving, setWebSearchSaving] = useState(false);
const [webSearchError, setWebSearchError] = useState<string | null>(null);
const [cloudSessionsEnabled, setCloudSessionsEnabled] = useState(false);
const [cloudSessionsLoading, setCloudSessionsLoading] = useState(true);
const [cloudSessionsSaving, setCloudSessionsSaving] = useState(false);
const [cloudSessionsError, setCloudSessionsError] = useState<string | null>(
null,
);
// The gate the composer actually uses. It can diverge from the stored
// setting when the CLINE_CODE_CLOUD_AGENTS env override is set; without
// surfacing that, the toggle silently appears to do nothing.
const [cloudSessionsEffective, setCloudSessionsEffective] = useState<
boolean | null
>(null);
// Hard-wired on for the preview. If rollout control is ever needed,
// gate this on a PostHog flag exposed through the sidecar's
// get_feature_flags command (the sidecar's remote-flag evaluation
// plumbing was removed with the old rollout gate; see the git history
// of sidecar/feature-flags.ts for the hardened version).
const cloudSessionsSettingVisible = true;
const [appVersion, setAppVersion] = useState<string | null>(null);
const refreshCloudSessionsEffective = useCallback(async () => {
try {
const flags = await desktopClient.invoke<{ cloudAgents?: boolean }>(
"get_feature_flags",
);
setCloudSessionsEffective(Boolean(flags.cloudAgents));
} catch {
setCloudSessionsEffective(null);
}
}, []);
useEffect(() => subscribeToAppFontSize(setFontSize), []);
useEffect(() => {
@@ -621,24 +718,50 @@ function GeneralSettingsContent() {
setAutoUpdateError(null);
setWebSearchLoading(true);
setWebSearchError(null);
try {
const settings = await desktopClient.invoke<GlobalSettingsResponse>(
"get_global_settings",
);
setTelemetryOptOut(settings.telemetryOptOut);
setAutoUpdateEnabled(settings.autoUpdateEnabled);
setWebSearchEnabled(settings.tools?.web_search?.enabled === true);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setTelemetryError(message);
setAutoUpdateError(message);
setWebSearchError(message);
} finally {
setTelemetryLoading(false);
setAutoUpdateLoading(false);
setWebSearchLoading(false);
}
}, []);
setCloudSessionsLoading(true);
setCloudSessionsError(null);
// Independent backends: load them concurrently so one slow call cannot
// hold the other's toggle in its loading state.
await Promise.all([
(async () => {
try {
const settings = await desktopClient.invoke<GlobalSettingsResponse>(
"get_global_settings",
);
setTelemetryOptOut(settings.telemetryOptOut);
setAutoUpdateEnabled(settings.autoUpdateEnabled);
setWebSearchEnabled(settings.tools?.web_search?.enabled === true);
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
setTelemetryError(message);
setAutoUpdateError(message);
setWebSearchError(message);
} finally {
setTelemetryLoading(false);
setAutoUpdateLoading(false);
setWebSearchLoading(false);
}
})(),
(async () => {
try {
const desktopSettings = await desktopClient.invoke<{
cloudSessionsEnabled: boolean;
}>("get_desktop_settings");
setCloudSessionsEnabled(
Boolean(desktopSettings.cloudSessionsEnabled),
);
} catch (error) {
setCloudSessionsError(
error instanceof Error ? error.message : String(error),
);
} finally {
setCloudSessionsLoading(false);
}
})(),
refreshCloudSessionsEffective(),
]);
}, [refreshCloudSessionsEffective]);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
@@ -647,6 +770,35 @@ function GeneralSettingsContent() {
return () => window.clearTimeout(timeoutId);
}, [loadGlobalSettings]);
useEffect(() => {
let cancelled = false;
void Promise.all([listAvatars(), getSelectedAvatar()])
.then(([avatars, selected]) => {
if (cancelled) return;
setAvatars(Array.isArray(avatars) ? avatars : []);
if (selected && typeof selected.id === "string") {
setSelectedAvatarId(selected.id);
}
if (selected && typeof selected.enabled === "boolean") {
setAvatarEnabledState(selected.enabled);
}
setAvatarError(null);
})
.catch((error) => {
if (!cancelled) {
setAvatarError(
error instanceof Error ? error.message : String(error),
);
}
})
.finally(() => {
if (!cancelled) setAvatarLoading(false);
});
return () => {
cancelled = true;
};
}, []);
const updateTelemetryOptOut = async (nextValue: boolean) => {
const previousValue = telemetryOptOut;
setTelemetryOptOut(nextValue);
@@ -707,6 +859,26 @@ function GeneralSettingsContent() {
}
};
const updateCloudSessionsEnabled = async (nextValue: boolean) => {
const previousValue = cloudSessionsEnabled;
setCloudSessionsEnabled(nextValue);
setCloudSessionsSaving(true);
setCloudSessionsError(null);
try {
const settings = await desktopClient.invoke<{
cloudSessionsEnabled: boolean;
}>("set_cloud_sessions_enabled", { cloud_sessions_enabled: nextValue });
setCloudSessionsEnabled(Boolean(settings.cloudSessionsEnabled));
await refreshCloudSessionsEffective();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setCloudSessionsEnabled(previousValue);
setCloudSessionsError(message);
} finally {
setCloudSessionsSaving(false);
}
};
const updateTheme = (darkModeEnabled: boolean) => {
const nextTheme = darkModeEnabled ? "dark" : "light";
setTheme(setStoredHubTheme(nextTheme));
@@ -747,6 +919,36 @@ function GeneralSettingsContent() {
}
};
const updateAvatar = async (nextId: string) => {
const previousId = selectedAvatarId;
setSelectedAvatarId(nextId);
setAvatarSaving(true);
setAvatarError(null);
try {
await selectAvatar(nextId);
} catch (error) {
setSelectedAvatarId(previousId);
setAvatarError(error instanceof Error ? error.message : String(error));
} finally {
setAvatarSaving(false);
}
};
const updateAvatarEnabled = async (enabled: boolean) => {
const previousEnabled = avatarEnabled;
setAvatarEnabledState(enabled);
setAvatarSaving(true);
setAvatarError(null);
try {
await setAvatarEnabled(enabled);
} catch (error) {
setAvatarEnabledState(previousEnabled);
setAvatarError(error instanceof Error ? error.message : String(error));
} finally {
setAvatarSaving(false);
}
};
// resetOnboarding dispatches ONBOARDING_RESET_EVENT, which the app shell
// listens for to re-enter the first-run flow immediately.
const replayOnboarding = () => {
@@ -896,6 +1098,51 @@ function GeneralSettingsContent() {
))}
</div>
</div>
<div className="flex min-h-20 items-center justify-between gap-5 border-b py-4 max-[720px]:flex-col max-[720px]:items-stretch">
<div className="flex flex-col gap-1">
<p className="text-base font-semibold text-foreground">
Show desktop avatar
</p>
<p className="text-sm text-muted-foreground">
Display the selected avatar as a desktop overlay.
</p>
{avatarError ? (
<p className="mt-2 text-xs text-destructive" role="alert">
Failed to update desktop avatar: {avatarError}
</p>
) : null}
</div>
<Switch
aria-label="Show desktop avatar"
checked={avatarEnabled}
disabled={avatarLoading || avatarSaving}
onCheckedChange={(checked) => void updateAvatarEnabled(checked)}
/>
</div>
<div className="flex min-h-20 items-center justify-between gap-5 border-b py-4 max-[720px]:flex-col max-[720px]:items-stretch">
<div className="flex flex-col gap-1">
<p className="text-base font-semibold text-foreground">
Desktop avatar
</p>
<p className="text-sm text-muted-foreground">
Choose Cline Bot, Mom, or a v2 avatar installed under
{" ~/.cline/avatars/<avatar-name>"}.
</p>
</div>
<select
aria-label="Desktop avatar"
className="h-9 min-w-44 rounded-md border border-input bg-background px-3 text-sm text-foreground"
disabled={avatarLoading || avatarSaving}
onChange={(event) => void updateAvatar(event.target.value)}
value={selectedAvatarId}
>
{avatars.map((avatar) => (
<option key={avatar.id} value={avatar.id}>
{avatar.displayName}
</option>
))}
</select>
</div>
<div className="flex py-4 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
<div className="flex flex-col gap-1">
<p className="text-base font-semibold text-foreground">
@@ -941,6 +1188,46 @@ function GeneralSettingsContent() {
onCheckedChange={(checked) => void updateAutoUpdateEnabled(checked)}
/>
</div>
{cloudSessionsSettingVisible ? (
<div className="flex py-4 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
<div className="flex flex-col gap-1">
<p className="flex items-center gap-2 text-base font-semibold text-foreground">
Cloud sessions
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium uppercase tracking-wide text-primary">
Preview
</span>
</p>
<p className="text-sm text-muted-foreground">
Run Cline on your GitHub repositories in secure cloud sandboxes.
Adds a Cloud option to the new-session composer. Requires a
Cline account with GitHub connected.
</p>
{cloudSessionsError ? (
<p className="mt-2 text-xs text-destructive" role="alert">
Failed to update cloud sessions setting: {cloudSessionsError}
</p>
) : null}
{cloudSessionsEffective !== null &&
!cloudSessionsLoading &&
cloudSessionsEffective !== cloudSessionsEnabled ? (
<p className="mt-2 text-xs text-muted-foreground">
Cloud sessions are currently{" "}
{cloudSessionsEffective ? "enabled" : "disabled"} by the
CLINE_CODE_CLOUD_AGENTS environment override, which takes
precedence over this setting.
</p>
) : null}
</div>
<Switch
aria-label="Cloud sessions"
checked={cloudSessionsEnabled}
disabled={cloudSessionsLoading || cloudSessionsSaving}
onCheckedChange={(checked) =>
void updateCloudSessionsEnabled(checked)
}
/>
</div>
) : null}
<div className="flex py-4 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
<div className="flex flex-col gap-1">
<p className="text-base font-semibold text-foreground">Telemetry</p>
@@ -2,7 +2,10 @@ import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared/browser";
import type { ChatSessionConfig } from "@/lib/chat-schema";
import { readModelSelectionStorageFromWindow } from "@/lib/model-selection";
import { normalizeProviderId } from "@/lib/provider-id";
import { readWorkspaceSelectionFromWindow } from "@/lib/workspace-paths";
import {
LOCAL_WORKSPACE_ENVIRONMENT_ID,
readWorkspaceSelectionFromWindow,
} from "@/lib/workspace-paths";
export const CHAT_TRANSPORT_UNAVAILABLE_MESSAGE =
"Chat connection is unavailable. Reopen the app window to restore realtime chat.";
@@ -19,8 +22,11 @@ export const OAUTH_MANAGED_PROVIDERS = new Set([
export const DEFAULT_CHAT_CONFIG: ChatSessionConfig = {
sessionId: undefined,
executionTarget: "local",
repoUrl: undefined,
workspaceRoot: "",
cwd: "",
environmentId: LOCAL_WORKSPACE_ENVIRONMENT_ID,
provider: "cline",
model: CLINE_DEFAULT_MODEL_ID,
apiKey: process.env.CLINE_API_KEY || "",
@@ -35,9 +41,9 @@ export const DEFAULT_CHAT_CONFIG: ChatSessionConfig = {
missionTimeIntervalMs: undefined,
};
export function getInitialChatConfig(): ChatSessionConfig {
export function getInitialChatConfig(environmentId: string): ChatSessionConfig {
const selection = readModelSelectionStorageFromWindow();
const workspaceSelection = readWorkspaceSelectionFromWindow();
const workspaceSelection = readWorkspaceSelectionFromWindow(environmentId);
const rememberedProvider = normalizeProviderId(selection.lastProvider);
const rememberedModelForProvider = rememberedProvider
? (selection.lastModelByProvider[rememberedProvider] ??
@@ -55,6 +61,7 @@ export function getInitialChatConfig(): ChatSessionConfig {
return {
...DEFAULT_CHAT_CONFIG,
environmentId,
provider,
model,
workspaceRoot: workspaceSelection.lastWorkspace,
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import type { ChatSessionConfig } from "@/lib/chat-schema";
import { resolveCredentialError } from "./helpers";
const CLOUD_CONFIG: ChatSessionConfig = {
executionTarget: "cloud",
provider: "cline",
model: "anthropic/claude-sonnet-5",
apiKey: "",
workspaceRoot: "",
cwd: "",
repoUrl: "https://github.com/cline/cline",
} as ChatSessionConfig;
describe("resolveCredentialError (cloud)", () => {
it("accepts a valid HTTPS GitHub URL for a new session", () => {
expect(resolveCredentialError(CLOUD_CONFIG)).toBeNull();
});
it("rejects a partial URL even though the picker never confirmed it", () => {
const error = resolveCredentialError({
...CLOUD_CONFIG,
repoUrl: "https://exa",
});
expect(error).toMatch(/valid HTTPS GitHub repository URL/);
});
it("rejects SSH and non-GitHub URLs", () => {
for (const repoUrl of [
"git@github.com:cline/cline.git",
"https://gitlab.com/cline/cline",
"http://github.com/cline/cline",
]) {
expect(resolveCredentialError({ ...CLOUD_CONFIG, repoUrl })).toMatch(
/valid HTTPS GitHub repository URL/,
);
}
});
it("does not require a repo URL when sending into an existing session", () => {
expect(
resolveCredentialError(
{ ...CLOUD_CONFIG, repoUrl: "" },
{ hasActiveSession: true },
),
).toBeNull();
});
it("still requires the Cline provider for existing sessions", () => {
expect(
resolveCredentialError(
{ ...CLOUD_CONFIG, provider: "anthropic" },
{ hasActiveSession: true },
),
).toMatch(/Cline provider/);
});
});

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