Compare commits

...
Author SHA1 Message Date
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
Saoud Rizwan f33ab3a872 chore(sdk): release v0.0.65 2026-07-18 20:46:28 -07:00
Saoud Rizwan 2ca8364ffc docs: add Kimi K3 to ClinePass model list and reference pricing (#12393) 2026-07-18 20:35:48 -07:00
Saoud Rizwan 2ef81be703 feat(llms): make Claude Code and Codex provider packages optional peers (#12379)
ai-sdk-provider-claude-code and ai-sdk-provider-codex-cli were hard
dependencies of @cline/llms, so every npm install of the cline CLI
pulled their native binaries (~250MB claude-agent-sdk platform binary,
~105MB @openai/codex) even for users who never select those providers.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Address shell resolution review feedback

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

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

* Apply terminal profile changes at the model-request boundary

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

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

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

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

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

* Harden shell profile path resolution edge cases

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

* ci(ui): add standalone npm publishing

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

* refactor(ui): simplify package validation

* refactor(ui): tighten package and release contracts

* docs(ui): remove duplicate install guidance

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

* shared

* max reasoning budgetTokens

* fix unit test

* fix: address review feedback on max output token handling

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

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

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

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

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

* refactor: extract isPositiveFiniteNumber into providers/utils.ts

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

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

* chore: remove unrelated VS Code changes

---------

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

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

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

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

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

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

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

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

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

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

* fix(vscode): ignore stale organization responses

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(vscode): cap detached command log replay
2026-07-15 22:46:37 -07:00
Saoud Rizwan 1ea34be611 chore(cli): release v3.0.42 2026-07-15 20:03:31 -07:00
226 changed files with 15681 additions and 2464 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.
+158
View File
@@ -0,0 +1,158 @@
---
name: publish-ui
description: Prepare, validate, and publish standalone @cline/ui npm releases. Use when bumping the UI package version, publishing latest or next through ui-publish.yml, checking UI release readiness, or completing the one-time npm trusted-publishing bootstrap.
---
# Publish UI
Release `@cline/ui` independently from the Cline SDK runtime packages.
## Release contract
- Version source: `sdk/packages/ui/package.json`.
- Workflow: `.github/workflows/ui-publish.yml`.
- The package keeps `internal: true` only to stay out of the SDK's shared
version/publish scripts. It is still a public npm package because
`private: false` and `publishConfig.access: public` control npm publication.
- `latest` is the production channel. `next` is an opt-in preview channel.
- Use prerelease versions such as `0.2.0-next.0` for `next`; do not publish a
version intended for `latest` under the preview tag because npm versions
cannot be republished.
- There is no UI Git tag, GitHub release, schedule, or Slack announcement.
- The workflow runs only by manual dispatch. Every release attempt runs the UI
quality checks before publishing and requires `confirm_publish=publish` from
`main`.
- The publish job and npm trust relationship use the protected `Publish`
environment.
- Every npm publication needs a new semver version; npm versions are immutable.
- Always ask before pushing commits, triggering the publish workflow, changing
npm trust settings, or running a local publish command.
## Normal release
1. Inspect the branch, current version, npm state, and UI changes.
```sh
git status --short --branch
node -p "require('./sdk/packages/ui/package.json').version"
npm view @cline/ui dist-tags versions --json
git log --oneline --no-merges -- \
sdk/packages/ui apps/examples/desktop-app/webview/components/views/chat \
.github/workflows/ui-publish.yml
```
2. Ask for the npm channel and version together. For `latest`, ask for patch,
minor, major, or an explicit version. For `next`, require an explicit
prerelease version such as `0.2.0-next.0`. Do not guess. Update only
`sdk/packages/ui/package.json` and its workspace version in `bun.lock`. Do
not run the SDK version command.
3. Validate the release candidate.
```sh
bun install --filter @cline/ui --filter @cline/code --frozen-lockfile
bun -F @cline/ui typecheck
bun -F @cline/ui test
bun -F @cline/ui test:package
bun -F @cline/ui build-storybook
bun -F @cline/code test:chat-ui
```
The packed-package test installs the tarball with Bun/React 19 and with
npm/Node/React 18.
Inspect `bun pm pack --dry-run` when the exported file set changed.
4. Commit the version bump separately from feature work. Ask before pushing.
```sh
git add sdk/packages/ui/package.json bun.lock
git commit -m "chore(ui): release vX.Y.Z"
git push origin HEAD
```
5. After the release commit reaches `main`, restate the selected npm tag and ask
for explicit publish approval. Then trigger and watch the standalone
workflow:
```sh
run_url=$(gh workflow run ui-publish.yml --ref main \
-f npm_tag=latest \
-f confirm_publish=publish)
test -n "$run_url"
run_id=${run_url##*/}
gh run watch "$run_id" --exit-status
```
Use `npm_tag=next` only for a deliberate preview. Do not report success until
the workflow succeeds and npm shows the exact version under the selected tag.
```sh
npm view @cline/ui dist-tags versions --json
```
## One-time npm bootstrap
Use this only while `npm view @cline/ui` returns `E404`. npm requires the
package to exist before its GitHub trusted publisher can be configured.
1. Merge the package and `ui-publish.yml` to `main`. Start from a clean,
reviewed `main` checkout. Verify authentication, account 2FA, and write
access to the `@cline` npm organization. The `npm trust` command in step 4
requires npm CLI 11.15 or newer; the automated trusted-publishing workflow
itself enforces npm 11.5.1 or newer.
```sh
npm --version
npm whoami
npm view @cline/ui version
```
If npm is older than 11.15, ask before upgrading with
`npm install -g npm@^11.15.0`.
2. Run the normal release validation in step 3 above. Then build, pack, test,
and inspect the exact initial tarball. Record the absolute archive path
printed by the final command.
```sh
bun -F @cline/ui build
pack_dir=$(mktemp -d)
(cd sdk/packages/ui && bun pm pack --ignore-scripts --destination "$pack_dir" --quiet)
tarball=$(find "$pack_dir" -maxdepth 1 -name '*.tgz' -print -quit)
test -n "$tarball"
bun sdk/packages/ui/scripts/smoke-package.ts "$tarball"
tar -tzf "$tarball"
printf 'Bootstrap archive: %s\n' "$tarball"
```
3. Ask for explicit approval, then publish the initial version publicly under
`latest`:
```sh
npm publish /absolute/path/from-step-2.tgz --access public --tag latest
```
4. Ask separately before configuring the standalone workflow as the trusted
publisher:
```sh
npm trust github @cline/ui \
--repo cline/cline \
--file ui-publish.yml \
--env Publish \
--allow-publish
```
5. Verify both package state and trust. Every later release uses the workflow;
do not add a long-lived npm token.
```sh
npm view @cline/ui dist-tags versions --json
npm trust list @cline/ui
```
## Final report
Report the version and npm tag, release commit, whether anything was pushed,
workflow URL or bootstrap result, npm verification, and tests/builds run. If
the package still returns `E404`, state that bootstrap remains required.
@@ -0,0 +1,4 @@
interface:
display_name: "Publish UI"
short_description: "Prepare and publish the Cline UI package"
default_prompt: "Use $publish-ui to prepare and publish a new @cline/ui npm release."
+3 -2
View File
@@ -8,8 +8,9 @@ HTTP-controlled debugger for the VSCode extension at `src/dev/debug-harness/serv
# Build extension first if needed (protos + esbuild):
bun run protos && IS_DEV=true bun esbuild.mjs
# Launch (skip-build if already built):
bun src/dev/debug-harness/server.ts --skip-build --auto-launch
# Launch (skip-build if already built). Run with node, NOT bun — Playwright's
# Electron launch times out under bun:
node src/dev/debug-harness/server.ts --skip-build --auto-launch
# In another terminal:
curl localhost:19229/api -d '{"method":"status"}'
+1
View File
@@ -16,6 +16,7 @@ This file is the secret sauce for working effectively in this codebase. It captu
- The whole repo (including `apps/vscode`) uses **bun** for package management and task running. Emit `bun run X` / `bun install` / `bunx <bin>` / `bun file.ts`, never npm/npx. Node remains the *runtime* (VS Code's extension host and the standalone cline-core are Node), so Node-runtime tokens are legitimate and must not be "fixed" to bun — see @.clinerules/bun-and-node.md for the keep-list vs rewrite-list.
- Avoid provider-specific string matching / hardcoded provider branches when fixing provider/config plumbing. Prefer provider metadata, shared catalog/defaults, explicit protocol/client capabilities, or centralized normalization utilities that apply by data shape rather than `providerId === "..."`. If a provider exception seems necessary, stop and explain why instead of adding ad-hoc string matching.
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `bun run compile`, not `bun run build`).
- When reading a configuration files that users may edit, use `readFileStrippingUtf8Bom`, `readFileSyncStrippingUtf8Bom`, or `stripUtf8Bom` from `@cline/shared/node`. DON'T strip byte order marks of user files handled by tools/passed to models.
- When creating PRs, contributors should not create changelog-entry files. Maintainers handle release versioning and changelog curation during the release process.
- When adding new feature flags, see this PR as a reference https://github.com/cline/cline/pull/7566
- Additional instructions about making requests: @.clinerules/network.md
+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) || '' }}"
+145
View File
@@ -0,0 +1,145 @@
name: ui-publish
on:
workflow_dispatch:
inputs:
npm_tag:
description: "npm distribution tag"
required: true
type: choice
options:
- next
- latest
default: next
confirm_publish:
description: 'Type "publish" to publish @cline/ui to npm'
required: true
type: string
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
quality:
name: UI quality and package checks
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24.x"
- name: Install dependencies
run: bun install --filter @cline/ui --filter @cline/code --frozen-lockfile
- name: Typecheck UI
run: bun -F @cline/ui typecheck
- name: Test UI
run: bun -F @cline/ui test
- name: Build Storybook
run: bun -F @cline/ui build-storybook
- name: Build UI package
run: bun -F @cline/ui build
- name: Test desktop chat integration
run: bun -F @cline/code test:chat-ui
- name: Pack publish artifact
id: pack
shell: bash
run: |
set -euo pipefail
pack_dir="$RUNNER_TEMP/ui-npm-pack"
mkdir -p "$pack_dir"
cd sdk/packages/ui
bun pm pack --ignore-scripts --destination "$pack_dir" --quiet
archive=$(find "$pack_dir" -maxdepth 1 -name '*.tgz' -print -quit)
test -n "$archive"
echo "archive=$archive" >> "$GITHUB_OUTPUT"
- name: Test packed package
env:
UI_PACKAGE_ARCHIVE: ${{ steps.pack.outputs.archive }}
run: bun sdk/packages/ui/scripts/smoke-package.ts "$UI_PACKAGE_ARCHIVE"
- name: Upload publish artifact
uses: actions/upload-artifact@v4
with:
name: ui-npm-package
path: ${{ runner.temp }}/ui-npm-pack/*.tgz
if-no-files-found: error
retention-days: 7
publish:
name: Publish @cline/ui
if: >-
github.event_name == 'workflow_dispatch' &&
github.repository == 'cline/cline' &&
github.ref == 'refs/heads/main' &&
inputs.confirm_publish == 'publish' &&
!endsWith(github.actor, '[bot]')
needs: quality
runs-on: ubuntu-latest
environment: Publish
permissions:
contents: read
id-token: write
steps:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24.x"
registry-url: "https://registry.npmjs.org"
- name: Download publish artifact
uses: actions/download-artifact@v4
with:
name: ui-npm-package
path: ${{ runner.temp }}/ui-npm-pack
- name: Verify publish tooling
shell: bash
run: |
set -euo pipefail
npm_version=$(npm --version)
echo "npm ${npm_version}"
node -e 'const [major, minor, patch] = process.argv[1].split(".").map(Number); if (major < 11 || (major === 11 && (minor < 5 || (minor === 5 && patch < 1)))) { console.error("npm 11.5.1 or newer is required for trusted publishing"); process.exit(1); }' "$npm_version"
- name: Publish package
shell: bash
env:
NPM_CONFIG_PROVENANCE: "true"
NPM_TAG: ${{ inputs.npm_tag }}
run: |
set -euo pipefail
archive=$(find "$RUNNER_TEMP/ui-npm-pack" -maxdepth 1 -name '*.tgz' -print -quit)
if [ -z "$archive" ]; then
echo "UI package archive was not downloaded"
exit 1
fi
version=$(tar -xOf "$archive" package/package.json | node -e 'let input=""; process.stdin.on("data", chunk => input += chunk); process.stdin.on("end", () => process.stdout.write(JSON.parse(input).version))')
if npm view "@cline/ui@${version}" version >/dev/null 2>&1; then
echo "@cline/ui@${version} already exists; bump sdk/packages/ui/package.json before publishing"
exit 1
fi
npm publish "$archive" --tag "$NPM_TAG" --access public
echo "Published @cline/ui@${version} with npm tag '${NPM_TAG}'"
+26
View File
@@ -1,5 +1,31 @@
# Cline CLI Changelog
## 3.0.46
- Fixed out-of-credits detection so the CLI reliably recognizes the Cline API's real `insufficient_credits` (402) error and shows the "add credits" card instead of a generic error
## 3.0.45
- Smaller install: the Claude Code and Codex providers are now optional and loaded on demand, cutting `npm i -g cline` from ~640MB to ~285MB (from SDK v0.0.65)
- Kimi K3 is now available as a ClinePass model (from SDK v0.0.65)
- Runs now retry once after refreshing expired OAuth credentials (from SDK v0.0.65)
- Team runs: the spawn tool is no longer exposed to teammates, and errored teammate runs now report as failed instead of completed (from SDK v0.0.65)
- Hub status output now includes version numbers
- Updated the bundled model catalog (from SDK v0.0.65)
## 3.0.44
- Improved max output token handling across providers (gateway routing, OpenAI vendor, and reasoning models) (from SDK v0.0.64)
- Frontmatter and configuration files that start with a UTF-8 byte order mark (e.g. saved by Windows editors) now parse correctly (from SDK v0.0.64)
## 3.0.43
- The CLI now automatically trusts your operating system's certificate store, so it works behind corporate proxies and TLS-inspecting firewalls without manually setting `NODE_EXTRA_CA_CERTS` (fixes "unable to get local issuer certificate" errors, including Windows intermediate CA stores)
## 3.0.42
- Fixed Ollama native API routing so context window and timeout settings work again
## 3.0.41
- Compaction now shows progress status in the TUI
+15
View File
@@ -346,9 +346,24 @@ Desktop-integrated approval mode is also supported via env wiring (`CLINE_TOOL_A
- `CLINE_LOG_LEVEL` - Runtime log level (`trace|debug|info|warn|error|fatal|silent`, default `info`)
- `CLINE_LOG_PATH` - Runtime log file path (default `<CLINE_DATA_DIR>/logs/cline.log`)
- `CLINE_LOG_NAME` - Logger name embedded in runtime log records
- `CLINE_DEBUG` - Set to `1`/`true` to print wrapper diagnostics (e.g. the CA bundle summary)
`--key` takes precedence over environment variables.
## Certificate trust
The CLI automatically trusts your operating system's certificate store, so it
works behind corporate TLS-inspecting proxies and with self-signed/internal
endpoints without any setup. On launch the `cline` wrapper harvests the OS trust
anchors and writes them to `~/.cline/cli-node-extra-ca-certs.pem`, then points
the runtime's `NODE_EXTRA_CA_CERTS` at that bundle. The file is regenerated when
it changes and is safe to delete (it is rebuilt on the next run).
If you set `NODE_EXTRA_CA_CERTS` yourself, your certificates are **merged** into
that bundle alongside the system store rather than replacing it. Run with
`CLINE_DEBUG=1` to see how many OS and user CAs were loaded and where the bundle
was written.
## Contributing
See [DEVELOPMENT.md](./DEVELOPMENT.md) for local development setup, monorepo structure, and TUI architecture. See [DISTRIBUTION.md](./DISTRIBUTION.md) for how the CLI is packaged and distributed.
+281
View File
@@ -0,0 +1,281 @@
// Auto-discovery of OS trust anchors for the Cline CLI.
//
// Bun does not read the OS trust store, so the 3.x CLI cannot see corporate
// MITM / self-signed CAs out of the box. This runs in the Node `bin/cline`
// wrapper (not Bun), reads the full OS store via tls.getCACertificates("system")
// (Node >= 22, no --use-system-ca flag), and hands the certs to the Bun child
// via NODE_EXTRA_CA_CERTS, which both runtimes honor. Mirrors the JetBrains
// plugin's configureCertificates(), sourcing from the OS instead of the IDE.
//
// Dependency-free CommonJS with injectable modules so it is unit-testable and
// ships verbatim in the published wrapper package.
const PEM_MARKER = "-----BEGIN CERTIFICATE-----";
const CERT_BLOCK =
/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g;
/**
* Returns only the complete certificate blocks from PEM text, or null when
* there are none. User files may also hold private keys (combined cert+key
* PEMs) or other sections, which must never be copied into the managed
* bundle. Files that contain nothing but certificates pass through verbatim
* so unchanged bundles keep hash-skipping the rewrite.
*/
function sanitizePem(text) {
const blocks = text.match(CERT_BLOCK) ?? [];
if (blocks.length === 0) {
return null;
}
const rest = text.replace(CERT_BLOCK, "");
if (/^\s*$/.test(rest)) {
return text;
}
return `${blocks.join("\n")}\n`;
}
/**
* Returns OS-trusted certificates as PEM strings, or [] when unavailable.
* tls.getCACertificates("system") requires Node >= 22.
*/
function harvestSystemCerts(tlsModule) {
try {
const tls = tlsModule || require("node:tls");
if (typeof tls.getCACertificates !== "function") {
return [];
}
const certs = tls.getCACertificates("system");
if (!Array.isArray(certs)) {
return [];
}
return certs.filter(
(cert) => typeof cert === "string" && cert.includes(PEM_MARKER),
);
} catch {
return [];
}
}
/**
* Returns the file's certificate blocks as PEM text, or null when missing,
* unreadable, or holding no complete certificate block.
*/
function readUserBundle(fsModule, userPath) {
if (!userPath) {
return null;
}
try {
const fs = fsModule || require("node:fs");
const stat = fs.statSync(userPath, { throwIfNoEntry: false });
if (!stat || !stat.isFile()) {
return null;
}
// Binary DER would not have loaded in the runtime either; require PEM.
return sanitizePem(fs.readFileSync(userPath, "utf8"));
} catch {
return null;
}
}
/**
* Reads the user's NODE_EXTRA_CA_CERTS value into PEM strings. Node treats the
* value as a single file, but some users set an OS-path-delimited list; the
* whole value is tried as one file first, then split.
* The managed bundle is excluded so reading it back never re-appends its certs.
*/
function readUserCerts(fsModule, pathModule, value, managedPath) {
if (!value) {
return [];
}
const fs = fsModule || require("node:fs");
const path = pathModule || require("node:path");
const candidates = [];
const whole = readUserBundle(fs, value);
if (whole) {
candidates.push({ filePath: value, pem: whole });
} else if (value.includes(path.delimiter)) {
for (const segment of value.split(path.delimiter)) {
const trimmed = segment.trim();
if (!trimmed) {
continue;
}
const pem = readUserBundle(fs, trimmed);
if (pem) {
candidates.push({ filePath: trimmed, pem });
}
}
}
const pems = [];
for (const candidate of candidates) {
const isManaged =
managedPath &&
path.resolve(candidate.filePath) === path.resolve(managedPath);
if (!isManaged) {
pems.push(candidate.pem);
}
}
return pems;
}
/**
* Concatenates the user PEMs (if any) and the system certificates into one
* bundle. A separating newline is inserted between parts so adjacent END/BEGIN
* markers cannot fuse into one invalid line.
*/
function buildBundle({ systemCerts, userPems }) {
const parts = [...(userPems ?? []), ...systemCerts];
return parts
.map((part) => (part.endsWith("\n") ? part : `${part}\n`))
.join("");
}
/** Counts individual PEM certificates across the given bundle strings. */
function countCerts(pems) {
let count = 0;
for (const pem of pems) {
count += pem.split(PEM_MARKER).length - 1;
}
return count;
}
function readFileIfExists(fs, filePath) {
try {
return fs.readFileSync(filePath, "utf8");
} catch {
return null;
}
}
function resolveClineDir(env, os, path) {
return env.CLINE_DIR?.trim() || path.join(os.homedir(), ".cline");
}
/**
* True when the api-unavailable warning should print. Stamped per Node version
* in the cline dir so the nudge shows once rather than on every command; a
* version change (upgrade that still falls short, or downgrade) re-arms it.
* When the stamp cannot be read or written, warn — bookkeeping failures must
* never suppress a real diagnostic.
*/
function shouldWarnApiUnavailable(env, deps = {}) {
const fs = deps.fs || require("node:fs");
const os = deps.os || require("node:os");
const path = deps.path || require("node:path");
const version = deps.nodeVersion || process.versions.node;
const dir = resolveClineDir(env, os, path);
const stamp = path.join(dir, `.ca-api-warned-${version}`);
try {
if (fs.existsSync(stamp)) {
return false;
}
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(stamp, "", { mode: 0o600 });
return true;
} catch {
return true;
}
}
/** Atomically writes [content] to [target]; returns true on success. */
function writeBundle(fs, dir, target, content) {
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
try {
fs.mkdirSync(dir, { recursive: true });
// Owner read/write: the bundle holds public CA material, not secrets,
// but there is no reason to make it world-writable.
fs.writeFileSync(tmp, content, { mode: 0o600 });
try {
fs.renameSync(tmp, target);
} catch {
// Windows can reject rename over a file a concurrent child holds open.
fs.rmSync(target, { force: true });
fs.renameSync(tmp, target);
}
return true;
} catch {
// Never leave a partial temp file behind (e.g. ENOSPC mid-write).
try {
fs.rmSync(tmp, { force: true });
} catch {
// Ignore: best-effort cleanup.
}
return false;
}
}
/**
* Harvests OS trust anchors, merges them with any user NODE_EXTRA_CA_CERTS, and
* points env.NODE_EXTRA_CA_CERTS at a single managed PEM bundle. Mutates `env`
* in place. Returns an outcome the caller can log; `action` is one of
* "unchanged" | "written" | "write-failed-reused" | "write-failed" |
* "no-system-certs" | "api-unavailable".
*/
function configureNodeExtraCaCerts(env, deps = {}) {
const fs = deps.fs || require("node:fs");
const os = deps.os || require("node:os");
const path = deps.path || require("node:path");
const tls = deps.tls || require("node:tls");
// tls.getCACertificates("system") needs Node >= 22.15; on older Nodes the
// harvest cannot run at all, which the caller should surface to the user.
if (typeof tls.getCACertificates !== "function") {
return {
action: "api-unavailable",
path: null,
systemCertCount: 0,
userCertCount: 0,
};
}
const systemCerts = harvestSystemCerts(tls);
if (systemCerts.length === 0) {
// Nothing to add: leave any user-provided NODE_EXTRA_CA_CERTS untouched
// and let the runtime fall back to its bundled CAs.
return {
action: "no-system-certs",
path: null,
systemCertCount: 0,
userCertCount: 0,
};
}
const managedDir = resolveClineDir(env, os, path);
const managedPath = path.join(managedDir, "cli-node-extra-ca-certs.pem");
const userValue = (env.NODE_EXTRA_CA_CERTS || "").trim() || null;
const userPems = readUserCerts(fs, path, userValue, managedPath);
const bundle = buildBundle({ systemCerts, userPems });
const base = {
path: managedPath,
systemCertCount: systemCerts.length,
userCertCount: countCerts(userPems),
};
// Skip the rewrite when the bundle is already current. Avoids per-launch I/O
// and the concurrent-rename race in the steady state.
if (readFileIfExists(fs, managedPath) === bundle) {
env.NODE_EXTRA_CA_CERTS = managedPath;
return { ...base, action: "unchanged" };
}
if (writeBundle(fs, managedDir, managedPath, bundle)) {
env.NODE_EXTRA_CA_CERTS = managedPath;
return { ...base, action: "written" };
}
// Write failed: fall back to a previously-written bundle if one exists.
if (readFileIfExists(fs, managedPath)) {
env.NODE_EXTRA_CA_CERTS = managedPath;
return { ...base, action: "write-failed-reused" };
}
return { ...base, path: null, action: "write-failed" };
}
module.exports = {
harvestSystemCerts,
sanitizePem,
readUserBundle,
readUserCerts,
buildBundle,
countCerts,
configureNodeExtraCaCerts,
shouldWarnApiUnavailable,
};
+42
View File
@@ -23,6 +23,48 @@ const childEnv = {
CLINE_WRAPPER_PATH: scriptPath,
};
// Auto-discover OS trust anchors and pass them to the Bun child via
// NODE_EXTRA_CA_CERTS. The Bun runtime does not read the OS store on its own,
// so corporate/self-signed CAs would otherwise fail. This wrapper runs on
// Node, which can read the full store here.
try {
const caCerts = require("./ca-certs.cjs");
const outcome = caCerts.configureNodeExtraCaCerts(childEnv);
const debug =
process.env.CLINE_DEBUG === "1" || process.env.CLINE_DEBUG === "true";
// Not debug-gated: on old Nodes the harvest silently doing nothing is
// indistinguishable from a broken corporate proxy. Stamped per Node
// version so the nudge shows once, not on every command.
if (
outcome &&
outcome.action === "api-unavailable" &&
!childEnv.NODE_EXTRA_CA_CERTS &&
caCerts.shouldWarnApiUnavailable(childEnv)
) {
console.warn(
`[cline] Node ${process.versions.node} cannot read the OS trust store (needs >= 22.15); ` +
"corporate or self-signed CAs may fail TLS. Upgrade Node or set NODE_EXTRA_CA_CERTS.",
);
}
if (debug && outcome) {
if (outcome.action === "no-system-certs") {
console.warn(
"[cline] No OS trust anchors found; relying on the runtime's bundled CAs.",
);
} else if (outcome.action === "write-failed") {
console.warn(
"[cline] Could not write the managed CA bundle; relying on the runtime's bundled CAs.",
);
} else {
console.warn(
`[cline] Trust: ${outcome.systemCertCount} OS + ${outcome.userCertCount} user CAs (${outcome.action}) -> ${outcome.path}`,
);
}
}
} catch {
// Best effort: fall back to the runtime's default trust on any failure.
}
function run(target) {
const result = childProcess.spawnSync(target, process.argv.slice(2), {
stdio: "inherit",
+5 -5
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.41",
"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"
+367
View File
@@ -0,0 +1,367 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
// The helper ships as CommonJS in the published wrapper package, so it is
// loaded via require rather than an ESM import.
const caCerts = require("../../bin/ca-certs.cjs") as {
harvestSystemCerts: (tls?: unknown) => string[];
readUserBundle: (fs: unknown, p: string | null) => string | null;
readUserCerts: (
fs: unknown,
path: unknown,
value: string | null,
managedPath: string | null,
) => string[];
buildBundle: (input: {
systemCerts: string[];
userPems?: string[];
}) => string;
countCerts: (pems: string[]) => number;
configureNodeExtraCaCerts: (
env: Record<string, string>,
deps?: { tls?: unknown; fs?: unknown },
) => {
action: string;
path: string | null;
systemCertCount: number;
userCertCount: number;
};
shouldWarnApiUnavailable: (
env: Record<string, string>,
deps?: { fs?: unknown; nodeVersion?: string },
) => boolean;
};
const fs = require("node:fs");
const path = require("node:path");
const certSystem =
"-----BEGIN CERTIFICATE-----\nSYSTEM\n-----END CERTIFICATE-----\n";
const certUser = "-----BEGIN CERTIFICATE-----\nUSER\n-----END CERTIFICATE-----";
function fakeTls(certs: unknown) {
return { getCACertificates: () => certs };
}
describe("ca-certs", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "cline-ca-"));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
describe("harvestSystemCerts", () => {
it("returns only PEM strings from the system store", () => {
expect(
caCerts.harvestSystemCerts(fakeTls([certSystem, "not-a-cert", 42])),
).toEqual([certSystem]);
});
it("returns [] when getCACertificates is unavailable", () => {
expect(caCerts.harvestSystemCerts({})).toEqual([]);
});
it("returns [] when getCACertificates throws", () => {
expect(
caCerts.harvestSystemCerts({
getCACertificates: () => {
throw new Error("nope");
},
}),
).toEqual([]);
});
});
describe("readUserBundle", () => {
it("returns PEM contents for a PEM file", () => {
const p = join(dir, "user.pem");
writeFileSync(p, certUser);
expect(caCerts.readUserBundle(fs, p)).toBe(certUser);
});
it("returns null for a non-PEM (DER) file", () => {
const p = join(dir, "user.der");
writeFileSync(p, Buffer.from([0x30, 0x82, 0x01, 0x02]));
expect(caCerts.readUserBundle(fs, p)).toBeNull();
});
it("returns null for a missing file and for null path", () => {
expect(caCerts.readUserBundle(fs, join(dir, "nope.pem"))).toBeNull();
expect(caCerts.readUserBundle(fs, null)).toBeNull();
});
it("strips non-certificate sections such as private keys", () => {
// Combined cert+key files (nginx/haproxy style) are common; the key
// must never reach the managed bundle.
const p = join(dir, "combined.pem");
writeFileSync(
p,
`${certUser}\n-----BEGIN PRIVATE KEY-----\nSECRET\n-----END PRIVATE KEY-----\n`,
);
const out = caCerts.readUserBundle(fs, p);
expect(out).toContain("USER");
expect(out).not.toContain("PRIVATE KEY");
expect(out).not.toContain("SECRET");
});
it("keeps certificates-only files verbatim", () => {
// Byte-identical passthrough keeps the unchanged-skip hash stable.
const p = join(dir, "clean.pem");
writeFileSync(p, `${certUser}\n${certSystem}`);
expect(caCerts.readUserBundle(fs, p)).toBe(`${certUser}\n${certSystem}`);
});
it("returns null for a BEGIN marker without a complete block", () => {
const p = join(dir, "truncated.pem");
writeFileSync(p, "-----BEGIN CERTIFICATE-----\ntruncated");
expect(caCerts.readUserBundle(fs, p)).toBeNull();
});
});
describe("readUserCerts", () => {
it("reads a single PEM file path", () => {
const p = join(dir, "corp.pem");
writeFileSync(p, certUser);
expect(caCerts.readUserCerts(fs, path, p, null)).toEqual([certUser]);
});
it("splits a legacy OS-path-delimited value and reads each PEM", () => {
// Legacy footgun: NODE_EXTRA_CA_CERTS="a.pem;b.pem".
const a = join(dir, "a.pem");
const b = join(dir, "b.pem");
writeFileSync(a, certUser);
writeFileSync(b, certSystem);
expect(
caCerts.readUserCerts(fs, path, [a, b].join(delimiter), null),
).toEqual([certUser, certSystem]);
});
it("skips missing segments in a delimited value", () => {
const a = join(dir, "a.pem");
writeFileSync(a, certUser);
const value = [a, join(dir, "missing.pem")].join(delimiter);
expect(caCerts.readUserCerts(fs, path, value, null)).toEqual([certUser]);
});
it("excludes the managed bundle from user certs", () => {
const managed = join(dir, "cli-node-extra-ca-certs.pem");
writeFileSync(managed, certUser);
expect(caCerts.readUserCerts(fs, path, managed, managed)).toEqual([]);
});
it("returns [] for empty value", () => {
expect(caCerts.readUserCerts(fs, path, null, null)).toEqual([]);
});
});
describe("buildBundle", () => {
it("merges user PEMs before system certs", () => {
expect(
caCerts.buildBundle({
systemCerts: [certSystem],
userPems: [certUser],
}),
).toBe(`${certUser}\n${certSystem}`);
});
it("inserts a separating newline so END/BEGIN markers do not fuse", () => {
// certUser has no trailing newline, so this proves the boundary fix.
const merged = caCerts.buildBundle({
systemCerts: [certSystem],
userPems: [certUser],
});
expect(merged).not.toContain(
"-----END CERTIFICATE----------BEGIN CERTIFICATE-----",
);
});
it("handles no user PEMs", () => {
expect(caCerts.buildBundle({ systemCerts: [certSystem] })).toBe(
certSystem,
);
});
});
describe("configureNodeExtraCaCerts", () => {
it("writes a managed bundle and points the env var at it", () => {
const env: Record<string, string> = { CLINE_DIR: dir };
const out = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
});
expect(out.action).toBe("written");
expect(out.path).toBe(join(dir, "cli-node-extra-ca-certs.pem"));
expect(env.NODE_EXTRA_CA_CERTS).toBe(out.path);
expect(readFileSync(out.path as string, "utf8")).toContain("SYSTEM");
});
it("merges a user-supplied NODE_EXTRA_CA_CERTS with system certs", () => {
const userPath = join(dir, "corp.pem");
writeFileSync(userPath, certUser);
const env: Record<string, string> = {
CLINE_DIR: dir,
NODE_EXTRA_CA_CERTS: userPath,
};
const out = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
});
expect(out.userCertCount).toBe(1);
const written = readFileSync(env.NODE_EXTRA_CA_CERTS, "utf8");
expect(written).toContain("USER");
expect(written).toContain("SYSTEM");
});
it("reports unchanged and skips rewrite on the second run", () => {
const env: Record<string, string> = { CLINE_DIR: dir };
expect(
caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([certSystem]) })
.action,
).toBe("written");
expect(
caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([certSystem]) })
.action,
).toBe("unchanged");
});
it("does not re-append when the user already points at the managed bundle", () => {
const env: Record<string, string> = { CLINE_DIR: dir };
const first = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
}).path as string;
const env2: Record<string, string> = {
CLINE_DIR: dir,
NODE_EXTRA_CA_CERTS: first,
};
caCerts.configureNodeExtraCaCerts(env2, { tls: fakeTls([certSystem]) });
const written = readFileSync(env2.NODE_EXTRA_CA_CERTS, "utf8");
expect(written.match(/SYSTEM/g)?.length).toBe(1);
});
it("no-ops when no system certs are available", () => {
const env: Record<string, string> = {
CLINE_DIR: dir,
NODE_EXTRA_CA_CERTS: "/user/corp.pem",
};
const out = caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([]) });
expect(out.action).toBe("no-system-certs");
expect(out.path).toBeNull();
expect(env.NODE_EXTRA_CA_CERTS).toBe("/user/corp.pem");
});
it("reports api-unavailable on Nodes without getCACertificates", () => {
const env: Record<string, string> = {
CLINE_DIR: dir,
NODE_EXTRA_CA_CERTS: "/user/corp.pem",
};
const out = caCerts.configureNodeExtraCaCerts(env, { tls: {} });
expect(out.action).toBe("api-unavailable");
expect(out.path).toBeNull();
expect(env.NODE_EXTRA_CA_CERTS).toBe("/user/corp.pem");
});
it("reports write-failed when the bundle cannot be written", () => {
const realFs = require("node:fs");
const failingFs = {
...realFs,
mkdirSync: () => {
throw new Error("EACCES");
},
writeFileSync: () => {
throw new Error("EACCES");
},
};
const env: Record<string, string> = { CLINE_DIR: dir };
const out = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
fs: failingFs,
});
expect(out.action).toBe("write-failed");
expect(out.path).toBeNull();
expect(env.NODE_EXTRA_CA_CERTS).toBeUndefined();
});
it("reuses a stale bundle when the rewrite fails", () => {
// First run writes the bundle normally.
const env: Record<string, string> = { CLINE_DIR: dir };
const managedPath = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
}).path as string;
// Second run: writes fail, but the stale bundle is still readable.
const realFs = require("node:fs");
const failingFs = {
...realFs,
mkdirSync: () => {
throw new Error("EACCES");
},
writeFileSync: () => {
throw new Error("EACCES");
},
};
const env2: Record<string, string> = { CLINE_DIR: dir };
const out = caCerts.configureNodeExtraCaCerts(env2, {
// A different system cert forces a rewrite attempt (not "unchanged").
tls: fakeTls([certUser]),
fs: failingFs,
});
expect(out.action).toBe("write-failed-reused");
expect(env2.NODE_EXTRA_CA_CERTS).toBe(managedPath);
});
});
describe("countCerts", () => {
it("counts individual certificates, not files", () => {
// One file holding two certs must report 2, not 1.
const twoInOne = `${certUser}\n${certSystem}`;
expect(caCerts.countCerts([twoInOne])).toBe(2);
expect(caCerts.countCerts([certUser, certSystem])).toBe(2);
expect(caCerts.countCerts([])).toBe(0);
});
});
describe("shouldWarnApiUnavailable", () => {
it("warns once per Node version, then stays quiet", () => {
const env = { CLINE_DIR: dir };
const deps = { nodeVersion: "22.1.0" };
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(false);
});
it("re-arms when the Node version changes", () => {
const env = { CLINE_DIR: dir };
expect(
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.1.0" }),
).toBe(true);
expect(
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.14.0" }),
).toBe(true);
expect(
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.1.0" }),
).toBe(false);
});
it("still warns when the stamp cannot be written", () => {
const realFs = require("node:fs");
const failingFs = {
...realFs,
mkdirSync: () => {
throw new Error("EACCES");
},
writeFileSync: () => {
throw new Error("EACCES");
},
};
const env = { CLINE_DIR: dir };
const deps = { fs: failingFs, nodeVersion: "22.1.0" };
// Bookkeeping failure must never suppress the diagnostic.
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
});
});
});
+3 -2
View File
@@ -1,4 +1,4 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { existsSync, readdirSync } from "node:fs";
import { homedir } from "node:os";
import { basename, extname, join } from "node:path";
import {
@@ -15,6 +15,7 @@ import {
type SkillConfig,
type WorkflowConfig,
} from "@cline/core";
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
import { Command } from "commander";
import { getToolCatalog } from "../runtime/tools";
import { loadInteractiveConfigData } from "../tui/interactive-config";
@@ -209,7 +210,7 @@ async function runAgentsConfigCommand(
continue;
}
const filePath = join(directory, entry.name);
const raw = readFileSync(filePath, "utf8");
const raw = readFileSyncStrippingUtf8Bom(filePath);
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const frontmatter = frontmatterMatch?.[1] ?? "";
const nameMatch = frontmatter.match(/^\s*name:\s*(.+?)\s*$/m);
+35
View File
@@ -9,6 +9,7 @@ import {
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { version as cliVersion } from "../../package.json";
import { getCliBuildInfo } from "../utils/common";
const {
@@ -174,6 +175,40 @@ describe("runDoctorCommand", () => {
);
});
it("reports CLI and running hub Core versions", async () => {
const cwd = "/workspace";
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
port: 25463,
pid: 50174,
coreVersion: "0.0.63",
});
mockProbeHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
port: 25463,
pid: 50174,
coreVersion: "0.0.64",
});
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
const output: string[] = [];
const code = await runDoctorCommand(
{ cwd, json: true },
{
writeln: (text) => {
output.push(text ?? "");
},
writeErr: () => {},
},
);
expect(code).toBe(0);
expect(JSON.parse(output[0] || "")).toMatchObject({
cliVersion,
coreVersion: "0.0.64",
});
});
it("doctor --fix clears wedged hub startup artifacts when no server is actually running", async () => {
const cwd = mkdtempSync(path.join(os.tmpdir(), "doctor-hub-fix-"));
tempDirs.push(cwd);
+7
View File
@@ -14,6 +14,7 @@ import {
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
import { Command } from "commander";
import open from "open";
import { version as cliVersion } from "../../package.json";
import { isProcessRunning } from "../connectors/common";
import {
type ActiveConnectorRecord,
@@ -49,6 +50,8 @@ type SpawnedProcessRecord = {
type DoctorStatus = {
cwd: string;
cliVersion: string;
coreVersion?: string;
hubUrl?: string;
hubHealthy: boolean;
hubPid?: number;
@@ -337,6 +340,8 @@ async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
];
return {
cwd,
cliVersion,
coreVersion: health?.coreVersion ?? discovery?.coreVersion,
hubUrl: current?.url,
hubHealthy: !!health?.url,
hubPid: current?.pid,
@@ -419,6 +424,8 @@ export async function runDoctorCommand(
io.writeln(JSON.stringify(before));
return 0;
}
writeln(`cli version ${c.dim}${before.cliVersion}${c.reset}`);
writeln(`core version ${c.dim}${before.coreVersion ?? "n/a"}${c.reset}`);
writeln(`hub url ${c.dim}${before.hubUrl ?? "none"}${c.reset}`);
writeln(
`hub healthy ${c.dim}${before.hubHealthy ? "yes" : "no"}${before.hubPid ? ` (pid=${before.hubPid})` : ""}${c.reset}`,
+4
View File
@@ -34,6 +34,7 @@ vi.mock("@cline/core", () => ({
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
}));
import { version as cliVersion } from "../../package.json";
import { createHubCommand } from "./hub";
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
@@ -63,6 +64,7 @@ describe("createHubCommand", () => {
port: 25463,
pid: 50174,
startedAt: "2026-01-01T00:00:00.000Z",
coreVersion: "0.0.62",
});
const output: string[] = [];
@@ -88,6 +90,8 @@ describe("createHubCommand", () => {
pid: 50174,
startedAt: "2026-01-01T00:00:00.000Z",
uptime: "1m 5s",
cliVersion,
coreVersion: "0.0.62",
});
});
+3
View File
@@ -9,6 +9,7 @@ import {
} from "@cline/core";
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
import { Command } from "commander";
import { version as cliVersion } from "../../package.json";
interface HubCommandIo {
writeln: (text?: string) => void;
@@ -134,6 +135,8 @@ export function createHubCommand(
pid: health?.pid,
startedAt: health?.startedAt,
uptime,
cliVersion,
coreVersion: health?.coreVersion ?? discovery?.coreVersion,
}),
);
}),
@@ -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(
+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"))
);
}
+2 -1
View File
@@ -27,6 +27,7 @@ import {
type UserInstructionConfigService,
type WorkflowConfig,
} from "@cline/core";
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
import { getToolCatalog } from "../runtime/tools";
import {
type InteractiveSlashCommand,
@@ -195,7 +196,7 @@ function loadAgentConfigItems(workspaceRoot: string): InteractiveConfigItem[] {
continue;
}
const filePath = join(directory, entry.name);
const raw = readFileSync(filePath, "utf8");
const raw = readFileSyncStrippingUtf8Bom(filePath);
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const frontmatter = frontmatterMatch?.[1] ?? "";
const nameMatch = frontmatter.match(/^\s*name:\s*(.+?)\s*$/m);
@@ -18,6 +18,7 @@ import {
resolvePluginConfigSearchPaths,
resolveAgentConfigSearchPaths as resolveSharedAgentConfigSearchPaths,
} from "@cline/core";
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
import { readMcpServersResponse } from "./mcp";
import type { JsonRecord } from "./types";
@@ -114,7 +115,7 @@ export async function listUserInstructionConfigs(
const ext = extname(entry.name).toLowerCase();
if (ext !== ".yml" && ext !== ".yaml") continue;
const filePath = join(directory, entry.name);
const raw = readFileSync(filePath, "utf8");
const raw = readFileSyncStrippingUtf8Bom(filePath);
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const fm = fmMatch?.[1] ?? "";
const nameMatch = fm.match(/^\s*name:\s*(.+?)\s*$/m);
+1 -1
View File
@@ -26,7 +26,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",
+11
View File
@@ -0,0 +1,11 @@
# Cline Code Desktop Changelog
## 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.
+30 -1
View File
@@ -25,7 +25,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:
@@ -108,6 +122,21 @@ 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.
+8 -1
View File
@@ -1,11 +1,14 @@
{
"name": "@cline/code",
"version": "0.0.1",
"version": "0.0.3",
"private": true,
"scripts": {
"build:ui": "bun -F @cline/ui build",
"predev:web": "bun run build:ui",
"dev:web": "next dev webview -p 3125 --turbo",
"dev:sidecar": "bun run sidecar/index.ts",
"dev": "tauri dev",
"prebuild": "bun run build:ui",
"build": "bun run bun.mts",
"build:sidecar": "mkdir -p dist/sidecar && bun build ./sidecar/index.ts --outfile ./dist/sidecar/index.js --target bun",
"build:sidecar:bin": "bun run scripts/build-sidecar-bin.ts",
@@ -16,7 +19,10 @@
"package:desktop:windows": "bun run scripts/package-desktop.ts --platform windows",
"package:desktop:linux": "bun run scripts/package-desktop.ts --platform linux",
"start": "next start webview",
"pretypecheck": "bun run build:ui",
"typecheck": "tsc -p tsconfig.dev.json --noEmit",
"pretest:chat-ui": "bun run build:ui",
"test:chat-ui": "vitest run webview/components/views/chat/chat-messages.test.tsx --config vitest.config.ts",
"clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)"
},
"dependencies": {
@@ -70,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();
}
@@ -1,8 +1,13 @@
import { rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
buildSessionConnectionUpdate,
consumeWorkspaceMetadata,
handleChatSessionCommand,
hasProviderChanged,
mergeSessionConfig,
prewarmWorkspaceMetadata,
shouldUpdateSessionConnection,
WORKSPACE_METADATA_PREWARM_TTL_MS,
@@ -87,6 +92,42 @@ 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("first-send connection updates", () => {
const baseConfig = {
provider: "cline",
@@ -105,7 +146,14 @@ describe("first-send connection updates", () => {
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 +169,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 () => {
@@ -158,6 +226,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,
+258 -58
View File
@@ -6,6 +6,9 @@ import {
buildWorkspaceMetadata,
type ClineCore,
type CoreSessionConfig,
createSessionCompactionState,
projectSessionCompactionState,
type SessionCompactionState,
type SessionPendingPrompt,
SessionSource,
splitCoreSessionConfig,
@@ -212,6 +215,9 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
modelId: config.model ?? config.modelId ?? "",
mode: config.mode ?? "act",
apiKey: config.apiKey ?? config.api_key ?? "",
baseUrl: config.baseUrl,
headers: config.headers,
providerConfig: config.providerConfig,
workspaceRoot: config.workspaceRoot ?? config.workspace_root ?? "",
cwd: config.cwd ?? config.workspaceRoot ?? config.workspace_root ?? "",
systemPrompt: config.systemPrompt ?? config.system_prompt ?? "",
@@ -293,6 +299,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 ?? "",
@@ -412,9 +466,10 @@ 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),
source: SessionSource.DESKTOP,
@@ -425,7 +480,7 @@ async function handleStart(
toolPolicies: resolveToolPolicies(request.config),
});
const sessionId = startResult.sessionId;
console.error(`[sidecar:handleStart] session started sessionId=${sessionId}`);
ctx.logger?.log("Desktop chat session started", { sessionId });
const session = createLiveSession(request.config, {
messages: initialMessages,
prompt: initialMessages
@@ -510,6 +565,114 @@ 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 CoreSessionConfig,
),
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,
@@ -520,70 +683,101 @@ async function handleSend(
if (!prompt) throw new Error("prompt 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)}`,
);
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;
}
}
}
if (delivery === "queue") {
if (session) {
session.prompt = prompt;
}
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),
};
}
ctx.logger?.debug("Sending desktop chat prompt", {
sessionId,
promptLength: prompt.length,
delivery,
});
const result = await manager.send({
sessionId,
prompt,
delivery,
userImages: request.attachments?.userImages,
});
console.error(
`[sidecar:handleSend] manager.send resolved sessionId=${sessionId} finishReason=${result?.finishReason} textLen=${result?.text?.length ?? 0}`,
);
if (session) {
session.busy = false;
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 +796,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 +817,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;
}
}
}
}
+410 -38
View File
@@ -6,7 +6,8 @@ import {
rmSync,
statSync,
} from "node:fs";
import { basename, dirname, extname, join } from "node:path";
import { homedir } from "node:os";
import { basename, dirname, extname, isAbsolute, join } from "node:path";
import type {
ClineAccountActionRequest,
ProviderCapability,
@@ -34,6 +35,7 @@ import {
markLocalProviderEnabled,
normalizeOAuthProvider,
ProviderSettingsManager,
RuntimeOAuthTokenManager,
readGlobalSettings,
resolveLocalClineAuthToken,
resolvePluginConfigSearchPaths,
@@ -50,6 +52,8 @@ import {
updateMcpSettingsFileSync,
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
import packageJson from "../package.json";
import {
connectorChannelsPayload,
startConnectorChannel,
@@ -81,6 +85,51 @@ import type {
SidecarContext,
} from "./types";
function openUrlInDefaultBrowser(url: string): Promise<void> {
const platform = process.platform;
// On Windows the URL must not pass through cmd.exe: `cmd /c start <url>`
// re-parses metacharacters (&, ^, |) that are valid inside http(s) URLs,
// turning a crafted URL into command execution. rundll32 hands the URL
// straight to the protocol handler with no shell parsing.
const spawned =
platform === "darwin"
? spawn("open", [url], { stdio: "ignore", detached: true })
: platform === "win32"
? spawn("rundll32", ["url.dll,FileProtocolHandler", url], {
stdio: "ignore",
detached: true,
})
: spawn("xdg-open", [url], {
stdio: "ignore",
detached: true,
});
// A missing opener binary emits an async "error" event; without a listener
// it becomes an uncaught exception that kills the sidecar. Launchers hand
// off to the browser and exit quickly, so a fast non-zero exit means the
// handoff failed (xdg-open exits 3 when no handler is available; rundll32
// exits 0 even on failure, so Windows stays best-effort). If the launcher
// is still running after the grace window, assume the handoff worked
// rather than blocking on a launcher that lingers.
return new Promise((resolve, reject) => {
const graceTimer = setTimeout(resolve, 2_000);
spawned.once("spawn", () => {
spawned.unref();
});
spawned.once("error", (error) => {
clearTimeout(graceTimer);
reject(new Error(`could not open browser: ${error.message}`));
});
spawned.once("exit", (code) => {
clearTimeout(graceTimer);
if (code === 0 || code === null) {
resolve();
} else {
reject(new Error(`browser opener exited with code ${code}`));
}
});
});
}
function readProviderSettingsUpdate(
args: Record<string, unknown> | undefined,
): Partial<Omit<SaveProviderSettingsActionRequest, "action" | "providerId">> {
@@ -108,8 +157,13 @@ function readMcpServersResponse(): JsonRecord {
record.transport && typeof record.transport === "object"
? (record.transport as JsonRecord)
: undefined;
const rawTransportType =
transport?.type ?? record.transportType ?? record.type;
const transportType = String(
transport?.type ?? record.transportType ?? record.type ?? "stdio",
rawTransportType ??
(typeof transport?.url === "string" || typeof record.url === "string"
? "sse"
: "stdio"),
).trim();
return {
name,
@@ -156,6 +210,31 @@ function readMcpServersResponse(): JsonRecord {
return { settingsPath, hasSettingsFile: true, servers: entries };
}
/**
* Transport type + URL a server record actually points at, tolerating both
* the nested `transport` shape and legacy flat fields (mirrors
* readMcpServersResponse).
*/
function mcpTransportIdentity(record: JsonRecord): string {
const transport =
record.transport && typeof record.transport === "object"
? (record.transport as JsonRecord)
: undefined;
const url =
typeof transport?.url === "string"
? transport.url
: typeof record.url === "string"
? record.url
: "";
// Core's config-loader defaults a URL-based legacy record with no explicit
// type to SSE and maps the legacy "http" alias to streamableHttp; mirror
// both so an unchanged endpoint keeps the same identity.
const rawType = transport?.type ?? record.transportType ?? record.type;
const type = String(rawType ?? (url ? "sse" : "stdio")).trim();
const normalizedType = type === "http" ? "streamableHttp" : type;
return `${normalizedType}\u0000${url}`;
}
function writeMcpServersMap(servers: JsonRecord): void {
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
settings.mcpServers = servers;
@@ -184,6 +263,31 @@ function removePathIfExists(
return true;
}
// Cline access tokens expire between app launches, so account requests must
// resolve through the refresh-aware OAuth manager instead of reading the
// persisted token directly. A single shared instance keeps concurrent account
// requests single-flight; the refresh token is single-use, so parallel
// refreshes would invalidate each other.
let clineOAuthTokenManager: RuntimeOAuthTokenManager | undefined;
async function resolveFreshClineAuthToken(
manager: ProviderSettingsManager,
): Promise<string | undefined> {
try {
clineOAuthTokenManager ??= new RuntimeOAuthTokenManager();
const resolution = await clineOAuthTokenManager.resolveProviderApiKey({
providerId: "cline",
});
if (resolution?.apiKey) {
return resolution.apiKey;
}
} catch {
// Fall back to the persisted token; the account request surfaces the
// auth failure to the caller.
}
return resolveLocalClineAuthToken(manager.getProviderSettings("cline"));
}
async function listSessionsFromSidecarManager(
ctx: SidecarContext,
limit: number,
@@ -405,17 +509,57 @@ async function handleRoutineScheduleCommand(
};
try {
if (command === "list_routine_schedules") {
const [schedules, activeExecutions, upcomingRuns] = await Promise.all([
clientCommand("schedule.list", {
limit: toPositiveInt(args?.limit) ?? 200,
}),
clientCommand("schedule.active"),
clientCommand("schedule.upcoming", { limit: 30 }),
]);
const [schedules, activeExecutions, upcomingRuns, lastExecutions] =
await Promise.all([
clientCommand("schedule.list", {
limit: toPositiveInt(args?.limit) ?? 200,
}),
clientCommand("schedule.active"),
clientCommand("schedule.upcoming", { limit: 30 }),
clientCommand("schedule.list_executions", { limit: 50 }),
]);
const scheduleRecords = (schedules.schedules ?? []) as JsonRecord[];
const executionRecords = (lastExecutions.executions ??
[]) as JsonRecord[];
// The bulk query returns the newest executions across ALL schedules,
// so a few chatty schedules can evict everyone else's latest run.
// Backfill the latest execution for schedules that have run
// (lastRunAt set) but fell out of that window.
const covered = new Set<string>();
for (const execution of executionRecords) {
if (typeof execution.scheduleId === "string") {
covered.add(execution.scheduleId);
}
}
const missing = scheduleRecords.filter(
(schedule) =>
typeof schedule.scheduleId === "string" &&
schedule.lastRunAt != null &&
!covered.has(schedule.scheduleId),
);
const concurrency = 8;
for (let index = 0; index < missing.length; index += concurrency) {
const chunk = missing.slice(index, index + concurrency);
const replies = await Promise.all(
chunk.map((schedule) =>
clientCommand("schedule.list_executions", {
scheduleId: schedule.scheduleId,
limit: 1,
}).catch(() => undefined),
),
);
for (const reply of replies) {
const executions = (reply?.executions ?? []) as JsonRecord[];
if (executions[0]) {
executionRecords.push(executions[0]);
}
}
}
return {
schedules: schedules.schedules ?? [],
schedules: scheduleRecords,
activeExecutions: activeExecutions.executions ?? [],
upcomingRuns: upcomingRuns.runs ?? [],
lastExecutions: executionRecords,
};
}
if (command === "create_routine_schedule") {
@@ -464,7 +608,13 @@ async function handleRoutineScheduleCommand(
return { schedule: reply.schedule ?? null };
}
if (command === "trigger_routine_schedule") {
const reply = await clientCommand("schedule.trigger", { scheduleId });
// wait: false queues the run and returns immediately; the default
// path blocks until the whole agent run finishes, which outlives the
// webview's request timeout.
const reply = await clientCommand("schedule.trigger", {
scheduleId,
wait: false,
});
return { execution: reply.execution ?? null };
}
if (command === "delete_routine_schedule") {
@@ -558,7 +708,7 @@ async function listUserInstructionConfigs(
const ext = extname(entry.name).toLowerCase();
if (ext !== ".yml" && ext !== ".yaml") continue;
const filePath = join(directory, entry.name);
const raw = readFileSync(filePath, "utf8");
const raw = readFileSyncStrippingUtf8Bom(filePath);
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const fm = fmMatch?.[1] ?? "";
const nameMatch = fm.match(/^\s*name:\s*(.+?)\s*$/m);
@@ -717,9 +867,176 @@ function openFileInEditor(filePath: string): void {
const cmdArgs =
platform === "win32" ? ["/c", "start", "", filePath] : [filePath];
const child = spawn(cmd, cmdArgs, { stdio: "ignore", detached: true });
// An unhandled child error event would crash the sidecar process.
child.once("error", () => {});
child.unref();
}
// The macOS app shell launches the sidecar with a minimal GUI PATH
// (/usr/bin:/bin:...), so editor CLIs installed under /usr/local/bin or
// /opt/homebrew/bin are often not resolvable. `macApps` lets `open -a`
// find the app bundle regardless of PATH.
interface CodeEditorDefinition {
id: string;
label: string;
cli: string;
macApps: string[];
}
// Order doubles as the auto-open preference when no editor is requested.
const CODE_EDITOR_CATALOG: readonly CodeEditorDefinition[] = [
{
id: "vscode",
label: "VS Code",
cli: "code",
macApps: ["Visual Studio Code"],
},
{ id: "cursor", label: "Cursor", cli: "cursor", macApps: ["Cursor"] },
{ id: "windsurf", label: "Windsurf", cli: "windsurf", macApps: ["Windsurf"] },
{ id: "zed", label: "Zed", cli: "zed", macApps: ["Zed"] },
{
id: "vscode-insiders",
label: "VS Code Insiders",
cli: "code-insiders",
macApps: ["Visual Studio Code - Insiders"],
},
{
id: "sublime",
label: "Sublime Text",
cli: "subl",
macApps: ["Sublime Text"],
},
{
id: "intellijidea",
label: "IntelliJ IDEA",
cli: "idea",
macApps: ["IntelliJ IDEA", "IntelliJ IDEA CE"],
},
{ id: "xcode", label: "Xcode", cli: "xed", macApps: ["Xcode"] },
];
function findExecutableOnPath(name: string): string | null {
try {
const locator = process.platform === "win32" ? "where" : "which";
const stdout = execFileSync(locator, [name], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
return stdout.split("\n")[0]?.trim() || null;
} catch {
return null;
}
}
// cmd.exe re-parses metacharacters inside arguments even when quoted (the
// reason Node refuses to spawn .cmd files without a shell), so strings that
// could smuggle a second command must never reach it.
const WINDOWS_CMD_UNSAFE_PATTERN = /[&|^<>%!"\r\n]/;
function isMacAppInstalled(app: string): boolean {
return (
existsSync(`/Applications/${app}.app`) ||
existsSync(join(homedir(), "Applications", `${app}.app`))
);
}
/** Editors the current machine can actually launch, in catalog order. */
function listAvailableCodeEditors(): Array<{ id: string; label: string }> {
return CODE_EDITOR_CATALOG.filter(
(editor) =>
findExecutableOnPath(editor.cli) !== null ||
(process.platform === "darwin" && editor.macApps.some(isMacAppInstalled)),
).map(({ id, label }) => ({ id, label }));
}
/** Launches `executable filePath` detached; false if the CLI is unusable. */
function launchEditorCli(executable: string, filePath: string): boolean {
// Windows `where` resolves editor CLIs to .cmd/.bat shims, which
// spawn() cannot launch directly — route those through cmd.exe.
const isWindowsShim =
process.platform === "win32" && /\.(cmd|bat)$/i.test(executable);
if (isWindowsShim && WINDOWS_CMD_UNSAFE_PATTERN.test(executable)) {
return false;
}
const child = isWindowsShim
? spawn("cmd", ["/c", executable, filePath], {
stdio: "ignore",
detached: true,
})
: spawn(executable, [filePath], {
stdio: "ignore",
detached: true,
});
// Spawn failures surface as async error events; without a listener
// they crash the sidecar. Fall back to the OS default opener so the
// click still opens the file.
child.once("error", () => {
openFileInEditor(filePath);
});
child.unref();
return true;
}
function launchMacApp(app: string, filePath: string): boolean {
try {
execFileSync("open", ["-a", app, filePath], {
stdio: ["ignore", "ignore", "ignore"],
});
return true;
} catch {
return false;
}
}
/** Returns the launcher that handled the file, for logging/UI feedback. */
function openFileInCodeEditor(filePath: string, editorId?: string): string {
if (
process.platform === "win32" &&
WINDOWS_CMD_UNSAFE_PATTERN.test(filePath)
) {
throw new Error(
"File path contains characters that cannot be passed safely to the Windows shell",
);
}
if (editorId && editorId !== "default") {
const editor = CODE_EDITOR_CATALOG.find((entry) => entry.id === editorId);
if (!editor) {
throw new Error(`Unknown editor: ${editorId}`);
}
const executable = findExecutableOnPath(editor.cli);
if (executable && launchEditorCli(executable, filePath)) {
return editor.label;
}
if (
process.platform === "darwin" &&
editor.macApps.some((app) => launchMacApp(app, filePath))
) {
return editor.label;
}
throw new Error(`${editor.label} is not available on this machine`);
}
if (!editorId) {
for (const editor of CODE_EDITOR_CATALOG) {
const executable = findExecutableOnPath(editor.cli);
if (executable && launchEditorCli(executable, filePath)) {
return editor.cli;
}
}
if (process.platform === "darwin") {
for (const editor of CODE_EDITOR_CATALOG) {
const app = editor.macApps.find((candidate) =>
launchMacApp(candidate, filePath),
);
if (app) {
return app;
}
}
}
}
openFileInEditor(filePath);
return "system default";
}
// ---------------------------------------------------------------------------
// Main command router
// ---------------------------------------------------------------------------
@@ -756,7 +1073,13 @@ export async function handleCommand(
// ── Process context ───────────────────────────────────────────────
if (command === "get_process_context") {
return { workspaceRoot: ctx.workspaceRoot, cwd: ctx.workspaceRoot };
return {
workspaceRoot: ctx.workspaceRoot,
cwd: ctx.workspaceRoot,
homeDir: homedir(),
platform: process.platform,
appVersion: packageJson.version,
};
}
if (command === "get_chat_ws_endpoint") {
return "";
@@ -841,9 +1164,7 @@ export async function handleCommand(
if (command === "delete_chat_session" || command === "delete_cli_session") {
const sessionId = String(args?.sessionId ?? args?.session_id ?? "").trim();
if (!sessionId) throw new Error("session id is required");
console.error(
`[sidecar:delete] request command=${command} sessionId=${sessionId}`,
);
ctx.logger?.log("Deleting desktop chat session", { command, sessionId });
const store = new SqliteSessionStore();
const row = store.get(sessionId);
const manifest = readSessionManifest(sessionId);
@@ -921,14 +1242,16 @@ export async function handleCommand(
}
}
if (!deleted && deleteError) {
console.error(
`[sidecar:delete] failed sessionId=${sessionId} error=${deleteError.message}`,
);
ctx.logger?.error?.("Failed to delete desktop chat session", {
sessionId,
error: deleteError,
});
throw deleteError;
}
console.error(
`[sidecar:delete] result sessionId=${sessionId} deleted=${deleted}`,
);
ctx.logger?.log("Desktop chat session delete completed", {
sessionId,
deleted,
});
if (deleted) {
broadcastEvent(ctx, "session_deleted", {
sessionId,
@@ -944,6 +1267,22 @@ export async function handleCommand(
return await searchWorkspaceFiles(ctx, args);
}
// ── External links ─────────────────────────────────────────────────
if (command === "open_external_url") {
const rawUrl = String(args?.url ?? "").trim();
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
throw new Error(`invalid url: ${rawUrl}`);
}
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
throw new Error("only http(s) urls can be opened externally");
}
await openUrlInDefaultBrowser(parsed.toString());
return { opened: true };
}
// ── Cline account ──────────────────────────────────────────────────
if (command === "cline_account") {
const operation = String(args?.operation ?? "").trim();
@@ -953,7 +1292,7 @@ export async function handleCommand(
const accountService = new ClineAccountService({
apiBaseUrl:
settings?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
getAuthToken: async () => resolveLocalClineAuthToken(settings),
getAuthToken: async () => resolveFreshClineAuthToken(manager),
});
return await executeClineAccountAction(
args as ClineAccountActionRequest,
@@ -1029,20 +1368,11 @@ export async function handleCommand(
manager,
providerId,
(url) => {
const platform = process.platform;
const spawned =
platform === "darwin"
? spawn("open", [url], { stdio: "ignore", detached: true })
: platform === "win32"
? spawn("cmd", ["/c", "start", "", url], {
stdio: "ignore",
detached: true,
})
: spawn("xdg-open", [url], {
stdio: "ignore",
detached: true,
});
spawned.unref();
// The OAuth helper's openUrl callback is fire-and-forget; surface
// opener failures in the log instead of an unhandled rejection.
openUrlInDefaultBrowser(url).catch((error) => {
console.warn(`[sidecar] ${error instanceof Error ? error.message : error}`);
});
},
);
if (saved.provider !== providerId) {
@@ -1145,10 +1475,31 @@ export async function handleCommand(
updateMcpSettingsFileSync(path, (settings) => {
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
{}) as JsonRecord;
// Preserve machine-managed fields the editor dialog doesn't expose:
// oauth tokens for remote servers and plugin-ownership metadata.
const sourceName =
previousName && servers[previousName] ? previousName : name;
const existing = servers[sourceName];
const upserted = { ...next };
if (existing && typeof existing === "object") {
const record = existing as JsonRecord;
if (upserted.metadata === undefined && record.metadata !== undefined) {
upserted.metadata = record.metadata;
}
// OAuth tokens were issued for a specific endpoint; carrying them
// onto an edited transport or URL would send the old server's
// credentials to a different endpoint.
if (
record.oauth !== undefined &&
mcpTransportIdentity(record) === mcpTransportIdentity(upserted)
) {
upserted.oauth = record.oauth;
}
}
if (previousName && previousName !== name) {
delete servers[previousName];
}
servers[name] = next;
servers[name] = upserted;
settings.mcpServers = servers;
});
return readMcpServersResponse();
@@ -1281,6 +1632,27 @@ export async function handleCommand(
openFileInEditor(path);
return path;
}
if (command === "list_available_editors") {
return listAvailableCodeEditors();
}
if (command === "open_file_in_editor") {
const rawPath = String(args?.path ?? "").trim();
if (!rawPath) throw new Error("path is required");
const baseDir =
typeof args?.cwd === "string" && args.cwd.trim()
? args.cwd.trim()
: ctx.workspaceRoot;
const filePath = isAbsolute(rawPath) ? rawPath : join(baseDir, rawPath);
if (!existsSync(filePath)) {
throw new Error(`File not found: ${filePath}`);
}
const requestedEditor =
typeof args?.editor === "string" && args.editor.trim()
? args.editor.trim()
: undefined;
const editor = openFileInCodeEditor(filePath, requestedEditor);
return { path: filePath, editor };
}
throw new Error(`unsupported desktop command: ${command}`);
}
@@ -118,6 +118,35 @@ describe("Code sidecar runtime capabilities", () => {
);
});
it("wires the desktop logger and telemetry through the client and embedded hub", 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(startHubWebSocketServerMock).toHaveBeenCalledWith(
expect.objectContaining({ logger, telemetry }),
);
expect(createCoreMock).toHaveBeenCalledWith(
expect.objectContaining({
clientName: "cline-code",
logger,
telemetry,
}),
);
});
it("resolves askQuestion through the websocket request/response protocol", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
+18 -3
View File
@@ -4,12 +4,14 @@ import { homedir } from "node:os";
import { dirname } from "node:path";
import {
type AgentToolContext,
type BasicLogger,
ClineCore,
createLocalHubScheduleRuntimeHandlers,
type CoreSessionEvent,
createLocalHubScheduleRuntimeHandlers,
type ITelemetryService,
NodeHubClient,
resolveHubOwnerContext,
type RuntimeCapabilities,
resolveHubOwnerContext,
setHomeDirIfUnset,
startHubWebSocketServer,
type ToolApprovalRequest,
@@ -380,7 +382,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(),
@@ -391,6 +399,8 @@ export function createSidecarContext(workspaceRoot: string): SidecarContext {
hubClient: null,
hubServer: null,
workspaceRoot,
logger: observability.logger,
telemetry: observability.telemetry,
unsubscribeSessionEvents: null,
};
}
@@ -698,10 +708,15 @@ export async function initializeSessionManager(
`code-sidecar:${process.pid}:${randomUUID()}`,
),
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
logger: ctx.logger,
telemetry: ctx.telemetry,
});
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,
+52 -3
View File
@@ -1,14 +1,20 @@
import { homedir } from "node:os";
import { setHomeDirIfUnset } from "@cline/core";
import { prewarmWorkspaceMetadata } from "./chat-session";
import {
createSidecarContext,
disposeSidecarContext,
initializeSessionManager,
} from "./context";
import { createDesktopObservability } from "./observability";
import { resolveWorkspaceRoot } from "./paths";
import { startServer } from "./server";
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;
@@ -33,18 +39,36 @@ async function main() {
}
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);
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 +79,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 +121,12 @@ async function main() {
);
}
main().catch((error) => {
main().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,
},
});
}
@@ -1,7 +1,9 @@
import type {
AgentToolContext,
BasicLogger,
ClineCore,
HubServer,
ITelemetryService,
NodeHubClient,
ToolApprovalResult,
} from "@cline/core";
@@ -51,6 +53,7 @@ export type LiveSession = {
startedAt: number;
endedAt?: number;
status: string;
transitioningProvider?: boolean;
prompt?: string;
title?: string;
attachedViaHub?: boolean;
@@ -106,6 +109,8 @@ export type SidecarContext = {
hubClient: NodeHubClient | null;
hubServer: HubServer | null;
workspaceRoot: string;
logger?: BasicLogger;
telemetry?: ITelemetryService;
unsubscribeSessionEvents: (() => void) | null;
};
export type BunRuntimeApi = {
@@ -10,6 +10,8 @@ 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"
[features]
+141 -2
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,108 @@ 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;
}
}
#[derive(Default)]
struct DesktopBackendState {
ws_endpoint: Mutex<Option<String>>,
@@ -44,7 +150,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)),
@@ -452,6 +562,22 @@ 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();
}
#[tauri::command]
fn open_mcp_settings_file() -> Result<String, String> {
let settings_path = resolve_mcp_settings_path()?;
@@ -485,14 +611,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 +644,9 @@ 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
])
.build(tauri::generate_context!())
.expect("error while building tauri app")
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cline Code",
"version": "0.0.1",
"version": "0.0.3",
"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": [
{
@@ -0,0 +1,6 @@
{
"$schema": "https://schema.tauri.app/config/2",
"bundle": {
"createUpdaterArtifacts": true
}
}
@@ -3,6 +3,7 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "@cline/ui/theme/index.css";
@import "@cline/ui/components/agent-chat.css";
@source "../../node_modules/streamdown/dist";
@@ -19,6 +20,60 @@
}
}
/* 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;
}
}
/* Aurora background (components/ui/aurora-bg.tsx) */
@keyframes aurora-drift {
0% {
@@ -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>
+93 -61
View File
@@ -29,13 +29,16 @@ 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 type { ChatSessionConfig } from "@/lib/chat-schema";
import { desktopClient } from "@/lib/desktop-client";
import { syncDesktopWindowTitle } from "@/lib/desktop-window-title";
import {
getSessionMetadataTitle,
type SessionHistoryItem,
@@ -43,6 +46,7 @@ import {
} from "@/lib/session-history";
import { syncHubTheme, watchSystemHubTheme } from "@/lib/theme";
import {
filterWorkspacePaths,
mergeWorkspacePaths,
normalizeWorkspacePath,
readWorkspaceSelectionFromWindow,
@@ -81,11 +85,17 @@ export default function Home() {
() => threads[0]?.id,
);
useAppUpdate();
useEffect(() => {
syncHubTheme();
return watchSystemHubTheme();
}, []);
useEffect(() => {
void syncDesktopWindowTitle();
}, []);
const handleNewThread = useCallback(() => {
const id = makeThreadId();
setThreads((prev) => [...prev, { id }]);
@@ -219,63 +229,68 @@ export default function Home() {
);
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}
isHomeActive={
view === "chat" &&
!activeThread?.historySession &&
!activeThread?.hasStarted
}
onHome={handleHome}
onNewThread={handleNewThread}
onSettingsSectionChange={setSettingsSection}
sessionHistory={sessionHistory}
setView={setView}
settingsSection={settingsSection}
view={view}
/>
) : 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={setSettingsSection}
section={settingsSection}
/>
</div>
) : null}
</SidebarInset>
</div>
</SidebarProvider>
</AccountProvider>
);
}
@@ -345,10 +360,14 @@ 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);
@@ -366,7 +385,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
@@ -508,7 +529,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 +544,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
@@ -562,7 +588,9 @@ function ChatThreadPane({
workspaceRoot: nextWorkspace,
cwd: nextWorkspace,
}));
setWorkspaces((prev) => mergeWorkspacePaths(prev, [nextWorkspace]));
setWorkspaces((prev) =>
filterWorkspacePaths(mergeWorkspacePaths(prev, [nextWorkspace])),
);
// Fire git branch + workspace list refresh in the background
desktopClient
@@ -1079,6 +1107,7 @@ function ChatThreadPane({
body={
showDiffView ? (
<DiffView
cwd={config.cwd || config.workspaceRoot}
fileDiffs={fileDiffs}
onClose={() => setShowDiffView(false)}
/>
@@ -1104,7 +1133,10 @@ function ChatThreadPane({
)
}
composer={composer}
gitBranch={gitBranch}
onListGitBranches={listGitBranches}
onStartChat={setPromptInput}
onSwitchGitBranch={switchGitBranch}
quickActions={[]}
/>
</div>
@@ -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;
@@ -78,6 +86,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 +112,42 @@ afterEach(async () => {
});
describe("AgentSidebar session organization", () => {
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"],
["Git 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([
"Git 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) =>
@@ -125,8 +172,10 @@ describe("AgentSidebar session organization", () => {
isHomeActive
onHome={vi.fn()}
onNewThread={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={sessionHistory}
setView={vi.fn()}
settingsSection="General"
view="chat"
/>
</SidebarProvider>,
@@ -173,4 +222,154 @@ 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}
isHomeActive
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}
isHomeActive
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).toHaveBeenCalledWith("settings");
});
it("shows the desktop app version in a popover when the Cline logo is clicked", async () => {
const onHome = vi.fn();
invoke.mockImplementation(async (command: string) => {
if (command === "get_process_context") {
return { appVersion: "1.2.3" };
}
throw new Error("No Cline account auth token found");
});
await act(async () => {
root.render(
<AccountProvider>
<SidebarProvider>
<AgentSidebar
activeSessionId={null}
isHomeActive
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 click(logoButton as Element);
await vi.waitFor(() => {
expect(document.body.textContent).toContain("Version 1.2.3");
});
expect(onHome).toHaveBeenCalled();
expect(invoke).toHaveBeenCalledWith("get_process_context");
});
it("falls back to a signed-out footer without account data", async () => {
await act(async () => {
root.render(
<AccountProvider>
<SidebarProvider>
<AgentSidebar
activeSessionId={null}
isHomeActive
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,31 @@
"use client";
import {
Activity,
ArrowDownUp,
Blocks,
Bot,
ChevronDown,
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,
@@ -64,18 +66,26 @@ import {
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
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 {
groupThreadsByProject,
INITIAL_VISIBLE_THREAD_COUNT,
@@ -92,12 +102,16 @@ type SidebarSortMode = "time" | "project";
const SETTINGS_SECTION_ICONS = {
General: SlidersHorizontal,
Models: Bot,
"MCP Servers": Server,
"MCP Marketplace": Store,
Customizations: Blocks,
Channels: Radio,
Schedules: Clock3,
Account: CircleUserRound,
Plugins: Plug,
Skills: Activity,
MCP: Server,
Hooks: Code,
Rules: FileText,
Agents: Bot,
Tools: Wrench,
} satisfies Record<SettingsSection, typeof Settings>;
function SettingsSectionNavigation({
@@ -109,6 +123,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,29 +160,15 @@ 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>
);
}
@@ -172,6 +196,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 +237,26 @@ export function AgentSidebar({
const [projectVisibleCounts, setProjectVisibleCounts] = useState<
Record<string, number>
>({});
const [appVersion, setAppVersion] = useState<string | null>(null);
const loadAppVersion = useCallback(async () => {
try {
const context = await desktopClient.invoke<{ appVersion?: unknown }>(
"get_process_context",
);
const version =
typeof context?.appVersion === "string"
? context.appVersion.trim()
: "";
setAppVersion(version || null);
} catch {
// Leave the version hidden; an older sidecar build has no appVersion.
}
}, []);
useEffect(() => {
void loadAppVersion();
}, [loadAppVersion]);
useEffect(() => {
if (isCollapsed && searchOpen) {
@@ -446,14 +498,31 @@ export function AgentSidebar({
isCollapsed && "justify-center px-0",
)}
>
<button
aria-label="Cline home"
className="rounded-md p-1 text-sidebar-foreground transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
onClick={openHome}
type="button"
<Popover
onOpenChange={(open) => {
if (open && !appVersion) {
void loadAppVersion();
}
}}
>
<ClineLogo className="h-6 w-6" />
</button>
<PopoverTrigger asChild>
<button
aria-label="Cline home"
className="flex items-center gap-2 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"
type="button"
onClick={openHome}
title="Home"
>
<ClineLogo className="h-6 w-6" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-52 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>
</PopoverContent>
</Popover>
</div>
<div className={cn("shrink-0 px-3", isCollapsed && "px-1.5")}>
@@ -465,13 +534,13 @@ export function AgentSidebar({
"bg-sidebar-accent text-sidebar-accent-foreground",
isCollapsed && "mx-auto size-9 justify-center px-0",
)}
aria-label="Home"
aria-label="New Session"
onClick={openHome}
title="Home"
title="New Session"
variant="sidebarItem"
>
<Home className="size-4" />
{!isCollapsed ? "Home" : null}
<Plus className="size-4" />
{!isCollapsed ? "New Session" : null}
</Button>
</div>
@@ -542,17 +611,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 +737,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 +892,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(thread.title);
const pending = pendingAction !== null;
const workspacePath = thread.workspacePath || thread.codebase;
const statusDotClass = pending
? "bg-yellow-400"
: thread.status === "running"
@@ -835,20 +902,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 +935,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,7 +969,9 @@ 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}>
@@ -942,6 +998,34 @@ 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,
],
["Git branch", thread.gitBranch],
["Provider", thread.provider],
["Model", thread.model],
["Tokens", formatTokenCount(thread.inputTokens, thread.outputTokens)],
["Cost", formatCostUsd(thread.totalCostUsd)],
["ID", thread.id],
["Updated", thread.time],
];
return items.filter((item): item is [string, string, string?] =>
Boolean(item[1]),
);
}
function EditableSessionTitle({
value,
disabled,
@@ -313,8 +313,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)]"
@@ -13,14 +13,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 +34,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 = {
@@ -1045,7 +1038,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 +1080,6 @@ export function ChatInputBar({
}
onProviderChange={onProviderChange}
provider={provider}
variant={variant}
/>
</div>
<Select
@@ -1097,7 +1089,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 +1120,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 +1173,6 @@ function ModelSelector({
provider,
model,
isBusy,
variant,
onProviderChange,
onModelChange,
onModelSupportsReasoningChange,
@@ -1189,7 +1180,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 +1406,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 +1429,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,251 @@ 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 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...");
});
});
File diff suppressed because it is too large Load Diff
@@ -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,104 @@
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}
>
<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}
>
<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}
>
<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}
>
<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}
>
<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}
>
<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,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,
@@ -80,61 +100,13 @@ export function WelcomeScreen({
switchWorkspace,
pickWorkspaceDirectory,
} = useWorkspace();
const [switchingWorkspace, setSwitchingWorkspace] = useState<string | null>(
null,
);
const [addingWorkspace, setAddingWorkspace] = useState(false);
const availableWorkspaces = useMemo(() => {
const next = new Map<string, string>();
const register = (path: string) => {
const trimmed = path.trim();
if (trimmed) 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 +126,25 @@ 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}
onSwitchGitBranch={onSwitchGitBranch}
onSwitchWorkspace={switchWorkspace}
workspaceRoot={workspaceRoot}
workspaces={workspaces}
/>
</div>
</>
) : null}
@@ -0,0 +1,400 @@
"use client";
import { Check, 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,
}: {
open: boolean;
onToggle: () => void;
onClose: () => void;
workspaceRoot: string;
workspaces: string[];
onRefreshWorkspaces: () => Promise<void>;
onSwitchWorkspace: (workspacePath: string) => Promise<boolean>;
onPickWorkspaceDirectory: (initialPath?: string) => Promise<string | null>;
}) {
const [search, setSearch] = useState("");
const [switching, setSwitching] = useState(false);
const [picking, setPicking] = useState(false);
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);
};
register(workspaceRoot);
for (const path of workspaces) register(path);
return [...byNormalizedPath.values()];
}, [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 || switching) return;
setPicking(true);
try {
const picked = await onPickWorkspaceDirectory(workspaceRoot || undefined);
if (picked?.trim()) await handleSelect(picked.trim());
} finally {
setPicking(false);
}
};
return (
<div className="relative shrink-0">
<button
aria-expanded={open}
aria-haspopup="menu"
className={TRIGGER_CLASS}
onClick={onToggle}
title={workspaceRoot}
type="button"
>
<Folder className="size-4 shrink-0 text-muted-foreground" />
<span className="max-w-44 truncate">
{workspaceName(workspaceRoot)}
</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}
onClick={() => void handleAddWorkspace()}
size="sm"
variant="ghost"
>
<Plus className="size-3" />
{picking ? "Opening folder picker..." : "Add project..."}
</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,
currentBranch,
onListGitBranches,
onSwitchGitBranch,
}: {
workspaceRoot: string;
workspaces: string[];
onRefreshWorkspaces: () => Promise<void>;
onSwitchWorkspace: (workspacePath: string) => Promise<boolean>;
onPickWorkspaceDirectory: (initialPath?: string) => Promise<string | null>;
currentBranch: string;
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
onSwitchGitBranch: (branch: string) => Promise<boolean>;
}) {
const [openMenu, setOpenMenu] = useState<"workspace" | "branch" | null>(null);
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}
onSwitchWorkspace={onSwitchWorkspace}
onToggle={() =>
setOpenMenu((current) =>
current === "workspace" ? null : "workspace",
)
}
open={openMenu === "workspace"}
workspaceRoot={workspaceRoot}
workspaces={workspaces}
/>
<BranchPicker
currentBranch={currentBranch}
onClose={() => setOpenMenu(null)}
onListGitBranches={onListGitBranches}
onSwitchGitBranch={onSwitchGitBranch}
onToggle={() =>
setOpenMenu((current) => (current === "branch" ? null : "branch"))
}
open={openMenu === "branch"}
/>
</div>
);
}
@@ -79,4 +79,30 @@ 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");
});
});
});
@@ -5,6 +5,7 @@ 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 +20,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,
@@ -181,7 +171,20 @@ 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);
};
register(workspaceRoot);
for (const path of workspaces) register(path);
return [...byNormalizedPath.values()];
}, [workspaceRoot, workspaces]);
const filteredWorkspaces = availableWorkspaces.filter((w) =>
w.toLowerCase().includes(search.toLowerCase()),
);
@@ -457,7 +457,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 +468,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 +563,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 +786,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}
@@ -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>
);
}
@@ -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>
@@ -11,7 +11,7 @@ import {
Trash2,
Zap,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
AlertDialog,
AlertDialogAction,
@@ -51,6 +51,12 @@ import {
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { toast } from "@/hooks/use-toast";
import { desktopClient } from "@/lib/desktop-client";
import { readModelSelectionStorageFromWindow } from "@/lib/model-selection";
import { normalizeProviderId } from "@/lib/provider-id";
@@ -355,7 +361,43 @@ export function RoutineSchedulesContent() {
);
const [isLoading, setIsLoading] = useState(() => !routineOverviewCache);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [busyScheduleId, setBusyScheduleId] = useState<string | null>(null);
// Sets rather than single ids: several rows can have in-flight actions at
// once, and one action finishing must not clear another row's busy state.
const [busyScheduleIds, setBusyScheduleIds] = useState<ReadonlySet<string>>(
new Set(),
);
const [triggeringScheduleIds, setTriggeringScheduleIds] = useState<
ReadonlySet<string>
>(new Set());
// Ref mirrors busyScheduleIds so a second click on the same row is
// rejected synchronously — two rapid clicks can both fire before React
// re-renders the disabled state, and state alone can't distinguish them.
const busyScheduleIdsRef = useRef<Set<string>>(new Set());
const beginScheduleAction = (scheduleId: string): boolean => {
if (busyScheduleIdsRef.current.has(scheduleId)) {
return false;
}
busyScheduleIdsRef.current.add(scheduleId);
setBusyScheduleIds(new Set(busyScheduleIdsRef.current));
return true;
};
const endScheduleAction = (scheduleId: string) => {
busyScheduleIdsRef.current.delete(scheduleId);
setBusyScheduleIds(new Set(busyScheduleIdsRef.current));
};
const setScheduleTriggering = (scheduleId: string, triggering: boolean) => {
setTriggeringScheduleIds((previous) => {
const next = new Set(previous);
if (triggering) {
next.add(scheduleId);
} else {
next.delete(scheduleId);
}
return next;
});
};
const [viewingSchedule, setViewingSchedule] =
useState<RoutineSchedule | null>(null);
const [schedulePendingDelete, setSchedulePendingDelete] =
useState<RoutineSchedule | null>(null);
const [isCreateOpen, setIsCreateOpen] = useState(false);
@@ -613,7 +655,9 @@ export function RoutineSchedulesContent() {
schedule: RoutineSchedule,
enabled: boolean,
) => {
setBusyScheduleId(schedule.scheduleId);
if (!beginScheduleAction(schedule.scheduleId)) {
return;
}
setErrorMessage(null);
try {
if (enabled) {
@@ -630,16 +674,23 @@ export function RoutineSchedulesContent() {
const message = error instanceof Error ? error.message : String(error);
setErrorMessage(message);
} finally {
setBusyScheduleId(null);
endScheduleAction(schedule.scheduleId);
}
};
const triggerSchedule = async (scheduleId: string) => {
setBusyScheduleId(scheduleId);
const triggerSchedule = async (schedule: RoutineSchedule) => {
if (!beginScheduleAction(schedule.scheduleId)) {
return;
}
setScheduleTriggering(schedule.scheduleId, true);
setErrorMessage(null);
try {
await desktopClient.invoke("trigger_routine_schedule", {
schedule_id: scheduleId,
schedule_id: schedule.scheduleId,
});
toast({
title: "Run started",
description: `"${schedule.name}" was queued to run now.`,
});
await refreshSchedules({ force: true, showLoading: false });
window.setTimeout(() => {
@@ -648,13 +699,21 @@ export function RoutineSchedulesContent() {
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setErrorMessage(message);
toast({
title: "Failed to start run",
description: message,
variant: "destructive",
});
} finally {
setBusyScheduleId(null);
endScheduleAction(schedule.scheduleId);
setScheduleTriggering(schedule.scheduleId, false);
}
};
const deleteSchedule = async (scheduleId: string) => {
setBusyScheduleId(scheduleId);
if (!beginScheduleAction(scheduleId)) {
return;
}
setErrorMessage(null);
try {
await desktopClient.invoke("delete_routine_schedule", {
@@ -666,7 +725,7 @@ export function RoutineSchedulesContent() {
const message = error instanceof Error ? error.message : String(error);
setErrorMessage(message);
} finally {
setBusyScheduleId(null);
endScheduleAction(scheduleId);
}
};
@@ -917,7 +976,7 @@ export function RoutineSchedulesContent() {
) : (
<div className="flex flex-col gap-3">
{sortedSchedules.map((schedule) => {
const isBusy = busyScheduleId === schedule.scheduleId;
const isBusy = busyScheduleIds.has(schedule.scheduleId);
const activeExecution = executionBySchedule.get(
schedule.scheduleId,
);
@@ -952,70 +1011,113 @@ export function RoutineSchedulesContent() {
</span>
<div className="flex-1" />
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
aria-label={`View ${schedule.name}`}
onClick={() => {
window.alert(JSON.stringify(schedule, null, 2));
}}
>
<Eye className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit ${schedule.name}`}
onClick={() => openEditDialog(schedule)}
disabled={isBusy}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Run ${schedule.name} now`}
onClick={() => void triggerSchedule(schedule.scheduleId)}
disabled={isBusy}
>
<Zap className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={
schedule.enabled
? `Pause ${schedule.name}`
: `Resume ${schedule.name}`
}
onClick={() =>
void upsertScheduleEnabled(schedule, !schedule.enabled)
}
disabled={isBusy}
>
{schedule.enabled ? (
<Pause className="h-3.5 w-3.5" />
) : (
<Play className="h-3.5 w-3.5" />
)}
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Delete ${schedule.name}`}
onClick={() => setSchedulePendingDelete(schedule)}
disabled={isBusy}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<Switch
checked={schedule.enabled}
onCheckedChange={(checked) =>
void upsertScheduleEnabled(schedule, checked)
}
disabled={isBusy}
aria-label={`Enable ${schedule.name}`}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
aria-label={`View ${schedule.name}`}
onClick={() => setViewingSchedule(schedule)}
>
<Eye className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>View details</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit ${schedule.name}`}
onClick={() => openEditDialog(schedule)}
disabled={isBusy}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Edit schedule</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Run ${schedule.name} now`}
onClick={() => void triggerSchedule(schedule)}
disabled={isBusy}
>
{triggeringScheduleIds.has(schedule.scheduleId) ? (
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
) : (
<Zap className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Run now</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
aria-label={
schedule.enabled
? `Pause ${schedule.name}`
: `Resume ${schedule.name}`
}
onClick={() =>
void upsertScheduleEnabled(
schedule,
!schedule.enabled,
)
}
disabled={isBusy}
>
{schedule.enabled ? (
<Pause className="h-3.5 w-3.5" />
) : (
<Play className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{schedule.enabled
? "Pause schedule"
: "Resume schedule"}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Delete ${schedule.name}`}
onClick={() => setSchedulePendingDelete(schedule)}
disabled={isBusy}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete schedule</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Switch
checked={schedule.enabled}
onCheckedChange={(checked) =>
void upsertScheduleEnabled(schedule, checked)
}
disabled={isBusy}
aria-label={`Enable ${schedule.name}`}
/>
</TooltipTrigger>
<TooltipContent>
{schedule.enabled
? "Enabled — click to disable"
: "Disabled — click to enable"}
</TooltipContent>
</Tooltip>
</div>
</div>
@@ -1095,6 +1197,58 @@ export function RoutineSchedulesContent() {
})}
</div>
)}
<Dialog
open={Boolean(viewingSchedule)}
onOpenChange={(open) => {
if (!open) {
setViewingSchedule(null);
}
}}
>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{viewingSchedule?.name ?? "Schedule"}</DialogTitle>
<DialogDescription>
Full configuration for this schedule.
</DialogDescription>
</DialogHeader>
{viewingSchedule && (
<div className="flex flex-col gap-3">
<div className="grid grid-cols-1 gap-1.5 text-xs sm:grid-cols-2">
<p>
<span className="text-muted-foreground/70">Cron:</span>{" "}
<span className="font-mono">
{viewingSchedule.cronPattern}
</span>
</p>
<p>
<span className="text-muted-foreground/70">Mode:</span>{" "}
{viewingSchedule.mode}
</p>
<p>
<span className="text-muted-foreground/70">Model:</span>{" "}
{formatScheduleModel(viewingSchedule)}
</p>
<p>
<span className="text-muted-foreground/70">Enabled:</span>{" "}
{viewingSchedule.enabled ? "yes" : "no"}
</p>
<p>
<span className="text-muted-foreground/70">Last run:</span>{" "}
{formatDateTime(viewingSchedule.lastRunAt)}
</p>
<p>
<span className="text-muted-foreground/70">Next run:</span>{" "}
{formatDateTime(viewingSchedule.nextRunAt)}
</p>
</div>
<pre className="max-h-80 overflow-auto rounded-md border border-border bg-muted/30 p-3 text-xs">
{JSON.stringify(viewingSchedule, null, 2)}
</pre>
</div>
)}
</DialogContent>
</Dialog>
<AlertDialog
open={Boolean(schedulePendingDelete)}
onOpenChange={(open) => {
@@ -1115,7 +1269,7 @@ export function RoutineSchedulesContent() {
<AlertDialogCancel
disabled={
schedulePendingDelete
? busyScheduleId === schedulePendingDelete.scheduleId
? busyScheduleIds.has(schedulePendingDelete.scheduleId)
: false
}
>
@@ -1124,7 +1278,7 @@ export function RoutineSchedulesContent() {
<AlertDialogAction
disabled={
!schedulePendingDelete ||
busyScheduleId === schedulePendingDelete.scheduleId
busyScheduleIds.has(schedulePendingDelete.scheduleId)
}
onClick={() => {
if (schedulePendingDelete) {
@@ -17,7 +17,7 @@ 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 +33,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;
@@ -400,12 +410,20 @@ 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" ? (
@@ -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);
}
@@ -3,6 +3,9 @@ import type { SessionHookEvent } from "@/lib/session-diff";
export type ProcessContext = {
workspaceRoot: string;
cwd: string;
homeDir?: string;
platform?: string;
appVersion?: string;
};
export type AgentChunkEvent = {
@@ -0,0 +1,79 @@
"use client";
import { useEffect } from "react";
import { ToastAction } from "@/components/ui/toast";
import { toast } from "@/hooks/use-toast";
import { desktopClient, isTauriAvailable } from "@/lib/desktop-client";
export type AppUpdateStatus = {
state: "idle" | "checking" | "downloading" | "ready" | "error";
version?: string | null;
error?: string | null;
};
const POLL_INTERVAL_MS = 30_000;
// Module-scoped so a page remount does not re-toast an update the user
// already dismissed while the Rust side still reports it as "ready".
let notifiedVersion: string | null = null;
/**
* Watches the Tauri shell's auto-updater. Updates are checked, downloaded, and
* installed in the background by the Rust side; once one is staged this hook
* surfaces a persistent toast offering a one-click restart into the new
* version. Ignoring the toast is fine too the staged update takes effect on
* the next launch. No-op in web/sidecar mode where there is no app bundle to
* update.
*/
export function useAppUpdate() {
useEffect(() => {
if (!isTauriAvailable()) {
return;
}
let cancelled = false;
const poll = async () => {
let status: AppUpdateStatus;
try {
status =
await desktopClient.invoke<AppUpdateStatus>("get_update_status");
} catch {
// Update status is best-effort; ignore transient bridge failures.
return;
}
if (cancelled || status.state !== "ready" || !status.version) {
return;
}
if (notifiedVersion === status.version) {
return;
}
notifiedVersion = status.version;
toast({
title: `Update ready: v${status.version}`,
description:
"The new version has been downloaded and will be used the next time the app starts.",
duration: Number.POSITIVE_INFINITY,
action: (
<ToastAction
altText="Restart now"
onClick={() => {
void desktopClient.invoke("restart_to_apply_update");
}}
>
Restart now
</ToastAction>
),
});
};
void poll();
const interval = setInterval(() => {
void poll();
}, POLL_INTERVAL_MS);
return () => {
cancelled = true;
clearInterval(interval);
};
}, []);
}
@@ -53,6 +53,100 @@ afterEach(async () => {
});
describe("useChatSession", () => {
it("asks the user to select a workspace before submitting", async () => {
await act(async () => {
current.setConfig((previous) => ({
...previous,
workspaceRoot: "",
cwd: "",
}));
});
invokeMock.mockClear();
await act(async () => current.sendPrompt("Start the task"));
expect(current.error).toBe("Select a workspace before trying again.");
expect(current.messages.at(-1)).toMatchObject({
role: "error",
content: "Select a workspace before trying again.",
});
expect(invokeMock).not.toHaveBeenCalledWith(
"chat_session_command",
expect.anything(),
);
});
it("replaces raw workspace manifest errors with actionable copy", async () => {
invokeMock.mockImplementation(async (command: string) => {
if (command === "get_process_context") {
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
}
if (command === "chat_session_command") {
throw new Error(
'[{"origin":"string","code":"too_small","path":["workspaces","/","hint"],"message":"Too small: expected string to have >=1 characters"}]',
);
}
return [];
});
await act(async () => current.start(current.config));
expect(current.error).toBe("Select a workspace before trying again.");
expect(current.messages.at(-1)?.content).toBe(
"Select a workspace before trying again.",
);
});
it.each([
{
finishReason: "completed",
expected:
'[{"code":"too_small","path":["workspaces","/","hint"],"message":"expected string to have >=1 characters"}]',
},
{
finishReason: "error",
expected: "Select a workspace before trying again.",
},
])("handles schema-like assistant text for $finishReason responses", async ({
finishReason,
expected,
}) => {
const schemaLikeText =
'[{"code":"too_small","path":["workspaces","/","hint"],"message":"expected string to have >=1 characters"}]';
invokeMock.mockImplementation(
async (command: string, args?: Record<string, unknown>) => {
if (command === "get_process_context") {
return {
cwd: "/workspace/cline",
workspaceRoot: "/workspace/cline",
};
}
if (command === "chat_session_command") {
const request = args?.request as
| { action?: string; config?: { sessionId?: string } }
| undefined;
if (request?.action === "start") {
return { sessionId: request.config?.sessionId ?? "session-test" };
}
if (request?.action === "send") {
return {
ok: true,
result: { text: schemaLikeText, finishReason },
};
}
}
return [];
},
);
await act(async () => current.sendPrompt("Explain this validation error"));
expect(
current.messages.findLast((message) => message.role === "assistant")
?.content,
).toBe(expected);
});
it("publishes the first user message before cold session startup resolves", async () => {
let resolveStart: ((value: { sessionId: string }) => void) | undefined;
const startResponse = new Promise<{ sessionId: string }>((resolve) => {
@@ -46,6 +46,7 @@ import type {
import {
normalizeWorkspacePath,
readWorkspaceSelectionFromWindow,
registerHostHomeDirectory,
} from "@/lib/workspace-paths";
export { DEFAULT_CHAT_CONFIG } from "@/hooks/chat-session/constants";
@@ -69,12 +70,29 @@ const BUSY_STATUSES = new Set<ChatSessionStatus>([
"stopping",
]);
const WORKSPACE_SELECTION_REQUIRED_MESSAGE =
"Select a workspace before trying again.";
// ---------------------------------------------------------------------------
// Helpers (pure, no hooks)
// ---------------------------------------------------------------------------
function userFacingMessage(message: string): string {
const normalized = message.toLowerCase();
const isWorkspaceManifestValidationError =
normalized.includes("workspaces") &&
(normalized.includes("too_small") ||
normalized.includes("expected string to have >=1"));
const isMissingWorkspaceConfig = normalized.includes(
"config.cwd or config.workspaceroot is required",
);
return isWorkspaceManifestValidationError || isMissingWorkspaceConfig
? WORKSPACE_SELECTION_REQUIRED_MESSAGE
: message;
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
return userFacingMessage(err instanceof Error ? err.message : String(err));
}
function makeErrorChatMessage(
@@ -95,6 +113,9 @@ function validateConfig(
):
| { parsed: ChatSessionConfig; error: null }
| { parsed: null; error: string } {
if (!config.workspaceRoot.trim()) {
return { parsed: null, error: WORKSPACE_SELECTION_REQUIRED_MESSAGE };
}
const runtimeConfig = normalizeRuntimeConfig(config);
const result = ChatSessionConfigSchema.safeParse(runtimeConfig);
if (!result.success) {
@@ -475,6 +496,9 @@ export function useChatSession() {
const ctx = await desktopClient.invoke<ProcessContext>(
"get_process_context",
);
if (ctx.homeDir) {
registerHostHomeDirectory(ctx.homeDir);
}
const rememberedWorkspace =
readWorkspaceSelectionFromWindow().lastWorkspace;
const validation = rememberedWorkspace
@@ -1221,8 +1245,11 @@ export function useChatSession() {
const fallbackAssistantTurn = extractAssistantTurnDataFromRpcMessages(
result?.messages,
);
const rawAssistantText = assistantText || fallbackAssistantTurn.text;
const resolvedAssistantText =
assistantText || fallbackAssistantTurn.text;
result?.finishReason === "error"
? userFacingMessage(rawAssistantText)
: rawAssistantText;
if (resolvedAssistantText) {
const assistantMessageId =
activeAssistantMessageIdRef.current ?? makeId("assistant");
@@ -1376,7 +1403,9 @@ export function useChatSession() {
addMessage(
makeErrorChatMessage(
activeSessionId,
toolError?.trim() ||
(toolError?.trim()
? userFacingMessage(toolError.trim())
: undefined) ||
"Runtime turn failed before an assistant response was produced.",
),
);
@@ -9,7 +9,10 @@ import type {
SessionHistoryStatus,
SessionMetadata,
} from "@/lib/session-history";
import { getSessionMetadataTitle } from "@/lib/session-history";
import {
getSessionMetadataGitBranch,
getSessionMetadataTitle,
} from "@/lib/session-history";
type CliDiscoveredSession = Omit<SessionHistoryItem, "status"> & {
status: string;
@@ -23,6 +26,7 @@ export interface SessionThread {
time: string;
provider: string;
model: string;
gitBranch?: string;
inputTokens?: number;
outputTokens?: number;
totalCostUsd?: number;
@@ -179,10 +183,10 @@ export function basenamePath(input?: string): string {
function toTitle(session: SessionHistoryItem): string {
const metadataTitle = getSessionMetadataTitle(session.metadata);
if (metadataTitle) {
return metadataTitle.slice(0, 70);
return metadataTitle;
}
const line = normalizeTitle(session.prompt).trim().split("\n")[0]?.trim();
if (line) return line.slice(0, 70);
if (line) return line;
return `Session ${session.sessionId.slice(-6)}`;
}
@@ -196,7 +200,7 @@ function titleFromMessages(messages: SessionMessage[]): string | null {
typeof message.content === "string" ? message.content : "";
const line = normalizeTitle(content).trim().split("\n")[0]?.trim();
if (line) {
return line.slice(0, 70);
return line;
}
}
}
@@ -234,6 +238,7 @@ function toThread(session: SessionHistoryItem): SessionThread {
time: formatRelativeTime(session.endedAt || session.startedAt),
provider: session.provider || "",
model: session.model || "",
gitBranch: getSessionMetadataGitBranch(session.metadata) || undefined,
status: normalizeDiscoveredStatus(session.status, session.prompt),
};
}
@@ -331,6 +336,8 @@ function areSessionsEquivalent(
a.startedAt !== b.startedAt ||
a.endedAt !== b.endedAt ||
a.prompt !== b.prompt ||
getSessionMetadataGitBranch(a.metadata) !==
getSessionMetadataGitBranch(b.metadata) ||
getSessionMetadataTitle(a.metadata) !==
getSessionMetadataTitle(b.metadata) ||
a.workspaceRoot !== b.workspaceRoot ||
@@ -362,6 +369,7 @@ function areThreadsEquivalent(
a.time !== b.time ||
a.provider !== b.provider ||
a.model !== b.model ||
a.gitBranch !== b.gitBranch ||
a.inputTokens !== b.inputTokens ||
a.outputTokens !== b.outputTokens ||
a.totalCostUsd !== b.totalCostUsd ||
@@ -99,13 +99,15 @@ const RECONNECT_MAX_DELAY_MS = 4_000;
// Commands that should be routed to Tauri's native invoke bridge instead of
// the WebSocket transport — only applicable in the full Tauri app shell.
// In sidecar/web mode these commands are handled by the sidecar over WebSocket.
function isTauriAvailable(): boolean {
export function isTauriAvailable(): boolean {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}
const NATIVE_COMMANDS = new Set([
"pick_workspace_directory",
"open_mcp_settings_file",
"get_update_status",
"restart_to_apply_update",
]);
class DesktopClient {
@@ -342,3 +344,18 @@ class DesktopClient {
}
export const desktopClient = new DesktopClient();
/**
* Open a URL in the user's default browser. Inside the Tauri shell,
* `target="_blank"` anchors are silently dropped (no window opener is
* configured), so external links must be routed through the sidecar, which
* runs on the host and can spawn the platform opener. In plain web mode the
* browser handles it directly.
*/
export async function openExternalUrl(url: string): Promise<void> {
if (isTauriAvailable()) {
await desktopClient.invoke("open_external_url", { url });
return;
}
window.open(url, "_blank", "noopener,noreferrer");
}
@@ -0,0 +1,90 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { invoke, setTitle } = vi.hoisted(() => ({
invoke: vi.fn(),
setTitle: vi.fn(async () => undefined),
}));
vi.mock("@/lib/desktop-client", () => ({
desktopClient: { invoke },
isTauriAvailable: () => window.__TAURI_INTERNALS__ !== undefined,
}));
vi.mock("@tauri-apps/api/window", () => ({
getCurrentWindow: () => ({ setTitle }),
}));
async function importFresh() {
vi.resetModules();
return await import("./desktop-window-title");
}
beforeEach(() => {
invoke.mockReset();
setTitle.mockClear();
// biome-ignore lint/suspicious/noExplicitAny: test-only global shim for the Tauri bridge marker
delete (window as any).__TAURI_INTERNALS__;
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("desktop window title", () => {
it("builds a versioned title, falling back to the base title without a version", async () => {
const { buildDesktopWindowTitle, DEFAULT_DESKTOP_WINDOW_TITLE } =
await importFresh();
expect(buildDesktopWindowTitle("1.2.3")).toBe(
`${DEFAULT_DESKTOP_WINDOW_TITLE} v1.2.3`,
);
expect(buildDesktopWindowTitle(" 1.2.3 ")).toBe(
`${DEFAULT_DESKTOP_WINDOW_TITLE} v1.2.3`,
);
expect(buildDesktopWindowTitle(undefined)).toBe(
DEFAULT_DESKTOP_WINDOW_TITLE,
);
expect(buildDesktopWindowTitle("")).toBe(DEFAULT_DESKTOP_WINDOW_TITLE);
});
it("does nothing outside the Tauri shell", async () => {
const { syncDesktopWindowTitle } = await importFresh();
await syncDesktopWindowTitle();
expect(invoke).not.toHaveBeenCalled();
expect(setTitle).not.toHaveBeenCalled();
});
it("sets the native window title once the sidecar reports a version", async () => {
// biome-ignore lint/suspicious/noExplicitAny: test-only global shim for the Tauri bridge marker
(window as any).__TAURI_INTERNALS__ = {};
invoke.mockResolvedValue({
workspaceRoot: "",
cwd: "",
appVersion: "1.2.3",
});
const { syncDesktopWindowTitle, DEFAULT_DESKTOP_WINDOW_TITLE } =
await importFresh();
await syncDesktopWindowTitle();
expect(invoke).toHaveBeenCalledWith("get_process_context");
expect(setTitle).toHaveBeenCalledWith(
`${DEFAULT_DESKTOP_WINDOW_TITLE} v1.2.3`,
);
});
it("leaves the title alone when the version is missing or the sidecar call fails", async () => {
// biome-ignore lint/suspicious/noExplicitAny: test-only global shim for the Tauri bridge marker
(window as any).__TAURI_INTERNALS__ = {};
invoke.mockResolvedValue({ workspaceRoot: "", cwd: "" });
const { syncDesktopWindowTitle } = await importFresh();
await syncDesktopWindowTitle();
expect(setTitle).not.toHaveBeenCalled();
invoke.mockRejectedValue(
new Error("Desktop backend transport unavailable"),
);
await syncDesktopWindowTitle();
expect(setTitle).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,34 @@
import type { ProcessContext } from "@/hooks/chat-session/types";
import { desktopClient, isTauriAvailable } from "@/lib/desktop-client";
export const DEFAULT_DESKTOP_WINDOW_TITLE = "Cline Code";
export function buildDesktopWindowTitle(version: string | undefined): string {
const trimmed = version?.trim();
return trimmed
? `${DEFAULT_DESKTOP_WINDOW_TITLE} v${trimmed}`
: DEFAULT_DESKTOP_WINDOW_TITLE;
}
/**
* Tauri's window title is static in tauri.conf.json; append the running app
* version once the sidecar reports it. No-op outside the Tauri shell (e.g.
* sidecar/web dev mode), where there is no native window to retitle.
*/
export async function syncDesktopWindowTitle(): Promise<void> {
if (!isTauriAvailable()) {
return;
}
try {
const ctx = await desktopClient.invoke<ProcessContext>(
"get_process_context",
);
if (!ctx.appVersion?.trim()) {
return;
}
const { getCurrentWindow } = await import("@tauri-apps/api/window");
await getCurrentWindow().setTitle(buildDesktopWindowTitle(ctx.appVersion));
} catch {
// Keep the default static title if the sidecar or window API is unavailable.
}
}
@@ -7,6 +7,10 @@ export type SessionHistoryStatus =
export type SessionMetadata = {
title?: string;
git?: {
url?: string;
branch?: string;
};
[key: string]: unknown;
};
@@ -31,3 +35,13 @@ export function getSessionMetadataTitle(metadata?: SessionMetadata): string {
}
return typeof metadata.title === "string" ? metadata.title.trim() : "";
}
export function getSessionMetadataGitBranch(
metadata?: SessionMetadata,
): string {
const git = metadata?.git;
if (!git || typeof git !== "object" || Array.isArray(git)) {
return "";
}
return typeof git.branch === "string" ? git.branch.trim() : "";
}
@@ -1,8 +1,13 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import {
filterWorkspacePaths,
isAbsoluteFilePath,
isExcludedWorkspacePath,
mergeWorkspacePaths,
normalizeWorkspacePath,
parseWorkspaceSelectionStorage,
registerHostHomeDirectory,
resolveWorkspaceFilePath,
workspacePathsFromSessions,
} from "./workspace-paths";
@@ -17,6 +22,33 @@ describe("workspace paths", () => {
expect(normalizeWorkspacePath("/")).toBe("/");
});
it("detects absolute file paths across platforms", () => {
expect(isAbsoluteFilePath("/Users/renee/cline/docs/a.mdx")).toBe(true);
expect(isAbsoluteFilePath("C:\\Users\\renee\\a.mdx")).toBe(true);
expect(isAbsoluteFilePath("C:/Users/renee/a.mdx")).toBe(true);
expect(isAbsoluteFilePath("\\\\server\\share\\a.mdx")).toBe(true);
expect(isAbsoluteFilePath("docs/a.mdx")).toBe(false);
expect(isAbsoluteFilePath("./docs/a.mdx")).toBe(false);
});
it("resolves relative diff paths against the session cwd", () => {
expect(resolveWorkspaceFilePath("docs/a.mdx", "/Users/renee/cline")).toBe(
"/Users/renee/cline/docs/a.mdx",
);
expect(
resolveWorkspaceFilePath("./docs/a.mdx", "/Users/renee/cline/"),
).toBe("/Users/renee/cline/docs/a.mdx");
expect(
resolveWorkspaceFilePath("/Users/renee/cline/docs/a.mdx", "/elsewhere"),
).toBe("/Users/renee/cline/docs/a.mdx");
expect(resolveWorkspaceFilePath("docs/a.mdx", undefined)).toBe(
"docs/a.mdx",
);
expect(resolveWorkspaceFilePath("docs\\a.mdx", "C:\\Users\\renee")).toBe(
"C:\\Users\\renee\\docs\\a.mdx",
);
});
it("retains known projects when discovery returns an incomplete subset", () => {
const known = ["/projects/a", "/projects/b", "/projects/c", "/projects/d"];
const afterFirstPick = mergeWorkspacePaths(known, [
@@ -45,6 +77,36 @@ describe("workspace paths", () => {
]);
});
it("keeps the first-seen order so earlier groups rank first", () => {
expect(
mergeWorkspacePaths(
["/projects/zulu", "/projects/mike"],
["/projects/alpha", "/projects/zulu/"],
),
).toEqual(["/projects/zulu", "/projects/mike", "/projects/alpha"]);
});
it("orders the catalog by the most recent session in each workspace", () => {
const paths = workspacePathsFromSessions([
{ workspaceRoot: "/projects/old", startedAt: "2026-01-05T00:00:00Z" },
{
workspaceRoot: "/projects/active",
startedAt: "2026-02-01T00:00:00Z",
endedAt: "2026-02-01T01:00:00Z",
},
{ workspaceRoot: "/projects/old", startedAt: "2026-03-01T00:00:00Z" },
{ workspaceRoot: "/projects/mid", startedAt: "2026-02-15T00:00:00Z" },
{ workspaceRoot: "/projects/undated" },
]);
expect(paths).toEqual([
"/projects/old",
"/projects/mid",
"/projects/active",
"/projects/undated",
]);
});
it("builds the project catalog from every loaded history workspace", () => {
const sessions = Array.from({ length: 25 }, (_, index) => ({
workspaceRoot: `/projects/project-${String(index + 1).padStart(2, "0")}`,
@@ -74,4 +136,93 @@ describe("workspace paths", () => {
workspaces: [],
});
});
it("excludes .cline-internal paths from the workspace catalog", () => {
expect(
isExcludedWorkspacePath("/Users/beatrix/.cline/worktrees/5e0b3/sdk-wip"),
).toBe(true);
expect(
isExcludedWorkspacePath(
"/Users/beatrix/.cline/plugins/_installed/git/github.com/example-plugin",
),
).toBe(true);
expect(
isExcludedWorkspacePath("C:\\Users\\Saoud\\.cline\\worktrees\\abc"),
).toBe(true);
});
describe("with a registered host home directory", () => {
afterEach(() => {
registerHostHomeDirectory("");
});
it("excludes a non-standard home and its Desktop but keeps projects inside them", () => {
registerHostHomeDirectory("/srv/homes/bea/");
expect(isExcludedWorkspacePath("/srv/homes/bea")).toBe(true);
expect(isExcludedWorkspacePath("/srv/homes/bea/Desktop")).toBe(true);
expect(isExcludedWorkspacePath("/srv/homes/bea/projects/app")).toBe(
false,
);
expect(isExcludedWorkspacePath("/srv/homes/beatrix")).toBe(false);
});
it("matches Windows homes case-insensitively", () => {
registerHostHomeDirectory("D:\\Homes\\Bea");
expect(isExcludedWorkspacePath("d:\\homes\\bea\\")).toBe(true);
expect(isExcludedWorkspacePath("D:\\Homes\\Bea\\Desktop")).toBe(true);
expect(isExcludedWorkspacePath("D:\\Homes\\Bea\\cline")).toBe(false);
});
});
it("excludes home and Desktop directories but keeps projects inside them", () => {
expect(isExcludedWorkspacePath("/Users/beatrix")).toBe(true);
expect(isExcludedWorkspacePath("/Users/beatrix/Desktop/")).toBe(true);
expect(isExcludedWorkspacePath("/home/beatrix")).toBe(true);
expect(isExcludedWorkspacePath("/root")).toBe(true);
expect(isExcludedWorkspacePath("C:\\Users\\Saoud")).toBe(true);
expect(isExcludedWorkspacePath("C:\\Users\\Saoud\\Desktop")).toBe(true);
expect(isExcludedWorkspacePath("/Users/beatrix/dev/cline")).toBe(false);
expect(isExcludedWorkspacePath("/Users/beatrix/Desktop/my-app")).toBe(
false,
);
expect(isExcludedWorkspacePath("/home/beatrix/projects")).toBe(false);
expect(isExcludedWorkspacePath("/workspace/cline")).toBe(false);
expect(isExcludedWorkspacePath("C:\\Users\\Saoud\\Cline")).toBe(false);
});
it("filters excluded paths out of session-derived workspaces", () => {
const paths = workspacePathsFromSessions([
{ workspaceRoot: "/projects/app" },
{ workspaceRoot: "/Users/beatrix/.cline/worktrees/97815/sdk-wip" },
{ cwd: "/Users/beatrix/Desktop" },
{ cwd: "/Users/beatrix" },
{ cwd: "/projects/tool" },
]);
expect(paths).toEqual(["/projects/app", "/projects/tool"]);
});
it("scrubs excluded paths from the stored catalog while keeping the selection", () => {
expect(
parseWorkspaceSelectionStorage(
JSON.stringify({
lastWorkspace: "/Users/beatrix/Desktop",
workspaces: [
"/projects/one",
"/Users/beatrix/.cline/worktrees/5e0b3/sdk-wip",
"/Users/beatrix",
],
}),
),
).toEqual({
lastWorkspace: "/Users/beatrix/Desktop",
workspaces: ["/projects/one"],
});
expect(
filterWorkspacePaths(["/projects/one", "/Users/beatrix/Desktop"]),
).toEqual(["/projects/one"]);
});
});
@@ -9,6 +9,8 @@ export type WorkspaceSelectionStorage = {
export type WorkspacePathSource = {
cwd?: string;
workspaceRoot?: string;
startedAt?: string;
endedAt?: string;
};
export function normalizeWorkspacePath(path: string): string {
@@ -21,6 +23,11 @@ export function normalizeWorkspacePath(path: string): string {
return /^[A-Za-z]:/.test(normalized) ? normalized.toLowerCase() : normalized;
}
/**
* Dedupes paths across groups, keeping the first spelling seen and the
* first-seen position, so callers control the ranking (e.g. session recency)
* through argument order.
*/
export function mergeWorkspacePaths(
...pathGroups: ReadonlyArray<readonly string[]>
): string[] {
@@ -34,15 +41,120 @@ export function mergeWorkspacePaths(
}
}
}
return [...byNormalizedPath.values()].sort((a, b) => a.localeCompare(b));
return [...byNormalizedPath.values()];
}
const WINDOWS_ABSOLUTE_PATH_PATTERN = /^(?:[A-Za-z]:[\\/]|\\\\)/;
export function isAbsoluteFilePath(path: string): boolean {
return path.startsWith("/") || WINDOWS_ABSOLUTE_PATH_PATTERN.test(path);
}
/**
* Diff tool outputs carry paths as the agent wrote them sometimes absolute,
* sometimes relative to the session cwd. The webview has no `node:path`, so
* relative paths are joined against the cwd with the separator style the cwd
* already uses.
*/
export function resolveWorkspaceFilePath(path: string, cwd?: string): string {
const trimmed = path.trim();
const base = (cwd ?? "").trim();
if (!trimmed || !base || isAbsoluteFilePath(trimmed)) {
return trimmed;
}
const separator = base.includes("\\") && !base.includes("/") ? "\\" : "/";
return `${base.replace(/[\\/]+$/, "")}${separator}${trimmed.replace(/^\.\//, "")}`;
}
const POSIX_HOME_OR_DESKTOP_PATTERN =
/^(?:\/Users\/[^/]+|\/home\/[^/]+|\/root)(?:\/Desktop)?$/;
const WINDOWS_HOME_OR_DESKTOP_PATTERN =
/^[a-z]:[\\/]users[\\/][^\\/]+(?:[\\/]desktop)?$/i;
let hostHomePath = "";
/**
* The webview bundle has no usable `process.env`, so standard home locations
* are matched by the patterns above and the sidecar reports the real host
* home directory through `get_process_context` to cover non-standard ones.
*/
export function registerHostHomeDirectory(path: string): void {
hostHomePath = normalizeWorkspacePath(path);
}
function isRegisteredHomeOrDesktop(normalized: string): boolean {
if (!hostHomePath) {
return false;
}
if (normalized === hostHomePath) {
return true;
}
return (
normalized.startsWith(hostHomePath) &&
/^[\\/]desktop$/i.test(normalized.slice(hostHomePath.length))
);
}
/**
* Sessions can run anywhere (Cline-internal worktrees and plugin installs
* under `.cline`, or a shell's default cwd like the home or Desktop
* directory), but those locations are not projects to offer in the
* workspace catalog. The active workspace root is registered separately,
* so an explicitly opened directory still shows while selected.
*/
export function isExcludedWorkspacePath(path: string): boolean {
const normalized = normalizeWorkspacePath(path);
if (!normalized) {
return false;
}
if (normalized.split(/[\\/]/).includes(".cline")) {
return true;
}
return (
isRegisteredHomeOrDesktop(normalized) ||
POSIX_HOME_OR_DESKTOP_PATTERN.test(normalized) ||
WINDOWS_HOME_OR_DESKTOP_PATTERN.test(normalized)
);
}
export function filterWorkspacePaths(paths: readonly string[]): string[] {
return paths.filter((path) => !isExcludedWorkspacePath(path));
}
/**
* Workspaces with the most recent session activity come first; paths whose
* sessions carry no parseable timestamp fall back to alphabetical order at
* the end.
*/
export function workspacePathsFromSessions(
sessions: readonly WorkspacePathSource[],
): string[] {
return mergeWorkspacePaths(
sessions.map((session) => session.workspaceRoot || session.cwd || ""),
);
const lastActivityByPath = new Map<string, number>();
for (const session of sessions) {
const normalized = normalizeWorkspacePath(
session.workspaceRoot || session.cwd || "",
);
if (!normalized) {
continue;
}
const activity = Date.parse(session.endedAt ?? session.startedAt ?? "");
if (Number.isNaN(activity)) {
continue;
}
const known = lastActivityByPath.get(normalized);
if (known === undefined || activity > known) {
lastActivityByPath.set(normalized, activity);
}
}
return filterWorkspacePaths(
mergeWorkspacePaths(
sessions.map((session) => session.workspaceRoot || session.cwd || ""),
),
).sort((a, b) => {
const aTime = lastActivityByPath.get(normalizeWorkspacePath(a)) ?? 0;
const bTime = lastActivityByPath.get(normalizeWorkspacePath(b)) ?? 0;
return bTime === aTime ? a.localeCompare(b) : bTime - aTime;
});
}
export function parseWorkspaceSelectionStorage(
@@ -67,7 +179,9 @@ export function parseWorkspaceSelectionStorage(
: [];
return {
lastWorkspace,
workspaces: mergeWorkspacePaths(workspaces, [lastWorkspace]),
workspaces: filterWorkspacePaths(
mergeWorkspacePaths(workspaces, [lastWorkspace]),
),
};
} catch {
return { lastWorkspace: "", workspaces: [] };
@@ -98,9 +212,9 @@ export function writeWorkspaceSelectionToWindow(
WORKSPACE_SELECTION_STORAGE_KEY,
JSON.stringify({
lastWorkspace: value.lastWorkspace.trim(),
workspaces: mergeWorkspacePaths(value.workspaces, [
value.lastWorkspace,
]),
workspaces: filterWorkspacePaths(
mergeWorkspacePaths(value.workspaces, [value.lastWorkspace]),
),
}),
);
} catch {
+3
View File
@@ -16,6 +16,9 @@ service TaskService {
rpc cancelQueuedPrompt(StringRequest) returns (Empty);
// Cancels the currently running background command
rpc cancelBackgroundCommand(EmptyRequest) returns (Empty);
// Detaches the running foreground terminal command ("Proceed While Running"):
// the agent receives the partial output and a log file path for the rest.
rpc proceedWhileRunningCommand(EmptyRequest) returns (Empty);
// Clears the current task
rpc clearTask(EmptyRequest) returns (Empty);
// Gets the total size of all tasks
@@ -56,4 +56,16 @@ describe("parseYamlFrontmatter", () => {
expect(result.data).to.deep.equal({ count: 42, enabled: true, tags: ["a", "b"] })
expect(result.body.trim()).to.equal("Content")
})
// Regression test for https://github.com/cline/cline/issues/12151
// A leading UTF-8 BOM (e.g. saved by Windows Notepad's "UTF-8 with BOM" encoding) must not
// prevent frontmatter from being recognized.
it("parses frontmatter correctly when the content has a leading UTF-8 BOM", () => {
const input = `\uFEFF---\nname: my-skill\ndescription: A test skill\n---\n# my-skill\nThis is a test skill.`
const result = parseYamlFrontmatter(input)
expect(result.hadFrontmatter).to.equal(true)
expect(result.parseError).to.equal(undefined)
expect(result.data).to.deep.equal({ name: "my-skill", description: "A test skill" })
expect(result.body.trim()).to.equal("# my-skill\nThis is a test skill.")
})
})
@@ -139,6 +139,34 @@ Instructions here`)
expect(skills[0].source).to.equal("global")
})
// Regression test for https://github.com/cline/cline/issues/12151:
// SKILL.md files saved with a UTF-8 BOM (e.g. by Windows Notepad's "UTF-8 with BOM"
// encoding) were silently skipped because the frontmatter regex required "---" at the
// very start of the file and never accounted for the leading \uFEFF byte sequence.
it("should discover skills whose SKILL.md starts with a UTF-8 BOM", async () => {
const skillDir = path.join(GLOBAL_SKILLS_DIR, "my-skill")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
fileExistsStub.withArgs(skillMdPath).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["my-skill"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`\uFEFF---
name: my-skill
description: A test skill
---
# my-skill
This is a test skill.`)
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(1)
expect(skills[0].name).to.equal("my-skill")
expect(skills[0].description).to.equal("A test skill")
expect(skills[0].source).to.equal("global")
})
it("should discover skills from project .clinerules/skills directory", async () => {
const projectSkillsDir = path.join(TEST_CWD, ".clinerules", "skills")
const skillDir = path.join(projectSkillsDir, "explaining-code")
@@ -1,3 +1,4 @@
import { stripUtf8Bom } from "@cline/shared"
import * as yaml from "js-yaml"
export type FrontmatterParseResult = {
@@ -35,11 +36,16 @@ export type FrontmatterParseResult = {
* - If no frontmatter exists, returns data={} and body=original markdown.
*/
export function parseYamlFrontmatter(markdown: string): FrontmatterParseResult {
// Strip a leading UTF-8 BOM (e.g. added by Windows Notepad's "UTF-8 with BOM" encoding),
// which Node's `utf-8` decoding does not strip on its own. Without this the frontmatter
// regex below never matches a file that starts with "\uFEFF---" (see cline/cline#12151).
const normalizedMarkdown = stripUtf8Bom(markdown)
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/
const match = markdown.match(frontmatterRegex)
const match = normalizedMarkdown.match(frontmatterRegex)
if (!match) {
return { data: {}, body: markdown, hadFrontmatter: false }
return { data: {}, body: normalizedMarkdown, hadFrontmatter: false }
}
const [, yamlContent, body] = match
@@ -48,6 +54,6 @@ export function parseYamlFrontmatter(markdown: string): FrontmatterParseResult {
return { data, body, hadFrontmatter: true }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { data: {}, body: markdown, hadFrontmatter: true, parseError: message }
return { data: {}, body: normalizedMarkdown, hadFrontmatter: true, parseError: message }
}
}
@@ -25,6 +25,7 @@ export async function getStateToPostToWebview(controller: {
mcpHub?: any
backgroundCommandRunning?: boolean
backgroundCommandTaskId?: string
foregroundCommandRunning?: boolean
workspaceManager?: any
checkpointRestoreInput?: ExtensionState["checkpointRestoreInput"]
}): Promise<ExtensionState> {
@@ -157,6 +158,7 @@ export async function getStateToPostToWebview(controller: {
favoritedModelIds,
backgroundCommandRunning: controller.backgroundCommandRunning ?? false,
backgroundCommandTaskId: controller.backgroundCommandTaskId,
foregroundCommandRunning: controller.foregroundCommandRunning ?? false,
workspaceRoots: controller.workspaceManager?.getRoots?.() ?? [],
primaryRootIndex: controller.workspaceManager?.getPrimaryIndex?.() ?? 0,
isMultiRootWorkspace: (controller.workspaceManager?.getRoots?.()?.length ?? 0) > 1,
@@ -1,6 +1,7 @@
import assert from "node:assert/strict"
import { DEFAULT_AUTO_APPROVAL_SETTINGS } from "@shared/AutoApprovalSettings"
import { AutoApprovalSettingsRequest } from "@shared/proto/cline/state"
import { describe, expect, it, vi } from "vitest"
import { describe, it, vi } from "vitest"
import type { Controller } from ".."
import { updateAutoApprovalSettings } from "./updateAutoApprovalSettings"
@@ -58,9 +59,11 @@ describe("updateAutoApprovalSettings", () => {
},
}
expect(controller.stateManager.setGlobalState.mock.calls).toEqual([["autoApprovalSettings", expectedSettings]])
expect(controller.stateManager.setTaskSettings.mock.calls).toEqual([["task-1", "autoApprovalSettings", expectedSettings]])
expect(controller.postStateToWebview.mock.calls.length).toBe(1)
assert.deepEqual(controller.stateManager.setGlobalState.mock.calls, [["autoApprovalSettings", expectedSettings]])
assert.deepEqual(controller.stateManager.setTaskSettings.mock.calls, [
["task-1", "autoApprovalSettings", expectedSettings],
])
assert.equal(controller.postStateToWebview.mock.calls.length, 1)
})
it("does not create a task override when no task is active", async () => {
@@ -76,9 +79,9 @@ describe("updateAutoApprovalSettings", () => {
}),
)
expect(controller.stateManager.setGlobalState.mock.calls.length).toBe(1)
expect(controller.stateManager.setTaskSettings.mock.calls.length).toBe(0)
expect(controller.postStateToWebview.mock.calls.length).toBe(1)
assert.equal(controller.stateManager.setGlobalState.mock.calls.length, 1)
assert.equal(controller.stateManager.setTaskSettings.mock.calls.length, 0)
assert.equal(controller.postStateToWebview.mock.calls.length, 1)
})
it("ignores stale auto-approval settings versions", async () => {
@@ -100,8 +103,8 @@ describe("updateAutoApprovalSettings", () => {
}),
)
expect(controller.stateManager.setGlobalState.mock.calls.length).toBe(0)
expect(controller.stateManager.setTaskSettings.mock.calls.length).toBe(0)
expect(controller.postStateToWebview.mock.calls.length).toBe(0)
assert.equal(controller.stateManager.setGlobalState.mock.calls.length, 0)
assert.equal(controller.stateManager.setTaskSettings.mock.calls.length, 0)
assert.equal(controller.postStateToWebview.mock.calls.length, 0)
})
})
@@ -239,7 +239,10 @@ export async function updateSettings(controller: Controller, request: UpdateSett
controller.stateManager.setGlobalState("defaultTerminalProfile", request.defaultTerminalProfile)
// Update the live terminal manager so new terminals use the new profile.
// Existing terminals are left open — they're keyed by effective shell
// and reused when compatible, or skipped when not.
// and reused when compatible, or skipped when not. No session rebuild
// is needed: the run_commands tool re-reads the profile each time a
// model request is built, so the description and execution both pick
// up the new shell at the next request boundary.
controller.terminalManager?.setDefaultTerminalProfile(request.defaultTerminalProfile)
}
@@ -192,7 +192,10 @@ export async function updateSettingsCli(controller: Controller, request: UpdateS
controller.stateManager.setGlobalState("defaultTerminalProfile", defaultTerminalProfile)
// Update the live terminal manager so new terminals use the new profile.
// Existing terminals are left open — they're keyed by effective shell
// and reused when compatible, or skipped when not.
// and reused when compatible, or skipped when not. No session rebuild
// is needed: the run_commands tool re-reads the profile each time a
// model request is built, so the description and execution both pick
// up the new shell at the next request boundary.
controller.terminalManager?.setDefaultTerminalProfile(defaultTerminalProfile)
}
}
@@ -0,0 +1,15 @@
import { Empty, EmptyRequest } from "@shared/proto/cline/common"
import { Controller } from ".."
/**
* "Proceed While Running": detach the in-flight foreground terminal command(s)
* so the agent turn continues with the partial output while the commands keep
* running in the user's terminal, streaming further output to a log file.
*/
export async function proceedWhileRunningCommand(controller: Controller, _request: EmptyRequest): Promise<Empty> {
const controllerWithProceed = controller as Controller & {
proceedWhileRunningCommand: () => Promise<void>
}
await controllerWithProceed.proceedWhileRunningCommand()
return Empty.create()
}
+5 -4
View File
@@ -15,8 +15,9 @@ Designed to be driven from an agentic loop via `curl` commands.
## Quick Start
```bash
# Terminal 1: Start the debug harness server
bun src/dev/debug-harness/server.ts --auto-launch --skip-build
# Terminal 1: Start the debug harness server.
# Run with node, NOT bun — Playwright's Electron launch times out under bun.
node src/dev/debug-harness/server.ts --auto-launch --skip-build
# Terminal 2: Interact via curl
curl localhost:19229/api -d '{"method":"status"}'
@@ -27,7 +28,7 @@ curl localhost:19229/api -d '{"method":"ui.screenshot"}'
## Server Options
```
bun src/dev/debug-harness/server.ts [options]
node src/dev/debug-harness/server.ts [options]
Options:
--skip-build Skip building extension/webview (use existing dist/)
@@ -42,7 +43,7 @@ Options:
```bash
# This builds protos, extension (unminified+sourcemaps), webview (unminified+sourcemaps),
# downloads VSCode, launches it, and connects CDP to the extension host.
bun src/dev/debug-harness/server.ts --auto-launch
node src/dev/debug-harness/server.ts --auto-launch
```
## Data Isolation
+15 -9
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env bun
#!/usr/bin/env node
/**
* Debug Harness Server
@@ -10,7 +10,12 @@
* - UI automation (click, type, screenshot) via Playwright
*
* Usage:
* bun src/dev/debug-harness/server.ts [options]
* node src/dev/debug-harness/server.ts [options]
*
* Run with node, not bun: Playwright's _electron.launch() never finishes
* attaching to the debugee under bun (the Electron process starts, but the
* launch times out), while the same launch works under node. Node >= 22.6
* runs this file directly via type stripping.
*
* Options:
* --skip-build Skip building extension/webview
@@ -39,7 +44,6 @@ import path from "node:path"
import { fileURLToPath } from "node:url"
import { downloadAndUnzipVSCode, SilentReporter } from "@vscode/test-electron"
import { _electron, type CDPSession, type ElectronApplication, type Frame, type Page } from "playwright"
import WebSocket from "ws"
const __script_dir = typeof __dirname !== "undefined" ? __dirname : path.dirname(fileURLToPath(import.meta.url))
@@ -201,19 +205,21 @@ class CdpClient {
async connect(wsUrl: string): Promise<void> {
return new Promise((resolve, reject) => {
// The runtime's built-in WebSocket (browser-style events), so the
// harness has no dependency on the `ws` package.
const ws = new WebSocket(wsUrl)
ws.on("open", () => {
ws.addEventListener("open", () => {
this.ws = ws
resolve()
})
ws.on("error", (e: Error) => {
if (!this.ws) reject(e)
ws.addEventListener("error", () => {
if (!this.ws) reject(new Error(`WebSocket connection failed: ${wsUrl}`))
})
ws.on("close", () => {
ws.addEventListener("close", () => {
this.ws = null
})
ws.on("message", (raw: WebSocket.Data) => {
const msg = JSON.parse(raw.toString())
ws.addEventListener("message", (event: MessageEvent) => {
const msg = JSON.parse(typeof event.data === "string" ? event.data : Buffer.from(event.data).toString())
if (msg.id !== undefined) {
const p = this.pending.get(msg.id)
if (p) {
@@ -262,10 +262,15 @@ export class VscodeTerminalManager {
return mergePromise(process, promise)
}
async getOrCreateTerminal(cwd: string): Promise<ITerminalInfo> {
/**
* @param profileId Terminal profile to create/match the terminal with.
* Defaults to the current setting; callers that captured the profile
* earlier (e.g. when the model request was built) pass it here so a
* settings change does not switch shells under an in-flight tool call.
*/
async getOrCreateTerminal(cwd: string, profileId: string = this.defaultTerminalProfile): Promise<ITerminalInfo> {
const terminals = TerminalRegistry.getAllTerminals()
const expectedShellPath =
this.defaultTerminalProfile !== "default" ? getShellForProfile(this.defaultTerminalProfile) : undefined
const expectedShellPath = profileId !== "default" ? getShellForProfile(profileId) : undefined
// Resolve effective shell for comparison (so "default" and "zsh" match on macOS)
const effectiveExpected = VscodeTerminalManager.effectiveShellPath(expectedShellPath)
@@ -694,4 +694,41 @@ describe("TerminalProcess (Integration Tests)", () => {
;(emitSpy as sinon.SinonSpy).calledWith("line", "line 3 continued").should.be.true()
processAny.buffer.should.equal("")
})
it("detach emits continue but keeps line listeners attached and listening", () => {
const processAny = process as any
const continueEvents: number[] = []
const lines: string[] = []
process.on("continue", () => continueEvents.push(1))
process.on("line", (line) => lines.push(line))
process.detach()
continueEvents.length.should.equal(1)
// Unlike continue(), detach must not stop listening or drop 'line'
// listeners: output after detach still reaches subscribers (this is
// what streams the rest of a detached command to the log file).
processAny.isListening.should.be.true()
processAny.emitIfEol("after detach\n")
lines.should.containEql("after detach")
})
it("detach flushes a buffered partial line before emitting continue", () => {
const processAny = process as any
const events: string[] = []
process.on("continue", () => events.push("continue"))
process.on("line", (line) => events.push(`line:${line}`))
// A chunk with no trailing newline stays in the internal buffer.
processAny.emitIfEol("partial output")
processAny.buffer.should.equal("partial output")
process.detach()
// The partial line must reach listeners before 'continue' resolves the
// awaited promise; otherwise it is missing from the partial output and
// from the log's initial flush.
events.should.eql(["line:partial output", "continue"])
processAny.buffer.should.equal("")
})
})
@@ -16,8 +16,8 @@ import {
TRUNCATE_KEEP_LINES,
} from "@/integrations/terminal/constants"
import type { ITerminalProcess, TerminalCompletionDetails, TerminalProcessEvents } from "@/integrations/terminal/types"
import { Logger } from "@/shared/services/Logger"
import type { MarkerlessCompletionCause } from "@/services/telemetry/TelemetryService"
import { Logger } from "@/shared/services/Logger"
import { Osc633EventType, Osc633Parser } from "./osc633Parser"
import { classifyShellPrompt, getLastLine } from "./shellPromptHeuristics"
@@ -522,6 +522,23 @@ export class VscodeTerminalProcess extends EventEmitter<TerminalProcessEvents> i
this.emit("continue")
}
/**
* Resolve the awaited promise while the command keeps running ("Proceed
* While Running"). Listeners stay attached and 'line' events keep
* flowing unlike continue() so callers can stream the remaining
* output until the command actually completes. Because 'completed' is
* only emitted by the read loop when the command genuinely ends, the
* terminal stays busy and is not eligible for reuse until then.
*/
detach() {
// Flush any partial line (no trailing newline yet) so it reaches
// listeners before the awaited promise resolves; otherwise it would be
// dropped from both the partial output and the log capture if the
// command exits without further newline-terminated output.
this.emitRemainingBufferIfListening()
this.emit("continue")
}
/**
* Get output that hasn't been retrieved yet.
* Truncates if output is too large to prevent context window overflow.

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