Compare commits

...
Author SHA1 Message Date
Saoud Rizwan a21b21de84 chore(desktop): release v0.0.4 2026-07-23 18:50:17 -07:00
BeeandSaoud Rizwan ca6f6a6c23 feat(desktop): use the shared Cline Hub runtime (#12508)
* feat(desktop): use the shared Cline Hub runtime

* fix(hub): group code-sidecar-observer clients under Code App

The desktop observer client type was renamed from code-sidecar-approvals
to code-sidecar-observer, but the Code App grouping matchers in the hub
dashboard and menubar sidecar still only matched the old type. Since the
observer now registers on the shared Hub, it showed up as a separate
ungrouped client. Keep the old type matched for older desktop builds.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-23 18:21:39 -07:00
BeeandSaoud Rizwan 3e06abc366 feat(desktop): support chat without workspaces (#12412)
* feat(core): support pathless sessions with temporary workspaces

* fix(desktop): mark editor icons as decorative

* fix(core): omit absent auth request IDs

* test(sdk): restore request_id auth telemetry param in core-events test

The branch's drive-by request_id -> requestId rename was dropped while
resolving the merge conflict with #12444 (which added requestIdDetails on
main), so the public captureAuthLoggedOut/captureAuthRefreshSoftFailure
API keeps its original parameter name.

* refactor(sdk): root pathless session workspaces under the cline data dir

Move the workspace created for pathless session starts from
<os.tmpdir()>/cline/sessions/<id>-temp/project to
<cline-data-dir>/workspaces/<id>/project (default
~/.cline/data/workspaces/<id>/project), per PR review:

- OS tmp reapers (macOS ~3-day purge, systemd-tmpfiles, reboot cleanup)
  silently delete user work created in 'New Project' sessions
- /tmp is a shared namespace on Linux: the first user to create /tmp/cline
  owns it (EACCES for everyone else), and guessable session IDs let a local
  attacker pre-create the workspace directory
- under the data dir the workspace shares the session store's lifecycle and
  the existing CLINE_DATA_DIR / CLINE_DIR overrides for tests and sandboxes

isTemporaryWorkspacePath now matches the .cline/data/workspaces/<id>/project
segment shape, and the -temp suffix is gone since the id-scoped directory no
longer needs to mark itself as reapable.

* feat(sdk): open pathless sessions in one shared chat workspace

Instead of minting a workspace directory per session
(<data>/workspaces/<session-id>/project), all sessions started without a
cwd/workspaceRoot now share <cline-data-dir>/workspaces/chat (default
~/.cline/data/workspaces/chat). Starting a pathless session seeds the
directory with an AGENTS.md rules file (only when missing, so users can
edit it) that tells the agent to treat the session as a chat: don't create
or edit files unprompted, ask where a project should live when the user
wants one built, and default to a new named folder inside the chat
directory that later sessions can reference.

This avoids unbounded per-session directory sprawl, gives chat sessions a
stable home the user can revisit, and groups them naturally in the desktop
sidebar. The desktop app now labels the shared workspace "Chat" (menu
action "Just chat") instead of "New Project", and isChatWorkspacePath
matches only the chat directory itself, so project folders created inside
it behave as regular workspaces.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-23 18:09:03 -07:00
Saoud Rizwan 6518b05b6c feat(desktop): first-run onboarding flow (#12495)
* feat(desktop): add replay new-user-experience setting

Adds an onboarding state module (localStorage-backed, with a reset event
the app shell can subscribe to) and a 'New user experience' row in
Settings -> General with a Replay button so beta testers and designers
can re-run the first-run flow. The flow itself ships in the stacked
follow-up PR.

* feat(desktop): first-run onboarding flow

Full-screen first-run experience shown until completed once: a welcome
step (3D glass logo over the aurora background), a connect step offering
Cline sign-in (recommended) or bring-your-own API key against the
provider catalog, and a done step that drops the user into a fresh
thread. Completion is tracked by the onboarding state module from the
previous PR; the Settings replay row now re-enters the flow in place via
the reset event, so its toast is gone. Skipping is always available so
nobody gets trapped; the connected provider (and its default model when
known) is remembered so the chat composer opens pointed at it.

* fix(desktop): address greptile review on onboarding flow

- Filter the bring-your-own-key picker to providers a lone API key can
  fully configure: providers with structured config fields (Vertex gcp.*,
  Bedrock aws.*) or no API-key field at all (Claude Code) no longer appear,
  since connecting them here would report success without working.
- Record Cline as the active provider when a signed-in user hits Continue,
  so replaying onboarding doesn't leave the chat pointed at a previously
  selected provider.

* feat(desktop): accent color themes and switchable app icon (#12496)

* feat(desktop): accent color themes and switchable app icon

Settings -> General grows an appearance cluster next to Dark mode:

- Accent color: six palettes from the Figma exploration (violet default,
  graphite, cyan, pink, espresso, ember). Non-default accents re-anchor
  --primary/--primary-foreground/--primary-emphasis/--ring per light and
  dark mode via html[data-cline-accent] overrides in globals.css, tuned in
  OKLCH to mirror the brand token relationships; chart and sidebar tokens
  alias var(--primary) so they follow. Persisted in localStorage and
  applied at boot alongside the dark-mode sync.

- App icon: the four Figma variants (Classic, Sunrise, Steel, Midnight).
  The webview persists the choice, swaps the favicon in browser mode, and
  in the Tauri shell calls the new set_app_icon native command, which
  loads the matching bundled resource (icons/dock/*.png) and applies it
  via NSApplication.applicationIconImage on the main thread. macOS resets
  the dock icon every launch, so the shell re-applies the stored choice at
  boot; classic is also loaded from a resource because the objc2 binding
  warns against passing nil to restore the bundled icon. Other platforms
  no-op (Ok(false)).

* fix(desktop): don't let a stale app-icon failure roll back a newer selection
2026-07-23 16:59:50 -07:00
BeeandSaoud Rizwan f9f492319e feat(desktop): redesign channel setup as expandable cards (#12490)
* feat(desktop): redesign channel setup as expandable cards

Replace the add-channel dialog with inline expandable configuration forms, including per-channel validation and error handling.

Add comprehensive tests for channel configuration, conditional fields, security options, and connection workflows.

* Connect

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-23 16:54:29 -07:00
Saoud Rizwan c3ab557194 polish(desktop): cleaner chat markdown + external links that actually open (#12497)
* polish(desktop): cleaner chat markdown + external links that actually open

Links in chat markdown never opened in the packaged app: the confirm
dialog's window.open(_blank) is silently dropped by the Tauri shell.
Route opens through openExternalUrl (open_external_url sidecar command)
and only keep the confirmation dialog for deceptive links whose visible
text reads as a URL on a different host than the real destination —
ordinary external links now open directly in the default browser.

Visual pass on Streamdown output for the chat pane: collapse the
double-boxed code block card and drop the language header row, reveal
the copy button on hover only, turn off line numbers, single-box tables,
chat-scale the heading ramp (h1 was text-3xl next to 14px body), outside
list markers, and tighter block rhythm.

* fix(desktop): harden deceptive-link detection per review

Recurse into element children when extracting link label text so inline
formatting (e.g. a bolded hostname) can't dodge the deception check, and
compare port and (when the label states one) scheme in addition to
hostname so same-host links to an unexpected scheme or port still get
the confirmation dialog. An unparseable destination behind URL-shaped
label text is now treated as deceptive rather than waved through.

* fix(desktop): treat trailing-dot FQDN labels like their plain hostname

Browsers resolve 'github.com.' identically to 'github.com', but the
URL-shaped-label pattern rejected the trailing dot, so a deceptive label
like [github.com.](https://evil.example) skipped the deception check and
opened directly. Accept one trailing dot in the pattern and strip
trailing dots during hostname normalization on both sides, so the FQDN
form is deceptive exactly when the plain form is.

* fix(desktop): treat protocol-relative labels like their https form

A label spelled '//github.com' reads as a URL but failed the URL-shaped
pattern (which only tolerated an https?:// prefix), so it skipped the
deception check and opened an unrelated destination directly. Accept a
protocol-relative prefix in the pattern, and parse '//'-prefixed values
as https-relative in parseLinkParts — prepending 'https://' to them
produced an empty hostname and made the comparison a no-op.
2026-07-23 16:48:50 -07:00
BeeandSaoud Rizwan 9fa37a9128 feat(desktop): display image attachments in chat (#12502)
* feat(desktop): drag and drop files to attach them to the chat

The Tauri webview swallows OS file drags by default (dragDropEnabled),
so HTML5 drop events never fire. Disable it on the main window per the
Tauri v2 docs, then handle standard dragenter/dragover/dragleave/drop on
the chat pane: dropped files feed the same dedupe-and-append pipeline as
the paperclip picker, with a depth-counted 'Drop to attach' overlay while
files are dragged over. Image drops become data-URL images via the
existing serializeAttachments path.

* feat(desktop): display image attachments in chat

* 225x225

* fix(desktop): preserve queued attachments

* fix(desktop): distinguish queued image turns

* fix pending

* fixed

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-23 16:46:36 -07:00
BeeandSaoud Rizwan 9cc7796bbe feat(desktop): add custom overlay title bar navigation (#12504)
* feat(desktop): add custom overlay title bar navigation

Configure Tauri to use a hidden overlay title bar and host back/forward navigation in the draggable sidebar header. Preserve agent title width during editing to prevent layout shifts, with tests covering both behaviors.

* fix(desktop): reconcile deleted navigation entries

* fix(desktop): dedupe session deletion events

* fix(desktop): serialize session deletion state

* fix(desktop): use exported DesktopAppView type in page.tsx

AppView is a non-exported type local to agent-sidebar.tsx, so referencing
it in page.tsx was a TS2304 error hidden by the typecheck script's webview
exclusion and next's ignoreBuildErrors.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-23 16:46:07 -07:00
Saoud Rizwan 439baee17c feat(desktop): add replay new-user-experience setting (#12494)
Adds an onboarding state module (localStorage-backed, with a reset event
the app shell can subscribe to) and a 'New user experience' row in
Settings -> General with a Replay button so beta testers and designers
can re-run the first-run flow. The flow itself ships in the stacked
follow-up PR.
2026-07-23 16:43:33 -07:00
BeeandSaoud Rizwan 469debdb30 fix(schedules): default headless routines to yolo (#12489)
* fix(schedules): default headless routines to yolo

Centralize the Cline default model ID in @cline/shared while preserving the @cline/llms export. Keep explicit modes stable and disable ask_question for unattended scheduled runs.

* autoapprove

* fix unit test

* fix(schedules): harden headless routine execution

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-23 14:53:03 -07:00
John Choi 79589c8417 test(cli): relax cold history dispatch timeout (#12511) 2026-07-23 14:36:33 -07:00
Saoud Rizwan 8e5471de9c feat(vscode): show compaction progress and results in the webview (#12487)
* feat(vscode): show compaction progress and results in the webview

Port the CLI's compaction UX to the VS Code extension:

- Translate the SDK's compaction status notices into a say:'compaction'
  divider row with a spinner while running, updated in place (same ts) to
  'Context compacted · x → y tokens · n → m messages' when done, matching
  the CLI's divider. Dangling dividers finalize as failed/cancelled when
  the turn errors or ends mid-compaction.
- Manual /compact (button or slash command) drives the same divider from
  the compaction coordinator instead of plain info lines, capturing token
  counters from the SDK's status notices.
- Drop raw status-notice slugs ('auto-compacting') that previously
  rendered as info rows.
- Context window bar now reads the compacted size (tokensAfter) from a
  compaction row newer than the last API request, so it drops immediately
  after compaction instead of waiting for the next turn.
- Fix the Auto Compact Strategy selector showing 'basic' when unset; the
  effective default is agentic (core defaults strategy ?? 'agentic').

* fix(vscode): apply compaction shrink as a ratio to the context meter

Address review feedback from #12487:

- getLastApiReqTotalTokens: instead of substituting the compaction
  notice's tokensAfter (an SDK estimate on a different scale than
  provider-reported usage, which made the bar re-snap when the next
  request's real usage landed), scale the last provider-reported request
  total by the compaction's tokensAfter/tokensBefore ratio. Both
  counters come from the same estimator, so the ratio is scale-free.
  Multiple compactions since the last request compound. A completed
  divider without token counters leaves the total unscaled.
- Suppress only the known-internal status notices explicitly
  (compaction-budget-adjusted); an unrecognized status notice now falls
  through to an info row so future notices surface instead of silently
  vanishing.
- Cross-reference the two compaction-divider finalization paths (auto:
  translator finalizeDanglingCompaction; manual: coordinator catch) so
  terminal-state rule changes touch both.
- Post state to the webview before re-throwing in the coordinator's
  failure path, consistent with the other terminal branches.
2026-07-23 10:07:57 -07:00
Saoud Rizwan cd553d2343 feat(desktop): drag and drop files to attach them to the chat (#12498)
The Tauri webview swallows OS file drags by default (dragDropEnabled),
so HTML5 drop events never fire. Disable it on the main window per the
Tauri v2 docs, then handle standard dragenter/dragover/dragleave/drop on
the chat pane: dropped files feed the same dedupe-and-append pipeline as
the paperclip picker, with a depth-counted 'Drop to attach' overlay while
files are dragged over. Image drops become data-URL images via the
existing serializeAttachments path.
2026-07-23 10:06:16 -07:00
Mikołaj KondratekandClaude Fable 5 7b776225b9 fix(telemetry): report host identity on SDK-pipeline events (#12503)
* fix(telemetry): report host identity on SDK-pipeline events

On JetBrains standalone cline-core, SDK-pipeline events (task lifecycle,
token usage, tool usage, provider failures) reported the hardcoded
cline_type "VSCode Extension", platform "VS Code", and
platform_version "unknown", unlike the classic TelemetryService which
resolves these from HostProvider.env.getHostVersion().

Extend the host_plugin_version resolution in VscodeTelemetryPolicyService
to apply the full host identity (cline_type, platform, platform_version)
with the same mapping the classic pipeline uses, before the telemetry
gate opens. Fields the host does not report keep the construction-time
fallbacks.

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

* fix(telemetry): fall back to unknown host identity, not VSCode labels

A failed getHostVersion lookup previously left the hardcoded VSCode
identity in place, hiding the failure as a plausible-looking row.
"unknown" makes the failure visible and matches the classic
TelemetryService's || "unknown" semantics.

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

* fix(telemetry): defer provider_created until host identity is applied

telemetry.provider_created was captured synchronously inside the core
factory, before VscodeTelemetryPolicyService resolves getHostVersion —
so that one event always carried the construction-time fallback identity
(pre-existing: it reported the hardcoded VSCode identity on JetBrains
and never had host_plugin_version).

Add an opt-in deferProviderCreatedEvent to the core telemetry factories
that skips the construction-time capture and exposes it as
ConfiguredTelemetryHandle.emitProviderCreated; the policy service emits
it right after applying the resolved host metadata. Other handle
consumers (CLI, hub daemon, examples) keep immediate emission.

Also close the subscription race on the same guarantee: a host setting
flip arriving while getHostVersion is still resolving now waits for the
metadata to be applied before opening the gate, and a slow initial
settings fetch no longer overwrites a newer subscription update.

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

* fix(telemetry): emit deferred provider_created on early dispose

If the policy service is disposed while the host-version lookup is
still pending, the deferred provider_created would never be captured —
the undeferred event was always emitted (with construction-time
identity) and exported by the shutdown flush. Emit-once semantics:
dispose fires the event with the fallback identity before shutting the
handle down, and the late metadata continuation cannot double-emit.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 22:25:53 +09:00
Bee ae033761ab feat: auto generate built-in provider list (#12204)
* feat: auto generate built-in provider list

- Generate `providers.generated.ts` and `provider-ids.generated.ts` from `models.dev/api.json` alongside the model catalog
- Merge generated provider specs with handwritten built-in overrides for Cline, Codex, local/OAuth providers, routing metadata, and product defaults
- Include additional `models.dev` providers only when they are OpenAI-compatible for now
- Keep lightweight provider ID utilities from importing the full generated provider spec catalog

* Removed redundant handwritten definitions for providers that are fully described by generated metadata

* update unit test
2026-07-23 14:27:59 +02:00
Mikołaj KondratekandClaude Fable 5 c961ae7730 feat(telemetry): emit host_plugin_version metadata on all events (#12478)
* feat(telemetry): emit host_plugin_version metadata on all events

The host already reports its Cline distribution version over the
hostbridge (getHostVersion.clineVersion — the JetBrains plugin version
on JetBrains, the extension version on VSCode), but telemetry never
attached it: extension_version is always the cline-core bundle version,
so JetBrains events could not be tied to a plugin release.

Attach it as a new optional host_plugin_version metadata field, omitted
when the host does not report one.

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

* test(telemetry): loop over host version cases instead of interleaving stubs

Review feedback: the two host_plugin_version cases were interleaved via
onFirstCall/onSecondCall stubs across two service instances. Run one
mock-assert-reset cycle per case so the only differences between them —
the host version response and the expected reported value — are visible
in the case table.

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

* test(telemetry): guarantee stub cleanup in host_plugin_version test

Review feedback: the loop installed process-global stubs and only
restored them on the happy path — a rejected create() or failed
assertion would leak exhausted stubs into subsequent tests and leave
the service undisposed. Use a sinon sandbox restored in finally, and
dispose the service there too.

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

* feat(telemetry): carry host_plugin_version on SDK-pipeline events too

Review feedback: main's production controller emits task lifecycle,
token usage, tool usage, and provider-failure events through a separate
SDK telemetry service whose metadata is built independently, so those
events still omitted the plugin version.

Add the optional host_plugin_version field to the shared SDK
TelemetryMetadata contract and resolve it from the authoritative
getHostVersion response during the policy service's init. The metadata
update is sequenced before the host telemetry setting is applied, and
events stay gated until that setting lands, so no event can be emitted
without the field in place. A failed host-version lookup degrades to
the previous behavior (field absent, telemetry still enabled).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:42:37 +09:00
Dominic CooneyandCline Agent e940b6a335 fix(vscode): preserve edit preview focus (#12491)
Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
2026-07-23 16:23:18 +09:00
David Knaack 5a0780a6f9 fix(llms): set metering header and use fetch adapter for sap ai core (#12337) 2026-07-23 00:05:27 -07:00
BeeandSaoud Rizwan 847276f4a5 feat(desktop): add one-time routines and run navigation (#12477)
* feat(schedules): add one-time routines and run navigation

* fix schedule review concerns

* fix repository lint errors

* fix optional auth request ID telemetry

* fix one-time schedule lifecycle

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-22 16:56:49 -07:00
Dominic Cooney 8b4d1973b5 fix(vscode): reclaim unobserved fallback terminals (#12352)
Classify unobservable terminal outcomes so cleanup and reporting share one source of truth. Reclaim managed sendText fallbacks at the disclosed next-acquisition boundary while preserving markerless, continued, detached, and uncertain-error terminals. Make cleanup, CWD reservations, process listeners, and detached logs failure-safe.
2026-07-22 16:22:11 -07:00
Dominic Cooney a5211a3a5c fix(vscode): make 'Proceed While Running' sticky to the command batch (#12359)
Register every foreground command before terminal acquisition so a parallel batch observes one Proceed While Running decision. If a command is still acquiring a terminal, settle its tool result immediately and transfer the approved command to an owned detached lifecycle that logs acquisition, output, completion, and failure. Abort before startup unregisters the handle and prevents the command from starting later.
2026-07-23 06:39:38 +09:00
Saoud Rizwan fb1333fdc2 fix(desktop): route external link opens through sidecar so they work in Tauri (#12481)
* fix(desktop): route external link opens through sidecar so they work in Tauri

The markdown 'Open external link?' dialog confirmed via window.open, which
the Tauri webview silently drops (no window opener configured), so clicking
'Open link' did nothing (ENG-2302). Route confirmation through
openExternalUrl, which invokes the open_external_url sidecar command inside
the Tauri shell and falls back to window.open in plain web mode.

Also fixes the marketplace 'Get value' env-var link, which relied on the
same dead target=_blank behavior.

* fix(desktop): open mailto/tel links and middle-clicked marketplace links

Greptile review fixes:
- open_external_url now allows mailto: and tel: alongside http(s) — the
  platform openers already dispatch any scheme to the OS protocol handler,
  the gate is just the allowlist. Streamdown's harden step blocks every
  other scheme before it reaches SafeMarkdownLink (test added to guard
  that assumption, since the sidecar allowlist relies on it).
- Protocol-relative URLs pass streamdown but fail the sidecar's new URL()
  parse; pin them to https before handing off.
- The marketplace 'Get value' link now intercepts middle clicks (auxclick)
  too, which bypassed the onClick handler and fell into the dead
  target=_blank path.
2026-07-22 14:27:01 -07:00
Renee Huang 59113c309c docs: update ClinePass wording to '2-5x the usage on popular open coding models compared to standard API rate' (#12479)
* docs: update ClinePass wording from '2-5x API rate limits' to '2-5x the usage on popular open coding models compared to standard API rate'

* docs: update ClinePass wording in cline-provider.mdx for consistency

* nit
2026-07-22 11:44:55 -07:00
TheRealSpencer 48d0c38f52 fix(security): bump axios to 1.18.0 to resolve multiple CVEs in axios (#12473)
* fix(security): bump axios to 1.18.0 to resolve multiple CVEs in axios

* fix(security): bump axios to 1.18.0 in docs project
2026-07-22 12:38:45 -05:00
aikido-autofix[bot] c7ab9ff839 fix(security): update js-yaml from 4.1.1 to 4.3.0 (#12456)
Co-authored-by: aikido-autofix[bot] <119856028+aikido-autofix[bot]@users.noreply.github.com>
2026-07-22 09:49:42 -05:00
Tomás Barreiro 045518d19f Add Request-ID to auth events (#12444)
* Add the X-Request-ID to the ClineOAuthTokenError

* Add request id to events

* address comments
2026-07-22 13:20:30 +09:00
Saoud Rizwan 099c6179e4 fix(desktop): resolve login shell PATH so agent can find gh and other CLI tools (#12429)
* fix(desktop): resolve login shell PATH so agent can find gh and other CLI tools

When the Tauri app is launched from Finder/the Dock on macOS it inherits
launchd's minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin), so the sidecar and
every process it spawns for the agent (bash tool, MCP servers) can't find
tools installed via shell profiles, e.g. Homebrew's gh in /opt/homebrew/bin.
The same task works from the CLI because a terminal runs with the full
login-shell PATH.

At sidecar startup, ask the user's login+interactive shell for its PATH
(sentinel markers isolate it from profile noise, 5s timeout, kill on hang)
and merge it into process.env.PATH: shell entries first, current-only
entries preserved. No-op on Windows; CLINE_SIDECAR_SKIP_SHELL_PATH=1 is the
escape hatch. Failures never block startup.

Fixes CLINE-2740

* fix(desktop): address greptile review on shell PATH resolution

- Don't let shell resolution eat the Tauri endpoint-readiness window: kick
  it off first so it overlaps sidecar startup (awaited before the session
  manager exists, which is what spawns children), drop the shell timeout
  5s -> 2s, and give the fallback attempt half the budget so the combined
  worst case (3s) stays inside the 5s readiness poll.
- Handle non-POSIX login shells: run the marker printf inside /bin/sh so
  $PATH expansion never depends on the outer shell's rules (fish would
  space-join it), pass -i/-l/-c as separate flags, give csh/tcsh only -c
  (their -l is valid only as the sole flag), and retry with the platform
  default shell when $SHELL can't produce a PATH.
- Don't log the resolved PATH: the applied result now carries an entry
  count instead of the merged PATH string.

* fix(desktop): read login shell from the account database, document PATH resolution

$SHELL is set by a parent shell, so a GUI-launched process may not have it.
Use os.userInfo().shell (getpwuid — DirectoryServices on macOS, same source
as dscl UserShell; NSS/etc/passwd on Linux) as the authoritative source,
with $SHELL and the platform default as fallbacks. Also documents the whole
mechanism in the app README.

* fix(desktop): widen endpoint readiness poll, source csh login profile

- The 5s get_desktop_backend_endpoint poll was already tight for
  session-manager init on slow machines; shell PATH resolution (bounded 3s
  worst case) made it tighter. Poll 15s instead — it returns as soon as the
  ready line arrives, so only genuine failure waits longer.
- csh/tcsh can't take -l alongside -c, so mark them as login shells via the
  argv[0] dash convention (argv0: "-tcsh") to get ~/.login sourced on top
  of the always-read rc file.

* fix(desktop): never spawn a second sidecar while one is alive

ensure_desktop_backend_started treated a live child with a pending
endpoint as absent and fell through to spawn a duplicate, orphaning the
first process. Hold the process lock across the whole check-and-spawn
(concurrent callers serialize), return early for any live child, fail
the endpoint poll fast when the child exits instead of respawning, and
stop a stale stdout-reader from wiping a successor's endpoint. The spawn
is injectable so regression tests cover repeated and concurrent startup
checks (exactly one spawn while pending) and dead-child replacement.

* style(desktop): tighten mergePaths and csh comment per review

* docs(desktop): codify backend state lock ordering
2026-07-21 18:11:55 -07:00
BeeandClaude Fable 5 26037b17ac feat(core): default to agentic compaction (#12317)
* feat(core): default to agentic compaction

Use agentic compaction when no valid strategy is configured while preserving explicit basic selection. Add a session compaction CLI and package script for testing and comparing compaction strategies.

* createHandlerMock

* fix(core): let the agentic compaction cut land on assistant boundaries

Agentic auto-compaction only accepted typed user messages (turn starts)
as cut boundaries. The canonical host transcript — one typed task
followed by a long assistant tool_use / user tool_result loop — has no
turn start past index 0, so findCutIndex snapped to 0 and
runAgenticCompaction returned undefined: the UI showed "auto-compacting"
then "auto-compaction-skipped" on every turn while the context kept
growing. Re-compaction had the same failure permanently, because the
projected transcript starts with a compaction summary message, which is
excluded from turn starts.

Assistant messages are equally safe boundaries: an assistant's tool_use
keeps its result in the user message that follows it, so a cut there
never orphans half of a tool pair. Typed-user protection is preserved —
when a typed turn exists past index 0 the cut still stays at or before
it, so the latest typed prompt is never folded into the summary.

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

* add compaction fixtures for testing

* basic compaction improvement

* feat: attach metadata to the merged compaction message

* fix(core): address review comments on compact-session script

- add cline provider to the API key env defaults (CLINE_API_KEY)
- accept legacy string-content messages in readMessages
- print usage instead of a stack trace when --provider/--model are missing

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

* fix(core): preserve basic compaction across restores

## Summary

- keep tool-result message IDs stable across restore/persist round-trips
- preserve concluding assistant responses as real messages during basic compaction
- freeze prior compaction output so later passes only fold newly added history
- accumulate removed-message and usage metadata across repeated compactions
- update the basic compaction fixture and regression coverage

## Problem

Tool-result IDs were re-suffixed every time persisted messages were converted
back into agent messages. Because compaction state hashes the source message
prefix, restoring a session changed that hash and invalidated an otherwise
successful compaction, causing the full transcript to be sent again.

Basic compaction also reprocessed its own output on subsequent passes. This
could stack duplicate system notices, discard assistant conclusions retained by
the previous pass, and replace cumulative compaction statistics with values
from only the latest pass.

## Solution

Only add tool-result suffixes when splitting a mixed message, leaving already
split and single-result message IDs unchanged. Mark non-user compaction
survivors as preserved, carry those messages through future passes verbatim,
and budget older turns' final assistant answers as first-class messages.
Compaction metadata now adds prior removed-message and usage totals to the work
performed by the current pass.

## Validation

- 66 focused codec and compaction tests pass
- @cline/core typecheck and smoke typecheck pass
- Biome checks pass for all changed TypeScript files
- git diff --check passes

* fix unit test

* fix compaction defaults and fallback

* fix basic compaction credential lookup

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 17:41:58 -07:00
Saoud Rizwan 1adce5e56d chore(desktop): release v0.0.3 2026-07-21 17:31:29 -07:00
Saoud Rizwan 73583ea178 polish(ui): reasoning trigger reads just 'Thinking' — drop status text and brain icon (#12460)
The collapsed reasoning block header showed 'Thought process · Complete'
with a brain icon; PM feedback is that the status text and icon read as
noise. The trigger is now just the label + disclosure chevron, with
'Thinking' as the default label in both streaming and complete states.
Removes the now-unused cline-chat-reasoning-status style and BrainIcon.
2026-07-21 17:28:25 -07:00
Saoud Rizwan 21a0141b86 chore(desktop): release v0.0.2 2026-07-21 17:00:20 -07:00
Saoud Rizwan ecb71ba7cc fix(desktop): make scheduler row actions work and add hover tooltips (#12428)
* fix(desktop): make scheduler row actions work and add tooltips (CLINE-2745)

- Replace the view icon's window.alert (a no-op inside the Tauri webview)
  with a proper schedule-details dialog
- Trigger schedule.trigger with wait: false so 'run now' queues the run
  and returns immediately instead of blocking until the whole agent run
  finishes (which outlived the webview's 120s request timeout)
- Add hover tooltips to all schedule row actions (view, edit, run now,
  pause/resume, delete, enable switch)
- Show a spinner on the run-now button while triggering and toast on
  success/failure
- Return lastExecutions from list_routine_schedules so 'Last result'
  actually populates (it was always '-')
- Mount <Toaster /> in the root layout; toast() calls app-wide were
  previously rendered nowhere

* fix(desktop): per-schedule last executions and concurrent row actions

- list_routine_schedules backfills the latest execution for schedules
  whose runs fell outside the 50-newest global window (skipping
  schedules that have never run), so every row can show a last result
- busy/triggering row state is now a set keyed by schedule id, so one
  action finishing no longer clears another row's in-flight spinner

* fix(desktop): reject same-row schedule actions synchronously

Two rapid clicks on the same row action could both fire before React
rendered the disabled state; the first completion then cleared the
shared busy id while the second request was still pending (and run-now
would enqueue two runs). Guard entry through a ref that mirrors
busyScheduleIds so the duplicate click is rejected before any request
is sent.
2026-07-21 16:41:05 -07:00
Saoud Rizwan f5224abdf5 feat(desktop): auto-updates + automated signed releases from GitHub Actions (#12420)
* feat(desktop): auto-update via Tauri updater with restart prompt

The Rust shell now checks the desktop-latest GitHub release feed on launch
and every 2 hours, downloads and stages updates in the background, and
exposes get_update_status/restart_to_apply_update commands. The webview
polls the status and shows a persistent toast with a one-click restart once
an update is staged; ignored updates apply on next launch. Updater
artifacts are only produced with the CI config overlay
(tauri.release.conf.json) so local packaging keeps working without the
updater signing key. Also mounts the previously-unmounted Toaster so
existing toast() calls render.

* ci(desktop): add desktop-publish release workflow and publish-desktop skill

desktop-publish.yml mirrors cli-publish: dispatch with a desktop-vX.Y.Z
tag + confirm gate, validates the tag against package.json and
tauri.conf.json, builds signed+notarized DMGs for aarch64 (native) and
x86_64 (cross-compiled sidecar via bun --target), generates the updater
manifest, publishes the versioned GitHub release, refreshes the rolling
desktop-latest auto-update feed, and posts to Slack. Adds the release
skill, changelog, and README docs for the required GitHub secrets.

* fix(desktop): address review — outlast sidecar shutdown window, dedupe update toast across remounts

stop() now polls for 7s before escalating to kill, past the sidecar's own
5s SHUTDOWN_TIMEOUT_MS graceful-shutdown budget, so clicking Restart now
(or quitting) during session persistence can't SIGKILL the sidecar
mid-write. notifiedVersion moves to module scope so a page remount doesn't
re-toast an update the user already dismissed.

* docs(desktop): move publish-desktop skill to .cline/skills, slim README release section

Match the publish-cli convention: the skill lives in .cline/skills/ and is
symlinked from both .agents/skills/ and .claude/skills/ so all agents pick
it up. The README's release section shrinks to a pointer + the two
never-lose invariants (desktop-latest feed, updater private key); the repo
secrets table moves into the skill, which also fixes its dangling reference
to a 'Release automation' README section and escapes the pipe that broke
the GFM table cell.
2026-07-21 16:37:18 -07:00
Saoud Rizwan 85484abf7a feat(desktop): align settings page with hub dashboard (#12427)
* feat(desktop): align settings with hub dashboard (ENG-2286)

- Break out Customizations into its own sidebar nav group (Plugins,
  Skills, MCP, Hooks, Rules, Agents, Tools), mirroring the hub
  dashboard's customizations break-out, replacing the single
  Customizations entry (Rules-only) and the MCP Marketplace entry
- Port the hub's account view: signed-out state with working Sign
  in/Sign out (the old Sign Out button had no handler), auth-error
  detection, disabled tabs when signed out, PageFrame/PageHeader layout
- Port the hub's add-provider view for consistent PageFrame layout

* fix(desktop): merge the two MCP sidebar entries into one

The sidebar showed MCP twice: 'MCP Servers' under Settings (full
management: add/edit/toggle/delete) and 'MCP' under Customizations
(marketplace browse with uninstall-only cards). Keep the single 'MCP'
entry under Customizations to match the hub sidebar, and route it to
McpServersContent with the marketplace embedded: the management cards
now render as the marketplace view's Installed section, so one page
covers add/edit/toggle/delete plus catalog install.

* fix(desktop): stop long marketplace taglines forcing page-wide overflow

line-clamp (webkit-box) paragraphs report their full unwrapped text
width as intrinsic min-content, and grid/flex items default to
min-width:auto, so long MCP taglines pushed the whole marketplace grid
(and the page) wider than the viewport. Add min-w-0 at each grid-item
level so cards clamp to the container and the tag row scrolls within
itself.
2026-07-21 16:33:54 -07:00
Saoud Rizwan 0b0e2fbab8 feat(desktop): add open-in-editor and copy-path actions to diff view (#12434)
* feat(desktop): add open-in-editor and copy-path actions to diff view

Adds per-file actions to the session diff view (CLINE-2738):
- copy the file path (resolved to an absolute path against the session cwd)
- open the file in a code editor via a new open_file_in_editor sidecar
  command that prefers editor CLIs (code/cursor/windsurf/zed/subl) and
  falls back to macOS app bundles, then the OS default opener

* fix(desktop): handle Windows editor shims and mount Toaster for failure feedback

Address greptile review on #12434:
- route .cmd/.bat editor shims through cmd.exe (spawn can't launch them
  directly) and attach spawn error listeners so async launch failures
  fall back to the OS opener instead of crashing the sidecar
- mount the app-wide Toaster (same lines as #12428) so copy/open failure
  toasts are actually visible

* fix(desktop): guard Windows shell launches against cmd metacharacters

cmd.exe re-parses metacharacters inside arguments even when Node quotes
them (the reason spawning .cmd files without a shell is banned), so a
file path like 'report & evil.cmd' handed to the cmd /c shim launch
could execute a second command. Reject such paths with a clear error on
win32 and skip shim executables containing metacharacters (CodeQL
js/shell-command-injection-from-environment on #12434).

* feat(desktop): editor picker dropdown + copy button next to path in diff view

Review feedback on #12434:
- Renee: open-in-editor is now a dropdown listing the editors actually
  installed on the machine (new list_available_editors sidecar command;
  PATH CLIs + macOS app bundles), plus a system-default entry.
  open_file_in_editor accepts an optional editor id; omitted keeps the
  old auto-cascade, so older sidecars and existing callers still work.
- Beatrix: copy-path button now sits right after the filename (GitHub
  style) instead of grouped at the right edge; an invisible flex spacer
  keeps the dead space clickable as a collapse toggle.

* feat(desktop): brand icons + kanban editor set in diff-view editor picker

Match the kanban open-in dropdown: monochrome brand glyphs (VS Code,
Cursor, Windsurf, Zed, Xcode, IntelliJ IDEA) rendered inline with
currentColor so they follow the theme, an 'Open in' menu header, and a
system-default entry with a generic icon. Catalog grows to the kanban
editor list (adds VS Code Insiders via code-insiders, IntelliJ via
idea, Xcode via xed; macApps is now a list so IntelliJ CE is found).
Sublime Text keeps a generic file-code glyph (kanban has no sublime
icon).
2026-07-21 16:31:48 -07:00
Saoud Rizwan 1e2e8fe81b fix(desktop): preserve MCP server oauth tokens and metadata across dialog edits (#12426)
* fix(desktop): preserve oauth and metadata when upserting MCP servers

upsert_mcp_server rebuilt the settings record from scratch, so editing a
remote server through the dialog silently wiped its oauth block (tokens)
and any plugin-ownership metadata. Merge machine-managed fields from the
existing record (following previousName across renames) into the upserted
entry.

* fix(desktop): drop MCP server oauth tokens when transport or URL changes

Editing a remote server's URL or transport previously carried the old
server's OAuth tokens onto the new registration, sending credentials
issued for one endpoint to a different one. Preserve oauth only when
the effective transport type + URL are unchanged (rename-safe).

* fix(desktop): treat legacy "http" MCP transport as streamableHttp alias

Core config-loader maps transportType "http" to streamableHttp, so a
legacy record resaved through the dialog is the same endpoint; without
normalizing, mcpTransportIdentity saw it as changed and dropped oauth.

* fix(desktop): default typeless URL-based legacy MCP records to sse

Core config-loader resolves a legacy flat record with a url but no
type/transportType as sse, while the sidecar defaulted to stdio. That
skewed mcpTransportIdentity (dropping oauth on a no-op edit) and made
list_mcp_servers report such records as stdio to the dialog.
2026-07-21 16:24:28 -07:00
Tomás Barreiro 78c6724c6a Add auth metadata to the auth telemetry (#12274)
* Add session and user id to auth telemetry events

* Add the auth metadata

* Address comments

* Add metadata to successful events

* remove user ids from the types

* fix tests

* address comments

* replace startedAtMs with sessionDurationMs

* fix tests

* update based on latest main

* fix imports
2026-07-22 00:41:43 +02:00
Bee f2a895cf86 fix(desktop): rebuild sessions when switching providers (#12454)
* fix(desktop): rebuild sessions when switching providers

Recreate active sessions with their existing transcript and compaction state before sending to a different provider. Preserve provider-specific connection settings and distinguish provider changes from model-only updates.

Add coverage to verify provider switches rebuild the session before sending.

Currently SendSessionInput has no provider/model configuration, so the desktop client must perform that lifecycle transition before sending. The cleaner long-term API would make provider selection part of an atomic turn request—something like send({ sessionId, prompt, providerId, modelId })—and let Core decide whether rebootstrap is necessary.

* fix(desktop): harden provider session transitions

* fix(desktop): make provider rebuilds transactional
2026-07-21 15:20:15 -07:00
Saoud Rizwan 1585999251 fix(desktop): make account page functional (#12424)
* fix(desktop): make account page functional

The account page rendered data but every interaction was dead:

- Sign Out button had no click handler at all. Wire it to clear the
  cline provider auth (same flow as cline-hub), show a signed-out card
  with a working Sign In (browser OAuth) instead of a raw error + Retry,
  and refresh the shared account context so the sidebar identity updates.
- Organization rows were static divs. Make them switchable (including a
  Personal row) via the existing cline_account switchAccount operation,
  with a pending spinner and overview + context reload after switching.
- External links (+ Credit, + Create org, open dashboard) used
  target=_blank anchors, which are silently dropped inside the Tauri
  shell (no window opener configured). Route them through a new
  open_external_url sidecar command that opens the host default browser
  (http/https only); plain web mode falls back to window.open.
- + Credit pointed at the organization credits page even for personal
  accounts; use dashboard/account?tab=credits when no org is active.
- Guard the browser-open spawn with an error listener so a missing
  opener binary can't crash the sidecar with an unhandled error event.
- Disable Usage/Billing tabs while signed out (they can only error).

Closes CLINE-2737

* fix(desktop): harden external URL opener and auth error classification

- open URLs on Windows via rundll32 instead of cmd /c start so URL
  metacharacters cannot be parsed as shell operators
- surface opener spawn failures instead of always reporting opened: true
- classify only definitive signals (missing token, re-auth required,
  status 401) as signed-out; transient refresh/permission errors keep
  the retryable error UI

* fix(desktop): reject external URL open when the launcher exits non-zero

The opener promise resolved on the spawn event, so a launcher that
started but failed to hand off (xdg-open exits 3 when no handler is
available) still reported opened: true. Reject on a fast non-zero exit;
if the launcher is still running after a 2s grace window, assume the
handoff worked rather than blocking on a launcher that lingers.
rundll32 exits 0 even on failure, so Windows stays best-effort.
2026-07-21 15:19:02 -07:00
Saoud Rizwan 2c556a4c94 feat(desktop): simplify Add MCP Server dialog with Local/Remote server types (#12425)
Replaces the raw stdio/sse/streamableHttp transport dropdown with a
plain-language Local vs Remote choice (CLINE-2748). Local (stdio) stays
the default per the MCP spec's "Clients SHOULD support stdio whenever
possible"; picking Remote defaults to Streamable HTTP with SSE offered
as a legacy option. Working directory and Metadata JSON move behind an
Advanced collapsible (auto-expanded when editing a server that uses
them), and the server list badge now shows friendly transport labels.
2026-07-21 14:53:33 -07:00
Saoud Rizwan 9a80fa04c2 fix(desktop): keep thinking indicator visible until first model output (#12432)
* fix(desktop): keep thinking indicator visible until first model output

The webview only rendered the Thinking indicator while the chat status
was 'starting', but Core reports 'running' as soon as the turn is
dispatched -- well before the first streamed token arrives. The spinner
flashed for the RPC roundtrip and then disappeared, leaving ~1s of dead
air (model time-to-first-token) before the assistant bubble appeared.

Keep the indicator up while the session is running and the model has
not produced output yet: no streaming assistant message, last visible
message is the user's prompt, and no approvals/questions pending.

Closes CLINE-2739

* test(desktop): tighten thinking indicator test formatting
2026-07-21 14:52:21 -07:00
Saoud Rizwan 22a1fa2c84 feat(cli): upgrade opentui 0.1.102 -> 0.4.3 (#12453)
* feat(cli): upgrade opentui 0.1.102 -> 0.4.3

Brings the TUI stack up from April's 0.1.102 to the current 0.4.x line
(0.4.4/0.4.5 are <7 days old and blocked by the registry release-age
gate; bump again once they age out).

- @opentui/core + @opentui/react 0.1.102 -> 0.4.3
- opentui-spinner ^0.0.6 -> ^0.0.7 (0.0.7 peers on @opentui/core ^0.3.4)
- react-reconciler pin 0.32.0 -> 0.33.0 to match @opentui/react 0.4.x

@opentui-ui/dialog stays at 0.1.2 (abandoned upstream, peers ^0.1.69 so
bun warns on install) but its runtime surface (DialogProvider,
useDialog, useDialogKeyboard) works against core 0.4.3 - the tui-test
command-palette spec renders a real dialog in a pty and passes.

Validation: tsc clean, unit 889/890 (the one failure repros on an
untouched main checkout - stale bun pm pack guard expectation), tui-test
62/62 across repeated runs.

* fix(cli): force single opentui generation via root overrides

The previous commit left @opentui-ui/dialog's ^0.1.69 peer range
unsatisfied by core/react 0.4.3, so bun recorded nested
@opentui/core@0.1.102 + @opentui/react@0.1.102 copies under the dialog
package in bun.lock. Local installs happened to link the dialog against
the hoisted 0.4.3 store variant (which is why tui-test passed), but a
fresh install from the lockfile - CI, release builds - would follow the
nested entries and run two renderer generations in one process: dialog
components extending 0.1.102 Renderable classes inside a 0.4.3 renderer
tree.

Pinning @opentui/core and @opentui/react in the root overrides block
forces every consumer, dialog included, onto 0.4.3. The nested lockfile
entries are gone and a runtime identity check confirms
DialogContainerRenderable's prototype chain reaches the same class
objects as the 0.4.3 core the app imports.

Side effect: changing overrides makes bun fully re-resolve the
lockfile. The only drift is ~108 @radix-ui entries nested under the
vscode webview-ui workspace moving to newer patch versions (~1.1.15 ->
~1.1.19); webview-ui's full build (tsc -b && vite build) passes with
them. This drift would land at the next release anyway since bun run
version deletes and re-resolves bun.lock.

Re-validated: tsc clean, tui-test 62/62, unit 889/890 (same single
pre-existing bun pm pack guard failure that repros on untouched main).
2026-07-21 14:49:26 -07:00
Parafee41andSaoud Rizwan 57d364ffc2 fix(cli): keep status delivery failures non-fatal (#12401)
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-21 14:18:02 -07:00
TheRealSpencer 0912f34286 fix(deps): bump mermaid to 11.16.0 and protobufjs to 7.6.5 (#12445)
Address mermaid CVEs (CVE-2026-41148/41149/41150/41159) and
protobufjs CVEs (CVE-2026-54269, CVE-2026-48712) by pinning
patched versions via package deps and workspace overrides.
2026-07-21 15:38:59 -05:00
Etisha Garg bdb216c110 Revert "docs: add Kimi K3 to ClinePass documentation (#12380)" (#12407)
This reverts commit cc29955c2d.
2026-07-21 10:02:58 -07:00
Mikołaj KondratekandClaude Fable 5 c92d4e7553 fix(sdk): preserve file line endings in the editor tool executor (#12305)
* fix(sdk): preserve file line endings in editor tool executor

The native editor executor split and joined file content on "\n" only.
On CRLF files (common on Windows), insertInFile left existing lines with
trailing "\r" while inserted lines were LF-only, producing mixed line
endings. Because reads go through readline with crlfDelay (which strips
"\r"), the model always emits LF-only old_text, so subsequent exact-match
replaceInFile calls failed; multi-line replace on pure-CRLF files was
broken the same way.

Detect the file's dominant EOL and normalize: insertInFile now splits
content and new_text on /\r\n|\n/ and joins with the detected EOL, and
replaceInFile normalizes old_text/new_text to the file's EOL before
matching. The str_replace diff output also splits on /\r\n|\n/ so it no
longer embeds stray "\r" in diff lines sent back to the model.

Reported via JetBrains marketplace review #141234 (DeepSeek + CLion on
Windows).

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

* fix(sdk): address review — accurate EOL doc, literal $-sequences in replace

Reword the detectLineEnding JSDoc: it is a presence check for CRLF, not a
majority vote, so say so instead of claiming "dominant" EOL.

Use a replacer function in replaceInFile so "$"-sequences in new_text
($&, $', $`, $$, $n) are inserted literally instead of being expanded by
String.prototype.replace. Pre-existing bug surfaced during review; adds a
regression test.

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

* docs(sdk): clarify why EOL detection is a CRLF presence check

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 16:33:37 +09:00
b919a7e86c docs: mark .clineignore as deprecated soon (#12410)
* docs: mark .clineignore as deprecated soon

Add a deprecation notice to the .clineignore page and update pages that recommend it. Enforcement of ignore rules is extremely difficult (agents can get around them via @ mentions or shell commands), and the feature is orphaned in the VS Code/JetBrains extension (ClineIgnoreController), not part of the Cline SDK or CLI.

* docs: update clineignore deprecation wording

* wording changes

* update clineignore docs with plugin reference

* fix plugin example url

* edit clineignore docs file

* update formatting for clineignore doc

* clean up clineignore docs file

---------

Co-authored-by: Cline <bot@cline.bot>
Co-authored-by: TheRealSpencer <spencer@cline.bot>
2026-07-20 18:01:37 -07:00
Bee eefbe9fb18 feat(desktop): log telemetry events in desktop app (#12416)
* feat(desktop): log telemetry events in desktop app

* Address feedback
2026-07-21 02:50:56 +02:00
Bee 402b9994d8 feat(desktop): tool use group (#12415)
* feat(desktop): tool use group

* focus block

* apple p2 feedback
2026-07-20 15:24:05 -07:00
Bee fce1b97512 feat(desktop): improve session overviews and clarify workspace errors (#12414)
* feat(desktop): improve session overview and workspace errors

* address p1
2026-07-20 14:41:30 -07:00
Bee e7b0cec8e1 fix(desktop): diff view layout with proper scrolling (#12411)
Use full-height flex sizing, prevent header shrinking, and allow the scroll area to contract so file diffs remain scrollable within the view.
2026-07-20 23:04:53 +02:00
Bee 353ddc10f4 feat(desktop): add account context and window title utilities (#12348)
* fix(desktop): filter project paths

Best effort to remove desktop and user's home directory from showing up in project list in the desktop app.

* feat(desktop-app): add account context and window title utilities

- Add AccountContext provider and hooks for managing Cline account identity
- Add account-context.tsx and account-context.test.tsx
- Add desktop-window-title.ts and desktop-window-title.test.ts
- Update workspace-paths.ts with new utility functions
- Update agent-sidebar.tsx and agent-sidebar.test.tsx to use account context
- Update page.tsx to integrate account context
- Update sidecar/commands.ts to support account operations
- Update core SDK exports

This adds proper account identity management and window title utilities for the desktop app.

* dedup normalizeWorkspacePath

* home page update
2026-07-20 21:40:39 +02:00
Ara cabeb61036 Add minimal task lifecycle telemetry (#11851)
* Add minimal task lifecycle telemetry

* fix(vscode): use runner-safe auto approval assertions

* fix task lifecycle telemetry cancellation ordering
2026-07-20 20:45:02 +02:00
Etisha Garg cc29955c2d docs: add Kimi K3 to ClinePass documentation (#12380) 2026-07-20 09:23:02 -07:00
Saoud Rizwan c2faf38d72 chore(cli): release v3.0.46 2026-07-18 23:14:41 -07:00
Saoud Rizwan 4dab17769c fix(cli): detect real insufficient_credits error from Cline API (#12394) 2026-07-18 23:11:46 -07:00
Saoud Rizwan 396032cd3b chore(cli): release v3.0.45 2026-07-18 21:03:37 -07:00
302 changed files with 115395 additions and 30904 deletions
+1
View File
@@ -0,0 +1 @@
../../.cline/skills/publish-desktop
+1
View File
@@ -0,0 +1 @@
../../.cline/skills/publish-desktop
+127
View File
@@ -0,0 +1,127 @@
---
name: publish-desktop
description: Use when preparing, tagging, and publishing a Cline Code desktop app (apps/examples/desktop-app) release. Guides changelog drafting, version bumps in package.json + tauri.conf.json, desktop-vX.Y.Z tags, and the desktop-publish GitHub workflow that builds, signs, notarizes, and updates the auto-update feed.
---
# Desktop App Release
Use this skill when the user asks to release the desktop app, publish Cline Code, bump the desktop version, create a `desktop-vX.Y.Z` tag, or trigger the desktop publish workflow.
> Working directory: run every command below from the repository root.
Desktop releases are macOS-only today (signed + notarized DMG for Apple Silicon and Intel) and are built entirely in GitHub Actions — there is no local publish path. Installed apps discover new releases automatically through the Tauri updater, so publishing a release is what ships the update to every existing user.
## Release contract
- Version sources (must match each other and the tag): `apps/examples/desktop-app/package.json` and `apps/examples/desktop-app/src-tauri/tauri.conf.json`. (`src-tauri/Cargo.toml` has its own version but `tauri.conf.json` overrides it; no need to touch it.)
- Release tag: `desktop-vX.Y.Z`, where `X.Y.Z` matches both version files.
- Release prep includes approved release notes, the version bumps, and an `apps/examples/desktop-app/CHANGELOG.md` update.
- Publish path: `.github/workflows/desktop-publish.yml` (workflow_dispatch, requires the tag to exist, point at the checked-out commit, and be reachable from `origin/main`).
- The workflow creates the `desktop-vX.Y.Z` GitHub release (DMGs + updater artifacts + `latest.json`) and refreshes the rolling `desktop-latest` release, which is the static auto-update feed every installed app polls. Never delete the `desktop-latest` release or tag.
- The changelog's top `## X.Y.Z` section is extracted verbatim into the GitHub release body, the Slack announcement, and the updater manifest notes.
- Always ask before pushing commits or tags.
## Workflow
1. Gather context.
```sh
git status --short --branch
git fetch origin --tags
git tag --list 'desktop-v*' --sort=-v:refname | head -10
node -p "require('./apps/examples/desktop-app/package.json').version"
node -p "require('./apps/examples/desktop-app/src-tauri/tauri.conf.json').version"
```
If there is no `desktop-v*` tag yet, this is the first release; use the desktop app's first commit as the baseline and say the baseline is inferred.
2. Collect release commits.
```sh
git log <last-desktop-tag>..HEAD --oneline --no-merges -- apps/examples/desktop-app sdk/packages .github/workflows/desktop-publish.yml
```
The sidecar bundles `@cline/core` and friends from the monorepo, so SDK changes ship inside the desktop app too. Fold user-visible SDK changes (providers, models, behavior fixes) into the notes; skip purely internal ones.
3. Draft user-facing release notes.
Flat bullet list, user-facing language. Present the draft and wait for approval before editing files.
4. Decide the version bump.
Ask whether this is patch, minor, major, or an explicit version. Do not guess if the user has not made it clear.
5. Update release files.
- `apps/examples/desktop-app/package.json` → new version
- `apps/examples/desktop-app/src-tauri/tauri.conf.json` → same version
- Prepend `## X.Y.Z` (no date) to `apps/examples/desktop-app/CHANGELOG.md` with the approved notes.
6. Verify before committing.
```sh
bun -F @cline/code typecheck
bun test apps/examples/desktop-app/scripts/generate-update-manifest.test.ts
```
The full desktop bundle can only be built on macOS; the workflow's build job is the real verification. For extra local confidence on a Mac checkout, `bun run package:desktop:mac --allow-unsigned-mac` from the app directory.
7. Commit release changes.
```sh
git add apps/examples/desktop-app/package.json apps/examples/desktop-app/src-tauri/tauri.conf.json apps/examples/desktop-app/CHANGELOG.md
git commit -m "chore(desktop): release vX.Y.Z"
```
Ask before pushing the release commit, then before creating and pushing the tag:
```sh
git push origin HEAD
git tag -a desktop-vX.Y.Z -m "Desktop vX.Y.Z"
git push origin refs/tags/desktop-vX.Y.Z
```
8. Publish.
The release commit must be on `main` and the tag pushed first.
```sh
gh workflow run desktop-publish.yml -f git_tag=desktop-vX.Y.Z -f confirm_publish=publish
gh run list --workflow=desktop-publish.yml --limit=1 --json url,status,conclusion,createdAt --jq '.[0]'
```
The workflow builds both architectures in parallel (aarch64 native, x86_64 cross-compiled), signs with the Developer ID certificate, notarizes with the App Store Connect API key, signs updater artifacts with the Tauri updater key, creates the GitHub release, refreshes `desktop-latest/latest.json`, and posts to Slack. Notarization typically adds 210 minutes.
If the workflow fails on missing credentials, see "Repo secrets (one-time setup)" below.
9. Verify the update feed after the run succeeds.
```sh
curl -sL https://github.com/cline/cline/releases/download/desktop-latest/latest.json | head -30
```
The `version` field must be the new release and both `darwin-aarch64` and `darwin-x86_64` URLs must point at the new `desktop-vX.Y.Z` assets. Installed apps pick the update up on next launch or within 2 hours.
10. Final response.
Report: version, tag, changelog updated, commit hash, what was pushed, workflow URL, and the feed verification result.
## Repo secrets (one-time setup)
The workflow needs these repository secrets. The Apple ones come from the same
Apple Developer account used for manual signing (see the app README's "macOS
signing & notarization" section for how to obtain them):
| Secret | Value |
| --- | --- |
| `APPLE_CERTIFICATE` | Base64 of the **Developer ID Application** identity exported from Keychain Access as `.p12` (must include the private key): `base64 -i certificate.p12 \| pbcopy` |
| `APPLE_CERTIFICATE_PASSWORD` | The password chosen when exporting the `.p12` |
| `APPLE_SIGNING_IDENTITY` | `Developer ID Application: <Team Name> (<TEAMID>)` — from `security find-identity -v -p codesigning` |
| `APPLE_API_KEY` | App Store Connect API **Key ID** (notarization) |
| `APPLE_API_KEY_CONTENT` | Contents of the `AuthKey_<KEYID>.p8` file |
| `APPLE_API_ISSUER` | App Store Connect **Issuer ID** (UUID from Users and Access → Integrations) |
| `TAURI_SIGNING_PRIVATE_KEY` | Contents of the Tauri updater private key (`tauri signer generate`). If this key is ever lost, shipped apps can no longer verify updates — guard it. |
| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Password for that key |
The Slack + telemetry secrets (`SLACK_RELEASE_BOT_TOKEN`, `TELEMETRY_SERVICE_API_KEY`,
OTEL settings) are shared with the CLI publish workflow and already configured.
+310
View File
@@ -0,0 +1,310 @@
name: desktop-publish
on:
workflow_dispatch:
inputs:
git_tag:
description: "Existing release tag to publish, for example desktop-v0.1.0"
required: true
type: string
confirm_publish:
description: 'Type "publish" to confirm the desktop release.'
required: true
type: string
permissions:
contents: read
defaults:
run:
working-directory: .
jobs:
validate:
name: Validate release tag
if: |
github.repository == 'cline/cline' &&
github.event.inputs.confirm_publish == 'publish' &&
!endsWith(github.actor, '[bot]')
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.git_tag }}
fetch-depth: 0
fetch-tags: true
- name: Validate release tag
id: version
env:
TAG: ${{ github.event.inputs.git_tag }}
run: |
if ! printf "%s\n" "$TAG" | grep -Eq '^desktop-v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
echo "git_tag must look like desktop-vX.Y.Z, got: ${TAG}"
exit 1
fi
VERSION="${TAG#desktop-v}"
PACKAGE_VERSION=$(node -p "require('./apps/examples/desktop-app/package.json').version")
TAURI_VERSION=$(node -p "require('./apps/examples/desktop-app/src-tauri/tauri.conf.json').version")
if [ "$PACKAGE_VERSION" != "$VERSION" ]; then
echo "apps/examples/desktop-app/package.json version ${PACKAGE_VERSION} does not match ${TAG}"
exit 1
fi
if [ "$TAURI_VERSION" != "$VERSION" ]; then
echo "apps/examples/desktop-app/src-tauri/tauri.conf.json version ${TAURI_VERSION} does not match ${TAG}"
exit 1
fi
TAG_COMMIT=$(git rev-parse "${TAG}^{commit}")
HEAD_COMMIT=$(git rev-parse HEAD)
if [ "$TAG_COMMIT" != "$HEAD_COMMIT" ]; then
echo "${TAG} does not point at the checked out commit"
exit 1
fi
git fetch origin +main:refs/remotes/origin/main
if ! git merge-base --is-ancestor "$HEAD_COMMIT" origin/main; then
echo "${TAG} is not reachable from origin/main"
exit 1
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
build:
name: Build macOS (${{ matrix.arch }})
needs: validate
runs-on: macos-latest
timeout-minutes: 90
strategy:
fail-fast: true
matrix:
include:
- target: aarch64-apple-darwin
arch: aarch64
- target: x86_64-apple-darwin
arch: x86_64
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ needs.validate.outputs.tag }}
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Cache Rust build
uses: swatinem/rust-cache@v2
with:
workspaces: apps/examples/desktop-app/src-tauri
key: ${{ matrix.target }}
- name: Install dependencies
run: bun install
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build SDK packages
run: bun run build:sdk
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
- name: Write App Store Connect API key
env:
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }}
run: |
if [ -z "$APPLE_API_KEY_CONTENT" ]; then
echo "APPLE_API_KEY_CONTENT secret is not configured"
exit 1
fi
printf "%s" "$APPLE_API_KEY_CONTENT" > "$RUNNER_TEMP/AuthKey.p8"
- name: Build, sign, and notarize desktop bundle
working-directory: apps/examples/desktop-app
run: bunx tauri build --target ${{ matrix.target }} --config src-tauri/tauri.release.conf.json
env:
# Developer ID signing (Tauri imports the cert into a temp keychain)
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
# Notarization via App Store Connect API key. Tauri reads the Key ID
# from APPLE_API_KEY; APPLE_API_KEY_ID alone silently skips notarization.
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_PATH: ${{ runner.temp }}/AuthKey.p8
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
# Updater artifact signing (minisign keypair, independent of Apple)
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
- name: Collect artifacts
working-directory: apps/examples/desktop-app
env:
VERSION: ${{ needs.validate.outputs.version }}
ARCH: ${{ matrix.arch }}
TARGET: ${{ matrix.target }}
run: |
BUNDLE_DIR="src-tauri/target/${TARGET}/release/bundle"
OUT="dist/publish"
mkdir -p "$OUT"
DMG=$(find "$BUNDLE_DIR/dmg" -name '*.dmg' -print -quit)
if [ -z "$DMG" ]; then
echo "no DMG produced under $BUNDLE_DIR/dmg"
exit 1
fi
cp "$DMG" "$OUT/Cline-Code_${VERSION}_${ARCH}.dmg"
TARBALL=$(find "$BUNDLE_DIR/macos" -name '*.app.tar.gz' -print -quit)
if [ -z "$TARBALL" ] || [ ! -f "${TARBALL}.sig" ]; then
echo "updater artifact or signature missing under $BUNDLE_DIR/macos"
exit 1
fi
cp "$TARBALL" "$OUT/Cline-Code_${VERSION}_${ARCH}.app.tar.gz"
cp "${TARBALL}.sig" "$OUT/Cline-Code_${VERSION}_${ARCH}.app.tar.gz.sig"
ls -lh "$OUT"
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: desktop-${{ matrix.arch }}
path: apps/examples/desktop-app/dist/publish/*
if-no-files-found: error
release:
name: Create GitHub release
needs: [validate, build]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ needs.validate.outputs.tag }}
fetch-depth: 0
fetch-tags: true
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: dist/desktop
merge-multiple: true
- name: Get Changelog Entry
id: changelog
run: |
# Grab content between the first "## " header and the next one
CONTENT=$(awk '/^## [0-9]/{if(found) exit; found=1; next} found{print}' apps/examples/desktop-app/CHANGELOG.md)
echo "content<<EOF" >> $GITHUB_OUTPUT
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
printf "%s\n" "$CONTENT" > "$RUNNER_TEMP/release-notes.md"
- name: Generate updater manifest
env:
VERSION: ${{ needs.validate.outputs.version }}
TAG: ${{ needs.validate.outputs.tag }}
run: |
bun apps/examples/desktop-app/scripts/generate-update-manifest.ts \
--version "$VERSION" \
--tag "$TAG" \
--dir dist/desktop \
--out dist/desktop/latest.json \
--repo "$GITHUB_REPOSITORY" \
--notes-file "$RUNNER_TEMP/release-notes.md"
cat dist/desktop/latest.json
- name: Get Previous Desktop Tag
id: prev_tag
env:
CURRENT_TAG: ${{ needs.validate.outputs.tag }}
run: |
PREV_TAG=$(git describe --tags --abbrev=0 --match 'desktop-v*' "$CURRENT_TAG^" 2>/dev/null || echo "")
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ needs.validate.outputs.tag }}
name: "Desktop v${{ needs.validate.outputs.version }}"
# The repo-wide "latest" release stays owned by CLI releases; the
# desktop auto-update feed is the rolling desktop-latest release.
make_latest: "false"
files: dist/desktop/*
body: |
${{ steps.changelog.outputs.content }}
${{ steps.prev_tag.outputs.prev_tag != '' && format('**Full Changelog**: https://github.com/{0}/compare/{1}...{2}', github.repository, steps.prev_tag.outputs.prev_tag, needs.validate.outputs.tag) || '' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Update auto-update feed (desktop-latest)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if ! gh release view desktop-latest >/dev/null 2>&1; then
gh release create desktop-latest \
--title "Cline Code desktop (auto-update feed)" \
--notes "Rolling release backing the desktop app auto-updater. The latest.json asset points at the newest desktop-vX.Y.Z release. Do not delete." \
--latest=false \
--target "$(git rev-parse HEAD)"
fi
gh release upload desktop-latest dist/desktop/latest.json --clobber
- name: Summary
env:
VERSION: ${{ needs.validate.outputs.version }}
TAG: ${{ needs.validate.outputs.tag }}
run: |
echo "Published Cline Code desktop v${VERSION}"
echo "Release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${TAG}"
echo "Auto-update feed refreshed: https://github.com/${GITHUB_REPOSITORY}/releases/download/desktop-latest/latest.json"
- name: Post release to Slack
uses: slackapi/slack-github-action@v3.0.1
with:
method: chat.postMessage
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
payload: |
channel: "C0APVKGGZFC"
text: "Cline Code desktop v${{ needs.validate.outputs.version }}"
blocks:
- type: "section"
text:
type: "mrkdwn"
text: "Cline Code desktop v${{ needs.validate.outputs.version }}"
- type: "section"
text:
type: "mrkdwn"
text: ${{ toJSON(steps.changelog.outputs.content) }}
- type: "context"
elements:
- type: "mrkdwn"
text: "<https://github.com/${{ github.repository }}/releases/tag/${{ needs.validate.outputs.tag }}|Download DMG> — installed apps auto-update on next launch${{ steps.prev_tag.outputs.prev_tag != '' && format(' | Full Changelog: https://github.com/{0}/compare/{1}...{2}', github.repository, steps.prev_tag.outputs.prev_tag, needs.validate.outputs.tag) || '' }}"
+13
View File
@@ -1,5 +1,18 @@
# Cline CLI Changelog
## 3.0.46
- Fixed out-of-credits detection so the CLI reliably recognizes the Cline API's real `insufficient_credits` (402) error and shows the "add credits" card instead of a generic error
## 3.0.45
- Smaller install: the Claude Code and Codex providers are now optional and loaded on demand, cutting `npm i -g cline` from ~640MB to ~285MB (from SDK v0.0.65)
- Kimi K3 is now available as a ClinePass model (from SDK v0.0.65)
- Runs now retry once after refreshing expired OAuth credentials (from SDK v0.0.65)
- Team runs: the spawn tool is no longer exposed to teammates, and errored teammate runs now report as failed instead of completed (from SDK v0.0.65)
- Hub status output now includes version numbers
- Updated the bundled model catalog (from SDK v0.0.65)
## 3.0.44
- Improved max output token handling across providers (gateway routing, OpenAI vendor, and reasoning models) (from SDK v0.0.64)
+1 -1
View File
@@ -257,7 +257,7 @@ Schedules can route results back to chat surfaces with `--delivery-adapter`, `--
| `--hooks-dir <path>` | Additional hooks directory hint for runtime hook injection |
| `--acp` | ACP (Agent Client Protocol) mode |
| `--thinking [none\|low\|medium\|high\|xhigh]` | Model thinking level when supported. Defaults to `medium` when the flag is provided without a level; thinking is off when the flag is omitted. |
| `--compaction <agentic\|basic\|off>` | Context compaction mode. Defaults to `basic`; use `agentic` for LLM compaction or `off` to disable. |
| `--compaction <agentic\|basic\|off>` | Context compaction mode. Defaults to `agentic`; use `basic` for local truncation or `off` to disable. |
| `--retries <count>` | Maximum consecutive mistakes (retries) before halting (default: `3`) |
| `--json` | Output NDJSON instead of styled text |
| `--data-dir <path>` | Use isolated local state at `<path>` instead of `~/.cline` (enables sandbox mode automatically) |
+5 -5
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.44",
"version": "3.0.46",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
@@ -78,19 +78,19 @@
"@cline/cline-hub": "workspace:*",
"@gramio/format": "^0.7.0",
"@opentui-ui/dialog": "^0.1.2",
"@opentui/core": "0.1.102",
"@opentui/react": "0.1.102",
"@opentui/core": "0.4.3",
"@opentui/react": "0.4.3",
"chat": "^4.23.0",
"commander": "^14.0.3",
"fzf": "^0.5.2",
"marked": "^15.0.12",
"open": "^10.2.0",
"opentui-spinner": "^0.0.6",
"opentui-spinner": "^0.0.7",
"pino": "^10.3.1",
"posthog-node": "^5.8.0",
"react": "19.2.4",
"react-devtools-core": "^7.0.1",
"react-reconciler": "0.32.0",
"react-reconciler": "0.33.0",
"yaml": "^2.8.2",
"nanoid": "^5.1.7",
"zod": "^4.1.11"
+4 -2
View File
@@ -148,8 +148,10 @@ export function isJsonPath(path: string): boolean {
return path.toLowerCase().endsWith(".json");
}
export function parseMode(raw: string | undefined): "act" | "plan" | undefined {
if (raw === "act" || raw === "plan") {
export function parseMode(
raw: string | undefined,
): "act" | "plan" | "yolo" | undefined {
if (raw === "act" || raw === "plan" || raw === "yolo") {
return raw;
}
return undefined;
+5 -3
View File
@@ -1,3 +1,4 @@
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
import type { Command } from "commander";
import { ensureSchedulerHub } from "./client";
import {
@@ -9,6 +10,7 @@ import {
mergeScheduleMetadata,
parseJsonObjectFlag,
parseList,
parseMode,
resolveAddress,
toPositiveInt,
} from "./common";
@@ -63,8 +65,8 @@ export function registerScheduleCommands(
.option("--disabled", "Create in disabled state")
.option("--max-parallel <n>", "Max parallel executions", "1")
.option("--metadata-json <json>", "Metadata as JSON object")
.option("--mode <act|plan>", "Execution mode")
.option("--model <model>", "Model to use", "openai/gpt-5.3-codex")
.option("--mode <act|plan|yolo>", "Execution mode", "yolo")
.option("--model <model>", "Model to use", CLINE_DEFAULT_MODEL_ID)
.option("--provider <id>", "Provider ID", "cline")
.option("--system-prompt <text>", "System prompt override")
.option("--tags <list>", "Comma-separated tags")
@@ -96,7 +98,7 @@ export function registerScheduleCommands(
prompt: opts.prompt,
provider: opts.provider,
model: opts.model,
mode: opts.mode === "plan" ? "plan" : "act",
mode: parseMode(opts.mode) ?? "yolo",
workspaceRoot: opts.workspace,
cwd: opts.cwd,
systemPrompt: opts.systemPrompt,
@@ -1,5 +1,6 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, resolve } from "node:path";
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
import type { Command } from "commander";
import { ensureSchedulerHub } from "./client";
import {
@@ -39,7 +40,7 @@ function resolveImportedModelSelection(parsed: Record<string, unknown>): {
modelSelection?.modelId ??
parsed.modelId ??
parsed.model ??
"openai/gpt-5.3-codex",
CLINE_DEFAULT_MODEL_ID,
).trim();
return { provider, model };
}
@@ -165,7 +166,10 @@ export function registerScheduleImportCommand(
prompt: String(parsed.prompt ?? "").trim(),
provider,
model,
mode: parsed.mode === "plan" ? "plan" : "act",
mode:
parseMode(
typeof parsed.mode === "string" ? parsed.mode : undefined,
) ?? "yolo",
workspaceRoot,
cwd: String(parsed.cwd ?? "").trim() || undefined,
systemPrompt:
@@ -229,7 +233,7 @@ export function registerScheduleUpdateCommand(
.option("--enabled", "Enable the schedule")
.option("--max-parallel <n>", "New max parallel executions")
.option("--metadata-json <json>", "New metadata as JSON object")
.option("--mode <act|plan>", "New execution mode")
.option("--mode <act|plan|yolo>", "New execution mode")
.option("--model <model>", "New model")
.option("--name <name>", "New name")
.option("--pause", "Pause the schedule")
@@ -67,6 +67,62 @@ describe("createConnectorRuntimeTurnStream", () => {
});
});
it("keeps streaming when tool status delivery fails", async () => {
let handlers: StreamHandlers | undefined;
const log = vi.fn();
const statusError = new Error("message_not_found");
const client = {
streamEvents: (_request: unknown, callbacks: StreamHandlers) => {
handlers = callbacks;
return () => {};
},
sendRuntimeSession: async () => {
handlers?.onEvent({
eventType: "runtime.chat.tool_call_start",
payload: { toolName: "run_commands" },
});
await new Promise((resolve) => setTimeout(resolve, 0));
handlers?.onEvent({
eventType: "runtime.chat.text_delta",
payload: { text: "Final response" },
});
return {
result: {
text: "Final response",
finishReason: "stop",
iterations: 1,
},
};
},
};
const chunks: string[] = [];
for await (const chunk of createConnectorRuntimeTurnStream({
client: client as never,
sessionId: "session-1",
request: { config: {} as never, prompt: "hi" },
clientId: "client-1",
logger: { core: { log } } as unknown as CliLoggerAdapter,
transport: "slack",
conversationId: "thread-1",
onToolStatus: async () => {
throw statusError;
},
})) {
chunks.push(chunk);
}
expect(chunks.join("")).toBe("Final response");
expect(log).toHaveBeenCalledWith(
"Connector tool status delivery failed",
expect.objectContaining({
severity: "warn",
transport: "slack",
error: statusError,
}),
);
});
it("treats queued runtime turns as non-error completion", async () => {
const log = vi.fn();
const client = {
+11 -1
View File
@@ -169,7 +169,17 @@ export function createConnectorRuntimeTurnStream(input: {
return;
}
lastStatusMessage = message;
await input.onToolStatus?.(message);
try {
await input.onToolStatus?.(message);
} catch (error) {
input.logger.core.log("Connector tool status delivery failed", {
severity: "warn",
transport: input.transport,
conversationId: input.conversationId,
sessionId: input.sessionId,
error,
});
}
};
const stopStreaming = input.client.streamEvents(
+2 -4
View File
@@ -333,7 +333,7 @@ describe("runCli lightweight command dispatch", () => {
expect(historyListCalls[0]?.[0]).not.toHaveProperty("workspaceRoot");
expect(mockState.runAgentImports).toBe(0);
expect(mockState.runInteractiveImports).toBe(0);
});
}, 30_000);
it("does not load runtime modules for root update", async () => {
mockState.runAgentImports = 0;
@@ -1302,7 +1302,6 @@ describe("runCli lightweight command dispatch", () => {
expect.objectContaining({
compaction: {
enabled: true,
strategy: "basic",
},
thinking: true,
reasoningEffort: "medium",
@@ -1389,7 +1388,7 @@ describe("runCli lightweight command dispatch", () => {
);
});
it("enables truncation compaction by default for prompt runs", async () => {
it("uses Core's agentic compaction default for prompt runs", async () => {
mockState.runAgentCalls = 0;
runtimeMocks.runAgent.mockClear();
@@ -1404,7 +1403,6 @@ describe("runCli lightweight command dispatch", () => {
expect.objectContaining({
compaction: {
enabled: true,
strategy: "basic",
},
}),
expect.anything(),
+5 -8
View File
@@ -2,7 +2,7 @@ let activeRuntimeAbort: (() => void) | undefined;
let activeRuntimeCleanup: (() => void) | undefined;
let abortGraceTimer: ReturnType<typeof setTimeout> | undefined;
let abortInProgress = false;
let savedRejectionListeners: Function[] | undefined;
let savedRejectionListeners: Array<(...args: unknown[]) => void> | undefined;
export function setActiveRuntimeAbort(abortFn: (() => void) | undefined): void {
activeRuntimeAbort = abortFn;
@@ -49,9 +49,9 @@ export function markAbortInProgress(): void {
// rejections in the LLM streaming layer that reach every registered
// listener (including OpenTUI's error overlay). Swapping the listeners
// is the only way to prevent them from surfacing to the user.
savedRejectionListeners = process.rawListeners(
"unhandledRejection",
) as Function[];
savedRejectionListeners = process.rawListeners("unhandledRejection") as Array<
(...args: unknown[]) => void
>;
process.removeAllListeners("unhandledRejection");
process.on("unhandledRejection", (_reason, promise) => {
promise.catch(() => {});
@@ -68,10 +68,7 @@ export function clearAbortInProgress(): void {
if (savedRejectionListeners) {
process.removeAllListeners("unhandledRejection");
for (const listener of savedRejectionListeners) {
process.on(
"unhandledRejection",
listener as (...args: unknown[]) => void,
);
process.on("unhandledRejection", listener);
}
savedRejectionListeners = undefined;
}
@@ -12,6 +12,25 @@ import {
resolveCompactionProviderConfig,
} from "./compaction";
const createHandlerMock = vi.fn();
// Core defaults to the agentic compaction strategy, which summarizes via a
// real LLM handler. Stub only `createHandlerAsync` so no network call (or API
// key) is needed; every other `@cline/llms` export stays real because
// `@cline/core` re-exports them.
vi.mock("@cline/llms", async (importOriginal) => ({
...(await importOriginal<typeof import("@cline/llms")>()),
createHandlerAsync: (config: unknown) => createHandlerMock(config),
}));
async function* streamChunks(
chunks: Array<Record<string, unknown>>,
): AsyncGenerator<Record<string, unknown>> {
for (const chunk of chunks) {
yield chunk;
}
}
function createConfig(): Config {
return {
providerId: "anthropic",
@@ -46,6 +65,7 @@ function createProviderSettingsManager(): ProviderSettingsManager {
}
afterEach(() => {
createHandlerMock.mockReset();
for (const tempDir of providerSettingsTempDirs.splice(0)) {
rmSync(tempDir, { force: true, recursive: true });
}
@@ -163,6 +183,15 @@ describe("compactInteractiveMessages", () => {
});
it("uses a useful target budget for manual compaction", async () => {
const mockSummary = "## Goal\nMocked agentic compaction summary";
createHandlerMock.mockReturnValue({
createMessage: vi.fn(() =>
streamChunks([
{ type: "text", id: "summary-1", text: mockSummary },
{ type: "done", id: "summary-1", success: true },
]),
),
});
const longText = "x".repeat(16_000);
const messages = Array.from({ length: 10 }, (_, index) => ({
role: index % 2 === 0 ? ("user" as const) : ("assistant" as const),
@@ -189,6 +218,17 @@ describe("compactInteractiveMessages", () => {
expect(compactedMessages.length).toBeGreaterThan(1);
expect(compactedMessages.length).toBeLessThan(messages.length);
expect(compactedTextLength).toBeGreaterThan(1_000);
// The agentic strategy folds older messages into a summary message
// built from the (mocked) summarizer output.
expect(createHandlerMock).toHaveBeenCalledTimes(1);
const [summaryMessage] = compactedMessages;
const summaryText = Array.isArray(summaryMessage?.content)
? summaryMessage.content
.map((block) => ("text" in block ? block.text : ""))
.join("\n")
: String(summaryMessage?.content ?? "");
expect(summaryText).toContain(mockSummary);
});
it("reports compaction when core returns changed messages with the same count", async () => {
+59
View File
@@ -309,3 +309,62 @@ describe("loadIndividualSubscriptionPlans", () => {
expect(result).toEqual(plans);
});
});
describe("isClineAccountCreditsErrorMessage", () => {
it("matches the raw insufficient_credits JSON payload from the Cline API 402", async () => {
const { isClineAccountCreditsErrorMessage } = await import(
"./cline-account"
);
expect(
isClineAccountCreditsErrorMessage(
'{"code":"insufficient_credits","current_balance":-0.14,"message":"Not enough credits available"}',
),
).toBe(true);
});
it("matches the insufficient_credits payload wrapped in an error prefix", async () => {
const { isClineAccountCreditsErrorMessage } = await import(
"./cline-account"
);
expect(
isClineAccountCreditsErrorMessage(
'Error: {"code":"insufficient_credits","current_balance":0,"message":"Not enough credits available"}',
),
).toBe(true);
});
it("matches the plain human-readable Cline API message", async () => {
const { isClineAccountCreditsErrorMessage } = await import(
"./cline-account"
);
expect(
isClineAccountCreditsErrorMessage("Not enough credits available"),
).toBe(true);
});
it("matches the legacy insufficient balance phrasing", async () => {
const { isClineAccountCreditsErrorMessage } = await import(
"./cline-account"
);
expect(
isClineAccountCreditsErrorMessage(
"Insufficient balance. Your Cline credits balance is $0.00.",
),
).toBe(true);
});
it("does not match unrelated errors", async () => {
const { isClineAccountCreditsErrorMessage } = await import(
"./cline-account"
);
expect(isClineAccountCreditsErrorMessage("Payment Required")).toBe(false);
expect(
isClineAccountCreditsErrorMessage(
"Your credit balance is too low to access the Anthropic API.",
),
).toBe(false);
expect(
isClineAccountCreditsErrorMessage("insufficient balance on gateway"),
).toBe(false);
});
});
+9 -2
View File
@@ -51,9 +51,16 @@ export function isClineAccountAuthErrorMessage(message: string): boolean {
export function isClineAccountCreditsErrorMessage(message: string): boolean {
const normalized = message.trim().toLowerCase();
// The Cline API's 402 response carries `code: "insufficient_credits"` and
// the message "Not enough credits available". Depending on how much of the
// payload survives error extraction, the CLI may see the raw JSON blob or
// just the human-readable message, so match both. The
// "insufficient balance" pair is an older backend phrasing kept for safety.
return (
normalized.includes("insufficient balance") &&
normalized.includes("cline credits balance")
normalized.includes("insufficient_credits") ||
normalized.includes("not enough credits") ||
(normalized.includes("insufficient balance") &&
normalized.includes("cline credits balance"))
);
}
+1 -1
View File
@@ -134,7 +134,7 @@ function formatToolParams(
const el = f.endLine != null ? String(f.endLine) : "undefined";
const sep = i > 0 ? "; " : "";
return (
<span key={`${i}:${f.path}`}>
<span key={`${f.path}:${sl}:${el}`}>
{sep}
{shortenPath(f.path)}
<span fg="gray">
+3 -6
View File
@@ -15,14 +15,12 @@ function createConfig(compaction?: Config["compaction"]): Config {
}
describe("CLI compaction mode helpers", () => {
it("defaults enabled compaction to basic truncation", () => {
expect(DEFAULT_CLI_COMPACTION_MODE).toBe("basic");
it("defaults enabled compaction to agentic summarization", () => {
expect(DEFAULT_CLI_COMPACTION_MODE).toBe("agentic");
expect(getCliCompactionMode(createConfig())).toBe(
DEFAULT_CLI_COMPACTION_MODE,
);
expect(formatCliCompactionMode(DEFAULT_CLI_COMPACTION_MODE)).toBe(
"Truncation",
);
expect(formatCliCompactionMode(DEFAULT_CLI_COMPACTION_MODE)).toBe("LLM");
});
it("maps basic and off modes to core compaction config", () => {
@@ -47,7 +45,6 @@ describe("CLI compaction mode helpers", () => {
it("builds default and explicit core compaction config", () => {
expect(buildCliCompactionConfig()).toEqual({
enabled: true,
strategy: "basic",
});
expect(buildCliCompactionConfig("agentic")).toEqual({
enabled: true,
+7 -6
View File
@@ -5,7 +5,7 @@ export const CLI_COMPACTION_MODES = ["basic", "agentic", "off"] as const;
export const DEFAULT_CLI_COMPACTION_MODE: Extract<
CliCompactionMode,
"agentic" | "basic"
> = "basic";
> = "agentic";
const CLI_COMPACTION_MODE_ALIASES: Record<string, CliCompactionMode> = {
agentic: "agentic",
@@ -20,7 +20,7 @@ const CLI_COMPACTION_MODE_LABELS = {
} as const satisfies Record<CliCompactionMode, string>;
export const CLI_COMPACTION_MODE_OPTION_DESCRIPTION =
"Context compaction mode: agentic|basic|off (default: basic)";
"Context compaction mode: agentic|basic|off (default: agentic)";
export const CLI_COMPACTION_MODE_EXPECTED_TEXT = '"agentic", "basic", or "off"';
@@ -31,8 +31,11 @@ export function parseCliCompactionMode(
}
export function buildCliCompactionConfig(
mode: CliCompactionMode | undefined = DEFAULT_CLI_COMPACTION_MODE,
mode?: CliCompactionMode,
): NonNullable<Config["compaction"]> {
if (mode === undefined) {
return { enabled: true };
}
if (mode === "off") {
return { enabled: false };
}
@@ -43,9 +46,7 @@ export function getCliCompactionMode(config: Config): CliCompactionMode {
if (config.compaction?.enabled === false) {
return "off";
}
return config.compaction?.strategy === "agentic"
? "agentic"
: DEFAULT_CLI_COMPACTION_MODE;
return config.compaction?.strategy ?? DEFAULT_CLI_COMPACTION_MODE;
}
export function applyCliCompactionMode(
+6 -1
View File
@@ -141,13 +141,18 @@ function captureRemoteConfigInitialized(bundle: RemoteConfigBundle): void {
export async function prepareCliEnterpriseIntegration(
input: ClineCoreStartInput,
) {
const workspacePath =
input.config.workspaceRoot?.trim() || input.config.cwd?.trim();
if (!workspacePath) {
return undefined;
}
const bundle = await loadCliRemoteConfigBundle();
if (!bundle) {
return undefined;
}
captureRemoteConfigInitialized(bundle);
return prepareRemoteConfigCoreIntegration({
workspacePath: input.config.workspaceRoot ?? input.config.cwd,
workspacePath,
pluginName: "enterprise",
controlPlane: {
name: "cline-account",
+5 -3
View File
@@ -1,4 +1,5 @@
import * as p from "@clack/prompts";
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
import {
ensureSchedulerHub,
type HubScheduleClient,
@@ -135,10 +136,11 @@ async function actionCreate(client: HubScheduleClient): Promise<void> {
const mode = await p.select({
message: "Agent mode",
options: [
{ value: "yolo", label: "Yolo", hint: "execute without approvals" },
{ value: "act", label: "Act", hint: "execute tasks" },
{ value: "plan", label: "Plan", hint: "plan only" },
],
initialValue: "act",
initialValue: "yolo",
});
if (isCancel(mode)) return;
@@ -214,8 +216,8 @@ async function actionCreate(client: HubScheduleClient): Promise<void> {
cronPattern,
prompt: (prompt as string).trim(),
provider: provider ?? "cline",
model: model ?? "openai/gpt-5.3-codex",
mode: (mode as string) === "plan" ? "plan" : "act",
model: model ?? CLINE_DEFAULT_MODEL_ID,
mode: mode as "act" | "plan" | "yolo",
workspaceRoot: (workspace as string).trim(),
systemPrompt,
maxIterations,
+46 -23
View File
@@ -3,6 +3,12 @@ import {
HubScheduleCommandService,
HubScheduleService,
} from "@cline/core";
import {
CLINE_DEFAULT_MODEL_ID,
ONE_TIME_SCHEDULE_CRON_PATTERN,
ONE_TIME_SCHEDULE_RUN_AT_METADATA_KEY,
readHubScheduleMode,
} from "@cline/shared";
import { asTrimmedString, toPositiveInt } from "./utils";
let scheduleService: HubScheduleService | undefined;
@@ -36,6 +42,31 @@ async function clientCommand(
return (reply.payload ?? {}) as Record<string, unknown>;
}
function routineScheduleTiming(
args?: Record<string, unknown>,
): { cronPattern: string; metadata?: Record<string, number> } | undefined {
if (args?.schedule_type === "once") {
const runAt =
typeof args.run_at === "number" ? args.run_at : Number(args?.run_at);
return Number.isFinite(runAt)
? {
cronPattern: ONE_TIME_SCHEDULE_CRON_PATTERN,
metadata: { [ONE_TIME_SCHEDULE_RUN_AT_METADATA_KEY]: runAt },
}
: undefined;
}
const cronPattern = asTrimmedString(args?.cron_pattern);
return cronPattern ? { cronPattern } : undefined;
}
function asTrimmedStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined;
const values = value
.map((item) => asTrimmedString(item))
.filter((item): item is string => item !== undefined);
return values.length > 0 ? values : undefined;
}
export async function handleRoutineScheduleCommand(
command: string,
args?: Record<string, unknown>,
@@ -76,23 +107,23 @@ export async function handleRoutineScheduleCommand(
if (command === "create_routine_schedule") {
const name = asTrimmedString(args?.name);
const cronPattern = asTrimmedString(args?.cron_pattern);
const timing = routineScheduleTiming(args);
const prompt = asTrimmedString(args?.prompt);
const routineWorkspaceRoot = asTrimmedString(args?.workspace_root);
if (!name || !cronPattern || !prompt || !routineWorkspaceRoot) {
if (!name || !timing || !prompt || !routineWorkspaceRoot) {
throw new Error(
"createSchedule requires name, cron_pattern, prompt, and workspace_root",
"createSchedule requires name, timing, prompt, and workspace_root",
);
}
const created = await clientCommand("schedule.create", {
name,
cronPattern,
...timing,
prompt,
modelSelection: {
providerId: asTrimmedString(args?.provider) ?? "cline",
modelId: asTrimmedString(args?.model) ?? "openai/gpt-5.3-codex",
modelId: asTrimmedString(args?.model) ?? CLINE_DEFAULT_MODEL_ID,
},
mode: args?.mode === "plan" ? "plan" : "act",
mode: readHubScheduleMode(args, "yolo"),
workspaceRoot: routineWorkspaceRoot,
cwd: asTrimmedString(args?.cwd),
systemPrompt: asTrimmedString(args?.system_prompt),
@@ -100,12 +131,7 @@ export async function handleRoutineScheduleCommand(
timeoutSeconds: toPositiveInt(args?.timeout_seconds),
maxParallel: toPositiveInt(args?.max_parallel) ?? 1,
enabled: args?.enabled !== false,
tags:
Array.isArray(args?.tags) && args.tags.length > 0
? (args.tags as string[])
.map((v) => v.trim())
.filter((v) => v.length > 0)
: undefined,
tags: asTrimmedStringArray(args?.tags),
});
return { schedule: created.schedule ?? null };
}
@@ -113,25 +139,26 @@ export async function handleRoutineScheduleCommand(
const scheduleId = asTrimmedString(args?.schedule_id);
if (!scheduleId) throw new Error(`${command} requires schedule_id`);
if (command === "update_routine_schedule") {
const mode = readHubScheduleMode(args);
const name = asTrimmedString(args?.name);
const cronPattern = asTrimmedString(args?.cron_pattern);
const timing = routineScheduleTiming(args);
const prompt = asTrimmedString(args?.prompt);
const routineWorkspaceRoot = asTrimmedString(args?.workspace_root);
if (!name || !cronPattern || !prompt || !routineWorkspaceRoot) {
if (!name || !timing || !prompt || !routineWorkspaceRoot) {
throw new Error(
"updateSchedule requires schedule_id, name, cron_pattern, prompt, and workspace_root",
"updateSchedule requires schedule_id, name, timing, prompt, and workspace_root",
);
}
const reply = await clientCommand("schedule.update", {
scheduleId,
name,
cronPattern,
...timing,
prompt,
modelSelection: {
providerId: asTrimmedString(args?.provider) ?? "cline",
modelId: asTrimmedString(args?.model) ?? "openai/gpt-5.3-codex",
modelId: asTrimmedString(args?.model) ?? CLINE_DEFAULT_MODEL_ID,
},
mode: args?.mode === "plan" ? "plan" : "act",
...(mode === undefined ? {} : { mode }),
workspaceRoot: routineWorkspaceRoot,
cwd: asTrimmedString(args?.cwd) ?? null,
systemPrompt:
@@ -148,11 +175,7 @@ export async function handleRoutineScheduleCommand(
: toPositiveInt(args?.timeout_seconds),
maxParallel: toPositiveInt(args?.max_parallel) ?? 1,
enabled: args?.enabled !== false,
tags: Array.isArray(args?.tags)
? (args.tags as string[])
.map((v) => v.trim())
.filter((v) => v.length > 0)
: [],
tags: asTrimmedStringArray(args?.tags) ?? [],
});
return { schedule: reply.schedule ?? null };
}
@@ -79,6 +79,7 @@ function summarizeClient(client: TrackedClient): {
if (
normalizedType === "code-sidecar" ||
normalizedType === "code-sidecar-approvals" ||
normalizedType === "code-sidecar-observer" ||
normalizedType === "code-sidecar-list"
) {
return { key: "code-app", label: "Code App", name: "Code App" };
+2 -1
View File
@@ -10,6 +10,7 @@
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@cline/shared": "workspace:*",
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@rive-app/react-webgl2": "^4.27.2",
@@ -26,7 +27,7 @@
"embla-carousel-react": "^8.6.0",
"lucide-react": "^0.577.0",
"media-chrome": "^4.18.1",
"mermaid": "^11.15.0",
"mermaid": "11.16.0",
"motion": "^12.38.0",
"nanoid": "^5.1.7",
"next-themes": "^0.4.6",
@@ -1,5 +1,10 @@
"use client";
import {
CLINE_DEFAULT_MODEL_ID,
ONE_TIME_SCHEDULE_CRON_PATTERN,
ONE_TIME_SCHEDULE_RUN_AT_METADATA_KEY,
} from "@cline/shared";
import {
Circle,
Eye,
@@ -72,6 +77,7 @@ interface RoutineSchedule {
scheduleId: string;
name: string;
cronPattern: string;
metadata?: Record<string, unknown>;
prompt: string;
provider?: string;
model?: string;
@@ -145,7 +151,7 @@ interface ProcessContext {
}
const FALLBACK_PROVIDER_MODELS: Record<string, string[]> = {
cline: ["anthropic/claude-sonnet-4.6"],
cline: [CLINE_DEFAULT_MODEL_ID],
anthropic: ["claude-sonnet-4-6"],
"openai-native": ["gpt-5.3-codex"],
openrouter: ["anthropic/claude-sonnet-4.6"],
@@ -170,13 +176,9 @@ interface RoutineFormState {
prompt: string;
provider: string;
model: string;
mode: "act" | "plan";
workspaceRoot: string;
cwd: string;
systemPrompt: string;
maxIterations: string;
timeoutSeconds: string;
maxParallel: string;
tags: string;
enabled: boolean;
}
@@ -203,6 +205,15 @@ function formatDateTime(value?: DateTimeValue | null): string {
return parsed.toLocaleString();
}
function getOneTimeScheduleRunAt(
schedule: RoutineSchedule,
): number | undefined {
const runAt = schedule.metadata?.[ONE_TIME_SCHEDULE_RUN_AT_METADATA_KEY];
return typeof runAt === "number" && Number.isFinite(runAt)
? runAt
: undefined;
}
function formatScheduleModel(schedule: RoutineSchedule): string {
const provider =
schedule.modelSelection?.providerId?.trim() || schedule.provider?.trim();
@@ -226,7 +237,7 @@ function getScheduleProviderModel(schedule: RoutineSchedule): {
model:
schedule.modelSelection?.modelId?.trim() ||
schedule.model?.trim() ||
"openai/gpt-5.3-codex",
CLINE_DEFAULT_MODEL_ID,
};
}
@@ -241,20 +252,27 @@ function formatExecutionResult(execution?: RoutineExecution): string {
return when === "-" ? status : `${status} at ${when}`;
}
function parseOptionalPositiveInt(text: string): number | undefined {
const trimmed = text.trim();
function asTrimmedFormString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function parseOptionalPositiveInt(value: unknown): number | undefined {
const trimmed = asTrimmedFormString(value);
if (!trimmed) {
return undefined;
}
const value = Number.parseInt(trimmed, 10);
if (!Number.isFinite(value) || value <= 0) {
const parsedValue = Number.parseInt(trimmed, 10);
if (!Number.isFinite(parsedValue) || parsedValue <= 0) {
return undefined;
}
return value;
return parsedValue;
}
function parseTags(text: string): string[] | undefined {
const tags = text
function parseTags(value: unknown): string[] | undefined {
if (typeof value !== "string") {
return undefined;
}
const tags = value
.split(",")
.map((value) => value.trim())
.filter((value) => value.length > 0);
@@ -382,14 +400,10 @@ export function RoutineSchedulesContent() {
scheduleDays: ["MON", "TUE", "WED", "THU", "FRI"],
prompt: "Review PRs opened yesterday and summarize issues.",
provider: "cline",
model: "openai/gpt-5.3-codex",
mode: "act",
model: CLINE_DEFAULT_MODEL_ID,
workspaceRoot: "",
cwd: "",
systemPrompt: "",
maxIterations: "",
timeoutSeconds: "",
maxParallel: "1",
tags: "",
enabled: true,
});
@@ -702,13 +716,9 @@ export function RoutineSchedulesContent() {
prompt: "Review PRs opened yesterday and summarize issues.",
provider: preferredProvider,
model: preferredModel,
mode: "act",
workspaceRoot: context.workspaceRoot || context.cwd,
cwd: context.cwd || "",
systemPrompt: "",
maxIterations: "",
timeoutSeconds: "",
maxParallel: "1",
tags: "",
enabled: true,
});
@@ -716,6 +726,9 @@ export function RoutineSchedulesContent() {
};
const openEditDialog = (schedule: RoutineSchedule) => {
if (schedule.cronPattern === ONE_TIME_SCHEDULE_CRON_PATTERN) {
return;
}
const { provider, model } = getScheduleProviderModel(schedule);
const parsedCron = parseCronPattern(schedule.cronPattern);
setEditingSchedule(schedule);
@@ -738,22 +751,12 @@ export function RoutineSchedulesContent() {
prompt: schedule.prompt,
provider,
model,
mode: schedule.mode === "plan" ? "plan" : "act",
workspaceRoot: schedule.workspaceRoot ?? "",
cwd: schedule.cwd ?? "",
systemPrompt: schedule.systemPrompt ?? "",
maxIterations:
typeof schedule.maxIterations === "number"
? String(schedule.maxIterations)
: "",
timeoutSeconds:
typeof schedule.timeoutSeconds === "number"
? String(schedule.timeoutSeconds)
: "",
maxParallel:
typeof schedule.maxParallel === "number"
? String(schedule.maxParallel)
: "1",
tags: schedule.tags?.join(",") ?? "",
enabled: schedule.enabled,
});
@@ -761,7 +764,7 @@ export function RoutineSchedulesContent() {
};
const submitCreateForm = async () => {
const name = createForm.name.trim();
const name = asTrimmedFormString(createForm.name);
if (!name) {
setCreateFormError("Routine name is required.");
return;
@@ -775,12 +778,12 @@ export function RoutineSchedulesContent() {
setCreateFormError("Select at least one day and a valid time.");
return;
}
const prompt = createForm.prompt.trim();
const prompt = asTrimmedFormString(createForm.prompt);
if (!prompt) {
setCreateFormError("Prompt is required.");
return;
}
const workspaceRoot = createForm.workspaceRoot.trim();
const workspaceRoot = asTrimmedFormString(createForm.workspaceRoot);
if (!workspaceRoot) {
setCreateFormError("Workspace root is required.");
return;
@@ -789,18 +792,17 @@ export function RoutineSchedulesContent() {
setIsCreating(true);
try {
const provider =
normalizeProviderId(createForm.provider) ||
normalizeProviderId(asTrimmedFormString(createForm.provider)) ||
availableProviders[0] ||
"cline";
const model =
createForm.model.trim() ||
asTrimmedFormString(createForm.model) ||
(visibleProviderModels[provider] ?? [])[0] ||
"openai/gpt-5.3-codex";
const maxIterations = parseOptionalPositiveInt(createForm.maxIterations);
CLINE_DEFAULT_MODEL_ID;
const systemPrompt = asTrimmedFormString(createForm.systemPrompt);
const timeoutSeconds = parseOptionalPositiveInt(
createForm.timeoutSeconds,
);
const maxParallel = parseOptionalPositiveInt(createForm.maxParallel) ?? 1;
const tags = parseTags(createForm.tags);
const command = editingSchedule
? "update_routine_schedule"
@@ -814,19 +816,16 @@ export function RoutineSchedulesContent() {
prompt,
provider,
model,
mode: createForm.mode,
mode: editingSchedule?.mode ?? "yolo", // New routines must default to yolo mode.
workspace_root: workspaceRoot,
cwd: createForm.cwd.trim() || undefined,
cwd: editingSchedule ? (editingSchedule.cwd ?? null) : workspaceRoot,
system_prompt: editingSchedule
? createForm.systemPrompt.trim() || null
: createForm.systemPrompt.trim() || undefined,
max_iterations: editingSchedule
? (maxIterations ?? null)
: maxIterations,
? systemPrompt || null
: systemPrompt || undefined,
timeout_seconds: editingSchedule
? (timeoutSeconds ?? null)
: timeoutSeconds,
max_parallel: maxParallel,
max_parallel: 1,
enabled: createForm.enabled,
tags: tags ?? [],
});
@@ -948,7 +947,9 @@ export function RoutineSchedulesContent() {
{schedule.mode}
</span>
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
{schedule.cronPattern}
{schedule.cronPattern === ONE_TIME_SCHEDULE_CRON_PATTERN
? `Once · ${formatDateTime(getOneTimeScheduleRunAt(schedule))}`
: schedule.cronPattern}
</span>
<div className="flex-1" />
<div className="flex items-center gap-1">
@@ -967,7 +968,10 @@ export function RoutineSchedulesContent() {
size="icon-sm"
aria-label={`Edit ${schedule.name}`}
onClick={() => openEditDialog(schedule)}
disabled={isBusy}
disabled={
isBusy ||
schedule.cronPattern === ONE_TIME_SCHEDULE_CRON_PATTERN
}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
@@ -1373,27 +1377,6 @@ export function RoutineSchedulesContent() {
</Combobox>
</div>
<div>
<Label>Mode</Label>
<Select
value={createForm.mode}
onValueChange={(value) =>
setCreateForm((prev) => ({
...prev,
mode: value === "plan" ? "plan" : "act",
}))
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select mode" />
</SelectTrigger>
<SelectContent>
<SelectItem value="act">act</SelectItem>
<SelectItem value="plan">plan</SelectItem>
</SelectContent>
</Select>
</div>
<div className="sm:col-span-2">
<Label htmlFor="routine-workspace">Workspace root</Label>
<Input
@@ -1408,20 +1391,6 @@ export function RoutineSchedulesContent() {
/>
</div>
<div className="sm:col-span-2">
<Label htmlFor="routine-cwd">CWD (optional)</Label>
<Input
id="routine-cwd"
value={createForm.cwd}
onChange={(event) =>
setCreateForm((prev) => ({
...prev,
cwd: event.target.value,
}))
}
/>
</div>
<div className="sm:col-span-2">
<Label htmlFor="routine-system-prompt">
System prompt (optional)
@@ -1439,23 +1408,6 @@ export function RoutineSchedulesContent() {
/>
</div>
<div>
<Label htmlFor="routine-max-iterations">
Max iterations (optional)
</Label>
<Input
id="routine-max-iterations"
value={createForm.maxIterations}
onChange={(event) =>
setCreateForm((prev) => ({
...prev,
maxIterations: event.target.value,
}))
}
placeholder="50"
/>
</div>
<div>
<Label htmlFor="routine-timeout">
Timeout seconds (optional)
@@ -1473,21 +1425,6 @@ export function RoutineSchedulesContent() {
/>
</div>
<div>
<Label htmlFor="routine-max-parallel">Max parallel</Label>
<Input
id="routine-max-parallel"
value={createForm.maxParallel}
onChange={(event) =>
setCreateForm((prev) => ({
...prev,
maxParallel: event.target.value,
}))
}
placeholder="1"
/>
</div>
<div>
<Label htmlFor="routine-tags">
Tags (comma-separated, optional)
+27
View File
@@ -0,0 +1,27 @@
# Cline Code Desktop Changelog
## 0.0.4
- Start chatting without opening a project folder — the app now supports workspace-free chat sessions.
- New first-run onboarding flow to get you set up on launch.
- Drag and drop files directly onto the chat to attach them.
- Image attachments now display inline in the chat transcript.
- Schedule one-time routines (not just recurring ones), with navigation to jump to a routine's run.
- New custom overlay title bar with in-app navigation.
- Redesigned channel setup as expandable cards.
- Added a setting to replay the new-user experience.
- Cleaner chat markdown rendering, and external links now open correctly in your browser.
- Agent sessions now use agentic compaction by default, keeping long conversations within context more intelligently.
- Fixed the agent not finding `gh` and other CLI tools by resolving your login shell's PATH.
- Headless routines now default to YOLO mode so they can run unattended.
- Fixed request metering for the SAP AI Core provider.
## 0.0.3
- The reasoning section in the chat transcript now reads simply "Thinking" — dropped the redundant status text and brain icon.
## 0.0.2
- First public release of Cline Code for macOS: a desktop app for running and inspecting Cline agent sessions, signed and notarized for Apple Silicon and Intel.
- Automatic updates: the app checks on launch and every 2 hours, downloads new versions in the background, and prompts for a one-click restart. Ignored updates apply on the next launch.
- Download the DMG once from GitHub Releases — every future release arrives automatically.
+52 -5
View File
@@ -16,6 +16,20 @@ From `apps/examples/desktop-app/`:
- `bun run package:desktop` - package the current OS desktop app into `dist/desktop/`
- `bun run typecheck` - TypeScript check
## Login Shell PATH Resolution
Apps launched from Finder/the Dock inherit launchd's minimal `PATH`
(`/usr/bin:/bin:/usr/sbin:/sbin`), not the one your shell profiles build, so
agent-run commands would miss Homebrew-installed tools like `gh` even though
they work fine from a terminal. At startup the sidecar asks the user's login
shell — read from the account database via `getpwuid`, falling back to
`$SHELL` — for its `PATH` and merges it into `process.env.PATH`, which every
agent-spawned child (run_commands, MCP servers) inherits. Only `PATH` is
imported, deliberately; other login-environment variables (`SSH_AUTH_SOCK`,
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).
## Web Visual System
The framework-neutral color, typography, radius, and navigation contract lives
@@ -25,7 +39,21 @@ Tailwind adapter and shared base styles without depending on the desktop
runtime. See [`webview/styles/README.md`](./webview/styles/README.md) for the
desktop integration notes.
## Shareable Desktop Packages
## Releases & Auto-Updates
Releases are built, signed, notarized, and published by the `desktop-publish`
GitHub workflow. The step-by-step flow (version bumps, changelog, tag, repo
secrets) lives in the `publish-desktop` skill
(`.cline/skills/publish-desktop/SKILL.md`).
Installed apps auto-update via the Tauri updater: they poll the rolling
`desktop-latest` release's `latest.json` on launch and every 2 hours, install
updates in the background, and prompt for a restart. Two things must never be
lost: the `desktop-latest` release/tag (its feed URL is baked into shipped
apps) and the updater private key (`TAURI_SIGNING_PRIVATE_KEY` — without it,
shipped apps can't verify new updates).
## Shareable Desktop Packages (manual fallback)
Tauri desktop bundles are OS-specific, so build each package on the target OS:
@@ -72,7 +100,9 @@ Do not remove `src-tauri/entitlements.plist` or the `bundle.macOS.entitlements`
Startup flow:
1. Tauri starts a persistent local desktop backend and keeps only native window/file-picker/open-path responsibilities.
2. The desktop backend starts the Bun sidecar and exposes one websocket transport (`/transport`) for commands, queries, and pushed events.
2. The desktop backend starts the Bun sidecar, which discovers or starts the
canonical shared Cline Hub and exposes one websocket transport (`/transport`)
for desktop commands, queries, and pushed events.
3. The React app uses `lib/desktop-client.ts` and no longer imports `@tauri-apps/api/core` directly in feature code.
4. Tool approval updates are pushed from the backend instead of polled from the UI.
5. Session process context resolves `workspaceRoot` from git root and uses that same path as default `cwd` for chat runtime and git operations unless explicitly overridden.
@@ -93,8 +123,8 @@ Desktop transport envelope:
## Key Files
- [`src-tauri/src/main.rs`](./src-tauri/src/main.rs) - Tauri shell lifecycle, backend launch, and native-only commands
- [`sidecar/index.ts`](./sidecar/index.ts) - persistent Bun sidecar backend
- [`sidecar/chat-session.ts`](./sidecar/chat-session.ts) - in-process chat session runtime
- [`sidecar/index.ts`](./sidecar/index.ts) - persistent Bun sidecar and Hub-daemon entry dispatch
- [`sidecar/chat-session.ts`](./sidecar/chat-session.ts) - shared-Hub chat session adapter
- [`webview/lib/desktop-client.ts`](./webview/lib/desktop-client.ts) - typed desktop websocket client
- [`webview/hooks/use-chat-session.ts`](./webview/hooks/use-chat-session.ts) - UI chat session state + backend subscriptions
- [`webview/lib/chat-schema.ts`](./webview/lib/chat-schema.ts) - chat message schema used by the UI
@@ -108,11 +138,28 @@ Desktop transport envelope:
- `<sessionId>.hooks.jsonl` is observability/debug telemetry and should not be required for normal history replay/export flows.
- Full v1 schema for the persisted messages file, including failure/retry semantics and golden fixtures, is documented in [`packages/core/docs/messages-contract-v1.md`](../../../sdk/packages/core/docs/messages-contract-v1.md).
## Sidecar observability
The desktop sidecar sends SDK telemetry through the same configured OpenTelemetry
pipeline used by the CLI and writes structured runtime logs to
`~/.cline/data/logs/code.log` by default. Telemetry continues to honor the global
opt-out setting exposed in the desktop settings UI. The sidecar truncates stale
logs and rotates the active file before it exceeds 50 MiB.
Logging can be configured with the same environment variables as the CLI:
- `CLINE_LOG_ENABLED=0` disables file logging.
- `CLINE_LOG_LEVEL` sets the Pino level (for example, `debug` or `warn`).
- `CLINE_LOG_PATH` overrides the log destination.
- `CLINE_LOG_NAME` overrides the logger name.
## Troubleshooting
- If live updates stall, verify the desktop backend websocket is connected and `chat_event` messages are arriving.
- Tauri restarts the desktop backend if the sidecar process exits and kills it on app teardown.
- Chat sends now preflight provider credentials. If a provider that requires API-key auth is selected without a key, the UI blocks the turn with a clear error message instead of starting a hanging session.
- If a turn completes with `finishReason=error` before any assistant content is produced, the UI now adds an explicit error chat message so failed turns are visible in the transcript.
- If package changes are not reflected, rebuild SDK packages (`bun run build:sdk`). The next `cline rpc ensure` call should attach to the current build's sidecar automatically.
- If package changes are not reflected, rebuild SDK packages (`bun run build:sdk`).
The next desktop or CLI Hub connection will reuse a compatible running Hub or
replace an incompatible one through the shared discovery path.
- Provider settings updates are patch-style: only fields you edit are changed. Unset fields are preserved instead of being cleared.
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/code",
"version": "0.0.1",
"version": "0.0.4",
"private": true,
"scripts": {
"build:ui": "bun -F @cline/ui build",
@@ -76,6 +76,7 @@
"lucide-react": "^0.564.0",
"next": "16.2.6",
"next-themes": "^0.4.6",
"pino": "^10.3.1",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-day-picker": "9.13.2",
@@ -17,13 +17,33 @@ const resolveTargetTriple = async (): Promise<string> => {
return host;
};
// Bun cross-compiles --compile binaries, so a CI runner can produce the
// sidecar for a different architecture than its own (e.g. the x86_64 macOS
// bundle from an arm64 runner). Without an explicit --target, bun always
// emits a host-arch binary even when Tauri is building for another triple.
const resolveBunCompileTarget = (targetTriple: string): string | undefined => {
if (targetTriple.startsWith("aarch64-apple-darwin"))
return "bun-darwin-arm64";
if (targetTriple.startsWith("x86_64-apple-darwin")) return "bun-darwin-x64";
if (targetTriple.startsWith("x86_64-pc-windows")) return "bun-windows-x64";
if (targetTriple.startsWith("x86_64-unknown-linux")) return "bun-linux-x64";
if (targetTriple.startsWith("aarch64-unknown-linux"))
return "bun-linux-arm64";
return undefined;
};
const main = async () => {
const targetTriple = await resolveTargetTriple();
const extension = targetTriple.includes("windows") ? ".exe" : "";
const outfile = `./src-tauri/bin/code-sidecar-${targetTriple}${extension}`;
const bunTarget = resolveBunCompileTarget(targetTriple);
await $`mkdir -p src-tauri/bin`;
await $`bun build ./sidecar/index.ts --compile --outfile ${outfile}`;
if (bunTarget) {
await $`bun build ./sidecar/index.ts --compile --target=${bunTarget} --outfile ${outfile}`;
} else {
await $`bun build ./sidecar/index.ts --compile --outfile ${outfile}`;
}
};
main().catch((error: unknown) => {
@@ -0,0 +1,77 @@
import { describe, expect, test } from "bun:test";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { buildUpdateManifest } from "./generate-update-manifest";
const makeArtifactDir = (): string => {
const dir = mkdtempSync(path.join(tmpdir(), "update-manifest-"));
writeFileSync(path.join(dir, "Cline-Code_0.1.0_aarch64.app.tar.gz"), "tar");
writeFileSync(
path.join(dir, "Cline-Code_0.1.0_aarch64.app.tar.gz.sig"),
"sig-aarch64\n",
);
writeFileSync(path.join(dir, "Cline-Code_0.1.0_x86_64.app.tar.gz"), "tar");
writeFileSync(
path.join(dir, "Cline-Code_0.1.0_x86_64.app.tar.gz.sig"),
"sig-x86_64\n",
);
writeFileSync(path.join(dir, "Cline-Code_0.1.0_aarch64.dmg"), "dmg");
return dir;
};
describe("buildUpdateManifest", () => {
test("maps updater artifacts to darwin platform entries", () => {
const dir = makeArtifactDir();
const manifest = buildUpdateManifest({
version: "0.1.0",
tag: "desktop-v0.1.0",
dir,
repo: "cline/cline",
notes: "notes",
pubDate: "2026-07-21T00:00:00.000Z",
});
expect(manifest.version).toBe("0.1.0");
expect(manifest.platforms["darwin-aarch64"]).toEqual({
signature: "sig-aarch64",
url: "https://github.com/cline/cline/releases/download/desktop-v0.1.0/Cline-Code_0.1.0_aarch64.app.tar.gz",
});
expect(manifest.platforms["darwin-x86_64"]).toEqual({
signature: "sig-x86_64",
url: "https://github.com/cline/cline/releases/download/desktop-v0.1.0/Cline-Code_0.1.0_x86_64.app.tar.gz",
});
// The DMG is a first-install artifact, not an updater artifact.
expect(Object.keys(manifest.platforms)).toHaveLength(2);
});
test("throws when a signature file is missing", () => {
const dir = mkdtempSync(path.join(tmpdir(), "update-manifest-"));
writeFileSync(path.join(dir, "Cline-Code_0.1.0_aarch64.app.tar.gz"), "tar");
expect(() =>
buildUpdateManifest({
version: "0.1.0",
tag: "desktop-v0.1.0",
dir,
repo: "cline/cline",
notes: "notes",
pubDate: "2026-07-21T00:00:00.000Z",
}),
).toThrow();
});
test("throws when no updater artifacts exist", () => {
const dir = mkdtempSync(path.join(tmpdir(), "update-manifest-"));
writeFileSync(path.join(dir, "Cline-Code_0.1.0_aarch64.dmg"), "dmg");
expect(() =>
buildUpdateManifest({
version: "0.1.0",
tag: "desktop-v0.1.0",
dir,
repo: "cline/cline",
notes: "notes",
pubDate: "2026-07-21T00:00:00.000Z",
}),
).toThrow(/no updater artifacts/);
});
});
@@ -0,0 +1,133 @@
// Generates the Tauri updater manifest (latest.json) from the updater
// artifacts produced by the desktop-publish workflow. The manifest is uploaded
// to the rolling `desktop-latest` GitHub release, which is the static endpoint
// configured in src-tauri/tauri.conf.json; its platform URLs point back at the
// immutable per-version release assets.
//
// Usage:
// bun scripts/generate-update-manifest.ts \
// --version 0.1.0 --tag desktop-v0.1.0 --dir dist/desktop \
// --out dist/desktop/latest.json [--repo cline/cline] [--notes-file notes.md]
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
type UpdaterPlatformEntry = {
signature: string;
url: string;
};
export type UpdateManifest = {
version: string;
notes: string;
pub_date: string;
platforms: Record<string, UpdaterPlatformEntry>;
};
// Maps the arch token embedded in artifact file names (see the "Collect
// artifacts" workflow step) to the platform keys the Tauri updater requests.
const PLATFORM_KEY_BY_ARCH_SUFFIX: Record<string, string> = {
aarch64: "darwin-aarch64",
x86_64: "darwin-x86_64",
};
const getArgValue = (args: string[], name: string): string | undefined => {
const index = args.indexOf(name);
if (index >= 0 && args[index + 1] && !args[index + 1].startsWith("--")) {
return args[index + 1];
}
const prefix = `${name}=`;
const inline = args.find((arg) => arg.startsWith(prefix));
return inline?.slice(prefix.length);
};
const archOfUpdaterArtifact = (fileName: string): string | undefined => {
if (!fileName.endsWith(".app.tar.gz")) {
return undefined;
}
return Object.keys(PLATFORM_KEY_BY_ARCH_SUFFIX).find((arch) =>
fileName.includes(`_${arch}`),
);
};
export const buildUpdateManifest = (options: {
version: string;
tag: string;
dir: string;
repo: string;
notes: string;
pubDate: string;
}): UpdateManifest => {
const platforms: Record<string, UpdaterPlatformEntry> = {};
for (const fileName of readdirSync(options.dir).sort()) {
const arch = archOfUpdaterArtifact(fileName);
if (!arch) {
continue;
}
const signaturePath = path.join(options.dir, `${fileName}.sig`);
const signature = readFileSync(signaturePath, "utf8").trim();
if (!signature) {
throw new Error(`empty updater signature at ${signaturePath}`);
}
platforms[PLATFORM_KEY_BY_ARCH_SUFFIX[arch]] = {
signature,
url: `https://github.com/${options.repo}/releases/download/${options.tag}/${encodeURIComponent(fileName)}`,
};
}
if (Object.keys(platforms).length === 0) {
throw new Error(
`no updater artifacts (*.app.tar.gz with a known arch suffix) found in ${options.dir}`,
);
}
return {
version: options.version,
notes: options.notes,
pub_date: options.pubDate,
platforms,
};
};
const main = () => {
const args = process.argv.slice(2);
const version = getArgValue(args, "--version");
const tag = getArgValue(args, "--tag");
const dir = getArgValue(args, "--dir");
const out = getArgValue(args, "--out");
const repo =
getArgValue(args, "--repo") ??
process.env.GITHUB_REPOSITORY ??
"cline/cline";
const notesFile = getArgValue(args, "--notes-file");
if (!version || !tag || !dir || !out) {
throw new Error(
"usage: generate-update-manifest.ts --version X.Y.Z --tag desktop-vX.Y.Z --dir <artifact dir> --out <latest.json> [--repo owner/repo] [--notes-file <file>]",
);
}
const notes = notesFile
? readFileSync(notesFile, "utf8").trim()
: `Cline Code v${version}`;
const manifest = buildUpdateManifest({
version,
tag,
dir,
repo,
notes,
pubDate: new Date().toISOString(),
});
writeFileSync(out, `${JSON.stringify(manifest, null, "\t")}\n`);
console.log(`wrote ${out}`);
for (const [platform, entry] of Object.entries(manifest.platforms)) {
console.log(`- ${platform}: ${entry.url}`);
}
};
if (import.meta.main) {
main();
}
@@ -2,9 +2,12 @@
## Overview
The sidecar is a single Bun process that handles the desktop backend runtime directly.
The sidecar is a Bun process that adapts the desktop UI and native operations to
the shared Cline Hub.
It imports `@cline/core` directly and serves the Next.js frontend over HTTP + WebSocket.
It imports `@cline/core`, discovers or starts the canonical shared Hub, registers
as a Hub client, and serves the Next.js frontend over HTTP + WebSocket. The
sidecar does not own a private agent runtime Hub.
## Directory Structure
@@ -14,7 +17,7 @@ sidecar/
├── server.ts # Bun HTTP server + WebSocket handlers
├── context.ts # SidecarContext type and factory
├── commands.ts # Command router
├── chat-session.ts # In-process chat session management
├── chat-session.ts # Shared-Hub chat session adapter
├── session-data/ # Shared discovery, messages, artifacts, search helpers
├── paths.ts # Path resolution
├── types.ts # Shared types
@@ -31,15 +34,23 @@ Event: { "type": "event", "event": { "name": string, "payload": unknown } }
## Key Design Decisions
### 1. Chat Sessions — In-Process via LocalRuntimeHost
### 1. Chat Sessions — Shared Hub Client
Instead of spawning a separate runtime bridge process, we use `LocalRuntimeHost` directly:
`ClineCore` uses Hub mode without an explicit endpoint. Core therefore reuses
the same compatible Hub discovered by the CLI or starts the canonical detached
Hub when the desktop is the first client:
```typescript
import { LocalRuntimeHost } from "@cline/core";
const sessionManager = await ClineCore.create({
clientName: "cline-code",
backendMode: "hub",
hub: {
strategy: "require-hub",
workspaceRoot,
cwd: workspaceRoot,
clientType: "code-sidecar",
displayName: "Code App sidecar",
},
capabilities: {
requestToolApproval: async (request) => {
// Push approval request to frontend via WebSocket event
@@ -66,9 +77,15 @@ sessionManager.subscribe((event) => {
});
```
### 2. Tool Approval — In-Memory Promise Resolution
The compiled sidecar also recognizes Core's Hub-daemon launch mode. This lets
the desktop start the same detached Hub when no CLI process has started it yet.
Startup discovery and locking ensure concurrent clients converge on one Hub.
No more file-system watchers. Tool approvals use in-memory promise maps:
### 2. Tool Approval — Client-Owned Promise Resolution
The shared Hub routes approval requests back to the client that created the
session. Desktop approvals use in-memory promise maps while the webview is
online:
```typescript
const pendingApprovals = new Map<string, {
@@ -96,12 +113,11 @@ const store = new SqliteSessionStore();
### 5. Routine Schedules — Direct Hub Commands
Routine operations now ensure the local hub server in-process and issue hub schedule commands directly. They are still called in-process, not via child script:
Routine operations use the same connected Hub client as chat session
observation. They never start a second in-process Hub:
```typescript
import { ensureHubServer, sendHubCommand } from "@cline/core";
await ensureHubServer({ runtimeHandlers: createLocalHubScheduleRuntimeHandlers() });
await sendHubCommand({}, { command: "schedule.list", payload: { limit: 200 } });
await ctx.hubClient.command("schedule.list", { limit: 200 });
```
### 6. Native Commands
@@ -122,7 +138,7 @@ Supported commands:
| Command | Implementation |
|---------|---------------|
| `chat_session_command` | `LocalRuntimeHost` in-process |
| `chat_session_command` | shared Hub through `ClineCore` |
| `list_provider_catalog` | `ProviderSettingsManager` + `listLocalProviders` |
| `list_provider_models` | `getLocalProviderModels` |
| `save_provider_settings` | `saveLocalProviderSettings` |
@@ -144,7 +160,7 @@ Supported commands:
| `get_process_context` | In-memory context |
| `poll_tool_approvals` | In-memory pending map |
| `respond_tool_approval` | In-memory promise resolution |
| `list_routine_schedules` | local hub schedule commands |
| `list_routine_schedules` | shared Hub schedule commands |
| `list_user_instruction_configs` | Direct core API |
| `pick_workspace_directory` | OS native dialog |
| `open_mcp_settings_file` | OS `open` command |
@@ -0,0 +1,158 @@
import { existsSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
deleteMaterializedAttachments,
discardAllTrackedAttachments,
flushConsumedAttachments,
markQueuedAttachmentsSubmitted,
materializeUserFiles,
reconcileQueuedAttachments,
sessionAttachmentsDir,
trackQueuedAttachments,
} from "./attachments";
import type { LiveSession } from "./types";
const sessionId = "attachment-test-session";
let previousSessionDataDir: string | undefined;
let testSessionDataDir: string;
function createSession(): LiveSession {
return {
config: {},
messages: [],
promptsInQueue: [],
busy: false,
startedAt: Date.now(),
status: "idle",
};
}
beforeEach(() => {
previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
testSessionDataDir = join(
tmpdir(),
`cline-desktop-attachment-lifecycle-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
});
afterEach(() => {
if (previousSessionDataDir === undefined) {
delete process.env.CLINE_SESSION_DATA_DIR;
} else {
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
}
rmSync(testSessionDataDir, { recursive: true, force: true });
});
describe("materialized attachment lifecycle", () => {
it("only deletes files inside the session attachments dir", () => {
const [staged] = materializeUserFiles(sessionId, [
{ name: "notes.txt", content: "hello" },
]) as string[];
const outside = join(testSessionDataDir, "outside.txt");
writeFileSync(outside, "keep me", "utf8");
deleteMaterializedAttachments(sessionId, [staged, outside]);
expect(existsSync(staged)).toBe(false);
expect(existsSync(outside)).toBe(true);
});
it("deletes consumed files when the turn ends", () => {
const session = createSession();
const files = materializeUserFiles(sessionId, [
{ name: "a.txt", content: "a" },
]) as string[];
trackQueuedAttachments(
session,
[
{
id: "pending_1",
prompt: "p",
delivery: "queue",
attachmentCount: 1,
userFiles: files,
},
],
files,
);
markQueuedAttachmentsSubmitted(session, "pending_1");
expect(existsSync(files[0] as string)).toBe(true);
flushConsumedAttachments(sessionId, session);
expect(existsSync(files[0] as string)).toBe(false);
expect(session.consumedAttachmentFiles?.size ?? 0).toBe(0);
});
it("keeps files for a submitted prompt that gets requeued", () => {
const session = createSession();
const files = materializeUserFiles(sessionId, [
{ name: "a.txt", content: "a" },
]) as string[];
trackQueuedAttachments(
session,
[
{
id: "pending_1",
prompt: "p",
delivery: "queue",
attachmentCount: 1,
userFiles: files,
},
],
files,
);
markQueuedAttachmentsSubmitted(session, "pending_1");
// Drain send failed → prompt is back in the queue snapshot.
reconcileQueuedAttachments(session, ["pending_1"]);
flushConsumedAttachments(sessionId, session);
expect(existsSync(files[0] as string)).toBe(true);
expect(session.queuedAttachmentFiles?.get("pending_1")).toEqual(files);
});
it("tracks files as consumed when the prompt is no longer queued", () => {
const session = createSession();
const files = materializeUserFiles(sessionId, [
{ name: "a.txt", content: "a" },
]) as string[];
trackQueuedAttachments(session, [], files);
expect(session.queuedAttachmentFiles?.size ?? 0).toBe(0);
expect(session.consumedAttachmentFiles?.size).toBe(1);
});
it("discards all tracked files on session end", () => {
const session = createSession();
const queued = materializeUserFiles(sessionId, [
{ name: "queued.txt", content: "q" },
]) as string[];
const consumed = materializeUserFiles(sessionId, [
{ name: "consumed.txt", content: "c" },
]) as string[];
trackQueuedAttachments(
session,
[
{
id: "pending_1",
prompt: "p",
delivery: "queue",
attachmentCount: 1,
userFiles: queued,
},
],
queued,
);
trackQueuedAttachments(session, [], consumed);
discardAllTrackedAttachments(sessionId, session);
expect(existsSync(queued[0] as string)).toBe(false);
expect(existsSync(consumed[0] as string)).toBe(false);
expect(existsSync(sessionAttachmentsDir(sessionId))).toBe(true);
});
});
@@ -0,0 +1,157 @@
import { randomUUID } from "node:crypto";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { basename, join, resolve, sep } from "node:path";
import { isDeepStrictEqual } from "node:util";
import type { SessionPendingPrompt } from "@cline/core";
import { sharedSessionDataDir } from "./paths";
import type { ChatTurnAttachments, LiveSession } from "./types";
function queuedFilesMap(session: LiveSession): Map<string, string[]> {
if (!session.queuedAttachmentFiles) {
session.queuedAttachmentFiles = new Map();
}
return session.queuedAttachmentFiles;
}
function consumedFilesMap(session: LiveSession): Map<string, string[]> {
if (!session.consumedAttachmentFiles) {
session.consumedAttachmentFiles = new Map();
}
return session.consumedAttachmentFiles;
}
// ---------------------------------------------------------------------------
// Materialized user-attachment lifecycle
//
// Non-image attachments arrive from the webview as inline content and are
// written to `<session-data>/<sessionId>/user-attachments/` so the SDK can
// load them by path at turn start. The sidecar owns these files and must
// delete them once consumed (turn completed) or discarded (queued prompt
// removed / session ended) — otherwise user data accumulates on disk.
// ---------------------------------------------------------------------------
export function sessionAttachmentsDir(sessionId: string): string {
return join(sharedSessionDataDir(), sessionId, "user-attachments");
}
export function materializeUserFiles(
sessionId: string,
files: ChatTurnAttachments["userFiles"],
): string[] | undefined {
if (!files?.length) {
return undefined;
}
const attachmentDir = sessionAttachmentsDir(sessionId);
mkdirSync(attachmentDir, { recursive: true });
return files.map((file) => {
const requestedName = basename(file.name.trim());
const safeName =
requestedName && requestedName !== "." && requestedName !== ".."
? requestedName
: "attachment.txt";
const path = join(attachmentDir, `${randomUUID()}-${safeName}`);
writeFileSync(path, file.content, "utf8");
return path;
});
}
/**
* Delete materialized attachment files. Only paths inside the session's
* user-attachments directory are removed, so files referenced from elsewhere
* (e.g. `@`-mentions) are never touched.
*/
export function deleteMaterializedAttachments(
sessionId: string,
paths: string[] | undefined,
): void {
if (!paths?.length) return;
const attachmentDir = resolve(sessionAttachmentsDir(sessionId)) + sep;
for (const path of paths) {
if (!resolve(path).startsWith(attachmentDir)) continue;
try {
rmSync(path, { force: true });
} catch {
// Best-effort cleanup; leftover files are removed with the session dir.
}
}
}
/**
* Track files staged for a queued/steered prompt so they can be deleted once
* the prompt is consumed or discarded. If the prompt is no longer in the
* queue (already submitted), the files are tracked as consumed and deleted
* when the running turn finishes.
*/
export function trackQueuedAttachments(
session: LiveSession | undefined,
prompts: SessionPendingPrompt[],
userFiles: string[] | undefined,
): void {
if (!session || !userFiles?.length) return;
const match = prompts.find((prompt) =>
isDeepStrictEqual(prompt.userFiles, userFiles),
);
if (match) {
queuedFilesMap(session).set(match.id, userFiles);
} else {
// Not in the queue → already being consumed by the running turn. Key by a
// fresh id so it never collides with a prompt-id key used elsewhere in the
// consumed bucket.
consumedFilesMap(session).set(randomUUID(), userFiles);
}
}
/** Move a submitted queued prompt's files into the consumed bucket. */
export function markQueuedAttachmentsSubmitted(
session: LiveSession | undefined,
promptId: string,
): void {
const files = session?.queuedAttachmentFiles?.get(promptId);
if (!session || !files) return;
session.queuedAttachmentFiles?.delete(promptId);
consumedFilesMap(session).set(promptId, files);
}
/**
* A prompt id reappearing in the queue means a submitted prompt was requeued
* (e.g. the drain send failed) — move its files back to the queued bucket so
* the turn-end flush does not delete files still pending consumption.
*/
export function reconcileQueuedAttachments(
session: LiveSession | undefined,
queuedPromptIds: string[],
): void {
if (!session?.consumedAttachmentFiles?.size) return;
for (const id of queuedPromptIds) {
const files = session.consumedAttachmentFiles.get(id);
if (!files) continue;
session.consumedAttachmentFiles.delete(id);
queuedFilesMap(session).set(id, files);
}
}
/** Delete files for prompts whose turn has finished. */
export function flushConsumedAttachments(
sessionId: string,
session: LiveSession | undefined,
): void {
if (!session?.consumedAttachmentFiles?.size) return;
for (const files of session.consumedAttachmentFiles.values()) {
deleteMaterializedAttachments(sessionId, files);
}
session.consumedAttachmentFiles.clear();
}
/** Delete every tracked file for a session (queued prompts are discarded). */
export function discardAllTrackedAttachments(
sessionId: string,
session: LiveSession | undefined,
): void {
if (!session) return;
flushConsumedAttachments(sessionId, session);
if (!session.queuedAttachmentFiles?.size) return;
for (const files of session.queuedAttachmentFiles.values()) {
deleteMaterializedAttachments(sessionId, files);
}
session.queuedAttachmentFiles.clear();
}
@@ -1,8 +1,14 @@
import { existsSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { materializeUserFiles } from "./attachments";
import {
buildSessionConnectionUpdate,
consumeWorkspaceMetadata,
handleChatSessionCommand,
hasProviderChanged,
mergeSessionConfig,
prewarmWorkspaceMetadata,
shouldUpdateSessionConnection,
WORKSPACE_METADATA_PREWARM_TTL_MS,
@@ -87,6 +93,87 @@ describe("shouldUpdateSessionConnection", () => {
});
});
describe("hasProviderChanged", () => {
it("distinguishes provider switches from model switches", () => {
expect(
hasProviderChanged(
{ provider: "cline", model: "anthropic/claude-sonnet-4.6" },
{ provider: "openai-codex", model: "gpt-5.3-codex" },
),
).toBe(true);
expect(
hasProviderChanged(
{ provider: "cline", model: "anthropic/claude-sonnet-4.6" },
{ provider: "cline", model: "openai/gpt-5.3-codex" },
),
).toBe(false);
});
it("honors a providerId-only update when the stored config uses provider", () => {
const current = {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
};
const update = {
providerId: "openai-codex",
modelId: "gpt-5.3-codex",
};
expect(hasProviderChanged(current, update)).toBe(true);
expect(mergeSessionConfig(current, update)).toMatchObject({
provider: "openai-codex",
providerId: "openai-codex",
model: "gpt-5.3-codex",
modelId: "gpt-5.3-codex",
});
});
});
describe("pathless session starts", () => {
it("omits workspace paths and returns the SDK-resolved chat workspace", async () => {
const start = vi.fn(async (input: { config: Record<string, unknown> }) => {
expect(input.config).not.toHaveProperty("cwd");
expect(input.config).not.toHaveProperty("workspaceRoot");
return {
sessionId: "session-pathless",
manifest: {
cwd: "/home/host/.cline/data/workspaces/chat",
workspace_root: "/home/host/.cline/data/workspaces/chat",
},
manifestPath: "/tmp/session-pathless.json",
messagesPath: "/tmp/session-pathless.messages.json",
};
});
const ctx = {
liveSessions: new Map(),
sessionManager: { start },
} as unknown as SidecarContext;
const result = (await handleChatSessionCommand(ctx, {
action: "start",
config: {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
enableTools: true,
},
})) as {
sessionId: string;
cwd: string;
workspaceRoot: string;
};
expect(result).toEqual({
sessionId: "session-pathless",
cwd: "/home/host/.cline/data/workspaces/chat",
workspaceRoot: "/home/host/.cline/data/workspaces/chat",
});
expect(ctx.liveSessions.get("session-pathless")?.config).toMatchObject({
cwd: "/home/host/.cline/data/workspaces/chat",
workspaceRoot: "/home/host/.cline/data/workspaces/chat",
});
});
});
describe("first-send connection updates", () => {
const baseConfig = {
provider: "cline",
@@ -100,12 +187,19 @@ describe("first-send connection updates", () => {
config?: Record<string, unknown>;
}) {
const updateSessionConnection = vi.fn(async () => undefined);
const send = vi.fn(async () => ({
const send = vi.fn(async (_input?: unknown) => ({
text: "done",
finishReason: "completed",
messages: [],
}));
const readMessages = vi.fn(async () => [
{ role: "user", content: "first prompt" },
{ role: "assistant", content: "first response" },
]);
const readSessionCompactionState = vi.fn(async () => undefined);
const stop = vi.fn(async () => undefined);
const sessionId = "session-connection-test";
const start = vi.fn(async (_input?: unknown) => ({ sessionId }));
const ctx = {
liveSessions: new Map([
[
@@ -121,9 +215,29 @@ describe("first-send connection updates", () => {
},
],
]),
sessionManager: { send, updateSessionConnection },
streamIndices: new Map(),
wsClients: new Set(),
sessionManager: {
readMessages,
readSessionCompactionState,
send,
start,
stop,
updateSessionConnection,
pendingPrompts: {
list: vi.fn(async () => []),
},
},
} as unknown as SidecarContext;
return { ctx, send, sessionId, updateSessionConnection };
return {
ctx,
readMessages,
send,
sessionId,
start,
stop,
updateSessionConnection,
};
}
it("skips an identical update for a locally-created session", async () => {
@@ -140,6 +254,266 @@ describe("first-send connection updates", () => {
expect(send).toHaveBeenCalledTimes(1);
});
it("allows an image-only user turn", async () => {
const { ctx, send, sessionId } = createContext();
await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "",
attachments: {
userImages: ["data:image/png;base64,aGVsbG8="],
userFiles: [],
},
});
expect(send).toHaveBeenCalledWith({
sessionId,
prompt: "",
delivery: undefined,
userImages: ["data:image/png;base64,aGVsbG8="],
});
});
it.each([
undefined,
"queue",
] as const)("forwards file attachments for %s delivery", async (delivery) => {
const { ctx, send, sessionId } = createContext();
const previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
const testSessionDataDir = join(
tmpdir(),
`cline-desktop-attachments-${Date.now()}-${delivery ?? "immediate"}`,
);
let sentFileContent: string | undefined;
send.mockImplementation(async (input?: unknown) => {
const files = (input as { userFiles?: string[] } | undefined)?.userFiles;
if (files?.[0]) {
sentFileContent = readFileSync(files[0], "utf8");
}
return { text: "done", finishReason: "completed", messages: [] };
});
try {
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "",
delivery,
attachments: {
userFiles: [{ name: "notes.txt", content: "hello" }],
},
});
const input = send.mock.calls[0]?.[0] as
| { userFiles?: string[] }
| undefined;
expect(send).toHaveBeenCalledWith({
sessionId,
prompt: "",
delivery,
userImages: undefined,
userFiles: [expect.stringMatching(/notes\.txt$/)],
});
expect(sentFileContent).toBe("hello");
if (delivery === "queue") {
// Queued attachments stay on disk until the prompt is consumed.
expect(existsSync(input?.userFiles?.[0] ?? "")).toBe(true);
} else {
// Immediate turns delete the materialized file once the send resolves.
expect(existsSync(input?.userFiles?.[0] ?? "")).toBe(false);
}
} finally {
if (previousSessionDataDir === undefined) {
delete process.env.CLINE_SESSION_DATA_DIR;
} else {
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
}
rmSync(testSessionDataDir, { recursive: true, force: true });
}
});
it("deletes materialized attachments when a queued prompt is removed", async () => {
const { ctx, send, sessionId } = createContext();
const previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
const testSessionDataDir = join(
tmpdir(),
`cline-desktop-attachments-remove-${Date.now()}`,
);
try {
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
const queue: Array<{
id: string;
prompt: string;
delivery: "queue";
attachmentCount: number;
userFiles?: string[];
}> = [];
const manager = ctx.sessionManager as unknown as {
send: typeof send;
pendingPrompts: {
list: (input: unknown) => Promise<unknown[]>;
delete: (input: {
sessionId: string;
promptId: string;
}) => Promise<unknown>;
};
};
manager.send = vi.fn(async (input?: unknown) => {
const { prompt, userFiles } = input as {
prompt: string;
userFiles?: string[];
};
queue.push({
id: "pending_1",
prompt,
delivery: "queue",
attachmentCount: userFiles?.length ?? 0,
userFiles,
});
return undefined;
}) as unknown as typeof send;
manager.pendingPrompts = {
list: vi.fn(async () => [...queue]),
delete: vi.fn(async ({ promptId }) => {
const index = queue.findIndex((entry) => entry.id === promptId);
const [removed] = index >= 0 ? queue.splice(index, 1) : [];
return {
sessionId,
prompts: [...queue],
prompt: removed,
removed: index >= 0,
};
}),
};
await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "queued with file",
delivery: "queue",
attachments: {
userFiles: [{ name: "notes.txt", content: "hello" }],
},
});
const filePath = queue[0]?.userFiles?.[0] ?? "";
expect(existsSync(filePath)).toBe(true);
expect(
ctx.liveSessions
.get(sessionId)
?.queuedAttachmentFiles?.get("pending_1"),
).toEqual([filePath]);
await handleChatSessionCommand(ctx, {
action: "remove_pending_prompt",
sessionId,
promptId: "pending_1",
});
expect(existsSync(filePath)).toBe(false);
expect(
ctx.liveSessions.get(sessionId)?.queuedAttachmentFiles?.size ?? 0,
).toBe(0);
} finally {
if (previousSessionDataDir === undefined) {
delete process.env.CLINE_SESSION_DATA_DIR;
} else {
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
}
rmSync(testSessionDataDir, { recursive: true, force: true });
}
});
it("deletes tracked attachments when a session is reset", async () => {
const { ctx, sessionId } = createContext();
const previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
const testSessionDataDir = join(
tmpdir(),
`cline-desktop-attachments-reset-${Date.now()}`,
);
try {
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
const [queuedFile] = materializeUserFiles(sessionId, [
{ name: "queued.txt", content: "q" },
]) as string[];
const [consumedFile] = materializeUserFiles(sessionId, [
{ name: "consumed.txt", content: "c" },
]) as string[];
const session = ctx.liveSessions.get(sessionId);
if (!session) throw new Error("missing session");
session.queuedAttachmentFiles = new Map([["pending_1", [queuedFile]]]);
session.consumedAttachmentFiles = new Map([
["pending_2", [consumedFile]],
]);
await handleChatSessionCommand(ctx, {
action: "reset",
sessionId,
});
expect(existsSync(queuedFile)).toBe(false);
expect(existsSync(consumedFile)).toBe(false);
expect(ctx.liveSessions.has(sessionId)).toBe(false);
} finally {
if (previousSessionDataDir === undefined) {
delete process.env.CLINE_SESSION_DATA_DIR;
} else {
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
}
rmSync(testSessionDataDir, { recursive: true, force: true });
}
});
it("preserves tracked attachments across re-attach", async () => {
const { ctx, sessionId } = createContext();
const previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
const testSessionDataDir = join(
tmpdir(),
`cline-desktop-attachments-attach-${Date.now()}`,
);
try {
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
const [queuedFile] = materializeUserFiles(sessionId, [
{ name: "queued.txt", content: "q" },
]) as string[];
const session = ctx.liveSessions.get(sessionId);
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",
}),
);
await handleChatSessionCommand(ctx, {
action: "attach",
sessionId,
});
expect(existsSync(queuedFile)).toBe(true);
expect(
ctx.liveSessions
.get(sessionId)
?.queuedAttachmentFiles?.get("pending_1"),
).toEqual([queuedFile]);
} finally {
if (previousSessionDataDir === undefined) {
delete process.env.CLINE_SESSION_DATA_DIR;
} else {
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
}
rmSync(testSessionDataDir, { recursive: true, force: true });
}
});
it("updates a changed connection before sending", async () => {
const { ctx, send, sessionId, updateSessionConnection } = createContext({
config: { ...baseConfig, reasoningEffort: "low" },
@@ -158,6 +532,265 @@ describe("first-send connection updates", () => {
);
});
it("rebuilds the same session with its transcript before a provider switch", async () => {
const {
ctx,
readMessages,
send,
sessionId,
start,
stop,
updateSessionConnection,
} = createContext();
await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "continue with Codex",
config: {
...baseConfig,
provider: "openai-codex",
model: "gpt-5.3-codex",
},
});
expect(readMessages).toHaveBeenCalledWith(sessionId);
expect(stop).toHaveBeenCalledWith(sessionId);
expect(start).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
providerId: "openai-codex",
modelId: "gpt-5.3-codex",
sessionId,
}),
initialMessages: [
{ role: "user", content: "first prompt" },
{ role: "assistant", content: "first response" },
],
}),
);
expect(updateSessionConnection).toHaveBeenCalledWith(sessionId, {
providerId: "openai-codex",
modelId: "gpt-5.3-codex",
thinking: true,
reasoningEffort: "high",
thinkingBudgetTokens: null,
});
expect(start.mock.invocationCallOrder[0]).toBeLessThan(
send.mock.invocationCallOrder[0] ?? 0,
);
});
it("blocks a concurrent send throughout provider-switch preparation", async () => {
let resolveMessages:
| ((messages: Array<{ role: string; content: string }>) => void)
| undefined;
const messages = new Promise<Array<{ role: string; content: string }>>(
(resolve) => {
resolveMessages = resolve;
},
);
const { ctx, readMessages, sessionId } = createContext();
readMessages.mockImplementationOnce(async () => await messages);
const switching = handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "continue with Codex",
config: {
...baseConfig,
provider: "openai-codex",
model: "gpt-5.3-codex",
},
});
await vi.waitFor(() => expect(readMessages).toHaveBeenCalledOnce());
await expect(
handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "racing prompt",
config: { ...baseConfig },
}),
).rejects.toThrow("A provider switch is already in progress");
resolveMessages?.([
{ role: "user", content: "first prompt" },
{ role: "assistant", content: "first response" },
]);
await switching;
});
it.each([
"queue",
"steer",
] as const)("locks provider-switch preparation for explicit %s delivery", async (delivery) => {
let resolveMessages:
| ((messages: Array<{ role: string; content: string }>) => void)
| undefined;
const messages = new Promise<Array<{ role: string; content: string }>>(
(resolve) => {
resolveMessages = resolve;
},
);
const { ctx, readMessages, sessionId } = createContext();
readMessages.mockImplementationOnce(async () => await messages);
const switching = handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "queue this for Codex",
delivery,
config: {
...baseConfig,
provider: "openai-codex",
model: "gpt-5.3-codex",
},
});
await vi.waitFor(() => expect(readMessages).toHaveBeenCalledOnce());
await expect(
handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "racing prompt",
config: { ...baseConfig },
}),
).rejects.toThrow("A provider switch is already in progress");
resolveMessages?.([
{ role: "user", content: "first prompt" },
{ role: "assistant", content: "first response" },
]);
await switching;
});
it("restores the previous provider runtime when replacement startup fails", async () => {
const { ctx, send, sessionId, start, stop } = createContext();
const previousKanbanDataDir = process.env.CLINE_KANBAN_DATA_DIR;
const testKanbanDataDir = join(
tmpdir(),
`cline-provider-rollback-${process.pid}`,
);
process.env.CLINE_KANBAN_DATA_DIR = testKanbanDataDir;
start
.mockRejectedValueOnce(new Error("Codex bootstrap failed"))
.mockResolvedValueOnce({ sessionId });
try {
const result = (await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "continue with Codex",
config: {
...baseConfig,
provider: "openai-codex",
model: "gpt-5.3-codex",
},
})) as { result?: { finishReason?: string; text?: string } };
expect(stop).toHaveBeenCalledOnce();
expect(start).toHaveBeenCalledTimes(2);
expect(start.mock.calls[1]?.[0]).toEqual(
expect.objectContaining({
config: expect.objectContaining({
providerId: "cline",
modelId: "anthropic/claude-sonnet-4.6",
sessionId,
}),
}),
);
expect(send).not.toHaveBeenCalled();
expect(result.result).toEqual({
finishReason: "error",
text: "Codex bootstrap failed",
});
await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "continue with Cline",
config: { ...baseConfig },
});
expect(send).toHaveBeenCalledOnce();
} finally {
if (previousKanbanDataDir === undefined) {
delete process.env.CLINE_KANBAN_DATA_DIR;
} else {
process.env.CLINE_KANBAN_DATA_DIR = previousKanbanDataDir;
}
rmSync(testKanbanDataDir, { recursive: true, force: true });
}
});
it("restores the previous provider when replacement label sync fails", async () => {
const { ctx, send, sessionId, start, stop, updateSessionConnection } =
createContext();
const previousKanbanDataDir = process.env.CLINE_KANBAN_DATA_DIR;
const testKanbanDataDir = join(
tmpdir(),
`cline-provider-label-rollback-${process.pid}`,
);
process.env.CLINE_KANBAN_DATA_DIR = testKanbanDataDir;
try {
updateSessionConnection
.mockRejectedValueOnce(new Error("manifest write failed"))
.mockResolvedValueOnce(undefined);
const result = (await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "continue with Codex",
config: {
...baseConfig,
provider: "openai-codex",
model: "gpt-5.3-codex",
},
})) as { result?: { finishReason?: string; text?: string } };
expect(stop).toHaveBeenCalledTimes(2);
expect(start).toHaveBeenCalledTimes(2);
expect(start.mock.calls[1]?.[0]).toEqual(
expect.objectContaining({
config: expect.objectContaining({
providerId: "cline",
modelId: "anthropic/claude-sonnet-4.6",
sessionId,
}),
}),
);
expect(updateSessionConnection).toHaveBeenNthCalledWith(2, sessionId, {
providerId: "cline",
modelId: "anthropic/claude-sonnet-4.6",
thinking: true,
reasoningEffort: "high",
thinkingBudgetTokens: null,
});
expect(send).not.toHaveBeenCalled();
expect(result.result).toEqual({
finishReason: "error",
text: "manifest write failed",
});
expect(ctx.liveSessions.get(sessionId)?.config).toEqual(baseConfig);
await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "continue with Cline",
config: { ...baseConfig },
});
expect(send).toHaveBeenCalledOnce();
expect(start).toHaveBeenCalledTimes(2);
} finally {
if (previousKanbanDataDir === undefined) {
delete process.env.CLINE_KANBAN_DATA_DIR;
} else {
process.env.CLINE_KANBAN_DATA_DIR = previousKanbanDataDir;
}
rmSync(testKanbanDataDir, { recursive: true, force: true });
}
});
it("refreshes hub-attached sessions even when the cached config matches", async () => {
const { ctx, sessionId, updateSessionConnection } = createContext({
attachedViaHub: true,
+363 -83
View File
@@ -5,13 +5,22 @@ import {
buildConnectionUpdate,
buildWorkspaceMetadata,
type ClineCore,
type CoreSessionConfig,
type ClineCoreStartConfig,
createSessionCompactionState,
projectSessionCompactionState,
type SessionCompactionState,
type SessionPendingPrompt,
SessionSource,
splitCoreSessionConfig,
} from "@cline/core";
import type { Message } from "@cline/llms";
import { buildClineSystemPrompt } from "@cline/shared";
import {
deleteMaterializedAttachments,
discardAllTrackedAttachments,
materializeUserFiles,
trackQueuedAttachments,
} from "./attachments";
import { emitChunk, nowMs, sendEvent } from "./context";
import { readSessionManifest, sharedSessionDataDir } from "./paths";
import type {
@@ -159,6 +168,8 @@ function createLiveSession(
prompt: overrides?.prompt,
title: overrides?.title,
attachedViaHub: overrides?.attachedViaHub ?? false,
queuedAttachmentFiles: overrides?.queuedAttachmentFiles,
consumedAttachmentFiles: overrides?.consumedAttachmentFiles,
};
}
@@ -194,6 +205,11 @@ function readPositiveInteger(value: unknown): number | undefined {
}
function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
const rawWorkspaceRoot = config.workspaceRoot ?? config.workspace_root;
const workspaceRoot =
typeof rawWorkspaceRoot === "string" ? rawWorkspaceRoot.trim() : "";
const cwd =
(typeof config.cwd === "string" ? config.cwd.trim() : "") || workspaceRoot;
const thinking =
typeof config.thinking === "boolean" ? config.thinking : undefined;
const reasoningEffort =
@@ -212,8 +228,11 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
modelId: config.model ?? config.modelId ?? "",
mode: config.mode ?? "act",
apiKey: config.apiKey ?? config.api_key ?? "",
workspaceRoot: config.workspaceRoot ?? config.workspace_root ?? "",
cwd: config.cwd ?? config.workspaceRoot ?? config.workspace_root ?? "",
baseUrl: config.baseUrl,
headers: config.headers,
providerConfig: config.providerConfig,
...(workspaceRoot ? { workspaceRoot } : {}),
...(cwd ? { cwd } : {}),
systemPrompt: config.systemPrompt ?? config.system_prompt ?? "",
maxIterations: config.maxIterations ?? config.max_iterations,
enableTools: config.enableTools ?? config.enable_tools ?? true,
@@ -293,6 +312,54 @@ export function shouldUpdateSessionConnection(
);
}
function readAliasedString(
config: JsonRecord,
primaryKey: string,
aliasKey: string,
): string | undefined {
for (const key of [primaryKey, aliasKey]) {
if (!Object.hasOwn(config, key)) continue;
const value = String(config[key] ?? "").trim();
return value || undefined;
}
return undefined;
}
export function mergeSessionConfig(
currentConfig: JsonRecord,
updates: JsonRecord,
): JsonRecord {
const providerId =
readAliasedString(updates, "provider", "providerId") ??
readAliasedString(currentConfig, "provider", "providerId");
const modelId =
readAliasedString(updates, "model", "modelId") ??
readAliasedString(currentConfig, "model", "modelId");
return {
...currentConfig,
...updates,
...(providerId ? { provider: providerId, providerId } : {}),
...(modelId ? { model: modelId, modelId } : {}),
};
}
export function hasProviderChanged(
currentConfig: JsonRecord,
nextConfig: JsonRecord,
): boolean {
const currentProviderId = readAliasedString(
currentConfig,
"provider",
"providerId",
);
const nextProviderId = readAliasedString(
nextConfig,
"provider",
"providerId",
);
return nextProviderId !== undefined && currentProviderId !== nextProviderId;
}
async function resolveSystemPrompt(config: JsonRecord): Promise<string> {
const cwd = String(
config.cwd ?? config.workspaceRoot ?? config.workspace_root ?? "",
@@ -348,7 +415,13 @@ function sendPromptsInQueueSnapshot(
const session = ctx.liveSessions.get(sessionId);
sendEvent(ctx, "prompts_in_queue_state", {
sessionId,
items: session?.promptsInQueue ?? [],
items:
session?.promptsInQueue.map(({ id, prompt, steer, attachmentCount }) => ({
id,
prompt,
steer,
attachmentCount,
})) ?? [],
});
}
@@ -358,6 +431,7 @@ function mapPendingPrompt(item: SessionPendingPrompt): PromptInQueue {
prompt: item.prompt,
steer: item.delivery === "steer",
attachmentCount: item.attachmentCount,
userImages: item.userImages,
};
}
@@ -372,7 +446,12 @@ function applyPendingPrompts(
session.promptsInQueue = mapped;
}
sendPromptsInQueueSnapshot(ctx, sessionId);
return mapped;
return mapped.map(({ id, prompt, steer, attachmentCount }) => ({
id,
prompt,
steer,
attachmentCount,
}));
}
function getSessionManager(ctx: SidecarContext): ClineCore {
@@ -412,11 +491,12 @@ async function handleStart(
// the frontend call the separate "send" action to dispatch the prompt.
// This avoids a double-execution bug where start() would run the turn AND
// the subsequent manager.send() fire-and-forget would run it again.
console.error(
`[sidecar:handleStart] calling manager.start provider=${coreConfig.providerId} model=${coreConfig.modelId}`,
);
ctx.logger?.log("Starting desktop chat session", {
providerId: String(coreConfig.providerId ?? ""),
modelId: String(coreConfig.modelId ?? ""),
});
const startResult = await manager.start({
...splitCoreSessionConfig(coreConfig as unknown as CoreSessionConfig),
...splitCoreSessionConfig(coreConfig as unknown as ClineCoreStartConfig),
source: SessionSource.DESKTOP,
interactive: true,
...(initialMessages
@@ -425,19 +505,24 @@ async function handleStart(
toolPolicies: resolveToolPolicies(request.config),
});
const sessionId = startResult.sessionId;
console.error(`[sidecar:handleStart] session started sessionId=${sessionId}`);
const session = createLiveSession(request.config, {
messages: initialMessages,
prompt: initialMessages
? derivePromptFromMessages(initialMessages)
: undefined,
title: requestedSessionId
? readSessionMetadataTitle(requestedSessionId)
: undefined,
status: "idle",
});
const workspaceRoot = startResult.manifest.workspace_root;
const cwd = startResult.manifest.cwd;
ctx.logger?.log("Desktop chat session started", { sessionId });
const session = createLiveSession(
{ ...request.config, cwd, workspaceRoot },
{
messages: initialMessages,
prompt: initialMessages
? derivePromptFromMessages(initialMessages)
: undefined,
title: requestedSessionId
? readSessionMetadataTitle(requestedSessionId)
: undefined,
status: "idle",
},
);
ctx.liveSessions.set(sessionId, session);
return { sessionId };
return { sessionId, cwd, workspaceRoot };
}
async function handleAttach(
@@ -495,6 +580,11 @@ async function handleAttach(
existing?.title,
endedAt: isoTimestampToMs(session.endedAt),
attachedViaHub: true,
// Preserve tracked attachment files so re-attach (called on every
// webview hydrate) does not orphan materialized files still awaiting
// cleanup.
queuedAttachmentFiles: existing?.queuedAttachmentFiles,
consumedAttachmentFiles: existing?.consumedAttachmentFiles,
}),
);
@@ -510,80 +600,251 @@ async function handleAttach(
};
}
async function startRebuiltSession(
manager: ClineCore,
sessionId: string,
config: JsonRecord,
systemPrompt: string,
messages: Message[],
compactionState: SessionCompactionState | undefined,
): Promise<void> {
const projectedMessages = compactionState
? projectSessionCompactionState(compactionState, messages)
: undefined;
const restarted = await manager.start({
...splitCoreSessionConfig(
buildCoreSessionConfig({
...config,
sessionId,
systemPrompt,
}) as unknown as ClineCoreStartConfig,
),
source: SessionSource.DESKTOP,
interactive: true,
initialMessages: messages,
...(projectedMessages
? {
initialCompactionState: createSessionCompactionState({
sourceMessages: messages,
compactedMessages: projectedMessages,
systemPrompt: compactionState?.system_prompt,
}),
}
: {}),
toolPolicies: resolveToolPolicies(config),
});
if (restarted.sessionId !== sessionId) {
throw new Error(
`Provider switch changed session id from ${sessionId} to ${restarted.sessionId}`,
);
}
}
async function rebuildSessionForProviderChange(
ctx: SidecarContext,
manager: ClineCore,
sessionId: string,
previousConfig: JsonRecord,
nextConfig: JsonRecord,
): Promise<void> {
const [messages, compactionState, previousSystemPrompt, nextSystemPrompt] =
await Promise.all([
manager.readMessages(sessionId),
manager.readSessionCompactionState(sessionId).catch((error) => {
ctx.logger?.log?.("Failed to read desktop session compaction state", {
sessionId,
error,
severity: "warn",
});
return undefined;
}),
resolveSystemPrompt(previousConfig),
resolveSystemPrompt(nextConfig),
]);
await manager.stop(sessionId);
let replacementStarted = false;
try {
await startRebuiltSession(
manager,
sessionId,
nextConfig,
nextSystemPrompt,
messages,
compactionState,
);
replacementStarted = true;
// Reusing a session id preserves its existing manifest. Treat refreshing
// its connection label as part of the replacement transaction so a
// persistence failure cannot leave runtime and cached state diverged.
await manager.updateSessionConnection(
sessionId,
buildSessionConnectionUpdate(nextConfig),
);
} catch (replacementError) {
try {
if (replacementStarted) {
await manager.stop(sessionId);
}
await startRebuiltSession(
manager,
sessionId,
previousConfig,
previousSystemPrompt,
messages,
compactionState,
);
await manager.updateSessionConnection(
sessionId,
buildSessionConnectionUpdate(previousConfig),
);
} catch (rollbackError) {
throw new AggregateError(
[replacementError, rollbackError],
"Provider switch and rollback both failed",
);
}
throw replacementError;
}
}
async function handleSend(
ctx: SidecarContext,
request: ChatSessionCommandRequest,
): Promise<unknown> {
const sessionId = request.sessionId?.trim();
if (!sessionId) throw new Error("sessionId is required");
const prompt = request.prompt?.trim();
if (!prompt) throw new Error("prompt is required");
const prompt = request.prompt?.trim() ?? "";
const hasAttachments =
(request.attachments?.userImages?.length ?? 0) > 0 ||
(request.attachments?.userFiles?.length ?? 0) > 0;
if (!prompt && !hasAttachments) {
throw new Error("prompt or attachment is required");
}
const manager = getSessionManager(ctx);
const session = ctx.liveSessions.get(sessionId);
if (request.config) {
if (
!session ||
session.attachedViaHub ||
shouldUpdateSessionConnection(session.config, request.config)
) {
const connectionUpdate = buildSessionConnectionUpdate(request.config);
await manager.updateSessionConnection(sessionId, connectionUpdate);
}
if (session) {
session.config = { ...session.config, ...request.config };
}
if (session?.transitioningProvider) {
throw new Error("A provider switch is already in progress");
}
// Determine effective delivery mode.
// When the session is busy and no explicit delivery was requested, queue it
// via Core so that Core's own pending-prompts mechanism handles draining.
// This avoids a sidecar-only local queue that never calls manager.send().
let delivery = request.delivery;
if (!delivery && session?.busy) {
delivery = "queue";
}
if (delivery === "queue") {
if (session) {
session.prompt = prompt;
}
// Delegate queuing to Core — it will drain the prompt once the current
// turn finishes and emit pending_prompts / pending_prompt_submitted events.
await manager.send({
sessionId,
prompt,
delivery: "queue",
userImages: request.attachments?.userImages,
});
const prompts = await manager.pendingPrompts.list({ sessionId });
return {
sessionId,
ok: true,
queued: true,
promptsInQueue: applyPendingPrompts(ctx, sessionId, prompts),
};
const nextConfig = request.config
? mergeSessionConfig(session?.config ?? {}, request.config)
: undefined;
const providerChanged = Boolean(
session &&
request.config &&
hasProviderChanged(session.config, request.config),
);
if (providerChanged && session?.busy) {
throw new Error("Cannot switch providers while a turn is running");
}
const ownsBusyState = Boolean(
session && delivery !== "queue" && delivery !== "steer",
);
if (session) {
session.prompt = prompt;
session.busy = true;
session.status = "running";
if (ownsBusyState) {
session.prompt = prompt;
session.busy = true;
session.status = "running";
}
if (providerChanged) {
session.transitioningProvider = true;
}
}
try {
console.error(
`[sidecar:handleSend] calling manager.send sessionId=${sessionId} prompt=${prompt.slice(0, 80)}`,
);
const result = await manager.send({
if (request.config && nextConfig) {
if (providerChanged && session) {
await rebuildSessionForProviderChange(
ctx,
manager,
sessionId,
session.config,
nextConfig,
);
} else if (
!session ||
session.attachedViaHub ||
shouldUpdateSessionConnection(session.config, nextConfig)
) {
await manager.updateSessionConnection(
sessionId,
buildSessionConnectionUpdate(nextConfig),
);
}
if (session) {
session.config = nextConfig;
if (providerChanged) {
session.attachedViaHub = false;
}
}
}
const userFiles = materializeUserFiles(
sessionId,
prompt,
delivery,
userImages: request.attachments?.userImages,
});
console.error(
`[sidecar:handleSend] manager.send resolved sessionId=${sessionId} finishReason=${result?.finishReason} textLen=${result?.text?.length ?? 0}`,
request.attachments?.userFiles,
);
if (session) {
session.busy = false;
if (delivery === "queue") {
if (session) {
session.prompt = prompt;
}
await manager.send({
sessionId,
prompt,
delivery: "queue",
userImages: request.attachments?.userImages,
userFiles,
});
const prompts = await manager.pendingPrompts.list({ sessionId });
trackQueuedAttachments(session, prompts, userFiles);
return {
sessionId,
ok: true,
queued: true,
promptsInQueue: applyPendingPrompts(ctx, sessionId, prompts),
};
}
ctx.logger?.debug("Sending desktop chat prompt", {
sessionId,
promptLength: prompt.length,
delivery,
});
let result: Awaited<ReturnType<ClineCore["send"]>>;
try {
result = await manager.send({
sessionId,
prompt,
delivery,
userImages: request.attachments?.userImages,
userFiles,
});
} catch (error) {
deleteMaterializedAttachments(sessionId, userFiles);
throw error;
}
if (result === undefined) {
// The runtime queued or steered the prompt instead of running it
// (busy interactive session / steer delivery) — track the files so
// they are deleted once the prompt is consumed or discarded.
if (userFiles?.length) {
trackQueuedAttachments(
session,
await manager.pendingPrompts.list({ sessionId }),
userFiles,
);
}
} else {
deleteMaterializedAttachments(sessionId, userFiles);
}
ctx.logger?.log("Desktop chat prompt completed", {
sessionId,
finishReason: result?.finishReason,
textLength: result?.text?.length ?? 0,
});
if (session && ownsBusyState) {
session.status = "idle";
if (result?.messages) session.messages = result.messages as unknown[];
}
@@ -602,11 +863,8 @@ async function handleSend(
: undefined,
};
} catch (error) {
console.error(
`[sidecar:handleSend] manager.send THREW sessionId=${sessionId} error=${error instanceof Error ? error.message : String(error)}`,
);
if (session) {
session.busy = false;
ctx.logger?.error?.("Desktop chat prompt failed", { sessionId, error });
if (session && ownsBusyState) {
session.status = "error";
}
emitChunk(
@@ -626,6 +884,15 @@ async function handleSend(
text: error instanceof Error ? error.message : String(error),
},
};
} finally {
if (session) {
if (ownsBusyState) {
session.busy = false;
}
if (providerChanged) {
session.transitioningProvider = false;
}
}
}
}
@@ -732,7 +999,7 @@ async function handleFork(
...forkConfig,
systemPrompt,
initialMessages: sourceMessages,
}) as unknown as CoreSessionConfig,
}) as unknown as ClineCoreStartConfig,
),
source: SessionSource.DESKTOP,
interactive: true,
@@ -741,6 +1008,10 @@ async function handleFork(
toolPolicies: resolveToolPolicies(forkConfig),
});
const newSessionId = startResult.sessionId;
discardAllTrackedAttachments(
sourceSessionId,
ctx.liveSessions.get(sourceSessionId),
);
ctx.liveSessions.delete(sourceSessionId);
ctx.liveSessions.set(
newSessionId,
@@ -780,6 +1051,7 @@ async function handleReset(
) {
await getSessionManager(ctx).stop(sessionId);
}
discardAllTrackedAttachments(sessionId, session);
ctx.liveSessions.delete(sessionId);
sendPromptsInQueueSnapshot(ctx, sessionId);
}
@@ -818,7 +1090,7 @@ async function handleRestoreCheckpoint(
buildCoreSessionConfig({
...request.config,
systemPrompt: await resolveSystemPrompt(request.config),
}) as unknown as CoreSessionConfig,
}) as unknown as ClineCoreStartConfig,
),
source: SessionSource.DESKTOP,
interactive: true,
@@ -830,6 +1102,10 @@ async function handleRestoreCheckpoint(
if (!sessionId || !restoredMessages) {
throw new Error("Checkpoint restore did not return a new session");
}
discardAllTrackedAttachments(
sourceSessionId,
ctx.liveSessions.get(sourceSessionId),
);
ctx.liveSessions.delete(sourceSessionId);
ctx.liveSessions.set(
sessionId,
@@ -931,6 +1207,10 @@ async function handleRemovePendingPrompt(
sessionId,
promptId,
});
if (result.removed === true) {
deleteMaterializedAttachments(sessionId, result.prompt?.userFiles);
ctx.liveSessions.get(sessionId)?.queuedAttachmentFiles?.delete(promptId);
}
return {
sessionId,
removed: result.removed === true,
File diff suppressed because it is too large Load Diff
@@ -263,6 +263,24 @@ export async function startConnectorChannel(
): Promise<WebviewConnectorChannelsResponse> {
const cliArgs = buildConnectorStartArgs(args);
const channel = cliArgs[0] ?? "";
if (listActiveConnectors().some((connector) => connector.type === channel)) {
const stopResult = await runCliConnectCommand(workspaceRoot, [
"--stop",
channel,
]);
if (stopResult.code !== 0) {
throw new Error(
normalizeConnectorError(
stopResult.stderr || stopResult.stdout,
"connector stop failed",
),
);
}
await waitForConnectorState(
() =>
!listActiveConnectors().some((connector) => connector.type === channel),
);
}
const result = await runCliConnectCommand(workspaceRoot, cliArgs);
if (result.code !== 0) {
throw new Error(
@@ -290,7 +308,7 @@ export async function stopConnectorChannel(
if (!supported.has(channel)) {
throw new Error(`unknown connector channel: ${channel}`);
}
const result = await runCliConnectCommand(workspaceRoot, [channel, "--stop"]);
const result = await runCliConnectCommand(workspaceRoot, ["--stop", channel]);
if (result.code !== 0) {
throw new Error(
normalizeConnectorError(
+201 -38
View File
@@ -1,12 +1,19 @@
import { existsSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { RuntimeCapabilities } from "@cline/core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SidecarContext } from "./types";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { materializeUserFiles } from "./attachments";
import type { LiveSession, SidecarContext } from "./types";
const createCoreMock = vi.hoisted(() => vi.fn());
const connectMock = vi.hoisted(() => vi.fn());
const ensureCompatibleLocalHubUrlMock = vi.hoisted(() => vi.fn());
const hubCommandMock = vi.hoisted(() => vi.fn());
const hubGetConnectionErrorMock = vi.hoisted(() => vi.fn());
const hubGetUrlMock = vi.hoisted(() => vi.fn());
const hubIsConnectedMock = vi.hoisted(() => vi.fn());
const nodeHubClientCtorMock = vi.hoisted(() => vi.fn());
const resolveHubOwnerContextMock = vi.hoisted(() => vi.fn());
const startHubWebSocketServerMock = vi.hoisted(() => vi.fn());
const subscribeMock = vi.hoisted(() => vi.fn());
vi.mock("@cline/core", async () => {
@@ -17,19 +24,16 @@ vi.mock("@cline/core", async () => {
ClineCore: {
create: createCoreMock,
},
createLocalHubScheduleRuntimeHandlers: vi.fn(() => ({
startSession: vi.fn(),
sendSession: vi.fn(),
abortSession: vi.fn(),
stopSession: vi.fn(),
})),
resolveHubOwnerContext: resolveHubOwnerContextMock,
startHubWebSocketServer: startHubWebSocketServerMock,
ensureCompatibleLocalHubUrl: ensureCompatibleLocalHubUrlMock,
NodeHubClient: class {
constructor(options: unknown) {
nodeHubClientCtorMock(options);
}
connect = connectMock;
command = hubCommandMock;
getConnectionError = hubGetConnectionErrorMock;
getUrl = hubGetUrlMock;
isConnected = hubIsConnectedMock;
subscribe = subscribeMock;
dispose = vi.fn();
},
@@ -53,20 +57,21 @@ describe("Code sidecar runtime capabilities", () => {
beforeEach(() => {
createCoreMock.mockReset();
connectMock.mockReset();
ensureCompatibleLocalHubUrlMock.mockReset();
hubCommandMock.mockReset();
hubGetConnectionErrorMock.mockReset();
hubGetUrlMock.mockReset();
hubIsConnectedMock.mockReset();
nodeHubClientCtorMock.mockReset();
resolveHubOwnerContextMock.mockReset();
startHubWebSocketServerMock.mockReset();
subscribeMock.mockReset();
connectMock.mockResolvedValue(undefined);
resolveHubOwnerContextMock.mockReturnValue({
ownerId: "code-sidecar-test",
discoveryPath: "/tmp/code-sidecar-test.json",
});
startHubWebSocketServerMock.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
close: vi.fn(),
});
ensureCompatibleLocalHubUrlMock.mockResolvedValue(
"ws://127.0.0.1:25463/hub",
);
hubCommandMock.mockResolvedValue({ ok: true, payload: {} });
hubGetConnectionErrorMock.mockReturnValue(null);
hubGetUrlMock.mockReturnValue("ws://127.0.0.1:25463/hub");
hubIsConnectedMock.mockReturnValue(true);
subscribeMock.mockReturnValue(() => {});
createCoreMock.mockResolvedValue({
runtimeAddress: "ws://127.0.0.1:25463/hub",
@@ -83,15 +88,6 @@ describe("Code sidecar runtime capabilities", () => {
const ctx = createSidecarContext("/workspace/project");
await initializeSessionManager(ctx);
expect(startHubWebSocketServerMock).toHaveBeenCalledWith(
expect.objectContaining({
port: 0,
owner: {
ownerId: "code-sidecar-test",
discoveryPath: "/tmp/code-sidecar-test.json",
},
}),
);
expect(createCoreMock).toHaveBeenCalledWith(
expect.objectContaining({
backendMode: "hub",
@@ -102,22 +98,115 @@ describe("Code sidecar runtime capabilities", () => {
requestToolApproval: expect.any(Function),
}),
hub: expect.objectContaining({
endpoint: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
strategy: "require-hub",
workspaceRoot: "/workspace/project",
cwd: "/workspace/project",
clientType: "code-sidecar",
displayName: "Code App sidecar",
}),
}),
);
const hubOptions = createCoreMock.mock.calls[0][0].hub;
expect(hubOptions).not.toHaveProperty("endpoint");
expect(hubOptions).not.toHaveProperty("authToken");
expect(nodeHubClientCtorMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
clientType: "code-sidecar-approvals",
clientType: "code-sidecar-observer",
displayName: "Code App observer",
}),
);
});
it("wires the desktop logger and telemetry through the shared Hub client", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
);
const logger = {
debug: vi.fn(),
log: vi.fn(),
error: vi.fn(),
};
const telemetry = { capture: vi.fn() };
const ctx = createSidecarContext("/workspace/project", {
logger,
telemetry: telemetry as never,
});
await initializeSessionManager(ctx);
expect(createCoreMock).toHaveBeenCalledWith(
expect.objectContaining({
clientName: "cline-code",
logger,
telemetry,
}),
);
});
it("reports the connected shared Hub endpoint in process context", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
);
const { handleCommand } = await import("./commands");
const ctx = createSidecarContext("/workspace/project");
await initializeSessionManager(ctx);
await expect(handleCommand(ctx, "get_process_context")).resolves.toEqual(
expect.objectContaining({
hub: {
status: "connected",
url: "ws://127.0.0.1:25463/hub",
error: null,
},
}),
);
});
it("starts or reuses the shared Hub when a command needs a client", async () => {
const { createSidecarContext, ensureSharedHubClient } = await import(
"./context"
);
const ctx = createSidecarContext("/workspace/project");
const hubClient = await ensureSharedHubClient(ctx);
expect(hubClient).toBe(ctx.hubClient);
expect(ensureCompatibleLocalHubUrlMock).toHaveBeenCalledWith({
strategy: "require-hub",
workspaceRoot: "/workspace/project",
cwd: "/workspace/project",
});
expect(nodeHubClientCtorMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "ws://127.0.0.1:25463/hub",
clientType: "code-sidecar-observer",
}),
);
expect(connectMock).toHaveBeenCalledOnce();
});
it("serializes queued image data when a queued prompt starts", async () => {
const { serializeQueuedPromptStart } = await import("./context");
expect(
JSON.parse(
serializeQueuedPromptStart({
promptId: "queued-prompt-1",
prompt: "Describe this",
attachmentCount: 1,
userImages: ["data:image/png;base64,AQID"],
}),
),
).toEqual({
promptId: "queued-prompt-1",
prompt: "Describe this",
attachmentCount: 1,
userImages: ["data:image/png;base64,AQID"],
});
});
it("resolves askQuestion through the websocket request/response protocol", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
@@ -192,8 +281,7 @@ describe("Code sidecar runtime capabilities", () => {
requestToolApproval: expect.any(Function),
}),
hub: expect.objectContaining({
endpoint: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
strategy: "require-hub",
clientType: "code-sidecar",
displayName: "Code App sidecar",
}),
@@ -253,4 +341,79 @@ describe("Code sidecar runtime capabilities", () => {
await handleCommand(ctx, "poll_tool_approvals", { sessionId: "sess-1" }),
).toEqual([]);
});
it("routes routine commands through the connected shared Hub client", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
);
const { handleCommand } = await import("./commands");
hubCommandMock.mockResolvedValue({
ok: true,
payload: { schedule: { scheduleId: "schedule-1", enabled: false } },
});
const ctx = createSidecarContext("/workspace/project");
await initializeSessionManager(ctx);
await expect(
handleCommand(ctx, "pause_routine_schedule", {
schedule_id: "schedule-1",
}),
).resolves.toEqual({
schedule: { scheduleId: "schedule-1", enabled: false },
});
expect(hubCommandMock).toHaveBeenCalledWith("schedule.disable", {
scheduleId: "schedule-1",
});
});
});
describe("disposeSidecarContext attachment cleanup", () => {
let previousSessionDataDir: string | undefined;
let testSessionDataDir: string;
beforeEach(() => {
previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
testSessionDataDir = join(
tmpdir(),
`cline-desktop-dispose-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
});
afterEach(() => {
if (previousSessionDataDir === undefined) {
delete process.env.CLINE_SESSION_DATA_DIR;
} else {
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
}
rmSync(testSessionDataDir, { recursive: true, force: true });
});
it("deletes tracked attachments for all live sessions on shutdown", async () => {
const { createSidecarContext, disposeSidecarContext } = await import(
"./context"
);
const ctx = createSidecarContext("/workspace/project");
const sessionId = "dispose-session";
const [queuedFile] = materializeUserFiles(sessionId, [
{ name: "queued.txt", content: "q" },
]) as string[];
const session: LiveSession = {
config: {},
messages: [],
promptsInQueue: [],
busy: false,
startedAt: Date.now(),
status: "idle",
queuedAttachmentFiles: new Map([["pending_1", [queuedFile]]]),
};
ctx.liveSessions.set(sessionId, session);
await disposeSidecarContext(ctx, "test_shutdown");
expect(existsSync(queuedFile)).toBe(false);
expect(ctx.liveSessions.size).toBe(0);
});
});
+132 -42
View File
@@ -4,18 +4,24 @@ import { homedir } from "node:os";
import { dirname } from "node:path";
import {
type AgentToolContext,
type BasicLogger,
ClineCore,
createLocalHubScheduleRuntimeHandlers,
type CoreSessionEvent,
ensureCompatibleLocalHubUrl,
type ITelemetryService,
NodeHubClient,
resolveHubOwnerContext,
type RuntimeCapabilities,
setHomeDirIfUnset,
startHubWebSocketServer,
type ToolApprovalRequest,
type ToolApprovalResult,
} from "@cline/core";
import type { AgentEvent } from "@cline/shared";
import {
discardAllTrackedAttachments,
flushConsumedAttachments,
markQueuedAttachmentsSubmitted,
reconcileQueuedAttachments,
} from "./attachments";
import { sessionLogPath } from "./paths";
import type {
LiveSession,
@@ -26,6 +32,10 @@ import type {
} from "./types";
const ASK_QUESTION_TIMEOUT_MS = 5 * 60_000;
const hubClientInitialization = new WeakMap<
SidecarContext,
Promise<NodeHubClient>
>();
// ---------------------------------------------------------------------------
// Helpers — WebSocket broadcast
@@ -109,7 +119,29 @@ export function broadcastChunk(
// ---------------------------------------------------------------------------
function getPromptsInQueue(session: LiveSession): PromptInQueue[] {
return session.promptsInQueue;
return session.promptsInQueue.map(
({ id, prompt, steer, attachmentCount, userImages }) => ({
id,
prompt,
steer,
attachmentCount,
userImages,
}),
);
}
export function serializeQueuedPromptStart(input: {
promptId: string;
prompt: string;
attachmentCount?: number;
userImages?: string[];
}): string {
return JSON.stringify({
promptId: input.promptId,
prompt: input.prompt,
attachmentCount: input.attachmentCount ?? 0,
userImages: input.userImages,
});
}
function sendPromptsInQueueSnapshot(
@@ -301,9 +333,16 @@ function handleCoreSessionEvent(
prompt: item.prompt ?? "",
steer: item.delivery === "steer",
attachmentCount: item.attachmentCount ?? 0,
userImages: item.userImages,
}))
.filter((item) => item.id && item.prompt);
.filter(
(item) => item.id && (item.prompt || (item.attachmentCount ?? 0) > 0),
);
if (session) {
reconcileQueuedAttachments(
session,
mapped.map((item) => item.id),
);
const previous = session.promptsInQueue;
session.promptsInQueue = mapped;
if (
@@ -315,9 +354,11 @@ function handleCoreSessionEvent(
ctx,
sessionId,
"chat_queued_prompt_start",
JSON.stringify({
serializeQueuedPromptStart({
promptId: previous[0].id,
prompt: previous[0].prompt,
attachmentCount: previous[0].attachmentCount ?? 0,
userImages: previous[0].userImages,
}),
);
}
@@ -326,14 +367,18 @@ function handleCoreSessionEvent(
break;
}
case "pending_prompt_submitted": {
const { sessionId, prompt, attachmentCount } = event.payload;
const { sessionId, id, prompt, attachmentCount, userImages } =
event.payload;
markQueuedAttachmentsSubmitted(ctx.liveSessions.get(sessionId), id);
emitChunk(
ctx,
sessionId,
"chat_queued_prompt_start",
JSON.stringify({
serializeQueuedPromptStart({
promptId: id,
prompt,
attachmentCount: attachmentCount ?? 0,
userImages,
}),
);
break;
@@ -346,6 +391,7 @@ function handleCoreSessionEvent(
session.endedAt = nowMs();
session.status = reason || "ended";
}
discardAllTrackedAttachments(sessionId, session);
sendEvent(ctx, "chat_session_ended", { sessionId, reason });
break;
}
@@ -365,6 +411,10 @@ function handleCoreSessionEvent(
if (session) {
session.status = status;
session.busy = status === "running";
if (status !== "running") {
// The turn that consumed submitted attachments has finished.
flushConsumedAttachments(sessionId, session);
}
}
sendEvent(ctx, "chat_session_status", { sessionId, status });
break;
@@ -380,7 +430,13 @@ function handleCoreSessionEvent(
// Context factory
// ---------------------------------------------------------------------------
export function createSidecarContext(workspaceRoot: string): SidecarContext {
export function createSidecarContext(
workspaceRoot: string,
observability: {
logger?: BasicLogger;
telemetry?: ITelemetryService;
} = {},
): SidecarContext {
return {
liveSessions: new Map(),
streamIndices: new Map(),
@@ -389,8 +445,9 @@ export function createSidecarContext(workspaceRoot: string): SidecarContext {
pendingQuestions: new Map(),
sessionManager: null,
hubClient: null,
hubServer: null,
workspaceRoot,
logger: observability.logger,
telemetry: observability.telemetry,
unsubscribeSessionEvents: null,
};
}
@@ -404,6 +461,11 @@ export async function disposeSidecarContext(
ctx.unsubscribeSessionEvents?.();
ctx.unsubscribeSessionEvents = null;
for (const [sessionId, session] of ctx.liveSessions) {
discardAllTrackedAttachments(sessionId, session);
}
ctx.liveSessions.clear();
for (const client of ctx.wsClients) {
try {
client.close?.();
@@ -434,12 +496,6 @@ export async function disposeSidecarContext(
cleanup.push(sessionManager.dispose(reason));
}
const hubServer = ctx.hubServer;
ctx.hubServer = null;
if (hubServer) {
cleanup.push(hubServer.close());
}
const results = await Promise.allSettled(cleanup);
const firstFailure = results.find(
(result): result is PromiseRejectedResult => result.status === "rejected",
@@ -692,19 +748,14 @@ export async function initializeSessionManager(
ctx: SidecarContext,
): Promise<void> {
setHomeDirIfUnset(homedir());
const hubServer = await startHubWebSocketServer({
port: 0,
owner: resolveHubOwnerContext(
`code-sidecar:${process.pid}:${randomUUID()}`,
),
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
});
const sessionManager = await ClineCore.create({
clientName: "cline-code",
backendMode: "hub",
capabilities: createSidecarRuntimeCapabilities(ctx),
logger: ctx.logger,
telemetry: ctx.telemetry,
hub: {
endpoint: hubServer.url,
authToken: hubServer.authToken,
strategy: "require-hub",
workspaceRoot: ctx.workspaceRoot,
cwd: ctx.workspaceRoot,
clientType: "code-sidecar",
@@ -717,25 +768,64 @@ export async function initializeSessionManager(
handleCoreSessionEvent(ctx, event);
});
const runtimeAddress = sessionManager.runtimeAddress?.trim();
let hubClient: NodeHubClient | null = null;
if (runtimeAddress) {
hubClient = new NodeHubClient({
url: runtimeAddress,
authToken: hubServer.authToken,
clientType: "code-sidecar-approvals",
displayName: "Code App approvals",
workspaceRoot: ctx.workspaceRoot,
cwd: ctx.workspaceRoot,
});
await hubClient.connect();
hubClient.subscribe((event) => {
handleHubLiveEvent(ctx, event);
});
try {
await ensureSharedHubClient(ctx, sessionManager.runtimeAddress);
} catch (error) {
unsubscribe();
await sessionManager.dispose("code_sidecar_hub_initialization_failed");
throw error;
}
ctx.sessionManager = sessionManager;
ctx.hubClient = hubClient;
ctx.hubServer = hubServer;
ctx.unsubscribeSessionEvents = unsubscribe;
}
export async function ensureSharedHubClient(
ctx: SidecarContext,
preferredUrl?: string,
): Promise<NodeHubClient> {
if (ctx.hubClient) {
return ctx.hubClient;
}
const pending = hubClientInitialization.get(ctx);
if (pending) {
return await pending;
}
const initialization = (async () => {
const url =
preferredUrl?.trim() ||
(await ensureCompatibleLocalHubUrl({
strategy: "require-hub",
workspaceRoot: ctx.workspaceRoot,
cwd: ctx.workspaceRoot,
}));
if (!url) {
throw new Error("Unable to start or connect to the shared Cline Hub.");
}
const client = new NodeHubClient({
url,
clientType: "code-sidecar-observer",
displayName: "Code App observer",
workspaceRoot: ctx.workspaceRoot,
cwd: ctx.workspaceRoot,
});
try {
await client.connect();
client.subscribe((event) => {
handleHubLiveEvent(ctx, event);
});
ctx.hubClient = client;
return client;
} catch (error) {
await client.dispose().catch(() => undefined);
throw error;
}
})().finally(() => {
hubClientInitialization.delete(ctx);
});
hubClientInitialization.set(ctx, initialization);
return await initialization;
}
+73 -3
View File
@@ -1,14 +1,22 @@
import { homedir } from "node:os";
import { setHomeDirIfUnset } from "@cline/core";
import { isHubDaemonProcess } from "@cline/shared";
import { prewarmWorkspaceMetadata } from "./chat-session";
import {
createSidecarContext,
disposeSidecarContext,
initializeSessionManager,
} from "./context";
import { createDesktopObservability } from "./observability";
import { resolveWorkspaceRoot } from "./paths";
import { startServer } from "./server";
import { ensureLoginShellPath } from "./shell-path";
import { BunRuntime, SIDECAR_HOST, SIDECAR_MODE, SIDECAR_PORT } from "./types";
const SHUTDOWN_TIMEOUT_MS = 5_000;
let activeObservability:
| ReturnType<typeof createDesktopObservability>
| undefined;
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
let timeout: ReturnType<typeof setTimeout> | undefined;
@@ -32,19 +40,48 @@ async function main() {
throw new Error("sidecar must be run with Bun");
}
// When launched from Finder/the Dock the app inherits launchd's minimal
// PATH, so agent-spawned processes can't find shell-profile-installed
// tools like `gh`. Kick resolution off first so it overlaps the rest of
// startup, but await it before the session manager exists — that's what
// spawns children (agent sessions, MCP servers, scheduled runs).
const shellPathPromise = ensureLoginShellPath();
const workspaceRoot = resolveWorkspaceRoot(process.cwd());
const ctx = createSidecarContext(workspaceRoot);
setHomeDirIfUnset(homedir());
const observability = createDesktopObservability();
activeObservability = observability;
const ctx = createSidecarContext(workspaceRoot, observability);
observability.logger.log("Desktop sidecar starting", {
workspaceRoot,
pid: process.pid,
});
prewarmWorkspaceMetadata(workspaceRoot);
observability.logger.log(
"Login shell PATH resolution",
await shellPathPromise,
);
await initializeSessionManager(ctx);
let shuttingDown = false;
let handlingFatalError = false;
const shutdown = async (reason = "code_sidecar_shutdown"): Promise<void> => {
if (shuttingDown) {
return;
}
shuttingDown = true;
await withTimeout(disposeSidecarContext(ctx, reason), SHUTDOWN_TIMEOUT_MS);
observability.logger.log("Desktop sidecar shutting down", { reason });
await withTimeout(
(async () => {
try {
await disposeSidecarContext(ctx, reason);
} finally {
await observability.dispose();
}
})(),
SHUTDOWN_TIMEOUT_MS,
);
};
const shutdownAndExit = (signal: string): void => {
@@ -55,11 +92,32 @@ async function main() {
process.once("SIGINT", () => shutdownAndExit("SIGINT"));
process.once("SIGTERM", () => shutdownAndExit("SIGTERM"));
const handleFatalError = (kind: string, error: unknown): void => {
if (handlingFatalError) {
process.exit(1);
}
handlingFatalError = true;
observability.logger.error?.("Desktop sidecar process error", {
kind,
error,
});
void shutdown(`code_sidecar_${kind}`).finally(() => process.exit(1));
};
process.on("uncaughtException", (error) => {
handleFatalError("uncaught_exception", error);
});
process.on("unhandledRejection", (error) => {
handleFatalError("unhandled_rejection", error);
});
process.once("beforeExit", () => {
void shutdown("code_sidecar_before_exit");
});
const { port } = startServer(ctx, SIDECAR_PORT, shutdown);
observability.logger.log("Desktop sidecar ready", {
port,
mode: SIDECAR_MODE,
});
// A wildcard bind isn't a dialable address; advertise loopback instead.
const dialHost = SIDECAR_HOST === "0.0.0.0" ? "127.0.0.1" : SIDECAR_HOST;
@@ -76,8 +134,20 @@ async function main() {
);
}
main().catch((error) => {
async function runEntrypoint(): Promise<void> {
if (isHubDaemonProcess()) {
await import("@cline/core/hub/daemon-entry");
return;
}
await main();
}
runEntrypoint().catch(async (error) => {
const message = error instanceof Error ? error.message : String(error);
activeObservability?.logger.error?.("Desktop sidecar process failed", {
error,
});
await activeObservability?.dispose();
process.stderr.write(`${message}\n`);
process.exit(1);
});
@@ -0,0 +1,95 @@
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
truncateSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createDesktopLoggerAdapter, DESKTOP_LOG_MAX_BYTES } from "./logging";
const originalEnv = {
CLINE_LOG_ENABLED: process.env.CLINE_LOG_ENABLED,
CLINE_LOG_LEVEL: process.env.CLINE_LOG_LEVEL,
CLINE_LOG_NAME: process.env.CLINE_LOG_NAME,
CLINE_LOG_PATH: process.env.CLINE_LOG_PATH,
};
afterEach(() => {
for (const [key, value] of Object.entries(originalEnv)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
describe("desktop sidecar logging", () => {
it("writes structured SDK logs to the configured file", () => {
const directory = mkdtempSync(join(tmpdir(), "cline-code-logging-"));
const destination = join(directory, "sidecar.log");
process.env.CLINE_LOG_PATH = destination;
process.env.CLINE_LOG_LEVEL = "debug";
delete process.env.CLINE_LOG_ENABLED;
try {
const adapter = createDesktopLoggerAdapter();
adapter.core.debug("desktop runtime event", { sessionId: "session-1" });
adapter.dispose();
const contents = readFileSync(destination, "utf8");
expect(contents).toContain("desktop runtime event");
expect(contents).toContain('"sessionId":"session-1"');
expect(adapter.runtimeConfig.name).toBe("cline-code.sidecar");
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
it("warns once before falling back to stderr when the log file cannot open", () => {
const directory = mkdtempSync(join(tmpdir(), "cline-code-fallback-"));
process.env.CLINE_LOG_PATH = directory;
delete process.env.CLINE_LOG_ENABLED;
const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true);
try {
const adapter = createDesktopLoggerAdapter();
adapter.dispose();
expect(stderr).toHaveBeenCalledWith(
expect.stringContaining("Unable to open log file"),
);
expect(stderr).toHaveBeenCalledWith(
expect.stringContaining("falling back to stderr"),
);
} finally {
stderr.mockRestore();
rmSync(directory, { recursive: true, force: true });
}
});
it("rotates the active log before a write exceeds the size limit", () => {
const directory = mkdtempSync(join(tmpdir(), "cline-code-rotation-"));
const destination = join(directory, "sidecar.log");
process.env.CLINE_LOG_PATH = destination;
delete process.env.CLINE_LOG_ENABLED;
try {
mkdirSync(directory, { recursive: true });
writeFileSync(destination, "");
truncateSync(destination, DESKTOP_LOG_MAX_BYTES - 1);
const adapter = createDesktopLoggerAdapter();
adapter.core.log("rotate before writing this entry");
adapter.dispose();
expect(statSync(destination).size).toBeLessThan(1_024);
expect(readFileSync(destination, "utf8")).toContain(
"rotate before writing this entry",
);
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,245 @@
import {
closeSync,
mkdirSync,
openSync,
statSync,
truncateSync,
} from "node:fs";
import { dirname, join } from "node:path";
import {
type BasicLogger,
type RuntimeLoggerConfig,
resolveClineDataDir,
} from "@cline/core";
import pino, {
type DestinationStream,
type LevelWithSilent,
type Logger as PinoLogger,
} from "pino";
const LOG_MAX_AGE_MS = 2 * 24 * 60 * 60 * 1_000;
export const DESKTOP_LOG_MAX_BYTES = 50 * 1024 * 1024;
const LOG_LEVELS: ReadonlySet<LevelWithSilent> = new Set([
"trace",
"debug",
"info",
"warn",
"error",
"fatal",
"silent",
]);
export interface DesktopLoggerAdapter {
readonly core: BasicLogger;
readonly runtimeConfig: Required<RuntimeLoggerConfig>;
flush(): void;
dispose(): void;
}
function resolveLogLevel(value: string | undefined): LevelWithSilent {
const candidate = value?.trim().toLowerCase() as LevelWithSilent | undefined;
return candidate && LOG_LEVELS.has(candidate) ? candidate : "info";
}
function resolveRuntimeConfig(): Required<RuntimeLoggerConfig> {
const enabledValue = process.env.CLINE_LOG_ENABLED?.trim().toLowerCase();
return {
enabled: enabledValue !== "0" && enabledValue !== "false",
level: resolveLogLevel(process.env.CLINE_LOG_LEVEL),
destination:
process.env.CLINE_LOG_PATH?.trim() ||
join(resolveClineDataDir(), "logs", "code.log"),
name: process.env.CLINE_LOG_NAME?.trim() || "cline-code.sidecar",
bindings: {},
};
}
type ManagedDestination = DestinationStream & {
flushSync(): void;
end(): void;
};
type DestinationResult =
| { destination: ManagedDestination; error?: never }
| { destination?: never; error: unknown };
function createDestination(path: string): DestinationResult {
try {
mkdirSync(dirname(path), { recursive: true });
const fd = openSync(path, "a");
closeSync(fd);
const initialStats = statSync(path);
if (
Date.now() - initialStats.mtimeMs >= LOG_MAX_AGE_MS ||
initialStats.size >= DESKTOP_LOG_MAX_BYTES
) {
truncateSync(path, 0);
}
const rawDestination = pino.destination({
dest: path,
mkdir: true,
sync: true,
});
const rawFlushSync = rawDestination.flushSync.bind(rawDestination);
let currentSize = statSync(path).size;
const destination: ManagedDestination = {
write(message: string) {
const messageSize = Buffer.byteLength(message);
if (currentSize + messageSize > DESKTOP_LOG_MAX_BYTES) {
try {
rawFlushSync();
truncateSync(path, 0);
currentSize = 0;
} catch {
// Rotation is best-effort; preserve the log entry if it fails.
}
}
rawDestination.write(message);
currentSize += messageSize;
},
flushSync() {
try {
rawFlushSync();
} catch {
// The synchronous stream may already be closed during teardown.
}
},
end() {
rawDestination.end();
},
};
return { destination };
} catch (error) {
return { error };
}
}
function writeDestinationFallbackWarning(path: string, error: unknown): void {
try {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(
`[cline-code] Unable to open log file ${path}; falling back to stderr (${message})\n`,
);
} catch {
// The fallback warning must never prevent sidecar startup.
}
}
function flushDestination(destination: ManagedDestination | undefined): void {
if (!destination) return;
try {
destination.flushSync();
} catch {
// Logging is best-effort during shutdown.
}
}
function closeDestination(destination: ManagedDestination | undefined): void {
if (!destination) return;
try {
destination.end();
} catch {
// Logging is best-effort during shutdown.
}
}
function createFallbackDestination(): DestinationStream {
return {
write(message: string) {
process.stderr.write(message);
},
};
}
/*
The adapter intentionally owns the destination lifecycle. Pino receives a
synchronous stream so telemetry and fatal-process logs can be flushed before
the sidecar exits.
*/
function createPinoLogger(
runtimeConfig: Required<RuntimeLoggerConfig>,
destination: ManagedDestination | undefined,
): PinoLogger {
return pino(
{
name: runtimeConfig.name,
level: runtimeConfig.enabled ? runtimeConfig.level : "silent",
enabled: runtimeConfig.enabled,
timestamp: pino.stdTimeFunctions.isoTime,
},
destination ?? createFallbackDestination(),
).child({ component: "sidecar" });
}
function flushLogger(logger: PinoLogger): void {
try {
logger.flush?.();
} catch {
// Logging is best-effort during shutdown.
}
}
function toFields(
metadata?: Record<string, unknown>,
): Record<string, unknown> | undefined {
if (!metadata) return undefined;
const { error, ...rest } = metadata;
const fields = error === undefined ? rest : { ...rest, err: error };
return Object.keys(fields).length > 0 ? fields : undefined;
}
function createCoreLogger(logger: PinoLogger): BasicLogger {
return {
debug(message, metadata) {
const fields = toFields(metadata);
fields ? logger.debug(fields, message) : logger.debug(message);
},
log(message, metadata) {
const fields = toFields(metadata);
const write =
metadata?.severity === "error"
? logger.error
: metadata?.severity === "warn"
? logger.warn
: logger.info;
fields
? write.call(logger, fields, message)
: write.call(logger, message);
},
error(message, metadata) {
const fields = toFields(metadata);
fields ? logger.error(fields, message) : logger.error(message);
},
};
}
export function createDesktopLoggerAdapter(): DesktopLoggerAdapter {
const runtimeConfig = resolveRuntimeConfig();
const destinationResult = runtimeConfig.enabled
? createDestination(runtimeConfig.destination)
: undefined;
const destination = destinationResult?.destination;
if (destinationResult?.error !== undefined) {
writeDestinationFallbackWarning(
runtimeConfig.destination,
destinationResult.error,
);
}
const logger = createPinoLogger(runtimeConfig, destination);
let disposed = false;
const flush = () => {
flushDestination(destination);
flushLogger(logger);
};
return {
core: createCoreLogger(logger),
runtimeConfig,
flush,
dispose() {
if (disposed) return;
disposed = true;
flush();
closeDestination(destination);
},
};
}
@@ -0,0 +1,81 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
captureExtensionActivated: vi.fn(),
createClineTelemetryServiceConfig: vi.fn((config: unknown) => config),
createConfiguredTelemetryHandle: vi.fn(),
disposeTelemetry: vi.fn(async () => {}),
disposeLogger: vi.fn(),
identifyAccount: vi.fn(),
setSdkLogger: vi.fn(),
}));
const logger = {
debug: vi.fn(),
log: vi.fn(),
error: vi.fn(),
};
const telemetry = { capture: vi.fn() };
vi.mock("@cline/core", async () => {
const actual =
await vi.importActual<typeof import("@cline/core")>("@cline/core");
return {
...actual,
captureExtensionActivated: mocks.captureExtensionActivated,
createClineTelemetryServiceConfig: mocks.createClineTelemetryServiceConfig,
createConfiguredTelemetryHandle: mocks.createConfiguredTelemetryHandle,
identifyAccount: mocks.identifyAccount,
ProviderSettingsManager: class {
getProviderSettings() {
return { auth: { accountId: "account-1" } };
}
},
setSdkLogger: mocks.setSdkLogger,
};
});
vi.mock("./logging", () => ({
createDesktopLoggerAdapter: () => ({
core: logger,
dispose: mocks.disposeLogger,
}),
}));
describe("desktop observability", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.createConfiguredTelemetryHandle.mockReturnValue({
telemetry,
dispose: mocks.disposeTelemetry,
});
});
it("configures desktop telemetry, identity, activation, and lifecycle", async () => {
const { createDesktopObservability } = await import("./observability");
const observability = createDesktopObservability();
expect(mocks.createClineTelemetryServiceConfig).toHaveBeenCalledWith({
metadata: expect.objectContaining({
cline_type: "desktop",
platform: "Cline Code",
}),
});
expect(mocks.createConfiguredTelemetryHandle).toHaveBeenCalledWith(
expect.objectContaining({ logger }),
);
expect(mocks.identifyAccount).toHaveBeenCalledWith(telemetry, {
id: "account-1",
provider: "cline",
});
expect(mocks.captureExtensionActivated).toHaveBeenCalledWith(telemetry);
expect(mocks.setSdkLogger).toHaveBeenCalledWith(logger);
await observability.dispose();
await observability.dispose();
expect(mocks.disposeTelemetry).toHaveBeenCalledTimes(1);
expect(mocks.setSdkLogger).toHaveBeenLastCalledWith(undefined);
expect(mocks.disposeLogger).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,63 @@
import * as os from "node:os";
import {
captureExtensionActivated,
createClineTelemetryServiceConfig,
createConfiguredTelemetryHandle,
type ITelemetryService,
identifyAccount,
ProviderSettingsManager,
setSdkLogger,
} from "@cline/core";
import { version } from "../package.json";
import {
createDesktopLoggerAdapter,
type DesktopLoggerAdapter,
} from "./logging";
export interface DesktopObservability {
readonly logger: DesktopLoggerAdapter["core"];
readonly telemetry: ITelemetryService;
dispose(): Promise<void>;
}
export function createDesktopObservability(): DesktopObservability {
const loggerAdapter = createDesktopLoggerAdapter();
const logger = loggerAdapter.core;
setSdkLogger(logger);
const telemetryHandle = createConfiguredTelemetryHandle({
...createClineTelemetryServiceConfig({
metadata: {
extension_version: version,
cline_type: "desktop",
platform: "Cline Code",
platform_version: process.version,
os_type: os.platform(),
os_version: os.version(),
},
}),
logger,
});
const telemetry = telemetryHandle.telemetry;
const auth = new ProviderSettingsManager().getProviderSettings("cline")?.auth;
if (auth?.accountId) {
identifyAccount(telemetry, {
id: auth.accountId,
provider: "cline",
});
}
captureExtensionActivated(telemetry);
let disposed = false;
return {
logger,
telemetry,
async dispose() {
if (disposed) return;
disposed = true;
await telemetryHandle.dispose();
setSdkLogger(undefined);
loggerAdapter.dispose();
},
};
}
+2 -6
View File
@@ -143,7 +143,7 @@ export function startServer(
}
export function createFetchHandler(
_ctx: SidecarContext,
ctx: SidecarContext,
onShutdown?: (reason?: string) => Promise<void>,
) {
return async (req: Request, server: SidecarServer) => {
@@ -199,11 +199,7 @@ export function createFetchHandler(
queueMicrotask(() => {
void onShutdown?.("code_sidecar_shutdown_endpoint")
.catch((error) => {
process.stderr.write(
`sidecar shutdown failed: ${
error instanceof Error ? error.message : String(error)
}\n`,
);
ctx.logger?.error?.("Desktop sidecar shutdown failed", { error });
})
.finally(() => process.exit(0));
});
@@ -58,6 +58,7 @@ export function discoverChatSessions(
prompt,
messages: session.messages,
});
const persistedMetadata = store.get(sessionId)?.metadata;
out.push({
sessionId,
status: session.status,
@@ -68,7 +69,10 @@ export function discoverChatSessions(
prompt,
startedAt: String(session.startedAt),
endedAt: session.endedAt ? String(session.endedAt) : undefined,
metadata: { title: resolvedTitle },
metadata: {
...(persistedMetadata ?? {}),
title: resolvedTitle,
},
});
}
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { readSessionMessages } from "./messages";
describe("readSessionMessages", () => {
it("projects image content blocks without replacing them with placeholder text", async () => {
const sessionId = `image-projection-${Date.now()}`;
const liveSessions = new Map([
[
sessionId,
{
messages: [
{
id: "user-image",
role: "user",
content: [
{ type: "text", text: "Describe this" },
{
type: "image",
mediaType: "image/png",
data: "aGVsbG8=",
},
],
},
],
},
],
]);
await expect(
readSessionMessages(
{ liveSessions } as Parameters<typeof readSessionMessages>[0],
sessionId,
),
).resolves.toEqual([
expect.objectContaining({
role: "user",
content: "Describe this",
images: [
{
id: "user-image_image_1",
mediaType: "image/png",
data: "aGVsbG8=",
},
],
}),
]);
});
});
@@ -1,5 +1,6 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { validateImageMedia } from "@cline/shared";
import {
readSessionManifest,
sharedSessionMessagesPath,
@@ -121,6 +122,20 @@ function trimNonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function extractImageBlock(
record: JsonRecord,
): { mediaType: string; data: string } | undefined {
const mediaType = trimNonEmptyString(record.mediaType);
const data = trimNonEmptyString(record.data);
if (!data) {
return undefined;
}
const validation = validateImageMedia(mediaType, data);
return validation.ok
? { mediaType: validation.mediaType, data: validation.base64 }
: undefined;
}
export function readPersistedChatMessages(sessionId: string): unknown[] | null {
const path = sharedSessionMessagesPath(sessionId);
if (!existsSync(path)) {
@@ -367,6 +382,7 @@ export async function readSessionMessages(
}
const textParts: string[] = [];
const images: Array<{ id: string; mediaType: string; data: string }> = [];
const reasoningParts: string[] = [];
let reasoningRedacted = false;
let textSegmentIndex = 0;
@@ -476,6 +492,16 @@ export async function readSessionMessages(
reasoningRedacted = true;
continue;
}
if (blockType === "image") {
const image = extractImageBlock(record);
if (image) {
images.push({
id: `${messageIdBase}_image_${blockIdx}`,
...image,
});
}
continue;
}
const line = stringifyMessageContent(block);
if (line.trim()) {
textParts.push(line);
@@ -483,6 +509,25 @@ export async function readSessionMessages(
}
flushTextParts();
if (images.length > 0) {
const target = out
.slice(outStartIndex)
.find((item) => item.role === role);
if (target) {
target.images = images;
} else {
out.push({
id: `${messageIdBase}_images`,
sessionId,
role,
content: "",
images,
createdAt: nextCreatedAt++,
meta: textMeta,
});
textMeta = undefined;
}
}
if (reasoningParts.length > 0 || reasoningRedacted) {
const reasoning = reasoningParts.join("\n").trim();
const target = out
@@ -0,0 +1,250 @@
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
defaultShellFor,
ensureLoginShellPath,
extractMarkedPath,
loginShellFor,
mergePaths,
resolveLoginShellPath,
shellInvocation,
} from "./shell-path";
const MARKER_START = "__CLINE_SIDECAR_PATH_START__";
const MARKER_END = "__CLINE_SIDECAR_PATH_END__";
let tempDirs: string[] = [];
/**
* Fake login shell: a /bin/sh script invoked as `fake-shell -i -l -c <cmd>`,
* so the command to run arrives as $4. The default body mimics a login shell
* whose profile prepends Homebrew before running the command.
*/
function writeFakeShell(
script = 'PATH="/opt/homebrew/bin:/usr/bin"; eval "$4"',
name = "fake-shell",
): string {
const dir = mkdtempSync(join(tmpdir(), "cline-shell-path-"));
tempDirs.push(dir);
const shellPath = join(dir, name);
writeFileSync(shellPath, `#!/bin/sh\n${script}\n`);
chmodSync(shellPath, 0o755);
return shellPath;
}
afterEach(() => {
for (const dir of tempDirs) {
rmSync(dir, { recursive: true, force: true });
}
tempDirs = [];
});
describe("extractMarkedPath", () => {
it("extracts the PATH between markers", () => {
expect(
extractMarkedPath(
`${MARKER_START}/opt/homebrew/bin:/usr/bin${MARKER_END}`,
),
).toBe("/opt/homebrew/bin:/usr/bin");
});
it("ignores shell profile noise around the markers", () => {
const output = `Welcome!\nsome banner\n${MARKER_START}/usr/local/bin${MARKER_END}\ntrailing noise`;
expect(extractMarkedPath(output)).toBe("/usr/local/bin");
});
it("returns undefined when markers are missing or empty", () => {
expect(extractMarkedPath("no markers here")).toBeUndefined();
expect(extractMarkedPath(`${MARKER_START}${MARKER_END}`)).toBeUndefined();
expect(extractMarkedPath(`${MARKER_START}/usr/bin`)).toBeUndefined();
});
});
describe("mergePaths", () => {
it("puts shell entries first and keeps current-only entries", () => {
expect(
mergePaths(
"/opt/homebrew/bin:/usr/bin:/bin",
"/usr/bin:/bin:/custom/bin",
),
).toBe("/opt/homebrew/bin:/usr/bin:/bin:/custom/bin");
});
it("drops duplicate and empty entries", () => {
expect(mergePaths("/a::/b:/a", "/b:/c:")).toBe("/a:/b:/c");
});
});
describe("defaultShellFor", () => {
it("uses zsh on macOS and bash elsewhere", () => {
expect(defaultShellFor("darwin")).toBe("/bin/zsh");
expect(defaultShellFor("linux")).toBe("/bin/bash");
});
});
describe("loginShellFor", () => {
it("returns the passwd-database shell when one exists", () => {
// The test runner's uid has a passwd entry, so $SHELL must lose.
const shell = loginShellFor(process.platform, {
SHELL: "/env/should-not-win",
});
expect(shell.startsWith("/")).toBe(true);
expect(shell).not.toBe("/env/should-not-win");
});
});
describe("shellInvocation", () => {
it("uses separate login+interactive flags for posix-style shells", () => {
expect(shellInvocation("/bin/zsh", "cmd")).toEqual({
args: ["-i", "-l", "-c", "cmd"],
});
expect(shellInvocation("/opt/homebrew/bin/fish", "cmd")).toEqual({
args: ["-i", "-l", "-c", "cmd"],
});
});
it("marks csh-family shells as login via argv0 (-l must be their sole flag)", () => {
expect(shellInvocation("/bin/tcsh", "cmd")).toEqual({
args: ["-c", "cmd"],
argv0: "-tcsh",
});
expect(shellInvocation("/bin/csh", "cmd")).toEqual({
args: ["-c", "cmd"],
argv0: "-csh",
});
});
});
describe("resolveLoginShellPath", () => {
it("captures PATH from the shell", async () => {
const shell = writeFakeShell();
await expect(resolveLoginShellPath(shell)).resolves.toBe(
"/opt/homebrew/bin:/usr/bin",
);
});
it("reads PATH from the environment, not the shell's own expansion", async () => {
// Mimics fish: its "$PATH" expansion would space-join the entries,
// but the printf runs inside /bin/sh, which reads the exported
// colon-delimited PATH env var — so the shell's expansion rules
// never apply. This fake shell never evals the command text; it
// only exports PATH and runs the command via sh, like fish would.
const shell = writeFakeShell(
'PATH="/opt/homebrew/bin:/usr/bin"; export PATH; /bin/sh -c "$4"',
);
await expect(resolveLoginShellPath(shell)).resolves.toBe(
"/opt/homebrew/bin:/usr/bin",
);
});
it("resolves undefined when the shell prints garbage", async () => {
const shell = writeFakeShell('echo "no markers"');
await expect(resolveLoginShellPath(shell)).resolves.toBeUndefined();
});
it("resolves undefined when the shell is missing", async () => {
await expect(
resolveLoginShellPath("/nonexistent/shell"),
).resolves.toBeUndefined();
});
it("times out hung shells without rejecting", async () => {
const shell = writeFakeShell("sleep 60");
await expect(resolveLoginShellPath(shell, 200)).resolves.toBeUndefined();
});
it("invokes csh-family shells without login/interactive flags", async () => {
// A csh stand-in that rejects any first flag other than -c.
const shell = writeFakeShell(
'[ "$1" = "-c" ] || exit 64; PATH="/opt/homebrew/bin:/usr/bin"; eval "$2"',
"tcsh",
);
await expect(resolveLoginShellPath(shell)).resolves.toBe(
"/opt/homebrew/bin:/usr/bin",
);
});
});
describe("ensureLoginShellPath", () => {
it("merges the login shell PATH into env.PATH", async () => {
const shell = writeFakeShell();
const env: NodeJS.ProcessEnv = { PATH: "/usr/bin:/bin" };
const result = await ensureLoginShellPath({
platform: "darwin",
env,
userShell: shell,
});
expect(result).toEqual({
status: "applied",
pathEntries: 3,
shell,
});
expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin:/bin");
});
it("falls back to the default shell when $SHELL can't resolve", async () => {
const fallbackShell = writeFakeShell();
const env: NodeJS.ProcessEnv = { PATH: "/usr/bin" };
const result = await ensureLoginShellPath({
platform: "darwin",
env,
userShell: "/nonexistent/shell",
fallbackShell,
});
expect(result.status).toBe("applied");
expect(result).toMatchObject({ shell: fallbackShell });
expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
});
it("leaves PATH untouched when every shell fails", async () => {
const env: NodeJS.ProcessEnv = { PATH: "/usr/bin" };
const result = await ensureLoginShellPath({
platform: "darwin",
env,
userShell: "/nonexistent/shell",
fallbackShell: "/nonexistent/other-shell",
});
expect(result).toEqual({ status: "failed", shell: "/nonexistent/shell" });
expect(env.PATH).toBe("/usr/bin");
});
it("skips on windows", async () => {
const env: NodeJS.ProcessEnv = { PATH: "C:\\Windows" };
const result = await ensureLoginShellPath({ platform: "win32", env });
expect(result).toEqual({ status: "skipped", reason: "windows" });
});
it("skips when the escape hatch is set", async () => {
const env: NodeJS.ProcessEnv = {
PATH: "/usr/bin",
CLINE_SIDECAR_SKIP_SHELL_PATH: "1",
};
const result = await ensureLoginShellPath({ platform: "darwin", env });
expect(result.status).toBe("skipped");
expect(env.PATH).toBe("/usr/bin");
});
it("never exposes the resolved PATH in its result", async () => {
const shell = writeFakeShell();
const env: NodeJS.ProcessEnv = { PATH: "/usr/bin" };
const result = await ensureLoginShellPath({
platform: "darwin",
env,
userShell: shell,
});
expect(JSON.stringify(result)).not.toContain("/opt/homebrew/bin");
});
it("resolves against a real shell end to end", async () => {
const env: NodeJS.ProcessEnv = { PATH: "/bin" };
const result = await ensureLoginShellPath({
platform: "linux",
env,
userShell: "/bin/sh",
});
expect(result.status).toBe("applied");
expect(env.PATH).toContain("/bin");
});
});
@@ -0,0 +1,239 @@
/**
* Login-shell PATH resolution for the desktop sidecar.
*
* When the Tauri app is launched from Finder/the Dock on macOS, it inherits
* launchd's minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin) instead of the
* user's shell PATH. The sidecar and every process it spawns for the agent
* (bash tool, MCP servers) then can't find tools like `gh` that live in
* /opt/homebrew/bin or other shell-profile-added directories, even though
* the same task works from the CLI in a terminal.
*
* At startup we ask the user's login shell for its PATH and merge it into
* process.env.PATH, so child processes see the same PATH a terminal would.
*/
import { spawn } from "node:child_process";
import { userInfo } from "node:os";
import { basename, delimiter } from "node:path";
const PATH_MARKER_START = "__CLINE_SIDECAR_PATH_START__";
const PATH_MARKER_END = "__CLINE_SIDECAR_PATH_END__";
/**
* Kept well under the Tauri shell's 5s endpoint-readiness poll: this
* resolution overlaps sidecar startup but is awaited before the server
* starts, so a pathological shell profile must not eat the whole window.
*/
const SHELL_TIMEOUT_MS = 2_000;
/**
* The command every shell is asked to run. $PATH expansion happens inside
* POSIX sh not the user's shell so shells with different expansion rules
* (fish would space-join "$PATH") still produce a colon-delimited value; sh
* reads the PATH environment variable the login shell exported.
*/
const PRINT_PATH_COMMAND = `/bin/sh -c 'printf "%s%s%s" "${PATH_MARKER_START}" "$PATH" "${PATH_MARKER_END}"'`;
/**
* Escape hatch: set CLINE_SIDECAR_SKIP_SHELL_PATH=1 to leave PATH untouched
* (e.g. if a broken shell profile makes resolution misbehave).
*/
const SKIP_ENV_VAR = "CLINE_SIDECAR_SKIP_SHELL_PATH";
export function defaultShellFor(platform: NodeJS.Platform): string {
return platform === "darwin" ? "/bin/zsh" : "/bin/bash";
}
/**
* The user's configured login shell. The account database is authoritative:
* a GUI-launched process has no parent shell, so $SHELL may be unset there.
* userInfo() reads getpwuid(), which on macOS goes through DirectoryServices
* the same source `dscl . -read /Users/$USER UserShell` reports and on
* Linux resolves via NSS (/etc/passwd et al.). $SHELL and the platform
* default are fallbacks for environments with no passwd entry.
*/
export function loginShellFor(
platform: NodeJS.Platform,
env: NodeJS.ProcessEnv,
): string {
try {
const shell = userInfo().shell?.trim();
if (shell) {
return shell;
}
} catch {
// No passwd entry for the current uid (some containers) — fall through.
}
return env.SHELL?.trim() || defaultShellFor(platform);
}
export interface ShellInvocation {
args: string[];
/**
* argv[0] the shell should see. A leading dash is the historical "you
* are a login shell" signal, used where -l can't be passed as a flag.
*/
argv0?: string;
}
/**
* How to invoke a shell so it sources its profiles and runs a command.
* csh/tcsh accept -l only as the sole flag, so they're marked login via the
* argv[0] dash convention instead (sources ~/.login on top of the always-read
* ~/.cshrc or ~/.tcshrc); everything else gets login (-l, ~/.zprofile
* Homebrew's shellenv) plus interactive (-i, ~/.zshrc nvm-style version
* managers) as separate flags.
*/
export function shellInvocation(
shell: string,
command: string,
): ShellInvocation {
const kind = basename(shell);
if (kind === "csh" || kind === "tcsh") {
return { args: ["-c", command], argv0: `-${kind}` };
}
return { args: ["-i", "-l", "-c", command] };
}
/**
* Extract the PATH value printed between the sentinel markers, ignoring any
* noise a shell profile writes to stdout around it.
*/
export function extractMarkedPath(output: string): string | undefined {
const start = output.indexOf(PATH_MARKER_START);
if (start === -1) {
return undefined;
}
const end = output.indexOf(PATH_MARKER_END, start);
if (end === -1) {
return undefined;
}
const value = output.slice(start + PATH_MARKER_START.length, end).trim();
return value.length > 0 ? value : undefined;
}
/**
* Merge the login shell's PATH with the current one: shell entries first (so
* profile-managed dirs like /opt/homebrew/bin win), then any current entries
* the shell PATH doesn't already contain (so explicitly-injected dirs from
* the launching environment aren't lost). Duplicates are dropped.
*/
export function mergePaths(shellPath: string, currentPath: string): string {
const entries = [
...shellPath.split(delimiter),
...currentPath.split(delimiter),
]
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
return Array.from(new Set(entries)).join(delimiter);
}
/**
* Run the user's shell with its profiles sourced and capture its PATH.
* Resolves to undefined on any failure (missing shell, timeout, profile
* error) callers should treat that as "keep the current PATH".
*/
export function resolveLoginShellPath(
shell: string,
timeoutMs = SHELL_TIMEOUT_MS,
): Promise<string | undefined> {
return new Promise((resolve) => {
const invocation = shellInvocation(shell, PRINT_PATH_COMMAND);
const child = spawn(shell, invocation.args, {
argv0: invocation.argv0,
stdio: ["ignore", "pipe", "ignore"],
detached: true,
});
let output = "";
let settled = false;
const settle = (value: string | undefined) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
resolve(value);
};
const timeout = setTimeout(() => {
try {
if (child.pid) {
process.kill(-child.pid, "SIGKILL");
}
} catch {
child.kill("SIGKILL");
}
settle(undefined);
}, timeoutMs);
child.stdout?.on("data", (data: Buffer) => {
output += data.toString("utf8");
});
child.on("error", () => settle(undefined));
child.on("close", () => settle(extractMarkedPath(output)));
});
}
/**
* Resolve the login shell's PATH and merge it into process.env.PATH. The
* shell comes from the account database (see loginShellFor); if it can't
* produce a PATH (exotic shell, broken profile), retry once with the
* platform default shell before giving up.
*
* No-op on Windows (the GUI PATH comes from the registry there) and when
* CLINE_SIDECAR_SKIP_SHELL_PATH is set. Failures are reported via the
* returned status but never block startup. The result never contains the
* resolved PATH itself so it is safe to log verbatim.
*/
export async function ensureLoginShellPath(options?: {
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
timeoutMs?: number;
/** Test seam: overrides passwd/$SHELL discovery of the user's shell. */
userShell?: string;
/** Test seam: overrides the platform-default fallback shell. */
fallbackShell?: string;
}): Promise<
| { status: "applied"; pathEntries: number; shell: string }
| { status: "skipped"; reason: string }
| { status: "failed"; shell: string }
> {
const platform = options?.platform ?? process.platform;
const env = options?.env ?? process.env;
if (platform === "win32") {
return { status: "skipped", reason: "windows" };
}
if (env[SKIP_ENV_VAR]?.trim()) {
return { status: "skipped", reason: SKIP_ENV_VAR };
}
const userShell = options?.userShell ?? loginShellFor(platform, env);
const fallbackShell = options?.fallbackShell ?? defaultShellFor(platform);
const baseTimeoutMs = options?.timeoutMs ?? SHELL_TIMEOUT_MS;
// The fallback gets half the budget so the combined worst case stays
// bounded even when both shells hang (see SHELL_TIMEOUT_MS).
const attempts: Array<[shell: string, timeoutMs: number]> =
userShell === fallbackShell
? [[userShell, baseTimeoutMs]]
: [
[userShell, baseTimeoutMs],
[fallbackShell, baseTimeoutMs / 2],
];
for (const [shell, timeoutMs] of attempts) {
const shellPath = await resolveLoginShellPath(shell, timeoutMs);
if (!shellPath) {
continue;
}
const merged = mergePaths(shellPath, env.PATH ?? "");
env.PATH = merged;
return {
status: "applied",
pathEntries: merged.split(delimiter).length,
shell,
};
}
return { status: "failed", shell: userShell };
}
+10 -2
View File
@@ -1,7 +1,8 @@
import type {
AgentToolContext,
BasicLogger,
ClineCore,
HubServer,
ITelemetryService,
NodeHubClient,
ToolApprovalResult,
} from "@cline/core";
@@ -41,6 +42,7 @@ export type PromptInQueue = {
prompt: string;
steer: boolean;
attachmentCount?: number;
userImages?: string[];
};
export type LiveSession = {
@@ -51,9 +53,14 @@ export type LiveSession = {
startedAt: number;
endedAt?: number;
status: string;
transitioningProvider?: boolean;
prompt?: string;
title?: string;
attachedViaHub?: boolean;
/** Materialized attachment files for prompts still waiting in the queue. */
queuedAttachmentFiles?: Map<string, string[]>;
/** Materialized attachment files whose prompt was submitted; deleted when the turn ends. */
consumedAttachmentFiles?: Map<string, string[]>;
};
export type ToolApprovalRequestItem = {
@@ -104,8 +111,9 @@ export type SidecarContext = {
pendingQuestions: Map<string, PendingAskQuestion>;
sessionManager: ClineCore | null;
hubClient: NodeHubClient | null;
hubServer: HubServer | null;
workspaceRoot: string;
logger?: BasicLogger;
telemetry?: ITelemetryService;
unsubscribeSessionEvents: (() => void) | null;
};
export type BunRuntimeApi = {
+363
View File
@@ -47,6 +47,15 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "ashpd"
version = "0.11.1"
@@ -504,11 +513,16 @@ dependencies = [
name = "cline-app"
version = "0.1.0"
dependencies = [
"objc2",
"objc2-app-kit",
"objc2-foundation",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-updater",
"tokio",
]
[[package]]
@@ -718,6 +732,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "derive_more"
version = "2.1.1"
@@ -1017,6 +1042,16 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -1579,6 +1614,21 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -2016,6 +2066,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2143,9 +2199,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.11.0",
"block2",
"libc",
"objc2",
"objc2-cloud-kit",
"objc2-core-data",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-image",
"objc2-core-text",
"objc2-core-video",
"objc2-foundation",
"objc2-quartz-core",
]
[[package]]
@@ -2165,6 +2229,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
dependencies = [
"bitflags 2.11.0",
"objc2",
"objc2-foundation",
]
@@ -2225,6 +2290,19 @@ dependencies = [
"objc2-core-graphics",
]
[[package]]
name = "objc2-core-video"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
dependencies = [
"bitflags 2.11.0",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-io-surface",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
@@ -2248,6 +2326,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.11.0",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
@@ -2263,6 +2342,18 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.11.0",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
@@ -2326,6 +2417,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -2342,6 +2439,20 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -2796,15 +2907,20 @@ dependencies = [
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -2840,6 +2956,20 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rustc-hash"
version = "2.1.2"
@@ -2868,6 +2998,79 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
dependencies = [
"core-foundation",
"core-foundation-sys",
"jni",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -2883,6 +3086,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "schemars"
version = "0.8.22"
@@ -2946,6 +3158,29 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.11.0",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "selectors"
version = "0.36.1"
@@ -3277,6 +3512,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.7"
@@ -3393,6 +3634,17 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -3512,6 +3764,55 @@ dependencies = [
"tauri-utils",
]
[[package]]
name = "tauri-plugin"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eefb2c18e8a605c23edb48fc56bb77381199e1a1e7f6ff0c9b970afe7b3cb8ee"
dependencies = [
"anyhow",
"glob",
"plist",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri-utils",
"walkdir",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
dependencies = [
"base64 0.22.1",
"dirs",
"flate2",
"futures-util",
"http",
"infer",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest",
"rustls",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.18",
"time",
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
]
[[package]]
name = "tauri-runtime"
version = "2.11.1"
@@ -3730,6 +4031,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
@@ -4025,6 +4336,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@@ -4387,6 +4704,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@@ -4617,6 +4943,15 @@ dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
@@ -5035,6 +5370,16 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "yoke"
version = "0.8.1"
@@ -5160,6 +5505,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]]
name = "zerotrie"
version = "0.2.3"
@@ -5193,6 +5544,18 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.13.0",
"memchr",
]
[[package]]
name = "zmij"
version = "1.0.21"
@@ -10,8 +10,15 @@ tauri-build = { version = "2.0.0", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "2.11.1", features = [] }
tauri-plugin-updater = "2"
tokio = { version = "1", features = ["time"] }
rfd = "0.15"
[target."cfg(target_os = \"macos\")".dependencies]
objc2 = "0.6"
objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSImage", "NSResponder"] }
objc2-foundation = { version = "0.3", features = ["NSString"] }
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+387 -48
View File
@@ -9,6 +9,10 @@ use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use tauri::{Manager, RunEvent, State};
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);
#[derive(Clone)]
struct AppContext {
@@ -16,6 +20,111 @@ struct AppContext {
workspace_root: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct UpdateStatus {
state: String,
version: Option<String>,
error: Option<String>,
}
impl Default for UpdateStatus {
fn default() -> Self {
Self {
state: "idle".to_string(),
version: None,
error: None,
}
}
}
#[derive(Default)]
struct UpdateState {
status: Mutex<UpdateStatus>,
}
impl UpdateState {
fn set(&self, state: &str, version: Option<String>, error: Option<String>) {
if let Ok(mut guard) = self.status.lock() {
*guard = UpdateStatus {
state: state.to_string(),
version,
error,
};
}
}
fn snapshot(&self) -> UpdateStatus {
self.status
.lock()
.map(|guard| guard.clone())
.unwrap_or_default()
}
fn ready_version(&self) -> Option<String> {
self.status.lock().ok().and_then(|guard| {
if guard.state == "ready" {
guard.version.clone()
} else {
None
}
})
}
}
async fn check_and_install_update(app: &tauri::AppHandle, state: &UpdateState) {
// An update that already finished downloading only needs a restart; keep
// reporting "ready" instead of flipping back to transient states unless a
// newer version shows up.
let ready_version = state.ready_version();
if ready_version.is_none() {
state.set("checking", None, None);
}
let updater = match app.updater() {
Ok(updater) => updater,
Err(error) => {
state.set("error", None, Some(error.to_string()));
return;
}
};
match updater.check().await {
Ok(Some(update)) => {
let version = update.version.clone();
if ready_version.as_deref() == Some(version.as_str()) {
return;
}
state.set("downloading", Some(version.clone()), None);
match update.download_and_install(|_, _| {}, || {}).await {
Ok(()) => state.set("ready", Some(version), None),
Err(error) => state.set("error", Some(version), Some(error.to_string())),
}
}
Ok(None) => {
if ready_version.is_none() {
state.set("idle", None, None);
}
}
Err(error) => {
if ready_version.is_none() {
state.set("error", None, Some(error.to_string()));
}
}
}
}
async fn run_update_loop(app: tauri::AppHandle, state: Arc<UpdateState>) {
tokio::time::sleep(UPDATE_INITIAL_DELAY).await;
loop {
check_and_install_update(&app, &state).await;
tokio::time::sleep(UPDATE_CHECK_INTERVAL).await;
}
}
/// Lock order: `process` may be held while acquiring `ws_endpoint`, never
/// the reverse. Anything that touches both (including stop()) must either
/// nest in that order or take them strictly sequentially.
#[derive(Default)]
struct DesktopBackendState {
ws_endpoint: Mutex<Option<String>>,
@@ -44,7 +153,11 @@ impl DesktopBackendState {
if let Ok(mut process_guard) = self.process.lock() {
if let Some(child) = process_guard.as_mut() {
for _ in 0..30 {
// The sidecar bounds its own graceful shutdown with
// SHUTDOWN_TIMEOUT_MS (5s in sidecar/index.ts) and then exits
// itself; wait past that window before escalating to kill so
// an active session can finish persisting.
for _ in 0..70 {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => thread::sleep(Duration::from_millis(100)),
@@ -236,43 +349,7 @@ fn resolve_desktop_backend_binary_path(context: &AppContext) -> Option<PathBuf>
candidates.into_iter().find(|path| path.exists())
}
fn ensure_desktop_backend_started(
state: &Arc<DesktopBackendState>,
context: &AppContext,
) -> Result<(), String> {
if state.is_shutting_down() {
return Ok(());
}
{
let mut process_guard = state
.process
.lock()
.map_err(|_| "failed to lock desktop backend process state")?;
if let Some(existing) = process_guard.as_mut() {
match existing.try_wait() {
Ok(None) => {
if state
.ws_endpoint
.lock()
.ok()
.and_then(|value| value.as_ref().cloned())
.map(|value| !value.trim().is_empty())
.unwrap_or(false)
{
return Ok(());
}
}
Ok(Some(_)) | Err(_) => {
*process_guard = None;
if let Ok(mut endpoint_guard) = state.ws_endpoint.lock() {
*endpoint_guard = None;
}
}
}
}
}
fn spawn_desktop_backend_process(context: &AppContext) -> Result<Child, String> {
let mut command = if let Some(binary_path) = resolve_desktop_backend_binary_path(context) {
let mut command = Command::new(binary_path);
command.current_dir(&context.workspace_root);
@@ -291,12 +368,54 @@ fn ensure_desktop_backend_started(
));
};
let mut child = command
command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("failed to start desktop backend sidecar: {e}"))?;
.map_err(|e| format!("failed to start desktop backend sidecar: {e}"))
}
fn ensure_desktop_backend_started(
state: &Arc<DesktopBackendState>,
context: &AppContext,
) -> Result<(), String> {
ensure_desktop_backend_started_with(state, || spawn_desktop_backend_process(context))
}
fn ensure_desktop_backend_started_with(
state: &Arc<DesktopBackendState>,
spawn_backend: impl FnOnce() -> Result<Child, String>,
) -> Result<(), String> {
if state.is_shutting_down() {
return Ok(());
}
// Hold the process lock for the entire check-and-spawn so concurrent
// callers (setup, the health-check loop, endpoint fetches from the
// webview) serialize: the second caller blocks here, then sees the live
// child and returns instead of spawning a duplicate.
let mut process_guard = state
.process
.lock()
.map_err(|_| "failed to lock desktop backend process state")?;
if let Some(existing) = process_guard.as_mut() {
match existing.try_wait() {
// A live child owns startup even while its endpoint is still
// pending (login-shell PATH resolution plus session-manager init
// take a few seconds). Spawning again here would orphan it and
// race on the port.
Ok(None) => return Ok(()),
Ok(Some(_)) | Err(_) => {
*process_guard = None;
if let Ok(mut endpoint_guard) = state.ws_endpoint.lock() {
*endpoint_guard = None;
}
}
}
}
let mut child = spawn_backend()?;
let stdout = child
.stdout
@@ -307,6 +426,7 @@ fn ensure_desktop_backend_started(
.take()
.ok_or_else(|| "failed to capture desktop backend stderr".to_string())?;
let child_pid = child.id();
let state_for_stdout = state.clone();
thread::spawn(move || {
let mut reader = BufReader::new(stdout);
@@ -335,8 +455,19 @@ fn ensure_desktop_backend_started(
}
eprintln!("[desktop-backend] {trimmed}");
}
if let Ok(mut endpoint_guard) = state_for_stdout.ws_endpoint.lock() {
*endpoint_guard = None;
// Only clear the endpoint if this thread's child is still the one
// being tracked — a late EOF from a replaced child must not wipe the
// endpoint its successor already published.
let owns_tracked_child = state_for_stdout
.process
.lock()
.ok()
.map(|guard| guard.as_ref().map(|child| child.id()) == Some(child_pid))
.unwrap_or(false);
if owns_tracked_child {
if let Ok(mut endpoint_guard) = state_for_stdout.ws_endpoint.lock() {
*endpoint_guard = None;
}
}
});
@@ -352,10 +483,6 @@ fn ensure_desktop_backend_started(
}
});
let mut process_guard = state
.process
.lock()
.map_err(|_| "failed to lock desktop backend process state")?;
*process_guard = Some(child);
Ok(())
}
@@ -423,7 +550,13 @@ fn get_desktop_backend_endpoint(
context: State<'_, AppContext>,
) -> Result<String, String> {
ensure_desktop_backend_started(backend_state.inner(), context.inner())?;
for _ in 0..50 {
// Sidecar startup includes login-shell PATH resolution (bounded at 3s,
// see sidecar/shell-path.ts) plus session-manager init, whose duration
// varies by machine. Poll well past that combined worst case; the loop
// returns as soon as the ready line arrives, so only failure waits long.
// While pending this only waits — respawning is ensure's job, and it
// refuses to start a second sidecar while the first one is still alive.
for _ in 0..150 {
if let Some(endpoint) = backend_state
.ws_endpoint
.lock()
@@ -433,6 +566,18 @@ fn get_desktop_backend_endpoint(
{
return Ok(endpoint);
}
let child_exited = backend_state
.process
.lock()
.ok()
.map(|mut guard| match guard.as_mut() {
Some(child) => !matches!(child.try_wait(), Ok(None)),
None => true,
})
.unwrap_or(false);
if child_exited {
return Err("desktop backend exited before publishing its endpoint".to_string());
}
thread::sleep(Duration::from_millis(100));
}
Err("desktop backend endpoint not ready".to_string())
@@ -452,6 +597,79 @@ fn pick_workspace_directory(initial_path: Option<String>) -> Option<String> {
.map(|path| path.to_string_lossy().to_string())
}
#[tauri::command]
fn get_update_status(update_state: State<'_, Arc<UpdateState>>) -> UpdateStatus {
update_state.snapshot()
}
#[tauri::command]
fn restart_to_apply_update(
app: tauri::AppHandle,
backend_state: State<'_, Arc<DesktopBackendState>>,
) {
// restart() never returns, so the run-loop Exit handler does not get a
// chance to stop the sidecar; shut it down explicitly first.
backend_state.stop();
app.restart();
}
/// Icon ids accepted by `set_app_icon`; kept in sync with APP_ICONS in
/// webview/lib/app-icon.ts. Every non-default id has a matching bundled
/// resource at icons/dock/<id>.png.
const APP_DOCK_ICONS: [&str; 4] = ["classic", "sunrise", "steel", "midnight"];
#[tauri::command]
fn set_app_icon(app: tauri::AppHandle, icon: String) -> Result<bool, String> {
if !APP_DOCK_ICONS.contains(&icon.as_str()) {
return Err(format!("unknown app icon: {icon}"));
}
#[cfg(target_os = "macos")]
{
// "classic" also ships as a dock resource, so every choice loads the
// same way; setApplicationIconImage's binding warns that passing nil
// to restore the bundled icon may not be allowed.
let icon_path = app
.path()
.resolve(
format!("icons/dock/{icon}.png"),
tauri::path::BaseDirectory::Resource,
)
.map_err(|e| format!("failed resolving dock icon resource: {e}"))?;
if !icon_path.exists() {
return Err(format!(
"dock icon resource missing: {}",
icon_path.display()
));
}
app.run_on_main_thread(move || {
use objc2::{AllocAnyThread, MainThreadMarker};
use objc2_app_kit::{NSApplication, NSImage};
use objc2_foundation::NSString;
let Some(mtm) = MainThreadMarker::new() else {
return;
};
let ns_app = NSApplication::sharedApplication(mtm);
let Some(image) = NSImage::initWithContentsOfFile(
NSImage::alloc(),
&NSString::from_str(&icon_path.to_string_lossy()),
) else {
eprintln!("[dock-icon] failed loading image: {}", icon_path.display());
return;
};
// SAFETY: called on the main thread with a valid, non-nil image.
unsafe { ns_app.setApplicationIconImage(Some(&image)) };
})
.map_err(|e| format!("failed switching dock icon: {e}"))?;
Ok(true)
}
#[cfg(not(target_os = "macos"))]
{
let _ = app;
Ok(false)
}
}
#[tauri::command]
fn open_mcp_settings_file() -> Result<String, String> {
let settings_path = resolve_mcp_settings_path()?;
@@ -485,14 +703,25 @@ fn main() {
};
tauri::Builder::default()
.plugin(tauri_plugin_updater::Builder::new().build())
.manage(desktop_backend)
.manage(app_context)
.manage(Arc::new(UpdateState::default()))
.setup(|app| {
let app_context = app.state::<AppContext>().inner().clone();
let backend_state = app.state::<Arc<DesktopBackendState>>().inner().clone();
if let Err(error) = ensure_desktop_backend_started(&backend_state, &app_context) {
eprintln!("[desktop-backend] startup failed: {error}");
}
// Dev builds are not installed app bundles, so there is nothing the
// updater could meaningfully check or replace.
if !cfg!(debug_assertions) {
let update_state = app.state::<Arc<UpdateState>>().inner().clone();
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
run_update_loop(app_handle, update_state).await;
});
}
thread::spawn(move || loop {
thread::sleep(Duration::from_secs(5));
if backend_state.is_shutting_down() {
@@ -507,7 +736,10 @@ fn main() {
.invoke_handler(tauri::generate_handler![
get_desktop_backend_endpoint,
pick_workspace_directory,
open_mcp_settings_file
open_mcp_settings_file,
get_update_status,
restart_to_apply_update,
set_app_icon
])
.build(tauri::generate_context!())
.expect("error while building tauri app")
@@ -521,3 +753,110 @@ fn main() {
_ => {}
});
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
/// A stand-in sidecar that stays alive without ever publishing a ready
/// line — the endpoint-pending startup window that used to trigger
/// duplicate spawns.
fn spawn_pending_sidecar() -> Result<Child, String> {
Command::new("sleep")
.arg("30")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| e.to_string())
}
#[test]
fn repeated_startup_checks_reuse_live_child_while_endpoint_pending() {
let state = Arc::new(DesktopBackendState::default());
let spawn_count = AtomicUsize::new(0);
for _ in 0..3 {
ensure_desktop_backend_started_with(&state, || {
spawn_count.fetch_add(1, Ordering::SeqCst);
spawn_pending_sidecar()
})
.expect("startup check should succeed");
}
assert_eq!(spawn_count.load(Ordering::SeqCst), 1);
// Kill the fake sidecar directly so stop() doesn't wait out its
// graceful-exit window.
if let Ok(mut guard) = state.process.lock() {
if let Some(child) = guard.as_mut() {
let _ = child.kill();
}
}
state.stop();
}
#[test]
fn concurrent_startup_checks_spawn_exactly_one_child() {
let state = Arc::new(DesktopBackendState::default());
let spawn_count = Arc::new(AtomicUsize::new(0));
let handles: Vec<_> = (0..8)
.map(|_| {
let state = state.clone();
let spawn_count = spawn_count.clone();
thread::spawn(move || {
ensure_desktop_backend_started_with(&state, || {
spawn_count.fetch_add(1, Ordering::SeqCst);
spawn_pending_sidecar()
})
.expect("startup check should succeed");
})
})
.collect();
for handle in handles {
handle.join().expect("startup thread should not panic");
}
assert_eq!(spawn_count.load(Ordering::SeqCst), 1);
if let Ok(mut guard) = state.process.lock() {
if let Some(child) = guard.as_mut() {
let _ = child.kill();
}
}
state.stop();
}
#[test]
fn exited_child_is_replaced_on_next_startup_check() {
let state = Arc::new(DesktopBackendState::default());
let spawn_count = AtomicUsize::new(0);
let spawn_exiting = || {
spawn_count.fetch_add(1, Ordering::SeqCst);
Command::new("true")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| e.to_string())
};
ensure_desktop_backend_started_with(&state, || spawn_exiting())
.expect("startup check should succeed");
// Wait for the first child to exit so the next check sees a dead one.
for _ in 0..100 {
let exited = state
.process
.lock()
.ok()
.map(|mut guard| match guard.as_mut() {
Some(child) => !matches!(child.try_wait(), Ok(None)),
None => true,
})
.unwrap_or(false);
if exited {
break;
}
thread::sleep(Duration::from_millis(10));
}
ensure_desktop_backend_started_with(&state, || spawn_exiting())
.expect("startup check should succeed");
assert_eq!(spawn_count.load(Ordering::SeqCst), 2);
state.stop();
}
}
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cline Code",
"version": "0.0.1",
"version": "0.0.4",
"identifier": "bot.cline.app",
"build": {
"beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web",
@@ -9,6 +9,14 @@
"beforeBuildCommand": "bun run build",
"frontendDist": "../webview/out"
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IENEMTJDNzk2RUExQUY3RDEKUldUUjl4cnFsc2NTelYxNzlFR1NkWnI0VTM1V0hvQXRyOW0xV2c0bFhkL3dhdkdpNGhNRW1MQXEK",
"endpoints": [
"https://github.com/cline/cline/releases/download/desktop-latest/latest.json"
]
}
},
"app": {
"windows": [
{
@@ -16,7 +24,10 @@
"title": "Cline Code",
"width": 1500,
"height": 980,
"resizable": true
"resizable": true,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"dragDropEnabled": false
}
],
"security": {
@@ -27,6 +38,7 @@
"active": true,
"targets": "all",
"externalBin": ["bin/code-sidecar"],
"resources": ["icons/dock/*.png"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
@@ -0,0 +1,6 @@
{
"$schema": "https://schema.tauri.app/config/2",
"bundle": {
"createUpdaterArtifacts": true
}
}
@@ -6,6 +6,9 @@
"@cline/agents": ["../../../sdk/packages/agents/src/index.ts"],
"@cline/core": ["../../../sdk/packages/core/src/index.ts"],
"@cline/core/hub": ["../../../sdk/packages/core/src/hub/index.ts"],
"@cline/core/hub/daemon-entry": [
"../../../sdk/packages/core/src/hub/daemon/entry.ts"
],
"@cline/llms": ["../../../sdk/packages/llms/src/index.ts"],
"@cline/shared": ["../../../sdk/packages/shared/src/index.ts"],
"@cline/shared/storage": [
+3
View File
@@ -23,6 +23,9 @@
"@cline/agents": ["../../../sdk/packages/agents/src/index.ts"],
"@cline/core": ["../../../sdk/packages/core/src/index.ts"],
"@cline/core/hub": ["../../../sdk/packages/core/src/hub/index.ts"],
"@cline/core/hub/daemon-entry": [
"../../../sdk/packages/core/src/hub/daemon/entry.ts"
],
"@cline/llms": ["../../../sdk/packages/llms/src/index.ts"],
"@cline/shared": ["../../../sdk/packages/shared/src/index.ts"],
"@cline/shared/storage": [
@@ -20,6 +20,208 @@
}
}
/* Hero heading cycling verb (components/views/chat/welcome-chat.tsx) */
@keyframes hero-word-in {
0% {
opacity: 0;
transform: translateY(0.42em);
filter: blur(6px);
}
60% {
filter: blur(0);
}
100% {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}
}
.hero-word-char {
display: inline-block;
white-space: pre;
/* Solid fallback so the word is never invisible if text clipping is unsupported. */
color: var(--brand-violet);
animation: hero-word-in 0.5s cubic-bezier(0.2, 0.65, 0.3, 1) both;
}
/*
* Gradient fill per character. The clip lives on each animated span (not a
* shared parent) because WebKit used by the Tauri webview on macOS drops
* the parent's background when a child paints on its own transform/filter
* layer, which would leave the animating letters blank. The -webkit- prefixes
* are required by WebKit; @supports keeps the solid fallback above otherwise.
*/
@supports ((-webkit-background-clip: text) or (background-clip: text)) {
.hero-word-char {
background-image: linear-gradient(
135deg,
var(--brand-periwinkle),
var(--brand-violet) 55%,
var(--brand-magenta)
);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
}
}
@media (prefers-reduced-motion: reduce) {
.hero-word-char {
/* biome-ignore lint/complexity/noImportantStyles: reduced motion must override inline animation delay */
animation: none !important;
}
}
/*
* Chat Markdown (components/ui/markdown.tsx). Streamdown ships its defaults
* as Tailwind utilities, which land in the layered @source output; these
* unlayered rules win the cascade without needing !important.
*/
/* Chat panes are narrow and the base text is --text-sm: tighten the rhythm
* between top-level blocks from Streamdown's document-scale 1rem. */
.cline-markdown > :not(:last-child) {
margin-block-end: 0.75rem;
}
/* Chat-scale heading ramp (defaults go up to text-3xl, huge next to 14px body). */
.cline-markdown :is(h1, h2, h3, h4, h5, h6) {
margin-block: 1.25rem 0.5rem;
line-height: 1.35;
}
.cline-markdown h1 {
font-size: 1.25rem;
}
.cline-markdown h2 {
font-size: 1.125rem;
}
.cline-markdown h3 {
font-size: 1rem;
}
.cline-markdown :is(h4, h5, h6) {
font-size: 0.875rem;
}
.cline-markdown hr {
margin-block: 1rem;
}
/* Outside markers so wrapped list lines align with their first line, not the
* bullet (Streamdown uses list-inside). */
.cline-markdown :is(ul, ol) {
list-style-position: outside;
padding-inline-start: 1.375rem;
}
.cline-markdown li {
padding-block: 0.125rem;
}
.cline-markdown li::marker {
color: var(--muted-foreground);
}
.cline-markdown [data-streamdown="inline-code"] {
padding: 0.1em 0.35em;
font-size: 0.8125rem;
}
/*
* Code blocks: Streamdown renders a double box an outer sidebar-tinted card
* holding a language header row plus an inner bordered body. Collapse it to a
* single quiet block: no outer chrome, no language label, and the copy button
* only appears while hovering the block.
*/
.cline-markdown [data-streamdown="code-block"] {
padding: 0;
gap: 0;
border: none;
background: transparent;
}
.cline-markdown [data-streamdown="code-block-header"] {
display: none;
}
.cline-markdown [data-streamdown="code-block-body"] {
border-radius: 0.5rem;
background: var(--card);
padding: 0.625rem 0.75rem;
font-size: 0.8125rem;
line-height: 1.6;
}
/* The body's <pre> paints the Shiki theme background, which never quite
* matches the app theme; let the body's --card background show instead. */
.cline-markdown [data-streamdown="code-block-body"] pre {
background: transparent;
}
/* The actions overlay's -mt-10 was sized to cover the (now hidden) header
* row; re-anchor it over the top-right of the code body and reveal on
* hover/focus so it stops competing with the code. Stickiness is kept so the
* copy button stays reachable inside long blocks. */
.cline-markdown [data-streamdown="code-block"]
> div:has(> [data-streamdown="code-block-actions"]) {
margin-block: 0 -2rem;
padding: 0.375rem 0.375rem 0 0;
opacity: 0;
transition: opacity 120ms ease;
}
/* While hidden the pill must not intercept clicks or text selection on the
* code beneath it (it ships with pointer-events-auto). Keyboard focus is
* unaffected by pointer-events, so tabbing still reveals it. */
.cline-markdown [data-streamdown="code-block-actions"] {
pointer-events: none;
}
.cline-markdown
[data-streamdown="code-block"]:hover
> div:has(> [data-streamdown="code-block-actions"]),
.cline-markdown
[data-streamdown="code-block"]
> div:focus-within:has(> [data-streamdown="code-block-actions"]) {
opacity: 1;
}
.cline-markdown
[data-streamdown="code-block"]:hover
[data-streamdown="code-block-actions"],
.cline-markdown
[data-streamdown="code-block"]
> div:focus-within
[data-streamdown="code-block-actions"] {
pointer-events: auto;
}
@media (hover: none) {
.cline-markdown
[data-streamdown="code-block"]
> div:has(> [data-streamdown="code-block-actions"]) {
opacity: 1;
}
.cline-markdown [data-streamdown="code-block-actions"] {
pointer-events: auto;
}
}
/* Tables get the same single-box treatment as code blocks. */
.cline-markdown [data-streamdown="table-wrapper"] {
padding: 0;
gap: 0;
overflow: hidden;
border-radius: 0.5rem;
background: var(--card);
}
/* Aurora background (components/ui/aurora-bg.tsx) */
@keyframes aurora-drift {
0% {
@@ -160,3 +362,84 @@
will-change: auto;
}
}
/*
* Accent palettes (Settings -> General -> Accent color). The default
* "violet" accent is the @cline/ui brand tokens untouched; each block below
* re-anchors the interactive tokens on the html[data-cline-accent] attribute
* set by lib/theme.ts. Values follow the brand token pattern: emphasis is a
* pressed/hover shade of primary, ring is a softer focus shade, and dark
* mode lifts lightness so the accent stays legible on dark surfaces.
* Tokens like --chart-1 and --sidebar-primary alias var(--primary), so they
* follow automatically.
*/
:root[data-cline-accent="graphite"] {
--primary: oklch(0.27 0.012 248);
--primary-foreground: oklch(0.99 0 0);
--primary-emphasis: oklch(0.2 0.012 248);
--ring: oklch(0.55 0.012 248);
}
:root.dark[data-cline-accent="graphite"] {
--primary: oklch(0.92 0.006 248);
--primary-foreground: oklch(0.18 0.01 248);
--primary-emphasis: oklch(0.97 0.004 248);
--ring: oklch(0.7 0.008 248);
}
:root[data-cline-accent="cyan"] {
--primary: oklch(0.6 0.12 222);
--primary-foreground: oklch(0.99 0 0);
--primary-emphasis: oklch(0.52 0.13 222);
--ring: oklch(0.66 0.1 222);
}
:root.dark[data-cline-accent="cyan"] {
--primary: oklch(0.7 0.12 222);
--primary-foreground: oklch(0.16 0.02 222);
--primary-emphasis: oklch(0.77 0.11 222);
--ring: oklch(0.7 0.1 222);
}
:root[data-cline-accent="pink"] {
--primary: oklch(0.75 0.1 354);
--primary-foreground: oklch(0.28 0.08 354);
--primary-emphasis: oklch(0.68 0.12 354);
--ring: oklch(0.79 0.08 354);
}
:root.dark[data-cline-accent="pink"] {
--primary: oklch(0.78 0.1 354);
--primary-foreground: oklch(0.25 0.07 354);
--primary-emphasis: oklch(0.84 0.08 354);
--ring: oklch(0.78 0.08 354);
}
:root[data-cline-accent="espresso"] {
--primary: oklch(0.36 0.035 35);
--primary-foreground: oklch(0.98 0.005 35);
--primary-emphasis: oklch(0.29 0.035 35);
--ring: oklch(0.55 0.03 35);
}
:root.dark[data-cline-accent="espresso"] {
--primary: oklch(0.72 0.045 45);
--primary-foreground: oklch(0.22 0.03 40);
--primary-emphasis: oklch(0.79 0.04 45);
--ring: oklch(0.72 0.04 45);
}
:root[data-cline-accent="ember"] {
--primary: oklch(0.6 0.19 33);
--primary-foreground: oklch(0.99 0 0);
--primary-emphasis: oklch(0.52 0.19 33);
--ring: oklch(0.66 0.16 33);
}
:root.dark[data-cline-accent="ember"] {
--primary: oklch(0.7 0.17 36);
--primary-foreground: oklch(0.17 0.03 33);
--primary-emphasis: oklch(0.77 0.15 36);
--ring: oklch(0.7 0.14 36);
}
@@ -1,5 +1,6 @@
import { Analytics } from "@vercel/analytics/next";
import type { Metadata } from "next";
import { Toaster } from "@/components/ui/toaster";
import "./globals.css";
export const metadata: Metadata = {
@@ -33,6 +34,7 @@ export default function RootLayout({
<html className="h-full" lang="en">
<body className="h-full min-h-screen font-sans antialiased">
{children}
<Toaster />
<Analytics />
</body>
</html>
+349 -189
View File
@@ -1,6 +1,14 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ImagePlus } from "lucide-react";
import {
useCallback,
useEffect,
useMemo,
useReducer,
useRef,
useState,
} from "react";
import { AgentHeader } from "@/components/agent-header";
import { AgentSidebar } from "@/components/agent-sidebar";
import {
@@ -24,25 +32,42 @@ import { ChatInputBar } from "@/components/views/chat/chat-input-bar";
import { ChatMessages } from "@/components/views/chat/chat-messages";
import { DiffView } from "@/components/views/chat/diff-view";
import { WelcomeScreen } from "@/components/views/chat/welcome-chat";
import { OnboardingView } from "@/components/views/onboarding/onboarding-view";
import { SessionsView } from "@/components/views/sessions/sessions-view";
import {
type SettingsSection,
SettingsView,
} from "@/components/views/settings/settings-view";
import { AccountProvider } from "@/contexts/account-context";
import { WorkspaceProvider } from "@/contexts/workspace-context";
import type { PromptInQueue } from "@/hooks/chat-session/types";
import { useAppUpdate } from "@/hooks/use-app-update";
import { useChatSession } from "@/hooks/use-chat-session";
import { useSessionHistory } from "@/hooks/use-session-history";
import { toast } from "@/hooks/use-toast";
import { syncAppIcon } from "@/lib/app-icon";
import type { ChatSessionConfig } from "@/lib/chat-schema";
import {
createDesktopAppState,
type DesktopAppLocation,
type DesktopAppView,
desktopAppReducer,
} from "@/lib/desktop-app-state";
import { desktopClient } from "@/lib/desktop-client";
import { syncDesktopWindowTitle } from "@/lib/desktop-window-title";
import {
hasCompletedOnboarding,
markOnboardingCompleted,
ONBOARDING_RESET_EVENT,
} from "@/lib/onboarding";
import {
getSessionMetadataTitle,
type SessionHistoryItem,
type SessionMetadata,
} from "@/lib/session-history";
import { syncHubTheme, watchSystemHubTheme } from "@/lib/theme";
import { syncHubAccent, syncHubTheme, watchSystemHubTheme } from "@/lib/theme";
import {
filterWorkspacePaths,
mergeWorkspacePaths,
normalizeWorkspacePath,
readWorkspaceSelectionFromWindow,
@@ -54,11 +79,7 @@ function makeThreadId(): string {
return `thread_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
}
type Thread = {
id: string;
historySession?: SessionHistoryItem;
hasStarted?: boolean;
};
type AppLocation = DesktopAppLocation<SettingsSection>;
function toThreadTitle(options: { title?: string; prompt?: string }): string {
const preferredTitle = options.title?.trim();
@@ -71,101 +92,91 @@ function toThreadTitle(options: { title?: string; prompt?: string }): string {
}
export default function Home() {
const [view, setView] = useState<"chat" | "sessions" | "settings">("chat");
const [settingsSection, setSettingsSection] =
useState<SettingsSection>("General");
const [threads, setThreads] = useState<Thread[]>(() => [
{ id: makeThreadId() },
]);
const [activeThreadId, setActiveThreadId] = useState<string>(
() => threads[0]?.id,
const [initialThreadId] = useState(makeThreadId);
const [appState, dispatchApp] = useReducer(
desktopAppReducer<SettingsSection>,
initialThreadId,
(threadId) => createDesktopAppState(threadId, "General"),
);
// Starts false on both server and first client render (hydration-safe);
// the effect below reads the persisted state right after mount.
const [showOnboarding, setShowOnboarding] = useState(false);
const { navigation, threads } = appState;
const { activeThreadId, settingsSection, view } = navigation.current;
const navigate = useCallback((destination: AppLocation) => {
dispatchApp({ type: "navigate", destination });
}, []);
const navigateWith = useCallback(
(destination: Partial<AppLocation>) => {
navigate({ ...navigation.current, ...destination });
},
[navigate, navigation.current],
);
const handleNavigateBack = useCallback(() => {
dispatchApp({ type: "back" });
}, []);
const handleNavigateForward = useCallback(() => {
dispatchApp({ type: "forward" });
}, []);
useAppUpdate();
useEffect(() => {
setShowOnboarding(!hasCompletedOnboarding());
const handleReset = () => setShowOnboarding(true);
window.addEventListener(ONBOARDING_RESET_EVENT, handleReset);
return () =>
window.removeEventListener(ONBOARDING_RESET_EVENT, handleReset);
}, []);
useEffect(() => {
syncHubTheme();
syncHubAccent();
return watchSystemHubTheme();
}, []);
const handleNewThread = useCallback(() => {
const id = makeThreadId();
setThreads((prev) => [...prev, { id }]);
setActiveThreadId(id);
setView("chat");
useEffect(() => {
// The dock reverts to the bundled icon every launch; re-apply the
// user's choice once the shell is up.
void syncAppIcon();
}, []);
useEffect(() => {
void syncDesktopWindowTitle();
}, []);
const handleNewThread = useCallback(() => {
dispatchApp({ type: "new-thread", threadId: makeThreadId() });
}, []);
const completeOnboarding = useCallback(() => {
markOnboardingCompleted();
setShowOnboarding(false);
// A fresh thread remounts the chat pane so it picks up credentials and
// the provider/model selection configured during onboarding.
handleNewThread();
}, [handleNewThread]);
const handleOpenSession = useCallback((session: SessionHistoryItem) => {
const threadId = `session_${session.sessionId}`;
setThreads((prev) => {
const existingIdx = prev.findIndex((item) => item.id === threadId);
if (existingIdx >= 0) {
const next = [...prev];
next[existingIdx] = {
...next[existingIdx],
hasStarted: true,
historySession: session,
};
return next;
}
return [
...prev,
{ id: threadId, hasStarted: true, historySession: session },
];
});
setActiveThreadId(threadId);
setView("chat");
dispatchApp({ type: "open-session", session });
}, []);
const handleDeleteSession = useCallback(
(deletedSessionId: string, deletedThreadId?: string) => {
const historyThreadId = `session_${deletedSessionId}`;
const deletedWasActive =
activeThreadId === deletedThreadId ||
activeThreadId === historyThreadId;
const fallback = deletedWasActive ? { id: makeThreadId() } : null;
let emptyFallbackId: string | null = null;
setThreads((prev) => {
const next = prev.filter(
(thread) =>
thread.id !== deletedThreadId &&
thread.id !== historyThreadId &&
thread.historySession?.sessionId !== deletedSessionId,
);
if (fallback) {
return [...next, fallback];
}
if (next.length === 0) {
emptyFallbackId = makeThreadId();
return [{ id: emptyFallbackId }];
}
return next;
dispatchApp({
type: "delete-session",
deletedSessionId,
deletedThreadId,
fallbackThreadId: makeThreadId(),
});
if (fallback) {
setActiveThreadId(fallback.id);
return;
}
if (emptyFallbackId) {
setActiveThreadId(emptyFallbackId);
}
},
[activeThreadId],
[],
);
const handleUpdateSessionMetadata = useCallback(
(sessionId: string, metadata: SessionMetadata) => {
setThreads((prev) =>
prev.map((thread) => {
if (thread.historySession?.sessionId !== sessionId) {
return thread;
}
return {
...thread,
historySession: {
...thread.historySession,
metadata,
},
};
}),
);
dispatchApp({ type: "update-session-metadata", sessionId, metadata });
},
[],
);
@@ -196,16 +207,22 @@ export default function Home() {
handleNewThread();
return;
}
setView("chat");
}, [activeThread, handleNewThread]);
navigateWith({ view: "chat" });
}, [activeThread, handleNewThread, navigateWith]);
const handleViewChange = useCallback(
(nextView: DesktopAppView) => {
navigateWith({ view: nextView });
},
[navigateWith],
);
const handleSettingsSectionChange = useCallback(
(section: SettingsSection) => {
navigateWith({ settingsSection: section, view: "settings" });
},
[navigateWith],
);
const handleThreadStarted = useCallback((threadId: string) => {
setThreads((current) =>
current.map((thread) =>
thread.id === threadId && !thread.hasStarted
? { ...thread, hasStarted: true }
: thread,
),
);
dispatchApp({ type: "thread-started", threadId });
}, []);
const sessionHistory = useSessionHistory({
activeSessionId: activeHistorySessionId,
@@ -213,69 +230,107 @@ export default function Home() {
onOpenSession: handleOpenSession,
onUpdateSessionMetadata: handleUpdateSessionMetadata,
});
const handleOpenSessionById = useCallback(
async (sessionId: string) => {
const cachedSession = sessionHistory.sessions.find(
(session) => session.sessionId === sessionId,
);
if (cachedSession) {
handleOpenSession(cachedSession);
return;
}
try {
const session = await desktopClient.invoke<SessionHistoryItem | null>(
"get_discovered_session",
{ session_id: sessionId },
);
if (!session) {
throw new Error("The session for this run is no longer available.");
}
handleOpenSession(session);
} catch (error) {
toast({
title: "Unable to open run",
description: error instanceof Error ? error.message : String(error),
variant: "destructive",
});
}
},
[handleOpenSession, sessionHistory.sessions],
);
const historyWorkspacePaths = useMemo(
() => workspacePathsFromSessions(sessionHistory.sessions),
[sessionHistory.sessions],
);
return (
<SidebarProvider>
<div className="flex h-screen w-full overflow-hidden bg-background text-foreground">
<Sidebar className="border-r border-sidebar-border" collapsible="icon">
<AgentSidebar
activeSessionId={activeHistorySessionId}
isHomeActive={
view === "chat" &&
!activeThread?.historySession &&
!activeThread?.hasStarted
}
onHome={handleHome}
onNewThread={handleNewThread}
onSettingsSectionChange={setSettingsSection}
sessionHistory={sessionHistory}
setView={setView}
settingsSection={settingsSection}
view={view}
/>
<SidebarRail />
</Sidebar>
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
<SidebarTrigger className="absolute left-3 top-3 z-40 md:hidden" />
{view === "sessions" ? (
<SessionsView
<AccountProvider>
<SidebarProvider>
<div className="flex h-screen w-full overflow-hidden bg-background text-foreground">
<Sidebar
className="border-r border-sidebar-border"
collapsible="icon"
>
<AgentSidebar
activeSessionId={activeHistorySessionId}
history={sessionHistory}
onHome={handleHome}
onNavigateBack={handleNavigateBack}
onNavigateForward={handleNavigateForward}
onNewThread={handleNewThread}
onSettingsSectionChange={handleSettingsSectionChange}
sessionHistory={sessionHistory}
setView={handleViewChange}
settingsSection={settingsSection}
view={view}
canNavigateBack={navigation.back.length > 0}
canNavigateForward={navigation.forward.length > 0}
/>
) : activeThread ? (
<div
aria-hidden={view === "settings" ? true : undefined}
className="flex min-h-0 flex-1 flex-col"
inert={view === "settings" ? true : undefined}
>
<ChatThreadPane
key={activeThread.id}
historySession={activeThread.historySession}
knownWorkspacePaths={historyWorkspacePaths}
onUpdateSessionMetadata={handleUpdateSessionMetadata}
threadId={activeThread.id}
onDeleteSession={handleDeleteSession}
onNewThread={handleNewThread}
onOpenSession={handleOpenSession}
onThreadStarted={handleThreadStarted}
<SidebarRail />
</Sidebar>
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
<SidebarTrigger className="absolute left-3 top-3 z-40 md:hidden" />
{view === "sessions" ? (
<SessionsView
activeSessionId={activeHistorySessionId}
history={sessionHistory}
/>
</div>
) : null}
{view === "settings" ? (
<div className="absolute inset-0 z-30 bg-background text-foreground">
<SettingsView
onNavigateSection={setSettingsSection}
section={settingsSection}
/>
</div>
) : null}
</SidebarInset>
</div>
</SidebarProvider>
) : activeThread ? (
<div
aria-hidden={view === "settings" ? true : undefined}
className="flex min-h-0 flex-1 flex-col"
inert={view === "settings" ? true : undefined}
>
<ChatThreadPane
key={activeThread.id}
historySession={activeThread.historySession}
knownWorkspacePaths={historyWorkspacePaths}
onUpdateSessionMetadata={handleUpdateSessionMetadata}
threadId={activeThread.id}
onDeleteSession={handleDeleteSession}
onNewThread={handleNewThread}
onOpenSession={handleOpenSession}
onThreadStarted={handleThreadStarted}
/>
</div>
) : null}
{view === "settings" ? (
<div className="absolute inset-0 z-30 bg-background text-foreground">
<SettingsView
onNavigateSection={handleSettingsSectionChange}
onOpenSession={handleOpenSessionById}
section={settingsSection}
/>
</div>
) : null}
</SidebarInset>
</div>
</SidebarProvider>
{showOnboarding ? (
<div className="fixed inset-0 z-50">
<OnboardingView onComplete={completeOnboarding} />
</div>
) : null}
</AccountProvider>
);
}
@@ -317,6 +372,7 @@ function ChatThreadPane({
pendingToolApprovals,
pendingAskQuestions,
setConfig,
setWorkspacePath,
sendPrompt,
steerPromptInQueue,
updatePromptInQueue,
@@ -332,6 +388,8 @@ function ChatThreadPane({
} = useChatSession();
const [promptInput, setPromptInput] = useState("");
const [pendingAttachments, setPendingAttachments] = useState<File[]>([]);
const [isDraggingFiles, setIsDraggingFiles] = useState(false);
const dragDepthRef = useRef(0);
const [showDiffView, setShowDiffView] = useState(false);
const [deletingSession, setDeletingSession] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
@@ -345,16 +403,21 @@ function ChatThreadPane({
Record<string, { apiKey: string }>
>({});
const [providersLoaded, setProvidersLoaded] = useState(false);
// History paths lead each merge: they are ordered by session recency, so
// stored or stale entries only append after them.
const [workspaces, setWorkspaces] = useState<string[]>(() =>
mergeWorkspacePaths(
readWorkspaceSelectionFromWindow().workspaces,
knownWorkspacePaths,
filterWorkspacePaths(
mergeWorkspacePaths(
knownWorkspacePaths,
readWorkspaceSelectionFromWindow().workspaces,
),
),
);
const [workspacesLoaded, setWorkspacesLoaded] = useState(false);
const hydratedSessionRef = useRef<string | null>(null);
const resetThreadRef = useRef<string | null>(null);
const manualTitleSessionRef = useRef<string | null>(null);
const workspaceSelectionRequestRef = useRef(0);
const workspaceRef = useRef({
cwd: config.cwd,
workspaceRoot: config.workspaceRoot,
@@ -366,7 +429,9 @@ function ChatThreadPane({
useEffect(() => {
setWorkspaces((current) => {
const merged = mergeWorkspacePaths(current, knownWorkspacePaths);
const merged = filterWorkspacePaths(
mergeWorkspacePaths(knownWorkspacePaths, current),
);
return current.length === merged.length &&
current.every((workspace, index) => workspace === merged[index])
? current
@@ -445,12 +510,15 @@ function ChatThreadPane({
);
const refreshGitBranch = useCallback(async () => {
const cwd = getWorkspaceCwd();
if (!cwd) {
setGitBranch("no-git");
return;
}
try {
const payload = await desktopClient.invoke<{ branch?: string }>(
"get_git_branch",
{
cwd: getWorkspaceCwd(),
},
{ cwd },
);
const branch = payload?.branch?.trim();
setGitBranch(branch && branch.length > 0 ? branch : "no-git");
@@ -463,13 +531,15 @@ function ChatThreadPane({
current: string;
branches: string[];
}> => {
const cwd = getWorkspaceCwd();
if (!cwd) {
return { current: "no-git", branches: [] };
}
try {
const payload = await desktopClient.invoke<{
current?: string;
branches?: string[];
}>("list_git_branches", {
cwd: getWorkspaceCwd(),
});
}>("list_git_branches", { cwd });
const current = payload?.current?.trim() || "no-git";
const branches = Array.isArray(payload?.branches)
? payload.branches.filter((item) => item.trim().length > 0)
@@ -482,13 +552,14 @@ function ChatThreadPane({
const switchGitBranch = useCallback(
async (nextBranch: string): Promise<boolean> => {
const cwd = getWorkspaceCwd();
if (!cwd) {
return false;
}
try {
const payload = await desktopClient.invoke<{ branch?: string }>(
"checkout_git_branch",
{
cwd: getWorkspaceCwd(),
branch: nextBranch,
},
{ cwd, branch: nextBranch },
);
const branch = payload?.branch?.trim();
setGitBranch(branch && branch.length > 0 ? branch : "no-git");
@@ -508,7 +579,12 @@ function ChatThreadPane({
workspaceRef.current.cwd ||
""
).trim();
return mergeWorkspacePaths(knownWorkspacePaths, [preferred, current]);
// The active workspace can be an excluded path (restored session,
// process cwd fallback); it renders via its own registration in the
// selector and welcome screen instead of joining the catalog.
return filterWorkspacePaths(
mergeWorkspacePaths(knownWorkspacePaths, [preferred, current]),
);
},
[knownWorkspacePaths],
);
@@ -518,7 +594,7 @@ function ChatThreadPane({
try {
const results = await listWorkspaces(preferredWorkspace);
setWorkspaces((current) => {
const merged = mergeWorkspacePaths(current, results);
const merged = mergeWorkspacePaths(results, current);
return current.length === merged.length &&
current.every((workspace, index) => workspace === merged[index])
? current
@@ -541,6 +617,7 @@ function ChatThreadPane({
if (!nextWorkspace) {
return false;
}
const requestId = ++workspaceSelectionRequestRef.current;
const normalizedNext = normalizeWorkspacePath(nextWorkspace);
const normalizedCurrent = normalizeWorkspacePath(
workspaceRef.current.workspaceRoot || workspaceRef.current.cwd || "",
@@ -556,13 +633,14 @@ function ChatThreadPane({
if (validation.valid !== true) {
return false;
}
if (requestId !== workspaceSelectionRequestRef.current) {
return false;
}
setConfig((prev) => ({
...prev,
workspaceRoot: nextWorkspace,
cwd: nextWorkspace,
}));
setWorkspaces((prev) => mergeWorkspacePaths(prev, [nextWorkspace]));
setWorkspacePath(nextWorkspace);
setWorkspaces((prev) =>
filterWorkspacePaths(mergeWorkspacePaths(prev, [nextWorkspace])),
);
// Fire git branch + workspace list refresh in the background
desktopClient
@@ -570,11 +648,16 @@ function ChatThreadPane({
cwd: nextWorkspace,
})
.then((payload) => {
if (requestId !== workspaceSelectionRequestRef.current) {
return;
}
const branch = payload?.branch?.trim();
setGitBranch(branch && branch.length > 0 ? branch : "no-git");
})
.catch(() => {
setGitBranch("no-git");
if (requestId === workspaceSelectionRequestRef.current) {
setGitBranch("no-git");
}
});
// Refresh the merged history, stored, and current workspace catalog.
@@ -582,9 +665,16 @@ function ChatThreadPane({
return true;
},
[setConfig, refreshWorkspaces],
[refreshWorkspaces, setWorkspacePath],
);
const selectChat = useCallback(async (): Promise<boolean> => {
workspaceSelectionRequestRef.current += 1;
setWorkspacePath("");
setGitBranch("no-git");
return true;
}, [setWorkspacePath]);
const pickWorkspaceDirectory = useCallback(
async (initialPath?: string): Promise<string | null> => {
try {
@@ -842,6 +932,69 @@ function ChatThreadPane({
threadId,
]);
const handleAttachFiles = useCallback((files: File[]) => {
setPendingAttachments((prev) => {
const existing = new Set(
prev.map((file) => `${file.name}:${file.size}:${file.lastModified}`),
);
const next = [...prev];
for (const file of files) {
const key = `${file.name}:${file.size}:${file.lastModified}`;
if (!existing.has(key)) {
existing.add(key);
next.push(file);
}
}
return next;
});
}, []);
// Drag-and-drop file attachments. Requires `dragDropEnabled: false` on the
// Tauri window — otherwise the native shell swallows OS file drags and these
// HTML5 events never fire.
const handleDragEnter = useCallback((event: React.DragEvent) => {
if (!event.dataTransfer.types.includes("Files")) {
return;
}
event.preventDefault();
dragDepthRef.current += 1;
setIsDraggingFiles(true);
}, []);
const handleDragOver = useCallback((event: React.DragEvent) => {
if (!event.dataTransfer.types.includes("Files")) {
return;
}
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}, []);
const handleDragLeave = useCallback((event: React.DragEvent) => {
if (!event.dataTransfer.types.includes("Files")) {
return;
}
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) {
setIsDraggingFiles(false);
}
}, []);
const handleDrop = useCallback(
(event: React.DragEvent) => {
if (!event.dataTransfer.types.includes("Files")) {
return;
}
event.preventDefault();
dragDepthRef.current = 0;
setIsDraggingFiles(false);
const files = Array.from(event.dataTransfer.files);
if (files.length > 0) {
handleAttachFiles(files);
}
},
[handleAttachFiles],
);
const attachmentList = pendingAttachments.map((file, index) => ({
id: `${file.name}:${file.size}:${file.lastModified}:${index}`,
name: file.name,
@@ -926,6 +1079,7 @@ function ChatThreadPane({
refreshWorkspaces,
switchWorkspace,
pickWorkspaceDirectory,
selectChat,
}),
[
resolvedWorkspaceRoot,
@@ -934,6 +1088,7 @@ function ChatThreadPane({
refreshWorkspaces,
switchWorkspace,
pickWorkspaceDirectory,
selectChat,
],
);
@@ -964,24 +1119,7 @@ function ChatThreadPane({
<ChatInputBar
attachments={attachmentList}
onAbort={() => void abort()}
onAttachFiles={(files) => {
setPendingAttachments((prev) => {
const existing = new Set(
prev.map(
(file) => `${file.name}:${file.size}:${file.lastModified}`,
),
);
const next = [...prev];
for (const file of files) {
const key = `${file.name}:${file.size}:${file.lastModified}`;
if (!existing.has(key)) {
existing.add(key);
next.push(file);
}
}
return next;
});
}}
onAttachFiles={handleAttachFiles}
onListGitBranches={listGitBranches}
onRemoveAttachment={(id) => {
setPendingAttachments((prev) =>
@@ -1045,13 +1183,31 @@ function ChatThreadPane({
return (
<WorkspaceProvider value={workspaceContextValue}>
{/* biome-ignore lint/a11y/noStaticElementInteractions: Drag-and-drop target only; the paperclip button is the accessible attach path. */}
<div
className={
isWelcomeState
? "grid h-full min-h-0 flex-1 grid-rows-[minmax(0,1fr)] overflow-hidden"
: "grid h-full min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden"
? "relative grid h-full min-h-0 flex-1 grid-rows-[minmax(0,1fr)] overflow-hidden"
: "relative grid h-full min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden"
}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{isDraggingFiles ? (
<div className="pointer-events-none absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
<div className="flex flex-col items-center gap-2 rounded-xl border-2 border-dashed border-primary/60 bg-card px-10 py-8 shadow-lg">
<ImagePlus className="h-8 w-8 text-primary" />
<p className="text-sm font-medium text-foreground">
Drop to attach
</p>
<p className="text-xs text-muted-foreground">
Screenshots and files will be added to your next message
</p>
</div>
</div>
) : null}
{!isWelcomeState ? (
<div className="z-20 border-b border-border/70 bg-background/85 backdrop-blur-sm">
<AgentHeader
@@ -1079,6 +1235,7 @@ function ChatThreadPane({
body={
showDiffView ? (
<DiffView
cwd={config.cwd || config.workspaceRoot}
fileDiffs={fileDiffs}
onClose={() => setShowDiffView(false)}
/>
@@ -1104,7 +1261,10 @@ function ChatThreadPane({
)
}
composer={composer}
gitBranch={gitBranch}
onListGitBranches={listGitBranches}
onStartChat={setPromptInput}
onSwitchGitBranch={switchGitBranch}
quickActions={[]}
/>
</div>
@@ -0,0 +1,58 @@
// @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 { AgentHeader } from "@/components/agent-header";
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.restoreAllMocks();
});
describe("AgentHeader title editor", () => {
it("preserves the displayed title width when editing starts", async () => {
await act(async () => {
root.render(
<AgentHeader
canEditTitle
onRenameTitle={vi.fn()}
status="completed"
title="A title wide enough to expose resizing"
/>,
);
});
const titleButton = container.querySelector<HTMLButtonElement>(
'button[title="A title wide enough to expose resizing"]',
);
expect(titleButton).not.toBeNull();
vi.spyOn(
titleButton as HTMLButtonElement,
"getBoundingClientRect",
).mockReturnValue({
width: 318,
} as DOMRect);
await act(async () => {
titleButton?.click();
});
const titleForm = container.querySelector("form");
const titleInput = container.querySelector<HTMLInputElement>("input");
expect(titleForm?.style.width).toBe("318px");
expect(titleInput?.className).toContain("w-full");
expect(titleInput?.className).not.toContain("w-64");
});
});
@@ -48,6 +48,7 @@ export function AgentHeader({
}: AgentHeaderProps) {
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [titleInput, setTitleInput] = useState("");
const [titleEditorWidth, setTitleEditorWidth] = useState<number>();
const additions = diff?.additions ?? 0;
const deletions = diff?.deletions ?? 0;
const hasChanges = additions + deletions > 0;
@@ -98,15 +99,16 @@ export function AgentHeader({
/>
{isEditingTitle ? (
<form
className="m-0 min-w-0 flex-1"
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-64 max-w-full text-sm"
className="h-7 w-full text-sm"
disabled={renamingTitle}
onBlur={() => {
void submitTitle();
@@ -130,10 +132,13 @@ export function AgentHeader({
"rounded px-1 py-0.5 transition-colors hover:bg-accent",
)}
disabled={!canEditTitle || renamingTitle}
onClick={() => {
onClick={(event) => {
if (!canEditTitle || renamingTitle) {
return;
}
setTitleEditorWidth(
event.currentTarget.getBoundingClientRect().width,
);
setTitleInput(threadTitle);
setIsEditingTitle(true);
}}
@@ -3,13 +3,21 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AgentSidebar } from "@/components/agent-sidebar";
import {
AgentSidebar,
getSessionOverviewItems,
getSessionOverviewTitle,
} from "@/components/agent-sidebar";
import { SidebarProvider } from "@/components/ui/sidebar";
import { AccountProvider } from "@/contexts/account-context";
import type {
SessionThread,
UseSessionHistoryResult,
} from "@/hooks/use-session-history";
const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() }));
vi.mock("@/lib/desktop-client", () => ({ desktopClient: { invoke } }));
let container: HTMLDivElement;
let root: Root;
@@ -62,6 +70,15 @@ async function click(element: Element): Promise<void> {
});
}
async function hover(element: Element): Promise<void> {
await act(async () => {
element.dispatchEvent(
new MouseEvent("pointerover", { bubbles: true, cancelable: true }),
);
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
function buttonWithText(text: string, rootNode: ParentNode = container) {
const button = [
...rootNode.querySelectorAll<HTMLButtonElement>("button"),
@@ -78,6 +95,9 @@ function sessionIsVisible(title: string): boolean {
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
window.localStorage.clear();
invoke.mockReset();
invoke.mockRejectedValue(new Error("No Cline account auth token found"));
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: vi.fn(() => ({
@@ -101,6 +121,86 @@ afterEach(async () => {
});
describe("AgentSidebar session organization", () => {
it("filters scheduled sessions without changing their titles", async () => {
const scheduled = {
...makeThread("scheduled", 1),
source: "hub-schedule",
};
const regular = makeThread("regular", 1);
await act(async () => {
root.render(
<SidebarProvider>
<AgentSidebar
activeSessionId={null}
onHome={vi.fn()}
onNewThread={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory([scheduled, regular], vi.fn())}
setView={vi.fn()}
settingsSection="General"
view="chat"
/>
</SidebarProvider>,
);
});
expect(sessionIsVisible("scheduled session 1")).toBe(true);
expect(container.textContent).not.toContain("(schedule)");
await click(
container.querySelector('[aria-label="Filter sessions"]') as Element,
);
expect(document.body.textContent).not.toContain("Recent");
const schedulesOption = await vi.waitFor(() => {
const option = [
...document.querySelectorAll<HTMLElement>('[role="menuitemradio"]'),
].find((candidate) => candidate.textContent?.includes("Schedules"));
expect(option).toBeDefined();
return option as HTMLElement;
});
await click(schedulesOption);
expect(sessionIsVisible("scheduled session 1")).toBe(true);
expect(sessionIsVisible("regular session 1")).toBe(false);
});
it("builds the hover overview with branch and secondary metadata last", () => {
const thread = {
...makeThread("cline", 5),
gitBranch: "bee/session-overview",
inputTokens: 3_000_000,
outputTokens: 9_000,
totalCostUsd: 3.06,
};
expect(getSessionOverviewItems(thread)).toEqual([
["Workspace", "cline", "/projects/cline"],
["Branch", "bee/session-overview"],
["Provider", "cline"],
["Model", "test-model"],
["Tokens", "3009k"],
["Cost", "$3.06"],
["ID", "cline-5"],
["Updated", "5m"],
]);
expect(getSessionOverviewItems(makeThread("cline", 5))).not.toContainEqual([
"Branch",
expect.anything(),
]);
expect(
getSessionOverviewItems(thread).some(([label]) => label === "Status"),
).toBe(false);
});
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";
expect(getSessionOverviewTitle(`${firstLine}\nSecond line`)).toBe(
firstLine,
);
});
it("defaults to time and keeps project expansion scoped to one project", async () => {
const threads = [
...Array.from({ length: 12 }, (_, index) =>
@@ -122,11 +222,12 @@ describe("AgentSidebar session organization", () => {
<SidebarProvider>
<AgentSidebar
activeSessionId={null}
isHomeActive
onHome={vi.fn()}
onNewThread={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={sessionHistory}
setView={vi.fn()}
settingsSection="General"
view="chat"
/>
</SidebarProvider>,
@@ -173,4 +274,298 @@ describe("AgentSidebar session organization", () => {
await click(buttonWithText("Load older projects"));
expect(loadOlderSessions).toHaveBeenCalledOnce();
});
it("shows the signed-in account and active organization in the footer", async () => {
invoke.mockResolvedValue({
id: "user-1",
email: "beatrix@cline.bot",
displayName: "Beatrix",
photoUrl: "",
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
organizations: [
{
active: true,
memberId: "member-1",
name: "Cline Bot Inc",
organizationId: "org-1",
roles: ["admin"],
},
],
});
await act(async () => {
root.render(
<AccountProvider>
<SidebarProvider>
<AgentSidebar
activeSessionId={null}
onHome={vi.fn()}
onNewThread={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory([], vi.fn())}
setView={vi.fn()}
settingsSection="General"
view="chat"
/>
</SidebarProvider>
</AccountProvider>,
);
});
await vi.waitFor(() => {
expect(container.textContent).toContain("Beatrix");
expect(container.textContent).toContain("Cline Bot Inc");
});
expect(container.textContent).not.toContain("Cline Desktop");
expect(container.textContent).not.toContain("Local");
});
it("opens the Account settings section when the footer account row is clicked", async () => {
const setView = vi.fn();
const onSettingsSectionChange = vi.fn();
await act(async () => {
root.render(
<AccountProvider>
<SidebarProvider>
<AgentSidebar
activeSessionId={null}
onHome={vi.fn()}
onNewThread={vi.fn()}
onSettingsSectionChange={onSettingsSectionChange}
sessionHistory={makeSessionHistory([], vi.fn())}
setView={setView}
settingsSection="General"
view="chat"
/>
</SidebarProvider>
</AccountProvider>,
);
});
const accountButton = container.querySelector(
'[aria-label="Account settings"]',
);
expect(accountButton).not.toBeNull();
await click(accountButton as Element);
expect(onSettingsSectionChange).toHaveBeenCalledWith("Account");
expect(setView).not.toHaveBeenCalled();
});
it("shows the desktop app version and connected Hub when the logo is hovered", async () => {
const onHome = vi.fn();
invoke.mockImplementation(async (command: string) => {
if (command === "get_process_context") {
return {
appVersion: "1.2.3",
hub: {
error: null,
status: "connected",
url: "ws://127.0.0.1:25463/hub",
},
};
}
throw new Error("No Cline account auth token found");
});
await act(async () => {
root.render(
<AccountProvider>
<SidebarProvider>
<AgentSidebar
activeSessionId={null}
onHome={onHome}
onNewThread={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory([], vi.fn())}
setView={vi.fn()}
settingsSection="General"
view="chat"
/>
</SidebarProvider>
</AccountProvider>,
);
});
const logoButton = container.querySelector('[aria-label="Cline home"]');
expect(logoButton).not.toBeNull();
expect(document.body.textContent).not.toContain("Version 1.2.3");
await hover(logoButton as Element);
await vi.waitFor(() => {
expect(document.body.textContent).toContain("Version 1.2.3");
expect(document.body.textContent).toContain("Cline Hub @25463");
expect(document.body.textContent).not.toContain(
"ws://127.0.0.1:25463/hub",
);
});
expect(onHome).not.toHaveBeenCalled();
await click(logoButton as Element);
expect(onHome).toHaveBeenCalled();
expect(invoke).toHaveBeenCalledWith("get_process_context");
});
it("shows a disconnected Hub when process context has no live connection", async () => {
invoke.mockResolvedValue({
appVersion: "1.2.3",
hub: {
error: "Hub connection closed (code=1006)",
status: "disconnected",
url: "ws://127.0.0.1:25463/hub",
},
});
await act(async () => {
root.render(
<AccountProvider>
<SidebarProvider>
<AgentSidebar
activeSessionId={null}
onHome={vi.fn()}
onNewThread={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory([], vi.fn())}
setView={vi.fn()}
settingsSection="General"
view="chat"
/>
</SidebarProvider>
</AccountProvider>,
);
});
const logoButton = container.querySelector('[aria-label="Cline home"]');
expect(logoButton).not.toBeNull();
await hover(logoButton as Element);
await vi.waitFor(() => {
expect(document.body.textContent).toContain("Cline Hub @25463");
expect(document.body.textContent).toContain(
"Hub connection closed (code=1006)",
);
});
});
it("hosts back and forward navigation in the draggable sidebar title bar", async () => {
const onNavigateBack = vi.fn();
const onNavigateForward = vi.fn();
await act(async () => {
root.render(
<AccountProvider>
<SidebarProvider>
<AgentSidebar
activeSessionId={null}
canNavigateBack
canNavigateForward
onHome={vi.fn()}
onNavigateBack={onNavigateBack}
onNavigateForward={onNavigateForward}
onNewThread={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory([], vi.fn())}
setView={vi.fn()}
settingsSection="General"
view="chat"
/>
</SidebarProvider>
</AccountProvider>,
);
});
const titleBar = container.querySelector("[data-tauri-drag-region]");
expect(titleBar).not.toBeNull();
expect(titleBar?.textContent).not.toContain("Cline Code");
await click(
container.querySelector('[aria-label="Previous page"]') as Element,
);
await click(container.querySelector('[aria-label="Next page"]') as Element);
expect(onNavigateBack).toHaveBeenCalledOnce();
expect(onNavigateForward).toHaveBeenCalledOnce();
});
it("places the logo and icon-only new-session action below the title bar", async () => {
const onNewThread = vi.fn();
await act(async () => {
root.render(
<AccountProvider>
<SidebarProvider>
<AgentSidebar
activeSessionId={null}
onHome={vi.fn()}
onNewThread={onNewThread}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory([], vi.fn())}
setView={vi.fn()}
settingsSection="General"
view="chat"
/>
</SidebarProvider>
</AccountProvider>,
);
});
const logo = container.querySelector('[aria-label="Cline home"]');
const newSession = container.querySelector('[aria-label="New Session"]');
expect(logo).not.toBeNull();
expect(newSession).not.toBeNull();
expect(newSession?.textContent).toBe("");
await click(newSession as Element);
expect(onNewThread).toHaveBeenCalledOnce();
});
it("uses only the Cline logo for home in the collapsed sidebar", async () => {
await act(async () => {
root.render(
<AccountProvider>
<SidebarProvider defaultOpen={false}>
<AgentSidebar
activeSessionId={null}
onHome={vi.fn()}
onNewThread={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory([], vi.fn())}
setView={vi.fn()}
settingsSection="General"
view="chat"
/>
</SidebarProvider>
</AccountProvider>,
);
});
expect(container.querySelector('[aria-label="Cline home"]')).not.toBeNull();
expect(container.querySelector('[aria-label="New Session"]')).toBeNull();
});
it("falls back to a signed-out footer without account data", async () => {
await act(async () => {
root.render(
<AccountProvider>
<SidebarProvider>
<AgentSidebar
activeSessionId={null}
onHome={vi.fn()}
onNewThread={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory([], vi.fn())}
setView={vi.fn()}
settingsSection="General"
view="chat"
/>
</SidebarProvider>
</AccountProvider>,
);
});
await vi.waitFor(() => {
expect(container.textContent).toContain("Cline Desktop");
});
expect(container.textContent).not.toContain("Local");
});
});
@@ -1,29 +1,32 @@
"use client";
import {
Activity,
ArrowDownUp,
Blocks,
Bot,
ChevronDown,
ChevronLeft,
ChevronRight,
CircleUserRound,
Clock3,
Code,
FileText,
Filter,
FolderTree,
GitFork,
Home,
Loader2,
MessageSquare,
PanelLeftOpen,
Pencil,
Pin,
Plug,
Plus,
Radio,
Search,
Server,
Settings,
SlidersHorizontal,
Store,
Trash2,
Wrench,
} from "lucide-react";
import {
type ReactNode,
@@ -68,14 +71,18 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { useSidebar } from "@/components/ui/sidebar";
import { normalizeTitle } from "@/components/utils";
import {
CUSTOMIZATION_SECTIONS,
SETTINGS_SECTIONS,
type SettingsSection,
} from "@/components/views/settings/settings-view";
import { useAccount } from "@/contexts/account-context";
import type {
SessionThread,
UseSessionHistoryResult,
} from "@/hooks/use-session-history";
import { formatCostUsd, formatTokenCount } from "@/hooks/use-session-history";
import { desktopClient } from "@/lib/desktop-client";
import { SCHEDULED_SESSION_SOURCE } from "@/lib/session-history";
import {
groupThreadsByProject,
INITIAL_VISIBLE_THREAD_COUNT,
@@ -86,18 +93,47 @@ import { cn } from "@/lib/utils";
type Thread = SessionThread;
type AppView = "chat" | "sessions" | "settings";
const filterOptions = ["All", "Running", "Recent", "Pinned"] as const;
const filterOptions = ["All", "Running", "Schedules", "Pinned"] as const;
type FilterOption = (typeof filterOptions)[number];
type SidebarSortMode = "time" | "project";
type DesktopProcessContext = {
appVersion?: unknown;
hub?: {
error?: unknown;
status?: unknown;
url?: unknown;
};
};
type HubStatus = {
connected: boolean;
error: string | null;
url: string | null;
};
function hubPort(url: string | null): string | null {
if (!url) {
return null;
}
try {
return new URL(url).port || null;
} catch {
return null;
}
}
const SETTINGS_SECTION_ICONS = {
General: SlidersHorizontal,
Models: Bot,
"MCP Servers": Server,
"MCP Marketplace": Store,
Customizations: Blocks,
Channels: Radio,
Schedules: Clock3,
Account: CircleUserRound,
Plugins: Plug,
Skills: Activity,
MCP: Server,
Hooks: Code,
Rules: FileText,
Agents: Bot,
Tools: Wrench,
} satisfies Record<SettingsSection, typeof Settings>;
function SettingsSectionNavigation({
@@ -109,6 +145,30 @@ function SettingsSectionNavigation({
collapsed: boolean;
onSelect: (section: SettingsSection) => void;
}) {
const renderSectionButton = (section: SettingsSection) => {
const Icon = SETTINGS_SECTION_ICONS[section];
return (
<Button
aria-current={activeSection === section ? "page" : undefined}
aria-label={section}
className={cn(
"min-w-0 justify-start",
activeSection === section &&
"bg-sidebar-accent text-sidebar-accent-foreground",
collapsed && "mx-auto size-9 justify-center px-0",
)}
key={section}
onClick={() => onSelect(section)}
title={section}
type="button"
variant="sidebarItem"
>
<Icon className="size-4 shrink-0" />
{!collapsed ? <span className="truncate">{section}</span> : null}
</Button>
);
};
return (
<nav
aria-label="Settings sections"
@@ -122,36 +182,25 @@ function SettingsSectionNavigation({
Settings
</p>
) : null}
{SETTINGS_SECTIONS.map((section) => {
const Icon = SETTINGS_SECTION_ICONS[section];
return (
<Button
aria-current={activeSection === section ? "page" : undefined}
aria-label={section}
className={cn(
"min-w-0 justify-start",
activeSection === section &&
"bg-sidebar-accent text-sidebar-accent-foreground",
collapsed && "mx-auto size-9 justify-center px-0",
)}
key={section}
onClick={() => onSelect(section)}
title={section}
type="button"
variant="sidebarItem"
>
<Icon className="size-4 shrink-0" />
{!collapsed ? <span className="truncate">{section}</span> : null}
</Button>
);
})}
{SETTINGS_SECTIONS.map(renderSectionButton)}
{!collapsed ? (
<p className="px-2 pb-2 pt-4 text-sm font-medium text-muted-foreground">
Customizations
</p>
) : (
<div className="my-2 h-px w-6 shrink-0 bg-sidebar-border" />
)}
{CUSTOMIZATION_SECTIONS.map(renderSectionButton)}
</nav>
);
}
export function AgentSidebar({
isHomeActive,
canNavigateBack = false,
canNavigateForward = false,
onHome,
onNavigateBack,
onNavigateForward,
onNewThread,
onSettingsSectionChange,
setView,
@@ -160,8 +209,11 @@ export function AgentSidebar({
activeSessionId,
sessionHistory,
}: {
isHomeActive: boolean;
canNavigateBack?: boolean;
canNavigateForward?: boolean;
onHome: () => void;
onNavigateBack?: () => void;
onNavigateForward?: () => void;
onNewThread?: () => void;
onSettingsSectionChange: (section: SettingsSection) => void;
setView: (view: AppView) => void;
@@ -172,6 +224,14 @@ export function AgentSidebar({
}) {
const { isMobile, setOpen, setOpenMobile, state } = useSidebar();
const isCollapsed = !isMobile && state === "collapsed";
const { user, activeOrganization } = useAccount();
const { displayName, email } = user || {};
const username = displayName?.split(" ")?.[0] || email?.split("@")?.[0];
const accountName = username?.trim() || "Cline Desktop";
const accountScope = user
? (activeOrganization?.name ?? "Personal")
: undefined;
const accountInitial = accountName.charAt(0).toUpperCase();
const {
deleteThread: deleteHistoryThread,
forkThread: forkHistoryThread,
@@ -205,6 +265,46 @@ export function AgentSidebar({
const [projectVisibleCounts, setProjectVisibleCounts] = useState<
Record<string, number>
>({});
const [appVersion, setAppVersion] = useState<string | null>(null);
const [hubStatus, setHubStatus] = useState<HubStatus | null>(null);
const loadProcessContext = useCallback(async () => {
try {
const context = await desktopClient.invoke<DesktopProcessContext>(
"get_process_context",
);
const version =
typeof context?.appVersion === "string"
? context.appVersion.trim()
: "";
setAppVersion(version || null);
const hubUrl =
typeof context?.hub?.url === "string"
? context.hub.url.trim() || null
: null;
setHubStatus({
connected: context?.hub?.status === "connected",
error:
typeof context?.hub?.error === "string"
? context.hub.error.trim() || null
: null,
url: hubUrl,
});
} catch (error) {
setHubStatus({
connected: false,
error:
error instanceof Error
? error.message
: "Unable to read Cline Hub status.",
url: null,
});
}
}, []);
useEffect(() => {
void loadProcessContext();
}, [loadProcessContext]);
useEffect(() => {
if (isCollapsed && searchOpen) {
@@ -226,8 +326,8 @@ export function AgentSidebar({
switch (filter) {
case "Running":
return filtered.filter((t) => t.status === "running");
case "Recent":
return filtered.slice(0, 8);
case "Schedules":
return filtered.filter((t) => t.source === SCHEDULED_SESSION_SOURCE);
case "Pinned":
return filtered.filter((t) => t.pinned);
default:
@@ -240,18 +340,16 @@ export function AgentSidebar({
const openThread = useCallback(
(threadId: string) => {
setView("chat");
openHistoryThread(threadId);
closeMobileSidebar();
},
[closeMobileSidebar, openHistoryThread, setView],
[closeMobileSidebar, openHistoryThread],
);
const openNewThread = useCallback(() => {
setView("chat");
onNewThread?.();
closeMobileSidebar();
}, [closeMobileSidebar, onNewThread, setView]);
}, [closeMobileSidebar, onNewThread]);
const openHome = useCallback(() => {
onHome();
closeMobileSidebar();
@@ -267,11 +365,16 @@ export function AgentSidebar({
const openSettingsSection = useCallback(
(section: SettingsSection) => {
onSettingsSectionChange(section);
setView("settings");
closeMobileSidebar();
},
[closeMobileSidebar, onSettingsSectionChange, setView],
[closeMobileSidebar, onSettingsSectionChange],
);
const navigateBack = useCallback(() => {
onNavigateBack?.();
}, [onNavigateBack]);
const navigateForward = useCallback(() => {
onNavigateForward?.();
}, [onNavigateForward]);
const startRenameThread = useCallback((thread: Thread) => {
setEditingSessionId(thread.id);
@@ -442,37 +545,107 @@ export function AgentSidebar({
<div className="flex h-full min-h-0 w-full min-w-0 shrink-0 flex-col overflow-hidden bg-sidebar text-sidebar-foreground">
<div
className={cn(
"flex h-16 shrink-0 items-center px-4",
isCollapsed && "justify-center px-0",
"flex h-12 shrink-0 items-center justify-end gap-0.5 pr-2 pl-[4.75rem]",
isCollapsed && "px-0",
)}
data-tauri-drag-region
>
<button
aria-label="Cline home"
className="rounded-md p-1 text-sidebar-foreground transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
onClick={openHome}
type="button"
>
<ClineLogo className="h-6 w-6" />
</button>
{!isCollapsed ? (
<>
<Button
aria-label="Previous page"
className="size-7 text-muted-foreground hover:text-sidebar-foreground"
disabled={!canNavigateBack}
onClick={navigateBack}
size="icon"
title="Previous page"
type="button"
variant="ghost"
>
<ChevronLeft className="size-4" />
</Button>
<Button
aria-label="Next page"
className="size-7 text-muted-foreground hover:text-sidebar-foreground"
disabled={!canNavigateForward}
onClick={navigateForward}
size="icon"
title="Next page"
type="button"
variant="ghost"
>
<ChevronRight className="size-4" />
</Button>
</>
) : null}
</div>
<div className={cn("shrink-0 px-3", isCollapsed && "px-1.5")}>
<Button
className={cn(
"min-w-0 justify-start",
view === "chat" &&
isHomeActive &&
"bg-sidebar-accent text-sidebar-accent-foreground",
isCollapsed && "mx-auto size-9 justify-center px-0",
)}
aria-label="Home"
onClick={openHome}
title="Home"
variant="sidebarItem"
<div
className={cn(
"flex h-10 shrink-0 items-center justify-between px-3",
isCollapsed && "px-1.5",
)}
>
<HoverCard
closeDelay={100}
openDelay={0}
onOpenChange={(open) => {
if (open) {
void loadProcessContext();
}
}}
>
<Home className="size-4" />
{!isCollapsed ? "Home" : null}
</Button>
<HoverCardTrigger asChild>
<button
aria-label="Cline home"
className="flex size-8 shrink-0 items-center justify-center rounded-md text-sidebar-foreground transition-colors hover:bg-sidebar-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
onClick={openHome}
title="Home"
type="button"
>
<ClineLogo className="size-6" />
</button>
</HoverCardTrigger>
<HoverCardContent align="start" className="w-64 p-3" side="bottom">
<p className="text-sm font-medium">Cline Code</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{appVersion ? `Version ${appVersion}` : "Version unavailable"}
</p>
<div className="mt-3 border-border border-t pt-3">
<div className="flex items-center gap-2 text-xs">
<span
aria-hidden="true"
className={cn(
"h-2 w-2 shrink-0 rounded-full",
hubStatus?.connected
? "bg-emerald-500"
: "bg-muted-foreground",
)}
/>
<span className="font-medium">
Cline Hub @{hubPort(hubStatus?.url ?? null) ?? "unknown"}
</span>
</div>
{hubStatus && !hubStatus.connected && (
<p className="mt-1 text-[11px] text-destructive">
{hubStatus.error ?? "Cline Hub is not connected."}
</p>
)}
</div>
</HoverCardContent>
</HoverCard>
{!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>
{isCollapsed ? (
@@ -483,18 +656,7 @@ export function AgentSidebar({
collapsed
onSelect={openSettingsSection}
/>
) : (
<Button
aria-label="New session"
className="mx-auto size-9 justify-center px-0"
onClick={openNewThread}
title="New session"
type="button"
variant="sidebarItem"
>
<MessageSquare className="size-4" />
</Button>
)}
) : null}
<Button
aria-label="Expand sidebar"
className="mx-auto size-9 justify-center px-0"
@@ -542,17 +704,6 @@ export function AgentSidebar({
</Button>
{sortMenu}
{filterMenu}
<Button
aria-label="New session"
className="m-0! size-8 p-0! text-muted-foreground hover:text-sidebar-foreground"
onClick={openNewThread}
size="icon"
title="New session"
type="button"
variant="ghost"
>
<Plus className="size-4" />
</Button>
</div>
</div>
{searchOpen ? (
@@ -679,36 +830,47 @@ export function AgentSidebar({
)}
<div className="shrink-0 border-t border-sidebar-border/70 px-2 py-3">
<Button
aria-label="Settings"
type="button"
variant="sidebarItem"
className={cn(
"min-w-0 justify-start",
view === "settings" &&
"bg-sidebar-accent text-sidebar-accent-foreground",
isCollapsed && "mx-auto size-9 justify-center px-0",
)}
onClick={openSettings}
title="Settings"
>
<Settings className="size-4" />
{!isCollapsed ? "Settings" : null}
</Button>
{view !== "settings" && (
<Button
aria-label="Settings"
type="button"
variant="sidebarItem"
className={cn(
"min-w-0 justify-start",
isCollapsed && "mx-auto size-9 justify-center px-0",
)}
onClick={openSettings}
title="Settings"
>
<Settings className="size-4" />
{!isCollapsed ? "Settings" : null}
</Button>
)}
{!isCollapsed ? (
<div className="mt-2 flex items-center gap-2 rounded-md px-3 py-2 text-sidebar-foreground">
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-primary text-[11px] font-semibold text-primary-foreground">
C
</span>
<span className="min-w-0">
<button
aria-label="Account settings"
className={cn(
"flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sidebar-foreground transition-colors hover:bg-sidebar-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
view === "settings" &&
settingsSection === "Account" &&
"bg-sidebar-accent text-sidebar-accent-foreground",
)}
onClick={() => openSettingsSection("Account")}
title={user?.email || undefined}
type="button"
>
<span className="min-w-0 flex gap-2 items-center">
<span className="flex size-4 shrink-0 items-center justify-center rounded-full bg-primary text-[11px] font-semibold text-primary-foreground">
{accountInitial}
</span>
<span className="block truncate text-sm font-medium">
Cline Desktop
</span>
<span className="block text-[11px] text-muted-foreground">
Local
{accountName}
<span className="pl-1 truncate text-[11px] text-muted-foreground">
{accountScope}
</span>
</span>
</span>
</div>
</button>
) : null}
</div>
</div>
@@ -823,11 +985,9 @@ function ThreadItem({
pendingAction: "rename" | "fork" | "delete" | null;
unread: boolean;
}) {
const tokenLabel = formatTokenCount(thread.inputTokens, thread.outputTokens);
const costLabel = formatCostUsd(thread.totalCostUsd);
const title = normalizeTitle(thread.title);
const overviewTitle = getSessionOverviewTitle(title);
const pending = pendingAction !== null;
const workspacePath = thread.workspacePath || thread.codebase;
const statusDotClass = pending
? "bg-yellow-400"
: thread.status === "running"
@@ -835,20 +995,7 @@ function ThreadItem({
: unread
? "bg-blue-500"
: "";
const infoItems: Array<[string, string | null | undefined, string?]> = [
["ID", thread.id],
[
"Workspace",
workspaceDisplayName(workspacePath),
workspacePath || undefined,
],
["Status", thread.status],
["Updated", thread.time],
["Provider", thread.provider],
["Model", thread.model],
["Tokens", tokenLabel],
["Cost", costLabel],
].filter((item): item is [string, string, string?] => Boolean(item[1]));
const infoItems = getSessionOverviewItems(thread);
if (editing) {
return (
@@ -881,7 +1028,7 @@ function ThreadItem({
<HoverCardTrigger asChild>
<button
className={cn(
"group grid h-8 w-full max-w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-2 overflow-hidden rounded-md px-2 text-left text-sm font-normal transition-colors",
"group grid h-8 w-full max-w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-1 overflow-hidden rounded-md px-2 text-left text-sm font-normal transition-colors",
isActive
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent/50",
@@ -915,13 +1062,15 @@ function ThreadItem({
sideOffset={8}
>
<div className="min-w-0 space-y-2">
<div className="truncate text-sm font-medium">{title}</div>
<div className="wrap-break-word text-sm font-medium">
{overviewTitle}
</div>
<div className="grid grid-cols-[72px_minmax(0,1fr)] gap-x-2 gap-y-1 text-xs">
{infoItems.map(([label, value, fullValue]) => (
<div className="contents" key={label}>
<span className="text-muted-foreground">{label}</span>
<span
className="min-w-0 truncate font-mono"
className="min-w-0 truncate font-mono font-thin text-foreground"
title={fullValue}
>
{value}
@@ -942,6 +1091,35 @@ function ThreadItem({
);
}
export function getSessionOverviewTitle(title: string): string {
const firstLine = title.split(/\r?\n/, 1)[0] ?? "";
return normalizeTitle(firstLine);
}
export function getSessionOverviewItems(
thread: SessionThread,
): Array<[string, string, string?]> {
const workspacePath = thread.workspacePath || thread.codebase;
const items: Array<[string, string | null | undefined, string?]> = [
[
"Workspace",
workspaceDisplayName(workspacePath),
workspacePath || undefined,
],
["Branch", thread.gitBranch],
["Provider", thread.provider],
["Model", thread.model],
["Tokens", formatTokenCount(thread.inputTokens, thread.outputTokens)],
["Cost", formatCostUsd(thread.totalCostUsd)],
["ID", thread.id],
["Source", thread.source],
["Updated", thread.time],
];
return items.filter((item): item is [string, string, string?] =>
Boolean(item[1]),
);
}
function EditableSessionTitle({
value,
disabled,
@@ -98,9 +98,32 @@ describe("MemoizedMarkdown interactions", () => {
});
});
test("requires confirmation before opening an external link", async () => {
test("opens honest external links directly in the default browser", async () => {
const url = "https://example.com/review?source=cline";
await renderMarkdown({ content: `[Review docs](${url})` });
const link = await vi.waitFor(() => {
const renderedLink = container.querySelector<HTMLAnchorElement>(
'[data-streamdown="link"]',
);
expect(renderedLink).not.toBeNull();
return renderedLink as HTMLAnchorElement;
});
expect(link.getAttribute("href")).toBe(url);
expect(link.getAttribute("title")).toBe(url);
await click(link);
expect(document.querySelector('[role="alertdialog"]')).toBeNull();
expect(openWindow).toHaveBeenCalledTimes(1);
expect(openWindow).toHaveBeenCalledWith(
url,
"_blank",
"noopener,noreferrer",
);
});
test("requires confirmation before opening a deceptive external link", async () => {
const url = "https://example.com/review?source=cline";
await renderMarkdown({ content: `[github.com/cline](${url})` });
const link = await vi.waitFor(() => {
const renderedLink = container.querySelector<HTMLElement>(
'[data-streamdown="link"]',
@@ -140,12 +163,50 @@ describe("MemoizedMarkdown interactions", () => {
await click(getButton("Open link"));
expect(openWindow).toHaveBeenCalledTimes(1);
expect(openWindow).toHaveBeenCalledWith(url, "_blank", "noreferrer");
expect(openWindow).toHaveBeenCalledWith(
url,
"_blank",
"noopener,noreferrer",
);
await vi.waitFor(() => {
expect(document.querySelector('[role="alertdialog"]')).toBeNull();
});
});
// The open_external_url sidecar command only opens http(s)/mailto/tel, and
// relies on Streamdown's harden step blocking every other scheme before it
// reaches SafeMarkdownLink. If a Streamdown upgrade starts letting other
// schemes through, confirming those links would silently open nothing.
test("blocks link schemes the sidecar cannot open before they render", async () => {
for (const url of ["vscode://settings/editor", "ftp://example.com/f"]) {
await renderMarkdown({ content: `[Open app](${url})` });
await vi.waitFor(() => {
expect(container.textContent).toContain("Open app");
});
expect(container.querySelector('[data-streamdown="link"]')).toBeNull();
}
});
test("opens mailto links directly through the external opener", async () => {
await renderMarkdown({ content: "[Email us](mailto:hi@cline.bot)" });
const link = await vi.waitFor(() => {
const renderedLink = container.querySelector<HTMLElement>(
'[data-streamdown="link"]',
);
expect(renderedLink).not.toBeNull();
return renderedLink as HTMLElement;
});
expect(link.tagName).toBe("A");
await click(link);
expect(document.querySelector('[role="alertdialog"]')).toBeNull();
expect(openWindow).toHaveBeenCalledWith(
"mailto:hi@cline.bot",
"_blank",
"noopener,noreferrer",
);
});
test("keeps same-document links navigable without a confirmation", async () => {
await renderMarkdown({ content: "[Details](#details)" });
const link = container.querySelector<HTMLAnchorElement>(
@@ -55,16 +55,86 @@ const ready = true;
expect(html).toContain("stillStreaming");
});
test("routes external links through confirmation controls", () => {
test("renders honest external links with their real destination", () => {
const html = renderToStaticMarkup(
<MemoizedMarkdown content="[Review](https://example.com/review)" />,
);
expect(html).toContain('data-streamdown="link"');
expect(html).toContain("Review");
expect(html).toContain('href="https://example.com/review"');
expect(html).not.toContain('aria-haspopup="dialog"');
});
test("keeps external links whose URL text matches the destination direct", () => {
const html = renderToStaticMarkup(
<MemoizedMarkdown content="[example.com/review](https://www.example.com/review)" />,
);
expect(html).toContain('href="https://www.example.com/review"');
expect(html).not.toContain('aria-haspopup="dialog"');
});
test("routes deceptive URL-text links through confirmation controls", () => {
const html = renderToStaticMarkup(
<MemoizedMarkdown content="[github.com/cline](https://evil.example/payload)" />,
);
expect(html).toContain('data-streamdown="link"');
expect(html).toContain('href="#confirm-external-link"');
expect(html).toContain('aria-haspopup="dialog"');
expect(html).not.toContain('href="https://example.com/review"');
expect(html).not.toContain('href="https://evil.example/payload"');
});
test("treats fully qualified trailing-dot hostnames like their plain form", () => {
const deceptiveHtml = renderToStaticMarkup(
<MemoizedMarkdown content="[github.com.](https://evil.example/payload)" />,
);
expect(deceptiveHtml).toContain('href="#confirm-external-link"');
expect(deceptiveHtml).not.toContain('href="https://evil.example/payload"');
const honestHtml = renderToStaticMarkup(
<MemoizedMarkdown content="[github.com.](https://github.com/cline)" />,
);
expect(honestHtml).toContain('href="https://github.com/cline"');
expect(honestHtml).not.toContain('aria-haspopup="dialog"');
});
test("treats protocol-relative labels like their https form", () => {
const deceptiveHtml = renderToStaticMarkup(
<MemoizedMarkdown content="[//github.com](https://evil.example/payload)" />,
);
expect(deceptiveHtml).toContain('href="#confirm-external-link"');
expect(deceptiveHtml).not.toContain('href="https://evil.example/payload"');
const honestHtml = renderToStaticMarkup(
<MemoizedMarkdown content="[//github.com](https://github.com/cline)" />,
);
expect(honestHtml).toContain('href="https://github.com/cline"');
expect(honestHtml).not.toContain('aria-haspopup="dialog"');
});
test("sees through inline formatting inside deceptive URL text", () => {
const html = renderToStaticMarkup(
<MemoizedMarkdown content="[**github.com**/cline](https://evil.example/payload)" />,
);
expect(html).toContain('href="#confirm-external-link"');
expect(html).not.toContain('href="https://evil.example/payload"');
});
test("treats scheme and port mismatches as deceptive", () => {
const schemeHtml = renderToStaticMarkup(
<MemoizedMarkdown content="[https://example.com](http://example.com/login)" />,
);
expect(schemeHtml).toContain('href="#confirm-external-link"');
expect(schemeHtml).not.toContain('href="http://example.com/login"');
const portHtml = renderToStaticMarkup(
<MemoizedMarkdown content="[example.com](https://example.com:8080/admin)" />,
);
expect(portHtml).toContain('href="#confirm-external-link"');
expect(portHtml).not.toContain('href="https://example.com:8080/admin"');
});
test("leaves app-local and fragment links navigable", () => {
@@ -1,6 +1,6 @@
import { cjk } from "@streamdown/cjk";
import type { ComponentProps, MouseEvent } from "react";
import { memo, useState } from "react";
import type { ComponentProps, MouseEvent, ReactNode } from "react";
import { isValidElement, memo, useState } from "react";
import {
type Components,
type ControlsConfig,
@@ -8,6 +8,7 @@ import {
type LinkSafetyModalProps,
Streamdown,
} from "streamdown";
import { openExternalUrl } from "@/lib/desktop-client";
import {
AlertDialog,
AlertDialogAction,
@@ -61,6 +62,82 @@ export function MarkdownLinkSafetyModal({
type MarkdownLinkProps = ComponentProps<"a"> & ExtraProps;
function extractLinkText(children: ReactNode): string {
if (typeof children === "string" || typeof children === "number") {
return String(children);
}
if (Array.isArray(children)) {
return children.map(extractLinkText).join("");
}
// Inline formatting (**bold**, `code`, …) nests the label text inside
// elements; recurse so styled hostnames can't dodge the deception check.
if (isValidElement(children)) {
return extractLinkText(
(children.props as { children?: ReactNode }).children,
);
}
return "";
}
// Tolerates one trailing dot after the TLD ("github.com." resolves the same
// as "github.com" in browsers) and a protocol-relative "//" prefix, so those
// label spellings can't slip past the deception check.
const urlLikeTextPattern =
/^(?:https?:\/\/|\/\/)?(?:[\w-]+\.)+[a-z]{2,}\.?(?:[/:?#]\S*)?$/i;
type LinkParts = {
protocol: string;
hostname: string;
port: string;
explicitScheme: boolean;
};
function parseLinkParts(value: string): LinkParts | null {
const explicitScheme = /^[a-z][a-z\d+.-]*:/i.test(value);
const withScheme = explicitScheme
? value
: value.startsWith("//")
? `https:${value}`
: `https://${value}`;
try {
const parsed = new URL(withScheme);
const hostname = parsed.hostname
.toLowerCase()
.replace(/\.+$/, "")
.replace(/^www\./, "");
if (!hostname) return null;
return {
explicitScheme,
hostname,
port: parsed.port,
protocol: parsed.protocol,
};
} catch {
return null;
}
}
/**
* A link is deceptive when its visible text reads as a URL that does not
* match the real destination the one shape where a click genuinely
* surprises the user. Only those links get the confirmation dialog;
* ordinary external links open directly. The label and destination must
* agree on hostname and port, and on scheme when the label states one.
*/
function isDeceptiveLink(children: ReactNode, url: string): boolean {
const text = extractLinkText(children).trim();
if (!text || !urlLikeTextPattern.test(text)) return false;
const textParts = parseLinkParts(text);
if (!textParts) return false;
const urlParts = parseLinkParts(url);
if (!urlParts) return true;
return (
textParts.hostname !== urlParts.hostname ||
textParts.port !== urlParts.port ||
(textParts.explicitScheme && textParts.protocol !== urlParts.protocol)
);
}
function SafeMarkdownLink({
children,
className,
@@ -108,6 +185,36 @@ function SafeMarkdownLink({
);
}
// Streamdown's harden step only lets http(s), mailto, tel, and
// protocol-relative URLs reach this component, matching the sidecar's
// open_external_url allowlist. Protocol-relative URLs fail the sidecar's
// `new URL()` parse, so pin them to https before handing them off.
const externalUrl = url.startsWith("//") ? `https:${url}` : url;
const openExternally = () => void openExternalUrl(externalUrl);
if (!isDeceptiveLink(children, externalUrl)) {
const openDirectly = (event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
openExternally();
};
return (
<a
{...props}
className={`wrap-anywhere font-medium text-primary underline ${className ?? ""}`}
data-streamdown="link"
href={externalUrl}
onAuxClick={(event) => {
if (event.button === 1) openDirectly(event);
}}
onClick={openDirectly}
rel="noreferrer"
title={title ?? externalUrl}
>
{children}
</a>
);
}
const openConfirmation = (event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
setIsOpen(true);
@@ -127,15 +234,15 @@ function SafeMarkdownLink({
href="#confirm-external-link"
onAuxClick={confirmMiddleClick}
onClick={openConfirmation}
title={title ?? url}
title={title ?? externalUrl}
>
{children}
</a>
<MarkdownLinkSafetyModal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
onConfirm={() => window.open(url, "_blank", "noreferrer")}
url={url}
onConfirm={openExternally}
url={externalUrl}
/>
</>
);
@@ -202,7 +309,7 @@ export const MemoizedMarkdown = memo(
controls={streamdownControls}
dir="auto"
isAnimating={streaming}
lineNumbers
lineNumbers={false}
mode={streaming ? "streaming" : "static"}
normalizeHtmlIndentation
parseIncompleteMarkdown={streaming}
@@ -28,7 +28,9 @@ const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = 240;
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
// The collapsed desktop sidebar still owns the macOS title-bar controls when
// the native title bar overlays the webview.
const SIDEBAR_WIDTH_ICON = "4.5rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
const SIDEBAR_WIDTH_COOKIE_NAME = "sidebar_width";
const SIDEBAR_MIN_WIDTH = 224;
@@ -313,8 +315,8 @@ function Sidebar({
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
? "left-0 group-data-[collapsible=offcanvas]:-left-(--sidebar-width)"
: "right-0 group-data-[collapsible=offcanvas]:-right-(--sidebar-width)",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
@@ -60,6 +60,7 @@ describe("ChatInputBar", () => {
refreshWorkspaces: vi.fn(async () => undefined),
switchWorkspace: vi.fn(async () => true),
pickWorkspaceDirectory: vi.fn(async () => null),
selectChat: vi.fn(async () => true),
}}
>
<ChatInputBar
@@ -139,6 +140,7 @@ describe("ChatInputBar", () => {
refreshWorkspaces: vi.fn(async () => undefined),
switchWorkspace: vi.fn(async () => true),
pickWorkspaceDirectory: vi.fn(async () => null),
selectChat: vi.fn(async () => true),
}}
>
<ChatInputBar
@@ -1,5 +1,6 @@
"use client";
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared/browser";
import {
ArrowUp,
Brain,
@@ -13,14 +14,6 @@ import {
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import {
Select,
SelectContent,
@@ -42,6 +35,7 @@ import {
loadProviderModels,
} from "@/lib/provider-model-catalog";
import { cn } from "@/lib/utils";
import { SearchableSelect } from "./searchable-select";
import { WorkspaceSelector } from "./workspace-selector";
type ActiveMention = {
@@ -69,7 +63,7 @@ const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
];
const FALLBACK_PROVIDER_MODELS: Record<string, string[]> = {
cline: ["anthropic/claude-sonnet-4.6"],
cline: [CLINE_DEFAULT_MODEL_ID],
anthropic: ["claude-sonnet-4-6"],
"openai-native": ["gpt-5.5"],
openrouter: ["anthropic/claude-sonnet-4.6"],
@@ -77,7 +71,7 @@ const FALLBACK_PROVIDER_MODELS: Record<string, string[]> = {
};
const FALLBACK_PROVIDER_REASONING_MODELS: Record<string, string[]> = {
cline: ["anthropic/claude-sonnet-4.6"],
cline: [CLINE_DEFAULT_MODEL_ID],
anthropic: ["claude-sonnet-4-6"],
"openai-native": ["gpt-5.5"],
openrouter: ["anthropic/claude-sonnet-4.6"],
@@ -1045,7 +1039,7 @@ export function ChatInputBar({
ref={fileInputRef}
type="file"
/>
<div className="flex shrink-0 items-center rounded-md bg-muted p-0.5 max-[560px]:col-start-2 max-[560px]:row-start-1">
<div className="hidden flex shrink-0 items-center rounded-md bg-muted p-0.5 max-[560px]:col-start-2 max-[560px]:row-start-1">
<button
aria-pressed={mode === "plan"}
className={cn(
@@ -1087,7 +1081,6 @@ export function ChatInputBar({
}
onProviderChange={onProviderChange}
provider={provider}
variant={variant}
/>
</div>
<Select
@@ -1097,7 +1090,7 @@ export function ChatInputBar({
>
<SelectTrigger
aria-label="Thinking level"
className="h-7 min-w-[5.75rem] gap-1.5 border-0 bg-muted px-2 text-[11px] shadow-none data-[size=sm]:h-7 max-[560px]:col-span-2 max-[560px]:col-start-1 max-[560px]:row-start-2"
className="h-7 gap-1.5 border-0 bg-muted px-2 text-[11px] shadow-none data-[size=sm]:h-7 [&>svg:last-child]:hidden max-[560px]:col-span-2 max-[560px]:col-start-1 max-[560px]:row-start-2"
size="sm"
title={
modelSupportsReasoning === false
@@ -1128,7 +1121,7 @@ export function ChatInputBar({
</div>
<div className="ml-auto flex min-w-0 shrink-0 items-center gap-2 max-[560px]:contents">
<div className="max-w-48 overflow-visible max-[720px]:max-w-36 max-[560px]:col-start-3 max-[560px]:row-start-2">
<div className="hidden max-w-48 overflow-visible max-[720px]:max-w-36 max-[560px]:col-start-3 max-[560px]:row-start-2">
<WorkspaceSelector
currentBranch={gitBranch}
onListGitBranches={onListGitBranches}
@@ -1181,7 +1174,6 @@ function ModelSelector({
provider,
model,
isBusy,
variant,
onProviderChange,
onModelChange,
onModelSupportsReasoningChange,
@@ -1189,7 +1181,6 @@ function ModelSelector({
provider: string;
model: string;
isBusy: boolean;
variant: "conversation" | "welcome";
onProviderChange: (provider: string) => void;
onModelChange: (model: string) => void;
onModelSupportsReasoningChange: (supportsReasoning: boolean | null) => void;
@@ -1416,13 +1407,13 @@ function ModelSelector({
]);
return (
<div className="flex min-w-0 shrink-0 items-center gap-1 text-[11px]">
<Combobox
<div className="flex min-w-0 shrink-0 items-center gap-0.5 text-[11px]">
<SearchableSelect
ariaLabel="Provider"
disabled={isBusy || providers.length === 0}
emptyLabel="No providers found."
items={providers}
onValueChange={(value) => {
if (!value) {
return;
}
onSelect={(value) => {
onProviderChange(value);
const rememberedModel = lastSelection.lastModelByProvider[value];
const providerModelIds = visibleProviderModels[value] ?? [];
@@ -1439,63 +1430,23 @@ function ModelSelector({
onModelChange(firstModel);
}
}}
placeholder="Provider"
searchPlaceholder="Search providers"
triggerClassName="max-w-28 text-[11px]"
value={resolvedProvider}
>
<ComboboxInput
aria-label="Provider"
className={cn(
"h-7 text-[11px] max-[560px]:w-20",
variant === "welcome" && "w-24 border-0 bg-transparent shadow-none",
)}
disabled={isBusy || providers.length === 0}
readOnly
showClear={false}
showTrigger
/>
<ComboboxContent>
<ComboboxEmpty>No providers found.</ComboboxEmpty>
<ComboboxList>
{(item) => (
<ComboboxItem className="text-[11px]" key={item} value={item}>
{item}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<Combobox
/>
<span className="text-muted-foreground/50">/</span>
<SearchableSelect
ariaLabel="Model"
disabled={isBusy || modelsForProvider.length === 0}
emptyLabel="No models found."
items={modelsForProvider}
onValueChange={(value) => {
if (!value) {
return;
}
onModelChange(value);
}}
onSelect={(value) => onModelChange(value)}
placeholder="Model"
searchPlaceholder="Search models"
triggerClassName="max-w-52 text-[11px]"
value={resolvedModel}
>
<ComboboxInput
aria-label="Model"
className={cn(
"h-7 text-[11px] max-[560px]:w-32",
variant === "welcome" && "w-52 border-0 bg-transparent shadow-none",
)}
disabled={isBusy || modelsForProvider.length === 0}
readOnly
showClear={false}
showTrigger
/>
<ComboboxContent>
<ComboboxEmpty>No models found.</ComboboxEmpty>
<ComboboxList>
{(item) => (
<ComboboxItem className="text-[11px]" key={item} value={item}>
{item}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
/>
</div>
);
}
@@ -23,7 +23,10 @@ afterEach(async () => {
vi.restoreAllMocks();
});
async function renderMessages(messages: ChatMessage[]) {
async function renderMessages(
messages: ChatMessage[],
overrides: Partial<Parameters<typeof ChatMessages>[0]> = {},
) {
await act(async () => {
root.render(
<ChatMessages
@@ -37,6 +40,7 @@ async function renderMessages(messages: ChatMessage[]) {
pendingToolApprovals={[]}
sessionId="session-1"
status="completed"
{...overrides}
/>,
);
});
@@ -90,4 +94,312 @@ describe("ChatMessages tool disclosures", () => {
"workspace selector",
);
});
it("groups consecutive tool calls and combines matching activity totals", async () => {
const tools: ChatMessage[] = [
{
id: "read",
sessionId: "session-1",
role: "tool",
content: JSON.stringify({
toolName: "read_files",
input: { paths: ["one.ts", "two.ts"] },
result: {},
}),
createdAt: 1,
},
...["one.ts", "two.ts", "three.ts", "four.ts"].map(
(path, index): ChatMessage => ({
id: `edit-${index}`,
sessionId: "session-1",
role: "tool",
content: JSON.stringify({
toolName: "editor",
input: { path, old_text: "before", new_text: "after" },
result: {},
}),
createdAt: index + 2,
}),
),
];
await renderMessages(tools);
expect(container.textContent).toContain("Read 2 files. Edited 4 files");
expect(container.textContent?.match(/Read 2 files/g)).toHaveLength(1);
});
it("preserves interleaved tool activity order", async () => {
const read = (
id: string,
path: string,
createdAt: number,
): ChatMessage => ({
id,
sessionId: "session-1",
role: "tool",
content: JSON.stringify({
toolName: "read_files",
input: { paths: [path] },
result: {},
}),
createdAt,
});
await renderMessages([
read("read-before", "before.ts", 1),
{
id: "edit",
sessionId: "session-1",
role: "tool",
content: JSON.stringify({
toolName: "editor",
input: {
path: "change.ts",
old_text: "before",
new_text: "after",
},
result: {},
}),
createdAt: 2,
},
read("read-after", "after.ts", 3),
]);
expect(container.textContent).toContain(
"Read 1 file. Edited 1 file. Read 1 file",
);
});
it("starts a new tool group after non-tool content", async () => {
const tool = (id: string, createdAt: number): ChatMessage => ({
id,
sessionId: "session-1",
role: "tool",
content: JSON.stringify({
toolName: "read_files",
input: { paths: [`${id}.ts`] },
result: {},
}),
createdAt,
});
await renderMessages([
tool("first", 1),
{
id: "assistant",
sessionId: "session-1",
role: "assistant",
content: "Between tools",
createdAt: 2,
},
tool("second", 3),
]);
expect(container.textContent?.match(/Read 1 file/g)).toHaveLength(2);
});
it("normalizes payload-backed configured subagent names", async () => {
await renderMessages([
{
id: "commands",
sessionId: "session-1",
role: "tool",
content: JSON.stringify({
toolName: "run_commands",
input: { commands: ["bun test", "bun run typecheck"] },
result: {},
}),
createdAt: 1,
},
...[2, 3, 4].map(
(createdAt): ChatMessage => ({
id: `configured-subagent-${createdAt}`,
sessionId: "session-1",
role: "tool",
content: JSON.stringify({
toolName: "subagent_subagent",
input: { prompt: "Investigate" },
result: { text: "Done" },
}),
createdAt,
}),
),
]);
expect(container.textContent).toContain(
"Ran 2 commands. spawn_agent. spawn_agent. spawn_agent",
);
expect(container.textContent).not.toContain("subagent_subagent");
});
it("does not render assistant actions without text content", async () => {
await renderMessages([
{
id: "reasoning-only",
sessionId: "session-1",
role: "assistant",
content: "",
reasoning: "Internal reasoning",
createdAt: 1,
},
]);
expect(
container.querySelector('button[aria-label="Copy assistant message"]'),
).toBeNull();
});
});
describe("ChatMessages image attachments", () => {
it("renders persisted image blocks in the user message", async () => {
await renderMessages([
{
id: "user-image",
sessionId: "session-1",
role: "user",
content: "Describe this",
images: [
{ id: "user-image-1", mediaType: "image/png", data: "aGVsbG8=" },
],
createdAt: 1,
},
]);
const image = container.querySelector<HTMLImageElement>(
'img[alt="Attachment 1"]',
);
expect(image?.src).toBe("data:image/png;base64,aGVsbG8=");
expect(image?.className).toContain("max-h-[225px]");
expect(image?.className).toContain("max-w-[225px]");
expect(container.textContent).toContain("Describe this");
});
it("expands an attachment within the conversation and closes it", async () => {
await renderMessages([
{
id: "user-image",
sessionId: "session-1",
role: "user",
content: "Describe this",
images: [
{ id: "user-image-1", mediaType: "image/png", data: "aGVsbG8=" },
],
createdAt: 1,
},
]);
const expand = container.querySelector<HTMLButtonElement>(
'button[aria-label="Expand attachment 1"]',
);
await act(async () => expand?.click());
expect(
container.querySelector(
'[role="dialog"][aria-label="Expanded attachment"]',
),
).not.toBeNull();
expect(
container.querySelector<HTMLImageElement>(
'img[alt="Expanded attachment"]',
)?.src,
).toBe("data:image/png;base64,aGVsbG8=");
await act(async () => {
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
});
expect(container.querySelector('[role="dialog"]')).toBeNull();
});
});
describe("ChatMessages thinking indicator", () => {
const userMessage: ChatMessage = {
id: "user-1",
sessionId: "session-1",
role: "user",
content: "Hello",
createdAt: 1,
};
it("shows while starting", async () => {
await renderMessages([userMessage], { status: "starting" });
expect(container.textContent).toContain("Thinking...");
});
it("keeps showing while running until the first assistant output arrives", async () => {
await renderMessages([userMessage], { status: "running" });
expect(container.textContent).toContain("Thinking...");
});
it("ignores trailing status messages when deciding to show", async () => {
await renderMessages(
[
userMessage,
{
id: "status-1",
sessionId: "session-1",
role: "status",
content: "Session started: session-1",
createdAt: 2,
},
],
{ status: "running" },
);
expect(container.textContent).toContain("Thinking...");
});
it("hides once assistant output is streaming", async () => {
await renderMessages(
[
userMessage,
{
id: "assistant-1",
sessionId: "session-1",
role: "assistant",
content: "Working on it",
createdAt: 2,
},
],
{ status: "running", streamingMessageId: "assistant-1" },
);
expect(container.textContent).not.toContain("Thinking...");
});
it("hides while a tool runs", async () => {
await renderMessages(
[
userMessage,
{
id: "tool-1",
sessionId: "session-1",
role: "tool",
content: "not-json",
createdAt: 2,
meta: { toolName: "search" },
},
],
{ status: "running" },
);
expect(container.textContent).not.toContain("Thinking...");
});
it("hides while a tool approval is pending", async () => {
await renderMessages([userMessage], {
status: "running",
pendingToolApprovals: [
{
requestId: "req-1",
sessionId: "session-1",
createdAt: new Date(1).toISOString(),
toolCallId: "call-1",
toolName: "execute_command",
},
],
});
expect(container.textContent).not.toContain("Thinking...");
});
});
@@ -34,11 +34,16 @@ import {
SplitIcon,
SquareTerminalIcon,
UndoIcon,
X,
} from "lucide-react";
import { memo, useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { toast } from "@/hooks/use-toast";
import type { ChatMessage, ChatSessionStatus } from "@/lib/chat-schema";
import type {
ChatMessage,
ChatMessageImage,
ChatSessionStatus,
} from "@/lib/chat-schema";
import { parseApplyPatchInput } from "@/lib/session-diff";
import { cn } from "@/lib/utils";
import { MemoizedMarkdown } from "../../ui/markdown";
@@ -92,6 +97,29 @@ type AskQuestionRequestItem = {
};
};
type ChatRenderItem =
| { type: "message"; message: ChatMessage }
| { type: "tools"; messages: ChatMessage[] };
function groupConsecutiveToolMessages(
messages: ChatMessage[],
): ChatRenderItem[] {
const items: ChatRenderItem[] = [];
for (const message of messages) {
const previous = items.at(-1);
if (message.role === "tool") {
if (previous?.type === "tools") {
previous.messages.push(message);
} else {
items.push({ type: "tools", messages: [message] });
}
continue;
}
items.push({ type: "message", message });
}
return items;
}
const IS_DEBUG = process.env.NODE_ENV === "test";
function ChatMessagesImpl({
@@ -116,6 +144,18 @@ function ChatMessagesImpl({
.find((message) => message.role === "error");
const shouldShowErrorBanner =
Boolean(error) && (!lastErrorMessage || lastErrorMessage.content !== error);
// Core reports "running" as soon as the turn is dispatched, well before the
// first streamed chunk arrives, so keep the thinking indicator up until the
// model produces output (or something else needs the user's attention).
const lastConversationMessage = [...messages]
.reverse()
.find((message) => message.role !== "status");
const isAwaitingFirstOutput =
status === "running" &&
!streamingMessageId &&
lastConversationMessage?.role === "user" &&
pendingToolApprovals.length === 0 &&
pendingAskQuestions.length === 0;
const [showSwitchTransition, setShowSwitchTransition] = useState(false);
const [toolApprovalActions, setToolApprovalActions] = useState<
Record<string, "approving" | "rejecting">
@@ -138,9 +178,28 @@ function ChatMessagesImpl({
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
const [forkingMessageId, setForkingMessageId] = useState<string | null>(null);
const [forkErrors, setForkErrors] = useState<Record<string, string>>({});
const [expandedImage, setExpandedImage] = useState<{
sessionId: string | null;
image: ChatMessageImage;
} | null>(null);
const visibleExpandedImage =
expandedImage?.sessionId === sessionId ? expandedImage.image : null;
const showIdleDetails =
!hasMessages && !isSessionSwitching && !showSwitchTransition;
useEffect(() => {
if (!visibleExpandedImage) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setExpandedImage(null);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [visibleExpandedImage]);
useEffect(() => {
if (!isSessionSwitching) {
setShowSwitchTransition((prev) => (prev ? false : prev));
@@ -318,7 +377,7 @@ function ChatMessagesImpl({
return (
<Conversation
className="h-full min-h-0 min-w-0"
className="relative isolate h-full min-h-0 min-w-0 overflow-hidden"
key={sessionId ?? "new-chat"}
>
<ConversationViewport
@@ -364,36 +423,50 @@ function ChatMessagesImpl({
requestErrors={askQuestionErrors}
/>
) : null}
{messages.map((message) => (
<MessageBubble
isStreaming={streamingMessageId === message.id}
key={message.id}
message={message}
onCopyRawText={() =>
void handleCopyMessage(message.id, message.content)
}
onRestoreCheckpoint={(runCount) =>
void handleRestoreCheckpoint(message.id, runCount)
}
restoreDisabled={
!onRestoreCheckpoint ||
status === "starting" ||
status === "running" ||
status === "stopping" ||
isSessionSwitching
}
restoreError={checkpointErrors[message.id]}
restorePending={checkpointActions[message.id] === "undoing"}
wasCopied={copiedMessageId === message.id}
onForkSession={
onForkSession
? () => void handleForkSession(message.id)
: undefined
}
forkPending={forkingMessageId === message.id}
forkError={forkErrors[message.id]}
/>
))}
{groupConsecutiveToolMessages(messages).map((item) => {
if (item.type === "tools") {
return (
<ToolMessageBlock
key={`tools_${item.messages[0]?.id ?? "empty"}`}
messages={item.messages}
/>
);
}
const { message } = item;
return (
<MessageBubble
isStreaming={streamingMessageId === message.id}
key={message.id}
message={message}
onExpandImage={(image) =>
setExpandedImage({ sessionId, image })
}
onCopyRawText={() =>
void handleCopyMessage(message.id, message.content)
}
onRestoreCheckpoint={(runCount) =>
void handleRestoreCheckpoint(message.id, runCount)
}
restoreDisabled={
!onRestoreCheckpoint ||
status === "starting" ||
status === "running" ||
status === "stopping" ||
isSessionSwitching
}
restoreError={checkpointErrors[message.id]}
restorePending={checkpointActions[message.id] === "undoing"}
wasCopied={copiedMessageId === message.id}
onForkSession={
onForkSession
? () => void handleForkSession(message.id)
: undefined
}
forkPending={forkingMessageId === message.id}
forkError={forkErrors[message.id]}
/>
);
})}
</div>
)}
{showSwitchTransition ? (
@@ -418,7 +491,8 @@ function ChatMessagesImpl({
</div>
)
) : null}
{status === "starting" && !isSessionSwitching ? (
{(status === "starting" || isAwaitingFirstOutput) &&
!isSessionSwitching ? (
<div className="mt-4 flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Thinking...
@@ -442,6 +516,39 @@ function ChatMessagesImpl({
</ConversationContent>
</ConversationViewport>
<ConversationScrollButton />
{visibleExpandedImage ? (
<div
aria-label="Expanded attachment"
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 attachment"
className="absolute inset-0 cursor-zoom-out"
onClick={() => setExpandedImage(null)}
type="button"
/>
<div className="pointer-events-none relative z-10 flex h-full w-full items-center justify-center">
{/* biome-ignore lint/performance/noImgElement: User-provided data URLs cannot use Next's optimizer. */}
<img
alt="Expanded attachment"
className="max-h-full max-w-full rounded-lg object-contain shadow-2xl"
src={`data:${visibleExpandedImage.mediaType};base64,${visibleExpandedImage.data}`}
/>
<Button
aria-label="Close image viewer"
className="pointer-events-auto absolute right-0 top-0 rounded-full"
onClick={() => setExpandedImage(null)}
size="icon"
type="button"
variant="secondary"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
) : null}
</Conversation>
);
}
@@ -644,6 +751,7 @@ function MessageBubble({
message,
isStreaming = false,
onCopyRawText,
onExpandImage,
onRestoreCheckpoint,
restoreDisabled = false,
restorePending = false,
@@ -656,6 +764,7 @@ function MessageBubble({
message: ChatMessage;
isStreaming?: boolean;
onCopyRawText?: () => void;
onExpandImage?: (image: ChatMessageImage) => void;
onRestoreCheckpoint?: (runCount: number) => void;
restoreDisabled?: boolean;
restorePending?: boolean;
@@ -668,24 +777,21 @@ function MessageBubble({
const isUser = message.role === "user";
const isError = message.role === "error";
const checkpoint = message.meta?.checkpoint;
const displayContent = formatChatMessageContent(
message.role,
message.content,
);
const shouldRenderAssistantActions =
message.role === "assistant" &&
!isStreaming &&
!isError &&
Boolean(displayContent.trim()) &&
Boolean(onCopyRawText || onForkSession);
const shouldRenderUserActions =
isUser && Boolean(onCopyRawText || checkpoint);
const keepUserActionsVisible = restorePending || Boolean(restoreError);
const keepAssistantActionsVisible = forkPending || Boolean(forkError);
if (message.role === "tool") {
return <ToolMessageBlock message={message} />;
}
const displayContent = formatChatMessageContent(
message.role,
message.content,
);
const reasoningContent = message.reasoning?.trim() || "";
return (
@@ -699,12 +805,35 @@ function MessageBubble({
/>
) : null}
<div className="my-1 min-w-0 max-w-full wrap-break-word">
<MemoizedMarkdown
content={displayContent || " "}
streaming={isStreaming && message.role === "assistant"}
/>
</div>
{message.images?.length ? (
<div className="grid max-w-2xl gap-2">
{message.images.map((image, index) => (
<button
aria-label={`Expand attachment ${index + 1}`}
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"
key={image.id}
onClick={() => onExpandImage?.(image)}
type="button"
>
{/* biome-ignore lint/performance/noImgElement: User-provided data URLs do not have dimensions and cannot use Next's optimizer. */}
<img
alt={`Attachment ${index + 1}`}
className="max-h-[225px] max-w-[225px] object-contain"
src={`data:${image.mediaType};base64,${image.data}`}
/>
</button>
))}
</div>
) : null}
{displayContent ? (
<div className="my-1 min-w-0 max-w-full wrap-break-word">
<MemoizedMarkdown
content={displayContent}
streaming={isStreaming && message.role === "assistant"}
/>
</div>
) : null}
</MessageContent>
{shouldRenderUserActions ? (
@@ -822,6 +951,13 @@ type ToolPayload = {
type ToolSummary = {
label: string;
details: string[];
aggregate?: {
key: string;
count: number;
noun: string;
completedVerb: string;
progressVerb: string;
};
diff?: {
additions: number;
deletions: number;
@@ -921,7 +1057,10 @@ function classifyTool(
)
return "file-edit";
if (["bash", "run_commands"].includes(normalized)) return "bash";
if (["spawn_agent", "spawn-agent", "spawn_agent_tool"].includes(normalized))
if (
["spawn_agent", "spawn-agent", "spawn_agent_tool"].includes(normalized) ||
normalized.startsWith("subagent_")
)
return "spawn";
return "tool";
}
@@ -1038,6 +1177,13 @@ function buildToolSummary(
if (files.length > 0) {
return {
label: `${inProgress ? "Reading" : "Read"} ${pluralize(files.length, "file")}`,
aggregate: {
key: "read-files",
count: files.length,
noun: "file",
completedVerb: "Read",
progressVerb: "Reading",
},
details: files.map(
(file) => `${inProgress ? "Reading" : "Read"} ${toDisplayPath(file)}`,
),
@@ -1050,6 +1196,13 @@ function buildToolSummary(
if (queries.length > 0) {
return {
label: `${inProgress ? "Exploring" : "Explored"} ${pluralize(queries.length, "search")}`,
aggregate: {
key: "searches",
count: queries.length,
noun: "search",
completedVerb: "Explored",
progressVerb: "Exploring",
},
details: queries.map((query) => query),
};
}
@@ -1060,6 +1213,13 @@ function buildToolSummary(
if (commands.length > 0) {
return {
label: `${inProgress ? "Running" : "Ran"} ${pluralize(commands.length, "command")}`,
aggregate: {
key: "commands",
count: commands.length,
noun: "command",
completedVerb: "Ran",
progressVerb: "Running",
},
details: commands.map((command) => command.trim()),
};
}
@@ -1080,6 +1240,13 @@ function buildToolSummary(
if (urls.length > 0) {
return {
label: `${inProgress ? "Exploring" : "Explored"} ${pluralize(urls.length, "link")}`,
aggregate: {
key: "links",
count: urls.length,
noun: "link",
completedVerb: "Explored",
progressVerb: "Exploring",
},
details: urls.map(
(url) => `${inProgress ? "Fetching" : "Fetched"} ${url}`,
),
@@ -1100,6 +1267,13 @@ function buildToolSummary(
const deletions = fileDiffs.reduce((sum, d) => sum + d.deletions, 0);
return {
label: `${inProgress ? "Editing" : "Edited"} ${pluralize(fileDiffs.length, "file")}`,
aggregate: {
key: "edited-files",
count: fileDiffs.length,
noun: "file",
completedVerb: "Edited",
progressVerb: "Editing",
},
diff: { additions, deletions },
details: fileDiffs.map(
(d) =>
@@ -1147,18 +1321,30 @@ function buildToolSummary(
: "Edited";
// The label already carries all the information; no expandable details.
const detail = `${action} ${path}`;
const aggregate = {
key: "edited-files",
count: 1,
noun: "file",
completedVerb: "Edited",
progressVerb: "Editing",
};
if (diff) {
return { label: detail, diff, details: [] };
return { label: detail, aggregate, diff, details: [] };
}
return { label: detail, details: [] };
return { label: detail, aggregate, details: [] };
}
const query =
typeof asRecord(result)?.query === "string"
? (asRecord(result)?.query as string)
: "";
const displayToolName = normalized.startsWith("subagent_")
? "spawn_agent"
: toolName;
const fallback =
query || (inProgress ? `Running ${toolName}` : toolName) || "Tool";
query ||
(inProgress ? `Running ${displayToolName}` : displayToolName) ||
"Tool";
return { label: fallback, details: [fallback] };
}
@@ -1188,7 +1374,16 @@ function buildToolSummaryFromMeta(
return { label: inProgress ? `Running ${toolName}` : toolName, details: [] };
}
function ToolMessageBlock({ message }: { message: ChatMessage }) {
type ToolPresentation = {
message: ChatMessage;
payload: ToolPayload | null;
toolName: string;
kind: ReturnType<typeof classifyTool>;
inProgress: boolean;
summary: ToolSummary;
};
function buildToolPresentation(message: ChatMessage): ToolPresentation {
const payload = parseToolPayload(message.content);
const toolName = message.meta?.toolName || payload?.toolName || "tool";
const hookEventName = message.meta?.hookEventName;
@@ -1197,8 +1392,77 @@ function ToolMessageBlock({ message }: { message: ChatMessage }) {
hookEventName === "history_tool_use" ||
(Boolean(payload) && payload?.result == null && !payload?.isError);
const kind = classifyTool(toolName);
const isFileRead = ["read_files", "file_read", "file-read"].includes(
toolName.toLowerCase(),
const summary = payload
? buildToolSummary(toolName, payload.input, payload.result, inProgress)
: buildToolSummaryFromMeta(toolName, kind, inProgress);
return { message, payload, toolName, kind, inProgress, summary };
}
function buildGroupedToolLabel(presentations: ToolPresentation[]): string {
if (presentations.length === 1) {
return presentations[0]?.summary.label ?? "Tool";
}
type Segment =
| { type: "label"; label: string }
| {
type: "aggregate";
aggregate: NonNullable<ToolSummary["aggregate"]> & {
inProgress: boolean;
};
};
const segments: Segment[] = [];
for (const presentation of presentations) {
const aggregate = presentation.summary.aggregate;
if (!aggregate) {
segments.push({ type: "label", label: presentation.summary.label });
continue;
}
const previous = segments.at(-1);
if (
previous?.type === "aggregate" &&
previous.aggregate.key === aggregate.key
) {
segments[segments.length - 1] = {
type: "aggregate",
aggregate: {
...previous.aggregate,
count: previous.aggregate.count + aggregate.count,
inProgress: previous.aggregate.inProgress || presentation.inProgress,
},
};
continue;
}
segments.push({
type: "aggregate",
aggregate: { ...aggregate, inProgress: presentation.inProgress },
});
}
return segments
.map((segment) => {
if (segment.type === "label") return segment.label;
const { aggregate } = segment;
const verb = aggregate.inProgress
? aggregate.progressVerb
: aggregate.completedVerb;
return `${verb} ${pluralize(aggregate.count, aggregate.noun)}`;
})
.join(". ");
}
function ToolMessageBlock({ messages }: { messages: ChatMessage[] }) {
const presentations = messages.map(buildToolPresentation);
const first = presentations[0];
if (!first) return null;
const hasError = presentations.some(({ payload }) => payload?.isError);
const isRunning = presentations.some(({ inProgress }) => inProgress);
const kinds = new Set(presentations.map(({ kind }) => kind));
const kind = kinds.size === 1 ? first.kind : "tool";
const isFileRead = presentations.every(({ toolName }) =>
["read_files", "file_read", "file-read"].includes(toolName.toLowerCase()),
);
const Icon = isFileRead
? FileIcon
@@ -1211,58 +1475,77 @@ function ToolMessageBlock({ message }: { message: ChatMessage }) {
: kind === "spawn"
? Bot
: FileSearch;
const summary = payload
? buildToolSummary(toolName, payload.input, payload.result, inProgress)
: buildToolSummaryFromMeta(toolName, kind, inProgress);
const details = summary.details;
const inputPreview =
IS_DEBUG && payload ? formatToolValue(payload.input) : "";
const resultPreview = payload?.isError ? formatToolValue(payload.result) : "";
const details = presentations.flatMap(({ message, summary }) =>
summary.details.map((detail) => ({
detail,
key: `${message.id}_${detail}`,
})),
);
const inputPreviews = IS_DEBUG
? presentations
.map(({ message, payload, toolName }) => ({
key: message.id,
toolName,
value: payload ? formatToolValue(payload.input) : "",
}))
.filter(({ value }) => Boolean(value))
: [];
const resultPreviews = presentations
.map(({ message, payload, toolName }) => ({
key: message.id,
toolName,
value: payload?.isError ? formatToolValue(payload.result) : "",
}))
.filter(({ value }) => Boolean(value));
const hasExpandedSections =
details.length > 0 || Boolean(inputPreview || resultPreview);
details.length > 0 || inputPreviews.length > 0 || resultPreviews.length > 0;
const diff = presentations.reduce(
(total, { summary }) => ({
additions: total.additions + (summary.diff?.additions ?? 0),
deletions: total.deletions + (summary.diff?.deletions ?? 0),
}),
{ additions: 0, deletions: 0 },
);
return (
<ToolActivity expandable={hasExpandedSections}>
<ToolActivityTrigger
additions={summary.diff?.additions}
deletions={summary.diff?.deletions}
additions={diff.additions || undefined}
deletions={diff.deletions || undefined}
icon={
payload?.isError ? (
hasError ? (
<AlertCircle className="size-4 text-destructive/80" />
) : (
<Icon className="size-4" />
)
}
label={summary.label}
status={payload?.isError ? "error" : inProgress ? "running" : "success"}
label={buildGroupedToolLabel(presentations)}
status={hasError ? "error" : isRunning ? "running" : "success"}
/>
<ToolActivityContent>
{details.length > 0 ? (
<ToolActivityDetails>
{details.map((detail) => (
<div key={`${message.id}_${detail}`}>{detail}</div>
{details.map(({ detail, key }) => (
<div key={key}>{detail}</div>
))}
</ToolActivityDetails>
) : null}
{inputPreview ? (
<div className="space-y-1">
{inputPreviews.map((preview) => (
<div className="space-y-1" key={`input_${preview.key}`}>
<div className="text-[11px] uppercase tracking-wide text-muted-foreground/80">
Input
{presentations.length > 1 ? `${preview.toolName} input` : "Input"}
</div>
<ToolActivityCode className="text-sm">
{inputPreview}
{preview.value}
</ToolActivityCode>
</div>
) : null}
{resultPreview ? (
payload?.isError ? (
<div className="mt-1 text-destructive">{resultPreview}</div>
) : (
<ToolActivityCode className="max-h-64 text-sm">
{resultPreview}
</ToolActivityCode>
)
) : null}
))}
{resultPreviews.map((preview) => (
<div className="mt-1 text-destructive" key={`result_${preview.key}`}>
{presentations.length > 1 ? `${preview.toolName}: ` : null}
{preview.value}
</div>
))}
</ToolActivityContent>
</ToolActivity>
);
@@ -0,0 +1,182 @@
// @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 { SessionFileDiff } from "@/lib/session-diff";
import { DiffView } from "./diff-view";
const { invokeMock } = vi.hoisted(() => ({
invokeMock: vi.fn(async (command: string) =>
command === "list_available_editors"
? [{ id: "vscode", label: "VS Code" }]
: { path: "/repo/docs/a.mdx", editor: "VS Code" },
),
}));
vi.mock("@/lib/desktop-client", () => ({
desktopClient: { invoke: invokeMock },
}));
let container: HTMLDivElement;
let root: Root;
let writeText: ReturnType<typeof vi.fn>;
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
// jsdom lacks the layout/pointer APIs the Radix dropdown menu touches.
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);
writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
invokeMock.mockClear();
});
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();
});
}
// Radix dropdown triggers open on pointerdown, not click.
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 buttonWithLabel(label: string): HTMLButtonElement {
const button = container.querySelector<HTMLButtonElement>(
`button[aria-label="${label}"]`,
);
expect(button).not.toBeNull();
return button as HTMLButtonElement;
}
// Menu items render in a portal attached to document.body.
function menuItems(): HTMLElement[] {
return Array.from(
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
);
}
const FILE_DIFF: SessionFileDiff = {
path: "docs/a.mdx",
additions: 2,
deletions: 1,
hunks: [],
};
describe("DiffView file actions", () => {
it("copies the cwd-resolved absolute file path", async () => {
await act(async () => {
root.render(
<DiffView
cwd="/Users/renee/cline"
fileDiffs={[FILE_DIFF]}
onClose={vi.fn()}
/>,
);
});
await click(buttonWithLabel("Copy file path for docs/a.mdx"));
expect(writeText).toHaveBeenCalledWith("/Users/renee/cline/docs/a.mdx");
});
it("opens the file in a chosen editor through the desktop backend", async () => {
await act(async () => {
root.render(
<DiffView
cwd="/Users/renee/cline"
fileDiffs={[FILE_DIFF]}
onClose={vi.fn()}
/>,
);
});
await pointerDown(buttonWithLabel("Open docs/a.mdx in editor"));
const labels = menuItems().map((item) => item.textContent);
expect(labels).toEqual(["VS Code", "System default"]);
const vscodeItem = menuItems().find(
(item) => item.textContent === "VS Code",
);
await click(vscodeItem as Element);
expect(invokeMock).toHaveBeenCalledWith("open_file_in_editor", {
path: "docs/a.mdx",
cwd: "/Users/renee/cline",
editor: "vscode",
});
});
it("still offers the system default opener when editor detection fails", async () => {
invokeMock.mockImplementation(async (command: string) => {
if (command === "list_available_editors") {
throw new Error("unsupported desktop command");
}
return { path: "/repo/docs/a.mdx", editor: "system default" };
});
await act(async () => {
root.render(<DiffView fileDiffs={[FILE_DIFF]} onClose={vi.fn()} />);
});
await pointerDown(buttonWithLabel("Open docs/a.mdx in editor"));
const labels = menuItems().map((item) => item.textContent);
expect(labels).toEqual(["System default"]);
await click(menuItems()[0] as Element);
expect(invokeMock).toHaveBeenCalledWith("open_file_in_editor", {
path: "docs/a.mdx",
editor: "default",
});
});
it("copies the path as-is when no cwd is available", async () => {
await act(async () => {
root.render(<DiffView fileDiffs={[FILE_DIFF]} onClose={vi.fn()} />);
});
await click(buttonWithLabel("Copy file path for docs/a.mdx"));
expect(writeText).toHaveBeenCalledWith("docs/a.mdx");
});
});
@@ -1,18 +1,63 @@
"use client";
import { ChevronDown, ChevronRight, Minus, Plus, X } from "lucide-react";
import { useMemo, useState } from "react";
import {
AppWindow,
Check,
ChevronDown,
ChevronRight,
Copy,
ExternalLink,
Minus,
Plus,
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ScrollArea } from "@/components/ui/scroll-area";
import { toast } from "@/hooks/use-toast";
import { desktopClient } from "@/lib/desktop-client";
import type { SessionFileDiff } from "@/lib/session-diff";
import { cn } from "@/lib/utils";
import { resolveWorkspaceFilePath } from "@/lib/workspace-paths";
import { EditorIcon } from "./editor-icons";
type DiffViewProps = {
fileDiffs: SessionFileDiff[];
cwd?: string;
onClose: () => void;
};
export function DiffView({ fileDiffs, onClose }: DiffViewProps) {
type EditorOption = {
id: string;
label: string;
};
export function DiffView({ fileDiffs, cwd, onClose }: DiffViewProps) {
const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(new Set());
const [editors, setEditors] = useState<EditorOption[]>([]);
useEffect(() => {
let cancelled = false;
desktopClient
.invoke<EditorOption[]>("list_available_editors")
.then((list) => {
if (!cancelled && Array.isArray(list)) setEditors(list);
})
.catch(() => {
// Older sidecars don't support the command; the menu still
// offers the system default opener.
});
return () => {
cancelled = true;
};
}, []);
const _totals = useMemo(
() =>
@@ -40,8 +85,8 @@ export function DiffView({ fileDiffs, onClose }: DiffViewProps) {
};
return (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex h-10 items-center justify-between border-b border-border bg-card px-4">
<div className="flex h-full min-h-0 flex-col overflow-hidden">
<div className="flex h-10 shrink-0 items-center justify-between border-b border-border bg-card px-4">
<div className="flex items-center gap-3">
<span className="text-xs font-medium text-foreground">
Uncommitted changes
@@ -64,7 +109,7 @@ export function DiffView({ fileDiffs, onClose }: DiffViewProps) {
</div>
</div>
<ScrollArea className="flex-1">
<ScrollArea className="min-h-0 flex-1">
{fileDiffs.length === 0 ? (
<div className="flex h-full items-center justify-center px-4 py-16 text-sm text-muted-foreground">
No file changes in this session yet.
@@ -74,6 +119,8 @@ export function DiffView({ fileDiffs, onClose }: DiffViewProps) {
{fileDiffs.map((file) => (
<DiffFileSection
collapsed={collapsedFiles.has(file.path)}
cwd={cwd}
editors={editors}
file={file}
key={file.path}
onToggle={() => toggleFileCollapse(file.path)}
@@ -89,34 +136,147 @@ export function DiffView({ fileDiffs, onClose }: DiffViewProps) {
function DiffFileSection({
file,
collapsed,
cwd,
editors,
onToggle,
}: {
file: SessionFileDiff;
collapsed: boolean;
cwd?: string;
editors: EditorOption[];
onToggle: () => void;
}) {
const [copied, setCopied] = useState(false);
const [opening, setOpening] = useState(false);
const copyResetTimerRef = useRef<number | null>(null);
const resolvedPath = resolveWorkspaceFilePath(file.path, cwd);
const handleCopyPath = useCallback(async () => {
try {
await navigator.clipboard.writeText(resolvedPath);
setCopied(true);
if (copyResetTimerRef.current !== null) {
window.clearTimeout(copyResetTimerRef.current);
}
copyResetTimerRef.current = window.setTimeout(() => {
setCopied(false);
copyResetTimerRef.current = null;
}, 1600);
} catch {
toast({
variant: "destructive",
title: "Copy failed",
description: "The file path could not be copied to the clipboard.",
});
}
}, [resolvedPath]);
const handleOpenInEditor = useCallback(
async (editor?: string) => {
setOpening(true);
try {
await desktopClient.invoke("open_file_in_editor", {
path: file.path,
...(cwd?.trim() ? { cwd } : {}),
...(editor ? { editor } : {}),
});
} catch (error) {
toast({
variant: "destructive",
title: "Could not open file",
description:
error instanceof Error
? error.message
: "The file could not be opened in an editor.",
});
} finally {
setOpening(false);
}
},
[file.path, cwd],
);
return (
<div className="border-b border-border">
<button
className="flex w-full items-center gap-2 bg-card/80 px-4 py-2 text-left hover:bg-accent/50 transition-colors"
onClick={onToggle}
type="button"
>
{collapsed ? (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground">
{file.path}
</span>
<div className="group flex w-full items-center gap-2 bg-card/80 px-4 py-2 hover:bg-accent/50 transition-colors">
<button
className="flex min-w-0 shrink items-center gap-2 text-left"
onClick={onToggle}
type="button"
>
{collapsed ? (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 truncate font-mono text-xs text-foreground">
{file.path}
</span>
</button>
<button
aria-label={`Copy file path for ${file.path}`}
className={cn(
"shrink-0 rounded-md p-1 text-muted-foreground transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100",
copied ? "opacity-100 text-primary" : "opacity-0",
)}
onClick={() => void handleCopyPath()}
title="Copy file path"
type="button"
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
{/* Invisible flex spacer that keeps the dead space between the
path and the right-aligned actions clickable as a toggle. */}
<button
aria-hidden
className="h-6 min-w-0 flex-1 cursor-pointer"
onClick={onToggle}
tabIndex={-1}
type="button"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
aria-label={`Open ${file.path} in editor`}
className="shrink-0 rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100 disabled:opacity-50 data-[state=open]:opacity-100 data-[state=open]:bg-accent data-[state=open]:text-foreground"
disabled={opening}
title="Open in editor"
type="button"
>
<ExternalLink className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuLabel>Open in</DropdownMenuLabel>
{editors.map((editor) => (
<DropdownMenuItem
key={editor.id}
onSelect={() => void handleOpenInEditor(editor.id)}
>
<EditorIcon editorId={editor.id} />
{editor.label}
</DropdownMenuItem>
))}
{editors.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuItem
onSelect={() => void handleOpenInEditor("default")}
>
<AppWindow aria-hidden />
System default
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<span className="shrink-0 font-mono text-[11px] text-primary">
+{file.additions}
</span>
<span className="shrink-0 font-mono text-[11px] text-destructive">
-{file.deletions}
</span>
</button>
</div>
{!collapsed && (
<div className="space-y-2 border-t border-border bg-card/40 px-4 py-3">
@@ -0,0 +1,110 @@
import { FileCode } from "lucide-react";
import type { ComponentType, SVGProps } from "react";
// Brand glyphs (simple-icons style, single monochrome path) filled with
// currentColor so they follow the menu's text color in light and dark mode.
function VsCodeIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
fill="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}
aria-hidden="true"
>
<path d="M22 5.75v12.48c0 .49-.28.94-.72 1.15-.91.43-2.46 1.18-3.33 1.59.03-.16.05-.33.05-.5V3.53C18 3.3 17.97 3.12 17.94 3c.94.46 2.44 1.18 3.33 1.6C21.72 4.81 22 5.26 22 5.75zM3.91 13.35c.89.8 1.73 1.56 2.51 2.28l-1.48 1.12c-.37.28-.89.27-1.25-.03l-.94-.79c-.46-.39-.48-1.09-.03-1.5C3.05 14.13 3.46 13.76 3.91 13.35zM16 3.53v4.81l-3.16 2.4-3.3-2.5c2.29-2.07 4.46-4.05 5.59-5.1.23-.22.56-.16.74.04.05.06.09.13.11.22C15.99 3.44 16 3.48 16 3.53zM16 20.47v-4.81L4.938 7.252c-.372-.283-.889-.27-1.247.03L2.754 8.066C2.289 8.456 2.271 9.162 2.72 9.569c2.747 2.488 9.998 9.06 12.41 11.291C15.462 21.167 16 20.93 16 20.47z" />
</svg>
);
}
function CursorIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
fill="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}
aria-hidden="true"
>
<path d="M11.503.131 1.891 5.678a.84.84 0 0 0-.42.726v11.188c0 .3.162.575.42.724l9.609 5.55a1 1 0 0 0 .998 0l9.61-5.55a.84.84 0 0 0 .42-.724V6.404a.84.84 0 0 0-.42-.726L12.497.131a1.01 1.01 0 0 0-.996 0M2.657 6.338h18.55c.263 0 .43.287.297.515L12.23 22.918c-.062.107-.229.064-.229-.06V12.335a.59.59 0 0 0-.295-.51l-9.11-5.257c-.109-.063-.064-.23.061-.23" />
</svg>
);
}
function WindsurfIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
fill="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}
aria-hidden="true"
>
<path d="M23.55 5.067c-1.2038-.002-2.1806.973-2.1806 2.1765v4.8676c0 .972-.8035 1.7594-1.7597 1.7594-.568 0-1.1352-.286-1.4718-.7659l-4.9713-7.1003c-.4125-.5896-1.0837-.941-1.8103-.941-1.1334 0-2.1533.9635-2.1533 2.153v4.8957c0 .972-.7969 1.7594-1.7596 1.7594-.57 0-1.1363-.286-1.4728-.7658L.4076 5.1598C.2822 4.9798 0 5.0688 0 5.2882v4.2452c0 .2147.0656.4228.1884.599l5.4748 7.8183c.3234.462.8006.8052 1.3509.9298 1.3771.313 2.6446-.747 2.6446-2.0977v-4.893c0-.972.7875-1.7593 1.7596-1.7593h.003a1.798 1.798 0 0 1 1.4718.7658l4.9723 7.0994c.4135.5905 1.05.941 1.8093.941 1.1587 0 2.1515-.9645 2.1515-2.153v-4.8948c0-.972.7875-1.7594 1.7596-1.7594h.194a.22.22 0 0 0 .2204-.2202v-4.622a.22.22 0 0 0-.2203-.2203Z" />
</svg>
);
}
function ZedIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
fill="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}
aria-hidden="true"
>
<path d="M2.25 1.5a.75.75 0 0 0-.75.75v16.5H0V2.25A2.25 2.25 0 0 1 2.25 0h20.095c1.002 0 1.504 1.212.795 1.92L10.764 14.298h3.486V12.75h1.5v1.922a1.125 1.125 0 0 1-1.125 1.125H9.264l-2.578 2.578h11.689V9h1.5v9.375a1.5 1.5 0 0 1-1.5 1.5H5.185L2.562 22.5H21.75a.75.75 0 0 0 .75-.75V5.25H24v16.5A2.25 2.25 0 0 1 21.75 24H1.655C.653 24 .151 22.788.86 22.08L13.19 9.75H9.75v1.5h-1.5V9.375A1.125 1.125 0 0 1 9.375 8.25h5.314l2.625-2.625H5.625V15h-1.5V5.625a1.5 1.5 0 0 1 1.5-1.5h13.19L21.438 1.5z" />
</svg>
);
}
function XcodeIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
fill="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}
aria-hidden="true"
>
<path d="M19.06 5.3327c.4517-.1936.7744-.2581 1.097-.1936.5163.1291.7744.5163.968.7098.1936.3872.9034.7744 1.2261.8389.2581.0645.7098-.6453 1.0325-1.2906.3227-.5808.5163-1.3552.4517-1.5488-.0645-.1936-.968-.5808-1.1616-.5808-.1291 0-.3872.1291-.8389.0645-.4517-.0645-.9034-.5808-1.1616-.968-.4517-.6453-1.097-1.0325-1.6778-1.3552-.6453-.3227-1.3552-.5163-2.065-.6453-1.0325-.2581-2.065-.4517-3.0975-.3227-.5808.0645-1.2906.1291-1.8069.3227-.0645 0-.1936.1936-.0645.1936s.5808.0645.5808.0645-.5807.1292-.5807.2583c0 .1291.0645.1291.1291.1291.0645 0 1.4842-.0645 2.065 0 .6453.1291 1.3552.4517 1.8069 1.2261.7744 1.4197.4517 2.7749.2581 3.2266-.968 2.1295-8.6472 15.2294-9.0344 16.1328-.3873.9034-.5163 1.4842.5807 2.065s1.6778.3227 2.0005-.0645c.3872-.5163 7.0339-17.1654 9.2925-18.2624zm-3.6138 8.7117h1.5488c1.0325 0 1.2261.5163 1.2261.7098.0645.5163-.1936 1.1616-1.2261 1.1616h-.968l.7744 1.2906c.4517.7744.2581 1.1616 0 1.4197-.3872.3872-1.2261.3872-1.6778-.4517l-.9034-1.5488c-.6453 1.4197-1.2906 2.9684-2.065 4.7753h4.0009c1.9359 0 3.5492-1.6133 3.5492-3.5492V6.5588c-.0645-.1291-.1936-.0645-.2581 0-.3872.4517-1.4842 2.0004-4.001 7.4856zm-9.8087 8.0019h-.3227c-2.3231 0-4.1945-1.8714-4.1945-4.1945V7.0105c0-2.3231 1.8714-4.1945 4.1945-4.1945h9.3571c-.1936-.1936-.968-.5163-1.7423-.4517-.3227 0-.968.1291-1.3552-.1291-.3872-.3227-.3227-.5163-.9034-.5163H4.9277c-2.6458 0-4.7753 2.1295-4.7753 4.7753v11.7447c0 2.6458 2.1295 4.7753 4.4527 4.7108.6452 0 .8388-.5162 1.0324-.9034zM20.4152 6.9459v10.9058c0 2.3231-1.8714 4.1945-4.1945 4.1945H11.897s-.3872 1.0325.8389 1.0325h3.8719c2.6458 0 4.7753-2.1295 4.7753-4.7753V8.8173c.0646-.9034-.7098-1.4842-.9679-1.8714zm-18.5851.0646v10.8413c0 1.9359 1.6133 3.5492 3.5492 3.5492h.5808c0-.0645.7744-1.4197 2.4522-4.2591.1936-.3872.4517-.7744.7098-1.2261H4.4114c-.5808 0-.9034-.3872-.968-.7098-.1291-.5163.1936-1.1616.9034-1.1616h2.3877l3.033-5.2916s-.7098-1.2906-.9034-1.6133c-.2582-.4517-.1291-.9034.129-1.1615.3872-.3872 1.0325-.5808 1.6778.4517l.2581.3872.2581-.3872c.5808-.8389.968-.7744 1.2906-.7098.5163.1291.8389.7098.3872 1.6133L8.864 14.0444h1.3552c.4517-.7744.9034-1.5488 1.3552-2.3877-.0645-.3227-.1291-.7098-.0645-1.0325.0645-.5163.3227-.968.6453-1.3552l.3872.6453c1.2261-2.1295 2.1295-3.9364 2.3877-4.6463.1291-.3872.3227-1.1616.1291-1.8069H5.3794c-2.0005.0001-3.5493 1.6134-3.5493 3.5494zM4.605 17.7872c0-.0645.7744-1.4197.7744-1.4197 1.2261-.3227 1.8069.4517 1.8714.5163 0 0-.8389 1.4842-1.097 1.7423s-.5808.3227-.9034.2581c-.5164-.129-.839-.6453-.6454-1.097z" />
</svg>
);
}
function IntelliJIdeaIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
fill="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}
aria-hidden="true"
>
<path d="M0 0v24h24V0zm3.723 3.111h5v1.834h-1.39v6.277h1.39v1.834h-5v-1.834h1.444V4.945H3.723zm11.055 0H17v6.5c0 .612-.055 1.111-.222 1.556-.167.444-.39.777-.723 1.11-.277.279-.666.557-1.11.668a3.933 3.933 0 0 1-1.445.278c-.778 0-1.444-.167-1.944-.445a4.81 4.81 0 0 1-1.279-1.056l1.39-1.555c.277.334.555.555.833.722.277.167.611.278.945.278.389 0 .721-.111 1-.389.221-.278.333-.667.333-1.278zM2.222 19.5h9V21h-9z" />
</svg>
);
}
const EDITOR_ICONS: Record<string, ComponentType<SVGProps<SVGSVGElement>>> = {
vscode: VsCodeIcon,
"vscode-insiders": VsCodeIcon,
cursor: CursorIcon,
windsurf: WindsurfIcon,
zed: ZedIcon,
xcode: XcodeIcon,
intellijidea: IntelliJIdeaIcon,
};
export function EditorIcon({
editorId,
className,
}: {
editorId: string;
className?: string;
}) {
const Icon = EDITOR_ICONS[editorId] ?? FileCode;
return <Icon aria-hidden className={className} />;
}
@@ -0,0 +1,147 @@
"use client";
import { Check, Search } from "lucide-react";
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
/**
* A button-styled select whose menu is a searchable, filterable list the same
* interaction the workspace and branch pickers use. The trigger shows the
* current value with no chevron; clicking it opens the popover.
*/
export function SearchableSelect({
value,
items,
onSelect,
disabled = false,
ariaLabel,
searchPlaceholder = "Search...",
emptyLabel = "No results",
placeholder = "Select",
icon,
triggerClassName,
align = "start",
placement = "top",
}: {
value: string;
items: string[];
onSelect: (value: string) => void;
disabled?: boolean;
ariaLabel: string;
searchPlaceholder?: string;
emptyLabel?: string;
placeholder?: string;
icon?: ReactNode;
triggerClassName?: string;
align?: "start" | "end";
placement?: "top" | "bottom";
}) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
// Close on outside click; reset the filter each time the menu opens.
useEffect(() => {
if (!open) {
setSearch("");
return;
}
const handlePointerDown = (event: PointerEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setOpen(false);
}
};
// pointerdown in the capture phase so we still fire before a portaled menu
// (e.g. the Radix effort Select) handles its own trigger's pointerdown and
// calls preventDefault, which would otherwise suppress a mousedown listener.
document.addEventListener("pointerdown", handlePointerDown, true);
return () =>
document.removeEventListener("pointerdown", handlePointerDown, true);
}, [open]);
const filtered = useMemo(
() =>
items.filter((item) => item.toLowerCase().includes(search.toLowerCase())),
[items, search],
);
const handleSelect = (item: string) => {
if (item !== value) onSelect(item);
setOpen(false);
};
return (
<div className="relative" ref={containerRef}>
<button
aria-expanded={open}
aria-haspopup="listbox"
aria-label={ariaLabel}
className={cn(
"inline-flex items-center gap-1.5 rounded-md px-2 py-1 font-medium text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
triggerClassName,
)}
disabled={disabled}
onClick={() => setOpen((current) => !current)}
title={value}
type="button"
>
{icon}
<span className="truncate">{value || placeholder}</span>
</button>
{open && (
<div
className={cn(
"absolute z-50 w-64 rounded-lg border border-border bg-popover shadow-xl",
align === "end" ? "right-0" : "left-0",
placement === "top" ? "bottom-full mb-2" : "top-full mt-2",
)}
>
<div className="border-b border-border p-2">
<div className="flex items-center gap-2 rounded-md bg-background px-2.5 py-1.5">
<Search className="size-3 shrink-0 text-muted-foreground" />
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<Input
autoFocus
className="h-auto flex-1 border-0 bg-transparent px-0 py-0 text-xs shadow-none focus-visible:ring-0"
onChange={(event) => setSearch(event.target.value)}
placeholder={searchPlaceholder}
value={search}
/>
</div>
</div>
<div className="flex max-h-56 flex-col gap-0.5 overflow-y-auto p-1.5">
{filtered.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">
{emptyLabel}
</div>
) : (
filtered.map((item) => (
<button
className={cn(
"flex items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left transition-colors",
item === value ? "bg-accent" : "hover:bg-accent/50",
)}
key={item}
onClick={() => handleSelect(item)}
type="button"
>
<span className="truncate text-xs text-foreground">
{item}
</span>
{item === value && (
<Check className="ml-2 size-3 shrink-0 text-foreground" />
)}
</button>
))
)}
</div>
</div>
)}
</div>
);
}
@@ -1,37 +1,127 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WorkspaceProvider } from "@/contexts/workspace-context";
import { WelcomeScreen } from "./welcome-chat";
describe("WelcomeScreen", () => {
it("renders every known project instead of capping the project strip", () => {
const workspaces = Array.from(
{ length: 6 },
(_, index) => `/projects/project-${index + 1}`,
);
const html = renderToStaticMarkup(
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
window.matchMedia = vi.fn().mockReturnValue({
matches: true,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.restoreAllMocks();
});
async function renderWelcomeScreen({
workspaceRoot,
workspaces,
selectChat = vi.fn(async () => true),
onListGitBranches = vi.fn(async () => ({
current: "main",
branches: ["main"],
})),
}: {
workspaceRoot: string;
workspaces: string[];
selectChat?: () => Promise<boolean>;
onListGitBranches?: () => Promise<{
current: string;
branches: string[];
}>;
}): Promise<void> {
await act(async () => {
root.render(
<WorkspaceProvider
value={{
workspaceRoot: workspaces[0] ?? "",
workspaceRoot,
workspaces,
listWorkspaces: vi.fn(async () => workspaces),
refreshWorkspaces: vi.fn(async () => undefined),
switchWorkspace: vi.fn(async () => true),
pickWorkspaceDirectory: vi.fn(async () => null),
selectChat,
}}
>
<WelcomeScreen
active
body={null}
composer={null}
gitBranch="main"
onListGitBranches={onListGitBranches}
onStartChat={vi.fn()}
onSwitchGitBranch={vi.fn(async () => true)}
quickActions={[]}
/>
</WorkspaceProvider>,
);
await Promise.resolve();
});
}
async function clickButton(text: string, last = false): Promise<void> {
const buttons = [
...container.querySelectorAll<HTMLButtonElement>("button"),
].filter((candidate) => candidate.textContent?.includes(text));
const button = last ? buttons.at(-1) : buttons[0];
expect(button).toBeDefined();
await act(async () => {
button?.click();
await Promise.resolve();
});
}
describe("WelcomeScreen", () => {
it("renders every known project in the opened workspace menu", async () => {
const workspaces = Array.from(
{ length: 6 },
(_, index) => `/projects/project-${index + 1}`,
);
await renderWelcomeScreen({
workspaceRoot: workspaces[0] ?? "",
workspaces,
});
await clickButton("project-1");
for (let index = 1; index <= workspaces.length; index += 1) {
expect(html).toContain(`project-${index}`);
expect(container.textContent).toContain(`project-${index}`);
}
});
it("selects Just chat from the pathless workspace menu", async () => {
const selectChat = vi.fn(async () => true);
const onListGitBranches = vi.fn(async () => ({
current: "main",
branches: ["main"],
}));
await renderWelcomeScreen({
workspaceRoot: "",
workspaces: ["/projects/existing"],
selectChat,
onListGitBranches,
});
expect(container.querySelector('button[title="main"]')).toBeNull();
expect(onListGitBranches).not.toHaveBeenCalled();
await clickButton("Chat");
expect(container.textContent).toContain("/projects/existing");
await clickButton("Just chat", true);
expect(selectChat).toHaveBeenCalledOnce();
});
});
@@ -1,12 +1,12 @@
"use client";
import { ArrowRight, FolderPlus, Plus } from "lucide-react";
import { ArrowRight } from "lucide-react";
import type { ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { AuroraBackground } from "@/components/ui/aurora-bg";
import { useWorkspace } from "@/contexts/workspace-context";
import { cn } from "@/lib/utils";
import { normalizeWorkspacePath } from "@/lib/workspace-paths";
import { WelcomeWorkspaceControls } from "./welcome-workspace-controls";
interface QuickAction {
id: string;
@@ -15,6 +15,9 @@ interface QuickAction {
prompt: string;
}
const HERO_VERBS = ["build", "create", "fix", "know"] as const;
const HERO_CYCLE_MS = 2600;
const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
{
id: "review-changes",
@@ -30,33 +33,44 @@ const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
},
];
function toWorkspaceName(path: string): string {
const trimmed = path.trim().replace(/[\\/]+$/, "");
if (!trimmed) return "Workspace";
const parts = trimmed.split(/[\\/]/);
return parts[parts.length - 1] || "Workspace";
}
function HeroHeading() {
const [verbIndex, setVerbIndex] = useState(0);
function workspaceLabels(paths: string[]): Map<string, string> {
const segments = paths.map((path) =>
path
.trim()
.replace(/[\\/]+$/, "")
.split(/[\\/]/)
.filter(Boolean),
);
return new Map(
paths.map((path, index) => {
const parts = segments[index] ?? [];
for (let depth = 1; depth <= parts.length; depth += 1) {
const candidate = parts.slice(-depth).join("/");
const matches = segments.filter(
(other) => other.slice(-depth).join("/") === candidate,
).length;
if (matches === 1) return [path, candidate];
}
return [path, toWorkspaceName(path)];
}),
useEffect(() => {
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
if (media.matches) return;
const interval = setInterval(() => {
setVerbIndex((prev) => (prev + 1) % HERO_VERBS.length);
}, HERO_CYCLE_MS);
return () => clearInterval(interval);
}, []);
const verb = HERO_VERBS[verbIndex];
return (
<h1
id="hero-header"
className="text-balance text-left text-[clamp(2rem,3vw,2.6rem)] font-semibold leading-[1.12] tracking-tight text-foreground"
>
<span className="sr-only">What would you like to build?</span>
<span aria-hidden="true">
What would you like to{" "}
{/* key remounts the word each cycle so the chars re-trigger their entrance */}
<span key={verb}>
{verb.split("").map((char, index) => (
<span
className="hero-word-char"
// biome-ignore lint/suspicious/noArrayIndexKey: the word remounts via the parent key each cycle, so char position is a stable, non-reordering identity
key={`${verb}-${index}`}
style={{ animationDelay: `${index * 45}ms` }}
>
{char}
</span>
))}
</span>
?
</span>
</h1>
);
}
@@ -66,12 +80,18 @@ export function WelcomeScreen({
composer,
onStartChat,
quickActions,
gitBranch,
onListGitBranches,
onSwitchGitBranch,
}: {
active: boolean;
body: ReactNode;
composer: ReactNode;
onStartChat: (prompt: string) => void;
quickActions: QuickAction[];
gitBranch: string;
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
onSwitchGitBranch: (branch: string) => Promise<boolean>;
}) {
const {
workspaceRoot,
@@ -79,62 +99,15 @@ export function WelcomeScreen({
refreshWorkspaces,
switchWorkspace,
pickWorkspaceDirectory,
selectChat,
} = useWorkspace();
const [switchingWorkspace, setSwitchingWorkspace] = useState<string | null>(
null,
);
const [addingWorkspace, setAddingWorkspace] = useState(false);
const availableWorkspaces = useMemo(() => {
const next = new Map<string, string>();
const register = (path: string) => {
const trimmed = path.trim();
if (trimmed) next.set(normalizeWorkspacePath(trimmed), trimmed);
};
register(workspaceRoot);
for (const workspacePath of workspaces) register(workspacePath);
return [...next.values()];
}, [workspaceRoot, workspaces]);
const actions =
quickActions.length > 0 ? quickActions : DEFAULT_QUICK_ACTIONS;
const labelsByWorkspace = useMemo(
() => workspaceLabels(availableWorkspaces),
[availableWorkspaces],
);
useEffect(() => {
if (active) void refreshWorkspaces();
}, [active, refreshWorkspaces]);
const handleSelectWorkspace = useCallback(
async (path: string) => {
if (
normalizeWorkspacePath(path) ===
normalizeWorkspacePath(workspaceRoot) ||
switchingWorkspace
) {
return;
}
setSwitchingWorkspace(path);
try {
await switchWorkspace(path);
} finally {
setSwitchingWorkspace(null);
}
},
[switchWorkspace, switchingWorkspace, workspaceRoot],
);
const handleAddWorkspace = useCallback(async () => {
if (addingWorkspace) return;
setAddingWorkspace(true);
try {
const selected = await pickWorkspaceDirectory(workspaceRoot || undefined);
if (selected) await switchWorkspace(selected);
} finally {
setAddingWorkspace(false);
}
}, [addingWorkspace, pickWorkspaceDirectory, switchWorkspace, workspaceRoot]);
return (
<div
className={cn(
@@ -154,60 +127,26 @@ export function WelcomeScreen({
<div
className={cn(
active
? "mx-auto flex w-full max-w-[960px] flex-col px-6 pb-32 pt-[clamp(8rem,26vh,17rem)] max-[720px]:px-4 max-[720px]:pb-20 max-[720px]:pt-16"
? "mx-auto flex w-full max-w-240 flex-col px-6 pb-32 pt-[clamp(8rem,26vh,17rem)] max-[720px]:px-4 max-[720px]:pb-20 max-[720px]:pt-16"
: "contents",
)}
>
{active ? (
<>
<h1 className="text-balance text-center text-[clamp(2rem,3vw,2.6rem)] font-semibold leading-[1.12] tracking-[-0.025em] text-foreground">
What would you like to build?
</h1>
<HeroHeading />
<div className="mt-11 flex min-w-0 items-center gap-1.5 text-sm">
<fieldset className="flex min-h-8 min-w-0 flex-1 items-center gap-1.5 overflow-x-auto pb-1">
<legend className="sr-only">Workspaces</legend>
{availableWorkspaces.map((path) => {
const isActive =
normalizeWorkspacePath(path) ===
normalizeWorkspacePath(workspaceRoot);
const isSwitching = switchingWorkspace === path;
return (
<button
aria-pressed={isActive}
className={cn(
"shrink-0 rounded-md px-3 py-1.5 font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
isActive
? "bg-foreground text-background"
: "text-muted-foreground hover:bg-accent hover:text-foreground",
)}
disabled={Boolean(switchingWorkspace)}
key={path}
onClick={() => void handleSelectWorkspace(path)}
title={path}
type="button"
>
{isSwitching
? "Switching..."
: (labelsByWorkspace.get(path) ??
toWorkspaceName(path))}
</button>
);
})}
</fieldset>
<button
className="inline-flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-1.5 font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring max-[480px]:px-2"
disabled={addingWorkspace}
onClick={() => void handleAddWorkspace()}
type="button"
>
{addingWorkspace ? (
<FolderPlus className="size-4 animate-pulse" />
) : (
<Plus className="size-4" />
)}
New project
</button>
<div className="mt-11 flex min-w-0 items-center">
<WelcomeWorkspaceControls
currentBranch={gitBranch}
onListGitBranches={onListGitBranches}
onPickWorkspaceDirectory={pickWorkspaceDirectory}
onRefreshWorkspaces={refreshWorkspaces}
onSelectChat={selectChat}
onSwitchGitBranch={onSwitchGitBranch}
onSwitchWorkspace={switchWorkspace}
workspaceRoot={workspaceRoot}
workspaces={workspaces}
/>
</div>
</>
) : null}
@@ -0,0 +1,444 @@
"use client";
import { isChatWorkspacePath } from "@cline/shared/browser";
import {
Check,
FilePlus2,
Folder,
GitBranch,
Plus,
Search,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { normalizeWorkspacePath } from "@/lib/workspace-paths";
function formatWorkspacePath(path: string): string {
const unixHome = path.match(/^\/Users\/[^/]+\/(.*)$/);
if (unixHome) return unixHome[1] ? `~/${unixHome[1]}` : "~";
const linuxHome = path.match(/^\/home\/[^/]+\/(.*)$/);
if (linuxHome) return linuxHome[1] ? `~/${linuxHome[1]}` : "~";
const windowsHome = path.match(/^[A-Za-z]:\\Users\\[^\\]+\\(.*)$/);
if (windowsHome) {
const tail = windowsHome[1]?.replaceAll("\\", "/") || "";
return tail ? `~/${tail}` : "~";
}
return path;
}
function workspaceName(path: string): string {
const trimmed = path.trim().replace(/[\\/]+$/, "");
if (!trimmed) return "workspace";
const parts = trimmed.split(/[\\/]/);
return parts[parts.length - 1] || "workspace";
}
const TRIGGER_CLASS =
"inline-flex items-center gap-1.5 rounded-md border border-border/70 bg-background/80 px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
const PANEL_CLASS =
"absolute left-0 top-full z-50 mt-2 w-72 rounded-lg border border-border bg-popover shadow-xl";
function SearchInput({
value,
onChange,
placeholder,
}: {
value: string;
onChange: (value: string) => void;
placeholder: string;
}) {
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" />
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<Input
autoFocus
className="h-auto flex-1 border-0 bg-transparent px-0 py-0 text-xs shadow-none focus-visible:ring-0"
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
value={value}
/>
</div>
</div>
);
}
function WorkspacePicker({
open,
onToggle,
onClose,
workspaceRoot,
workspaces,
onRefreshWorkspaces,
onSwitchWorkspace,
onPickWorkspaceDirectory,
onSelectChat,
}: {
open: boolean;
onToggle: () => void;
onClose: () => void;
workspaceRoot: string;
workspaces: string[];
onRefreshWorkspaces: () => Promise<void>;
onSwitchWorkspace: (workspacePath: string) => Promise<boolean>;
onPickWorkspaceDirectory: (initialPath?: string) => Promise<string | null>;
onSelectChat: () => Promise<boolean>;
}) {
const [search, setSearch] = useState("");
const [switching, setSwitching] = useState(false);
const [picking, setPicking] = useState(false);
const [selectingChat, setSelectingChat] = useState(false);
const isChatWorkspace =
!workspaceRoot.trim() || isChatWorkspacePath(workspaceRoot);
const normalizedWorkspaceRoot = useMemo(
() => normalizeWorkspacePath(workspaceRoot),
[workspaceRoot],
);
// Refresh the catalog and clear the filter each time the menu opens.
useEffect(() => {
if (!open) return;
setSearch("");
void onRefreshWorkspaces();
}, [open, onRefreshWorkspaces]);
// The active workspace can be an excluded path (restored session, process
// cwd fallback); register it explicitly so it stays visible while active.
const availableWorkspaces = useMemo(() => {
const byNormalizedPath = new Map<string, string>();
const register = (path: string) => {
const trimmed = path.trim();
if (trimmed)
byNormalizedPath.set(normalizeWorkspacePath(trimmed), trimmed);
};
if (!isChatWorkspace) register(workspaceRoot);
for (const path of workspaces) register(path);
return [...byNormalizedPath.values()];
}, [isChatWorkspace, workspaceRoot, workspaces]);
const filteredWorkspaces = availableWorkspaces.filter((path) =>
path.toLowerCase().includes(search.toLowerCase()),
);
const handleSelect = async (path: string) => {
const next = path.trim();
if (!next || normalizeWorkspacePath(next) === normalizedWorkspaceRoot) {
onClose();
return;
}
if (switching) return;
setSwitching(true);
const switched = await onSwitchWorkspace(next);
setSwitching(false);
if (switched) onClose();
};
const handleAddWorkspace = async () => {
if (picking || selectingChat || switching) return;
setPicking(true);
try {
const picked = await onPickWorkspaceDirectory(
isChatWorkspace ? undefined : workspaceRoot || undefined,
);
if (picked?.trim()) await handleSelect(picked.trim());
} finally {
setPicking(false);
}
};
const handleSelectChat = async () => {
if (picking || selectingChat || switching) return;
setSelectingChat(true);
try {
if (await onSelectChat()) onClose();
} finally {
setSelectingChat(false);
}
};
const workspaceLabel = isChatWorkspace
? "Chat"
: workspaceName(workspaceRoot);
return (
<div className="relative shrink-0">
<button
aria-expanded={open}
aria-haspopup="menu"
className={TRIGGER_CLASS}
onClick={onToggle}
title={workspaceLabel}
type="button"
>
<Folder className="size-4 shrink-0 text-muted-foreground" />
<span className="max-w-44 truncate">{workspaceLabel}</span>
</button>
{open && (
<div className={PANEL_CLASS}>
<SearchInput
onChange={setSearch}
placeholder="Search workspaces"
value={search}
/>
<div className="p-1.5">
<div className="flex max-h-48 flex-col gap-0.5 overflow-y-auto">
{filteredWorkspaces.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">
No workspaces found
</div>
) : (
filteredWorkspaces.map((path) => {
const isActive =
normalizeWorkspacePath(path) === normalizedWorkspaceRoot;
return (
<Button
className={cn(
"flex h-auto w-full items-center justify-between rounded-md p-2 text-left",
isActive ? "bg-accent" : "hover:bg-accent/50",
)}
disabled={switching}
key={path}
onClick={() => void handleSelect(path)}
variant="ghost"
>
<span className="flex min-w-0 items-center gap-2">
<Folder className="size-3 shrink-0 text-muted-foreground" />
<span className="truncate text-xs text-foreground">
{formatWorkspacePath(path)}
</span>
</span>
{isActive && (
<Check className="ml-2 size-3 shrink-0 text-foreground" />
)}
</Button>
);
})
)}
</div>
<Button
className="mt-0.5 w-full justify-start text-xs text-muted-foreground"
disabled={switching || picking || selectingChat}
onClick={() => void handleAddWorkspace()}
size="sm"
variant="ghost"
>
<Plus className="size-3" />
{picking ? "Opening folder picker..." : "Add project..."}
</Button>
<Button
className="w-full justify-start text-xs text-muted-foreground"
disabled={switching || picking || selectingChat}
onClick={() => void handleSelectChat()}
size="sm"
variant="ghost"
>
<FilePlus2 className="size-3" />
{selectingChat ? "Switching to chat..." : "Just chat"}
</Button>
</div>
</div>
)}
</div>
);
}
function BranchPicker({
open,
onToggle,
onClose,
currentBranch,
onListGitBranches,
onSwitchGitBranch,
}: {
open: boolean;
onToggle: () => void;
onClose: () => void;
currentBranch: string;
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
onSwitchGitBranch: (branch: string) => Promise<boolean>;
}) {
const [search, setSearch] = useState("");
const [branches, setBranches] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [switching, setSwitching] = useState(false);
// Load branches fresh each time the menu opens.
useEffect(() => {
if (!open) return;
let cancelled = false;
setSearch("");
setLoading(true);
onListGitBranches()
.then((payload) => {
if (!cancelled) setBranches(payload.branches);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [open, onListGitBranches]);
const hasGit = currentBranch !== "no-git";
const branchLabel = hasGit ? currentBranch : "No branch";
const filteredBranches = branches.filter((branch) =>
branch.toLowerCase().includes(search.toLowerCase()),
);
const handleSelect = async (branch: string) => {
if (branch === currentBranch) {
onClose();
return;
}
if (switching) return;
setSwitching(true);
const switched = await onSwitchGitBranch(branch);
setSwitching(false);
if (switched) onClose();
};
return (
<div className="relative min-w-0">
<button
aria-expanded={open}
aria-haspopup="menu"
className={cn(TRIGGER_CLASS, "min-w-0 max-w-full")}
onClick={onToggle}
title={branchLabel}
type="button"
>
<GitBranch className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate">{branchLabel}</span>
</button>
{open && (
<div className={PANEL_CLASS}>
<SearchInput
onChange={setSearch}
placeholder="Search branches"
value={search}
/>
<div className="p-1.5">
{loading ? (
<div className="px-2 py-4 text-xs text-muted-foreground">
Loading...
</div>
) : (
<div className="flex max-h-56 flex-col gap-0.5 overflow-y-auto">
{filteredBranches.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">
No branches found
</div>
) : (
filteredBranches.map((branch) => (
<Button
className={cn(
"flex h-auto items-center gap-2 rounded-md px-2 py-2 text-left",
currentBranch === branch
? "bg-accent"
: "hover:bg-accent/50",
)}
disabled={switching}
key={branch}
onClick={() => void handleSelect(branch)}
variant="ghost"
>
<GitBranch className="size-3 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">
{branch}
</span>
{currentBranch === branch && (
<Check className="ml-auto size-3 shrink-0 text-foreground" />
)}
</Button>
))
)}
</div>
)}
</div>
</div>
)}
</div>
);
}
export function WelcomeWorkspaceControls({
workspaceRoot,
workspaces,
onRefreshWorkspaces,
onSwitchWorkspace,
onPickWorkspaceDirectory,
onSelectChat,
currentBranch,
onListGitBranches,
onSwitchGitBranch,
}: {
workspaceRoot: string;
workspaces: string[];
onRefreshWorkspaces: () => Promise<void>;
onSwitchWorkspace: (workspacePath: string) => Promise<boolean>;
onPickWorkspaceDirectory: (initialPath?: string) => Promise<string | null>;
onSelectChat: () => Promise<boolean>;
currentBranch: string;
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
onSwitchGitBranch: (branch: string) => Promise<boolean>;
}) {
const [openMenu, setOpenMenu] = useState<"workspace" | "branch" | null>(null);
const isChatWorkspace =
!workspaceRoot.trim() || isChatWorkspacePath(workspaceRoot);
const containerRef = useRef<HTMLDivElement>(null);
// Close whichever menu is open when clicking outside the control row.
useEffect(() => {
if (!openMenu) return;
const handlePointerDown = (event: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setOpenMenu(null);
}
};
document.addEventListener("mousedown", handlePointerDown);
return () => document.removeEventListener("mousedown", handlePointerDown);
}, [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}
/>
{!isChatWorkspace ? (
<BranchPicker
currentBranch={currentBranch}
onClose={() => setOpenMenu(null)}
onListGitBranches={onListGitBranches}
onSwitchGitBranch={onSwitchGitBranch}
onToggle={() =>
setOpenMenu((current) => (current === "branch" ? null : "branch"))
}
open={openMenu === "branch"}
/>
) : null}
</div>
);
}
@@ -79,4 +79,58 @@ describe("WorkspaceSelector", () => {
expect(onSwitchGitBranch).toHaveBeenCalledWith("feature/review");
});
});
it("lists the active workspace even when the catalog excludes it", async () => {
await act(async () => {
root.render(
<WorkspaceSelector
currentBranch="main"
onListGitBranches={vi.fn(async () => ({
current: "main",
branches: ["main"],
}))}
onPickWorkspaceDirectory={vi.fn(async () => null)}
onRefreshWorkspaces={vi.fn(async () => undefined)}
onSwitchGitBranch={vi.fn(async () => true)}
onSwitchWorkspace={vi.fn(async () => true)}
workspaceRoot="/Users/beatrix/Desktop"
workspaces={["/workspace/one"]}
/>,
);
});
await click(container.querySelector("#git-branch-btn") as Element);
await vi.waitFor(() => {
expect(container.textContent).toContain("~/Desktop");
expect(container.textContent).toContain("/workspace/one");
});
});
it("labels the SDK chat workspace as Chat without listing the raw path", async () => {
const temporaryWorkspace = "/home/host/.cline/data/workspaces/chat";
await act(async () => {
root.render(
<WorkspaceSelector
currentBranch="no-git"
onListGitBranches={vi.fn(async () => ({
current: "no-git",
branches: [],
}))}
onPickWorkspaceDirectory={vi.fn(async () => null)}
onRefreshWorkspaces={vi.fn(async () => undefined)}
onSwitchGitBranch={vi.fn(async () => false)}
onSwitchWorkspace={vi.fn(async () => true)}
workspaceRoot={temporaryWorkspace}
workspaces={["/workspace/one"]}
/>,
);
});
expect(container.textContent).toContain("Chat");
await click(container.querySelector("#git-branch-btn") as Element);
await vi.waitFor(() => {
expect(container.textContent).toContain("/workspace/one");
});
expect(container.textContent).not.toContain(temporaryWorkspace);
});
});
@@ -1,10 +1,12 @@
"use client";
import { isChatWorkspacePath } from "@cline/shared/browser";
import { Check, FolderCode, GitBranch, Plus, Search } from "lucide-react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { normalizeWorkspacePath } from "@/lib/workspace-paths";
function formatWorkspacePath(path: string): string {
const unixHome = path.match(/^\/Users\/[^/]+\/(.*)$/);
@@ -19,17 +21,6 @@ function formatWorkspacePath(path: string): string {
return path;
}
function normalizeWorkspacePath(path: string): string {
const normalized = path.trim().replace(/[\\/]+$/, "");
if (!normalized) {
return "";
}
if (/^[A-Za-z]:/.test(normalized)) {
return normalized.toLowerCase();
}
return normalized;
}
export function WorkspaceSelector({
currentBranch,
workspaceRoot,
@@ -64,9 +55,12 @@ export function WorkspaceSelector({
const [newBranchName, setNewBranchName] = useState("");
const workspaceName = useMemo(() => {
if (isChatWorkspacePath(workspaceRoot)) {
return "Chat";
}
const trimmed = workspaceRoot.trim().replace(/[\\/]+$/, "");
if (!trimmed) {
return "workspace";
return "Chat";
}
const parts = trimmed.split(/[\\/]/);
return parts[parts.length - 1] || "workspace";
@@ -181,7 +175,21 @@ export function WorkspaceSelector({
b.toLowerCase().includes(search.toLowerCase()),
);
const filteredWorkspaces = workspaces.filter((w) =>
// The catalog excludes non-project paths (home, Desktop, ~/.cline), but an
// explicitly opened workspace must stay visible while it is active.
const availableWorkspaces = useMemo(() => {
const byNormalizedPath = new Map<string, string>();
const register = (path: string) => {
const trimmed = path.trim();
if (trimmed)
byNormalizedPath.set(normalizeWorkspacePath(trimmed), trimmed);
};
if (!isChatWorkspacePath(workspaceRoot)) register(workspaceRoot);
for (const path of workspaces) register(path);
return [...byNormalizedPath.values()];
}, [workspaceRoot, workspaces]);
const filteredWorkspaces = availableWorkspaces.filter((w) =>
w.toLowerCase().includes(search.toLowerCase()),
);
@@ -19,7 +19,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { desktopClient } from "@/lib/desktop-client";
import { desktopClient, openExternalUrl } from "@/lib/desktop-client";
import {
fetchMarketplaceCatalog,
type MarketplaceCatalog,
@@ -228,6 +228,15 @@ function EntryDetails({
<a
className="mt-1 inline-flex items-center gap-1 text-xs text-primary hover:underline"
href={env.url}
onAuxClick={(event) => {
if (event.button !== 1) return;
event.preventDefault();
if (env.url) void openExternalUrl(env.url);
}}
onClick={(event) => {
event.preventDefault();
if (env.url) void openExternalUrl(env.url);
}}
rel="noreferrer"
target="_blank"
>
@@ -457,7 +466,7 @@ function MarketplaceEntryCard({
if (!hasExpandableDetails) {
return (
<div className="relative grid gap-2 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20">
<div className="relative grid min-w-0 gap-2 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20">
{content}
</div>
);
@@ -468,7 +477,7 @@ function MarketplaceEntryCard({
<div
aria-expanded={expanded}
aria-label={`${expanded ? "Collapse" : "Expand"} ${entry.name}`}
className="relative grid cursor-pointer gap-2 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
className="relative grid min-w-0 cursor-pointer gap-2 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
onClick={(event) => {
if (
event.target instanceof HTMLElement &&
@@ -563,14 +572,14 @@ function MarketplaceSection({
}) {
const totalCount = entries.length + localOnlyInstalledItems.length;
return (
<section className="grid gap-3">
<section className="grid min-w-0 gap-3">
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-foreground">{title}</h2>
<span className="text-sm text-muted-foreground">{totalCount}</span>
</div>
{headerContent}
{totalCount > 0 ? (
<div className="grid gap-3">
<div className="grid min-w-0 gap-3">
{localOnlyInstalledItems.map((item) => item.render())}
{entries.map((entry) => {
const key = entryKey(entry);
@@ -786,8 +795,8 @@ export function MarketplaceView({
const marketplaceTagFilters =
primitiveTags.length > 0 ? (
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div className="flex gap-2 overflow-x-auto pb-1">
<div className="flex min-w-0 flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div className="flex min-w-0 gap-2 overflow-x-auto pb-1">
{primitiveTags.map((tag) => (
<TagButton
active={selectedTag === tag.id}
@@ -0,0 +1,250 @@
// @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 { AccountProvider } from "@/contexts/account-context";
import {
MODEL_SELECTION_STORAGE_KEY,
parseModelSelectionStorage,
} from "@/lib/model-selection";
import type { Provider } from "@/lib/provider-schema";
import { OnboardingView, sortProvidersForApiKeySetup } from "./onboarding-view";
const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() }));
vi.mock("@/lib/desktop-client", () => ({
desktopClient: { invoke },
openExternalUrl: vi.fn(),
}));
function makeProvider(overrides: Partial<Provider> = {}): Provider {
return {
id: "anthropic",
name: "Anthropic",
models: 4,
color: "#000",
letter: "A",
enabled: false,
...overrides,
};
}
describe("sortProvidersForApiKeySetup", () => {
it("drops OAuth-managed providers and ranks popular ones first", () => {
const sorted = sortProvidersForApiKeySetup([
makeProvider({ id: "zai", name: "Z AI" }),
makeProvider({ id: "cline", name: "Cline" }),
makeProvider({ id: "openai-codex", name: "ChatGPT" }),
makeProvider({ id: "openrouter", name: "OpenRouter" }),
makeProvider({ id: "anthropic", name: "Anthropic" }),
makeProvider({ id: "baseten", name: "Baseten" }),
]);
expect(sorted.map((provider) => provider.id)).toEqual([
"anthropic",
"openrouter",
"baseten",
"zai",
]);
});
it("drops providers the API-key form cannot fully configure", () => {
const apiKeyField = {
path: "apiKey",
label: "API Key",
type: "password" as const,
};
const sorted = sortProvidersForApiKeySetup([
makeProvider({
id: "vertex",
name: "Google Vertex AI",
configFields: [
{ path: "gcp.projectId", label: "Project", type: "text" },
apiKeyField,
],
}),
makeProvider({
id: "bedrock",
name: "AWS Bedrock",
configFields: [
{ path: "aws.region", label: "Region", type: "text" },
apiKeyField,
],
}),
makeProvider({
id: "claude-code",
name: "Claude Code",
configFields: [],
}),
makeProvider({
id: "ollama",
name: "Ollama",
configFields: [
apiKeyField,
{ path: "baseUrl", label: "Base URL", type: "url" },
],
}),
makeProvider({ id: "anthropic", name: "Anthropic" }),
]);
expect(sorted.map((provider) => provider.id)).toEqual([
"anthropic",
"ollama",
]);
});
});
describe("OnboardingView", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
window.localStorage.clear();
invoke.mockReset();
// AccountProvider fetches the account on mount; unresolved auth means
// the signed-out variant of the connect step renders.
invoke.mockImplementation(async (command: string) => {
if (command === "cline_account") {
throw new Error("No Cline account auth token found");
}
if (command === "list_provider_catalog") {
return {
providers: [
makeProvider(),
makeProvider({ id: "openrouter", name: "OpenRouter" }),
],
settingsPath: "/tmp/providers.json",
};
}
return {};
});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
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(onComplete = vi.fn()) {
await act(async () => {
root.render(
<AccountProvider>
<OnboardingView onComplete={onComplete} />
</AccountProvider>,
);
});
return onComplete;
}
it("walks from welcome to the connect step", async () => {
await render();
expect(container.textContent).toContain("Build software your way");
await act(async () => {
buttonByText("Get started").click();
});
expect(container.textContent).toContain("Set up Cline");
expect(container.textContent).toContain("Sign in with Cline");
expect(container.textContent).toContain("Use your own API key");
});
it("completes without connecting when skipped", async () => {
const onComplete = await render();
await act(async () => {
buttonByText("Get started").click();
});
await act(async () => {
buttonByText("Skip for now").click();
});
expect(onComplete).toHaveBeenCalledTimes(1);
});
it("records Cline as the provider when a signed-in user continues", async () => {
// Simulate replaying onboarding after previously using another provider.
window.localStorage.setItem(
MODEL_SELECTION_STORAGE_KEY,
JSON.stringify({ lastProvider: "anthropic", lastModelByProvider: {} }),
);
invoke.mockImplementation(async (command: string) => {
if (command === "cline_account") {
return { email: "dev@example.com", displayName: "Dev" };
}
if (command === "list_provider_catalog") {
return { providers: [makeProvider()], settingsPath: "/tmp/p.json" };
}
return {};
});
await render();
await act(async () => {
buttonByText("Get started").click();
});
expect(container.textContent).toContain("Signed in as");
await act(async () => {
buttonByText("Continue").click();
});
expect(container.textContent).toContain("You're all set");
expect(
parseModelSelectionStorage(
window.localStorage.getItem(MODEL_SELECTION_STORAGE_KEY),
).lastProvider,
).toBe("cline");
});
it("saves an API key provider and remembers the selection", async () => {
const onComplete = await render();
await act(async () => {
buttonByText("Get started").click();
});
// Expand the bring-your-own-key form; drive state through the select's
// props via the API key path (jsdom cannot open the radix listbox).
const expandButton = Array.from(container.querySelectorAll("button")).find(
(candidate) => candidate.textContent?.includes("Use your own API key"),
);
expect(expandButton).toBeDefined();
await act(async () => {
expandButton?.click();
});
expect(container.textContent).toContain("Choose a provider");
// Sign-in path still available alongside the expanded form.
invoke.mockImplementation(async (command: string) => {
if (command === "run_provider_oauth_login") {
return { provider: "cline", accessToken: "token" };
}
if (command === "cline_account") {
throw new Error("No Cline account auth token found");
}
return {};
});
await act(async () => {
buttonByText("Sign in").click();
});
expect(invoke).toHaveBeenCalledWith("run_provider_oauth_login", {
provider: "cline",
});
expect(container.textContent).toContain("You're all set");
expect(
parseModelSelectionStorage(
window.localStorage.getItem(MODEL_SELECTION_STORAGE_KEY),
).lastProvider,
).toBe("cline");
await act(async () => {
buttonByText("Start building").click();
});
expect(onComplete).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,540 @@
"use client";
import {
ArrowLeft,
CheckCircle2,
ExternalLink,
KeyRound,
Loader2,
LogIn,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { AuroraBackground } from "@/components/ui/aurora-bg";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useAccount } from "@/contexts/account-context";
import { OAUTH_MANAGED_PROVIDERS } from "@/hooks/chat-session/constants";
import { desktopClient, openExternalUrl } from "@/lib/desktop-client";
import {
readModelSelectionStorageFromWindow,
writeModelSelectionStorageToWindow,
} from "@/lib/model-selection";
import type { Provider, ProviderCatalogResponse } from "@/lib/provider-schema";
const CREATE_ACCOUNT_URL = "https://app.cline.bot";
export type OnboardingStep = "welcome" | "connect" | "done";
type OnboardingConnection =
| { kind: "cline" }
| { kind: "provider"; providerName: string };
/**
* Providers surfaced first in the bring-your-own-key picker. Everything else
* from the catalog follows alphabetically.
*/
const PREFERRED_PROVIDER_ORDER = [
"anthropic",
"openai-native",
"openrouter",
"gemini",
"xai",
"groq",
"mistral",
"deepseek",
"ollama",
];
/**
* True when entering an API key is all the provider needs: it declares an
* API-key config field and nothing beyond key/base-URL. Providers with
* structured setup (Vertex `gcp.*`, Bedrock `aws.*`) or no API-key field at
* all (Claude Code) would "connect" here without working, so they stay in
* Settings where the full form lives. Missing metadata means the catalog
* fell back to a plain API-key field.
*/
function isApiKeyOnlyProvider(provider: Provider): boolean {
const fields = provider.configFields;
if (!fields) {
return true;
}
return (
fields.some((field) => field.path === "apiKey") &&
fields.every((field) => field.path === "apiKey" || field.path === "baseUrl")
);
}
/**
* Orders the provider catalog for the API-key setup step: OAuth-managed
* providers (Cline itself, ChatGPT, OCA) are excluded because they have
* dedicated sign-in paths, providers needing more than an API key are
* excluded because this form only collects one, popular API-key providers
* come first, and the rest follow alphabetically.
*/
export function sortProvidersForApiKeySetup(providers: Provider[]): Provider[] {
const rank = (id: string) => {
const index = PREFERRED_PROVIDER_ORDER.indexOf(id);
return index === -1 ? PREFERRED_PROVIDER_ORDER.length : index;
};
return providers
.filter(
(provider) =>
!OAUTH_MANAGED_PROVIDERS.has(provider.id) &&
isApiKeyOnlyProvider(provider),
)
.sort((a, b) => rank(a.id) - rank(b.id) || a.name.localeCompare(b.name));
}
/**
* Remembers the connected provider (and its default model when known) so the
* chat composer opens pointed at what the user just set up.
*/
function rememberProviderSelection(provider: {
id: string;
defaultModelId?: string;
}): void {
const selection = readModelSelectionStorageFromWindow();
writeModelSelectionStorageToWindow({
lastProvider: provider.id,
lastModelByProvider: provider.defaultModelId
? {
...selection.lastModelByProvider,
[provider.id]: provider.defaultModelId,
}
: selection.lastModelByProvider,
});
}
function OnboardingCard({
children,
wide = false,
}: {
children: React.ReactNode;
wide?: boolean;
}) {
return (
<div
className={
wide
? "relative z-10 w-full max-w-130 rounded-3xl border border-border/50 bg-background/80 p-8 shadow-2xl backdrop-blur-2xl max-[720px]:p-6"
: "relative z-10 w-full max-w-105 rounded-3xl border border-border/50 bg-background/80 p-8 shadow-2xl backdrop-blur-2xl max-[720px]:p-6"
}
>
{children}
</div>
);
}
function WelcomeStep({ onContinue }: { onContinue: () => void }) {
return (
<OnboardingCard>
<div className="flex flex-col items-center py-4 text-center">
<img
alt=""
aria-hidden="true"
className="h-28 w-auto drop-shadow-[0_16px_32px_color-mix(in_oklab,var(--brand-violet)_35%,transparent)]"
draggable={false}
height={477}
src="/cline-logo-glass.png"
width={486}
/>
<h1 className="mt-5 text-3xl font-semibold tracking-tight text-foreground">
Cline
</h1>
<p className="mt-2 text-[15px] text-muted-foreground">
Build software your way
</p>
<Button
className="mt-9 h-11 w-full rounded-full text-[15px]"
onClick={onContinue}
type="button"
>
Get started
</Button>
<p className="mt-4 text-xs text-muted-foreground">
Takes less than a minute. Everything can be changed later in Settings.
</p>
</div>
</OnboardingCard>
);
}
function ConnectStep({
onBack,
onConnected,
onSkip,
}: {
onBack: () => void;
onConnected: (connection: OnboardingConnection) => void;
onSkip: () => void;
}) {
const { user, refreshAccount } = useAccount();
const [signingIn, setSigningIn] = useState(false);
const [signInError, setSignInError] = useState<string | null>(null);
const [showApiKeyForm, setShowApiKeyForm] = useState(false);
const [providers, setProviders] = useState<Provider[]>([]);
const [providersLoading, setProvidersLoading] = useState(true);
const [providersError, setProvidersError] = useState<string | null>(null);
const [selectedProviderId, setSelectedProviderId] = useState("");
const [apiKey, setApiKey] = useState("");
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function loadProviders() {
try {
const payload = await desktopClient.invoke<ProviderCatalogResponse>(
"list_provider_catalog",
);
if (cancelled) {
return;
}
setProviders(sortProvidersForApiKeySetup(payload.providers ?? []));
setProvidersError(null);
} catch (error) {
if (cancelled) {
return;
}
setProvidersError(
error instanceof Error ? error.message : String(error),
);
} finally {
if (!cancelled) {
setProvidersLoading(false);
}
}
}
void loadProviders();
return () => {
cancelled = true;
};
}, []);
const signInWithCline = useCallback(async () => {
setSigningIn(true);
setSignInError(null);
try {
await desktopClient.invoke("run_provider_oauth_login", {
provider: "cline",
});
rememberProviderSelection({ id: "cline" });
await refreshAccount();
onConnected({ kind: "cline" });
} catch (error) {
setSignInError(error instanceof Error ? error.message : String(error));
} finally {
setSigningIn(false);
}
}, [onConnected, refreshAccount]);
const selectedProvider =
providers.find((provider) => provider.id === selectedProviderId) ?? null;
const connectProvider = useCallback(async () => {
if (!selectedProvider || !apiKey.trim()) {
return;
}
setSaving(true);
setSaveError(null);
try {
await desktopClient.invoke("save_provider_settings", {
provider: selectedProvider.id,
enabled: true,
api_key: apiKey.trim(),
});
rememberProviderSelection(selectedProvider);
onConnected({
kind: "provider",
providerName: selectedProvider.name,
});
} catch (error) {
setSaveError(error instanceof Error ? error.message : String(error));
} finally {
setSaving(false);
}
}, [apiKey, onConnected, selectedProvider]);
return (
<OnboardingCard wide>
<div className="flex items-center gap-2">
<Button
aria-label="Back"
className="-ml-2 size-8 rounded-full p-0 text-muted-foreground"
onClick={onBack}
type="button"
variant="ghost"
>
<ArrowLeft className="size-4" />
</Button>
<h1 className="text-2xl font-semibold tracking-tight text-foreground">
Set up Cline
</h1>
</div>
<p className="mt-2 text-sm text-muted-foreground">
Choose how Cline connects to a model. You can add more providers anytime
in Settings.
</p>
<div className="mt-6 flex flex-col gap-3">
{/* Cline account */}
<div className="rounded-2xl border border-primary/30 bg-primary/5 p-4">
<div className="flex items-center gap-2">
<p className="text-[15px] font-semibold text-foreground">
Sign in with Cline
</p>
<Badge className="bg-primary/15 text-primary" variant="secondary">
Recommended
</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">
Latest models with regular free promos. No API keys needed.
</p>
{user ? (
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
<p className="text-sm text-foreground">
Signed in as{" "}
<span className="font-medium">
{user.displayName || user.email}
</span>
</p>
<Button
className="rounded-full"
onClick={() => {
rememberProviderSelection({ id: "cline" });
onConnected({ kind: "cline" });
}}
type="button"
>
Continue
</Button>
</div>
) : (
<div className="mt-3 flex flex-wrap items-center gap-3">
<Button
className="rounded-full"
disabled={signingIn}
onClick={() => void signInWithCline()}
type="button"
>
{signingIn ? (
<Loader2 className="size-4 animate-spin" />
) : (
<LogIn className="size-4" />
)}
{signingIn ? "Waiting for browser..." : "Sign in"}
</Button>
<button
className="inline-flex items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground"
onClick={() => void openExternalUrl(CREATE_ACCOUNT_URL)}
type="button"
>
Create account
<ExternalLink className="size-3.5" />
</button>
</div>
)}
{signInError ? (
<p className="mt-2 text-xs text-destructive" role="alert">
Sign in failed: {signInError}
</p>
) : null}
</div>
{/* Bring your own key */}
<div className="rounded-2xl border border-border/70 bg-background/60 p-4">
<button
aria-expanded={showApiKeyForm}
className="flex w-full items-start gap-3 text-left"
onClick={() => setShowApiKeyForm((current) => !current)}
type="button"
>
<span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-secondary text-muted-foreground">
<KeyRound className="size-4" />
</span>
<span className="min-w-0">
<span className="block text-[15px] font-semibold text-foreground">
Use your own API key
</span>
<span className="mt-0.5 block text-sm text-muted-foreground">
Anthropic, OpenAI, OpenRouter, and more.
</span>
</span>
</button>
{showApiKeyForm ? (
<div className="mt-4 flex flex-col gap-3">
{providersError ? (
<p className="text-xs text-destructive" role="alert">
Failed to load providers: {providersError}
</p>
) : (
<Select
onValueChange={(value) => {
setSelectedProviderId(value);
setSaveError(null);
}}
value={selectedProviderId || undefined}
>
<SelectTrigger
aria-label="Provider"
className="w-full bg-background"
>
<SelectValue
placeholder={
providersLoading
? "Loading providers..."
: "Choose a provider"
}
/>
</SelectTrigger>
<SelectContent>
{providers.map((provider) => (
<SelectItem key={provider.id} value={provider.id}>
{provider.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<Input
aria-label="API key"
autoComplete="off"
className="bg-background"
onChange={(event) => {
setApiKey(event.target.value);
setSaveError(null);
}}
placeholder={
selectedProvider
? `${selectedProvider.name} API key`
: "API key"
}
type="password"
value={apiKey}
/>
<div className="flex flex-wrap items-center justify-between gap-2">
{selectedProvider?.docUrl ? (
<button
className="inline-flex items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground"
onClick={() =>
void openExternalUrl(selectedProvider.docUrl ?? "")
}
type="button"
>
{selectedProvider.docLabel || "Get an API key"}
<ExternalLink className="size-3.5" />
</button>
) : (
<span />
)}
<Button
className="rounded-full"
disabled={!selectedProvider || !apiKey.trim() || saving}
onClick={() => void connectProvider()}
type="button"
>
{saving ? <Loader2 className="size-4 animate-spin" /> : null}
{saving ? "Connecting..." : "Connect"}
</Button>
</div>
{saveError ? (
<p className="text-xs text-destructive" role="alert">
Failed to save provider: {saveError}
</p>
) : null}
</div>
) : null}
</div>
</div>
<div className="mt-5 flex justify-center">
<button
className="text-sm text-muted-foreground transition-colors hover:text-foreground"
onClick={onSkip}
type="button"
>
Skip for now
</button>
</div>
</OnboardingCard>
);
}
function DoneStep({
connection,
onFinish,
}: {
connection: OnboardingConnection | null;
onFinish: () => void;
}) {
return (
<OnboardingCard>
<div className="flex flex-col items-center py-4 text-center">
<CheckCircle2 aria-hidden="true" className="size-10 text-primary" />
<h1 className="mt-4 text-2xl font-semibold tracking-tight text-foreground">
You&apos;re all set
</h1>
<p className="mt-2 text-sm text-muted-foreground">
{connection?.kind === "provider"
? `${connection.providerName} is connected. Pick a project and start your first session.`
: "Your Cline account is connected. Pick a project and start your first session."}
</p>
<Button
className="mt-8 h-11 w-full rounded-full text-[15px]"
onClick={onFinish}
type="button"
>
Start building
</Button>
</div>
</OnboardingCard>
);
}
/**
* 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
* onboarding has not been completed (see lib/onboarding.ts); `onComplete`
* marks it completed and returns to the chat.
*/
export function OnboardingView({
onComplete,
initialStep = "welcome",
}: {
onComplete: () => void;
initialStep?: OnboardingStep;
}) {
const [step, setStep] = useState<OnboardingStep>(initialStep);
const [connection, setConnection] = useState<OnboardingConnection | null>(
null,
);
return (
<div className="relative flex h-full w-full items-center justify-center overflow-hidden bg-background p-6">
<AuroraBackground />
{step === "welcome" ? (
<WelcomeStep onContinue={() => setStep("connect")} />
) : step === "connect" ? (
<ConnectStep
onBack={() => setStep("welcome")}
onConnected={(nextConnection) => {
setConnection(nextConnection);
setStep("done");
}}
onSkip={onComplete}
/>
) : (
<DoneStep connection={connection} onFinish={onComplete} />
)}
</div>
);
}
@@ -15,17 +15,28 @@ import {
CreditCard,
ExternalLink,
Loader2,
LogIn,
LogOut,
Plus,
Receipt,
RefreshCw,
User,
UserCircleIcon,
} from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { desktopClient } from "@/lib/desktop-client";
import { useAccount } from "@/contexts/account-context";
import { desktopClient, openExternalUrl } from "@/lib/desktop-client";
import { cn } from "@/lib/utils";
const DASHBOARD_URL = "https://app.cline.bot/dashboard";
const USER_CREDITS_URL =
"https://app.cline.bot/dashboard/account?tab=credits&redirect=true";
const ORGANIZATION_CREDITS_URL =
"https://app.cline.bot/dashboard/organization?tab=credits&redirect=true";
const CREATE_ORGANIZATION_URL = "https://app.cline.bot/onboarding?step=1";
const CREATE_ACCOUNT_URL = "https://app.cline.bot";
function normalizeAccountViewError(error: unknown): Error {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("unsupported desktop command: cline_account")) {
@@ -36,6 +47,18 @@ function normalizeAccountViewError(error: unknown): Error {
return error instanceof Error ? error : new Error(message);
}
function isAccountAuthError(message: string): boolean {
// Only definitive signed-out signals belong here: matching broader
// substrings like "auth token" or "unauthorized" turns transient refresh
// failures and org-permission errors into a sign-in card with no retry.
const normalized = message.toLowerCase();
return (
normalized.includes("no cline account auth token found") ||
normalized.includes("requires re-authentication") ||
normalized.includes("failed with status 401")
);
}
// ---------------------------------------------------------------------------
// Data fetching helpers via sidecar command
// ---------------------------------------------------------------------------
@@ -118,6 +141,16 @@ async function fetchPaymentTransactions(): Promise<
);
}
async function switchActiveAccount(
organizationId: string | null,
): Promise<void> {
await desktopClient.invoke("cline_account", {
action: "clineAccount",
operation: "switchAccount",
organizationId,
});
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
@@ -126,6 +159,7 @@ export function AccountView() {
const [activeTab, setActiveTab] = useState<"overview" | "usage" | "billing">(
"overview",
);
const { refreshAccount } = useAccount();
// Overview data
const [user, setUser] = useState<ClineAccountUser | null>(null);
@@ -137,6 +171,12 @@ export function AccountView() {
>([]);
const [overviewLoading, setOverviewLoading] = useState(true);
const [overviewError, setOverviewError] = useState<string | null>(null);
const [accountActionPending, setAccountActionPending] = useState<
"sign-in" | "sign-out" | null
>(null);
// Organization id being switched to, "" while switching to the personal
// account, null when no switch is in flight.
const [switchTargetId, setSwitchTargetId] = useState<string | null>(null);
// Usage data
const [usageTransactions, setUsageTransactions] = useState<
@@ -156,6 +196,19 @@ export function AccountView() {
const [billingLoaded, setBillingLoaded] = useState(false);
const activeOrganization = organizations.find((org) => org.active) ?? null;
const resetAccountData = useCallback(() => {
setUser(null);
setBalance(null);
setOrganizationBalance(null);
setOrganizations([]);
setUsageTransactions([]);
setUsageLoaded(false);
setUsageError(null);
setPaymentTransactions([]);
setBillingLoaded(false);
setBillingError(null);
}, []);
// -- Overview fetch --
const loadOverview = useCallback(async () => {
setOverviewLoading(true);
@@ -176,17 +229,80 @@ export function AccountView() {
setOrganizationBalance(organizationBalanceData);
setOrganizations(orgsData);
} catch (err) {
resetAccountData();
const message = normalizeAccountViewError(err).message;
setOverviewError(message);
} finally {
setOverviewLoading(false);
}
}, []);
}, [resetAccountData]);
useEffect(() => {
void loadOverview();
}, [loadOverview]);
const signIn = async () => {
setAccountActionPending("sign-in");
setOverviewError(null);
try {
await desktopClient.invoke("run_provider_oauth_login", {
provider: "cline",
});
await loadOverview();
setActiveTab("overview");
} catch (err) {
const message = normalizeAccountViewError(err).message;
setOverviewError(message);
resetAccountData();
} finally {
setAccountActionPending(null);
void refreshAccount();
}
};
const signOut = async () => {
setAccountActionPending("sign-out");
try {
await desktopClient.invoke("save_provider_settings", {
provider: "cline",
api_key: "",
settings: {
auth: {
accessToken: "",
refreshToken: "",
accountId: "",
},
},
});
resetAccountData();
setActiveTab("overview");
setOverviewError("No Cline account auth token found");
} catch (err) {
const message = normalizeAccountViewError(err).message;
setOverviewError(message);
} finally {
setAccountActionPending(null);
void refreshAccount();
}
};
const switchAccount = async (organizationId: string | null) => {
if (switchTargetId !== null) {
return;
}
setSwitchTargetId(organizationId ?? "");
try {
await switchActiveAccount(organizationId);
await loadOverview();
} catch (err) {
const message = normalizeAccountViewError(err).message;
setOverviewError(message);
} finally {
setSwitchTargetId(null);
void refreshAccount();
}
};
// -- Usage fetch (lazy on tab switch) --
const loadUsage = useCallback(async () => {
const generation = usageGenerationRef.current;
@@ -296,54 +412,154 @@ export function AccountView() {
</div>
);
const renderSignedOut = () => (
<div className="rounded-lg border border-border p-6">
<div className="mx-auto flex max-w-xl flex-col items-center gap-4 py-8 text-center">
<div className="flex size-12 items-center justify-center rounded-lg bg-primary/10 text-primary">
<UserCircleIcon className="h-6 w-6" />
</div>
<div>
<h3 className="text-lg font-semibold text-foreground">
Sign in to Cline
</h3>
<p className="mt-2 text-sm text-muted-foreground">
Connect your Cline account to review credits, usage, billing, and
organization details.
</p>
</div>
<div className="flex flex-wrap items-center justify-center gap-2">
<button
type="button"
disabled={accountActionPending !== null}
onClick={() => void signIn()}
className="flex items-center gap-2 rounded-lg bg-primary px-3.5 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-60"
>
{accountActionPending === "sign-in" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<LogIn className="h-4 w-4" />
)}
{accountActionPending === "sign-in" ? "Signing in" : "Sign in"}
</button>
<button
type="button"
onClick={() => void openExternalUrl(CREATE_ACCOUNT_URL)}
className="flex items-center gap-2 rounded-lg border border-border px-3.5 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
Create account
<ExternalLink className="h-4 w-4" />
</button>
</div>
</div>
</div>
);
const renderLoading = () => (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
const renderAccountRow = (input: {
key: string;
name: string;
subtitle: string;
icon: React.ReactNode;
active: boolean;
switching: boolean;
onSelect: () => void;
}) => (
<button
key={input.key}
type="button"
disabled={input.active || switchTargetId !== null}
onClick={input.onSelect}
className={cn(
"flex w-full items-center gap-3 rounded-lg border border-border px-4 py-3 text-left transition-colors",
input.active ? "cursor-default" : "hover:bg-accent/20",
!input.active && switchTargetId !== null && "opacity-60",
)}
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-secondary text-sm font-bold text-foreground">
{input.icon}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">{input.name}</p>
<p className="text-xs text-muted-foreground capitalize">
{input.subtitle}
</p>
</div>
{input.switching ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : input.active ? (
<span className="rounded-full bg-primary/20 px-2 py-0.5 text-xs font-medium text-primary">
Active
</span>
) : (
<span className="text-xs text-muted-foreground">Switch</span>
)}
</button>
);
return (
<ScrollArea className="h-full">
<div className="mx-auto max-w-3xl px-8 py-6">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">Account</h2>
<button
type="button"
className="flex items-center gap-2 rounded-lg border border-border px-3.5 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<LogOut className="h-4 w-4" />
Sign Out
</button>
{user && (
<button
type="button"
disabled={accountActionPending !== null}
onClick={() => void signOut()}
className="flex items-center gap-2 rounded-lg border border-border px-3.5 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-60"
>
{accountActionPending === "sign-out" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<LogOut className="h-4 w-4" />
)}
{accountActionPending === "sign-out" ? "Signing Out" : "Sign Out"}
</button>
)}
</div>
{/* Tabs */}
<div className="mb-6 flex items-center gap-0 border-b border-border">
{tabs.map((tab) => (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
className={cn(
"relative px-4 py-2.5 text-sm font-medium capitalize transition-colors",
activeTab === tab
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{tab}
{activeTab === tab && (
<span className="absolute inset-x-0 -bottom-px h-0.5 bg-foreground" />
)}
</button>
))}
{tabs.map((tab) => {
const disabled = !user && tab !== "overview";
return (
<button
key={tab}
type="button"
disabled={disabled}
onClick={() => setActiveTab(tab)}
className={cn(
"relative px-4 py-2.5 text-sm font-medium capitalize transition-colors",
activeTab === tab
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
disabled &&
"cursor-not-allowed opacity-45 hover:text-muted-foreground",
)}
>
{tab}
{activeTab === tab && (
<span className="absolute inset-x-0 -bottom-px h-0.5 bg-foreground" />
)}
</button>
);
})}
</div>
{/* Overview Tab */}
{activeTab === "overview" && (
<div className="flex flex-col gap-6">
{overviewLoading && renderLoading()}
{overviewError && renderError(overviewError, loadOverview)}
{overviewError &&
(isAccountAuthError(overviewError)
? renderSignedOut()
: renderError(overviewError, loadOverview))}
{!overviewLoading && !overviewError && user && (
<>
{/* User Profile Card */}
@@ -365,14 +581,14 @@ export function AccountView() {
Member since {formatDate(user.createdAt)}
</p>
</div>
<Link
href="https://app.cline.bot/dashboard"
target="_blank"
rel="noopener noreferrer"
<button
type="button"
title="Open dashboard"
onClick={() => void openExternalUrl(DASHBOARD_URL)}
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<ExternalLink className="h-4 w-4" />
</Link>
</button>
</div>
</div>
@@ -388,15 +604,20 @@ export function AccountView() {
: "Credits Balance"}
</h3>
</div>
<Link
href="https://app.cline.bot/dashboard/organization?tab=credits&redirect=true"
target="_blank"
rel="noopener noreferrer"
<button
type="button"
onClick={() =>
void openExternalUrl(
activeOrganization
? ORGANIZATION_CREDITS_URL
: USER_CREDITS_URL,
)
}
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<Plus className="h-3.5 w-3.5" />
Credit
</Link>
</button>
</div>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold text-foreground">
@@ -421,47 +642,39 @@ export function AccountView() {
Organizations
</h3>
</div>
<Link
href="https://app.cline.bot/onboarding?step=1"
target="_blank"
rel="noopener noreferrer"
<button
type="button"
onClick={() =>
void openExternalUrl(CREATE_ORGANIZATION_URL)
}
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<Plus className="h-3.5 w-3.5" />
Create
</Link>
</button>
</div>
<div className="flex flex-col gap-2">
{renderAccountRow({
key: "personal",
name: "Personal",
subtitle: user.email ?? "Personal account",
icon: <User className="h-4 w-4" />,
active: !activeOrganization,
switching: switchTargetId === "",
onSelect: () => void switchAccount(null),
})}
{organizations.map((org) =>
renderAccountRow({
key: org.organizationId,
name: org.name,
subtitle: org.roles.join(", "),
icon: org.name.charAt(0),
active: org.active,
switching: switchTargetId === org.organizationId,
onSelect: () => void switchAccount(org.organizationId),
}),
)}
</div>
{organizations.length === 0 ? (
<p className="text-sm text-muted-foreground">
No organizations yet.
</p>
) : (
<div className="flex flex-col gap-2">
{organizations.map((org) => (
<div
key={org.organizationId}
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 transition-colors hover:bg-accent/20"
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-secondary text-sm font-bold text-foreground">
{org.name.charAt(0)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">
{org.name}
</p>
<p className="text-xs text-muted-foreground capitalize">
{org.roles.join(", ")}
</p>
</div>
{org.active && (
<span className="rounded-full bg-primary/20 px-2 py-0.5 text-xs font-medium text-primary">
Active
</span>
)}
</div>
))}
</div>
)}
</div>
</>
)}
@@ -13,8 +13,8 @@ import {
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { PageFrame, PageHeader } from "../page-layout";
const CAPABILITY_OPTIONS = [
"streaming",
@@ -204,326 +204,326 @@ export function AddProviderContent({
};
return (
<ScrollArea className="h-full">
<div className="mx-auto max-w-3xl px-8 py-6">
<div className="mb-6 flex items-center gap-3">
<PageFrame contentClassName="max-w-4xl">
<PageHeader
description="Add an OpenAI-compatible provider and choose its available models."
title="Add Provider"
actions={
<Button
onClick={onBack}
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
variant="secondary"
className="rounded-md p-1.5 transition-colors"
aria-label="Back to providers"
>
<ArrowLeft className="h-4 w-4" />
Providers
</Button>
<h2 className="text-lg font-semibold text-foreground">
Add Provider
</h2>
</div>
}
/>
<div className="flex flex-col gap-6">
<div className="rounded-lg border border-border p-5">
<h3 className="mb-4 text-sm font-semibold text-foreground">
OpenAI-Compatible Provider
</h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Provider ID
</Label>
<input
type="text"
value={form.providerId}
onChange={(e) =>
setForm((prev) => ({ ...prev, providerId: e.target.value }))
}
placeholder="my-provider"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<p className="mt-1.5 text-xs text-muted-foreground">
Lowercase ID used in provider registry.
</p>
{duplicateProviderId ? (
<p className="mt-1 text-xs text-destructive">
This provider ID already exists.
</p>
) : null}
</div>
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Provider Name
</Label>
<input
type="text"
value={form.name}
onChange={(e) =>
setForm((prev) => ({ ...prev, name: e.target.value }))
}
placeholder="My Provider"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
</div>
</div>
</div>
<div className="rounded-lg border border-border p-5">
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Base URL
</Label>
<input
type="url"
value={form.baseUrl}
onChange={(e) =>
setForm((prev) => ({ ...prev, baseUrl: e.target.value }))
}
placeholder="https://api.example.com/v1"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
</div>
<div className="rounded-lg border border-border p-5">
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Model Source URL (Optional)
</Label>
<input
type="url"
value={form.modelsSourceUrl}
onChange={(e) =>
setForm((prev) => ({
...prev,
modelsSourceUrl: e.target.value,
}))
}
placeholder="https://api.example.com/v1/models"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<p className="mt-1.5 text-xs text-muted-foreground">
Supported JSON: OpenAI `/models` shape with a `data` array, or a
direct model array.
</p>
</div>
<div className="rounded-lg border border-border p-5">
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Models
</Label>
<div className="flex min-h-11 flex-wrap content-start gap-1.5 rounded-lg border border-border bg-input px-3 py-2 focus-within:ring-1 focus-within:ring-ring">
{form.models.map((model) => (
<span
key={model}
className="inline-flex items-center gap-1 rounded-md bg-primary/10 px-2 py-1 text-xs font-medium text-primary"
>
<span className="font-mono">{model}</span>
<Button
onClick={() => removeModel(model)}
className="text-primary/60 hover:text-primary transition-colors"
aria-label={`Remove ${model}`}
>
<X className="h-3 w-3" />
</Button>
</span>
))}
<div className="flex flex-col gap-6">
<div className="rounded-lg border border-border p-5">
<h3 className="mb-4 text-sm font-semibold text-foreground">
OpenAI-Compatible Provider
</h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Provider ID
</Label>
<input
type="text"
value={modelInput}
onChange={(e) => setModelInput(e.target.value)}
onKeyDown={handleAddModel}
placeholder={
form.models.length === 0
? "Type model ID and press Enter"
: ""
value={form.providerId}
onChange={(e) =>
setForm((prev) => ({ ...prev, providerId: e.target.value }))
}
className="min-w-35 flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground/50 outline-none"
placeholder="my-provider"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<p className="mt-1.5 text-xs text-muted-foreground">
Lowercase ID used in provider registry.
</p>
{duplicateProviderId ? (
<p className="mt-1 text-xs text-destructive">
This provider ID already exists.
</p>
) : null}
</div>
<p className="mt-1.5 text-xs text-muted-foreground">
Add at least one model or set a Model Source URL.
</p>
</div>
{form.models.length > 1 ? (
<div className="rounded-lg border border-border p-5">
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Default Model
Provider Name
</Label>
<select
value={form.defaultModel}
onChange={(e) =>
setForm((prev) => ({ ...prev, defaultModel: e.target.value }))
}
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
>
{form.models.map((model) => (
<option key={model} value={model}>
{model}
</option>
))}
</select>
</div>
) : null}
<div className="rounded-lg border border-border p-5">
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
API Key (Optional)
</Label>
<div className="relative">
<input
type={showApiKey ? "text" : "password"}
value={form.apiKey}
type="text"
value={form.name}
onChange={(e) =>
setForm((prev) => ({ ...prev, apiKey: e.target.value }))
setForm((prev) => ({ ...prev, name: e.target.value }))
}
placeholder="sk-..."
className="w-full rounded-lg border border-border bg-input px-3 py-2 pr-20 font-mono text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
placeholder="My Provider"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
<Button
onClick={() => setShowApiKey(!showApiKey)}
className="rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
aria-label={showApiKey ? "Hide API key" : "Show API key"}
>
{showApiKey ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
<Button
onClick={() => navigator.clipboard.writeText(form.apiKey)}
className="rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
aria-label="Copy API key"
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
<div className="rounded-lg border border-border p-5">
<Label className="mb-3 block text-xs font-medium text-muted-foreground">
Capabilities
</Label>
<div className="flex flex-wrap gap-2">
{CAPABILITY_OPTIONS.map((cap) => (
<div className="rounded-lg border border-border p-5">
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Base URL
</Label>
<input
type="url"
value={form.baseUrl}
onChange={(e) =>
setForm((prev) => ({ ...prev, baseUrl: e.target.value }))
}
placeholder="https://api.example.com/v1"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
</div>
<div className="rounded-lg border border-border p-5">
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Model Source URL (Optional)
</Label>
<input
type="url"
value={form.modelsSourceUrl}
onChange={(e) =>
setForm((prev) => ({
...prev,
modelsSourceUrl: e.target.value,
}))
}
placeholder="https://api.example.com/v1/models"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<p className="mt-1.5 text-xs text-muted-foreground">
Supported JSON: OpenAI `/models` shape with a `data` array, or a
direct model array.
</p>
</div>
<div className="rounded-lg border border-border p-5">
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Models
</Label>
<div className="flex min-h-11 flex-wrap content-start gap-1.5 rounded-lg border border-border bg-input px-3 py-2 focus-within:ring-1 focus-within:ring-ring">
{form.models.map((model) => (
<span
key={model}
className="inline-flex items-center gap-1 rounded-md bg-primary/10 px-2 py-1 text-xs font-medium text-primary"
>
<span className="font-mono">{model}</span>
<Button
key={cap}
onClick={() => toggleCapability(cap)}
className={cn(
"rounded-lg border px-3 py-1.5 text-xs font-medium transition-all",
form.capabilities.includes(cap)
? "border-primary/40 bg-primary/10 text-primary"
: "border-border bg-card text-muted-foreground hover:border-muted-foreground/50 hover:text-foreground",
)}
onClick={() => removeModel(model)}
className="text-primary/60 hover:text-primary transition-colors"
aria-label={`Remove ${model}`}
>
{cap.replace(/-/g, " ")}
<X className="h-3 w-3" />
</Button>
</span>
))}
<input
type="text"
value={modelInput}
onChange={(e) => setModelInput(e.target.value)}
onKeyDown={handleAddModel}
placeholder={
form.models.length === 0 ? "Type model ID and press Enter" : ""
}
className="min-w-35 flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground/50 outline-none"
/>
</div>
<p className="mt-1.5 text-xs text-muted-foreground">
Add at least one model or set a Model Source URL.
</p>
</div>
{form.models.length > 1 ? (
<div className="rounded-lg border border-border p-5">
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Default Model
</Label>
<select
value={form.defaultModel}
onChange={(e) =>
setForm((prev) => ({ ...prev, defaultModel: e.target.value }))
}
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
>
{form.models.map((model) => (
<option key={model} value={model}>
{model}
</option>
))}
</select>
</div>
) : null}
<div className="rounded-lg border border-border p-5">
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
API Key (Optional)
</Label>
<div className="relative">
<input
type={showApiKey ? "text" : "password"}
value={form.apiKey}
onChange={(e) =>
setForm((prev) => ({ ...prev, apiKey: e.target.value }))
}
placeholder="sk-..."
className="w-full rounded-lg border border-border bg-input px-3 py-2 pr-20 font-mono text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
<Button
onClick={() => setShowApiKey(!showApiKey)}
variant="ghost"
className="rounded-md p-1 transition-colors"
aria-label={showApiKey ? "Hide API key" : "Show API key"}
>
{showApiKey ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
<Button
onClick={() => navigator.clipboard.writeText(form.apiKey)}
variant="ghost"
className="rounded-md p-1 transition-colors"
aria-label="Copy API key"
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<div className="rounded-lg border border-border overflow-hidden">
<Button
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex w-full items-center justify-between px-5 py-4 text-sm font-medium text-foreground hover:bg-accent/30 transition-colors"
>
Advanced Settings
<ChevronDown
<div className="rounded-lg border border-border p-5">
<Label className="mb-3 block text-xs font-medium text-muted-foreground">
Capabilities
</Label>
<div className="flex flex-wrap gap-2">
{CAPABILITY_OPTIONS.map((cap) => (
<Button
key={cap}
onClick={() => toggleCapability(cap)}
className={cn(
"h-4 w-4 text-muted-foreground transition-transform",
showAdvanced && "rotate-180",
"rounded-lg border px-3 py-1.5 text-xs font-medium transition-all",
form.capabilities.includes(cap)
? "border-primary/40 bg-primary/10 text-primary"
: "border-border bg-card text-muted-foreground hover:border-muted-foreground/50 hover:text-foreground",
)}
/>
</Button>
>
{cap.replace(/-/g, " ")}
</Button>
))}
</div>
</div>
<div className="rounded-lg border border-border overflow-hidden">
<Button
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex w-full items-center justify-between px-5 py-4 text-sm font-medium transition-colors text-foreground/40"
variant="ghost"
>
Advanced Settings
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform",
showAdvanced && "rotate-180",
)}
/>
</Button>
{showAdvanced ? (
<div className="border-t border-border px-5 py-5 flex flex-col gap-5">
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Timeout (ms)
</Label>
<input
type="number"
value={form.timeoutMs}
onChange={(e) =>
setForm((prev) => ({
...prev,
timeoutMs: e.target.value,
}))
}
placeholder="30000"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
</div>
{showAdvanced ? (
<div className="border-t border-border px-5 py-5 flex flex-col gap-5">
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Timeout (ms)
</Label>
<input
type="number"
value={form.timeoutMs}
onChange={(e) =>
setForm((prev) => ({
...prev,
timeoutMs: e.target.value,
}))
}
placeholder="30000"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
</div>
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Custom Headers
</Label>
<div className="flex flex-col gap-2">
{Object.entries(form.headers).map(([key, value], idx) => (
<div key={key} className="flex items-center gap-2">
<input
type="text"
value={key}
onChange={(e) =>
updateHeaderKey(key, e.target.value, idx)
}
placeholder="Header name"
className="flex-1 rounded-lg border border-border bg-input px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<input
type="text"
value={value}
onChange={(e) =>
updateHeaderValue(key, e.target.value)
}
placeholder="Value"
className="flex-1 rounded-lg border border-border bg-input px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<Button
onClick={() => removeHeader(key)}
className="rounded-md p-2 text-muted-foreground hover:text-destructive transition-colors"
aria-label="Remove header"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button
onClick={addHeader}
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors w-fit"
>
<Plus className="h-3 w-3" />
Add Header
</Button>
</div>
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Custom Headers
</Label>
<div className="flex flex-col gap-2">
{Object.entries(form.headers).map(([key, value], idx) => (
<div key={key} className="flex items-center gap-2">
<input
type="text"
value={key}
onChange={(e) =>
updateHeaderKey(key, e.target.value, idx)
}
placeholder="Header name"
className="flex-1 rounded-lg border border-border bg-input px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<input
type="text"
value={value}
onChange={(e) => updateHeaderValue(key, e.target.value)}
placeholder="Value"
className="flex-1 rounded-lg border border-border bg-input px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<Button
onClick={() => removeHeader(key)}
className="rounded-md p-2 text-muted-foreground hover:text-destructive transition-colors"
aria-label="Remove header"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button
onClick={addHeader}
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors w-fit"
>
<Plus className="h-3 w-3" />
Add Header
</Button>
</div>
</div>
) : null}
</div>
</div>
) : null}
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<div className="flex items-center justify-end gap-3 pt-2">
<Button
onClick={onBack}
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
Cancel
</Button>
<Button
onClick={() => void handleSave()}
disabled={!canSave || saving}
className={cn(
"rounded-lg px-4 py-2 text-sm font-medium transition-colors",
canSave && !saving
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-muted text-muted-foreground cursor-not-allowed",
)}
>
{saving ? "Saving..." : "Add Provider"}
</Button>
</div>
<div className="flex items-center justify-end gap-3 pt-2">
<Button
onClick={onBack}
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
Cancel
</Button>
<Button
onClick={() => void handleSave()}
disabled={!canSave || saving}
className={cn(
"rounded-lg px-4 py-2 text-sm font-medium transition-colors",
canSave && !saving
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-muted text-muted-foreground cursor-not-allowed",
)}
>
{saving ? "Saving..." : "Add Provider"}
</Button>
</div>
</div>
</ScrollArea>
</PageFrame>
);
}
@@ -0,0 +1,489 @@
// @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 { ChannelsContent } from "./channels-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 telegramChannel = {
id: "telegram",
name: "Telegram",
type: "polling" as const,
hint: "No public URL needed.",
fields: [
{
flag: "-k",
label: "Bot token",
placeholder: "7123456789:AAH...",
required: true,
help: ["Copy the token from @BotFather."],
},
],
security: {
prompt: "Restrict access to your Telegram user ID?",
fields: [
{
key: "userId",
label: "Your Telegram user ID",
placeholder: "123456789",
requiredMessage: "User ID is required to restrict access",
},
],
},
};
const slackChannel = {
id: "slack",
name: "Slack",
type: "hybrid" as const,
hint: "Public URL for webhook mode; leave blank for socket mode.",
fields: [
{
flag: "--bot-token",
label: "Bot token",
required: true,
},
{
flag: "--base-url",
label: "Public base URL",
placeholder: "leave blank for socket mode",
},
{
flag: "--signing-secret",
label: "Signing secret",
required: true,
includeWhen: { flag: "--base-url", notEquals: "" },
},
{
flag: "--app-token",
label: "App-level token",
required: true,
includeWhen: { flag: "--base-url", equals: "" },
},
],
};
const gchatChannel = {
id: "gchat",
name: "Google Chat",
type: "webhook" as const,
hint: "Requires Google Cloud credentials and a public URL.",
fields: [
{
flag: "--credentials-json",
label: "Service account credentials JSON",
required: true,
},
],
};
let container: HTMLDivElement;
let root: Root;
class ResizeObserverStub {
disconnect() {}
observe() {}
unobserve() {}
}
beforeEach(() => {
Object.assign(globalThis, {
IS_REACT_ACT_ENVIRONMENT: true,
ResizeObserver: ResizeObserverStub,
});
HTMLElement.prototype.scrollTo = vi.fn();
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 renderChannels() {
await act(async () => {
root.render(<ChannelsContent />);
});
await vi.waitFor(() => {
expect(invokeMock).toHaveBeenCalledWith("list_connector_channels");
});
}
async function click(element: Element): Promise<void> {
await act(async () => {
element.dispatchEvent(
new MouseEvent("click", { bubbles: true, cancelable: true }),
);
await Promise.resolve();
});
}
async function changeInput(
input: HTMLInputElement,
value: string,
): Promise<void> {
const setValue = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)?.set;
await act(async () => {
setValue?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
}
async function changeTextarea(
textarea: HTMLTextAreaElement,
value: string,
): Promise<void> {
const setValue = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
"value",
)?.set;
await act(async () => {
setValue?.call(textarea, value);
textarea.dispatchEvent(new Event("input", { bubbles: true }));
});
}
function buttonWithText(text: string, rootElement: ParentNode = container) {
const button = [
...rootElement.querySelectorAll<HTMLButtonElement>("button"),
].find((candidate) => candidate.textContent?.includes(text));
expect(button).toBeDefined();
return button as HTMLButtonElement;
}
function channelListIds(): string[] {
return [
...container.querySelectorAll<HTMLButtonElement>(
'button[id^="channel-"][id$="-trigger"]',
),
].map((button) =>
button.id.replace(/^channel-/, "").replace(/-trigger$/, ""),
);
}
describe("ChannelsContent", () => {
it("renders the backend catalog and starts Telegram with canonical field and security keys", async () => {
const initialResponse = {
available: [telegramChannel, slackChannel],
active: [],
};
invokeMock.mockImplementation(async (command: string) => {
if (command === "list_connector_channels") {
return initialResponse;
}
if (command === "start_connector_channel") {
return {
...initialResponse,
active: [
{
id: "telegram:test_bot",
type: "telegram",
pid: 42,
hubUrl: "ws://127.0.0.1:4317",
botUsername: "test_bot",
},
],
};
}
throw new Error(`Unexpected command: ${command}`);
});
await renderChannels();
await vi.waitFor(() => {
expect(container.textContent).toContain("Telegram");
expect(container.textContent).toContain("Slack");
});
expect(channelListIds()).toEqual(["slack", "telegram"]);
expect(container.textContent).not.toContain("Mattermost");
expect(
container
.querySelector('button[aria-label="Connect Telegram"]')
?.getAttribute("aria-checked"),
).toBe("false");
await click(buttonWithText("Telegram"));
const tokenInput = container.querySelector<HTMLInputElement>(
"#channel-telegram-credential--k",
) as HTMLInputElement;
await changeInput(tokenInput, "7123456789:test-token");
await click(
container.querySelector('button[aria-label="Show Bot token"]') as Element,
);
expect(tokenInput.type).toBe("text");
await click(
container.querySelector("#channel-telegram-security-toggle") as Element,
);
await changeInput(
container.querySelector<HTMLInputElement>(
"#channel-telegram-security-userId",
) as HTMLInputElement,
"123456789",
);
await click(buttonWithText("Save"));
await vi.waitFor(() => {
expect(invokeMock).toHaveBeenCalledWith("start_connector_channel", {
channel: "telegram",
values: { "-k": "7123456789:test-token" },
security: {
enabled: true,
values: { userId: "123456789" },
},
});
expect(container.textContent).toContain("@test_bot");
expect(
container
.querySelector('button[aria-label="Disconnect Telegram"]')
?.getAttribute("aria-checked"),
).toBe("true");
expect(channelListIds()).toEqual(["telegram", "slack"]);
expect(tokenInput.type).toBe("password");
expect(tokenInput.value).toBe("7123456789:test-token");
});
});
it("masks multiline credentials before and after connecting", async () => {
const initialResponse = { available: [gchatChannel], active: [] };
invokeMock.mockImplementation(async (command: string) => {
if (command === "list_connector_channels") {
return initialResponse;
}
if (command === "start_connector_channel") {
return {
...initialResponse,
active: [
{
id: "gchat:cline-bot",
type: "gchat",
pid: 43,
hubUrl: "ws://127.0.0.1:4317",
userName: "cline-bot",
},
],
};
}
throw new Error(`Unexpected command: ${command}`);
});
await renderChannels();
await vi.waitFor(() => {
expect(container.textContent).toContain("Google Chat");
});
await click(buttonWithText("Google Chat"));
const textarea = container.querySelector<HTMLTextAreaElement>(
"#channel-gchat-credential---credentials-json",
) as HTMLTextAreaElement;
expect(textarea.className).toContain("[-webkit-text-security:disc]");
await changeTextarea(textarea, '{"private_key":"secret"}');
await click(
container.querySelector(
'button[aria-label="Show Service account credentials JSON"]',
) as Element,
);
expect(textarea.className).not.toContain("[-webkit-text-security:disc]");
await click(buttonWithText("Save"));
await vi.waitFor(() => {
expect(invokeMock).toHaveBeenCalledWith("start_connector_channel", {
channel: "gchat",
values: { "--credentials-json": '{"private_key":"secret"}' },
security: { enabled: false, values: {} },
});
expect(textarea.className).toContain("[-webkit-text-security:disc]");
expect(textarea.disabled).toBe(false);
});
});
it("retains submitted credentials when connecting fails", async () => {
invokeMock.mockImplementation(async (command: string) => {
if (command === "list_connector_channels") {
return { available: [telegramChannel], active: [] };
}
if (command === "start_connector_channel") {
throw new Error("connector failed to start");
}
throw new Error(`Unexpected command: ${command}`);
});
await renderChannels();
await vi.waitFor(() => {
expect(container.textContent).toContain("Telegram");
});
await click(buttonWithText("Telegram"));
const tokenInput = container.querySelector<HTMLInputElement>(
"#channel-telegram-credential--k",
) as HTMLInputElement;
await changeInput(tokenInput, "7123456789:retry-token");
await click(buttonWithText("Save"));
await vi.waitFor(() => {
expect(container.textContent).toContain("connector failed to start");
expect(tokenInput.value).toBe("7123456789:retry-token");
expect(tokenInput.disabled).toBe(false);
expect(
container
.querySelector("#channel-telegram-trigger")
?.getAttribute("aria-expanded"),
).toBe("true");
});
});
it("saves edited configuration for an active channel", async () => {
const activeConnector = {
id: "telegram:first_bot",
type: "telegram",
pid: 41,
hubUrl: "ws://127.0.0.1:4317",
botUsername: "first_bot",
};
invokeMock.mockImplementation(async (command: string) => {
if (command === "list_connector_channels") {
return { available: [telegramChannel], active: [activeConnector] };
}
if (command === "start_connector_channel") {
return {
available: [telegramChannel],
active: [activeConnector],
};
}
throw new Error(`Unexpected command: ${command}`);
});
await renderChannels();
await vi.waitFor(() => {
expect(container.textContent).toContain("Telegram");
});
await click(buttonWithText("Telegram"));
const tokenInput = container.querySelector<HTMLInputElement>(
"#channel-telegram-credential--k",
) as HTMLInputElement;
expect(tokenInput.disabled).toBe(false);
expect(
(
container.querySelector(
"#channel-telegram-security-toggle",
) as HTMLButtonElement
).disabled,
).toBe(false);
expect(container.textContent).not.toContain("New Connection");
await changeInput(tokenInput, "7123456789:updated-token");
await click(buttonWithText("Save"));
await vi.waitFor(() => {
expect(invokeMock).toHaveBeenCalledWith("start_connector_channel", {
channel: "telegram",
values: { "-k": "7123456789:updated-token" },
security: { enabled: false, values: {} },
});
expect(container.textContent).toContain("@first_bot");
expect(container.textContent).toContain("Active connection");
});
});
it("switches Slack conditional fields and blocks a missing visible required field", async () => {
invokeMock.mockResolvedValue({ available: [slackChannel], active: [] });
await renderChannels();
await vi.waitFor(() => {
expect(container.textContent).toContain("Slack");
});
const slackTrigger = container.querySelector(
"#channel-slack-trigger",
) as HTMLButtonElement;
expect(slackTrigger.getAttribute("aria-expanded")).toBe("false");
await click(
container.querySelector('button[aria-label="Connect Slack"]') as Element,
);
expect(slackTrigger.getAttribute("aria-expanded")).toBe("true");
expect(container.textContent).toContain("Bot token is required");
expect(container.textContent).toContain("App-level token");
expect(container.textContent).not.toContain("Signing secret");
await changeInput(
container.querySelector<HTMLInputElement>(
"#channel-slack-credential---base-url",
) as HTMLInputElement,
"https://example.com",
);
expect(container.textContent).toContain("Signing secret");
expect(container.textContent).not.toContain("App-level token");
await click(buttonWithText("Save"));
expect(container.textContent).toContain("Bot token is required");
expect(invokeMock).not.toHaveBeenCalledWith(
"start_connector_channel",
expect.anything(),
);
});
it("resets the active connection from the expanded channel footer", async () => {
const activeConnector = {
id: "telegram:test_bot",
type: "telegram",
pid: 42,
hubUrl: "ws://127.0.0.1:4317",
botUsername: "test_bot",
};
invokeMock.mockImplementation(async (command: string) => {
if (command === "list_connector_channels") {
return { available: [telegramChannel], active: [activeConnector] };
}
if (command === "stop_connector_channel") {
return { available: [telegramChannel], active: [] };
}
throw new Error(`Unexpected command: ${command}`);
});
await renderChannels();
await vi.waitFor(() => {
expect(container.textContent).toContain("Telegram");
});
await click(buttonWithText("Telegram"));
expect(
container.querySelector('button[aria-label^="Disconnect @"]'),
).toBeNull();
await click(buttonWithText("Reset"));
await vi.waitFor(() => {
expect(document.body.textContent).toContain("Reset Telegram?");
});
await click(
buttonWithText(
"Reset",
document.querySelector('[role="alertdialog"]') as Element,
),
);
await vi.waitFor(() => {
expect(invokeMock).toHaveBeenCalledWith("stop_connector_channel", {
channel: "telegram",
});
expect(
container
.querySelector('button[aria-label="Connect Telegram"]')
?.getAttribute("aria-checked"),
).toBe("false");
});
});
});
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,14 @@
"use client";
import { Circle, Minus, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import {
ChevronRight,
Circle,
Minus,
Pencil,
Plus,
RefreshCw,
Trash2,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
AlertDialog,
@@ -13,6 +21,11 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Dialog,
DialogContent,
@@ -23,6 +36,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectContent,
@@ -34,10 +48,26 @@ import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { desktopClient } from "@/lib/desktop-client";
import { cn } from "@/lib/utils";
import {
type MarketplaceLocalInstalledItem,
MarketplaceView,
} from "../marketplace-view";
import { CommandBadge, PageFrame, PageHeader } from "../page-layout";
type McpTransportType = "stdio" | "sse" | "streamableHttp";
type McpServerType = "local" | "remote";
function serverTypeOf(transportType: McpTransportType): McpServerType {
return transportType === "stdio" ? "local" : "remote";
}
const TRANSPORT_TYPE_LABELS: Record<McpTransportType, string> = {
stdio: "Local · stdio",
sse: "Remote · SSE (legacy)",
streamableHttp: "Remote · Streamable HTTP",
};
interface McpServer {
name: string;
transportType: McpTransportType;
@@ -178,6 +208,7 @@ export function McpServersContent() {
const [formState, setFormState] = useState<McpServerFormState>(() =>
createServerFormState(),
);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [formErrorMessage, setFormErrorMessage] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<McpServer | null>(null);
@@ -287,7 +318,7 @@ export function McpServersContent() {
if (form.transportType === "stdio") {
const command = form.command.trim();
if (!command) {
throw new Error("Command is required for stdio transport.");
throw new Error("Command is required for local servers.");
}
const args = splitCsv(form.argsText);
return {
@@ -304,7 +335,7 @@ export function McpServersContent() {
}
const url = form.url.trim();
if (!url) {
throw new Error("URL is required for sse and streamableHttp transport.");
throw new Error("Server URL is required for remote servers.");
}
return {
name,
@@ -320,6 +351,7 @@ export function McpServersContent() {
const openCreateDialog = () => {
setEditorMode("create");
setFormState(createServerFormState());
setAdvancedOpen(false);
setFormErrorMessage(null);
setEditorOpen(true);
};
@@ -327,6 +359,9 @@ export function McpServersContent() {
const openEditDialog = (server: McpServer) => {
setEditorMode("edit");
setFormState(createServerFormState(server));
setAdvancedOpen(
Boolean(server.cwd?.trim()) || server.metadata !== undefined,
);
setFormErrorMessage(null);
setEditorOpen(true);
};
@@ -404,6 +439,117 @@ export function McpServersContent() {
}));
};
const renderServerActions = (server: McpServer) => {
const isBusy = busyServerName === server.name;
return (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit ${server.name}`}
onClick={() => openEditDialog(server)}
disabled={isBusy}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Delete ${server.name}`}
onClick={() => setDeleteTarget(server)}
disabled={isBusy}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<Switch
checked={!server.disabled}
onCheckedChange={(enabled) => toggleServer(server, !enabled)}
disabled={isBusy}
aria-label={`Enable ${server.name}`}
/>
</div>
);
};
const renderServerDetails = (server: McpServer) => (
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
{server.command && (
<p>
<span className="text-muted-foreground/70">Command:</span>{" "}
{server.command}
</p>
)}
{server.args && server.args.length > 0 && (
<p>
<span className="text-muted-foreground/70">Args:</span>{" "}
{server.args.join(", ")}
</p>
)}
{server.cwd && (
<p>
<span className="text-muted-foreground/70">CWD:</span> {server.cwd}
</p>
)}
{server.url && (
<p>
<span className="text-muted-foreground/70">URL:</span> {server.url}
</p>
)}
{server.env && Object.keys(server.env).length > 0 && (
<p>
<span className="text-muted-foreground/70">Env:</span>{" "}
{stringifyRedactedKeyValuePairs(server.env)}
</p>
)}
{server.headers && Object.keys(server.headers).length > 0 && (
<p>
<span className="text-muted-foreground/70">Headers:</span>{" "}
{stringifyKeyValuePairs(server.headers)}
</p>
)}
</div>
);
const renderServerCard = (server: McpServer) => (
<div
key={server.name}
className="rounded-lg border border-border px-5 py-4 transition-colors hover:bg-accent/20"
>
<div className="flex items-center gap-3">
<Circle
className={cn(
"h-2.5 w-2.5 shrink-0",
server.disabled
? "fill-muted-foreground/40 text-muted-foreground/40"
: "fill-primary text-primary",
)}
/>
<h3 className="text-sm font-semibold text-foreground">{server.name}</h3>
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
{TRANSPORT_TYPE_LABELS[server.transportType] ?? server.transportType}
</span>
<div className="flex-1" />
{renderServerActions(server)}
</div>
<div className="mt-2.5 ml-5.5">{renderServerDetails(server)}</div>
</div>
);
const installedItems = sortedServers.map(
(server): MarketplaceLocalInstalledItem => ({
key: server.name,
matchValues: [server.name],
render: () => renderServerCard(server),
renderMatchedBadges: () => (
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
{TRANSPORT_TYPE_LABELS[server.transportType] ?? server.transportType}
</span>
),
renderMatchedControls: () => renderServerActions(server),
renderMatchedDetails: () => renderServerDetails(server),
}),
);
return (
<PageFrame>
<PageHeader
@@ -458,112 +604,12 @@ export function McpServersContent() {
</div>
)}
{isLoading ? (
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
Loading MCP servers...
</div>
) : sortedServers.length === 0 ? (
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
No MCP servers configured.
</div>
) : (
<div className="flex flex-col gap-3">
{sortedServers.map((server) => {
const isBusy = busyServerName === server.name;
return (
<div
key={server.name}
className="rounded-lg border border-border px-5 py-4 transition-colors hover:bg-accent/20"
>
<div className="flex items-center gap-3">
<Circle
className={cn(
"h-2.5 w-2.5 shrink-0",
server.disabled
? "fill-muted-foreground/40 text-muted-foreground/40"
: "fill-primary text-primary",
)}
/>
<h3 className="text-sm font-semibold text-foreground">
{server.name}
</h3>
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
{server.transportType}
</span>
<div className="flex-1" />
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit ${server.name}`}
onClick={() => openEditDialog(server)}
disabled={isBusy}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Delete ${server.name}`}
onClick={() => setDeleteTarget(server)}
disabled={isBusy}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<Switch
checked={!server.disabled}
onCheckedChange={(enabled) =>
toggleServer(server, !enabled)
}
disabled={isBusy}
aria-label={`Enable ${server.name}`}
/>
</div>
</div>
<div className="mt-2.5 ml-5.5 flex flex-col gap-1 text-xs text-muted-foreground">
{server.command && (
<p>
<span className="text-muted-foreground/70">Command:</span>{" "}
{server.command}
</p>
)}
{server.args && server.args.length > 0 && (
<p>
<span className="text-muted-foreground/70">Args:</span>{" "}
{server.args.join(", ")}
</p>
)}
{server.cwd && (
<p>
<span className="text-muted-foreground/70">CWD:</span>{" "}
{server.cwd}
</p>
)}
{server.url && (
<p>
<span className="text-muted-foreground/70">URL:</span>{" "}
{server.url}
</p>
)}
{server.env && Object.keys(server.env).length > 0 && (
<p>
<span className="text-muted-foreground/70">Env:</span>{" "}
{stringifyRedactedKeyValuePairs(server.env)}
</p>
)}
{server.headers && Object.keys(server.headers).length > 0 && (
<p>
<span className="text-muted-foreground/70">Headers:</span>{" "}
{stringifyKeyValuePairs(server.headers)}
</p>
)}
</div>
</div>
);
})}
</div>
)}
<MarketplaceView
chrome="embedded"
installedItems={installedItems}
onInstalledItemsChanged={() => refreshServers()}
primitive="mcp"
/>
<Dialog
open={editorOpen}
onOpenChange={(open) => {
@@ -579,7 +625,9 @@ export function McpServersContent() {
{editorMode === "edit" ? "Edit MCP Server" : "Add MCP Server"}
</DialogTitle>
<DialogDescription>
Update the MCP server stored in{" "}
{editorMode === "edit"
? "Update the MCP server stored in "
: "The server is saved to "}
<code className="font-mono">
{settingsPath || "cline_mcp_settings.json"}
</code>
@@ -604,25 +652,60 @@ export function McpServersContent() {
</div>
<div className="grid gap-2">
<Label>Transport type</Label>
<Select
value={formState.transportType}
<Label>Server type</Label>
<RadioGroup
className="grid gap-2"
value={serverTypeOf(formState.transportType)}
onValueChange={(value) =>
setFormState((current) => ({
...current,
transportType: value as McpTransportType,
transportType:
value === "local"
? "stdio"
: serverTypeOf(current.transportType) === "remote"
? current.transportType
: "streamableHttp",
}))
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select transport" />
</SelectTrigger>
<SelectContent>
<SelectItem value="stdio">stdio</SelectItem>
<SelectItem value="sse">sse</SelectItem>
<SelectItem value="streamableHttp">streamableHttp</SelectItem>
</SelectContent>
</Select>
<Label
htmlFor="mcp-server-type-local"
className="flex cursor-pointer items-start gap-3 rounded-md border border-border px-3 py-2.5 font-normal has-[[data-state=checked]]:border-primary/60 has-[[data-state=checked]]:bg-accent/30"
>
<RadioGroupItem
className="mt-0.5"
id="mcp-server-type-local"
value="local"
/>
<span className="grid gap-0.5">
<span className="text-sm font-medium text-foreground">
Local
</span>
<span className="text-xs text-muted-foreground">
Runs a command on this machine (stdio). Recommended when
available.
</span>
</span>
</Label>
<Label
htmlFor="mcp-server-type-remote"
className="flex cursor-pointer items-start gap-3 rounded-md border border-border px-3 py-2.5 font-normal has-[[data-state=checked]]:border-primary/60 has-[[data-state=checked]]:bg-accent/30"
>
<RadioGroupItem
className="mt-0.5"
id="mcp-server-type-remote"
value="remote"
/>
<span className="grid gap-0.5">
<span className="text-sm font-medium text-foreground">
Remote
</span>
<span className="text-xs text-muted-foreground">
Connects to a hosted server over HTTP by URL.
</span>
</span>
</Label>
</RadioGroup>
</div>
{formState.transportType === "stdio" ? (
@@ -655,20 +738,6 @@ export function McpServersContent() {
placeholder="-y, @modelcontextprotocol/server-github"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="mcp-cwd">Working directory</Label>
<Input
id="mcp-cwd"
value={formState.cwd}
onChange={(event) =>
setFormState((current) => ({
...current,
cwd: event.target.value,
}))
}
placeholder="/path/to/project"
/>
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between gap-3">
<Label>Environment variables</Label>
@@ -747,23 +816,83 @@ export function McpServersContent() {
placeholder="Authorization=Bearer token"
/>
</div>
<div className="grid gap-2">
<Label>Transport</Label>
<Select
value={formState.transportType}
onValueChange={(value) =>
setFormState((current) => ({
...current,
transportType: value as McpTransportType,
}))
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select transport" />
</SelectTrigger>
<SelectContent>
<SelectItem value="streamableHttp">
Streamable HTTP (recommended)
</SelectItem>
<SelectItem value="sse">SSE (legacy)</SelectItem>
</SelectContent>
</Select>
</div>
</>
)}
<div className="grid gap-2">
<Label htmlFor="mcp-metadata">Metadata JSON</Label>
<Textarea
id="mcp-metadata"
value={formState.metadataText}
onChange={(event) =>
setFormState((current) => ({
...current,
metadataText: event.target.value,
}))
}
placeholder='{"key":"value"}'
/>
</div>
<Collapsible
className="grid gap-3"
onOpenChange={setAdvancedOpen}
open={advancedOpen}
>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-fit items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<ChevronRight
className={cn(
"h-3.5 w-3.5 transition-transform",
advancedOpen && "rotate-90",
)}
/>
Advanced
</button>
</CollapsibleTrigger>
<CollapsibleContent className="grid gap-4">
{formState.transportType === "stdio" && (
<div className="grid gap-2">
<Label htmlFor="mcp-cwd">Working directory</Label>
<Input
id="mcp-cwd"
value={formState.cwd}
onChange={(event) =>
setFormState((current) => ({
...current,
cwd: event.target.value,
}))
}
placeholder="/path/to/project"
/>
</div>
)}
<div className="grid gap-2">
<Label htmlFor="mcp-metadata">Metadata JSON</Label>
<Textarea
id="mcp-metadata"
value={formState.metadataText}
onChange={(event) =>
setFormState((current) => ({
...current,
metadataText: event.target.value,
}))
}
placeholder='{"key":"value"}'
/>
</div>
</CollapsibleContent>
</Collapsible>
<div className="flex items-center justify-between rounded-md border border-border px-3 py-2">
<div>
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,16 @@
import { useCallback, useEffect, useState } from "react";
import { RotateCcw } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
APP_ICONS,
type AppIconId,
appIconAssetPath,
readStoredAppIcon,
setStoredAppIcon,
} from "@/lib/app-icon";
import { desktopClient } from "@/lib/desktop-client";
import { resetOnboarding } from "@/lib/onboarding";
import type {
Provider,
ProviderCatalogResponse,
@@ -8,16 +18,20 @@ import type {
ProviderSettingsUpdate,
} from "@/lib/provider-schema";
import {
type HubAccent,
type HubTheme,
readStoredHubAccent,
readStoredHubTheme,
readSystemHubTheme,
setStoredHubAccent,
setStoredHubTheme,
} from "@/lib/theme";
import { cn } from "@/lib/utils";
import { PageFrame, PageHeader } from "../page-layout";
import { AccountView } from "./account-view";
import { AddProviderContent, type AddProviderPayload } from "./add-provider";
import { ChannelsContent } from "./channels-view";
import { CustomizationSectionView, RulesView } from "./extensions-view";
import { CustomizationSectionView } from "./extensions-view";
import { McpServersContent } from "./mcp-view";
import {
ProviderDetailContent,
@@ -33,15 +47,25 @@ import { toSettingsPatch } from "./settings-patch";
export const SETTINGS_SECTIONS = [
"General",
"Models",
"MCP Servers",
"MCP Marketplace",
"Customizations",
"Channels",
"Schedules",
"Account",
] as const;
export type SettingsSection = (typeof SETTINGS_SECTIONS)[number];
// Mirrors the Cline Hub dashboard's Customizations nav group.
export const CUSTOMIZATION_SECTIONS = [
"Plugins",
"Skills",
"MCP",
"Hooks",
"Rules",
"Agents",
"Tools",
] as const;
export type SettingsSection =
| (typeof SETTINGS_SECTIONS)[number]
| (typeof CUSTOMIZATION_SECTIONS)[number];
type GlobalSettingsResponse = {
telemetryOptOut: boolean;
autoUpdateEnabled: boolean;
@@ -61,9 +85,11 @@ let providerCatalogCache: {
export function SettingsView({
section,
onNavigateSection,
onOpenSession,
}: {
section: SettingsSection;
onNavigateSection: (section: SettingsSection) => void;
onOpenSession?: (sessionId: string) => void | Promise<void>;
}) {
const activeNav = section;
const [providers, setProviders] = useState<Provider[]>(
@@ -400,16 +426,24 @@ export function SettingsView({
const content =
activeNav === "Models" ? (
providerContent
) : activeNav === "MCP Servers" ? (
) : activeNav === "Plugins" ? (
<CustomizationSectionView catalogPrimitive="plugin" section="Plugins" />
) : activeNav === "Skills" ? (
<CustomizationSectionView catalogPrimitive="skill" section="Skills" />
) : activeNav === "MCP" ? (
<McpServersContent />
) : activeNav === "MCP Marketplace" ? (
<CustomizationSectionView catalogPrimitive="mcp" section="MCP" />
) : activeNav === "Customizations" ? (
<RulesView />
) : activeNav === "Hooks" ? (
<CustomizationSectionView section="Hooks" />
) : activeNav === "Rules" ? (
<CustomizationSectionView section="Rules" />
) : activeNav === "Agents" ? (
<CustomizationSectionView section="Agents" />
) : activeNav === "Tools" ? (
<CustomizationSectionView section="Tools" />
) : activeNav === "Channels" ? (
<ChannelsContent />
) : activeNav === "Schedules" ? (
<RoutineSchedulesContent />
<RoutineSchedulesContent onOpenSession={onOpenSession} />
) : activeNav === "Account" ? (
<AccountView />
) : activeNav === "General" ? (
@@ -430,11 +464,35 @@ export function SettingsView({
);
}
/**
* Swatches shown in the accent picker. The swatch color is the accent's
* light-mode primary (see the [data-cline-accent] blocks in globals.css);
* violet reads the live brand token so it always matches the default theme.
*/
const ACCENT_OPTIONS: { id: HubAccent; label: string; swatch: string }[] = [
{ id: "violet", label: "Violet", swatch: "var(--brand-violet)" },
{ id: "graphite", label: "Graphite", swatch: "oklch(0.27 0.012 248)" },
{ id: "cyan", label: "Cyan", swatch: "oklch(0.6 0.12 222)" },
{ id: "pink", label: "Pink", swatch: "oklch(0.75 0.1 354)" },
{ id: "espresso", label: "Espresso", swatch: "oklch(0.36 0.035 35)" },
{ id: "ember", label: "Ember", swatch: "oklch(0.6 0.19 33)" },
];
function GeneralSettingsContent() {
const [theme, setTheme] = useState<HubTheme>(() => {
if (typeof window === "undefined") return "light";
return readStoredHubTheme() ?? readSystemHubTheme();
});
const [accent, setAccent] = useState<HubAccent>(() => {
if (typeof window === "undefined") return "violet";
return readStoredHubAccent();
});
const [appIcon, setAppIcon] = useState<AppIconId>(() => {
if (typeof window === "undefined") return "classic";
return readStoredAppIcon();
});
const [appIconError, setAppIconError] = useState<string | null>(null);
const appIconRequestRef = useRef(0);
const [telemetryOptOut, setTelemetryOptOut] = useState(false);
const [telemetryLoading, setTelemetryLoading] = useState(true);
const [telemetrySaving, setTelemetrySaving] = useState(false);
@@ -517,6 +575,37 @@ function GeneralSettingsContent() {
setTheme(setStoredHubTheme(nextTheme));
};
const updateAccent = (nextAccent: HubAccent) => {
setAccent(setStoredHubAccent(nextAccent));
};
const updateAppIcon = async (nextIcon: AppIconId) => {
const requestId = ++appIconRequestRef.current;
const previousIcon = appIcon;
setAppIcon(nextIcon);
setAppIconError(null);
try {
await setStoredAppIcon(nextIcon);
} catch (error) {
// A newer selection supersedes this request; rolling back now
// would clobber it.
if (appIconRequestRef.current !== requestId) {
return;
}
setAppIcon(previousIcon);
setAppIconError(error instanceof Error ? error.message : String(error));
// Storage was written before the native call failed; roll it back
// so the persisted choice matches what the dock actually shows.
await setStoredAppIcon(previousIcon).catch(() => {});
}
};
// resetOnboarding dispatches ONBOARDING_RESET_EVENT, which the app shell
// listens for to re-enter the first-run flow immediately.
const replayOnboarding = () => {
resetOnboarding();
};
return (
<PageFrame>
<PageHeader
@@ -539,6 +628,84 @@ function GeneralSettingsContent() {
onCheckedChange={updateTheme}
/>
</div>
<div className="flex min-h-20 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
<div>
<p className="text-[17px] font-semibold text-foreground">
Accent color
</p>
<p className="mt-1 text-[15px] text-muted-foreground">
Tint buttons, links, and highlights across the app.
</p>
</div>
<div className="flex shrink-0 items-center gap-2.5">
{ACCENT_OPTIONS.map((option) => (
<button
aria-label={option.label}
aria-pressed={accent === option.id}
className={cn(
"size-7 rounded-full border border-foreground/10 transition-transform hover:scale-110",
accent === option.id &&
"ring-2 ring-ring ring-offset-2 ring-offset-background",
)}
key={option.id}
onClick={() => updateAccent(option.id)}
style={{ backgroundColor: option.swatch }}
title={option.label}
type="button"
/>
))}
</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>
<p className="text-[17px] font-semibold text-foreground">
App icon
</p>
<p className="mt-1 text-[15px] text-muted-foreground">
Pick the icon Cline shows in the Dock.
</p>
{appIconError ? (
<p className="mt-2 text-xs text-destructive" role="alert">
Failed to change app icon: {appIconError}
</p>
) : null}
</div>
<div className="flex shrink-0 items-start gap-4">
{APP_ICONS.map((icon) => (
<button
aria-label={icon.label}
aria-pressed={appIcon === icon.id}
className="group flex flex-col items-center gap-1.5"
key={icon.id}
onClick={() => void updateAppIcon(icon.id)}
type="button"
>
<img
alt=""
className={cn(
"size-14 rounded-2xl transition-transform group-hover:scale-105",
appIcon === icon.id &&
"ring-2 ring-ring ring-offset-2 ring-offset-background",
)}
draggable={false}
height={112}
src={appIconAssetPath(icon.id)}
width={112}
/>
<span
className={cn(
"text-xs",
appIcon === icon.id
? "font-medium text-foreground"
: "text-muted-foreground",
)}
>
{icon.label}
</span>
</button>
))}
</div>
</div>
<div className="flex min-h-20 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
<div>
<p className="text-[17px] font-semibold text-foreground">
@@ -581,6 +748,26 @@ function GeneralSettingsContent() {
onCheckedChange={(checked) => void updateTelemetryOptOut(!checked)}
/>
</div>
<div className="flex min-h-20 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
<div>
<p className="text-[17px] font-semibold text-foreground">
New user experience
</p>
<p className="mt-1 text-[15px] text-muted-foreground">
Replay the first-run experience new users see when they open Cline
for the first time.
</p>
</div>
<Button
className="shrink-0"
onClick={replayOnboarding}
type="button"
variant="outline"
>
<RotateCcw className="size-3.5" />
Replay
</Button>
</div>
</section>
</PageFrame>
);
@@ -0,0 +1,175 @@
// @vitest-environment jsdom
import type { ClineAccountUser } from "@cline/core";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
ACCOUNT_IDENTITY_STORAGE_KEY,
AccountProvider,
isSignedOutAccountError,
parseCachedAccountUser,
useAccount,
} from "./account-context";
const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() }));
vi.mock("@/lib/desktop-client", () => ({ desktopClient: { invoke } }));
function makeUser(overrides: Partial<ClineAccountUser> = {}): ClineAccountUser {
return {
id: "user-1",
email: "beatrix@cline.bot",
displayName: "Beatrix",
photoUrl: "",
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
organizations: [],
...overrides,
};
}
function Probe() {
const { user, activeOrganization } = useAccount();
return (
<div>
<span data-testid="account-name">{user?.displayName ?? "none"}</span>
<span data-testid="account-org">
{activeOrganization?.name ?? "none"}
</span>
</div>
);
}
let container: HTMLDivElement;
let root: Root;
function probeText(testId: string): string | null | undefined {
return container.querySelector(`[data-testid="${testId}"]`)?.textContent;
}
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
window.localStorage.clear();
invoke.mockReset();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.restoreAllMocks();
});
describe("account context", () => {
it("parses only cached payloads that look like an account user", () => {
expect(parseCachedAccountUser(null)).toBeNull();
expect(parseCachedAccountUser("not json")).toBeNull();
expect(parseCachedAccountUser(JSON.stringify({ user: 42 }))).toBeNull();
expect(
parseCachedAccountUser(JSON.stringify({ user: makeUser() }))?.displayName,
).toBe("Beatrix");
});
it("classifies signed-out errors separately from transient failures", () => {
expect(
isSignedOutAccountError(new Error("No Cline account auth token found")),
).toBe(true);
expect(
isSignedOutAccountError(
new Error(
'OAuth credentials for provider "cline" are no longer valid. Re-run authentication for this provider.',
),
),
).toBe(true);
expect(isSignedOutAccountError(new Error("fetch failed"))).toBe(false);
});
it("fetches the signed-in user on mount and caches the identity", async () => {
invoke.mockResolvedValue(
makeUser({
organizations: [
{
active: true,
memberId: "member-1",
name: "Cline Bot Inc",
organizationId: "org-1",
roles: ["admin"],
},
],
}),
);
await act(async () => {
root.render(
<AccountProvider>
<Probe />
</AccountProvider>,
);
});
await vi.waitFor(() => {
expect(probeText("account-name")).toBe("Beatrix");
expect(probeText("account-org")).toBe("Cline Bot Inc");
});
expect(invoke).toHaveBeenCalledWith("cline_account", {
action: "clineAccount",
operation: "fetchMe",
});
expect(
parseCachedAccountUser(
window.localStorage.getItem(ACCOUNT_IDENTITY_STORAGE_KEY),
)?.email,
).toBe("beatrix@cline.bot");
});
it("clears the cached identity when the account is signed out", async () => {
window.localStorage.setItem(
ACCOUNT_IDENTITY_STORAGE_KEY,
JSON.stringify({ user: makeUser() }),
);
invoke.mockRejectedValue(new Error("No Cline account auth token found"));
await act(async () => {
root.render(
<AccountProvider>
<Probe />
</AccountProvider>,
);
});
await vi.waitFor(() => {
expect(probeText("account-name")).toBe("none");
});
expect(
window.localStorage.getItem(ACCOUNT_IDENTITY_STORAGE_KEY),
).toBeNull();
});
it("keeps the cached identity when the refresh fails transiently", async () => {
window.localStorage.setItem(
ACCOUNT_IDENTITY_STORAGE_KEY,
JSON.stringify({ user: makeUser() }),
);
invoke.mockRejectedValue(
new Error("Desktop backend transport unavailable"),
);
await act(async () => {
root.render(
<AccountProvider>
<Probe />
</AccountProvider>,
);
});
await vi.waitFor(() => {
expect(invoke).toHaveBeenCalled();
});
expect(probeText("account-name")).toBe("Beatrix");
expect(
window.localStorage.getItem(ACCOUNT_IDENTITY_STORAGE_KEY),
).not.toBeNull();
});
});
@@ -0,0 +1,144 @@
"use client";
import type { ClineAccountOrganization, ClineAccountUser } from "@cline/core";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { desktopClient } from "@/lib/desktop-client";
export const ACCOUNT_IDENTITY_STORAGE_KEY = "cline.code.account-identity.v1";
const SIGNED_OUT_ERROR_MARKERS = [
"No Cline account auth token found",
"no longer valid",
];
type AccountContextValue = {
user: ClineAccountUser | null;
organizations: ClineAccountOrganization[];
activeOrganization: ClineAccountOrganization | null;
refreshAccount: () => Promise<void>;
};
const AccountContext = createContext<AccountContextValue>({
user: null,
organizations: [],
activeOrganization: null,
refreshAccount: async () => undefined,
});
export function parseCachedAccountUser(
raw: string | null,
): ClineAccountUser | null {
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw) as { user?: ClineAccountUser | null };
const user = parsed?.user;
if (!user || typeof user !== "object") {
return null;
}
if (
typeof user.email !== "string" &&
typeof user.displayName !== "string"
) {
return null;
}
return user;
} catch {
return null;
}
}
function readCachedAccountUser(): ClineAccountUser | null {
if (typeof window === "undefined") {
return null;
}
try {
return parseCachedAccountUser(
window.localStorage.getItem(ACCOUNT_IDENTITY_STORAGE_KEY),
);
} catch {
return null;
}
}
function writeCachedAccountUser(user: ClineAccountUser | null): void {
if (typeof window === "undefined") {
return;
}
try {
if (user) {
window.localStorage.setItem(
ACCOUNT_IDENTITY_STORAGE_KEY,
JSON.stringify({ user }),
);
} else {
window.localStorage.removeItem(ACCOUNT_IDENTITY_STORAGE_KEY);
}
} catch {
// Account identity still works for this session without the cache.
}
}
export function isSignedOutAccountError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return SIGNED_OUT_ERROR_MARKERS.some((marker) => message.includes(marker));
}
export function AccountProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<ClineAccountUser | null>(null);
const refreshAccount = useCallback(async () => {
try {
const me = await desktopClient.invoke<ClineAccountUser>("cline_account", {
action: "clineAccount",
operation: "fetchMe",
});
setUser(me ?? null);
writeCachedAccountUser(me ?? null);
} catch (error) {
if (isSignedOutAccountError(error)) {
setUser(null);
writeCachedAccountUser(null);
}
// Transient failures (offline, sidecar restarting) keep the cached
// identity rather than flashing a signed-out state.
}
}, []);
useEffect(() => {
// Seed from the cached identity after mount so the signed-in name renders
// without waiting on the network fetch, which revalidates it right after.
// localStorage must not be read during the initial render: the server
// renders the signed-out state, and a differing first client render would
// be a hydration mismatch.
setUser((current) => current ?? readCachedAccountUser());
void refreshAccount();
}, [refreshAccount]);
const value = useMemo<AccountContextValue>(() => {
const organizations = user?.organizations ?? [];
return {
user,
organizations,
activeOrganization:
organizations.find((organization) => organization.active) ?? null,
refreshAccount,
};
}, [refreshAccount, user]);
return (
<AccountContext.Provider value={value}>{children}</AccountContext.Provider>
);
}
export function useAccount(): AccountContextValue {
return useContext(AccountContext);
}

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